"""好感度系统 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', ), ), ]