234 lines
8.0 KiB
Markdown
234 lines
8.0 KiB
Markdown
# 站内信通知中心 — 设计规格
|
||
|
||
> 版本: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=<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 接口
|
||
|
||
```typescript
|
||
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 写操作完成后调用:
|
||
|
||
```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_*` 前缀扩展
|