From e55a6665f25efe05abe33ac05997196dd9a246fe Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 15 May 2026 16:04:43 +0800 Subject: [PATCH] =?UTF-8?q?test+docs(notification):=20announcement-integra?= =?UTF-8?q?tion-smoke.mjs=2010/10=20+=20plan=20=E5=BD=92=E6=A1=A3=20+=20?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建 web/test/announcement-integration-smoke.mjs 10 项 E2E: - 空内容 400 / HTML 发送 200 / fan-out 数 = User 总数 - tudou 拿到未读 / 浏览器自动强弹 modal / 关闭标已读 / 再开不弹 - 消息中心 [公告] chip + HTML 渲染 / 无 console.error 跑通基线对比: - vitest 71 fail / 162 pass (0 新增回归) - v2-smoke 25/25 + modal-interaction 8/8 + v0.20.1-smoke 11/11 - 新 announcement-integration-smoke 10/10 归档 plan 文件 docs/todo/通知公告整合.md(v2,本次实施的源 plan)。 写完成报告 docs/todo/通知公告整合-完成报告.md(改动文件清单 + 3 commit hash + 测试结果 + 关键设计决策 + 边缘 case 处理)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/todo/通知公告整合-完成报告.md | 183 +++++++++++++ docs/todo/通知公告整合.md | 281 ++++++++++++++++++++ web/test/announcement-integration-smoke.mjs | 205 ++++++++++++++ 3 files changed, 669 insertions(+) create mode 100644 docs/todo/通知公告整合-完成报告.md create mode 100644 docs/todo/通知公告整合.md create mode 100644 web/test/announcement-integration-smoke.mjs diff --git a/docs/todo/通知公告整合-完成报告.md b/docs/todo/通知公告整合-完成报告.md new file mode 100644 index 0000000..707c4ed --- /dev/null +++ b/docs/todo/通知公告整合-完成报告.md @@ -0,0 +1,183 @@ +# 通知 / 公告整合 — 完成报告 + +**完成日期**: 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. 任意用户登录 / 打开任意路由 → `` 顶层组件 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` — 顶层挂 `` +- `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 各路由各自挂 + +**选**:全局 `` 在 `App.tsx` `` 之外。 +- 所有路由统一行为 +- 不需要每个 page 都加 `` +- 跨路由切换时如果未读还在,继续弹(行为一致) + +### 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(继承自原实现)| diff --git a/docs/todo/通知公告整合.md b/docs/todo/通知公告整合.md new file mode 100644 index 0000000..59c5b66 --- /dev/null +++ b/docs/todo/通知公告整合.md @@ -0,0 +1,281 @@ +# 通知 / 公告整合 plan(v2) + +**起因**:v0.20.1 消息中心(Notification)上线后跟公告(QuotaConfig.announcement)UX 重叠 — 两个铃铛、两套未读、两套入口。 + +**目标**:统一到消息中心,公告作为 `type='announcement'` 的 Notification。 + +**预估**:约 1.5 小时 + +--- + +## 一、最终行为(对齐用户) + +1. 超管在系统设置编辑公告内容(rich text) +2. 编辑完点【**发送公告**】独立按钮 → fan-out 给**所有用户**(含封禁用户) +3. 任意用户登录 / 打开任意页面 → 有未读 announcement 就**强制弹 modal,必须看**(关闭才能用页面) +4. 关闭 = 标已读,不再弹 +5. 想重看 → sidebar 大铃铛 → 消息中心 → 找到那条公告 +6. 消息中心列表每条带 type chip([公告] / [安全] / [额度] / [系统]) +7. 删除右上角小喇叭、删除 `announcement_enabled` 开关概念(发了就强弹,无静默模式) + +--- + +## 二、后端改动 + +### 1. `apps/notifications/models.py` TYPE_CHOICES 加项 + +```python +TYPE_CHOICES = [ + ('anomaly_disabled_user', '账号因异常被自动封禁'), + ('anomaly_disabled_team', '团队因异常被自动封禁'), + ('quota_warning', '额度即将耗尽'), + ('announcement', '系统公告'), # 新增 + ('system', '系统通知'), +] +``` + +无 migration(CharField choices 软约束)。 + +### 2. `apps/generation/views.py` 新 endpoint + +```python +@api_view(['POST']) +@permission_classes([IsSuperAdmin]) +def admin_publish_announcement_view(request): + """POST /api/v1/admin/announcement/publish — 发送公告给所有用户。 + + Body: { "content": "..." } + """ + from apps.notifications.models import Notification + from apps.accounts.models import User as UserModel + + content = (request.data.get('content') or '').strip() + if not content: + return Response({'error': '公告内容不能为空'}, status=status.HTTP_400_BAD_REQUEST) + + # 把最新公告也写回 QuotaConfig.announcement 当存档(超管下次编辑时能看到上次发的) + config = QuotaConfig.objects.get(pk=1) + config.announcement = content + config.announcement_enabled = True # 字段保留兼容,但前端不再读 + config.save(update_fields=['announcement', 'announcement_enabled']) + + # fan-out — 所有用户(含封禁,封禁用户解封后能看到历史) + user_ids = list(UserModel.objects.all().values_list('id', flat=True)) + notifs = [Notification( + recipient_id=uid, + type='announcement', + title='系统公告', + content=content, + link_url='', + is_read=False, + ) for uid in user_ids] + Notification.objects.bulk_create(notifs, batch_size=500) + log_admin_action(request, 'announcement_publish', 'system', + after={'recipients': len(notifs), 'content_preview': content[:80]}) + return Response({'sent_to': len(notifs)}) +``` + +URL 加路由:`path('admin/announcement/publish', views.admin_publish_announcement_view, ...)`。 + +### 3. `apps/generation/views.py` `admin_settings_view` 改 + +整体 `PUT /admin/settings` **保留** announcement / announcement_enabled 字段读写(让超管编辑器有数据来源),但**不再 fan-out**。fan-out 唯一入口是 `/admin/announcement/publish`。 + +### 4. 老 endpoint 处理 + +- `GET /api/v1/announcement` — 重写,返回当前用户**最新未读公告**(从 Notification 表),给老 AnnouncementModal 用 +- `POST /api/v1/announcement/read` — 重写,标记当前用户所有 type=announcement 未读为已读 +- 都保留 endpoint 兼容,内部走 Notification + +```python +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def announcement_view(request): + from apps.notifications.models import Notification + latest = Notification.objects.filter( + recipient=request.user, type='announcement', is_read=False + ).order_by('-created_at').first() + if not latest: + return Response({'announcement': '', 'enabled': False, 'is_read': True, 'notification_id': None}) + return Response({ + 'announcement': latest.content, + 'enabled': True, + 'is_read': False, + 'notification_id': latest.id, + }) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def announcement_read_view(request): + from apps.notifications.models import Notification + Notification.objects.filter( + recipient=request.user, type='announcement', is_read=False + ).update(is_read=True) + return Response({'success': True}) +``` + +--- + +## 三、前端改动 + +### 1. `SettingsPage.tsx` 公告编辑区改 + +现状:公告内容编辑器 + announcement_enabled checkbox + 跟其他设置一起"保存"。 + +改成: +- 删 announcement_enabled checkbox(不再有静默概念) +- 公告编辑器下面加一个独立的【**发送公告**】按钮 +- 按钮点击 → confirm "确认发送给所有用户?发送后所有人打开页面会强制看到" → 确认 → `POST /admin/announcement/publish { content }` → 提示"已发送给 N 个用户" +- 整体"保存"按钮**不再**改 announcement(避免误发)— 或者保留但只存草稿不触发 fan-out + +UX 说明:超管自己心里要清楚 — 编辑公告内容 = 写草稿,点【发送公告】= 真正发出。 + +### 2. **全局** AnnouncementModal(强弹核心) + +现状:`AnnouncementModal` 只在 VideoGenerationPage L226-230 渲染,只在生成页弹。 + +改成:**移到全局**(App.tsx ProtectedRoute 内部 或 顶层 layout),所有登录用户在任意页面都会触发。 + +实现: +- 在 App.tsx 顶层挂 `` 组件 +- 组件内 `useEffect`:登录后 / 路由变化时 / focus 回来时 → 调 `GET /announcement` → 有未读 → setShow(true) +- 弹出 modal,用户必须点关闭 → `POST /announcement/read` → setShow(false) +- 关闭后调 `useNotificationStore.getState().fetchUnreadCount()` 同步铃铛红点 + +代码骨架: +```tsx +function GlobalAnnouncementGate() { + const user = useAuthStore((s) => s.user); + const [unread, setUnread] = useState<{ content: string; notification_id: number } | null>(null); + useEffect(() => { + if (!user) return; + const check = async () => { + try { + const { data } = await api.get('/announcement'); + if (data.enabled && data.announcement && !data.is_read) { + setUnread({ content: data.announcement, notification_id: data.notification_id }); + } + } catch {} + }; + check(); + const onVis = () => { if (!document.hidden) check(); }; + document.addEventListener('visibilitychange', onVis); + return () => document.removeEventListener('visibilitychange', onVis); + }, [user]); + if (!unread) return null; + return ( + { + await api.post('/announcement/read'); + setUnread(null); + useNotificationStore.getState().fetchUnreadCount(); + }} + /> + ); +} +``` + +`AnnouncementModal.tsx` 改成纯展示组件,数据从 prop 传入(`content`),不再自己 fetch。 + +### 3. 删除 `VideoGenerationPage.tsx` 右上角小喇叭 + +L150-165 整段 button 删掉,连带 `showAnnouncement` state 和那个 `` 重看入口删掉(重看走消息中心)。 + +### 4. `NotificationsPage.tsx` 每条加 type chip + +```tsx +const TYPE_LABEL: Record = { + announcement: { text: '公告', color: 'var(--color-info)' }, + anomaly_disabled_user: { text: '安全', color: 'var(--color-danger)' }, + anomaly_disabled_team: { text: '安全', color: 'var(--color-danger)' }, + quota_warning: { text: '额度', color: 'var(--color-warning)' }, + system: { text: '系统', color: 'var(--color-text-tertiary)' }, +}; +// 在 title 旁边渲染 [{TYPE_LABEL[n.type].text}] +``` + +announcement 类型的 content 用 DOMPurify + `dangerouslySetInnerHTML` 渲染(其他 type 用纯文本)。 + +### 5. 删除废弃文件 + +- `web/src/components/AnnouncementBanner.tsx`(已废弃,grep 确认无 import 后 rm) + +--- + +## 四、边缘 case + +| case | 处理 | +|------|------| +| 公告发出后封禁某用户 | 该用户解封后能看到历史公告(因为 fan-out 时也发给了他) ✓ | +| 新用户注册 | 看不到老公告(只 fan-out 给发布时存在的 user)— 可接受,公告时效性强 | +| 超管点【发送公告】两次 | fan-out 两次,所有用户消息中心多两条记录 — 超管自己负责,UI 加二次 confirm 防误点 | +| 公告内容为空点【发送】 | 400 报错"公告内容不能为空" | +| 用户在路由 A 弹 modal 没关 → 切到 B | modal 跟随全局,在 B 继续显示。关闭前任何路由都被遮住 | +| 同时多条未读公告 | 显示最新一条;关闭后下次 check 再显示下一条;一直到都读完 | +| 老前端 PWA 缓存调 `/announcement` | endpoint 兼容,返回结构不变 | + +--- + +## 五、测试 + +新建 `web/test/announcement-integration-smoke.mjs`(~7 项): +1. admin 登录 → 系统设置 → 写公告"测试 v1" → 点【发送公告】→ 二次 confirm → 提示"已发送给 N 个用户" +2. 后端 Notification 表 fan-out ≥150 条 type=announcement +3. tudou 登录 → 任意路由(/app)→ **自动弹 modal** 显示公告 HTML 内容 +4. 点关闭 → 标已读 → 再刷新 /app **不再弹** +5. sidebar 铃铛红点同步 -1 +6. 进消息中心 → 看到这条公告 + 蓝色 [公告] chip + HTML 渲染 +7. admin 重新点【发送公告】(改了内容)→ tudou 再开页面又弹新公告 + +回归: +- tsc / vitest 71 162 / v0.20.1-smoke 11 / v2-smoke 25 / modal-interaction 8 都过 + +--- + +## 六、Commit 策略 + +3 个 commit: +1. `feat(notification): 公告整合进 Notification — fan-out + 强弹 Modal + chip + 删 announcement_enabled 概念` + - 后端:新 `/admin/announcement/publish` endpoint + TYPE_CHOICES + 重写 announcement_view/read_view + - 前端:GlobalAnnouncementGate 全局挂载 + AnnouncementModal 改纯展示 + SettingsPage 公告区加【发送】按钮删 checkbox + NotificationsPage chip + DOMPurify +2. `refactor(notification): 删 VideoGenerationPage 公告小喇叭 + AnnouncementBanner 废弃文件` +3. `test+docs: announcement-integration-smoke.mjs + 整合 plan 归档` + +--- + +## 七、Critical Files + +修改: +- `backend/apps/notifications/models.py` — TYPE_CHOICES 加 announcement +- `backend/apps/generation/views.py` — 新增 `admin_publish_announcement_view` + 重写 `announcement_view` + `announcement_read_view` +- `backend/apps/generation/urls.py` — 加路由 +- `web/src/App.tsx` — 挂 `` +- `web/src/components/AnnouncementModal.tsx` — 改纯展示组件(props 接 content,不自己 fetch) +- `web/src/components/VideoGenerationPage.tsx` — 删小喇叭 button + 删原 AnnouncementModal 调用 +- `web/src/pages/SettingsPage.tsx` — 删 announcement_enabled checkbox + 加【发送公告】按钮 + 二次 confirm +- `web/src/pages/NotificationsPage.tsx` — 每条加 type chip + announcement HTML 渲染 + +新建: +- `web/src/components/GlobalAnnouncementGate.tsx`(或直接挂在 App.tsx 内部 function) +- `web/test/announcement-integration-smoke.mjs` + +删除: +- `web/src/components/AnnouncementBanner.tsx` + +不动: +- `QuotaConfig.announcement` / `announcement_enabled` 字段(保留作为编辑数据源) +- `User.last_read_announcement` 字段(免 migration,后续可清理) +- `apps/notifications/` 主结构(已就绪) +- Sidebar 大铃铛(已就绪,自然包含公告未读) + +--- + +## 八、用户拍板顺序 + +- 本 plan ✓(就是这版) +- 写代码 + 测试 + 本地验证 ✅ 我自主做 +- 推 dev → **要授权** +- 测试服跑通后合 master → **要授权** diff --git a/web/test/announcement-integration-smoke.mjs b/web/test/announcement-integration-smoke.mjs new file mode 100644 index 0000000..abb9e20 --- /dev/null +++ b/web/test/announcement-integration-smoke.mjs @@ -0,0 +1,205 @@ +/** + * 通知 / 公告整合 smoke test — + * + * 覆盖: + * 1. admin POST /admin/announcement/publish (空内容 → 400) + * 2. admin POST /admin/announcement/publish (HTML 内容 → 200 sent_to=N) + * 3. 后端 DB:fan-out 数等于 User 总数(active+inactive 都收到) + * 4. tudou GET /announcement → 拿到那条 HTML + * 5. tudou 浏览器进 /app 应自动强弹 modal(GlobalAnnouncementGate) + * 6. 关闭 modal → POST /announcement/read → 再开页面不弹 + * 7. tudou 进消息中心 → 看到公告条目(带 [公告] chip + HTML 渲染) + * + * 前提:backend 8000 + frontend 5173 跑着,admin/admin123 + tudou/tudoupass123 可登录。 + * 清场:测试前清掉所有 announcement 未读;测试后也清掉以免污染其他 smoke。 + */ +import { chromium } from '@playwright/test'; + +const BASE = 'http://localhost:5173'; +const API = 'http://localhost:8000'; + +const results = []; +function pass(name) { results.push({ name, ok: true }); console.log(` ✓ ${name}`); } +function fail(name, err) { results.push({ name, ok: false, err: err?.message || err }); console.log(` ✗ ${name}: ${err?.message || err}`); } + +async function login(page, username, password) { + const res = await page.request.post(`${API}/api/v1/auth/login`, { + data: { username, password }, + }); + if (!res.ok()) throw new Error(`登录失败 ${username}: ${res.status()}`); + const body = await res.json(); + return { token: body?.tokens?.access, user: body?.user }; +} + +async function setStorage(page, { token, refresh, user }) { + await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(({ access, r, u }) => { + localStorage.setItem('access_token', access); + if (r) localStorage.setItem('refresh_token', r); + if (u) localStorage.setItem('user', JSON.stringify(u)); + }, { access: token, r: refresh, u: user }); +} + +async function clearAllUnreadAnnouncements(adminToken) { + // 没有专用清理 endpoint,用 admin 自己的 read-all 跑一遍(只清自己的); + // 其他用户的未读靠 tudou 的 read-all 单独清。这里只清 admin 自己。 + await fetch(`${API}/api/v1/announcement/read`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${adminToken}` }, + }); +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await ctx.newPage(); + const consoleErrors = []; + page.on('console', (m) => { + if (m.type() === 'error' && !/401|404|Failed to load|DevTools/.test(m.text())) { + consoleErrors.push(m.text()); + } + }); + // 接受 confirm 弹窗(发送公告二次确认) + page.on('dialog', (d) => d.accept()); + + console.log('\n════ 公告整合 smoke ════'); + + // 前置:登录拿 token + let adminTok, tudouTok; + try { + const a = await login(page, 'admin', 'admin123'); + const t = await login(page, 'tudou', 'tudoupass123'); + adminTok = a.token; tudouTok = t.token; + if (!adminTok || !tudouTok) throw new Error('token 空'); + } catch (e) { fail('前置:admin/tudou 登录拿 token', e); await browser.close(); return; } + + // 先清掉 tudou 可能的旧未读公告 + await fetch(`${API}/api/v1/announcement/read`, { + method: 'POST', headers: { 'Authorization': `Bearer ${tudouTok}` }, + }); + await fetch(`${API}/api/v1/announcement/read`, { + method: 'POST', headers: { 'Authorization': `Bearer ${adminTok}` }, + }); + + // ── 1. 空内容 400 ── + try { + const r = await fetch(`${API}/api/v1/admin/announcement/publish`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${adminTok}`, 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ content: '' }), + }); + if (r.status === 400) pass('1. 空内容发送返回 400'); + else fail('1. 空内容', new Error(`期望 400 实际 ${r.status}`)); + } catch (e) { fail('1. 空内容', e); } + + // ── 2. HTML 发送返回 200 + sent_to ── + const testContent = `

