# 站内信通知中心 — 设计规格 > 版本:v1.0 > 日期:2026-07-05 > 基于 PRD:`PRD-恭学教育学生管理系统.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-count`(60s 间隔) - 钉钉/企微外发通过 EventEmitter2 异步解耦 ## 4. 数据模型 ```sql 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=&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 接口 ```typescript class NotificationsService { create(dto: CreateNotificationDto): Promise; findByUser(userId: number, after?: number, limit?: number): Promise; getUnreadCount(userId: number): Promise; markRead(id: number, userId: number): Promise; markAllRead(userId: number): Promise; subscribe(userId: number): Observable; // SSE } ``` ### 5.4 SSE 实现要点 - Controller 使用 `@Sse('stream')` + `@Req()` 获取 `req.user.id` - Service 内部维护 `Map>` - `create()` 方法写入 DB 后 → `subject.next(notification)` 推送给订阅者 - 用户断开连接时清理 Subject ### 5.5 业务模块集成模式 各业务 Controller 写操作完成后调用: ```typescript this.notificationsService.create({ recipientIds: [studentUserId, financeUserIds], type: 'bill_generated', title: '账单已生成', content: `您的 ${periodLabel} 账单已生成,总额 ¥${totalAmount}`, link: `/bills/${billId}`, }); ``` 钉钉/企微外发通过 `EventEmitter2` 解耦: ```typescript 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`) ```typescript 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 token(EventSource 不支持自定义 header)。 ## 7. SSE 认证与 Nginx 配置 ### 7.1 后端 Guard 适配 `JwtAuthGuard` 需支持从 query string 提取 token(当前仅从 `Authorization` header): ```typescript // 在 canActivate 中增加 fallback const token = extractFromHeader(request) || request.query?.token; ``` ### 7.2 Nginx 配置 SSE 长连接需关闭对该路径的 proxy buffering: ```nginx 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.ts`,`AppModule` 中 `TypeOrmModule.forFeature([Notification])`。 ## 9. 钉钉/企微外发(预留) - `NotificationsService.create()` 后 emit `notification.created` 事件 - 钉钉模块(`apps/server/src/sync/` 下已有工作通知能力)监听该事件 - 根据 `notification.type` 判断是否外发(如 `bill_generated` 发钉钉,`announcement` 仅站内信) - 外发失败不影响站内信记录,日志告警即可 ## 10. 扩展点(学生端未来接入) - 学生端前端独立部署时,复用同一套 API(JWT 认证统一) - `link` 字段路径由前端根据当前角色拼接 base path - 通知类型枚举预留 `student_*` 前缀扩展