pmc 7c79b72544 feat(affinity-P2): admin API — Rule/Level CRUD + Setting + Logs + Stats + Devices + Adjust (P2-06~P2-12)
新增 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 <noreply@anthropic.com>
2026-05-14 09:36:11 +08:00

398 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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=Trueis_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=Falseservice 层基于这两个标记拒绝触发)"""
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=Trueinclude_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