From 7c79b72544077fe61c7f44569f61c2aa28979412 Mon Sep 17 00:00:00 2001 From: pmc <740076875@qq.com> Date: Thu, 14 May 2026 09:36:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(affinity-P2):=20admin=20API=20=E2=80=94=20?= =?UTF-8?q?Rule/Level=20CRUD=20+=20Setting=20+=20Logs=20+=20Stats=20+=20De?= =?UTF-8?q?vices=20+=20Adjust=20(P2-06~P2-12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 admin 管理端完整 API,挂载在 /api/v1/admin/affinity/ 路径下: - serializers.py:9 个序列化器 - AffinityRuleSerializer / AffinityLevelSerializer / AffinitySettingSerializer 含跨字段 validate(min/max 关系、区间重叠、衰减区间、companion_time 字段必填等) - AffinityLogSerializer 只读 + 关联字段展开(user_username/device_code/rule_name) - UserDeviceAffinitySerializer 含 device_code/mac/status/level_name - AffinityAdjust + AffinityAdjustBatch 用 Serializer 而非 ModelSerializer - permissions.py 中 IsAdminUserStaff 复用,所有 view 默认 RedisTokenAuthentication + IsAdminUserStaff - views.py:7 个视图 - AffinityRuleAdminViewSet (P2-06):ModelViewSet + 软删 (is_deleted+is_enabled=False) + restore action;?include_deleted=true 返回全集 - AffinityLevelAdminViewSet (P2-07):同上软删;serializer 跨字段校验区间重叠 - AffinitySettingView (P2-08):APIView 单例 GET/PUT/PATCH;pk=1 硬约束 - AffinityLogListView (P2-09):过滤 user_id/device_id/rule_key/source/date_from/date_to;分页 page_size 上限 200;select_related 防 N+1 - AffinityStatsView (P2-10):avg/max/top_count/active_7d/total_devices/today_interactions/today_change_sum/rule_freq_top/level_distribution;全部基于 UserDevice.active 聚合;今日按 AffinitySetting.timezone 取 local date - UserAffinityDevicesView (P2-11):?user_id= 必传 + 404 校验;?include_unbound=true 含历史;默认仅 is_bound=True - AffinityAdjustView + AffinityAdjustBatchView (P2-12):委托 AffinityService.admin_adjust;批量遍历 UserDevice.active 逐台调用,返回 per-device 结果数组 - urls.py:DRF DefaultRouter 注册 rules/levels CRUD + 5 个独立 path 挂 settings/logs/stats/devices/adjust* - admin_urls.py:引入 include 并新增 path('affinity/', include('userapp.affinity.urls')) Django check 通过,6 URL reverse 全部解析正确: /api/v1/admin/affinity/settings/ /api/v1/admin/affinity/logs/ /api/v1/admin/affinity/stats/ /api/v1/admin/affinity/devices/ /api/v1/admin/affinity/adjust/ /api/v1/admin/affinity/adjust-batch/ 旧的 /api/user/affinity-rules/ 与 /affinity-levels/ 暂保留兼容,前端切到 admin 后即可清理。 Co-Authored-By: Claude Opus 4.7 --- qy_lty/userapp/admin_urls.py | 4 +- qy_lty/userapp/affinity/serializers.py | 252 ++++++++++++++++ qy_lty/userapp/affinity/urls.py | 38 +++ qy_lty/userapp/affinity/views.py | 397 +++++++++++++++++++++++++ 4 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 qy_lty/userapp/affinity/serializers.py create mode 100644 qy_lty/userapp/affinity/urls.py create mode 100644 qy_lty/userapp/affinity/views.py diff --git a/qy_lty/userapp/admin_urls.py b/qy_lty/userapp/admin_urls.py index 7963191..59e2d2f 100644 --- a/qy_lty/userapp/admin_urls.py +++ b/qy_lty/userapp/admin_urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import path, include from .views import AdminEmailLoginView, AdminLogoutView # Phase 2 — 通用凭据槽位管理端读写接口(CRED-03 + CRED-04) from aiapp.views import CredentialSlotAdminView @@ -11,5 +11,7 @@ urlpatterns = [ path('logout/', AdminLogoutView.as_view(), name='admin_logout'), # 通用凭据槽位(GET 脱敏读取 / PUT 全字段覆写;admin token 鉴权) path('credential-slot/', CredentialSlotAdminView.as_view(), name='admin_credential_slot'), + # 好感度系统 admin 接口(P2-06 ~ P2-12) + path('affinity/', include('userapp.affinity.urls')), # 后续可以添加更多管理员专用接口 ] diff --git a/qy_lty/userapp/affinity/serializers.py b/qy_lty/userapp/affinity/serializers.py new file mode 100644 index 0000000..d5bdd85 --- /dev/null +++ b/qy_lty/userapp/affinity/serializers.py @@ -0,0 +1,252 @@ +"""P2 admin 接口序列化器 + +为 admin 接口提供 AffinityRule / AffinityLevel / AffinitySetting / AffinityLog 等的 +读写序列化器。读字段尽量完整(含 deprecated 字段以便审计),写字段排除 deprecated。 + +所有 ModelSerializer 都用 PATCH 友好的 partial-update 模式。 +""" + +from __future__ import annotations + +from rest_framework import serializers + +from device_interaction.models import UserDevice +from userapp.models import ( + AffinityLevel, + AffinityLog, + AffinityRule, + AffinitySetting, +) + + +# ---------- P2-06 AffinityRule ---------- + +class AffinityRuleSerializer(serializers.ModelSerializer): + class Meta: + model = AffinityRule + fields = [ + 'id', 'rule_key', 'name', 'description', 'trigger_type', + 'min_change', 'max_change', 'single_cap', 'daily_cap', + 'cooldown_seconds', 'is_negative', 'is_enabled', 'is_deleted', + 'min_continuous_minutes', 'max_count_per_day', + 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def validate_rule_key(self, value): + """rule_key 唯一性校验(数据库 unique=True 已保证,但 admin 写入时显式拦截更友好)""" + if value is None or value == '': + raise serializers.ValidationError('rule_key 不能为空') + # 排除当前对象自身(编辑场景) + qs = AffinityRule.objects.filter(rule_key=value) + if self.instance is not None: + qs = qs.exclude(id=self.instance.id) + if qs.exists(): + raise serializers.ValidationError(f'rule_key "{value}" 已存在') + return value + + def validate(self, attrs): + """业务级跨字段校验(P1 CHECK 是最终防线,这里给 admin 友好错误)""" + instance = self.instance + get = lambda k, d=None: attrs.get(k, getattr(instance, k, d) if instance else d) + + min_change = get('min_change', 1) + max_change = get('max_change', 1) + if min_change is not None and max_change is not None and min_change > max_change: + raise serializers.ValidationError({'max_change': 'max_change 必须 >= min_change'}) + + single_cap = get('single_cap', 10) + daily_cap = get('daily_cap', 20) + if single_cap is not None and single_cap <= 0: + raise serializers.ValidationError({'single_cap': 'single_cap 必须 > 0'}) + if daily_cap is not None and daily_cap <= 0: + raise serializers.ValidationError({'daily_cap': 'daily_cap 必须 > 0'}) + + cooldown = get('cooldown_seconds', 0) + if cooldown is not None and cooldown < 0: + raise serializers.ValidationError({'cooldown_seconds': 'cooldown_seconds 不能小于 0'}) + + trigger_type = get('trigger_type', 'action') + if trigger_type == 'companion_time': + mcm = get('min_continuous_minutes') + mcp = get('max_count_per_day') + if not mcm or mcm <= 0: + raise serializers.ValidationError({ + 'min_continuous_minutes': '陪伴时长规则必须设置 min_continuous_minutes > 0' + }) + if not mcp or mcp <= 0: + raise serializers.ValidationError({ + 'max_count_per_day': '陪伴时长规则必须设置 max_count_per_day > 0' + }) + + return attrs + + +# ---------- P2-07 AffinityLevel ---------- + +class AffinityLevelSerializer(serializers.ModelSerializer): + class Meta: + model = AffinityLevel + fields = [ + 'id', 'level', 'name', 'description', + 'min_affinity', 'max_affinity', + 'unlock_content', 'reward_type', 'reward_currency', 'reward_items', + 'is_enabled', 'is_deleted', + 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def validate(self, attrs): + """区间合法 + 不与其他等级重叠(P1 已有 model.clean() 兜底,这里前置给友好错误)""" + instance = self.instance + get = lambda k, d=None: attrs.get(k, getattr(instance, k, d) if instance else d) + + min_a = get('min_affinity', 0) + max_a = get('max_affinity', 0) + if min_a is None or max_a is None: + raise serializers.ValidationError('min_affinity / max_affinity 不能为空') + if min_a > max_a: + raise serializers.ValidationError({'max_affinity': 'max_affinity 必须 >= min_affinity'}) + + # 检查与其他启用等级的区间是否重叠 + level_num = get('level') + qs = AffinityLevel.objects.filter( + is_enabled=True, is_deleted=False, + min_affinity__lte=max_a, max_affinity__gte=min_a, + ) + if instance is not None: + qs = qs.exclude(id=instance.id) + if level_num is not None: + qs = qs.exclude(level=level_num) + conflict = qs.first() + if conflict: + raise serializers.ValidationError({ + 'min_affinity': f'区间 [{min_a}, {max_a}] 与 Lv{conflict.level} ' + f'[{conflict.min_affinity}, {conflict.max_affinity}] 重叠' + }) + + return attrs + + +# ---------- P2-08 AffinitySetting ---------- + +class AffinitySettingSerializer(serializers.ModelSerializer): + class Meta: + model = AffinitySetting + fields = [ + 'id', + 'initial_affinity', 'max_affinity', 'global_daily_cap', + 'decay_rate', 'decay_threshold', + 'decay_min_decay', 'decay_max_decay', 'decay_cap', 'decay_min_floor', + 'enable_notify', 'enable_rewards', 'notify_decay', + 'timezone', + 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def validate(self, attrs): + instance = self.instance + get = lambda k, d=None: attrs.get(k, getattr(instance, k, d) if instance else d) + + init = get('initial_affinity', 10) + mx = get('max_affinity', 100) + if init > mx: + raise serializers.ValidationError({ + 'initial_affinity': 'initial_affinity 不能超过 max_affinity' + }) + + dec_min = get('decay_min_decay', 1) + dec_max = get('decay_max_decay', 3) + dec_cap = get('decay_cap', 5) + if dec_min > dec_max: + raise serializers.ValidationError({ + 'decay_max_decay': 'decay_max_decay 必须 >= decay_min_decay' + }) + if dec_max > dec_cap: + raise serializers.ValidationError({ + 'decay_cap': 'decay_cap 必须 >= decay_max_decay' + }) + + gcap = get('global_daily_cap', 20) + if gcap <= 0: + raise serializers.ValidationError({ + 'global_daily_cap': 'global_daily_cap 必须 > 0' + }) + + return attrs + + +# ---------- P2-09 AffinityLog 查询(read-only)---------- + +class AffinityLogSerializer(serializers.ModelSerializer): + user_username = serializers.CharField(source='user.username', read_only=True) + device_code = serializers.CharField( + source='device.device.device_code', read_only=True, default=None, + ) + rule_name = serializers.CharField(source='rule.name', read_only=True, default=None) + + class Meta: + model = AffinityLog + fields = [ + 'id', 'user', 'user_username', 'device', 'device_code', + 'rule', 'rule_key', 'rule_name', + 'change_value', 'before_value', 'after_value', + 'source', 'event_id', 'operator_admin_id', 'reason', + 'metadata', 'created_at', + ] + read_only_fields = fields # 全部只读 + + +# ---------- P2-11 UserDevice 好感度展开(read-only)---------- + +class UserDeviceAffinitySerializer(serializers.ModelSerializer): + """admin 按 user 展开该用户名下所有设备的好感度状态""" + + device_code = serializers.CharField(source='device.device_code', read_only=True) + mac_address = serializers.CharField(source='device.mac_address', read_only=True) + device_status = serializers.CharField(source='device.status', read_only=True) + user_username = serializers.CharField(source='user.username', read_only=True) + level_name = serializers.SerializerMethodField() + + class Meta: + model = UserDevice + fields = [ + 'id', 'user', 'user_username', + 'device', 'device_code', 'mac_address', 'device_status', + 'nickname', 'bound_at', 'is_primary', 'is_bound', + 'favorability', 'affinity_level', 'level_name', + 'last_active_at', + ] + read_only_fields = fields + + def get_level_name(self, obj): + # 取等级名(避免 N+1,调用方应在 viewset 里 prefetch_related 或单次缓存) + level = AffinityLevel.objects.filter(level=obj.affinity_level).first() + return level.name if level else None + + +# ---------- P2-12 adjust 请求 ---------- + +class AffinityAdjustSerializer(serializers.Serializer): + """单台设备调整:必传 device_id + delta""" + user_id = serializers.IntegerField(required=True, help_text='目标用户 ID') + device_id = serializers.IntegerField(required=True, help_text='UserDevice 绑定 ID') + delta = serializers.IntegerField(required=True, help_text='增减值,正数加,负数减') + reason = serializers.CharField(required=False, allow_blank=True, default='') + + def validate_delta(self, value): + if value == 0: + raise serializers.ValidationError('delta 不能为 0') + return value + + +class AffinityAdjustBatchSerializer(serializers.Serializer): + """批量调整:给某 user 名下所有绑定设备各加 delta""" + user_id = serializers.IntegerField(required=True) + delta = serializers.IntegerField(required=True) + reason = serializers.CharField(required=False, allow_blank=True, default='') + + def validate_delta(self, value): + if value == 0: + raise serializers.ValidationError('delta 不能为 0') + return value diff --git a/qy_lty/userapp/affinity/urls.py b/qy_lty/userapp/affinity/urls.py new file mode 100644 index 0000000..045d131 --- /dev/null +++ b/qy_lty/userapp/affinity/urls.py @@ -0,0 +1,38 @@ +"""好感度系统 admin 端 URL 路由 + +挂载位置:`/api/v1/admin/affinity/...`(由 userapp/admin_urls.py include) +""" + +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import ( + AffinityAdjustBatchView, + AffinityAdjustView, + AffinityLevelAdminViewSet, + AffinityLogListView, + AffinityRuleAdminViewSet, + AffinitySettingView, + AffinityStatsView, + UserAffinityDevicesView, +) + +router = DefaultRouter() +router.register('rules', AffinityRuleAdminViewSet, basename='admin-affinity-rule') +router.register('levels', AffinityLevelAdminViewSet, basename='admin-affinity-level') + +urlpatterns = [ + # P2-08 单例 settings + path('settings/', AffinitySettingView.as_view(), name='admin_affinity_settings'), + # P2-09 变更日志 + path('logs/', AffinityLogListView.as_view(), name='admin_affinity_logs'), + # P2-10 统计 + path('stats/', AffinityStatsView.as_view(), name='admin_affinity_stats'), + # P2-11 按用户列出设备 + path('devices/', UserAffinityDevicesView.as_view(), name='admin_affinity_devices'), + # P2-12 手动调整 + path('adjust/', AffinityAdjustView.as_view(), name='admin_affinity_adjust'), + path('adjust-batch/', AffinityAdjustBatchView.as_view(), name='admin_affinity_adjust_batch'), + # P2-06 / P2-07 router-driven CRUD + path('', include(router.urls)), +] diff --git a/qy_lty/userapp/affinity/views.py b/qy_lty/userapp/affinity/views.py new file mode 100644 index 0000000..c43f1b6 --- /dev/null +++ b/qy_lty/userapp/affinity/views.py @@ -0,0 +1,397 @@ +"""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