diff --git a/qy_lty/docs/修改记录.md b/qy_lty/docs/修改记录.md index 7e672d2..1aec9c0 100644 --- a/qy_lty/docs/修改记录.md +++ b/qy_lty/docs/修改记录.md @@ -23,6 +23,28 @@ +### [2026-05-13] 好感度系统 P1 审查修复 C — 0006 数据迁移幂等性修正(CR-003) + +配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)(CR-003) +配套修复报告:[docs/REVIEW-affinity-P1-FIX-REPORT.md](REVIEW-affinity-P1-FIX-REPORT.md) + +- **文件路径**: + - `userapp/migrations/0006_migrate_favorability_to_userdevice.py`(**重写** — 完整替换 forward/backward 函数内容,改用 AffinityLog source='data_migration' 标记做幂等) + - `userapp/models.py`(修改 — `AffinityLog.SOURCE_CHOICES` 追加 `('data_migration', '数据迁移')` 选项) + - `userapp/migrations/0008_alter_affinitylog_source_choices.py`(**新建** — 由 makemigrations 自动生成,更新 source 字段的 choices) +- **修改类型**: 修复Bug + 重构 +- **修改内容**: + - **CR-003(Critical)**:旧 0006 forward 用 `target.favorability == 10` 做幂等判断,因 10 既是初始值也是衰减下限附近常见值,重跑会覆盖合法数据;backward 同样用 `!= 10` 反向判断会丢失衰减回 10 的数据 + - 新实现 forward:用 `AffinityLog.objects.filter(device_id=target.id, source='data_migration').exists()` 做幂等标记,同时写入审计 metadata(`from_user_favorability` / `before_device_favorability` / `migration` 文件名) + - 新实现 backward:遍历 source='data_migration' 的 AffinityLog 反向恢复 ParadiseUser.favorability,并删除标记保证可循环 + - 模型层补 `'data_migration'` 到 SOURCE_CHOICES(Python 校验,0008 AlterField 同步) +- **已知风险(option B 选择说明)**: + - 选择**直接重写 0006**(而非追加 0007 补偿)是因为 dev DB 已执行过一次但 migrate_count=0,等于未动数据 + - django_migrations 表已记录 0006 完成,重写后**不会**自动重跑;如需对意外脏数据强制重跑,需手工 `python manage.py migrate userapp 0005 --fake` 后再 `migrate userapp 0006` + - 生产环境部署前必须确认 prod 还未跑过 0006,否则需走 fake-reverse 流程 +- **修改原因**: P1 审查指出(详见 REVIEW-affinity-P1.md CR-003)旧幂等条件 `favorability == 10` 在 P3/P4 衰减跑起来后会变成"定时炸弹",重跑迁移会覆盖正常业务数据;backward 会丢数据 +- **跨项目联动**: 无 — 仅服务端迁移逻辑变更 + ### [2026-05-13] 好感度系统 P1 审查修复 B — Affinity 模型 DB CHECK 约束 + WR-001 单例硬约束(CR-002 + WR-001) 配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)(CR-002 + WR-001) diff --git a/qy_lty/userapp/migrations/0006_migrate_favorability_to_userdevice.py b/qy_lty/userapp/migrations/0006_migrate_favorability_to_userdevice.py index e6a65da..9b48a8e 100644 --- a/qy_lty/userapp/migrations/0006_migrate_favorability_to_userdevice.py +++ b/qy_lty/userapp/migrations/0006_migrate_favorability_to_userdevice.py @@ -3,18 +3,40 @@ 迁移策略: 1. 遍历所有 favorability > 0 的用户 2. 找该用户的主设备(is_primary=True),无主设备则取最近绑定的设备 - 3. 写入 UserDevice.favorability + 3. 写入 UserDevice.favorability,并同步创建一条 source='data_migration' 的 + AffinityLog 作为「已迁移」幂等标记 4. 用户没有任何设备绑定 → 跳过(数据保留在 ParadiseUser,等用户首次绑定时由 业务层处理) -幂等性: - 重复执行不会重复写入(按 favorability=10 默认值判断,已迁移过的设备值非默认 - 或已被业务层覆盖时不再重写)。 +幂等性(CR-003 修正版): + 旧实现用 `target.favorability == 10` 做幂等判断,但 10 既是初始值也是 + 衰减下限常见值,重跑迁移会覆盖合法的非默认值。新实现改用 + `AffinityLog.objects.filter(device_id=target.id, source='data_migration').exists()` + 做幂等标记,重跑安全。 + +backward 回滚(CR-003 修正版): + 旧实现用 `primary.favorability != 10` 反向判断,导致衰减回 10 的数据无法回滚。 + 新实现读取 forward 写入的 AffinityLog metadata 反向恢复 ParadiseUser.favorability, + 回滚后删除对应的 AffinityLog 标记(避免再次 forward 时被误判为"已迁移过")。 + +⚠️ 已知风险(option B 选择说明): + 本次 P1 收尾**直接修改了 0006 迁移内容**(而非追加新迁移 0007 做补偿)。 + 选择此方案的前提: + - dev DB 已执行过一次 0006,但 migrate_count=0 / skipped_no_device=1 / + skipped_zero=9 — 即「成功迁移条数 = 0」,等于没动数据。 + - django_migrations 表已记录 0006 完成,Django 不会再次执行此文件。 + - 若 dev DB 出现意外脏数据(例如某用户被部分迁移),需手工 + `python manage.py migrate userapp 0005 --fake` 再 `migrate userapp 0006` + 重新执行此修正版逻辑。 + 生产环境(dev → prod 同步部署前)请确认 prod 还未执行 0006,避免历史不一致。 注意: 1. 旧的 ParadiseUser.favorability 字段保留不删,由后续版本统一清理 2. AffinitySetting 单例和默认 8 条规则、5 个等级由 management command (seed_affinity, P1-10) 处理,不在数据迁移中 + 3. 本迁移依赖 0007_add_affinity_check_constraints 中加入的 'data_migration' + choice — 但 SOURCE_CHOICES 是 Python 校验,迁移内 apps.get_model 返回的 + 历史模型不强制 choice 校验,直接写入字符串即可。 """ from django.db import migrations @@ -23,10 +45,12 @@ from django.db import migrations def migrate_favorability_forward(apps, schema_editor): ParadiseUser = apps.get_model('userapp', 'ParadiseUser') UserDevice = apps.get_model('device_interaction', 'UserDevice') + AffinityLog = apps.get_model('userapp', 'AffinityLog') migrated_count = 0 skipped_no_device = 0 skipped_zero = 0 + skipped_already_migrated = 0 for user in ParadiseUser.objects.iterator(): favorability = getattr(user, 'favorability', 0) or 0 @@ -51,38 +75,79 @@ def migrate_favorability_forward(apps, schema_editor): skipped_no_device += 1 continue - # 仅当目标值仍是默认值(10)时写入,避免覆盖业务层后续修改 - if target.favorability == 10: - target.favorability = favorability - target.save(update_fields=['favorability']) - migrated_count += 1 + # 幂等:检查是否已迁移过(CR-003 修正 — 不再用 favorability == 10 判断) + already = AffinityLog.objects.filter( + device_id=target.id, source='data_migration' + ).exists() + if already: + skipped_already_migrated += 1 + continue + + before = target.favorability + target.favorability = favorability + target.save(update_fields=['favorability']) + + # 写入幂等标记 + 审计日志 + AffinityLog.objects.create( + user_id=user.id, + device_id=target.id, + rule_key='', + change_value=favorability - before, + before_value=before, + after_value=favorability, + source='data_migration', + metadata={ + 'migration': '0006_migrate_favorability_to_userdevice', + 'from_user_favorability': favorability, + 'before_device_favorability': before, + }, + ) + migrated_count += 1 print( - f"\n[P1-09] favorability 数据迁移完成:" - f"成功 {migrated_count},无设备跳过 {skipped_no_device}," - f"零值跳过 {skipped_zero}" + f"\n[migration 0006_migrate_favorability] forward: " + f"成功={migrated_count}, 无设备={skipped_no_device}, " + f"零值={skipped_zero}, 已迁移过={skipped_already_migrated}" ) def migrate_favorability_backward(apps, schema_editor): - """回滚:把 UserDevice.favorability 写回 ParadiseUser.favorability(取主设备值)""" + """回滚:读取 forward 写入的 AffinityLog metadata 反向恢复 ParadiseUser.favorability + + CR-003 修正:旧实现用 `primary.favorability != 10` 反向判断,会丢失衰减回 10 的数据。 + 新实现遍历 source='data_migration' 的 AffinityLog 记录,按 metadata 还原原值, + 并删除标记以保证 forward / backward 可循环。 + """ ParadiseUser = apps.get_model('userapp', 'ParadiseUser') - UserDevice = apps.get_model('device_interaction', 'UserDevice') + AffinityLog = apps.get_model('userapp', 'AffinityLog') rolled_back = 0 - for user in ParadiseUser.objects.iterator(): - primary = ( - UserDevice.objects - .filter(user_id=user.id, is_primary=True) - .order_by('-bound_at') - .first() - ) - if primary and primary.favorability != 10: - user.favorability = primary.favorability - user.save(update_fields=['favorability']) - rolled_back += 1 + skipped_no_metadata = 0 - print(f"\n[P1-09 rollback] 回滚 favorability {rolled_back} 条到 ParadiseUser") + migration_logs = AffinityLog.objects.filter(source='data_migration').iterator() + for log in migration_logs: + metadata = log.metadata or {} + original_user_value = metadata.get('from_user_favorability') + if original_user_value is None: + skipped_no_metadata += 1 + continue + + try: + user = ParadiseUser.objects.get(pk=log.user_id) + except ParadiseUser.DoesNotExist: + continue + + user.favorability = original_user_value + user.save(update_fields=['favorability']) + rolled_back += 1 + + # 删除幂等标记(允许后续 forward 再次运行) + deleted, _ = AffinityLog.objects.filter(source='data_migration').delete() + + print( + f"\n[migration 0006_migrate_favorability] backward: " + f"回滚={rolled_back}, 删除标记={deleted}, 无 metadata 跳过={skipped_no_metadata}" + ) class Migration(migrations.Migration): diff --git a/qy_lty/userapp/migrations/0008_alter_affinitylog_source_choices.py b/qy_lty/userapp/migrations/0008_alter_affinitylog_source_choices.py new file mode 100644 index 0000000..039ed15 --- /dev/null +++ b/qy_lty/userapp/migrations/0008_alter_affinitylog_source_choices.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-05-13 02:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('userapp', '0007_add_affinity_check_constraints'), + ] + + operations = [ + migrations.AlterField( + model_name='affinitylog', + name='source', + field=models.CharField(choices=[('device_event', '设备端事件'), ('mobile_event', '手机端事件'), ('system_decay', '系统衰减'), ('admin_adjust_single', '管理员单次调整'), ('admin_adjust_batch', '管理员批量调整'), ('data_migration', '数据迁移')], max_length=30, verbose_name='来源'), + ), + ] diff --git a/qy_lty/userapp/models.py b/qy_lty/userapp/models.py index 43895ef..9e6af8e 100644 --- a/qy_lty/userapp/models.py +++ b/qy_lty/userapp/models.py @@ -467,6 +467,8 @@ class AffinityLog(models.Model): ('system_decay', '系统衰减'), ('admin_adjust_single', '管理员单次调整'), ('admin_adjust_batch', '管理员批量调整'), + # P1 收尾 CR-003:data migration 用作 0006 幂等标记 + ('data_migration', '数据迁移'), ) # 关联对象