Files
gongxue-base/docs/superpowers/specs/2026-07-05-notification-center-design.md

8.0 KiB
Raw Blame History

站内信通知中心 — 设计规格

版本v1.0 日期2026-07-05 基于 PRDPRD-恭学教育学生管理系统.md §23.7(通知中心确认需要) + §12.3(账单通知推送 P2

1. 目标

为系统全部角色(超管、教职工、班主任、学生)提供统一的站内信通知中心,支撑以下业务场景的实时通知,同时预留钉钉/企微外发扩展点。

2. 通知场景

场景 触发方 通知类型 接收方
账单生成 系统/管理员 bill_generated 学生 + 财务
账单确认/已付 管理员 bill_paid 学生 + 宿管
入住登记 宿管 check_in 宿管 + 学生
退宿 宿管 check_out 宿管 + 学生
押金催缴 财务 deposit_due 学生 + 财务
押金退还 财务 deposit_refunded 学生 + 财务
班级学员增减 教务 class_change 班主任
班级教师调整 教务 class_change 相关教师
排课冲突 系统检测 schedule_conflict 教务
系统公告 管理员手动 announcement 全员/指定角色

3. 技术方案

SSE (Server-Sent Events) 推送 + 轮询兜底。

  • NestJS 原生 @Sse() + RxJS Observable
  • 前端 EventSource 建立长连接,断开时自动重连
  • 重连间隙兜底轮询 GET /notifications/unread-count60s 间隔)
  • 钉钉/企微外发通过 EventEmitter2 异步解耦

4. 数据模型

notifications
├── id              INTEGER PK AUTOINCREMENT
├── recipient_id    INTEGER NOT NULL        -- FK → users.id
├── type            VARCHAR(30) NOT NULL    -- bill_generated | bill_paid | check_in | check_out |
                                            --   deposit_due | deposit_refunded | class_change |
                                            --   schedule_conflict | announcement
├── title           VARCHAR(200) NOT NULL   -- 通知标题
├── content         TEXT                    -- 通知正文(支持模板变量)
├── link            VARCHAR(500) NULLABLE   -- 点击跳转路径,如 /bills/123
├── is_read         BOOLEAN DEFAULT false
├── read_at         DATETIME NULLABLE
├── created_at      DATETIME DEFAULT CURRENT_TIMESTAMP

设计决策:

  • 一通知一接收方 — 同一事件对 N 个用户各建一条记录,避免 is_read 共享状态。
  • 无软删除 — 通知不可删除(保留审计痕迹),支持"全部已读"。
  • cursor-based 分页?after=<id>&limit=20,适合实时追加场景。

5. 后端模块

5.1 文件结构

apps/server/src/
├── entities/
│   └── notification.entity.ts    🆕
├── notifications/                🆕
│   ├── notifications.module.ts
│   ├── notifications.controller.ts
│   ├── notifications.service.ts
│   └── dto/
│       └── notification.dto.ts
└── app.module.ts                 ✏️ 注册 NotificationsModule

5.2 API

方法 路径 认证 说明
GET /notifications JWT 当前用户通知列表cursor 分页,?after=&limit=20
GET /notifications/unread-count JWT { count: number }
GET /notifications/stream JWT SSE 端点,text/event-stream
PUT /notifications/:id/read JWT 标记单条已读
PUT /notifications/read-all JWT 当前用户全部已读

5.3 Service 接口

class NotificationsService {
  create(dto: CreateNotificationDto): Promise<Notification>;
  findByUser(userId: number, after?: number, limit?: number): Promise<Notification[]>;
  getUnreadCount(userId: number): Promise<number>;
  markRead(id: number, userId: number): Promise<void>;
  markAllRead(userId: number): Promise<void>;
  subscribe(userId: number): Observable<Notification>;  // SSE
}

