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>
This commit is contained in:
pmc 2026-05-14 09:36:11 +08:00
parent f26e78c545
commit 7c79b72544
4 changed files with 690 additions and 1 deletions

View File

@ -1,4 +1,4 @@
from django.urls import path from django.urls import path, include
from .views import AdminEmailLoginView, AdminLogoutView from .views import AdminEmailLoginView, AdminLogoutView
# Phase 2 — 通用凭据槽位管理端读写接口CRED-03 + CRED-04 # Phase 2 — 通用凭据槽位管理端读写接口CRED-03 + CRED-04
from aiapp.views import CredentialSlotAdminView from aiapp.views import CredentialSlotAdminView
@ -11,5 +11,7 @@ urlpatterns = [
path('logout/', AdminLogoutView.as_view(), name='admin_logout'), path('logout/', AdminLogoutView.as_view(), name='admin_logout'),
# 通用凭据槽位GET 脱敏读取 / PUT 全字段覆写admin token 鉴权) # 通用凭据槽位GET 脱敏读取 / PUT 全字段覆写admin token 鉴权)
path('credential-slot/', CredentialSlotAdminView.as_view(), name='admin_credential_slot'), path('credential-slot/', CredentialSlotAdminView.as_view(), name='admin_credential_slot'),
# 好感度系统 admin 接口P2-06 ~ P2-12
path('affinity/', include('userapp.affinity.urls')),
# 后续可以添加更多管理员专用接口 # 后续可以添加更多管理员专用接口
] ]

View File

@ -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

View File

@ -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)),
]

View File

@ -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=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