后端 — 新建 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>
115 lines
3.4 KiB
Python
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})
|