seaislee1209 c53144b2ac feat(notification): 站内通知系统 — Notification 模型 + 4 个 API + Sidebar 铃铛 + 通知中心页
后端 — 新建 app apps.notifications:
- Notification model:type/title/content/link_url/is_read,索引 (recipient, is_read, -created_at)
- 4 个 endpoint:
  - GET    /api/v1/notifications/         (列表 + 总未读数,unread_only/page/page_size)
  - GET    /api/v1/notifications/unread-count  (轻量,前端 60s 轮询用)
  - PATCH  /api/v1/notifications/<id>/read     (标单条已读)
  - POST   /api/v1/notifications/read-all      (一键全部已读)
- 严格守 user 隔离:所有查询都 filter(recipient=request.user)
- INSTALLED_APPS 注册 + urls.py include
- migration 0001_initial 应用成功
- MySQL 严格模式:所有 CharField 加 default=''(memory feedback_mysql_default)

后端 — anomaly_detector 集成:
- _RULE_LABELS / _team_admin_recipients() / _notify_user_disabled() / _notify_team_disabled() helper
- process_anomalies 里 _disable_user/_disable_team 之后调对应 notify
- 接收人 = 同团队的主管+副管(is_team_admin OR is_team_owner)
- 用 bulk_create 一次写多条
- try/except 保护:通知失败不阻断封禁主流程

前端:
- types/index.ts:AppNotification / NotificationListResponse(避开浏览器 Web API Notification 冲突)
- lib/api.ts:notificationApi (list/getUnreadCount/markRead/markAllRead)
- store/notification.ts:Zustand store 乐观更新(markRead 先动 UI 再发请求)
- pages/NotificationsPage.tsx:标题 + 全部标记已读按钮 + 未读蓝点 + 相对时间 + 点击跳 link_url + 分页
- App.tsx:/notifications 路由(ProtectedRoute 不限 role)
- Sidebar.tsx(用户 76px):铃铛 SVG + 红点 + 60s 轮询 + visibilitychange 立即刷新
- AdminLayout.tsx(超管 220px):同步加铃铛(本来 sub-agent 只加了用户侧 sidebar,我补全 admin 侧)

测试:
- 新建 web/test/v0.20.1-smoke.mjs:11 项 — 铃铛/红点/跳页/标题/100dvh/min-height:0/调试折叠/poster
- 11/11 通过 + v2-smoke 25/25 + modal-interaction 8/8 全部基线 OK
- 后端 4 endpoint 用 curl 验过:list / unread-count / PATCH read / POST read-all 都正常

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:32:29 +08:00

115 lines
3.4 KiB
Python

import logging
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import Notification
from .serializers import NotificationSerializer
logger = logging.getLogger(__name__)
def _safe_int(value, default=0):
"""安全转 int — 防止前端传非数字字符导致 500。"""
try:
return int(value)
except (TypeError, ValueError):
return default
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def notifications_list_view(request):
"""GET /api/v1/notifications/
Query params:
unread_only: 'true' / 'false' (default 'false')
page: 默认 1
page_size: 默认 20, 上限 100
Response:
{
"total": int, # 当前过滤条件下总条数
"unread_count": int, # 该用户全部未读数(不受 unread_only/分页影响)
"page": int,
"page_size": int,
"results": [...]
}
"""
user = request.user
unread_only_raw = (request.query_params.get('unread_only') or 'false').strip().lower()
unread_only = unread_only_raw in ('true', '1', 'yes')
page = max(_safe_int(request.query_params.get('page'), 1), 1)
page_size = _safe_int(request.query_params.get('page_size'), 20)
if page_size <= 0:
page_size = 20
page_size = min(page_size, 100)
base_qs = Notification.objects.filter(recipient=user)
qs = base_qs
if unread_only:
qs = qs.filter(is_read=False)
total = qs.count()
# unread_count 必须基于该用户全部通知,不受 unread_only/分页影响
unread_count = base_qs.filter(is_read=False).count()
offset = (page - 1) * page_size
records = list(qs.order_by('-created_at')[offset:offset + page_size])
results = NotificationSerializer(records, many=True).data
return Response({
'total': total,
'unread_count': unread_count,
'page': page,
'page_size': page_size,
'results': results,
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def notifications_unread_count_view(request):
"""GET /api/v1/notifications/unread-count
前端 60s 轮询,只拿数字不拉列表。
"""
count = Notification.objects.filter(recipient=request.user, is_read=False).count()
return Response({'unread_count': count})
@api_view(['PATCH'])
@permission_classes([IsAuthenticated])
def notification_mark_read_view(request, notification_id):
"""PATCH /api/v1/notifications/<id>/read
标记某条通知为已读。404 if not found 或不属于当前用户。
"""
try:
notification = Notification.objects.get(pk=notification_id, recipient=request.user)
except Notification.DoesNotExist:
return Response({'error': '通知不存在'}, status=status.HTTP_404_NOT_FOUND)
if not notification.is_read:
notification.is_read = True
notification.save(update_fields=['is_read'])
return Response({'id': notification.id, 'is_read': True})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def notifications_mark_all_read_view(request):
"""POST /api/v1/notifications/read-all
一键已读。返回被标已读的条数。
"""
updated = Notification.objects.filter(
recipient=request.user, is_read=False
).update(is_read=True)
return Response({'updated': updated})