# 通知 / 公告整合 — 完成报告

**完成日期**: 2026-05-15
**分支**: dev(3 个 commit + plan/报告 docs)
**push 状态**: 本地完成,等用户授权 push

---

## 一、改动总览

| # | Commit | 简述 |
|---|--------|------|
| 1 | `7a503db` | feat(notification): 公告整合进 Notification — fan-out + 强弹 Modal + chip + 删 announcement_enabled 概念 |
| 2 | `850acf6` | refactor(notification): 删 AnnouncementBanner 废弃文件 |
| 3 | (即将) | test+docs: announcement-integration-smoke.mjs + plan 归档 + 完成报告 |

---

## 二、最终行为(对齐用户)

1. 超管在系统设置编辑公告内容(rich text + 6 个工具按钮 + 预览)
2. 编辑完点【**发送公告**】独立按钮 → 二次 confirm "确认发送给所有用户?发送后所有人打开页面会强制看到这条公告,无法撤回"
3. 后端 fan-out 给**所有用户**(含 `is_active=False` 封禁用户,解封后能看到累积历史)
4. 任意用户登录 / 打开任意路由 → `<GlobalAnnouncementGate>` 顶层组件 fetch 未读公告 → **强制弹 modal,必须看**
5. 关闭 modal(点【我知道了】/ ✕ / 遮罩点击)= 标已读 → 不再弹
6. 想重看 → sidebar 大铃铛 → 消息中心 → 找到那条公告点开(HTML 渲染)
7. 消息中心列表每条带 type chip: [公告] / [安全] / [额度] / [系统] 4 色
8. 删除右上角小喇叭、删除 `announcement_enabled` 开关(发了就强弹,无静默模式)

---

## 三、改动文件

### 后端 (3 modified)
- `backend/apps/notifications/models.py` — TYPE_CHOICES 加 `'announcement'`
- `backend/apps/generation/views.py` — 新增 `admin_publish_announcement_view` + 重写 `announcement_view` / `announcement_read_view`(内部全部走 Notification 表)
- `backend/apps/generation/urls.py` — 新路由 `POST /api/v1/admin/announcement/publish`

### 前端 (7 modified + 1 new)
- `web/src/App.tsx` — 顶层挂 `<GlobalAnnouncementGate />`
- `web/src/components/GlobalAnnouncementGate.tsx` — **新建**,负责"任意路由检测 + 强弹 + 标已读 + 同步铃铛"
- `web/src/components/AnnouncementModal.tsx` — 改纯展示组件(props 接 content + onClose,不自己 fetch),HTML 用 DOMPurify
- `web/src/components/VideoGenerationPage.tsx` — 删右上角小喇叭 + 旧 AnnouncementModal 自动弹 + 旧 AnnouncementBanner import
- `web/src/lib/api.ts` — 加 `announcementApi.publish(content)`
- `web/src/pages/SettingsPage.tsx` — 删 announcement_enabled checkbox + 加【发送公告】按钮 + 二次 confirm
- `web/src/pages/NotificationsPage.tsx` — 每条加 type chip + announcement HTML 渲染(DOMPurify)
- `web/src/types/index.ts` — `NotificationType` 加 `'announcement'`

### 删除 (2 files)
- `web/src/components/AnnouncementBanner.tsx`(废弃,无引用)
- `web/src/components/AnnouncementBanner.module.css`

### 测试 + 文档 (3 new)
- `web/test/announcement-integration-smoke.mjs` — 10 项 E2E
- `docs/todo/通知公告整合.md` — 整合 plan v2
- `docs/todo/通知公告整合-完成报告.md` — 本报告

---

## 四、关键设计决策

### 1. 强弹位置:全局顶层 vs 各路由各自挂

**选**:全局 `<GlobalAnnouncementGate>` 在 `App.tsx` `<Routes>` 之外。
- 所有路由统一行为
- 不需要每个 page 都加 `<AnnouncementModal />`
- 跨路由切换时如果未读还在,继续弹(行为一致)

### 2. fan-out 范围:含封禁用户

**选**:`User.objects.all()` 而非 `filter(is_active=True)`。
- 封禁用户解封后能看到累积的历史公告
- bulk_create + 索引 `(recipient, is_read, -created_at)` 性能足够(150 用户 / 1 年 50 公告 = 7500 行可忽略)
- 用户不会因为"封禁期间错过通知"而抱怨

### 3. 发布触发:独立按钮 vs 整体保存

**选**:独立【发送公告】按钮 + 二次 confirm,不和"保存设置"按钮共用。
- 防误操作 — 超管改配额时不会顺带触发公告 fan-out
- 用户明确表达"我点发就直接发了"的明确动作语义

### 4. announcement_enabled 字段保留 vs 删除

**选**:字段保留(QuotaConfig 不动 schema)但前端不再读。
- 避免 migration
- 后端发送时仍写 `announcement_enabled=True`,作为"最近一次发送过"的标记
- 真实判断 fan-out 走的是 `Notification.is_read`,不再依赖这个 flag

