新增 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>
253 lines
9.9 KiB
Python
253 lines
9.9 KiB
Python
"""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
|