新增 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>
398 lines
14 KiB
Python
398 lines
14 KiB
Python
"""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
|