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

234 lines
8.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 站内信通知中心 — 设计规格
> 版本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 tokenEventSource 不支持自定义 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. 扩展点(学生端未来接入)
- 学生端前端独立部署时,复用同一套 APIJWT 认证统一)
- `link` 字段路径由前端根据当前角色拼接 base path
- 通知类型枚举预留 `student_*` 前缀扩展