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。
226 lines
9.3 KiB
Python
226 lines
9.3 KiB
Python
"""好感度系统 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',
|
||
),
|
||
),
|
||
]
|