diff --git a/dsp/backend/apps/creator/views.py b/dsp/backend/apps/creator/views.py index 40f4f1b..311afae 100644 --- a/dsp/backend/apps/creator/views.py +++ b/dsp/backend/apps/creator/views.py @@ -11,7 +11,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from apps.videos.models import Video -from core.api import render_data +from core.api import opt_id, render_data from .models import VideoPlayStat @@ -27,7 +27,7 @@ class PlayStatReportView(APIView): async def post(self, request): try: - video_id = int(request.data.get("video_id") or 0) + video_id = opt_id(request.data.get("video_id")) watch_seconds = float(request.data.get("watch_seconds") or 0.0) duration = float(request.data.get("duration") or 0.0) except (TypeError, ValueError): diff --git a/dsp/backend/apps/messages_app/views.py b/dsp/backend/apps/messages_app/views.py index d13e255..028c0b3 100644 --- a/dsp/backend/apps/messages_app/views.py +++ b/dsp/backend/apps/messages_app/views.py @@ -1,247 +1,244 @@ -"""Messages views: system, conversations, messages REST.""" -from __future__ import annotations - -from asgiref.sync import async_to_sync, sync_to_async -from channels.layers import get_channel_layer -from django.db.models import F -from rest_framework import status -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from adrf.views import APIView - -from .models import Conversation, Message, SystemMessage, SystemMessageRead -from .serializers import ( - ConversationSerializer, - MessageCreateSerializer, - MessageSerializer, - SystemMessageSerializer, -) -from core.api import paginate_cursor, parse_page_size, render_data - - -def _channel_layer(): - return get_channel_layer() - - -def _user_key(uid: int) -> str: - return f"user_{uid}" - - -async def _broadcast(conversation_id: int, sender_id: int, receiver_id: int, payload: dict): - layer = _channel_layer() - if layer is None: - return - await layer.group_send( - f"conv_{conversation_id}", - {"type": "chat.message", "payload": payload}, - ) - await layer.group_send( - f"user_{receiver_id}", - {"type": "user.notify", "payload": {"event": "new_message", **payload}}, - ) - - -class SystemMessageListView(APIView): - permission_classes = [IsAuthenticated] - - async def get(self, request): - from django.db.models import Q - - page_size = parse_page_size(request.query_params.get("page_size")) - qs = SystemMessage.objects.filter( - Q(user__isnull=True) | Q(user=request.user) - ).order_by("-id") - rows, next_cursor = await paginate_cursor( - qs, request.query_params.get("cursor"), page_size - ) - return Response( - { - "next_cursor": next_cursor, - "results": await render_data(SystemMessageSerializer(rows, many=True)), - } - ) - - async def post(self, request): - """Mark messages as read. - - Only the caller's own targeted messages may be touched; a broadcast row - (user=null) is shared by every account, so marking it read here would - clear it for everyone. Those are marked per-user in the read table. - """ - from django.db.models import Q - - ids = request.data.get("ids") or [] - if not isinstance(ids, list): - return Response({"detail": "ids must be list"}, status=400) - if not ids: - return Response({"status": "ok", "updated_count": 0}) - - own = SystemMessage.objects.filter(pk__in=ids, user=request.user) - updated = await own.aupdate(is_read=True) - - broadcast_ids = [ - sid - async for sid in SystemMessage.objects.filter( - Q(pk__in=ids) & Q(user__isnull=True) - ).values_list("id", flat=True) - ] - for sid in broadcast_ids: - await SystemMessageRead.objects.aget_or_create( - user=request.user, message_id=sid - ) - return Response({"status": "ok", "updated_count": updated}) - - -class UnreadCountView(APIView): - permission_classes = [IsAuthenticated] - - async def get(self, request): - from django.db.models import Q - - read_ids = [ - mid - async for mid in SystemMessageRead.objects.filter(user=request.user).values_list( - "message_id", flat=True - ) - ] - sys_unread = ( - await SystemMessage.objects.filter(Q(user__isnull=True) | Q(user=request.user), is_read=False) - .exclude(pk__in=read_ids) - .acount() - ) - dm_unread = await Message.objects.filter(receiver=request.user, is_read=False).acount() - return Response({"system_unread": sys_unread, "dm_unread": dm_unread}) - - -class ConversationListCreateView(APIView): - permission_classes = [IsAuthenticated] - - async def get(self, request): - from django.db.models import Q - - qs = ( - Conversation.objects.filter(Q(user_a=request.user) | Q(user_b=request.user)) - .select_related("user_a", "user_b") - .order_by(F("last_message_at").desc(nulls_last=True), "-id") - ) - page_size = parse_page_size(request.query_params.get("page_size")) - rows, next_cursor = await paginate_cursor( - qs, request.query_params.get("cursor"), page_size - ) - return Response( - { - "next_cursor": next_cursor, - "results": await render_data( - ConversationSerializer(rows, many=True, context={"request": request}) - ), - } - ) - - async def post(self, request): - try: - peer_id = int(request.data.get("peer_id") or 0) - except (TypeError, ValueError): - return Response({"detail": "invalid peer"}, status=400) - if not peer_id or peer_id == request.user.id: - return Response({"detail": "invalid peer"}, status=400) - a, b = sorted([request.user.id, peer_id]) - from apps.accounts.models import User - - me = ( - await Conversation.objects.filter(user_a_id=a, user_b_id=b) - .select_related("user_a", "user_b") - .afirst() - ) - if me: - return Response( - await render_data(ConversationSerializer(me, context={"request": request})) - ) - try: - peer = await User.objects.aget(pk=peer_id) - except User.DoesNotExist: - return Response({"detail": "peer not found"}, status=404) - - @sync_to_async(thread_sensitive=True) - def _open(): - # 并发下同一对用户可能同时创建;唯一约束冲突时取已存在那条。 - from django.db import IntegrityError, transaction - - conv = Conversation() - if a == request.user.id: - conv.user_a = request.user - conv.user_b = peer - else: - conv.user_a = peer - conv.user_b = request.user - try: - with transaction.atomic(): - conv.save() - return conv, True - except IntegrityError: - return Conversation.objects.get(user_a_id=a, user_b_id=b), False - - conv, created = await _open() - return Response( - await render_data(ConversationSerializer(conv, context={"request": request})), - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, - ) - - -class ConversationMessagesView(APIView): - permission_classes = [IsAuthenticated] - - async def get(self, request, conv_id: int): - from django.db.models import Q - - try: - conv = await Conversation.objects.aget(pk=conv_id) - except Conversation.DoesNotExist: - return Response({"detail": "not found"}, status=404) - if conv.user_a_id != request.user.id and conv.user_b_id != request.user.id: - return Response({"detail": "forbidden"}, status=403) - - cursor = request.query_params.get("cursor") - page_size = parse_page_size(request.query_params.get("page_size"), default=30) - qs = Message.objects.filter(conversation_id=conv_id).order_by("-id") - rows, next_cursor = await paginate_cursor( - qs, request.query_params.get("cursor"), page_size - ) - # mark received as read - await Message.objects.filter( - conversation_id=conv_id, receiver=request.user, is_read=False - ).aupdate(is_read=True) - return Response( - { - "next_cursor": next_cursor, - "results": MessageSerializer(list(reversed(rows)), many=True).data, - } - ) - - async def post(self, request, conv_id: int): - s = MessageCreateSerializer(data=request.data) - s.is_valid(raise_exception=True) - d = s.validated_data - try: - conv = await Conversation.objects.aget(pk=conv_id) - except Conversation.DoesNotExist: - return Response({"detail": "not found"}, status=404) - if conv.user_a_id != request.user.id and conv.user_b_id != request.user.id: - return Response({"detail": "forbidden"}, status=403) - receiver_id = conv.user_b_id if conv.user_a_id == request.user.id else conv.user_a_id - - msg = await Message.objects.acreate( - conversation=conv, - sender=request.user, - receiver_id=receiver_id, - content_type=d["content_type"], - content=d["content"], - ) - from django.utils import timezone - - await Conversation.objects.filter(pk=conv.id).aupdate( - last_message=d["content"][:200], - last_message_at=timezone.now(), - ) - payload = MessageSerializer(msg).data - await _broadcast(conv.id, request.user.id, receiver_id, payload) - return Response(payload, status=status.HTTP_201_CREATED) +"""Messages views: system, conversations, messages REST.""" +from __future__ import annotations + +from asgiref.sync import async_to_sync, sync_to_async +from channels.layers import get_channel_layer +from django.db.models import F +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from adrf.views import APIView + +from .models import Conversation, Message, SystemMessage, SystemMessageRead +from .serializers import ( + ConversationSerializer, + MessageCreateSerializer, + MessageSerializer, + SystemMessageSerializer, +) +from core.api import opt_id, paginate_cursor, parse_page_size, render_data + + +def _channel_layer(): + return get_channel_layer() + + +def _user_key(uid: int) -> str: + return f"user_{uid}" + + +async def _broadcast(conversation_id: int, sender_id: int, receiver_id: int, payload: dict): + layer = _channel_layer() + if layer is None: + return + await layer.group_send( + f"conv_{conversation_id}", + {"type": "chat.message", "payload": payload}, + ) + await layer.group_send( + f"user_{receiver_id}", + {"type": "user.notify", "payload": {"event": "new_message", **payload}}, + ) + + +class SystemMessageListView(APIView): + permission_classes = [IsAuthenticated] + + async def get(self, request): + from django.db.models import Q + + page_size = parse_page_size(request.query_params.get("page_size")) + qs = SystemMessage.objects.filter( + Q(user__isnull=True) | Q(user=request.user) + ).order_by("-id") + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) + return Response( + { + "next_cursor": next_cursor, + "results": await render_data(SystemMessageSerializer(rows, many=True)), + } + ) + + async def post(self, request): + """Mark messages as read. + + Only the caller's own targeted messages may be touched; a broadcast row + (user=null) is shared by every account, so marking it read here would + clear it for everyone. Those are marked per-user in the read table. + """ + from django.db.models import Q + + ids = request.data.get("ids") or [] + if not isinstance(ids, list): + return Response({"detail": "ids must be list"}, status=400) + if not ids: + return Response({"status": "ok", "updated_count": 0}) + + own = SystemMessage.objects.filter(pk__in=ids, user=request.user) + updated = await own.aupdate(is_read=True) + + broadcast_ids = [ + sid + async for sid in SystemMessage.objects.filter( + Q(pk__in=ids) & Q(user__isnull=True) + ).values_list("id", flat=True) + ] + for sid in broadcast_ids: + await SystemMessageRead.objects.aget_or_create( + user=request.user, message_id=sid + ) + return Response({"status": "ok", "updated_count": updated}) + + +class UnreadCountView(APIView): + permission_classes = [IsAuthenticated] + + async def get(self, request): + from django.db.models import Q + + read_ids = [ + mid + async for mid in SystemMessageRead.objects.filter(user=request.user).values_list( + "message_id", flat=True + ) + ] + sys_unread = ( + await SystemMessage.objects.filter(Q(user__isnull=True) | Q(user=request.user), is_read=False) + .exclude(pk__in=read_ids) + .acount() + ) + dm_unread = await Message.objects.filter(receiver=request.user, is_read=False).acount() + return Response({"system_unread": sys_unread, "dm_unread": dm_unread}) + + +class ConversationListCreateView(APIView): + permission_classes = [IsAuthenticated] + + async def get(self, request): + from django.db.models import Q + + qs = ( + Conversation.objects.filter(Q(user_a=request.user) | Q(user_b=request.user)) + .select_related("user_a", "user_b") + .order_by(F("last_message_at").desc(nulls_last=True), "-id") + ) + page_size = parse_page_size(request.query_params.get("page_size")) + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) + return Response( + { + "next_cursor": next_cursor, + "results": await render_data( + ConversationSerializer(rows, many=True, context={"request": request}) + ), + } + ) + + async def post(self, request): + peer_id = opt_id(request.data.get("peer_id")) + if not peer_id or peer_id == request.user.id: + return Response({"detail": "invalid peer"}, status=400) + a, b = sorted([request.user.id, peer_id]) + from apps.accounts.models import User + + me = ( + await Conversation.objects.filter(user_a_id=a, user_b_id=b) + .select_related("user_a", "user_b") + .afirst() + ) + if me: + return Response( + await render_data(ConversationSerializer(me, context={"request": request})) + ) + try: + peer = await User.objects.aget(pk=peer_id) + except User.DoesNotExist: + return Response({"detail": "peer not found"}, status=404) + + @sync_to_async(thread_sensitive=True) + def _open(): + # 并发下同一对用户可能同时创建;唯一约束冲突时取已存在那条。 + from django.db import IntegrityError, transaction + + conv = Conversation() + if a == request.user.id: + conv.user_a = request.user + conv.user_b = peer + else: + conv.user_a = peer + conv.user_b = request.user + try: + with transaction.atomic(): + conv.save() + return conv, True + except IntegrityError: + return Conversation.objects.get(user_a_id=a, user_b_id=b), False + + conv, created = await _open() + return Response( + await render_data(ConversationSerializer(conv, context={"request": request})), + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + +class ConversationMessagesView(APIView): + permission_classes = [IsAuthenticated] + + async def get(self, request, conv_id: int): + from django.db.models import Q + + try: + conv = await Conversation.objects.aget(pk=conv_id) + except Conversation.DoesNotExist: + return Response({"detail": "not found"}, status=404) + if conv.user_a_id != request.user.id and conv.user_b_id != request.user.id: + return Response({"detail": "forbidden"}, status=403) + + cursor = request.query_params.get("cursor") + page_size = parse_page_size(request.query_params.get("page_size"), default=30) + qs = Message.objects.filter(conversation_id=conv_id).order_by("-id") + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) + # mark received as read + await Message.objects.filter( + conversation_id=conv_id, receiver=request.user, is_read=False + ).aupdate(is_read=True) + return Response( + { + "next_cursor": next_cursor, + "results": MessageSerializer(list(reversed(rows)), many=True).data, + } + ) + + async def post(self, request, conv_id: int): + s = MessageCreateSerializer(data=request.data) + s.is_valid(raise_exception=True) + d = s.validated_data + try: + conv = await Conversation.objects.aget(pk=conv_id) + except Conversation.DoesNotExist: + return Response({"detail": "not found"}, status=404) + if conv.user_a_id != request.user.id and conv.user_b_id != request.user.id: + return Response({"detail": "forbidden"}, status=403) + receiver_id = conv.user_b_id if conv.user_a_id == request.user.id else conv.user_a_id + + msg = await Message.objects.acreate( + conversation=conv, + sender=request.user, + receiver_id=receiver_id, + content_type=d["content_type"], + content=d["content"], + ) + from django.utils import timezone + + await Conversation.objects.filter(pk=conv.id).aupdate( + last_message=d["content"][:200], + last_message_at=timezone.now(), + ) + payload = MessageSerializer(msg).data + await _broadcast(conv.id, request.user.id, receiver_id, payload) + return Response(payload, status=status.HTTP_201_CREATED) diff --git a/dsp/backend/apps/moderation/views.py b/dsp/backend/apps/moderation/views.py index 3a06d4f..4b7e3f4 100644 --- a/dsp/backend/apps/moderation/views.py +++ b/dsp/backend/apps/moderation/views.py @@ -8,7 +8,7 @@ from rest_framework import status from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated from rest_framework.response import Response -from core.api import render_data +from core.api import opt_id, render_data from .models import Report, SensitiveWord from .serializers import ReportCreateSerializer, ReportSerializer, SensitiveWordSerializer @@ -122,7 +122,7 @@ class AdminAuditView(APIView): permission_classes = [IsAdminUser] async def post(self, request): - report_id = int(request.data.get("report_id") or 0) + report_id = opt_id(request.data.get("report_id")) action = request.data.get("action") # "hide", "delete", "reject" note = request.data.get("note", "") try: diff --git a/dsp/backend/apps/music/views.py b/dsp/backend/apps/music/views.py index 81798c0..9b11138 100644 --- a/dsp/backend/apps/music/views.py +++ b/dsp/backend/apps/music/views.py @@ -1,70 +1,70 @@ -"""Music views (async).""" -from __future__ import annotations - -from adrf.views import APIView -from django.db.models import Q -from rest_framework.permissions import AllowAny -from rest_framework.response import Response - -from apps.videos.models import Video -from apps.videos.serializers import VideoListSerializer -from core.api import paginate_cursor, parse_page_size, render_data - -from .models import Music -from .serializers import MusicSerializer - - -class MusicListView(APIView): - permission_classes = [AllowAny] - - async def get(self, request): - q = (request.query_params.get("q") or "").strip() - page_size = min(max(int(request.query_params.get("page_size") or 20), 1), 50) - qs = Music.objects.all().order_by("-use_count", "-id") - if q: - qs = qs.filter(Q(title__icontains=q) | Q(artist__icontains=q)) - rows = [m async for m in qs[:page_size]] - data = await render_data(MusicSerializer(rows, many=True, context={"request": request})) - return Response({"results": data}) - - -class MusicDetailView(APIView): - permission_classes = [AllowAny] - - async def get(self, request, music_id: int): - try: - m = await Music.objects.select_related("original_author").aget(pk=music_id) - except Music.DoesNotExist: - return Response({"detail": "not found"}, status=404) - data = await render_data(MusicSerializer(m, context={"request": request})) - return Response(data) - - -class MusicVideosListView(APIView): - """Aggregate all videos using this same music / soundtrack.""" - - permission_classes = [AllowAny] - - async def get(self, request, music_id: int): - try: - m = await Music.objects.aget(pk=music_id) - except Music.DoesNotExist: - return Response({"detail": "music not found"}, status=404) - - page_size = parse_page_size(request.query_params.get("page_size")) - qs = ( - Video.objects.filter(music=m, status="published") - .select_related("author", "music") - .prefetch_related("tags") - # 排序键与游标键保持同序(hot_score 恒为 0,真正生效的是 id) - .order_by("-id") - ) - rows, next_cursor = await paginate_cursor( - qs, request.query_params.get("cursor"), page_size - ) - data = await render_data(VideoListSerializer(rows, many=True, context={"request": request})) - return Response({ - "music": await render_data(MusicSerializer(m, context={"request": request})), - "next_cursor": next_cursor, - "results": data, - }) +"""Music views (async).""" +from __future__ import annotations + +from adrf.views import APIView +from django.db.models import Q +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from apps.videos.models import Video +from apps.videos.serializers import VideoListSerializer +from core.api import paginate_cursor, parse_page_size, render_data + +from .models import Music +from .serializers import MusicSerializer + + +class MusicListView(APIView): + permission_classes = [AllowAny] + + async def get(self, request): + q = (request.query_params.get("q") or "").strip() + page_size = parse_page_size(request.query_params.get("page_size")) + qs = Music.objects.all().order_by("-use_count", "-id") + if q: + qs = qs.filter(Q(title__icontains=q) | Q(artist__icontains=q)) + rows = [m async for m in qs[:page_size]] + data = await render_data(MusicSerializer(rows, many=True, context={"request": request})) + return Response({"results": data}) + + +class MusicDetailView(APIView): + permission_classes = [AllowAny] + + async def get(self, request, music_id: int): + try: + m = await Music.objects.select_related("original_author").aget(pk=music_id) + except Music.DoesNotExist: + return Response({"detail": "not found"}, status=404) + data = await render_data(MusicSerializer(m, context={"request": request})) + return Response(data) + + +class MusicVideosListView(APIView): + """Aggregate all videos using this same music / soundtrack.""" + + permission_classes = [AllowAny] + + async def get(self, request, music_id: int): + try: + m = await Music.objects.aget(pk=music_id) + except Music.DoesNotExist: + return Response({"detail": "music not found"}, status=404) + + page_size = parse_page_size(request.query_params.get("page_size")) + qs = ( + Video.objects.filter(music=m, status="published") + .select_related("author", "music") + .prefetch_related("tags") + # 排序键与游标键保持同序(hot_score 恒为 0,真正生效的是 id) + .order_by("-id") + ) + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) + data = await render_data(VideoListSerializer(rows, many=True, context={"request": request})) + return Response({ + "music": await render_data(MusicSerializer(m, context={"request": request})), + "next_cursor": next_cursor, + "results": data, + }) diff --git a/dsp/backend/apps/visitors/views.py b/dsp/backend/apps/visitors/views.py index a4ccaa5..1b40ff1 100644 --- a/dsp/backend/apps/visitors/views.py +++ b/dsp/backend/apps/visitors/views.py @@ -1,59 +1,59 @@ -"""Visitor views.""" -from __future__ import annotations - -from adrf.views import APIView -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response - -from core.api import paginate_cursor, parse_page_size, render_data - -from .models import Visit -from .serializers import VisitSerializer - - -class VisitView(APIView): - """Record a visit and list recent visits received by the current user.""" - - permission_classes = [IsAuthenticated] - - async def post(self, request): - owner_id = int(request.data.get("owner_id") or 0) - if not owner_id or owner_id == request.user.id: - return Response({"detail": "invalid owner"}, status=400) - video_id = request.data.get("video_id") - source = request.data.get("source", "profile")[:32] - await Visit.objects.acreate( - visitor=request.user, - owner_id=owner_id, - video_id=video_id, - source=source, - ) - return Response({"status": "recorded"}) - - async def get(self, request): - cursor = request.query_params.get("cursor") - page_size = parse_page_size(request.query_params.get("page_size"), default=20) - qs = ( - Visit.objects.filter(owner=request.user) - .select_related("visitor", "video") - .order_by("-visited_at", "-id") - ) - rows, next_cursor = await paginate_cursor( - qs, request.query_params.get("cursor"), page_size - ) - return Response( - { - "next_cursor": next_cursor, - "results": await render_data(VisitSerializer(rows, many=True)), - } - ) - - -class MyVisitsView(APIView): - """Visits made BY the current user.""" - - permission_classes = [IsAuthenticated] - - async def get(self, request): - rows = [v async for v in Visit.objects.filter(visitor=request.user).order_by("-visited_at")[:50]] - return Response(await render_data(VisitSerializer(rows, many=True))) +"""Visitor views.""" +from __future__ import annotations + +from adrf.views import APIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from core.api import opt_id, paginate_cursor, parse_page_size, render_data + +from .models import Visit +from .serializers import VisitSerializer + + +class VisitView(APIView): + """Record a visit and list recent visits received by the current user.""" + + permission_classes = [IsAuthenticated] + + async def post(self, request): + owner_id = opt_id(request.data.get("owner_id")) + if not owner_id or owner_id == request.user.id: + return Response({"detail": "invalid owner"}, status=400) + video_id = opt_id(request.data.get("video_id")) + source = request.data.get("source", "profile")[:32] + await Visit.objects.acreate( + visitor=request.user, + owner_id=owner_id, + video_id=video_id or None, + source=source, + ) + return Response({"status": "recorded"}) + + async def get(self, request): + cursor = request.query_params.get("cursor") + page_size = parse_page_size(request.query_params.get("page_size"), default=20) + qs = ( + Visit.objects.filter(owner=request.user) + .select_related("visitor", "video") + .order_by("-visited_at", "-id") + ) + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) + return Response( + { + "next_cursor": next_cursor, + "results": await render_data(VisitSerializer(rows, many=True)), + } + ) + + +class MyVisitsView(APIView): + """Visits made BY the current user.""" + + permission_classes = [IsAuthenticated] + + async def get(self, request): + rows = [v async for v in Visit.objects.filter(visitor=request.user).order_by("-visited_at")[:50]] + return Response(await render_data(VisitSerializer(rows, many=True))) diff --git a/dsp/backend/core/api.py b/dsp/backend/core/api.py index 29b3a72..32cd1e0 100644 --- a/dsp/backend/core/api.py +++ b/dsp/backend/core/api.py @@ -31,6 +31,18 @@ def parse_page_size(raw: Any, default: int = DEFAULT_PAGE_SIZE, maximum: int = M return default +def opt_id(raw: Any, default: int = 0) -> int: + """归一化外部 id:非正整数一律回落 default,绝不抛 ValueError 进 500。 + + S-01 反模式清扫:外部 id 必须经此函数再进 ORM,禁止 int() 裸调用。 + """ + try: + v = int(raw) + except (TypeError, ValueError): + return default + return v if v > 0 else default + + async def paginate_cursor( qs, cursor: Any,