5.4 SSE 实现要点

  • Controller 使用 @Sse('stream') + @Req() 获取 req.user.id
  • Service 内部维护 Map<userId, Subject<Notification>>
  • create() 方法写入 DB 后 → subject.next(notification) 推送给订阅者
  • 用户断开连接时清理 Subject

5.5 业务模块集成模式

各业务 Controller 写操作完成后调用:

this.notificationsService.create({
  recipientIds: [studentUserId, financeUserIds],
  type: 'bill_generated',
  title: '账单已生成',
  content: `您的 ${periodLabel} 账单已生成,总额 ¥${totalAmount}`,
  link: `/bills/${billId}`,
});

钉钉/企微外发通过 EventEmitter2 解耦:

this.eventEmitter.emit('notification.created', notification);

6. 前端

6.1 文件结构

apps/admin/src/
├── pages/
│   └── Notifications/
│       └── index.tsx              🆕  通知全屏页
├── components/
│   └── NotificationBell.tsx       🆕  Header 铃铛组件
├── hooks/
│   └── useNotifications.ts        🆕  SSE 连接 + 未读计数
└── layouts/
    └── MainLayout.tsx             ✏️  挂载 NotificationBell + SSE hook

6.2 Header 铃铛

  • Badge 组件显示未读数count > 99 显示 "99+"
  • 点击展开 Popover(宽 380px高 480px
  • Popover 内容:
    • 头部:"通知中心" + "全部已读" Button
    • 列表:虚拟滚动,未读条目左侧蓝点
    • 点击条目 → api.put(/notifications/${id}/read) + navigate(link)
    • 底部 "查看全部 →" → /notifications
  • 空状态:"暂无通知" 插画

6.3 全屏通知页 /notifications

  • 左侧类型筛选 Menu(全部/账单/入住/班级/系统)
  • 右侧通知列表 + InfiniteScroll
  • 列表项:类型图标 + 标题 + 内容摘要 + 时间(相对时间 "3分钟前"
  • 点击条目 → 标已读 + 跳转 link

6.4 SSE Hook (useNotifications)

function useNotifications() {
  const [unreadCount, setUnreadCount] = useState(0);

  useEffect(() => {
    const token = localStorage.getItem('token');
    const es = new EventSource(`/api/notifications/stream?token=${token}`);

    es.onmessage = (event) => {
      const notification = JSON.parse(event.data);
      setUnreadCount((c) => c + 1);
    };

    es.onerror = () => {
      // SSE 断开,切换到轮询兜底
      const interval = setInterval(async () => {
        const { count } = await api.get('/notifications/unread-count');
        setUnreadCount(count);
      }, 60_000);
      return () => clearInterval(interval);
    };

    return () => es.close();
  }, []);

  return { unreadCount };
}

SSE 认证URL query 传 JWT tokenEventSource 不支持自定义 header

7. SSE 认证与 Nginx 配置

7.1 后端 Guard 适配

JwtAuthGuard 需支持从 query string 提取 token当前仅从 Authorization header

// 在 canActivate 中增加 fallback
const token = extractFromHeader(request) || request.query?.token;

7.2 Nginx 配置

SSE 长连接需关闭对该路径的 proxy buffering

location /api/notifications/stream {
    proxy_pass http://127.0.0.1:3000;
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding off;
}

8. 数据库迁移

TypeORM synchronize: true 自动建表。Entity 注册在 apps/server/src/entities/index.tsAppModuleTypeOrmModule.forFeature([Notification])

9. 钉钉/企微外发(预留)

  • NotificationsService.create() 后 emit notification.created 事件
  • 钉钉模块(apps/server/src/sync/ 下已有工作通知能力)监听该事件
  • 根据 notification.type 判断是否外发(如 bill_generated 发钉钉,announcement 仅站内信)
  • 外发失败不影响站内信记录,日志告警即可

10. 扩展点(学生端未来接入)

  • 学生端前端独立部署时,复用同一套 APIJWT 认证统一)
  • link 字段路径由前端根据当前角色拼接 base path
  • 通知类型枚举预留 student_* 前缀扩展