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:
parent
9a87f5e2b5
commit
2a28aa8b28
@ -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)
|
### [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)
|
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)(CR-002 + WR-001)
|
||||||
|
|||||||
@ -3,18 +3,40 @@
|
|||||||
迁移策略:
|
迁移策略:
|
||||||
1. 遍历所有 favorability > 0 的用户
|
1. 遍历所有 favorability > 0 的用户
|
||||||
2. 找该用户的主设备(is_primary=True),无主设备则取最近绑定的设备
|
2. 找该用户的主设备(is_primary=True),无主设备则取最近绑定的设备
|
||||||
3. 写入 UserDevice.favorability
|
3. 写入 UserDevice.favorability,并同步创建一条 source='data_migration' 的
|
||||||
|
AffinityLog 作为「已迁移」幂等标记
|
||||||
4. 用户没有任何设备绑定 → 跳过(数据保留在 ParadiseUser,等用户首次绑定时由
|
4. 用户没有任何设备绑定 → 跳过(数据保留在 ParadiseUser,等用户首次绑定时由
|
||||||
业务层处理)
|
业务层处理)
|
||||||
|
|
||||||
幂等性:
|
幂等性(CR-003 修正版):
|
||||||
重复执行不会重复写入(按 favorability=10 默认值判断,已迁移过的设备值非默认
|
旧实现用 `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 字段保留不删,由后续版本统一清理
|
1. 旧的 ParadiseUser.favorability 字段保留不删,由后续版本统一清理
|
||||||
2. AffinitySetting 单例和默认 8 条规则、5 个等级由 management command
|
2. AffinitySetting 单例和默认 8 条规则、5 个等级由 management command
|
||||||
(seed_affinity, P1-10) 处理,不在数据迁移中
|
(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
|
from django.db import migrations
|
||||||
@ -23,10 +45,12 @@ from django.db import migrations
|
|||||||
def migrate_favorability_forward(apps, schema_editor):
|
def migrate_favorability_forward(apps, schema_editor):
|
||||||
ParadiseUser = apps.get_model('userapp', 'ParadiseUser')
|
ParadiseUser = apps.get_model('userapp', 'ParadiseUser')
|
||||||
UserDevice = apps.get_model('device_interaction', 'UserDevice')
|
UserDevice = apps.get_model('device_interaction', 'UserDevice')
|
||||||
|
AffinityLog = apps.get_model('userapp', 'AffinityLog')
|
||||||
|
|
||||||
migrated_count = 0
|
migrated_count = 0
|
||||||
skipped_no_device = 0
|
skipped_no_device = 0
|
||||||
skipped_zero = 0
|
skipped_zero = 0
|
||||||
|
skipped_already_migrated = 0
|
||||||
|
|
||||||
for user in ParadiseUser.objects.iterator():
|
for user in ParadiseUser.objects.iterator():
|
||||||
favorability = getattr(user, 'favorability', 0) or 0
|
favorability = getattr(user, 'favorability', 0) or 0
|
||||||
@ -51,38 +75,79 @@ def migrate_favorability_forward(apps, schema_editor):
|
|||||||
skipped_no_device += 1
|
skipped_no_device += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 仅当目标值仍是默认值(10)时写入,避免覆盖业务层后续修改
|
# 幂等:检查是否已迁移过(CR-003 修正 — 不再用 favorability == 10 判断)
|
||||||
if target.favorability == 10:
|
already = AffinityLog.objects.filter(
|
||||||
target.favorability = favorability
|
device_id=target.id, source='data_migration'
|
||||||
target.save(update_fields=['favorability'])
|
).exists()
|
||||||
migrated_count += 1
|
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(
|
print(
|
||||||
f"\n[P1-09] favorability 数据迁移完成:"
|
f"\n[migration 0006_migrate_favorability] forward: "
|
||||||
f"成功 {migrated_count},无设备跳过 {skipped_no_device},"
|
f"成功={migrated_count}, 无设备={skipped_no_device}, "
|
||||||
f"零值跳过 {skipped_zero}"
|
f"零值={skipped_zero}, 已迁移过={skipped_already_migrated}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def migrate_favorability_backward(apps, schema_editor):
|
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')
|
ParadiseUser = apps.get_model('userapp', 'ParadiseUser')
|
||||||
UserDevice = apps.get_model('device_interaction', 'UserDevice')
|
AffinityLog = apps.get_model('userapp', 'AffinityLog')
|
||||||
|
|
||||||
rolled_back = 0
|
rolled_back = 0
|
||||||
for user in ParadiseUser.objects.iterator():
|
skipped_no_metadata = 0
|
||||||
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
|
|
||||||
|
|
||||||
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):
|
class Migration(migrations.Migration):
|
||||||
|
|||||||
@ -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='来源'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -467,6 +467,8 @@ class AffinityLog(models.Model):
|
|||||||
('system_decay', '系统衰减'),
|
('system_decay', '系统衰减'),
|
||||||
('admin_adjust_single', '管理员单次调整'),
|
('admin_adjust_single', '管理员单次调整'),
|
||||||
('admin_adjust_batch', '管理员批量调整'),
|
('admin_adjust_batch', '管理员批量调整'),
|
||||||
|
# P1 收尾 CR-003:data migration 用作 0006 幂等标记
|
||||||
|
('data_migration', '数据迁移'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 关联对象
|
# 关联对象
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user