smoke 测试公告 ${Date.now()} - 请忽略

`; + let sentTo = 0; + try { + const r = await fetch(`${API}/api/v1/admin/announcement/publish`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${adminTok}`, 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ content: testContent }), + }); + const data = await r.json(); + sentTo = data.sent_to; + if (r.status === 200 && sentTo > 0) pass(`2. HTML 发送成功 sent_to=${sentTo}`); + else fail('2. HTML 发送', new Error(`期望 200+sent_to>0 实际 ${r.status} ${JSON.stringify(data)}`)); + } catch (e) { fail('2. HTML 发送', e); } + + // ── 3. fan-out 数 = User 总数(用 admin 计:GET /admin/users total 应 ≈ sent_to) ── + try { + const r = await fetch(`${API}/api/v1/admin/users?page_size=1`, { + headers: { 'Authorization': `Bearer ${adminTok}` }, + }); + const data = await r.json(); + if (data.total === sentTo) pass(`3. fan-out 数 (${sentTo}) = User 总数 (${data.total})`); + else fail('3. fan-out 数', new Error(`sent_to=${sentTo} vs admin/users.total=${data.total}`)); + } catch (e) { fail('3. fan-out 数', e); } + + // ── 4. tudou GET /announcement 拿到那条 ── + try { + const r = await fetch(`${API}/api/v1/announcement`, { + headers: { 'Authorization': `Bearer ${tudouTok}` }, + }); + const data = await r.json(); + if (data.enabled && data.announcement.includes('smoke 测试公告')) { + pass('4. tudou 拿到未读公告'); + } else { + fail('4. tudou 未读公告', new Error(`enabled=${data.enabled} content=${data.announcement.slice(0, 40)}`)); + } + } catch (e) { fail('4. tudou GET /announcement', e); } + + // ── 5. tudou 浏览器进任意路由(/app)应自动弹 modal ── + const tudouLogin = await login(page, 'tudou', 'tudoupass123'); + await setStorage(page, { token: tudouLogin.token, refresh: undefined, user: tudouLogin.user }); + await page.goto(`${BASE}/app`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); // 等 GlobalAnnouncementGate fetch + 渲染 + try { + const modalVisible = await page.locator('text=我知道了').isVisible({ timeout: 3000 }); + if (modalVisible) pass('5. tudou 进 /app 自动强弹公告 modal'); + else fail('5. 自动强弹', new Error('未找到"我知道了"按钮 — modal 没弹')); + } catch (e) { fail('5. 自动强弹', e); } + + // ── 6. 关闭 modal → 标已读 → 再开页面不弹 ── + try { + await page.locator('button:has-text("我知道了")').click({ timeout: 3000 }); + await page.waitForTimeout(1500); // 等 POST /announcement/read 完成 + // 验证后端已标读 + const r = await fetch(`${API}/api/v1/announcement`, { + headers: { 'Authorization': `Bearer ${tudouTok}` }, + }); + const data = await r.json(); + if (!data.enabled) pass('6. 关闭 modal 后 GET /announcement 返回 enabled=false (已读)'); + else fail('6. 关闭后未读取', new Error(`期望 enabled=false 实际 ${data.enabled}`)); + + // 再开 /app 不该弹 + await page.goto(`${BASE}/admin/dashboard`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + const stillVisible = await page.locator('button:has-text("我知道了")').isVisible({ timeout: 1500 }).catch(() => false); + if (!stillVisible) pass('6.1 再开页面不再弹 modal'); + else fail('6.1 再次弹出', new Error('已读状态下还在弹')); + } catch (e) { fail('6. 关闭流程', e); } + + // ── 7. 消息中心:看到公告条目 + chip + HTML 渲染 ── + try { + await page.goto(`${BASE}/notifications`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + // 找带 [公告] chip 的行 + const chip = await page.locator('text=/^公告$/').first().isVisible({ timeout: 3000 }); + if (chip) pass('7. 消息中心显示 [公告] chip'); + else fail('7. chip 缺失', new Error('未找到公告 chip')); + // HTML 渲染:smoke 测试公告 文字应可见 + const contentVisible = await page.locator('text=smoke 测试公告').first().isVisible({ timeout: 2000 }); + if (contentVisible) pass('7.1 公告内容 HTML 渲染正常'); + else fail('7.1 HTML 渲染', new Error('看不到公告文字')); + } catch (e) { fail('7. 消息中心', e); } + + // 清场:把测试造的公告标已读,避免污染下一次 smoke + await fetch(`${API}/api/v1/announcement/read`, { + method: 'POST', headers: { 'Authorization': `Bearer ${adminTok}` }, + }); + await fetch(`${API}/api/v1/announcement/read`, { + method: 'POST', headers: { 'Authorization': `Bearer ${tudouTok}` }, + }); + + if (consoleErrors.length > 0) { + fail('console errors', new Error(consoleErrors.slice(0, 3).join(' | '))); + } else { + pass('全程无 console.error'); + } + + console.log('\n────────────── 汇总 ──────────────'); + const passed = results.filter(r => r.ok).length; + const failed = results.filter(r => !r.ok); + console.log(`通过: ${passed} / ${results.length}`); + if (failed.length > 0) { + console.log(`失败 ${failed.length} 项:`); + failed.forEach(r => console.log(` - ${r.name}: ${r.err}`)); + } + + await browser.close(); + process.exit(failed.length === 0 ? 0 : 1); +} + +main().catch((e) => { console.error(e); process.exit(1); });