lty/qy_lty/userapp/affinity/serializers.py
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

253 lines
9.9 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 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