之前公告(QuotaConfig.announcement,v0.12.6)与 v0.20.1 消息中心 UX 重叠 —
两个铃铛 + 两套未读 + 两套入口让用户分不清。整合到统一 Notification 表:
后端:
- apps.notifications Notification.TYPE_CHOICES 加 'announcement'
- 新 endpoint POST /api/v1/admin/announcement/publish (IsSuperAdmin)
- body { content: HTML 字符串 }
- 空内容 400 "公告内容不能为空"
- User.objects.all() (含 is_active=False 封禁用户,解封后能看到历史)
- bulk_create(batch_size=500) 防大团队 OOM
- 同步把 content 存档到 QuotaConfig.announcement 作为下次编辑器初始值
- audit log: settings_update, target=announcement
- 重写 GET /announcement 内部查 Notification 表最新未读
- 重写 POST /announcement/read 标记所有未读公告已读
- endpoint 签名不变保持老前端兼容(返回结构相同)
前端:
- App.tsx 顶层挂 <GlobalAnnouncementGate /> — 任意路由有未读公告强弹 modal
必须看(关闭遮罩点击也算关闭→标已读),关闭后 60s 内不再弹
- AnnouncementModal 改成纯展示组件: props { content, onClose },不自己 fetch
HTML 内容用 DOMPurify.sanitize 防 XSS
- 删 VideoGenerationPage 右上角小喇叭 + 旧 AnnouncementModal 自动弹 + 重看路径
(用户重看走 sidebar 大铃铛 → 消息中心)
- SettingsPage 公告区:
- 删 announcement_enabled checkbox(不再有"启用/停用"概念,发了就强弹)
- "保存公告"按钮 → 改 "发送公告" 按钮
- 二次 confirm "确认发送给所有用户?发送后所有人打开页面会强制看到这条公告,无法撤回"
- 调 announcementApi.publish (POST /admin/announcement/publish)
- NotificationsPage 每条加 type chip([公告]/[安全]/[额度]/[系统]) 4 色
announcement type 用 DOMPurify + dangerouslySetInnerHTML 渲染(其他 type 纯文本)
- types/index.ts NotificationType 加 'announcement'
验证:
- 后端 curl 全过:发空 400 / 发 HTML 200 sent_to=21 / tudou GET 拿到未读 / read 后 GET 拿空
- typecheck 0 error
- v0.20.1-smoke 11/11 + modal-interaction 8/8 + v2-smoke 25/25
- vitest 71 fail / 162 pass 与基线 0 新增回归
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
39 lines
1.5 KiB
Python
39 lines
1.5 KiB
Python
from django.db import models
|
|
from django.conf import settings
|
|
|
|
|
|
class Notification(models.Model):
|
|
"""站内通知 — 异常封禁/额度告警/系统消息等。"""
|
|
|
|
TYPE_CHOICES = [
|
|
('anomaly_disabled_user', '账号因异常被自动封禁'),
|
|
('anomaly_disabled_team', '团队因异常被自动封禁'),
|
|
('quota_warning', '额度即将耗尽'),
|
|
('announcement', '系统公告'),
|
|
('system', '系统通知'),
|
|
]
|
|
|
|
recipient = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='notifications',
|
|
verbose_name='接收人',
|
|
)
|
|
type = models.CharField(max_length=30, choices=TYPE_CHOICES, default='system', verbose_name='类型')
|
|
title = models.CharField(max_length=200, blank=True, default='', verbose_name='标题')
|
|
content = models.TextField(blank=True, default='', verbose_name='内容')
|
|
link_url = models.CharField(max_length=500, blank=True, default='', verbose_name='跳转链接')
|
|
is_read = models.BooleanField(default=False, db_index=True, verbose_name='已读')
|
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')
|
|
|
|
class Meta:
|
|
verbose_name = '站内通知'
|
|
verbose_name_plural = '站内通知'
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['recipient', 'is_read', '-created_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f'{self.recipient.username} - {self.title}'
|