feat: 通知系统(notifications表+CRUD接口+管理员接口)
This commit is contained in:
34
migrations/011_add_notifications.sql
Normal file
34
migrations/011_add_notifications.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 迁移: 011_add_notifications.sql
|
||||||
|
-- 目的: 创建统一通知表,覆盖系统公告/个人通知/事件通知
|
||||||
|
-- 日期: 2026-05-26
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
scope VARCHAR(16) NOT NULL DEFAULT 'user',
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type VARCHAR(32) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
priority VARCHAR(16) NOT NULL DEFAULT 'normal',
|
||||||
|
link VARCHAR(512),
|
||||||
|
is_read BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user
|
||||||
|
ON notifications(scope, user_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_all
|
||||||
|
ON notifications(scope, created_at DESC) WHERE scope = 'all';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_expires
|
||||||
|
ON notifications(expires_at) WHERE expires_at IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE notifications IS '统一通知表:scope=all 系统广播,scope=user 定向通知';
|
||||||
|
COMMENT ON COLUMN notifications.scope IS '作用域: all(广播) / user(定向)';
|
||||||
|
COMMENT ON COLUMN notifications.type IS '通知类型: system_maintenance / member_expiry / payment_success / version_update';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -4,6 +4,7 @@ pub mod auth;
|
|||||||
pub mod favorites;
|
pub mod favorites;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
|
pub mod notifications;
|
||||||
pub mod payment;
|
pub mod payment;
|
||||||
pub mod sentry;
|
pub mod sentry;
|
||||||
pub mod static_files;
|
pub mod static_files;
|
||||||
@@ -29,6 +30,10 @@ pub use auth::web_login_auto_confirm;
|
|||||||
pub use favorites::{add_favorite, get_favorites, remove_favorite};
|
pub use favorites::{add_favorite, get_favorites, remove_favorite};
|
||||||
pub use health::health_check;
|
pub use health::health_check;
|
||||||
pub use meta::root;
|
pub use meta::root;
|
||||||
|
pub use notifications::{
|
||||||
|
list_notifications, mark_notification_read, mark_all_read, unread_count,
|
||||||
|
admin_create_notification, admin_delete_notification,
|
||||||
|
};
|
||||||
pub use static_files::serve_static_files;
|
pub use static_files::serve_static_files;
|
||||||
pub use user::get_current_user_profile;
|
pub use user::get_current_user_profile;
|
||||||
pub use user::save_user_profile;
|
pub use user::save_user_profile;
|
||||||
|
|||||||
214
src/handlers/notifications.rs
Normal file
214
src/handlers/notifications.rs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
// handlers/notifications.rs — 通知系统处理器
|
||||||
|
use actix_web::{web, get, put, post, delete, HttpResponse};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::postgres::PgPool;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::db;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::Claims;
|
||||||
|
|
||||||
|
// ===== 查询参数 =====
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct NotificationListQuery {
|
||||||
|
page: Option<i32>,
|
||||||
|
limit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 创建通知请求 =====
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateNotificationRequest {
|
||||||
|
pub scope: String, // "all" 或 "user"
|
||||||
|
pub user_id: Option<i32>, // scope="user" 时必填
|
||||||
|
pub type_: String,
|
||||||
|
pub title: String,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub priority: Option<String>,
|
||||||
|
pub link: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 用户接口 =====
|
||||||
|
|
||||||
|
/// GET /api/notifications — 获取当前用户的通知列表
|
||||||
|
#[get("/api/notifications")]
|
||||||
|
pub async fn list_notifications(
|
||||||
|
pool: web::Data<PgPool>,
|
||||||
|
claims: web::ReqData<Claims>,
|
||||||
|
query: web::Query<NotificationListQuery>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let user_id = claims.user_id;
|
||||||
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
|
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
let offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, crate::models::Notification>(
|
||||||
|
r#"
|
||||||
|
SELECT id, scope, user_id, type AS type_, title, content, priority, link,
|
||||||
|
is_read, created_at, expires_at
|
||||||
|
FROM notifications
|
||||||
|
WHERE scope = 'all' OR (scope = 'user' AND user_id = $1)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool.get_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Database(format!("查询通知失败: {}", e)))?;
|
||||||
|
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
r#"SELECT COUNT(*) FROM notifications WHERE scope = 'all' OR (scope = 'user' AND user_id = $1)"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool.get_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Database(format!("统计通知失败: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"list": rows,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/notifications/{id}/read — 标记单条已读
|
||||||
|
#[put("/api/notifications/{id}/read")]
|
||||||
|
pub async fn mark_notification_read(
|
||||||
|
pool: web::Data<PgPool>,
|
||||||
|
claims: web::ReqData<Claims>,
|
||||||
|
path: web::Path<i32>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let notif_id = path.into_inner();
|
||||||
|
let user_id = claims.user_id;
|
||||||
|
|
||||||
|
let affected = sqlx::query(
|
||||||
|
r#"UPDATE notifications SET is_read = true WHERE id = $1 AND (scope = 'all' OR user_id = $2)"#,
|
||||||
|
)
|
||||||
|
.bind(notif_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool.get_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Database(format!("标记已读失败: {}", e)))?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if affected == 0 {
|
||||||
|
return Err(AppError::NotFound("通知不存在".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"success": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/notifications/read-all — 全部标记已读
|
||||||
|
#[put("/api/notifications/read-all")]
|
||||||
|
pub async fn mark_all_read(
|
||||||
|
pool: web::Data<PgPool>,
|
||||||
|
claims: web::ReqData<Claims>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let user_id = claims.user_id;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE notifications SET is_read = true WHERE (scope = 'all' OR user_id = $1) AND is_read = false"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool.get_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Database(format!("全部标记已读失败: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"success": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/notifications/unread-count — 未读通知数量
|
||||||
|
#[get("/api/notifications/unread-count")]
|
||||||
|
pub async fn unread_count(
|
||||||
|
pool: web::Data<PgPool>,
|
||||||
|
claims: web::ReqData<Claims>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let user_id = claims.user_id;
|
||||||
|
|
||||||
|
let count: i64 = sqlx::query_scalar(
|
||||||
|
r#"SELECT COUNT(*) FROM notifications WHERE (scope = 'all' OR user_id = $1) AND is_read = false"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool.get_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Database(format!("查询未读数失败: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"data": { "count": count }
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 管理员接口 =====
|
||||||
|
|
||||||
|
/// POST /api/admin/notifications — 管理员创建通知
|
||||||
|
#[post("/api/admin/notifications")]
|
||||||
|
pub async fn admin_create_notification(
|
||||||
|
pool: web::Data<PgPool>,
|
||||||
|
claims: web::ReqData<Claims>,
|
||||||
|
body: web::Json<CreateNotificationRequest>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let admin_id = claims.user_id;
|
||||||
|
|
||||||
|
// 验证权限
|
||||||
|
let admin_user = db::get_user_by_id(pool.get_ref(), admin_id).await?;
|
||||||
|
if !admin_user.is_admin {
|
||||||
|
return Err(AppError::Forbidden("无权限".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.scope != "all" && body.scope != "user" {
|
||||||
|
return Err(AppError::BadRequest("scope 必须是 all 或 user".to_string()));
|
||||||
|
}
|
||||||
|
if body.scope == "user" && body.user_id.is_none() {
|
||||||
|
return Err(AppError::BadRequest("scope=user 时必须指定 user_id".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO notifications (scope, user_id, type, title, content, priority, link)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&body.scope)
|
||||||
|
.bind(body.user_id)
|
||||||
|
.bind(&body.type_)
|
||||||
|
.bind(&body.title)
|
||||||
|
.bind(&body.content)
|
||||||
|
.bind(body.priority.as_deref().unwrap_or("normal"))
|
||||||
|
.bind(&body.link)
|
||||||
|
.execute(pool.get_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Database(format!("创建通知失败: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"success": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/admin/notifications/{id} — 管理员删除通知
|
||||||
|
#[delete("/api/admin/notifications/{id}")]
|
||||||
|
pub async fn admin_delete_notification(
|
||||||
|
pool: web::Data<PgPool>,
|
||||||
|
claims: web::ReqData<Claims>,
|
||||||
|
path: web::Path<i32>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let notif_id = path.into_inner();
|
||||||
|
let admin_user = db::get_user_by_id(pool.get_ref(), claims.user_id).await?;
|
||||||
|
if !admin_user.is_admin {
|
||||||
|
return Err(AppError::Forbidden("无权限".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM notifications WHERE id = $1")
|
||||||
|
.bind(notif_id)
|
||||||
|
.execute(pool.get_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Database(format!("删除通知失败: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"success": true})))
|
||||||
|
}
|
||||||
11
src/main.rs
11
src/main.rs
@@ -18,10 +18,11 @@ use auth::jwt_middleware;
|
|||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
use db::create_pool;
|
use db::create_pool;
|
||||||
use handlers::{
|
use handlers::{
|
||||||
admin_force_confirm_order, admin_get_user, admin_refund_order, admin_update_user_payment, add_favorite, alipay_notify, alipay_pay_page, alipay_refund_notify, cancel_order,
|
admin_force_confirm_order, admin_get_user, admin_refund_order, admin_update_user_payment, add_favorite, alipay_notify,
|
||||||
|
admin_create_notification, admin_delete_notification, alipay_pay_page, alipay_refund_notify, cancel_order,
|
||||||
create_order, delete_weather, generate_code, generate_temp_token_handler, get_current_user_profile,
|
create_order, delete_weather, generate_code, generate_temp_token_handler, get_current_user_profile,
|
||||||
get_favorites, get_user_quota, get_weather_brief, get_weather_details,
|
get_favorites, get_user_quota, get_weather_brief, get_weather_details,
|
||||||
get_user_orders, health_check, login, mock_login, mock_confirm, sync_order, payment_index, payment_login_status, payment_page, payment_success,
|
get_user_orders, health_check, list_notifications, login, mark_all_read, mark_notification_read, unread_count, mock_login, mock_confirm, sync_order, payment_index, payment_login_status, payment_page, payment_success,
|
||||||
post_weather_data, report_frontend_error,
|
post_weather_data, report_frontend_error,
|
||||||
refresh_token, remove_favorite, root, save_user_profile, serve_static_files,
|
refresh_token, remove_favorite, root, save_user_profile, serve_static_files,
|
||||||
web_generate_login_code, web_login_confirm, web_login_auto_confirm,
|
web_generate_login_code, web_login_confirm, web_login_auto_confirm,
|
||||||
@@ -109,6 +110,8 @@ fn create_server_config(
|
|||||||
.service(admin_update_user_payment)
|
.service(admin_update_user_payment)
|
||||||
.service(admin_force_confirm_order)
|
.service(admin_force_confirm_order)
|
||||||
.service(admin_refund_order)
|
.service(admin_refund_order)
|
||||||
|
.service(admin_create_notification)
|
||||||
|
.service(admin_delete_notification)
|
||||||
.service(create_order)
|
.service(create_order)
|
||||||
.service(mock_confirm)
|
.service(mock_confirm)
|
||||||
.service(sync_order)
|
.service(sync_order)
|
||||||
@@ -117,6 +120,10 @@ fn create_server_config(
|
|||||||
.service(get_user_quota)
|
.service(get_user_quota)
|
||||||
.service(get_favorites)
|
.service(get_favorites)
|
||||||
.service(add_favorite)
|
.service(add_favorite)
|
||||||
|
.service(list_notifications)
|
||||||
|
.service(mark_notification_read)
|
||||||
|
.service(mark_all_read)
|
||||||
|
.service(unread_count)
|
||||||
.service(remove_favorite)
|
.service(remove_favorite)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,3 +401,21 @@ pub struct PaymentOrder {
|
|||||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 通知系统 =====
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, FromRow)]
|
||||||
|
pub struct Notification {
|
||||||
|
pub id: i32,
|
||||||
|
pub scope: String,
|
||||||
|
pub user_id: Option<i32>,
|
||||||
|
#[sqlx(rename = "type")]
|
||||||
|
pub type_: String,
|
||||||
|
pub title: String,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub priority: String,
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub is_read: bool,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user