feat: 通知系统(notifications表+CRUD接口+管理员接口)

This commit is contained in:
2026-05-26 15:02:49 +08:00
parent 80434047df
commit 640f57b6da
5 changed files with 280 additions and 2 deletions

View 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;

View File

@@ -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;

View 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})))
}

View File

@@ -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)
) )
} }

View File

@@ -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>>,
}