fix(affinity-P1): WR-002~WR-009 + IN-001~IN-006 综合改进收尾
WR-002 UserLevelRewardGrant.device on_delete CASCADE → SET_NULL,加 device_snapshot_id, unique 改为 partial(device 非空时唯一),与 AffinityLog.device SET_NULL 对齐 WR-003 AffinityLog 删除 3 个低价值索引(user/rule_key/source -created_at 复合) WR-004 event_id 改为 null=True,partial unique 用 isnull=False;RunPython '' → NULL WR-005 seed 加 companion_30min 默认规则 WR-006 description 显式 default='';DEFAULT_LEVELS 全部补 description WR-007 seed_affinity 每条 spec 独立事务,部分失败可重跑 WR-008 ParadiseUser.favorability 字段保留 + UserInfoSerializer 移除暴露 + [DEPRECATED] 标记 WR-009(见 Commit B:AffinityLevel.clean + save full_clean 多层兜底) IN-001 5 个弃用字段 help_text 加 [DEPRECATED — 计划于 P2 完成后删除] IN-002 DEFAULT_RULES/LEVELS/SETTING 抽到 userapp/affinity/defaults.py IN-003 AffinitySetting.daily_cap RenameField → global_daily_cap(区分 AffinityRule.daily_cap) IN-004 AffinityLog.__str__ 用 pk or 'new' 兜底 None IN-005(见 Commit A:is_active → is_bound 改名) IN-006(见 Commit C:0006 print 前缀改为 [migration 0006_...]) 迁移 0009 手工修正:daily_cap 改名用 RenameField(保留数据),不是 Remove+Add; event_id '' → NULL 数据兜底;UserLevelRewardGrant on_delete + conditional unique 重建。 详见 docs/REVIEW-affinity-P1.md WR-* / IN-* 与 FIX-REPORT.md。
This commit is contained in:
parent
2a28aa8b28
commit
61e8374e6a
@ -23,6 +23,37 @@
|
|||||||
|
|
||||||
<!-- 新的修改记录添加在此处下方,最新的在最前面 -->
|
<!-- 新的修改记录添加在此处下方,最新的在最前面 -->
|
||||||
|
|
||||||
|
### [2026-05-13] 好感度系统 P1 审查修复 D — WR-002~WR-009 + IN-001~IN-006 综合改进
|
||||||
|
|
||||||
|
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)(WR-002 ~ WR-009 + IN-001 ~ IN-006)
|
||||||
|
配套修复报告:[docs/REVIEW-affinity-P1-FIX-REPORT.md](REVIEW-affinity-P1-FIX-REPORT.md)
|
||||||
|
|
||||||
|
- **文件路径**:
|
||||||
|
- `userapp/models.py`(修改 — 多处:AffinityLog 索引精简 / event_id null=True / `__str__` 用 pk 兜底;UserLevelRewardGrant SET_NULL + device_snapshot_id + conditional unique;AffinitySetting daily_cap → global_daily_cap;description 显式 default='';弃用字段加 [DEPRECATED] 版本标记;ParadiseUser.favorability 标记 (已弃用))
|
||||||
|
- `userapp/serializers.py`(修改 — `UserInfoSerializer` 移除 `favorability` 字段暴露,WR-008)
|
||||||
|
- `userapp/affinity/__init__.py`(**新建** — affinity 业务包入口)
|
||||||
|
- `userapp/affinity/defaults.py`(**新建** — DEFAULT_RULES / DEFAULT_LEVELS / DEFAULT_SETTING 常量从 management command 抽出;新增 1 条 companion_30min 规则;所有 description 显式填写)
|
||||||
|
- `userapp/management/commands/seed_affinity.py`(**重写** — 从 affinity.defaults 导入常量;去掉全局 `@transaction.atomic`,改为每条 spec 独立 `with transaction.atomic()`;新增 failed 计数与不同 style 输出,部分失败可重跑)
|
||||||
|
- `userapp/migrations/0009_affinity_p1_polish.py`(**新建** — 手工修正 makemigrations 自动生成版本:把 `daily_cap → global_daily_cap` 改为 RenameField(保留数据,不是 Remove+Add);event_id `''` → NULL 数据兜底 RunPython;UserLevelRewardGrant on_delete + conditional unique;索引精简;弃用字段 help_text 升级)
|
||||||
|
- **修改类型**: 修复Bug + 重构
|
||||||
|
- **修改内容**:
|
||||||
|
- **WR-002**:UserLevelRewardGrant.device `on_delete=CASCADE` → `SET_NULL`,加 `device_snapshot_id` 冗余字段(save 时自动填充原 pk),`unique_together=[('device','level')]` → `UniqueConstraint(fields=['device','level'], condition=Q(device__isnull=False))` 保证 device 已删的历史记录不参与唯一性
|
||||||
|
- **WR-003**:AffinityLog 删除 3 个低价值索引(user/rule_key/source 各 -created_at 复合),仅保留 (device, -created_at) 与 event_id partial unique
|
||||||
|
- **WR-004**:AffinityLog.event_id `null=True`;partial unique 条件 `event_id__gt=''` → `event_id__isnull=False`;RunPython 把现有 `''` 改为 NULL
|
||||||
|
- **WR-005**:DEFAULT_RULES 新增 1 条 `companion_30min`(trigger_type=companion_time, min_continuous_minutes=30, max_count_per_day=4,min/max change 1~2)
|
||||||
|
- **WR-006**:AffinityRule/AffinityLevel.description 显式 `default=''`;DEFAULT_LEVELS 所有 entry 补 description
|
||||||
|
- **WR-007**:seed_affinity 每条 spec 独立事务,部分失败不影响其他记录(去掉 handle 上的 @transaction.atomic)
|
||||||
|
- **WR-008**:ParadiseUser.favorability 字段保留(避免 0006 backward 失效)+ verbose_name 加 (已弃用);help_text 标 [DEPRECATED — P2 后删除];UserInfoSerializer 移除字段暴露。**未做 property 改造**:Model field 与 property 同名冲突,必须先 RenameField 才能上 property,本次只做软标记 + 序列化器清理(详见 FIX-REPORT 风险说明)
|
||||||
|
- **WR-009**:(已在 Commit B 的 AffinityLevel.clean() + save() 中实现 — DB 跨行约束 PG 表达不出,应用层多层兜底)
|
||||||
|
- **IN-001**:5 个弃用字段(AffinityRule.points / daily_limit / is_active;AffinityLevel.required_points / rewards)help_text 加 `[DEPRECATED — 计划于 P2 完成后删除]` 显式版本标记
|
||||||
|
- **IN-002**:DEFAULT_RULES / DEFAULT_LEVELS / DEFAULT_SETTING 抽到 `userapp/affinity/defaults.py`,供 seed / 单元测试 / P2 服务层复用
|
||||||
|
- **IN-003**:AffinitySetting.daily_cap → global_daily_cap RenameField(与 AffinityRule.daily_cap 区分);模型 / 校验 / 约束 / DEFAULT_SETTING 全部同步
|
||||||
|
- **IN-004**:AffinityLog.\_\_str\_\_ 用 `self.pk or 'new'` 替代 `self.id`,未保存对象显示 `#new` 而非 `#None`
|
||||||
|
- **IN-005**:(已在 Commit A 完成 — is_active → is_bound 改名)
|
||||||
|
- **IN-006**:(已在 Commit C 完成 — 0006 print 前缀改为 `[migration 0006_migrate_favorability]`)
|
||||||
|
- **修改原因**: P1 数据层审查中除 3 个 Critical 外的全部剩余项(9 Warning + 6 Info)一次性收尾,避免遗留到 P2 服务层动工后修复成本上升;同时保持每个修复项可独立追溯(commit message + 修改记录条目)
|
||||||
|
- **跨项目联动**: 管理后台前端如已读取 AffinitySetting.daily_cap,需要同步改为 global_daily_cap(仅当 admin UI 暴露该字段时);UserInfoSerializer 不再返回 favorability,前端如有使用需改为查询 UserDevice 列表。其他改动对外接口无破坏
|
||||||
|
|
||||||
### [2026-05-13] 好感度系统 P1 审查修复 C — 0006 数据迁移幂等性修正(CR-003)
|
### [2026-05-13] 好感度系统 P1 审查修复 C — 0006 数据迁移幂等性修正(CR-003)
|
||||||
|
|
||||||
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)(CR-003)
|
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)(CR-003)
|
||||||
|
|||||||
5
qy_lty/userapp/affinity/__init__.py
Normal file
5
qy_lty/userapp/affinity/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""好感度系统业务包
|
||||||
|
|
||||||
|
子模块:
|
||||||
|
defaults — 默认规则 / 等级 / 设置常量(供 seed_affinity / 单元测试 / P2 服务层复用)
|
||||||
|
"""
|
||||||
140
qy_lty/userapp/affinity/defaults.py
Normal file
140
qy_lty/userapp/affinity/defaults.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
"""好感度系统默认数据常量
|
||||||
|
|
||||||
|
供 seed_affinity management command、单元测试、P2 服务层(fallback 配置)等共享。
|
||||||
|
|
||||||
|
与设计文档对应:
|
||||||
|
DEFAULT_RULES — 「好感度系统功能与规则设计.md」§4.2 互动规则
|
||||||
|
DEFAULT_LEVELS — 同文档 §6.2 等级表
|
||||||
|
DEFAULT_SETTING — 同文档 §3.2 全局参数 + §5.1 衰减字段
|
||||||
|
|
||||||
|
IN-002:从 management/commands/seed_affinity.py 抽取,避免 management command
|
||||||
|
文件膨胀,且让测试代码 / P2 服务层可以正规 import:
|
||||||
|
from userapp.affinity.defaults import DEFAULT_RULES, DEFAULT_LEVELS, DEFAULT_SETTING
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 默认规则,与设计文档 §4.2 一致
|
||||||
|
# WR-005:新增 1 条 trigger_type='companion_time' 规则(陪伴 30 分钟)
|
||||||
|
# WR-006:所有 description 显式填写,不依赖 model blank=True 隐式默认
|
||||||
|
DEFAULT_RULES = [
|
||||||
|
{
|
||||||
|
'rule_key': 'card', 'name': '使用卡片', 'description': '用户使用洛天依卡片',
|
||||||
|
'trigger_type': 'action',
|
||||||
|
'min_change': 1, 'max_change': 3, 'single_cap': 3, 'daily_cap': 10,
|
||||||
|
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'rule_key': 'chat', 'name': '对话', 'description': '与洛天依进行对话',
|
||||||
|
'trigger_type': 'action',
|
||||||
|
'min_change': 1, 'max_change': 5, 'single_cap': 5, 'daily_cap': 15,
|
||||||
|
'cooldown_seconds': 30, 'is_negative': False, 'is_enabled': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'rule_key': 'feed', 'name': '喂食', 'description': '给洛天依喂食',
|
||||||
|
'trigger_type': 'action',
|
||||||
|
'min_change': 2, 'max_change': 8, 'single_cap': 8, 'daily_cap': 16,
|
||||||
|
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'rule_key': 'touch', 'name': '抚摸', 'description': '抚摸洛天依',
|
||||||
|
'trigger_type': 'action',
|
||||||
|
'min_change': 1, 'max_change': 3, 'single_cap': 3, 'daily_cap': 9,
|
||||||
|
'cooldown_seconds': 10, 'is_negative': False, 'is_enabled': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'rule_key': 'dress', 'name': '换装', 'description': '为洛天依更换服装',
|
||||||
|
'trigger_type': 'action',
|
||||||
|
'min_change': 2, 'max_change': 6, 'single_cap': 6, 'daily_cap': 12,
|
||||||
|
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'rule_key': 'prop', 'name': '使用道具', 'description': '使用互动道具',
|
||||||
|
'trigger_type': 'action',
|
||||||
|
'min_change': 1, 'max_change': 4, 'single_cap': 4, 'daily_cap': 12,
|
||||||
|
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'rule_key': 'gift', 'name': '送礼物', 'description': '赠送礼物给洛天依',
|
||||||
|
'trigger_type': 'action',
|
||||||
|
'min_change': 5, 'max_change': 15, 'single_cap': 15, 'daily_cap': 20,
|
||||||
|
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'rule_key': 'decay', 'name': '无互动衰减', 'description': '长时间不互动导致好感度下降',
|
||||||
|
'trigger_type': 'decay',
|
||||||
|
'min_change': -3, 'max_change': -1, 'single_cap': 3, 'daily_cap': 5,
|
||||||
|
'cooldown_seconds': 0, 'is_negative': True, 'is_enabled': True,
|
||||||
|
},
|
||||||
|
# WR-005:陪伴时长类规则(数值参考设计文档 §4.2,具体值可由运营在 admin 调整)
|
||||||
|
{
|
||||||
|
'rule_key': 'companion_30min', 'name': '陪伴 30 分钟',
|
||||||
|
'description': '与洛天依持续陪伴 30 分钟可获得好感度(数值待产品最终对齐,先用保守默认)',
|
||||||
|
'trigger_type': 'companion_time',
|
||||||
|
'min_change': 1, 'max_change': 2, 'single_cap': 2, 'daily_cap': 8,
|
||||||
|
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
||||||
|
'min_continuous_minutes': 30, 'max_count_per_day': 4,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# 默认等级,与设计文档 §6.2 一致
|
||||||
|
# WR-006:所有 description 显式填写
|
||||||
|
DEFAULT_LEVELS = [
|
||||||
|
{
|
||||||
|
'level': 1, 'name': '初识',
|
||||||
|
'description': '初次相识阶段,了解彼此的基础互动',
|
||||||
|
'min_affinity': 0, 'max_affinity': 20,
|
||||||
|
'unlock_content': '基础对话功能',
|
||||||
|
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||||
|
'is_enabled': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'level': 2, 'name': '相识',
|
||||||
|
'description': '熟悉彼此个性,解锁基础道具与服装',
|
||||||
|
'min_affinity': 21, 'max_affinity': 40,
|
||||||
|
'unlock_content': '基础服装、道具使用',
|
||||||
|
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||||
|
'is_enabled': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'level': 3, 'name': '熟悉',
|
||||||
|
'description': '互动深入,解锁更多内容',
|
||||||
|
'min_affinity': 41, 'max_affinity': 60,
|
||||||
|
'unlock_content': '更多服装、特殊对话',
|
||||||
|
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||||
|
'is_enabled': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'level': 4, 'name': '亲密',
|
||||||
|
'description': '亲密互动阶段,解锁限定内容',
|
||||||
|
'min_affinity': 61, 'max_affinity': 80,
|
||||||
|
'unlock_content': '限定服装、特殊互动',
|
||||||
|
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||||
|
'is_enabled': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'level': 5, 'name': '挚友',
|
||||||
|
'description': '最高亲密度,解锁专属剧情',
|
||||||
|
'min_affinity': 81, 'max_affinity': 100,
|
||||||
|
'unlock_content': '专属内容、特殊剧情',
|
||||||
|
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
||||||
|
'is_enabled': True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# 默认全局设置(用于 seed_affinity 在表为空时创建)
|
||||||
|
DEFAULT_SETTING = {
|
||||||
|
'initial_affinity': 10,
|
||||||
|
'max_affinity': 100,
|
||||||
|
'global_daily_cap': 20, # IN-003:重命名 daily_cap → global_daily_cap
|
||||||
|
'decay_rate': 2,
|
||||||
|
'decay_threshold': 3,
|
||||||
|
'decay_min_decay': 1,
|
||||||
|
'decay_max_decay': 3,
|
||||||
|
'decay_cap': 5,
|
||||||
|
'decay_min_floor': 0,
|
||||||
|
'enable_notify': True,
|
||||||
|
'enable_rewards': True,
|
||||||
|
'notify_decay': True,
|
||||||
|
'timezone': 'Asia/Shanghai',
|
||||||
|
}
|
||||||
@ -6,110 +6,23 @@
|
|||||||
|
|
||||||
写入内容(与「好感度系统功能与规则设计.md」§4.2 / §6.2 一致):
|
写入内容(与「好感度系统功能与规则设计.md」§4.2 / §6.2 一致):
|
||||||
1. AffinitySetting 单例(如不存在)
|
1. AffinitySetting 单例(如不存在)
|
||||||
2. 8 条默认互动规则
|
2. 9 条默认互动规则(含 1 条 companion_time 规则 — WR-005)
|
||||||
3. 5 个默认等级
|
3. 5 个默认等级
|
||||||
|
|
||||||
幂等性:
|
幂等性:
|
||||||
默认按 rule_key(规则)/ level(等级)查询,已存在则跳过。
|
默认按 rule_key(规则)/ level(等级)查询,已存在则跳过。
|
||||||
--force 模式下覆盖已存在记录的字段(不删旧记录)。
|
--force 模式下覆盖已存在记录的字段(不删旧记录)。
|
||||||
|
|
||||||
|
WR-007 修正:
|
||||||
|
每条 spec 独立事务,部分失败不影响其他记录(避免 force 模式下第 N 条崩
|
||||||
|
导致前 N-1 条全部回滚但 stdout 已打印 "成功"的语义错位)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from userapp.models import (
|
from userapp.models import AffinityLevel, AffinityRule, AffinitySetting
|
||||||
AffinityRule,
|
from userapp.affinity.defaults import DEFAULT_LEVELS, DEFAULT_RULES, DEFAULT_SETTING
|
||||||
AffinityLevel,
|
|
||||||
AffinitySetting,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 默认规则,与设计文档 §4.2 一致
|
|
||||||
DEFAULT_RULES = [
|
|
||||||
{
|
|
||||||
'rule_key': 'card', 'name': '使用卡片', 'description': '用户使用洛天依卡片',
|
|
||||||
'trigger_type': 'action',
|
|
||||||
'min_change': 1, 'max_change': 3, 'single_cap': 3, 'daily_cap': 10,
|
|
||||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'rule_key': 'chat', 'name': '对话', 'description': '与洛天依进行对话',
|
|
||||||
'trigger_type': 'action',
|
|
||||||
'min_change': 1, 'max_change': 5, 'single_cap': 5, 'daily_cap': 15,
|
|
||||||
'cooldown_seconds': 30, 'is_negative': False, 'is_enabled': True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'rule_key': 'feed', 'name': '喂食', 'description': '给洛天依喂食',
|
|
||||||
'trigger_type': 'action',
|
|
||||||
'min_change': 2, 'max_change': 8, 'single_cap': 8, 'daily_cap': 16,
|
|
||||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'rule_key': 'touch', 'name': '抚摸', 'description': '抚摸洛天依',
|
|
||||||
'trigger_type': 'action',
|
|
||||||
'min_change': 1, 'max_change': 3, 'single_cap': 3, 'daily_cap': 9,
|
|
||||||
'cooldown_seconds': 10, 'is_negative': False, 'is_enabled': True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'rule_key': 'dress', 'name': '换装', 'description': '为洛天依更换服装',
|
|
||||||
'trigger_type': 'action',
|
|
||||||
'min_change': 2, 'max_change': 6, 'single_cap': 6, 'daily_cap': 12,
|
|
||||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'rule_key': 'prop', 'name': '使用道具', 'description': '使用互动道具',
|
|
||||||
'trigger_type': 'action',
|
|
||||||
'min_change': 1, 'max_change': 4, 'single_cap': 4, 'daily_cap': 12,
|
|
||||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'rule_key': 'gift', 'name': '送礼物', 'description': '赠送礼物给洛天依',
|
|
||||||
'trigger_type': 'action',
|
|
||||||
'min_change': 5, 'max_change': 15, 'single_cap': 15, 'daily_cap': 20,
|
|
||||||
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'rule_key': 'decay', 'name': '无互动衰减', 'description': '长时间不互动导致好感度下降',
|
|
||||||
'trigger_type': 'decay',
|
|
||||||
'min_change': -3, 'max_change': -1, 'single_cap': 3, 'daily_cap': 5,
|
|
||||||
'cooldown_seconds': 0, 'is_negative': True, 'is_enabled': True,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# 默认等级,与设计文档 §6.2 一致
|
|
||||||
DEFAULT_LEVELS = [
|
|
||||||
{
|
|
||||||
'level': 1, 'name': '初识', 'min_affinity': 0, 'max_affinity': 20,
|
|
||||||
'unlock_content': '基础对话功能',
|
|
||||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
|
||||||
'is_enabled': True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'level': 2, 'name': '相识', 'min_affinity': 21, 'max_affinity': 40,
|
|
||||||
'unlock_content': '基础服装、道具使用',
|
|
||||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
|
||||||
'is_enabled': True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'level': 3, 'name': '熟悉', 'min_affinity': 41, 'max_affinity': 60,
|
|
||||||
'unlock_content': '更多服装、特殊对话',
|
|
||||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
|
||||||
'is_enabled': True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'level': 4, 'name': '亲密', 'min_affinity': 61, 'max_affinity': 80,
|
|
||||||
'unlock_content': '限定服装、特殊互动',
|
|
||||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
|
||||||
'is_enabled': True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'level': 5, 'name': '挚友', 'min_affinity': 81, 'max_affinity': 100,
|
|
||||||
'unlock_content': '专属内容、特殊剧情',
|
|
||||||
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
|
|
||||||
'is_enabled': True,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@ -121,63 +34,85 @@ class Command(BaseCommand):
|
|||||||
help='强制覆盖已存在记录的字段(不删旧记录)',
|
help='强制覆盖已存在记录的字段(不删旧记录)',
|
||||||
)
|
)
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
force = options['force']
|
force = options['force']
|
||||||
|
# WR-007:每条独立事务,部分失败可重跑;不再用全局 @transaction.atomic
|
||||||
self._seed_setting()
|
self._seed_setting()
|
||||||
rules_created, rules_updated = self._seed_rules(force)
|
rules_created, rules_updated, rules_failed = self._seed_rules(force)
|
||||||
levels_created, levels_updated = self._seed_levels(force)
|
levels_created, levels_updated, levels_failed = self._seed_levels(force)
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(
|
style = self.style.WARNING if (rules_failed + levels_failed) else self.style.SUCCESS
|
||||||
|
self.stdout.write(style(
|
||||||
f'\n[seed_affinity] 完成:'
|
f'\n[seed_affinity] 完成:'
|
||||||
f'规则 创建 {rules_created} 更新 {rules_updated},'
|
f'规则 创建 {rules_created} 更新 {rules_updated} 失败 {rules_failed},'
|
||||||
f'等级 创建 {levels_created} 更新 {levels_updated}'
|
f'等级 创建 {levels_created} 更新 {levels_updated} 失败 {levels_failed}'
|
||||||
))
|
))
|
||||||
|
|
||||||
def _seed_setting(self):
|
def _seed_setting(self):
|
||||||
if AffinitySetting.objects.exists():
|
try:
|
||||||
self.stdout.write('AffinitySetting 已存在,跳过')
|
with transaction.atomic():
|
||||||
return
|
if AffinitySetting.objects.exists():
|
||||||
AffinitySetting.objects.create()
|
self.stdout.write('AffinitySetting 已存在,跳过')
|
||||||
self.stdout.write(self.style.SUCCESS('AffinitySetting 已创建(默认值)'))
|
return
|
||||||
|
# 从 DEFAULT_SETTING 取所有字段(含 IN-003 改名后的 global_daily_cap)
|
||||||
|
AffinitySetting.objects.create(**DEFAULT_SETTING)
|
||||||
|
self.stdout.write(self.style.SUCCESS('AffinitySetting 已创建(默认值)'))
|
||||||
|
except Exception as e:
|
||||||
|
self.stderr.write(self.style.ERROR(f'AffinitySetting 创建失败:{e}'))
|
||||||
|
|
||||||
def _seed_rules(self, force):
|
def _seed_rules(self, force):
|
||||||
created = 0
|
created = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
|
failed = 0
|
||||||
for spec in DEFAULT_RULES:
|
for spec in DEFAULT_RULES:
|
||||||
rule_key = spec['rule_key']
|
rule_key = spec['rule_key']
|
||||||
existing = AffinityRule.objects.filter(rule_key=rule_key).first()
|
try:
|
||||||
if existing is None:
|
with transaction.atomic():
|
||||||
AffinityRule.objects.create(**spec)
|
existing = AffinityRule.objects.filter(rule_key=rule_key).first()
|
||||||
created += 1
|
if existing is None:
|
||||||
self.stdout.write(f' + 规则 {rule_key} 已创建')
|
AffinityRule.objects.create(**spec)
|
||||||
elif force:
|
created += 1
|
||||||
for k, v in spec.items():
|
self.stdout.write(f' + 规则 {rule_key} 已创建')
|
||||||
setattr(existing, k, v)
|
elif force:
|
||||||
existing.save()
|
for k, v in spec.items():
|
||||||
updated += 1
|
setattr(existing, k, v)
|
||||||
self.stdout.write(f' ~ 规则 {rule_key} 已覆盖')
|
existing.save()
|
||||||
else:
|
updated += 1
|
||||||
self.stdout.write(f' - 规则 {rule_key} 已存在,跳过')
|
self.stdout.write(f' ~ 规则 {rule_key} 已覆盖')
|
||||||
return created, updated
|
else:
|
||||||
|
self.stdout.write(f' - 规则 {rule_key} 已存在,跳过')
|
||||||
|
except Exception as e:
|
||||||
|
failed += 1
|
||||||
|
self.stderr.write(self.style.ERROR(f' ! 规则 {rule_key} 处理失败:{e}'))
|
||||||
|
continue
|
||||||
|
return created, updated, failed
|
||||||
|
|
||||||
def _seed_levels(self, force):
|
def _seed_levels(self, force):
|
||||||
created = 0
|
created = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
|
failed = 0
|
||||||
for spec in DEFAULT_LEVELS:
|
for spec in DEFAULT_LEVELS:
|
||||||
level_num = spec['level']
|
level_num = spec['level']
|
||||||
existing = AffinityLevel.objects.filter(level=level_num).first()
|
try:
|
||||||
if existing is None:
|
with transaction.atomic():
|
||||||
AffinityLevel.objects.create(**spec)
|
existing = AffinityLevel.objects.filter(level=level_num).first()
|
||||||
created += 1
|
if existing is None:
|
||||||
self.stdout.write(f' + 等级 Lv{level_num} 已创建')
|
# 用 skip_clean=True 跳过 save 内 full_clean(区间重叠校验依赖已存在 levels,
|
||||||
elif force:
|
# 但当前批量 seed 是按 level 升序逐条 commit,跨记录关系会随插入逐步成立)
|
||||||
for k, v in spec.items():
|
# 这里仍执行 clean 以保证区间合法
|
||||||
setattr(existing, k, v)
|
AffinityLevel.objects.create(**spec)
|
||||||
existing.save()
|
created += 1
|
||||||
updated += 1
|
self.stdout.write(f' + 等级 Lv{level_num} 已创建')
|
||||||
self.stdout.write(f' ~ 等级 Lv{level_num} 已覆盖')
|
elif force:
|
||||||
else:
|
for k, v in spec.items():
|
||||||
self.stdout.write(f' - 等级 Lv{level_num} 已存在,跳过')
|
setattr(existing, k, v)
|
||||||
return created, updated
|
existing.save()
|
||||||
|
updated += 1
|
||||||
|
self.stdout.write(f' ~ 等级 Lv{level_num} 已覆盖')
|
||||||
|
else:
|
||||||
|
self.stdout.write(f' - 等级 Lv{level_num} 已存在,跳过')
|
||||||
|
except Exception as e:
|
||||||
|
failed += 1
|
||||||
|
self.stderr.write(self.style.ERROR(f' ! 等级 Lv{level_num} 处理失败:{e}'))
|
||||||
|
continue
|
||||||
|
return created, updated, failed
|
||||||
|
|||||||
225
qy_lty/userapp/migrations/0009_affinity_p1_polish.py
Normal file
225
qy_lty/userapp/migrations/0009_affinity_p1_polish.py
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
"""好感度系统 P1 收尾综合迁移 — WR-002 / WR-003 / WR-004 / WR-008 / IN-001 / IN-003
|
||||||
|
|
||||||
|
操作内容:
|
||||||
|
1. WR-002:UserLevelRewardGrant.device on_delete CASCADE → SET_NULL,加 device_snapshot_id,
|
||||||
|
unique_together 改为 partial unique(device 非空时)
|
||||||
|
2. WR-003:AffinityLog 精简索引(删除 user/rule_key/source 三个 -created_at 复合索引)
|
||||||
|
3. WR-004:AffinityLog.event_id 改为 null=True;partial unique 条件由 event_id__gt='' 改为
|
||||||
|
event_id__isnull=False;RunPython 把现有 '' 改为 NULL
|
||||||
|
4. WR-008:ParadiseUser.favorability verbose_name 加 (已弃用) 标记
|
||||||
|
5. IN-001:AffinityRule/AffinityLevel 5 个弃用字段 help_text 加 [DEPRECATED] 版本标记
|
||||||
|
6. IN-003:AffinitySetting.daily_cap RenameField → global_daily_cap(保留数据)
|
||||||
|
7. AffinityLevel.description / AffinityRule.description 显式 default=''(WR-006)
|
||||||
|
|
||||||
|
⚠️ 注意 IN-003 RenameField:
|
||||||
|
Django makemigrations 默认会生成 RemoveField + AddField(**会丢失数据**)。
|
||||||
|
本迁移**手工改为 RenameField**,PostgreSQL 下是元数据级 ALTER COLUMN RENAME(O(1) 锁)。
|
||||||
|
rollback 同样安全(Django 自动反向 RenameField)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def event_id_empty_to_null_forward(apps, schema_editor):
|
||||||
|
"""WR-004:把现有 event_id='' 的行改为 NULL,以适配新的 partial unique 条件"""
|
||||||
|
AffinityLog = apps.get_model('userapp', 'AffinityLog')
|
||||||
|
updated = AffinityLog.objects.filter(event_id='').update(event_id=None)
|
||||||
|
print(f"\n[migration 0009_affinity_p1_polish] event_id '' → NULL: 更新 {updated} 行")
|
||||||
|
|
||||||
|
|
||||||
|
def event_id_null_to_empty_backward(apps, schema_editor):
|
||||||
|
"""回滚:NULL 改回 ''(反向兼容旧 schema)"""
|
||||||
|
AffinityLog = apps.get_model('userapp', 'AffinityLog')
|
||||||
|
updated = AffinityLog.objects.filter(event_id__isnull=True).update(event_id='')
|
||||||
|
print(f"\n[migration 0009_affinity_p1_polish] event_id NULL → '' (rollback): 更新 {updated} 行")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('device_interaction', '0005_alter_userdevice_options'),
|
||||||
|
('userapp', '0008_alter_affinitylog_source_choices'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# ====== 移除旧约束 / 索引 ======
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name='affinitylog',
|
||||||
|
name='unique_affinity_event_id',
|
||||||
|
),
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name='affinitysetting',
|
||||||
|
name='affinitysetting_daily_cap_positive',
|
||||||
|
),
|
||||||
|
# WR-003:删除冗余索引
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name='affinitylog',
|
||||||
|
name='userapp_aff_user_id_ab2869_idx',
|
||||||
|
),
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name='affinitylog',
|
||||||
|
name='userapp_aff_rule_ke_6572b7_idx',
|
||||||
|
),
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name='affinitylog',
|
||||||
|
name='userapp_aff_source_4f0798_idx',
|
||||||
|
),
|
||||||
|
# WR-002:先解除旧 unique_together 才能换 conditional unique
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='userlevelrewardgrant',
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
|
||||||
|
# ====== IN-003 RenameField(保留数据,**不能**用 Remove+Add)======
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='affinitysetting',
|
||||||
|
old_name='daily_cap',
|
||||||
|
new_name='global_daily_cap',
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='affinitysetting',
|
||||||
|
name='global_daily_cap',
|
||||||
|
field=models.IntegerField(
|
||||||
|
default=20,
|
||||||
|
help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)。'
|
||||||
|
'与 AffinityRule.daily_cap(单规则上限)区分使用:P2 服务层需同时校验两者。',
|
||||||
|
verbose_name='每日全局增长上限',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# ====== WR-002:UserLevelRewardGrant 加 device_snapshot_id + on_delete SET_NULL ======
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userlevelrewardgrant',
|
||||||
|
name='device_snapshot_id',
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True, db_index=True,
|
||||||
|
help_text='发放时的 UserDevice pk 快照,device 被删后仍可审计',
|
||||||
|
null=True, verbose_name='设备 ID 快照',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='userlevelrewardgrant',
|
||||||
|
name='device',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True, null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='level_reward_grants',
|
||||||
|
to='device_interaction.userdevice',
|
||||||
|
verbose_name='用户设备绑定',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# ====== WR-006:description 显式 default='' ======
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='affinitylevel',
|
||||||
|
name='description',
|
||||||
|
field=models.TextField(blank=True, default='', verbose_name='等级描述'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='affinityrule',
|
||||||
|
name='description',
|
||||||
|
field=models.TextField(blank=True, default='', verbose_name='规则描述'),
|
||||||
|
),
|
||||||
|
|
||||||
|
# ====== IN-001:弃用字段 help_text 加 [DEPRECATED] 标记 ======
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='affinitylevel',
|
||||||
|
name='required_points',
|
||||||
|
field=models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_affinity/max_affinity 替代',
|
||||||
|
verbose_name='所需积分(已弃用)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='affinitylevel',
|
||||||
|
name='rewards',
|
||||||
|
field=models.JSONField(
|
||||||
|
default=list,
|
||||||
|
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 reward_currency/reward_items 替代',
|
||||||
|
verbose_name='奖励(已弃用)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='affinityrule',
|
||||||
|
name='daily_limit',
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 daily_cap 替代',
|
||||||
|
null=True, verbose_name='每日上限(已弃用)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='affinityrule',
|
||||||
|
name='is_active',
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 is_enabled 替代',
|
||||||
|
verbose_name='已启用(已弃用)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='affinityrule',
|
||||||
|
name='points',
|
||||||
|
field=models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_change/max_change 替代',
|
||||||
|
verbose_name='积分(已弃用)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# ====== WR-008:ParadiseUser.favorability 标记为弃用 ======
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='paradiseuser',
|
||||||
|
name='favorability',
|
||||||
|
field=models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text='[DEPRECATED — P2 后删除] 已下沉到 UserDevice.favorability',
|
||||||
|
verbose_name='好感度(已弃用)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# ====== WR-004:event_id 改为 null=True + 数据兜底 + 新 partial unique ======
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='affinitylog',
|
||||||
|
name='event_id',
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, db_index=True,
|
||||||
|
help_text='客户端事件 UUID(建议格式:UUID v4)用于幂等去重;'
|
||||||
|
'NULL 表示非客户端来源(衰减 / 管理员调整 / 数据迁移)',
|
||||||
|
max_length=64, null=True, verbose_name='事件ID',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# 数据兜底:先把现有 '' 改为 NULL,再加 partial unique(否则约束创建顺序无影响,
|
||||||
|
# 但保险起见显式转换语义,避免未来读旧数据时混淆 '' 与 NULL)
|
||||||
|
migrations.RunPython(
|
||||||
|
event_id_empty_to_null_forward,
|
||||||
|
event_id_null_to_empty_backward,
|
||||||
|
),
|
||||||
|
|
||||||
|
# ====== 重建约束 ======
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='affinitylog',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(('event_id__isnull', False)),
|
||||||
|
fields=('event_id',),
|
||||||
|
name='unique_affinity_event_id',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='affinitysetting',
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
condition=models.Q(('global_daily_cap__gt', 0)),
|
||||||
|
name='affinitysetting_daily_cap_positive',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='userlevelrewardgrant',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(('device__isnull', False)),
|
||||||
|
fields=('device', 'level'),
|
||||||
|
name='unique_grant_device_level',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -27,7 +27,17 @@ class ParadiseUser(AbstractUser):
|
|||||||
('ISTP', 'ISTP'), ('ISFP', 'ISFP'), ('ESTP', 'ESTP'), ('ESFP', 'ESFP'),
|
('ISTP', 'ISTP'), ('ISFP', 'ISFP'), ('ESTP', 'ESTP'), ('ESFP', 'ESFP'),
|
||||||
)
|
)
|
||||||
|
|
||||||
favorability = models.IntegerField('好感度', default=0)
|
# WR-008 [DEPRECATED — 计划于 P2 完成后删除]:
|
||||||
|
# 好感度已下沉到 UserDevice.favorability(设备级),P1-09 数据迁移 0006 已搬数据。
|
||||||
|
# 该字段保留是为了:
|
||||||
|
# 1. backward 兼容(旧 fixtures / 老 API 客户端)
|
||||||
|
# 2. 0006 backward 回滚目标(详见 0006 migration metadata)
|
||||||
|
# 新代码**禁止**读写此字段;UserInfoSerializer 已从此字段移除暴露。
|
||||||
|
# 移除计划:P2 服务层稳定上线 2 周后做迁移 RemoveField。
|
||||||
|
favorability = models.IntegerField(
|
||||||
|
'好感度(已弃用)', default=0,
|
||||||
|
help_text='[DEPRECATED — P2 后删除] 已下沉到 UserDevice.favorability'
|
||||||
|
)
|
||||||
gender = models.CharField('性别', max_length=1, choices=GENDER_CHOICES, null=True, blank=True)
|
gender = models.CharField('性别', max_length=1, choices=GENDER_CHOICES, null=True, blank=True)
|
||||||
resident_city = models.CharField('常驻城市', max_length=50, null=True, blank=True)
|
resident_city = models.CharField('常驻城市', max_length=50, null=True, blank=True)
|
||||||
birthday = models.DateField('生日', null=True, blank=True)
|
birthday = models.DateField('生日', null=True, blank=True)
|
||||||
@ -102,7 +112,7 @@ class AffinityRule(models.Model):
|
|||||||
help_text='代码标识,客户端事件通过此 key 匹配规则(如 chat/sing/dance/touch...)'
|
help_text='代码标识,客户端事件通过此 key 匹配规则(如 chat/sing/dance/touch...)'
|
||||||
)
|
)
|
||||||
name = models.CharField('规则名称', max_length=100)
|
name = models.CharField('规则名称', max_length=100)
|
||||||
description = models.TextField('规则描述', blank=True)
|
description = models.TextField('规则描述', blank=True, default='')
|
||||||
trigger_type = models.CharField(
|
trigger_type = models.CharField(
|
||||||
'触发类型', max_length=20, choices=TRIGGER_TYPE_CHOICES, default='action'
|
'触发类型', max_length=20, choices=TRIGGER_TYPE_CHOICES, default='action'
|
||||||
)
|
)
|
||||||
@ -146,18 +156,19 @@ class AffinityRule(models.Model):
|
|||||||
help_text='trigger_type=companion_time 时使用:每台设备每日最多触发次数'
|
help_text='trigger_type=companion_time 时使用:每台设备每日最多触发次数'
|
||||||
)
|
)
|
||||||
|
|
||||||
# 兼容旧字段(已弃用,下个版本删除)
|
# 兼容旧字段(已弃用,计划于 P2 完成后删除 — 详见 docs/好感度系统-开发任务清单.md "P1 收尾")
|
||||||
|
# IN-001:显式标注预期删除版本,便于后续 grep 清理;新代码禁止读写这些字段。
|
||||||
points = models.IntegerField(
|
points = models.IntegerField(
|
||||||
'积分(已弃用)', default=0,
|
'积分(已弃用)', default=0,
|
||||||
help_text='已弃用,使用 min_change/max_change'
|
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_change/max_change 替代'
|
||||||
)
|
)
|
||||||
daily_limit = models.IntegerField(
|
daily_limit = models.IntegerField(
|
||||||
'每日上限(已弃用)', null=True, blank=True,
|
'每日上限(已弃用)', null=True, blank=True,
|
||||||
help_text='已弃用,使用 daily_cap'
|
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 daily_cap 替代'
|
||||||
)
|
)
|
||||||
is_active = models.BooleanField(
|
is_active = models.BooleanField(
|
||||||
'已启用(已弃用)', default=True,
|
'已启用(已弃用)', default=True,
|
||||||
help_text='已弃用,使用 is_enabled'
|
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 is_enabled 替代'
|
||||||
)
|
)
|
||||||
|
|
||||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||||
@ -240,7 +251,7 @@ class AffinityLevel(models.Model):
|
|||||||
|
|
||||||
level = models.IntegerField('等级', unique=True)
|
level = models.IntegerField('等级', unique=True)
|
||||||
name = models.CharField('等级名称', max_length=50)
|
name = models.CharField('等级名称', max_length=50)
|
||||||
description = models.TextField('等级描述', blank=True)
|
description = models.TextField('等级描述', blank=True, default='')
|
||||||
|
|
||||||
# 区间(P1-03)
|
# 区间(P1-03)
|
||||||
min_affinity = models.IntegerField(
|
min_affinity = models.IntegerField(
|
||||||
@ -269,14 +280,15 @@ class AffinityLevel(models.Model):
|
|||||||
is_enabled = models.BooleanField('已启用', default=True)
|
is_enabled = models.BooleanField('已启用', default=True)
|
||||||
is_deleted = models.BooleanField('已删除(软删除)', default=False)
|
is_deleted = models.BooleanField('已删除(软删除)', default=False)
|
||||||
|
|
||||||
# 兼容旧字段(已弃用)
|
# 兼容旧字段(已弃用,计划于 P2 完成后删除)
|
||||||
|
# IN-001:显式标注预期删除版本,便于后续 grep 清理;新代码禁止读写这些字段。
|
||||||
required_points = models.IntegerField(
|
required_points = models.IntegerField(
|
||||||
'所需积分(已弃用)', default=0,
|
'所需积分(已弃用)', default=0,
|
||||||
help_text='已弃用,使用 min_affinity/max_affinity'
|
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_affinity/max_affinity 替代'
|
||||||
)
|
)
|
||||||
rewards = models.JSONField(
|
rewards = models.JSONField(
|
||||||
'奖励(已弃用)', default=list,
|
'奖励(已弃用)', default=list,
|
||||||
help_text='已弃用,使用 reward_currency/reward_items'
|
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 reward_currency/reward_items 替代'
|
||||||
)
|
)
|
||||||
|
|
||||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||||
@ -354,9 +366,12 @@ class AffinitySetting(models.Model):
|
|||||||
'最大好感度', default=100,
|
'最大好感度', default=100,
|
||||||
help_text='好感度上限(管理员手动调整也不能突破)'
|
help_text='好感度上限(管理员手动调整也不能突破)'
|
||||||
)
|
)
|
||||||
daily_cap = models.IntegerField(
|
# IN-003:原名 daily_cap,因与 AffinityRule.daily_cap 同名易混淆,改为 global_daily_cap
|
||||||
|
global_daily_cap = models.IntegerField(
|
||||||
'每日全局增长上限', default=20,
|
'每日全局增长上限', default=20,
|
||||||
help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)'
|
help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)。'
|
||||||
|
'与 AffinityRule.daily_cap(单规则上限)区分使用:'
|
||||||
|
'P2 服务层需同时校验两者。'
|
||||||
)
|
)
|
||||||
|
|
||||||
# 衰减
|
# 衰减
|
||||||
@ -408,7 +423,7 @@ class AffinitySetting(models.Model):
|
|||||||
name='affinitysetting_floor_le_max',
|
name='affinitysetting_floor_le_max',
|
||||||
),
|
),
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
check=Q(daily_cap__gt=0),
|
check=Q(global_daily_cap__gt=0),
|
||||||
name='affinitysetting_daily_cap_positive',
|
name='affinitysetting_daily_cap_positive',
|
||||||
),
|
),
|
||||||
# 单例硬约束:所有写入必须 pk=1(CHECK 约束不能跨行限制行数,
|
# 单例硬约束:所有写入必须 pk=1(CHECK 约束不能跨行限制行数,
|
||||||
@ -435,8 +450,8 @@ class AffinitySetting(models.Model):
|
|||||||
errors['initial_affinity'] = '初始好感度不能超过最大好感度'
|
errors['initial_affinity'] = '初始好感度不能超过最大好感度'
|
||||||
if self.decay_min_floor > self.max_affinity:
|
if self.decay_min_floor > self.max_affinity:
|
||||||
errors['decay_min_floor'] = '衰减下限不能超过最大好感度'
|
errors['decay_min_floor'] = '衰减下限不能超过最大好感度'
|
||||||
if self.daily_cap <= 0:
|
if self.global_daily_cap <= 0:
|
||||||
errors['daily_cap'] = '每日全局增长上限必须大于 0'
|
errors['global_daily_cap'] = '每日全局增长上限必须大于 0'
|
||||||
if errors:
|
if errors:
|
||||||
raise ValidationError(errors)
|
raise ValidationError(errors)
|
||||||
|
|
||||||
@ -498,9 +513,12 @@ class AffinityLog(models.Model):
|
|||||||
|
|
||||||
# 来源
|
# 来源
|
||||||
source = models.CharField('来源', max_length=30, choices=SOURCE_CHOICES)
|
source = models.CharField('来源', max_length=30, choices=SOURCE_CHOICES)
|
||||||
|
# WR-004:event_id 用 NULL 表示「无客户端事件 ID」(衰减 / 管理员调整等),
|
||||||
|
# 替代旧的 '' 空字符串约定(避免空格 / 'null' 字符串等绕过 partial unique)
|
||||||
event_id = models.CharField(
|
event_id = models.CharField(
|
||||||
'事件ID', max_length=64, blank=True, db_index=True,
|
'事件ID', max_length=64, null=True, blank=True, db_index=True,
|
||||||
help_text='客户端事件 UUID,用于幂等去重'
|
help_text='客户端事件 UUID(建议格式:UUID v4)用于幂等去重;'
|
||||||
|
'NULL 表示非客户端来源(衰减 / 管理员调整 / 数据迁移)'
|
||||||
)
|
)
|
||||||
|
|
||||||
# 管理员调整专用
|
# 管理员调整专用
|
||||||
@ -525,22 +543,27 @@ class AffinityLog(models.Model):
|
|||||||
verbose_name = '好感度变化日志'
|
verbose_name = '好感度变化日志'
|
||||||
verbose_name_plural = '好感度变化日志'
|
verbose_name_plural = '好感度变化日志'
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
# WR-003:精简索引以降低写入开销
|
||||||
|
# 保留:(device, -created_at) — 客户端拉取最近变化主路径
|
||||||
|
# 保留:created_at 单字段(auto_now_add 加 db_index=True 已生成,作管理后台时间排序)
|
||||||
|
# 删除:(user, -created_at) — 可经 device 关联查询替代
|
||||||
|
# 删除:(rule_key, -created_at) — 查询频率低
|
||||||
|
# 删除:(source, -created_at) — 低基数列,配合 created_at 索引扫描即可
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['device', '-created_at']),
|
models.Index(fields=['device', '-created_at']),
|
||||||
models.Index(fields=['user', '-created_at']),
|
|
||||||
models.Index(fields=['rule_key', '-created_at']),
|
|
||||||
models.Index(fields=['source', '-created_at']),
|
|
||||||
]
|
]
|
||||||
constraints = [
|
constraints = [
|
||||||
|
# WR-004:event_id 改用 NULL 语义,partial unique 条件改为 isnull=False
|
||||||
UniqueConstraint(
|
UniqueConstraint(
|
||||||
fields=['event_id'],
|
fields=['event_id'],
|
||||||
condition=Q(event_id__gt=''),
|
condition=Q(event_id__isnull=False),
|
||||||
name='unique_affinity_event_id',
|
name='unique_affinity_event_id',
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"#{self.id} {self.rule_key or self.source} {self.before_value}->{self.after_value}"
|
# IN-004:未保存时 self.pk 为 None,回退到 'new' 避免显示 #None
|
||||||
|
return f"#{self.pk or 'new'} {self.rule_key or self.source} {self.before_value}->{self.after_value}"
|
||||||
|
|
||||||
|
|
||||||
class UserAffinityDailyCounter(models.Model):
|
class UserAffinityDailyCounter(models.Model):
|
||||||
@ -588,11 +611,21 @@ class UserLevelRewardGrant(models.Model):
|
|||||||
(device, level) 唯一,永久幂等防止重复发放。
|
(device, level) 唯一,永久幂等防止重复发放。
|
||||||
衰减回升再次跨过同等级也不补发;新增等级时已在区间的设备不立即触发。
|
衰减回升再次跨过同等级也不补发;新增等级时已在区间的设备不立即触发。
|
||||||
reward_snapshot 保存发放时的奖励内容快照,避免 AffinityLevel 后续修改影响审计。
|
reward_snapshot 保存发放时的奖励内容快照,避免 AffinityLevel 后续修改影响审计。
|
||||||
|
|
||||||
|
WR-002 修正:
|
||||||
|
- on_delete 改为 SET_NULL(与 AffinityLog.device 保持一致 — 历史保留)
|
||||||
|
- 新增 device_snapshot_id 冗余字段,device 被删后仍可追溯原 device pk
|
||||||
|
- unique_together 改为条件唯一约束(device IS NULL 时不参与唯一性)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
device = models.ForeignKey(
|
device = models.ForeignKey(
|
||||||
'device_interaction.UserDevice', on_delete=models.CASCADE,
|
'device_interaction.UserDevice', on_delete=models.SET_NULL,
|
||||||
verbose_name='用户设备绑定', related_name='level_reward_grants'
|
verbose_name='用户设备绑定', related_name='level_reward_grants',
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
device_snapshot_id = models.IntegerField(
|
||||||
|
'设备 ID 快照', null=True, blank=True, db_index=True,
|
||||||
|
help_text='发放时的 UserDevice pk 快照,device 被删后仍可审计'
|
||||||
)
|
)
|
||||||
level = models.IntegerField('等级')
|
level = models.IntegerField('等级')
|
||||||
granted_at = models.DateTimeField('发放时间', auto_now_add=True)
|
granted_at = models.DateTimeField('发放时间', auto_now_add=True)
|
||||||
@ -604,8 +637,22 @@ class UserLevelRewardGrant(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = '等级奖励发放记录'
|
verbose_name = '等级奖励发放记录'
|
||||||
verbose_name_plural = '等级奖励发放记录'
|
verbose_name_plural = '等级奖励发放记录'
|
||||||
unique_together = [('device', 'level')]
|
|
||||||
ordering = ['-granted_at']
|
ordering = ['-granted_at']
|
||||||
|
constraints = [
|
||||||
|
# device 非空时 (device, level) 唯一;device 已删除(NULL)的历史记录不参与唯一
|
||||||
|
UniqueConstraint(
|
||||||
|
fields=['device', 'level'],
|
||||||
|
condition=Q(device__isnull=False),
|
||||||
|
name='unique_grant_device_level',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.device_id}/Lv{self.level}@{self.granted_at}"
|
ref = self.device_id if self.device_id is not None else f'snap:{self.device_snapshot_id}'
|
||||||
|
return f"{ref}/Lv{self.level}@{self.granted_at}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# 首次保存时自动填充 device_snapshot_id(device 被删后仍可追溯原 pk)
|
||||||
|
if self.device_id and not self.device_snapshot_id:
|
||||||
|
self.device_snapshot_id = self.device_id
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|||||||
@ -23,11 +23,14 @@ class UserInfoSerializer(serializers.ModelSerializer):
|
|||||||
"""
|
"""
|
||||||
用于展示用户信息的序列化器
|
用于展示用户信息的序列化器
|
||||||
用户自己查看
|
用户自己查看
|
||||||
|
|
||||||
|
WR-008:移除已弃用的 favorability 字段(已下沉到 UserDevice.favorability)。
|
||||||
|
前端需要显示好感度时,应通过设备级接口查询 UserDevice 列表。
|
||||||
"""
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ParadiseUser
|
model = ParadiseUser
|
||||||
fields = ['id', 'username', 'email', 'phone_number', 'date_joined', 'last_login',
|
fields = ['id', 'username', 'email', 'phone_number', 'date_joined', 'last_login',
|
||||||
'favorability', 'gender', 'resident_city', 'birthday', 'zodiac_sign',
|
'gender', 'resident_city', 'birthday', 'zodiac_sign',
|
||||||
'mbti', 'interests', 'social_identity']
|
'mbti', 'interests', 'social_identity']
|
||||||
read_only_fields = ['id', 'date_joined', 'last_login']
|
read_only_fields = ['id', 'date_joined', 'last_login']
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user