"""P2-06 ~ P2-12 管理端 admin API ViewSets 挂载在 /api/v1/admin/affinity/...,要求 RedisTokenAuthentication + is_staff。 所有接口走 StandardResponseMiddleware 自动包成 {success,code,message,data}。 """ from __future__ import annotations import logging from datetime import datetime, timedelta from django.db import transaction from django.db.models import Avg, Count, Q from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.views import APIView from device_interaction.models import UserDevice from userapp.authentication import RedisTokenAuthentication from userapp.models import ( AffinityLevel, AffinityLog, AffinityRule, AffinitySetting, ParadiseUser, ) from .permissions import IsAdminUserStaff from .serializers import ( AffinityAdjustBatchSerializer, AffinityAdjustSerializer, AffinityLevelSerializer, AffinityLogSerializer, AffinityRuleSerializer, AffinitySettingSerializer, UserDeviceAffinitySerializer, ) from .services import AffinityService, ApplyOutcome logger = logging.getLogger(__name__) # ---------- P2-06 AffinityRule admin CRUD ---------- class AffinityRuleAdminViewSet(viewsets.ModelViewSet): """好感度规则管理端 CRUD - GET 默认排除软删(is_deleted=False),加 ?include_deleted=true 全集 - DELETE 走软删(is_deleted=True,is_enabled=False),保留 rule_key 但禁触发 - POST/PATCH 走 serializer 跨字段校验 + DB CHECK 兜底 """ authentication_classes = [RedisTokenAuthentication] permission_classes = [IsAdminUserStaff] serializer_class = AffinityRuleSerializer queryset = AffinityRule.objects.all() def get_queryset(self): qs = AffinityRule.objects.all().order_by('-created_at') if self.request.query_params.get('include_deleted', '').lower() not in ('1', 'true', 'yes'): qs = qs.filter(is_deleted=False) return qs def perform_destroy(self, instance): """软删:is_deleted=True + is_enabled=False(service 层基于这两个标记拒绝触发)""" instance.is_deleted = True instance.is_enabled = False instance.save(update_fields=['is_deleted', 'is_enabled', 'updated_at']) @action(detail=True, methods=['post'], url_path='restore') def restore(self, request, pk=None): """恢复软删的规则。允许把 is_enabled 一起拉起来,默认 False(管理员手动启用)""" rule = get_object_or_404(AffinityRule, pk=pk) rule.is_deleted = False rule.save(update_fields=['is_deleted', 'updated_at']) return Response(AffinityRuleSerializer(rule).data) # ---------- P2-07 AffinityLevel admin CRUD ---------- class AffinityLevelAdminViewSet(viewsets.ModelViewSet): authentication_classes = [RedisTokenAuthentication] permission_classes = [IsAdminUserStaff] serializer_class = AffinityLevelSerializer queryset = AffinityLevel.objects.all() def get_queryset(self): qs = AffinityLevel.objects.all().order_by('level') if self.request.query_params.get('include_deleted', '').lower() not in ('1', 'true', 'yes'): qs = qs.filter(is_deleted=False) return qs def perform_destroy(self, instance): instance.is_deleted = True instance.is_enabled = False instance.save(update_fields=['is_deleted', 'is_enabled', 'updated_at']) # ---------- P2-08 AffinitySetting 单例 GET/PUT ---------- class AffinitySettingView(APIView): """好感度系统全局设置 — 单例 GET/PUT GET → 当前配置 PUT → 全字段覆写(serializer 验证) PATCH → 部分字段更新 """ authentication_classes = [RedisTokenAuthentication] permission_classes = [IsAdminUserStaff] def get(self, request): instance = AffinitySetting.get_solo() return Response(AffinitySettingSerializer(instance).data) def put(self, request): instance = AffinitySetting.get_solo() ser = AffinitySettingSerializer(instance, data=request.data) ser.is_valid(raise_exception=True) ser.save() return Response(ser.data) def patch(self, request): instance = AffinitySetting.get_solo() ser = AffinitySettingSerializer(instance, data=request.data, partial=True) ser.is_valid(raise_exception=True) ser.save() return Response(ser.data) # ---------- P2-09 AffinityLog 查询 ---------- class AffinityLogListView(APIView): """好感度变化日志查询 支持过滤参数: user_id — 用户 ID device_id — UserDevice 绑定 ID rule_key — 规则代码 source — 来源(device_event / mobile_event / system_decay / admin_adjust_*) date_from / date_to — created_at 范围(ISO 日期) page / page_size — 分页(默认 page_size=20,最大 200) """ authentication_classes = [RedisTokenAuthentication] permission_classes = [IsAdminUserStaff] def get(self, request): qs = AffinityLog.objects.select_related('user', 'rule', 'device__device').order_by('-created_at') user_id = request.query_params.get('user_id') device_id = request.query_params.get('device_id') rule_key = request.query_params.get('rule_key') source = request.query_params.get('source') date_from = request.query_params.get('date_from') date_to = request.query_params.get('date_to') if user_id: qs = qs.filter(user_id=user_id) if device_id: qs = qs.filter(device_id=device_id) if rule_key: qs = qs.filter(rule_key=rule_key) if source: qs = qs.filter(source=source) if date_from: qs = qs.filter(created_at__gte=date_from) if date_to: qs = qs.filter(created_at__lte=date_to) # 简易分页 try: page = max(int(request.query_params.get('page', 1)), 1) page_size = min(max(int(request.query_params.get('page_size', 20)), 1), 200) except (TypeError, ValueError): page, page_size = 1, 20 total = qs.count() items = qs[(page - 1) * page_size: page * page_size] data = AffinityLogSerializer(items, many=True).data return Response({ 'total': total, 'page': page, 'page_size': page_size, 'items': data, }) # ---------- P2-10 数据统计 ---------- class AffinityStatsView(APIView): """好感度系统数据统计(admin 概览) 返回结构(与设计文档 §7 对齐): avg_favorability — 全设备平均好感度 max_favorability — 当前最高好感度值 top_count — 达到 max_affinity 上限的设备数 active_devices_7d — 近 7 日有互动(last_active_at)的设备数 total_devices — 已绑定(is_bound=True)设备总数 today_interactions — 今日(local timezone)触发数 today_change_sum — 今日好感度变化总和(含正负) rule_freq_top — 互动规则触发频次 Top 10 level_distribution — 各等级设备数占比 """ authentication_classes = [RedisTokenAuthentication] permission_classes = [IsAdminUserStaff] def get(self, request): setting = AffinitySetting.get_solo() max_aff = setting.max_affinity # 仅统计有效绑定(is_bound=True)的设备 active_qs = UserDevice.active.all() total_devices = active_qs.count() avg_fav = active_qs.aggregate(v=Avg('favorability'))['v'] or 0.0 max_fav = active_qs.order_by('-favorability').values_list('favorability', flat=True).first() or 0 top_count = active_qs.filter(favorability__gte=max_aff).count() seven_days_ago = timezone.now() - timedelta(days=7) active_7d = active_qs.filter(last_active_at__gte=seven_days_ago).count() # 今日(local timezone)日志聚合 from .counters import local_today_date today = local_today_date(setting.timezone) # date → datetime 边界(避免时区错位,用 UTC 范围更稳) today_logs = AffinityLog.objects.filter(created_at__date=today) today_interactions = today_logs.count() today_change_sum = today_logs.aggregate(s=Count('id'))['s'] # 触发数 # 用 Sum 更合理 from django.db.models import Sum today_change_sum_val = today_logs.aggregate(s=Sum('change_value'))['s'] or 0 rule_freq_top = list( AffinityLog.objects .filter(created_at__gte=seven_days_ago, rule_key__isnull=False) .exclude(rule_key='') .values('rule_key') .annotate(c=Count('id')) .order_by('-c')[:10] ) level_distribution = list( active_qs .values('affinity_level') .annotate(c=Count('id')) .order_by('affinity_level') ) return Response({ 'avg_favorability': round(float(avg_fav), 2), 'max_favorability': max_fav, 'top_count': top_count, 'active_devices_7d': active_7d, 'total_devices': total_devices, 'today_interactions': today_interactions, 'today_change_sum': today_change_sum_val, 'rule_freq_top': rule_freq_top, 'level_distribution': level_distribution, 'computed_at': timezone.now().isoformat(), }) # ---------- P2-11 按用户列出设备好感度 ---------- class UserAffinityDevicesView(APIView): """admin 按 user_id 列出该用户名下所有设备的好感度状态 GET /api/v1/admin/affinity/devices/?user_id=123 &include_unbound=true — 是否含已解绑历史(默认 false) """ authentication_classes = [RedisTokenAuthentication] permission_classes = [IsAdminUserStaff] def get(self, request): user_id = request.query_params.get('user_id') if not user_id: return Response({'detail': 'user_id 必传'}, status=status.HTTP_400_BAD_REQUEST) if not ParadiseUser.objects.filter(id=user_id).exists(): return Response({'detail': f'用户 {user_id} 不存在'}, status=status.HTTP_404_NOT_FOUND) include_unbound = request.query_params.get('include_unbound', '').lower() in ('1', 'true', 'yes') # CR-001 修复:默认仅返回 is_bound=True,include_unbound=true 时给历史 qs = (UserDevice.objects if include_unbound else UserDevice.active).filter(user_id=user_id) qs = qs.select_related('user', 'device').order_by('-is_primary', '-bound_at') data = UserDeviceAffinitySerializer(qs, many=True).data return Response({ 'user_id': int(user_id), 'count': len(data), 'items': data, }) # ---------- P2-12 admin 手动调整 ---------- class AffinityAdjustView(APIView): """单台设备调整 POST /api/v1/admin/affinity/adjust/ Body: { user_id, device_id, delta, reason } """ authentication_classes = [RedisTokenAuthentication] permission_classes = [IsAdminUserStaff] def post(self, request): ser = AffinityAdjustSerializer(data=request.data) ser.is_valid(raise_exception=True) params = ser.validated_data result = AffinityService.admin_adjust( user_id=params['user_id'], device_id=params['device_id'], delta=params['delta'], operator_admin_id=request.user.id, reason=params.get('reason', ''), batch=False, ) return Response(_apply_result_to_dict(result), status=_status_from_outcome(result.outcome)) class AffinityAdjustBatchView(APIView): """批量调整:给某 user 名下所有绑定设备各加 delta POST /api/v1/admin/affinity/adjust-batch/ Body: { user_id, delta, reason } 返回每台设备的处理结果。 """ authentication_classes = [RedisTokenAuthentication] permission_classes = [IsAdminUserStaff] def post(self, request): ser = AffinityAdjustBatchSerializer(data=request.data) ser.is_valid(raise_exception=True) params = ser.validated_data user_id = params['user_id'] delta = params['delta'] reason = params.get('reason', '') if not ParadiseUser.objects.filter(id=user_id).exists(): return Response({'detail': f'用户 {user_id} 不存在'}, status=status.HTTP_404_NOT_FOUND) device_ids = list(UserDevice.active.filter(user_id=user_id).values_list('id', flat=True)) if not device_ids: return Response({'detail': '该用户名下无有效绑定设备'}, status=status.HTTP_404_NOT_FOUND) results = [] applied_count = 0 for did in device_ids: res = AffinityService.admin_adjust( user_id=user_id, device_id=did, delta=delta, operator_admin_id=request.user.id, reason=reason, batch=True, ) results.append(_apply_result_to_dict(res, device_id=did)) if res.is_applied: applied_count += 1 return Response({ 'user_id': user_id, 'device_count': len(device_ids), 'applied_count': applied_count, 'results': results, }) # ---------- 辅助 ---------- def _apply_result_to_dict(result, *, device_id=None) -> dict: d = { 'outcome': result.outcome, 'applied': result.is_applied, 'change_value': result.change_value, 'before_value': result.before_value, 'after_value': result.after_value, 'rule_key': result.rule_key, 'old_level': result.old_level, 'new_level': result.new_level, 'log_id': result.log_id, 'rewards_granted': result.rewards_granted, 'rewards_failed': result.rewards_failed, 'error': result.error, } if device_id is not None: d['device_id'] = device_id return d def _status_from_outcome(outcome: str) -> int: if outcome == ApplyOutcome.APPLIED: return status.HTTP_200_OK if outcome == ApplyOutcome.ERROR: return status.HTTP_400_BAD_REQUEST # 其他 NOOP_* 视为业务正常拒绝 return status.HTTP_200_OK