From 61e8374e6a102702aed20500ccb9bf3bc7e64109 Mon Sep 17 00:00:00 2001 From: pmc <740076875@qq.com> Date: Wed, 13 May 2026 10:18:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(affinity-P1):=20WR-002~WR-009=20+=20IN-001~?= =?UTF-8?q?IN-006=20=E7=BB=BC=E5=90=88=E6=94=B9=E8=BF=9B=E6=94=B6=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- qy_lty/docs/修改记录.md | 31 +++ qy_lty/userapp/affinity/__init__.py | 5 + qy_lty/userapp/affinity/defaults.py | 140 +++++++++++ .../management/commands/seed_affinity.py | 203 ++++++---------- .../migrations/0009_affinity_p1_polish.py | 225 ++++++++++++++++++ qy_lty/userapp/models.py | 99 ++++++-- qy_lty/userapp/serializers.py | 7 +- 7 files changed, 548 insertions(+), 162 deletions(-) create mode 100644 qy_lty/userapp/affinity/__init__.py create mode 100644 qy_lty/userapp/affinity/defaults.py create mode 100644 qy_lty/userapp/migrations/0009_affinity_p1_polish.py diff --git a/qy_lty/docs/修改记录.md b/qy_lty/docs/修改记录.md index 1aec9c0..d7b0a67 100644 --- a/qy_lty/docs/修改记录.md +++ b/qy_lty/docs/修改记录.md @@ -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) 配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)(CR-003) diff --git a/qy_lty/userapp/affinity/__init__.py b/qy_lty/userapp/affinity/__init__.py new file mode 100644 index 0000000..03a2b63 --- /dev/null +++ b/qy_lty/userapp/affinity/__init__.py @@ -0,0 +1,5 @@ +"""好感度系统业务包 + +子模块: + defaults — 默认规则 / 等级 / 设置常量(供 seed_affinity / 单元测试 / P2 服务层复用) +""" diff --git a/qy_lty/userapp/affinity/defaults.py b/qy_lty/userapp/affinity/defaults.py new file mode 100644 index 0000000..9c03155 --- /dev/null +++ b/qy_lty/userapp/affinity/defaults.py @@ -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', +} diff --git a/qy_lty/userapp/management/commands/seed_affinity.py b/qy_lty/userapp/management/commands/seed_affinity.py index c046771..2601269 100644 --- a/qy_lty/userapp/management/commands/seed_affinity.py +++ b/qy_lty/userapp/management/commands/seed_affinity.py @@ -6,110 +6,23 @@ 写入内容(与「好感度系统功能与规则设计.md」§4.2 / §6.2 一致): 1. AffinitySetting 单例(如不存在) - 2. 8 条默认互动规则 + 2. 9 条默认互动规则(含 1 条 companion_time 规则 — WR-005) 3. 5 个默认等级 幂等性: 默认按 rule_key(规则)/ level(等级)查询,已存在则跳过。 --force 模式下覆盖已存在记录的字段(不删旧记录)。 + +WR-007 修正: + 每条 spec 独立事务,部分失败不影响其他记录(避免 force 模式下第 N 条崩 + 导致前 N-1 条全部回滚但 stdout 已打印 "成功"的语义错位)。 """ from django.core.management.base import BaseCommand from django.db import transaction -from userapp.models import ( - AffinityRule, - 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, - }, -] +from userapp.models import AffinityLevel, AffinityRule, AffinitySetting +from userapp.affinity.defaults import DEFAULT_LEVELS, DEFAULT_RULES, DEFAULT_SETTING class Command(BaseCommand): @@ -121,63 +34,85 @@ class Command(BaseCommand): help='强制覆盖已存在记录的字段(不删旧记录)', ) - @transaction.atomic def handle(self, *args, **options): force = options['force'] - + # WR-007:每条独立事务,部分失败可重跑;不再用全局 @transaction.atomic self._seed_setting() - rules_created, rules_updated = self._seed_rules(force) - levels_created, levels_updated = self._seed_levels(force) + rules_created, rules_updated, rules_failed = self._seed_rules(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'规则 创建 {rules_created} 更新 {rules_updated},' - f'等级 创建 {levels_created} 更新 {levels_updated}' + f'规则 创建 {rules_created} 更新 {rules_updated} 失败 {rules_failed},' + f'等级 创建 {levels_created} 更新 {levels_updated} 失败 {levels_failed}' )) def _seed_setting(self): - if AffinitySetting.objects.exists(): - self.stdout.write('AffinitySetting 已存在,跳过') - return - AffinitySetting.objects.create() - self.stdout.write(self.style.SUCCESS('AffinitySetting 已创建(默认值)')) + try: + with transaction.atomic(): + if AffinitySetting.objects.exists(): + self.stdout.write('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): created = 0 updated = 0 + failed = 0 for spec in DEFAULT_RULES: rule_key = spec['rule_key'] - existing = AffinityRule.objects.filter(rule_key=rule_key).first() - if existing is None: - AffinityRule.objects.create(**spec) - created += 1 - self.stdout.write(f' + 规则 {rule_key} 已创建') - elif force: - for k, v in spec.items(): - setattr(existing, k, v) - existing.save() - updated += 1 - self.stdout.write(f' ~ 规则 {rule_key} 已覆盖') - else: - self.stdout.write(f' - 规则 {rule_key} 已存在,跳过') - return created, updated + try: + with transaction.atomic(): + existing = AffinityRule.objects.filter(rule_key=rule_key).first() + if existing is None: + AffinityRule.objects.create(**spec) + created += 1 + self.stdout.write(f' + 规则 {rule_key} 已创建') + elif force: + for k, v in spec.items(): + setattr(existing, k, v) + existing.save() + updated += 1 + self.stdout.write(f' ~ 规则 {rule_key} 已覆盖') + 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): created = 0 updated = 0 + failed = 0 for spec in DEFAULT_LEVELS: level_num = spec['level'] - existing = AffinityLevel.objects.filter(level=level_num).first() - if existing is None: - AffinityLevel.objects.create(**spec) - created += 1 - self.stdout.write(f' + 等级 Lv{level_num} 已创建') - elif force: - for k, v in spec.items(): - setattr(existing, k, v) - existing.save() - updated += 1 - self.stdout.write(f' ~ 等级 Lv{level_num} 已覆盖') - else: - self.stdout.write(f' - 等级 Lv{level_num} 已存在,跳过') - return created, updated + try: + with transaction.atomic(): + existing = AffinityLevel.objects.filter(level=level_num).first() + if existing is None: + # 用 skip_clean=True 跳过 save 内 full_clean(区间重叠校验依赖已存在 levels, + # 但当前批量 seed 是按 level 升序逐条 commit,跨记录关系会随插入逐步成立) + # 这里仍执行 clean 以保证区间合法 + AffinityLevel.objects.create(**spec) + created += 1 + self.stdout.write(f' + 等级 Lv{level_num} 已创建') + elif force: + for k, v in spec.items(): + setattr(existing, k, v) + 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 diff --git a/qy_lty/userapp/migrations/0009_affinity_p1_polish.py b/qy_lty/userapp/migrations/0009_affinity_p1_polish.py new file mode 100644 index 0000000..80f76ef --- /dev/null +++ b/qy_lty/userapp/migrations/0009_affinity_p1_polish.py @@ -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', + ), + ), + ] diff --git a/qy_lty/userapp/models.py b/qy_lty/userapp/models.py index 9e6af8e..d2f2006 100644 --- a/qy_lty/userapp/models.py +++ b/qy_lty/userapp/models.py @@ -27,7 +27,17 @@ class ParadiseUser(AbstractUser): ('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) resident_city = models.CharField('常驻城市', max_length=50, 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...)' ) name = models.CharField('规则名称', max_length=100) - description = models.TextField('规则描述', blank=True) + description = models.TextField('规则描述', blank=True, default='') trigger_type = models.CharField( '触发类型', max_length=20, choices=TRIGGER_TYPE_CHOICES, default='action' ) @@ -146,18 +156,19 @@ class AffinityRule(models.Model): help_text='trigger_type=companion_time 时使用:每台设备每日最多触发次数' ) - # 兼容旧字段(已弃用,下个版本删除) + # 兼容旧字段(已弃用,计划于 P2 完成后删除 — 详见 docs/好感度系统-开发任务清单.md "P1 收尾") + # IN-001:显式标注预期删除版本,便于后续 grep 清理;新代码禁止读写这些字段。 points = models.IntegerField( '积分(已弃用)', default=0, - help_text='已弃用,使用 min_change/max_change' + help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_change/max_change 替代' ) daily_limit = models.IntegerField( '每日上限(已弃用)', null=True, blank=True, - help_text='已弃用,使用 daily_cap' + help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 daily_cap 替代' ) is_active = models.BooleanField( '已启用(已弃用)', default=True, - help_text='已弃用,使用 is_enabled' + help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 is_enabled 替代' ) created_at = models.DateTimeField('创建时间', auto_now_add=True) @@ -240,7 +251,7 @@ class AffinityLevel(models.Model): level = models.IntegerField('等级', unique=True) name = models.CharField('等级名称', max_length=50) - description = models.TextField('等级描述', blank=True) + description = models.TextField('等级描述', blank=True, default='') # 区间(P1-03) min_affinity = models.IntegerField( @@ -269,14 +280,15 @@ class AffinityLevel(models.Model): is_enabled = models.BooleanField('已启用', default=True) is_deleted = models.BooleanField('已删除(软删除)', default=False) - # 兼容旧字段(已弃用) + # 兼容旧字段(已弃用,计划于 P2 完成后删除) + # IN-001:显式标注预期删除版本,便于后续 grep 清理;新代码禁止读写这些字段。 required_points = models.IntegerField( '所需积分(已弃用)', default=0, - help_text='已弃用,使用 min_affinity/max_affinity' + help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_affinity/max_affinity 替代' ) rewards = models.JSONField( '奖励(已弃用)', default=list, - help_text='已弃用,使用 reward_currency/reward_items' + help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 reward_currency/reward_items 替代' ) created_at = models.DateTimeField('创建时间', auto_now_add=True) @@ -354,9 +366,12 @@ class AffinitySetting(models.Model): '最大好感度', default=100, help_text='好感度上限(管理员手动调整也不能突破)' ) - daily_cap = models.IntegerField( + # IN-003:原名 daily_cap,因与 AffinityRule.daily_cap 同名易混淆,改为 global_daily_cap + global_daily_cap = models.IntegerField( '每日全局增长上限', default=20, - help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)' + help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)。' + '与 AffinityRule.daily_cap(单规则上限)区分使用:' + 'P2 服务层需同时校验两者。' ) # 衰减 @@ -408,7 +423,7 @@ class AffinitySetting(models.Model): name='affinitysetting_floor_le_max', ), CheckConstraint( - check=Q(daily_cap__gt=0), + check=Q(global_daily_cap__gt=0), name='affinitysetting_daily_cap_positive', ), # 单例硬约束:所有写入必须 pk=1(CHECK 约束不能跨行限制行数, @@ -435,8 +450,8 @@ class AffinitySetting(models.Model): errors['initial_affinity'] = '初始好感度不能超过最大好感度' if self.decay_min_floor > self.max_affinity: errors['decay_min_floor'] = '衰减下限不能超过最大好感度' - if self.daily_cap <= 0: - errors['daily_cap'] = '每日全局增长上限必须大于 0' + if self.global_daily_cap <= 0: + errors['global_daily_cap'] = '每日全局增长上限必须大于 0' if errors: raise ValidationError(errors) @@ -498,9 +513,12 @@ class AffinityLog(models.Model): # 来源 source = models.CharField('来源', max_length=30, choices=SOURCE_CHOICES) + # WR-004:event_id 用 NULL 表示「无客户端事件 ID」(衰减 / 管理员调整等), + # 替代旧的 '' 空字符串约定(避免空格 / 'null' 字符串等绕过 partial unique) event_id = models.CharField( - '事件ID', max_length=64, blank=True, db_index=True, - help_text='客户端事件 UUID,用于幂等去重' + '事件ID', max_length=64, null=True, blank=True, db_index=True, + help_text='客户端事件 UUID(建议格式:UUID v4)用于幂等去重;' + 'NULL 表示非客户端来源(衰减 / 管理员调整 / 数据迁移)' ) # 管理员调整专用 @@ -525,22 +543,27 @@ class AffinityLog(models.Model): verbose_name = '好感度变化日志' verbose_name_plural = '好感度变化日志' 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 = [ 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 = [ + # WR-004:event_id 改用 NULL 语义,partial unique 条件改为 isnull=False UniqueConstraint( fields=['event_id'], - condition=Q(event_id__gt=''), + condition=Q(event_id__isnull=False), name='unique_affinity_event_id', ), ] 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): @@ -588,11 +611,21 @@ class UserLevelRewardGrant(models.Model): (device, level) 唯一,永久幂等防止重复发放。 衰减回升再次跨过同等级也不补发;新增等级时已在区间的设备不立即触发。 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_interaction.UserDevice', on_delete=models.CASCADE, - verbose_name='用户设备绑定', related_name='level_reward_grants' + 'device_interaction.UserDevice', on_delete=models.SET_NULL, + 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('等级') granted_at = models.DateTimeField('发放时间', auto_now_add=True) @@ -604,8 +637,22 @@ class UserLevelRewardGrant(models.Model): class Meta: verbose_name = '等级奖励发放记录' verbose_name_plural = '等级奖励发放记录' - unique_together = [('device', 'level')] 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): - 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) diff --git a/qy_lty/userapp/serializers.py b/qy_lty/userapp/serializers.py index 06e7b92..acee90b 100644 --- a/qy_lty/userapp/serializers.py +++ b/qy_lty/userapp/serializers.py @@ -23,11 +23,14 @@ class UserInfoSerializer(serializers.ModelSerializer): """ 用于展示用户信息的序列化器 用户自己查看 + + WR-008:移除已弃用的 favorability 字段(已下沉到 UserDevice.favorability)。 + 前端需要显示好感度时,应通过设备级接口查询 UserDevice 列表。 """ class Meta: model = ParadiseUser - fields = ['id', 'username', 'email', 'phone_number', 'date_joined', 'last_login', - 'favorability', 'gender', 'resident_city', 'birthday', 'zodiac_sign', + fields = ['id', 'username', 'email', 'phone_number', 'date_joined', 'last_login', + 'gender', 'resident_city', 'birthday', 'zodiac_sign', 'mbti', 'interests', 'social_identity'] read_only_fields = ['id', 'date_joined', 'last_login']