### 5. HTML 安全:DOMPurify 而非禁 HTML

**选**:Modal 和 NotificationsPage 两处都用 `DOMPurify.sanitize(content)`。
- 保留 HTML 富文本能力(超管可加粗/红字/链接)
- 用 DOMPurify 防 XSS

### 6. 老 endpoint 兼容

**选**:`/announcement` + `/announcement/read` 保留 endpoint,内部改走 Notification 表,响应结构不变。
- 老前端 PWA 缓存平滑过渡
- 后续可以无痛删 endpoint(本次不删)

---

## 五、测试结果

### 后端 curl 验证(5 项全过)

| Case | 期望 | 结果 |
|------|------|------|
| 空内容发送 | 400 "公告内容不能为空" | ✓ |
| HTML 发送 | 200 sent_to=N | ✓ (sent_to=21) |
| tudou GET /announcement | 拿到那条 HTML + is_read=false + notification_id | ✓ |
| tudou POST /announcement/read | updated≥1 | ✓ (updated=1) |
| tudou 再 GET | enabled=false + is_read=true | ✓ |

### 前端 smoke 全过

| 测试 | 基线 | 整合后 | 状态 |
|------|------|--------|------|
| `npx tsc -b` | 0 error | 0 error | ✓ |
| `npx vitest run` | 71 fail / 162 pass | 71 fail / 162 pass | ✓ 0 新增回归 |
| `web/test/v2-smoke.mjs` | 25/25 | 25/25 | ✓ |
| `web/test/modal-interaction.mjs` | 8/8 | 8/8 | ✓ |
| `web/test/v0.20.1-smoke.mjs` | 11/11 | 11/11 | ✓ |
| `web/test/announcement-integration-smoke.mjs` | 新增 | **10/10** | ✓ |

### 新 smoke 覆盖 10 项

1. 空内容发送返回 400
2. HTML 发送成功 sent_to=N
3. fan-out 数 = User 总数(21=21)
4. tudou GET /announcement 拿到未读公告
5. tudou 浏览器进 /app **自动强弹公告 modal**
6. 关闭 modal 后 GET /announcement 返回已读
6.1 再开页面不再弹 modal
7. 消息中心显示 [公告] chip
7.1 公告内容 HTML 渲染正常
8. 全程无 console.error

---

## 六、边缘 case 处理

| Case | 处理 |
|------|------|
| 超管反复编辑公告但不点【发送】 | textarea 内容只保存在本地 state,刷新页面会重置到上次发布的内容(QuotaConfig.announcement)— 接受 |
| 超管点【发送】两次 | fan-out 两次,所有用户消息中心多两条记录 — UI 二次 confirm 防误点 |
| 公告内容为空点【发送】 | 前端先校验 + 后端 400 兜底,双重保护 |
| 用户在路由 A 弹 modal 没关 → 切到 B | modal 跟随全局,在 B 继续显示。关闭前任何路由都被遮住 |
| 同时多条未读公告 | 显示最新一条;关闭后下次 visibilitychange 再 check 又拿到下一条;依次到都读完 |
| 封禁用户公告 | 发出去时也 bulk_create 给他;封禁状态登不上来看不到,解封后能看到累积 |
| 老前端 PWA 缓存调 `/announcement` | endpoint 响应结构兼容,平滑 |

---

## 七、本地状态

- **分支**: dev
- **commit 数**: 2 个功能 commit + 1 个 docs 收尾(下一步)
- **未 push**: 等用户授权
- **DB migration**: 无(TYPE_CHOICES 是 choices 软约束,DB schema 不变)

---

## 八、待用户做

1. **本地手测**(可选,smoke 已覆盖关键链路)— 浏览器跑一遍:
   - admin 系统设置写公告 → 点【发送公告】→ 二次确认 → 提示 "已发送给 N 个用户"
   - 退出 admin → 用 tudou 登录 → 任何页面应该被 modal 挡住
   - 点【我知道了】→ modal 关闭 + sidebar 铃铛红点同步
   - 进消息中心 → 看到刚发的公告 + [公告] chip + HTML 渲染
2. **授权 push** — 等"可以 push"指令,跑 `ALLOW_PUSH=1 git push origin dev`(memory `feedback_must_confirm_push`)
3. **测试服跑通后** — 合 master 部署生产(单独授权)

---

## 九、风险与已知限制

| 风险 | 缓解 |
|------|------|
| Notification 表 announcement 类条目随时间膨胀 | 现有索引 `(recipient, is_read, -created_at)` 覆盖查询;未来加 90 天软清理 |
| 超管打开页面也会被自己刚发的公告挡 | 接受 — 超管也是用户,看一次就行;关掉就不再弹 |
| 新注册用户看不到历史公告 | 接受 — 公告时效性强,新人不补发 |
| HTML 内容超长 modal 滚动 | AnnouncementModal CSS 已有 max-height + overflow-y(继承自原实现)|
