test+docs(notification): announcement-integration-smoke.mjs 10/10 + plan 归档 + 完成报告

新建 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) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-05-15 16:04:43 +08:00
parent 850acf646e
commit e55a6665f2
3 changed files with 669 additions and 0 deletions

View File

@ -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. 任意用户登录 / 打开任意路由 → `<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(继承自原实现)|

View File

@ -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": "<html>...</html>" }
"""
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 顶层挂 `<GlobalAnnouncementGate />` 组件
- 组件内 `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 (
<AnnouncementModal
forceOpen
content={unread.content}
onClose={async () => {
await api.post('/announcement/read');
setUnread(null);
useNotificationStore.getState().fetchUnreadCount();
}}
/>
);
}
```
`AnnouncementModal.tsx` 改成纯展示组件,数据从 prop 传入(`content`),不再自己 fetch。
### 3. 删除 `VideoGenerationPage.tsx` 右上角小喇叭
L150-165 整段 button 删掉,连带 `showAnnouncement` state 和那个 `<AnnouncementModal forceOpen ...>` 重看入口删掉(重看走消息中心)。
### 4. `NotificationsPage.tsx` 每条加 type chip
```tsx
const TYPE_LABEL: Record<string, { text: string; color: string }> = {
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 旁边渲染 <span style={{...chip风格}}>[{TYPE_LABEL[n.type].text}]</span>
```
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` — 挂 `<GlobalAnnouncementGate />`
- `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 → **要授权**

View File

@ -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 = `<p>smoke 测试公告 ${Date.now()} - <b>请忽略</b></p>`;
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); });