lty/qy_lty/userapp/migrations/0009_affinity_p1_polish.py
pmc 61e8374e6a fix(affinity-P1): WR-002~WR-009 + IN-001~IN-006 综合改进收尾
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。
2026-05-13 10:18:47 +08:00

226 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""好感度系统 P1 收尾综合迁移 — WR-002 / WR-003 / WR-004 / WR-008 / IN-001 / IN-003
操作内容:
1. WR-002UserLevelRewardGrant.device on_delete CASCADE → SET_NULL加 device_snapshot_id
unique_together 改为 partial uniquedevice 非空时)
2. WR-003AffinityLog 精简索引(删除 user/rule_key/source 三个 -created_at 复合索引)
3. WR-004AffinityLog.event_id 改为 null=Truepartial unique 条件由 event_id__gt='' 改为
event_id__isnull=FalseRunPython 把现有 '' 改为 NULL
4. WR-008ParadiseUser.favorability verbose_name 加 (已弃用) 标记
5. IN-001AffinityRule/AffinityLevel 5 个弃用字段 help_text 加 [DEPRECATED] 版本标记
6. IN-003AffinitySetting.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 RENAMEO(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-002UserLevelRewardGrant 加 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-006description 显式 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-008ParadiseUser.favorability 标记为弃用 ======
migrations.AlterField(
model_name='paradiseuser',
name='favorability',
field=models.IntegerField(
default=0,
help_text='[DEPRECATED — P2 后删除] 已下沉到 UserDevice.favorability',
verbose_name='好感度(已弃用)',
),
),
# ====== WR-004event_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',
),
),
]