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:
parent
850acf646e
commit
e55a6665f2
183
docs/todo/通知公告整合-完成报告.md
Normal file
183
docs/todo/通知公告整合-完成报告.md
Normal 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(继承自原实现)|
|
||||
281
docs/todo/通知公告整合.md
Normal file
281
docs/todo/通知公告整合.md
Normal 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 → **要授权**
|
||||
205
web/test/announcement-integration-smoke.mjs
Normal file
205
web/test/announcement-integration-smoke.mjs
Normal 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); });
|
||||
Loading…
x
Reference in New Issue
Block a user