diff --git a/gvsds/apps/community/manager.py b/gvsds/apps/community/manager.py new file mode 100644 index 00000000..cadafac6 --- /dev/null +++ b/gvsds/apps/community/manager.py @@ -0,0 +1,150 @@ +"""模块:Community 社区数据访问层 + +官网社区(discuss.gvsds.com)的板块/分类/帖子/回复/点赞数据访问收敛在此, +视图层不直接操作 ORM。所有方法遵循帕斯卡命名法。 +""" + +import uuid + +from django.db.models import Count, F + +from .models import ( + CommunityBoard, + CommunityCategory, + CommunityLike, + CommunityPost, + CommunityReply, +) + + +class CommunityManager: + """社区管理器""" + + # ========== 板块 / 分类 ========== + + @staticmethod + def ListActiveBoards(): + """启用板块列表""" + return list(CommunityBoard.objects.filter(BoardStatus=1)) + + @staticmethod + def CountPostsByBoards(BoardUUIDs): + """各板块的已发布帖子数 {板块bytes: 帖子数}(BoardUUIDs 为空返回 {})""" + if not BoardUUIDs: + return {} + return { + BoardUUID: PostCount + for BoardUUID, PostCount in CommunityPost.objects.filter( + PostStatus=1, BoardUUID__in=BoardUUIDs, + ).values_list('BoardUUID').annotate(c=Count('PostUUID')) + } + + @staticmethod + def ListActiveCategories(): + """启用分类列表""" + return list(CommunityCategory.objects.filter(CategoryStatus=1)) + + @staticmethod + def GetBoard(BoardUUID): + """按 UUID 取板块(不存在返回 None)""" + return CommunityBoard.objects.filter(BoardUUID=BoardUUID).first() + + @staticmethod + def GetActiveBoard(BoardUUID): + """按 UUID 取启用板块(不存在或已停用返回 None)""" + return CommunityBoard.objects.filter(BoardUUID=BoardUUID, BoardStatus=1).first() + + @staticmethod + def GetCategory(CategoryUUID): + """按 UUID 取分类(UUID 为空返回 None)""" + if not CategoryUUID: + return None + return CommunityCategory.objects.filter(CategoryUUID=CategoryUUID).first() + + # ========== 帖子 ========== + + @staticmethod + def QueryPosts(Filters=None): + """已发布帖子查询集 + + Filters 为附加过滤条件(键为 ORM lookup、值为已转换好的 bytes/str), + 由调用方按「参数存在才过滤」的语义构造;为空表示仅限已发布。 + """ + return CommunityPost.objects.filter(PostStatus=1, **(Filters or {})) + + @staticmethod + def GetPost(PostUUID, OnlyPublished=False): + """按 UUID 取帖子(OnlyPublished=True 时排除已删除;不存在返回 None)""" + qs = CommunityPost.objects.filter(PostUUID=PostUUID) + if OnlyPublished: + qs = qs.filter(PostStatus=1) + return qs.first() + + @staticmethod + def CreatePost(BoardUUID, Title, Content, AuthorUUID, AuthorName, CategoryUUID=None): + """发帖""" + return CommunityPost.objects.create( + PostUUID=uuid.uuid4().bytes, + BoardUUID=BoardUUID, + CategoryUUID=CategoryUUID, + Title=Title, + Content=Content, + AuthorUUID=AuthorUUID, + AuthorName=AuthorName, + ) + + @staticmethod + def IncreaseViewCount(PostUUID): + """浏览量 +1(DB 侧原子自增)""" + CommunityPost.objects.filter(PostUUID=PostUUID).update(ViewCount=F('ViewCount') + 1) + + @staticmethod + def IncreaseReplyCount(PostUUID): + """回复数 +1(DB 侧原子自增)""" + CommunityPost.objects.filter(PostUUID=PostUUID).update(ReplyCount=F('ReplyCount') + 1) + + # ========== 回复 ========== + + @staticmethod + def ListReplies(PostUUID): + """帖子下未删除回复列表""" + return CommunityReply.objects.filter(PostUUID=PostUUID, ReplyStatus=1) + + @staticmethod + def CreateReply(PostUUID, AuthorUUID, AuthorName, Content, ReplyToUUID=None): + """新增回复""" + return CommunityReply.objects.create( + ReplyUUID=uuid.uuid4().bytes, + PostUUID=PostUUID, + AuthorUUID=AuthorUUID, + AuthorName=AuthorName, + Content=Content, + ReplyToUUID=ReplyToUUID, + ) + + # ========== 点赞 ========== + + @staticmethod + def HasLiked(PostUUID, AuthorUUID): + """判断用户是否已点赞该帖子""" + return CommunityLike.objects.filter(PostUUID=PostUUID, AuthorUUID=AuthorUUID).exists() + + @staticmethod + def ToggleLike(PostUUID, AuthorUUID): + """切换点赞状态并同步帖子点赞数 + + Returns: + True 表示本次为点赞,False 表示本次为取消点赞 + """ + existing = CommunityLike.objects.filter(PostUUID=PostUUID, AuthorUUID=AuthorUUID).first() + if existing is not None: + existing.delete() + CommunityPost.objects.filter(PostUUID=PostUUID).update(LikeCount=F('LikeCount') - 1) + return False + CommunityLike.objects.create( + LikeUUID=uuid.uuid4().bytes, + PostUUID=PostUUID, + AuthorUUID=AuthorUUID, + ) + CommunityPost.objects.filter(PostUUID=PostUUID).update(LikeCount=F('LikeCount') + 1) + return True diff --git a/gvsds/apps/community/views.py b/gvsds/apps/community/views.py index 5464fc45..50aa3c0d 100644 --- a/gvsds/apps/community/views.py +++ b/gvsds/apps/community/views.py @@ -9,22 +9,14 @@ POST /API/Community/Like — 点赞/取消(登录) """ import datetime -import uuid -from django.db.models import Count, F from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.response import Response from rest_framework.views import APIView from gvsdsdk.model_utils import UUIDObj -from .models import ( - CommunityBoard, - CommunityCategory, - CommunityPost, - CommunityReply, - CommunityLike, -) +from .manager import CommunityManager def _ToBytes(value): @@ -88,14 +80,11 @@ class CommunityBoardsView(APIView): permission_classes = [AllowAny] def get(self, request): - boards = list(CommunityBoard.objects.filter(BoardStatus=1)) - post_counts = { - _ToBytes(b): c - for b, c in CommunityPost.objects.filter( - PostStatus=1, BoardUUID__in=[_ToBytes(x.BoardUUID) for x in boards], - ).values_list('BoardUUID').annotate(c=Count('PostUUID')) - } if boards else {} - categories = list(CommunityCategory.objects.filter(CategoryStatus=1)) + boards = CommunityManager.ListActiveBoards() + post_counts = CommunityManager.CountPostsByBoards( + [_ToBytes(x.BoardUUID) for x in boards], + ) + categories = CommunityManager.ListActiveCategories() cat_map = {} for cat in categories: cat_map.setdefault(_ToBytes(cat.BoardUUID), []).append({ @@ -126,13 +115,14 @@ class CommunityPostsView(APIView): page_size = min(50, max(1, int(request.query_params.get('PageSize') or 20))) except (TypeError, ValueError): page, page_size = 1, 20 - qs = CommunityPost.objects.filter(PostStatus=1) + filters = {} if board_str: - qs = qs.filter(BoardUUID=_ToBytes(board_str)) + filters['BoardUUID'] = _ToBytes(board_str) if category_str: - qs = qs.filter(CategoryUUID=_ToBytes(category_str)) + filters['CategoryUUID'] = _ToBytes(category_str) if keyword: - qs = qs.filter(Title__icontains=keyword) + filters['Title__icontains'] = keyword + qs = CommunityManager.QueryPosts(filters) total = qs.count() rows = [] for p in qs.order_by('-IsPinned', '-CreateTime')[(page - 1) * page_size: page * page_size]: @@ -158,18 +148,17 @@ class CommunityPostDetailView(APIView): permission_classes = [AllowAny] def get(self, request, post_uuid): - post = CommunityPost.objects.filter(PostUUID=_ToBytes(post_uuid), PostStatus=1).first() + post = CommunityManager.GetPost(_ToBytes(post_uuid), OnlyPublished=True) if post is None: return Response({'code': 1, 'msg': '帖子不存在或已删除'}, status=404) - CommunityPost.objects.filter(PostUUID=post.PostUUID).update(ViewCount=F('ViewCount') + 1) + CommunityManager.IncreaseViewCount(post.PostUUID) post.ViewCount += 1 liked = 0 user_bytes = _UserBytes(request.user) if user_bytes: - liked = 1 if CommunityLike.objects.filter( - PostUUID=post.PostUUID, AuthorUUID=user_bytes).exists() else 0 + liked = 1 if CommunityManager.HasLiked(post.PostUUID, user_bytes) else 0 replies = [] - for r in CommunityReply.objects.filter(PostUUID=post.PostUUID, ReplyStatus=1): + for r in CommunityManager.ListReplies(post.PostUUID): replies.append({ 'ReplyUUID': UUIDObj(r.ReplyUUID).str, 'AuthorName': r.AuthorName, @@ -178,9 +167,8 @@ class CommunityPostDetailView(APIView): 'ReplyToUUID': UUIDObj(r.ReplyToUUID).str if r.ReplyToUUID else '', 'CreateTime': _TimeStr(r.CreateTime), }) - board = CommunityBoard.objects.filter(BoardUUID=post.BoardUUID).first() - category = CommunityCategory.objects.filter(CategoryUUID=post.CategoryUUID).first() \ - if post.CategoryUUID else None + board = CommunityManager.GetBoard(post.BoardUUID) + category = CommunityManager.GetCategory(post.CategoryUUID) data = { 'PostUUID': UUIDObj(post.PostUUID).str, 'BoardUUID': UUIDObj(post.BoardUUID).str, @@ -220,21 +208,20 @@ class CommunityPostCreateView(APIView): return Response({'code': 1, 'msg': '请填写标题'}, status=400) if not content: return Response({'code': 1, 'msg': '请填写内容'}, status=400) - board = CommunityBoard.objects.filter(BoardUUID=_ToBytes(board_str), BoardStatus=1).first() + board = CommunityManager.GetActiveBoard(_ToBytes(board_str)) if board is None: return Response({'code': 1, 'msg': '板块不存在或已停用'}, status=400) category_bytes = None category_str = (request.data.get('CategoryUUID') or '').strip() if category_str: category_bytes = _ToBytes(category_str) - post = CommunityPost.objects.create( - PostUUID=uuid.uuid4().bytes, + post = CommunityManager.CreatePost( BoardUUID=_ToBytes(board_str), - CategoryUUID=category_bytes, Title=title, Content=content, AuthorUUID=user_bytes, AuthorName=_UserName(request.user), + CategoryUUID=category_bytes, ) return Response({'code': 0, 'msg': '发布成功', 'data': {'PostUUID': UUIDObj(post.PostUUID).str}}) @@ -254,7 +241,7 @@ class CommunityReplyCreateView(APIView): return Response({'code': 1, 'msg': '缺少帖子 UUID'}, status=400) if not content: return Response({'code': 1, 'msg': '请填写回复内容'}, status=400) - post = CommunityPost.objects.filter(PostUUID=_ToBytes(post_str), PostStatus=1).first() + post = CommunityManager.GetPost(_ToBytes(post_str), OnlyPublished=True) if post is None: return Response({'code': 1, 'msg': '帖子不存在或已删除'}, status=404) if post.IsLocked: @@ -263,15 +250,14 @@ class CommunityReplyCreateView(APIView): reply_to_str = (request.data.get('ReplyToUUID') or '').strip() if reply_to_str: reply_to_bytes = _ToBytes(reply_to_str) - CommunityReply.objects.create( - ReplyUUID=uuid.uuid4().bytes, + CommunityManager.CreateReply( PostUUID=post.PostUUID, AuthorUUID=user_bytes, AuthorName=_UserName(request.user), Content=content, ReplyToUUID=reply_to_bytes, ) - CommunityPost.objects.filter(PostUUID=post.PostUUID).update(ReplyCount=F('ReplyCount') + 1) + CommunityManager.IncreaseReplyCount(post.PostUUID) return Response({'code': 0, 'msg': '回复成功'}) @@ -287,20 +273,10 @@ class CommunityLikeView(APIView): post_str = (request.data.get('PostUUID') or '').strip() if not post_str: return Response({'code': 1, 'msg': '缺少帖子 UUID'}, status=400) - post = CommunityPost.objects.filter(PostUUID=_ToBytes(post_str), PostStatus=1).first() + post = CommunityManager.GetPost(_ToBytes(post_str), OnlyPublished=True) if post is None: return Response({'code': 1, 'msg': '帖子不存在或已删除'}, status=404) - liked = CommunityLike.objects.filter(PostUUID=post.PostUUID, AuthorUUID=user_bytes).first() - if liked: - liked.delete() - CommunityPost.objects.filter(PostUUID=post.PostUUID).update( - LikeCount=F('LikeCount') - 1) + liked = CommunityManager.ToggleLike(post.PostUUID, user_bytes) + if not liked: return Response({'code': 0, 'msg': '已取消点赞', 'data': {'Liked': 0}}) - CommunityLike.objects.create( - LikeUUID=uuid.uuid4().bytes, - PostUUID=post.PostUUID, - AuthorUUID=user_bytes, - ) - CommunityPost.objects.filter(PostUUID=post.PostUUID).update( - LikeCount=F('LikeCount') + 1) return Response({'code': 0, 'msg': '点赞成功', 'data': {'Liked': 1}}) diff --git a/gvsds/apps/doc/manager.py b/gvsds/apps/doc/manager.py index c8d6d628..d645b2ea 100644 --- a/gvsds/apps/doc/manager.py +++ b/gvsds/apps/doc/manager.py @@ -137,6 +137,26 @@ class DocManager: serie.delete() return True, None + # ========== 查询集(供视图 get_queryset 复用) ========== + + @staticmethod + def QueryDocuments(Keyword='', DocSerieUUID=None): + """文档查询集——按标题模糊 / 所属系列过滤(空值表示不过滤)""" + qs = CommunityDocument.objects.all() + if Keyword: + qs = qs.filter(DocTitle__icontains=Keyword) + if DocSerieUUID: + qs = qs.filter(DocSerieUUID=UUIDObj(DocSerieUUID).bytes) + return qs + + @staticmethod + def QuerySeries(Keyword=''): + """系列查询集——按名称模糊过滤""" + qs = CommunitySeries.objects.all() + if Keyword: + qs = qs.filter(DocSerieName__icontains=Keyword) + return qs + # ========== 系列附加查询(供视图收敛裸 ORM) ========== @staticmethod diff --git a/gvsds/apps/doc/views.py b/gvsds/apps/doc/views.py index 58318487..b540b50f 100644 --- a/gvsds/apps/doc/views.py +++ b/gvsds/apps/doc/views.py @@ -23,16 +23,10 @@ class CommunityDocumentViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] def get_queryset(self): - qs = CommunityDocument.objects.all() - keyword = self.request.query_params.get('keyword', '') - serie_uuid = self.request.query_params.get('DocSerieUUID', '') - if keyword: - qs = qs.filter(DocTitle__icontains=keyword) - if serie_uuid: - serie_bytes = UUIDObj(serie_uuid).bytes - if serie_bytes: - qs = qs.filter(DocSerieUUID=serie_bytes) - return qs + return DocManager.QueryDocuments( + Keyword=self.request.query_params.get('keyword', ''), + DocSerieUUID=self.request.query_params.get('DocSerieUUID', ''), + ) def list(self, request, *args, **kwargs): qs = self.get_queryset() @@ -128,11 +122,7 @@ class CommunitySeriesViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] def get_queryset(self): - qs = CommunitySeries.objects.all() - keyword = self.request.query_params.get('keyword', '') - if keyword: - qs = qs.filter(DocSerieName__icontains=keyword) - return qs + return DocManager.QuerySeries(Keyword=self.request.query_params.get('keyword', '')) def list(self, request, *args, **kwargs): qs = self.get_queryset() diff --git a/gvsds/apps/invite/manager.py b/gvsds/apps/invite/manager.py new file mode 100644 index 00000000..2cec18ce --- /dev/null +++ b/gvsds/apps/invite/manager.py @@ -0,0 +1,33 @@ +"""模块:Invite AI 面试官数据访问层 + +AI 面试官对话记录的数据访问收敛在此,视图层不直接操作 ORM。 +所有方法遵循帕斯卡命名法。 +""" + +from .models import InviteConversation + + +class InviteManager: + """AI 面试官对话管理器""" + + @staticmethod + def QuerySet(): + """基础查询集(供 DRF queryset 复用)""" + return InviteConversation.objects.all() + + @staticmethod + def GetByInviteID(InviteID): + """按 InviteID 取对话(不存在返回 None)""" + try: + return InviteConversation.objects.get(InviteID=InviteID) + except InviteConversation.DoesNotExist: + return None + + @staticmethod + def Create(InviteID, Messages, CreateTime): + """新建对话记录""" + return InviteConversation.objects.create( + InviteID=InviteID, + Messages=Messages, + CreateTime=CreateTime, + ) diff --git a/gvsds/apps/invite/views.py b/gvsds/apps/invite/views.py index 279224e8..a4cd9557 100644 --- a/gvsds/apps/invite/views.py +++ b/gvsds/apps/invite/views.py @@ -12,7 +12,7 @@ from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated, AllowAny from openai import OpenAI -from .models import InviteConversation +from .manager import InviteManager from .serializers import ( InviteConversationSerializer, InitConversationSerializer, @@ -73,7 +73,7 @@ def _parse_final_status(ai_reply): class InviteConversationViewSet(viewsets.ModelViewSet): - queryset = InviteConversation.objects.all() + queryset = InviteManager.QuerySet() serializer_class = InviteConversationSerializer permission_classes = [AllowAny] @@ -83,21 +83,19 @@ class InviteConversationViewSet(viewsets.ModelViewSet): ser.IsValid(raise_exception=True) invite_id = ser.ValidatedData['InviteID'] - try: - conv = InviteConversation.objects.get(InviteID=invite_id) + conv = InviteManager.GetByInviteID(invite_id) + if conv is not None: return Response({ 'code': 0, 'msg': '对话已存在', 'data': InviteConversationSerializer(conv).data, }) - except InviteConversation.DoesNotExist: - pass greeting = _call_ai([{"role": "user", "content": "你好,我准备好了,请开始面试。"}]) messages = [ {"role": "assistant", "content": greeting}, ] - conv = InviteConversation.objects.create( + conv = InviteManager.Create( InviteID=invite_id, Messages=messages, CreateTime=time.time(), @@ -115,9 +113,8 @@ class InviteConversationViewSet(viewsets.ModelViewSet): invite_id = ser.ValidatedData['InviteID'] user_message = ser.ValidatedData['Message'] - try: - conv = InviteConversation.objects.get(InviteID=invite_id) - except InviteConversation.DoesNotExist: + conv = InviteManager.GetByInviteID(invite_id) + if conv is None: return Response({'code': 1, 'msg': '对话不存在'}, status=404) if conv.FinalStatus is not None: @@ -156,9 +153,8 @@ class InviteConversationViewSet(viewsets.ModelViewSet): if not invite_id: return Response({'code': 1, 'msg': '缺少invite_id'}, status=400) - try: - conv = InviteConversation.objects.get(InviteID=invite_id) - except InviteConversation.DoesNotExist: + conv = InviteManager.GetByInviteID(invite_id) + if conv is None: return Response({'code': 1, 'msg': '对话不存在'}, status=404) messages = conv.Messages or [] diff --git a/gvsds/apps/sve/manager.py b/gvsds/apps/sve/manager.py new file mode 100644 index 00000000..09f24d4f --- /dev/null +++ b/gvsds/apps/sve/manager.py @@ -0,0 +1,45 @@ +"""模块:SVE 安全漏洞百科数据访问层 + +SveID 为 5 字节二进制主键,存量存在「16 字节补零」「UTF-8 文本字节」等历史形态, +按候选顺序回退匹配的逻辑收敛在此,视图层不直接操作 ORM。所有方法遵循帕斯卡命名法。 +""" + +from .models import SVEBase + + +class SveManager: + """漏洞百科管理器""" + + @staticmethod + def QuerySet(): + """基础查询集(供 DRF queryset 与列表筛选复用)""" + return SVEBase.objects.all() + + @staticmethod + def GetByCandidates(Candidates): + """按候选主键依次匹配(规范 5 字节 → 旧补零 16 字节 → 旧 UTF-8 字节) + + 全部候选均不存在时返回 None。 + """ + for Candidate in Candidates: + try: + return SVEBase.objects.get(SveID=Candidate) + except SVEBase.DoesNotExist: + continue + return None + + @staticmethod + def Upsert(SveID, Defaults, CreateDefaults=None): + """按 SveID 覆盖写入 + + CreateDefaults 仅在新建时写入(如 SveCreateTime),不覆盖存量值。 + + Returns: + (实例, 是否新建) + """ + instance, created = SVEBase.objects.update_or_create(SveID=SveID, defaults=Defaults) + if created and CreateDefaults: + for Field, Value in CreateDefaults.items(): + setattr(instance, Field, Value) + instance.save(update_fields=list(CreateDefaults)) + return instance, created diff --git a/gvsds/apps/sve/views.py b/gvsds/apps/sve/views.py index 4aa0e0fc..4f72c5b1 100644 --- a/gvsds/apps/sve/views.py +++ b/gvsds/apps/sve/views.py @@ -15,7 +15,7 @@ from rest_framework.response import Response from rest_framework.permissions import AllowAny from django.db.models import Q -from .models import SVEBase +from .manager import SveManager from .serializers import SVESerializer, SVEListSerializer, SVEWriteSerializer @@ -79,7 +79,7 @@ def _detail_data(sve): class SVEViewSet(viewsets.ModelViewSet): - queryset = SVEBase.objects.all() + queryset = SveManager.QuerySet() serializer_class = SVESerializer permission_classes = [AllowAny] @@ -92,7 +92,7 @@ class SVEViewSet(viewsets.ModelViewSet): ser.IsValid(raise_exception=True) data = ser.ValidatedData - qs = SVEBase.objects.all() + qs = SveManager.QuerySet() Keyword = data.get('Keyword', '') if Keyword: qs = qs.filter( @@ -165,13 +165,7 @@ class SVEViewSet(viewsets.ModelViewSet): SveID.encode('utf-8')[:16].ljust(16, b'\x00'), SveID.encode('utf-8'), ) - sve = None - for candidate in candidates: - try: - sve = SVEBase.objects.get(SveID=candidate) - break - except SVEBase.DoesNotExist: - continue + sve = SveManager.GetByCandidates(candidates) if sve is None: return Response({'code': 1, 'msg': 'SVE不存在'}, status=404) @@ -199,13 +193,11 @@ class SVEViewSet(viewsets.ModelViewSet): 'SveLastModified': datetime.datetime.utcnow(), } - sve, created = SVEBase.objects.update_or_create( - SveID=sve_id_bytes, - defaults=defaults, + sve, created = SveManager.Upsert( + sve_id_bytes, + defaults, + CreateDefaults={'SveCreateTime': datetime.datetime.utcnow()}, ) - if created: - sve.SveCreateTime = datetime.datetime.utcnow() - sve.save(update_fields=['SveCreateTime']) return Response({ 'code': 0,