# 通知 / 公告整合 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 → **要授权**
