fix(affinity-P1): CR-003 修正 0006 数据迁移幂等性

旧 forward 用 target.favorability == 10 判断"未迁移",10 既是初始值也是衰减
常见值,重跑会覆盖合法数据;backward 用 != 10 反向判断会丢衰减回 10 的数据。

改用 AffinityLog source='data_migration' 标记做幂等:
  - forward 写入新值时同步写一条 audit log,metadata 含原 ParadiseUser 值
  - backward 遍历 audit log 反向恢复并删除标记,保证 forward/backward 可循环

同步:AffinityLog.SOURCE_CHOICES 追加 'data_migration' + 0008 AlterField 迁移
更新 Python 端 choices 校验。

option B 选择:直接重写 0006(dev 已跑过但 migrate_count=0 等于未动数据,
django_migrations 表已记录完成不会再跑)。生产部署前需确认 prod 未跑过 0006,
否则需 fake-reverse 流程,详见迁移文件 docstring 与 FIX-REPORT。

详见 docs/REVIEW-affinity-P1.md CR-003。
This commit is contained in:
pmc 2026-05-13 10:13:31 +08:00
parent 9a87f5e2b5
commit 2a28aa8b28
4 changed files with 133 additions and 26 deletions

View File

@ -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-003Critical**:旧 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_CHOICESPython 校验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

View File

@ -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):

View File

@ -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='来源'),
),
]

View File

@ -467,6 +467,8 @@ class AffinityLog(models.Model):
('system_decay', '系统衰减'),
('admin_adjust_single', '管理员单次调整'),
('admin_adjust_batch', '管理员批量调整'),
# P1 收尾 CR-003data migration 用作 0006 幂等标记
('data_migration', '数据迁移'),
)
# 关联对象