diff --git a/migrations/011_add_notifications.sql b/migrations/011_add_notifications.sql new file mode 100644 index 0000000..e8ba897 --- /dev/null +++ b/migrations/011_add_notifications.sql @@ -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; diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 85a8386..06b514c 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -4,6 +4,7 @@ pub mod auth; pub mod favorites; pub mod health; pub mod meta; +pub mod notifications; pub mod payment; pub mod sentry; 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 health::health_check; 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 user::get_current_user_profile; pub use user::save_user_profile; diff --git a/src/handlers/notifications.rs b/src/handlers/notifications.rs new file mode 100644 index 0000000..2990201 --- /dev/null +++ b/src/handlers/notifications.rs @@ -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, + limit: Option, +} + +// ===== 创建通知请求 ===== + +#[derive(Debug, Deserialize)] +pub struct CreateNotificationRequest { + pub scope: String, // "all" 或 "user" + pub user_id: Option, // scope="user" 时必填 + pub type_: String, + pub title: String, + pub content: Option, + pub priority: Option, + pub link: Option, +} + +// ===== 用户接口 ===== + +/// GET /api/notifications — 获取当前用户的通知列表 +#[get("/api/notifications")] +pub async fn list_notifications( + pool: web::Data, + claims: web::ReqData, + query: web::Query, +) -> Result { + 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, + claims: web::ReqData, + path: web::Path, +) -> Result { + 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, + claims: web::ReqData, +) -> Result { + 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, + claims: web::ReqData, +) -> Result { + 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, + claims: web::ReqData, + body: web::Json, +) -> Result { + 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, + claims: web::ReqData, + path: web::Path, +) -> Result { + 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}))) +} diff --git a/src/main.rs b/src/main.rs index 6ac5e6c..1a3043e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,10 +18,11 @@ use auth::jwt_middleware; use config::AppConfig; use db::create_pool; 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, 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, refresh_token, remove_favorite, root, save_user_profile, serve_static_files, 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_force_confirm_order) .service(admin_refund_order) + .service(admin_create_notification) + .service(admin_delete_notification) .service(create_order) .service(mock_confirm) .service(sync_order) @@ -117,6 +120,10 @@ fn create_server_config( .service(get_user_quota) .service(get_favorites) .service(add_favorite) + .service(list_notifications) + .service(mark_notification_read) + .service(mark_all_read) + .service(unread_count) .service(remove_favorite) ) } diff --git a/src/models.rs b/src/models.rs index e66fa84..c89ae4d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -401,3 +401,21 @@ pub struct PaymentOrder { pub expires_at: Option>, pub created_at: chrono::DateTime, } + +// ===== 通知系统 ===== + +#[derive(Debug, Serialize, FromRow)] +pub struct Notification { + pub id: i32, + pub scope: String, + pub user_id: Option, + #[sqlx(rename = "type")] + pub type_: String, + pub title: String, + pub content: Option, + pub priority: String, + pub link: Option, + pub is_read: bool, + pub created_at: chrono::DateTime, + pub expires_at: Option>, +}