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:
parent
f26e78c545
commit
7c79b72544
@ -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')),
|
||||||
# 后续可以添加更多管理员专用接口
|
# 后续可以添加更多管理员专用接口
|
||||||
]
|
]
|
||||||
|
|||||||
252
qy_lty/userapp/affinity/serializers.py
Normal file
252
qy_lty/userapp/affinity/serializers.py
Normal 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
|
||||||
38
qy_lty/userapp/affinity/urls.py
Normal file
38
qy_lty/userapp/affinity/urls.py
Normal 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)),
|
||||||
|
]
|
||||||
397
qy_lty/userapp/affinity/views.py
Normal file
397
qy_lty/userapp/affinity/views.py
Normal 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=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
|
||||||
Loading…
x
Reference in New Issue
Block a user