forked from wangziqi/gongxue-base
8.0 KiB
8.0 KiB
站内信通知中心 — 设计规格
版本: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()+ RxJSObservable - 前端
EventSource建立长连接,断开时自动重连 - 重连间隙兜底轮询
GET /notifications/unread-count(60s 间隔) - 钉钉/企微外发通过 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 token(EventSource 不支持自定义 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.ts,AppModule 中 TypeOrmModule.forFeature([Notification])。
9. 钉钉/企微外发(预留)
NotificationsService.create()后 emitnotification.created事件- 钉钉模块(
apps/server/src/sync/下已有工作通知能力)监听该事件 - 根据
notification.type判断是否外发(如bill_generated发钉钉,announcement仅站内信) - 外发失败不影响站内信记录,日志告警即可
10. 扩展点(学生端未来接入)
- 学生端前端独立部署时,复用同一套 API(JWT 认证统一)
link字段路径由前端根据当前角色拼接 base path- 通知类型枚举预留
student_*前缀扩展