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。
This commit is contained in:
pmc 2026-05-13 10:18:47 +08:00
parent 2a28aa8b28
commit 61e8374e6a
7 changed files with 548 additions and 162 deletions

View File

@ -23,6 +23,37 @@
<!-- 新的修改记录添加在此处下方,最新的在最前面 --> <!-- 新的修改记录添加在此处下方,最新的在最前面 -->
### [2026-05-13] 好感度系统 P1 审查修复 D — WR-002~WR-009 + IN-001~IN-006 综合改进
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)WR-002 ~ WR-009 + IN-001 ~ IN-006
配套修复报告:[docs/REVIEW-affinity-P1-FIX-REPORT.md](REVIEW-affinity-P1-FIX-REPORT.md)
- **文件路径**:
- `userapp/models.py`(修改 — 多处AffinityLog 索引精简 / event_id null=True / `__str__` 用 pk 兜底UserLevelRewardGrant SET_NULL + device_snapshot_id + conditional uniqueAffinitySetting daily_cap → global_daily_capdescription 显式 default='';弃用字段加 [DEPRECATED] 版本标记ParadiseUser.favorability 标记 (已弃用)
- `userapp/serializers.py`(修改 — `UserInfoSerializer` 移除 `favorability` 字段暴露WR-008
- `userapp/affinity/__init__.py`**新建** — affinity 业务包入口)
- `userapp/affinity/defaults.py`**新建** — DEFAULT_RULES / DEFAULT_LEVELS / DEFAULT_SETTING 常量从 management command 抽出;新增 1 条 companion_30min 规则;所有 description 显式填写)
- `userapp/management/commands/seed_affinity.py`**重写** — 从 affinity.defaults 导入常量;去掉全局 `@transaction.atomic`,改为每条 spec 独立 `with transaction.atomic()`;新增 failed 计数与不同 style 输出,部分失败可重跑)
- `userapp/migrations/0009_affinity_p1_polish.py`**新建** — 手工修正 makemigrations 自动生成版本:把 `daily_cap → global_daily_cap` 改为 RenameField保留数据不是 Remove+Addevent_id `''` → NULL 数据兜底 RunPythonUserLevelRewardGrant on_delete + conditional unique索引精简弃用字段 help_text 升级)
- **修改类型**: 修复Bug + 重构
- **修改内容**:
- **WR-002**UserLevelRewardGrant.device `on_delete=CASCADE``SET_NULL`,加 `device_snapshot_id` 冗余字段save 时自动填充原 pk`unique_together=[('device','level')]``UniqueConstraint(fields=['device','level'], condition=Q(device__isnull=False))` 保证 device 已删的历史记录不参与唯一性
- **WR-003**AffinityLog 删除 3 个低价值索引user/rule_key/source 各 -created_at 复合),仅保留 (device, -created_at) 与 event_id partial unique
- **WR-004**AffinityLog.event_id `null=True`partial unique 条件 `event_id__gt=''``event_id__isnull=False`RunPython 把现有 `''` 改为 NULL
- **WR-005**DEFAULT_RULES 新增 1 条 `companion_30min`trigger_type=companion_time, min_continuous_minutes=30, max_count_per_day=4min/max change 1~2
- **WR-006**AffinityRule/AffinityLevel.description 显式 `default=''`DEFAULT_LEVELS 所有 entry 补 description
- **WR-007**seed_affinity 每条 spec 独立事务,部分失败不影响其他记录(去掉 handle 上的 @transaction.atomic
- **WR-008**ParadiseUser.favorability 字段保留(避免 0006 backward 失效)+ verbose_name 加 (已弃用)help_text 标 [DEPRECATED — P2 后删除]UserInfoSerializer 移除字段暴露。**未做 property 改造**Model field 与 property 同名冲突,必须先 RenameField 才能上 property本次只做软标记 + 序列化器清理(详见 FIX-REPORT 风险说明)
- **WR-009**:(已在 Commit B 的 AffinityLevel.clean() + save() 中实现 — DB 跨行约束 PG 表达不出,应用层多层兜底)
- **IN-001**5 个弃用字段AffinityRule.points / daily_limit / is_activeAffinityLevel.required_points / rewardshelp_text 加 `[DEPRECATED — 计划于 P2 完成后删除]` 显式版本标记
- **IN-002**DEFAULT_RULES / DEFAULT_LEVELS / DEFAULT_SETTING 抽到 `userapp/affinity/defaults.py`,供 seed / 单元测试 / P2 服务层复用
- **IN-003**AffinitySetting.daily_cap → global_daily_cap RenameField与 AffinityRule.daily_cap 区分);模型 / 校验 / 约束 / DEFAULT_SETTING 全部同步
- **IN-004**AffinityLog.\_\_str\_\_ 用 `self.pk or 'new'` 替代 `self.id`,未保存对象显示 `#new` 而非 `#None`
- **IN-005**:(已在 Commit A 完成 — is_active → is_bound 改名)
- **IN-006**:(已在 Commit C 完成 — 0006 print 前缀改为 `[migration 0006_migrate_favorability]`
- **修改原因**: P1 数据层审查中除 3 个 Critical 外的全部剩余项9 Warning + 6 Info一次性收尾避免遗留到 P2 服务层动工后修复成本上升同时保持每个修复项可独立追溯commit message + 修改记录条目)
- **跨项目联动**: 管理后台前端如已读取 AffinitySetting.daily_cap需要同步改为 global_daily_cap仅当 admin UI 暴露该字段时UserInfoSerializer 不再返回 favorability前端如有使用需改为查询 UserDevice 列表。其他改动对外接口无破坏
### [2026-05-13] 好感度系统 P1 审查修复 C — 0006 数据迁移幂等性修正CR-003 ### [2026-05-13] 好感度系统 P1 审查修复 C — 0006 数据迁移幂等性修正CR-003
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)CR-003 配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)CR-003

View File

@ -0,0 +1,5 @@
"""好感度系统业务包
子模块
defaults 默认规则 / 等级 / 设置常量 seed_affinity / 单元测试 / P2 服务层复用
"""

View File

@ -0,0 +1,140 @@
"""好感度系统默认数据常量
seed_affinity management command单元测试P2 服务层fallback 配置等共享
与设计文档对应
DEFAULT_RULES 好感度系统功能与规则设计.md§4.2 互动规则
DEFAULT_LEVELS 同文档 §6.2 等级表
DEFAULT_SETTING 同文档 §3.2 全局参数 + §5.1 衰减字段
IN-002 management/commands/seed_affinity.py 抽取避免 management command
文件膨胀且让测试代码 / P2 服务层可以正规 import
from userapp.affinity.defaults import DEFAULT_RULES, DEFAULT_LEVELS, DEFAULT_SETTING
"""
# 默认规则,与设计文档 §4.2 一致
# WR-005新增 1 条 trigger_type='companion_time' 规则(陪伴 30 分钟)
# WR-006所有 description 显式填写,不依赖 model blank=True 隐式默认
DEFAULT_RULES = [
{
'rule_key': 'card', 'name': '使用卡片', 'description': '用户使用洛天依卡片',
'trigger_type': 'action',
'min_change': 1, 'max_change': 3, 'single_cap': 3, 'daily_cap': 10,
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'chat', 'name': '对话', 'description': '与洛天依进行对话',
'trigger_type': 'action',
'min_change': 1, 'max_change': 5, 'single_cap': 5, 'daily_cap': 15,
'cooldown_seconds': 30, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'feed', 'name': '喂食', 'description': '给洛天依喂食',
'trigger_type': 'action',
'min_change': 2, 'max_change': 8, 'single_cap': 8, 'daily_cap': 16,
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'touch', 'name': '抚摸', 'description': '抚摸洛天依',
'trigger_type': 'action',
'min_change': 1, 'max_change': 3, 'single_cap': 3, 'daily_cap': 9,
'cooldown_seconds': 10, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'dress', 'name': '换装', 'description': '为洛天依更换服装',
'trigger_type': 'action',
'min_change': 2, 'max_change': 6, 'single_cap': 6, 'daily_cap': 12,
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'prop', 'name': '使用道具', 'description': '使用互动道具',
'trigger_type': 'action',
'min_change': 1, 'max_change': 4, 'single_cap': 4, 'daily_cap': 12,
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'gift', 'name': '送礼物', 'description': '赠送礼物给洛天依',
'trigger_type': 'action',
'min_change': 5, 'max_change': 15, 'single_cap': 15, 'daily_cap': 20,
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'decay', 'name': '无互动衰减', 'description': '长时间不互动导致好感度下降',
'trigger_type': 'decay',
'min_change': -3, 'max_change': -1, 'single_cap': 3, 'daily_cap': 5,
'cooldown_seconds': 0, 'is_negative': True, 'is_enabled': True,
},
# WR-005陪伴时长类规则数值参考设计文档 §4.2,具体值可由运营在 admin 调整)
{
'rule_key': 'companion_30min', 'name': '陪伴 30 分钟',
'description': '与洛天依持续陪伴 30 分钟可获得好感度(数值待产品最终对齐,先用保守默认)',
'trigger_type': 'companion_time',
'min_change': 1, 'max_change': 2, 'single_cap': 2, 'daily_cap': 8,
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
'min_continuous_minutes': 30, 'max_count_per_day': 4,
},
]
# 默认等级,与设计文档 §6.2 一致
# WR-006所有 description 显式填写
DEFAULT_LEVELS = [
{
'level': 1, 'name': '初识',
'description': '初次相识阶段,了解彼此的基础互动',
'min_affinity': 0, 'max_affinity': 20,
'unlock_content': '基础对话功能',
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
'is_enabled': True,
},
{
'level': 2, 'name': '相识',
'description': '熟悉彼此个性,解锁基础道具与服装',
'min_affinity': 21, 'max_affinity': 40,
'unlock_content': '基础服装、道具使用',
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
'is_enabled': True,
},
{
'level': 3, 'name': '熟悉',
'description': '互动深入,解锁更多内容',
'min_affinity': 41, 'max_affinity': 60,
'unlock_content': '更多服装、特殊对话',
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
'is_enabled': True,
},
{
'level': 4, 'name': '亲密',
'description': '亲密互动阶段,解锁限定内容',
'min_affinity': 61, 'max_affinity': 80,
'unlock_content': '限定服装、特殊互动',
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
'is_enabled': True,
},
{
'level': 5, 'name': '挚友',
'description': '最高亲密度,解锁专属剧情',
'min_affinity': 81, 'max_affinity': 100,
'unlock_content': '专属内容、特殊剧情',
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
'is_enabled': True,
},
]
# 默认全局设置(用于 seed_affinity 在表为空时创建)
DEFAULT_SETTING = {
'initial_affinity': 10,
'max_affinity': 100,
'global_daily_cap': 20, # IN-003重命名 daily_cap → global_daily_cap
'decay_rate': 2,
'decay_threshold': 3,
'decay_min_decay': 1,
'decay_max_decay': 3,
'decay_cap': 5,
'decay_min_floor': 0,
'enable_notify': True,
'enable_rewards': True,
'notify_decay': True,
'timezone': 'Asia/Shanghai',
}

View File

@ -6,110 +6,23 @@
写入内容好感度系统功能与规则设计.md§4.2 / §6.2 一致 写入内容好感度系统功能与规则设计.md§4.2 / §6.2 一致
1. AffinitySetting 单例如不存在 1. AffinitySetting 单例如不存在
2. 8 条默认互动规则 2. 9 条默认互动规则 1 companion_time 规则 WR-005
3. 5 个默认等级 3. 5 个默认等级
幂等性 幂等性
默认按 rule_key规则/ level等级查询已存在则跳过 默认按 rule_key规则/ level等级查询已存在则跳过
--force 模式下覆盖已存在记录的字段不删旧记录 --force 模式下覆盖已存在记录的字段不删旧记录
WR-007 修正
每条 spec 独立事务部分失败不影响其他记录避免 force 模式下第 N 条崩
导致前 N-1 条全部回滚但 stdout 已打印 "成功"的语义错位
""" """
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction from django.db import transaction
from userapp.models import ( from userapp.models import AffinityLevel, AffinityRule, AffinitySetting
AffinityRule, from userapp.affinity.defaults import DEFAULT_LEVELS, DEFAULT_RULES, DEFAULT_SETTING
AffinityLevel,
AffinitySetting,
)
# 默认规则,与设计文档 §4.2 一致
DEFAULT_RULES = [
{
'rule_key': 'card', 'name': '使用卡片', 'description': '用户使用洛天依卡片',
'trigger_type': 'action',
'min_change': 1, 'max_change': 3, 'single_cap': 3, 'daily_cap': 10,
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'chat', 'name': '对话', 'description': '与洛天依进行对话',
'trigger_type': 'action',
'min_change': 1, 'max_change': 5, 'single_cap': 5, 'daily_cap': 15,
'cooldown_seconds': 30, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'feed', 'name': '喂食', 'description': '给洛天依喂食',
'trigger_type': 'action',
'min_change': 2, 'max_change': 8, 'single_cap': 8, 'daily_cap': 16,
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'touch', 'name': '抚摸', 'description': '抚摸洛天依',
'trigger_type': 'action',
'min_change': 1, 'max_change': 3, 'single_cap': 3, 'daily_cap': 9,
'cooldown_seconds': 10, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'dress', 'name': '换装', 'description': '为洛天依更换服装',
'trigger_type': 'action',
'min_change': 2, 'max_change': 6, 'single_cap': 6, 'daily_cap': 12,
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'prop', 'name': '使用道具', 'description': '使用互动道具',
'trigger_type': 'action',
'min_change': 1, 'max_change': 4, 'single_cap': 4, 'daily_cap': 12,
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'gift', 'name': '送礼物', 'description': '赠送礼物给洛天依',
'trigger_type': 'action',
'min_change': 5, 'max_change': 15, 'single_cap': 15, 'daily_cap': 20,
'cooldown_seconds': 0, 'is_negative': False, 'is_enabled': True,
},
{
'rule_key': 'decay', 'name': '无互动衰减', 'description': '长时间不互动导致好感度下降',
'trigger_type': 'decay',
'min_change': -3, 'max_change': -1, 'single_cap': 3, 'daily_cap': 5,
'cooldown_seconds': 0, 'is_negative': True, 'is_enabled': True,
},
]
# 默认等级,与设计文档 §6.2 一致
DEFAULT_LEVELS = [
{
'level': 1, 'name': '初识', 'min_affinity': 0, 'max_affinity': 20,
'unlock_content': '基础对话功能',
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
'is_enabled': True,
},
{
'level': 2, 'name': '相识', 'min_affinity': 21, 'max_affinity': 40,
'unlock_content': '基础服装、道具使用',
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
'is_enabled': True,
},
{
'level': 3, 'name': '熟悉', 'min_affinity': 41, 'max_affinity': 60,
'unlock_content': '更多服装、特殊对话',
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
'is_enabled': True,
},
{
'level': 4, 'name': '亲密', 'min_affinity': 61, 'max_affinity': 80,
'unlock_content': '限定服装、特殊互动',
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
'is_enabled': True,
},
{
'level': 5, 'name': '挚友', 'min_affinity': 81, 'max_affinity': 100,
'unlock_content': '专属内容、特殊剧情',
'reward_type': 'unlock', 'reward_currency': 0, 'reward_items': [],
'is_enabled': True,
},
]
class Command(BaseCommand): class Command(BaseCommand):
@ -121,32 +34,40 @@ class Command(BaseCommand):
help='强制覆盖已存在记录的字段(不删旧记录)', help='强制覆盖已存在记录的字段(不删旧记录)',
) )
@transaction.atomic
def handle(self, *args, **options): def handle(self, *args, **options):
force = options['force'] force = options['force']
# WR-007每条独立事务部分失败可重跑不再用全局 @transaction.atomic
self._seed_setting() self._seed_setting()
rules_created, rules_updated = self._seed_rules(force) rules_created, rules_updated, rules_failed = self._seed_rules(force)
levels_created, levels_updated = self._seed_levels(force) levels_created, levels_updated, levels_failed = self._seed_levels(force)
self.stdout.write(self.style.SUCCESS( style = self.style.WARNING if (rules_failed + levels_failed) else self.style.SUCCESS
self.stdout.write(style(
f'\n[seed_affinity] 完成:' f'\n[seed_affinity] 完成:'
f'规则 创建 {rules_created} 更新 {rules_updated}' f'规则 创建 {rules_created} 更新 {rules_updated} 失败 {rules_failed}'
f'等级 创建 {levels_created} 更新 {levels_updated}' f'等级 创建 {levels_created} 更新 {levels_updated} 失败 {levels_failed}'
)) ))
def _seed_setting(self): def _seed_setting(self):
try:
with transaction.atomic():
if AffinitySetting.objects.exists(): if AffinitySetting.objects.exists():
self.stdout.write('AffinitySetting 已存在,跳过') self.stdout.write('AffinitySetting 已存在,跳过')
return return
AffinitySetting.objects.create() # 从 DEFAULT_SETTING 取所有字段(含 IN-003 改名后的 global_daily_cap
AffinitySetting.objects.create(**DEFAULT_SETTING)
self.stdout.write(self.style.SUCCESS('AffinitySetting 已创建(默认值)')) self.stdout.write(self.style.SUCCESS('AffinitySetting 已创建(默认值)'))
except Exception as e:
self.stderr.write(self.style.ERROR(f'AffinitySetting 创建失败:{e}'))
def _seed_rules(self, force): def _seed_rules(self, force):
created = 0 created = 0
updated = 0 updated = 0
failed = 0
for spec in DEFAULT_RULES: for spec in DEFAULT_RULES:
rule_key = spec['rule_key'] rule_key = spec['rule_key']
try:
with transaction.atomic():
existing = AffinityRule.objects.filter(rule_key=rule_key).first() existing = AffinityRule.objects.filter(rule_key=rule_key).first()
if existing is None: if existing is None:
AffinityRule.objects.create(**spec) AffinityRule.objects.create(**spec)
@ -160,15 +81,25 @@ class Command(BaseCommand):
self.stdout.write(f' ~ 规则 {rule_key} 已覆盖') self.stdout.write(f' ~ 规则 {rule_key} 已覆盖')
else: else:
self.stdout.write(f' - 规则 {rule_key} 已存在,跳过') self.stdout.write(f' - 规则 {rule_key} 已存在,跳过')
return created, updated except Exception as e:
failed += 1
self.stderr.write(self.style.ERROR(f' ! 规则 {rule_key} 处理失败:{e}'))
continue
return created, updated, failed
def _seed_levels(self, force): def _seed_levels(self, force):
created = 0 created = 0
updated = 0 updated = 0
failed = 0
for spec in DEFAULT_LEVELS: for spec in DEFAULT_LEVELS:
level_num = spec['level'] level_num = spec['level']
try:
with transaction.atomic():
existing = AffinityLevel.objects.filter(level=level_num).first() existing = AffinityLevel.objects.filter(level=level_num).first()
if existing is None: if existing is None:
# 用 skip_clean=True 跳过 save 内 full_clean区间重叠校验依赖已存在 levels
# 但当前批量 seed 是按 level 升序逐条 commit跨记录关系会随插入逐步成立
# 这里仍执行 clean 以保证区间合法
AffinityLevel.objects.create(**spec) AffinityLevel.objects.create(**spec)
created += 1 created += 1
self.stdout.write(f' + 等级 Lv{level_num} 已创建') self.stdout.write(f' + 等级 Lv{level_num} 已创建')
@ -180,4 +111,8 @@ class Command(BaseCommand):
self.stdout.write(f' ~ 等级 Lv{level_num} 已覆盖') self.stdout.write(f' ~ 等级 Lv{level_num} 已覆盖')
else: else:
self.stdout.write(f' - 等级 Lv{level_num} 已存在,跳过') self.stdout.write(f' - 等级 Lv{level_num} 已存在,跳过')
return created, updated except Exception as e:
failed += 1
self.stderr.write(self.style.ERROR(f' ! 等级 Lv{level_num} 处理失败:{e}'))
continue
return created, updated, failed

View File

@ -0,0 +1,225 @@
"""好感度系统 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',
),
),
]

View File

@ -27,7 +27,17 @@ class ParadiseUser(AbstractUser):
('ISTP', 'ISTP'), ('ISFP', 'ISFP'), ('ESTP', 'ESTP'), ('ESFP', 'ESFP'), ('ISTP', 'ISTP'), ('ISFP', 'ISFP'), ('ESTP', 'ESTP'), ('ESFP', 'ESFP'),
) )
favorability = models.IntegerField('好感度', default=0) # WR-008 [DEPRECATED — 计划于 P2 完成后删除]
# 好感度已下沉到 UserDevice.favorability设备级P1-09 数据迁移 0006 已搬数据。
# 该字段保留是为了:
# 1. backward 兼容(旧 fixtures / 老 API 客户端)
# 2. 0006 backward 回滚目标(详见 0006 migration metadata
# 新代码**禁止**读写此字段UserInfoSerializer 已从此字段移除暴露。
# 移除计划P2 服务层稳定上线 2 周后做迁移 RemoveField。
favorability = models.IntegerField(
'好感度(已弃用)', default=0,
help_text='[DEPRECATED — P2 后删除] 已下沉到 UserDevice.favorability'
)
gender = models.CharField('性别', max_length=1, choices=GENDER_CHOICES, null=True, blank=True) gender = models.CharField('性别', max_length=1, choices=GENDER_CHOICES, null=True, blank=True)
resident_city = models.CharField('常驻城市', max_length=50, null=True, blank=True) resident_city = models.CharField('常驻城市', max_length=50, null=True, blank=True)
birthday = models.DateField('生日', null=True, blank=True) birthday = models.DateField('生日', null=True, blank=True)
@ -102,7 +112,7 @@ class AffinityRule(models.Model):
help_text='代码标识,客户端事件通过此 key 匹配规则(如 chat/sing/dance/touch...' help_text='代码标识,客户端事件通过此 key 匹配规则(如 chat/sing/dance/touch...'
) )
name = models.CharField('规则名称', max_length=100) name = models.CharField('规则名称', max_length=100)
description = models.TextField('规则描述', blank=True) description = models.TextField('规则描述', blank=True, default='')
trigger_type = models.CharField( trigger_type = models.CharField(
'触发类型', max_length=20, choices=TRIGGER_TYPE_CHOICES, default='action' '触发类型', max_length=20, choices=TRIGGER_TYPE_CHOICES, default='action'
) )
@ -146,18 +156,19 @@ class AffinityRule(models.Model):
help_text='trigger_type=companion_time 时使用:每台设备每日最多触发次数' help_text='trigger_type=companion_time 时使用:每台设备每日最多触发次数'
) )
# 兼容旧字段(已弃用,下个版本删除) # 兼容旧字段(已弃用,计划于 P2 完成后删除 — 详见 docs/好感度系统-开发任务清单.md "P1 收尾"
# IN-001显式标注预期删除版本便于后续 grep 清理;新代码禁止读写这些字段。
points = models.IntegerField( points = models.IntegerField(
'积分(已弃用)', default=0, '积分(已弃用)', default=0,
help_text='已弃用,使用 min_change/max_change' help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_change/max_change 替代'
) )
daily_limit = models.IntegerField( daily_limit = models.IntegerField(
'每日上限(已弃用)', null=True, blank=True, '每日上限(已弃用)', null=True, blank=True,
help_text='已弃用,使用 daily_cap' help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 daily_cap 替代'
) )
is_active = models.BooleanField( is_active = models.BooleanField(
'已启用(已弃用)', default=True, '已启用(已弃用)', default=True,
help_text='已弃用,使用 is_enabled' help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 is_enabled 替代'
) )
created_at = models.DateTimeField('创建时间', auto_now_add=True) created_at = models.DateTimeField('创建时间', auto_now_add=True)
@ -240,7 +251,7 @@ class AffinityLevel(models.Model):
level = models.IntegerField('等级', unique=True) level = models.IntegerField('等级', unique=True)
name = models.CharField('等级名称', max_length=50) name = models.CharField('等级名称', max_length=50)
description = models.TextField('等级描述', blank=True) description = models.TextField('等级描述', blank=True, default='')
# 区间P1-03 # 区间P1-03
min_affinity = models.IntegerField( min_affinity = models.IntegerField(
@ -269,14 +280,15 @@ class AffinityLevel(models.Model):
is_enabled = models.BooleanField('已启用', default=True) is_enabled = models.BooleanField('已启用', default=True)
is_deleted = models.BooleanField('已删除(软删除)', default=False) is_deleted = models.BooleanField('已删除(软删除)', default=False)
# 兼容旧字段(已弃用) # 兼容旧字段(已弃用,计划于 P2 完成后删除)
# IN-001显式标注预期删除版本便于后续 grep 清理;新代码禁止读写这些字段。
required_points = models.IntegerField( required_points = models.IntegerField(
'所需积分(已弃用)', default=0, '所需积分(已弃用)', default=0,
help_text='已弃用,使用 min_affinity/max_affinity' help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_affinity/max_affinity 替代'
) )
rewards = models.JSONField( rewards = models.JSONField(
'奖励(已弃用)', default=list, '奖励(已弃用)', default=list,
help_text='已弃用,使用 reward_currency/reward_items' help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 reward_currency/reward_items 替代'
) )
created_at = models.DateTimeField('创建时间', auto_now_add=True) created_at = models.DateTimeField('创建时间', auto_now_add=True)
@ -354,9 +366,12 @@ class AffinitySetting(models.Model):
'最大好感度', default=100, '最大好感度', default=100,
help_text='好感度上限(管理员手动调整也不能突破)' help_text='好感度上限(管理员手动调整也不能突破)'
) )
daily_cap = models.IntegerField( # IN-003原名 daily_cap因与 AffinityRule.daily_cap 同名易混淆,改为 global_daily_cap
global_daily_cap = models.IntegerField(
'每日全局增长上限', default=20, '每日全局增长上限', default=20,
help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)' help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)。'
'与 AffinityRule.daily_cap单规则上限区分使用'
'P2 服务层需同时校验两者。'
) )
# 衰减 # 衰减
@ -408,7 +423,7 @@ class AffinitySetting(models.Model):
name='affinitysetting_floor_le_max', name='affinitysetting_floor_le_max',
), ),
CheckConstraint( CheckConstraint(
check=Q(daily_cap__gt=0), check=Q(global_daily_cap__gt=0),
name='affinitysetting_daily_cap_positive', name='affinitysetting_daily_cap_positive',
), ),
# 单例硬约束:所有写入必须 pk=1CHECK 约束不能跨行限制行数, # 单例硬约束:所有写入必须 pk=1CHECK 约束不能跨行限制行数,
@ -435,8 +450,8 @@ class AffinitySetting(models.Model):
errors['initial_affinity'] = '初始好感度不能超过最大好感度' errors['initial_affinity'] = '初始好感度不能超过最大好感度'
if self.decay_min_floor > self.max_affinity: if self.decay_min_floor > self.max_affinity:
errors['decay_min_floor'] = '衰减下限不能超过最大好感度' errors['decay_min_floor'] = '衰减下限不能超过最大好感度'
if self.daily_cap <= 0: if self.global_daily_cap <= 0:
errors['daily_cap'] = '每日全局增长上限必须大于 0' errors['global_daily_cap'] = '每日全局增长上限必须大于 0'
if errors: if errors:
raise ValidationError(errors) raise ValidationError(errors)
@ -498,9 +513,12 @@ class AffinityLog(models.Model):
# 来源 # 来源
source = models.CharField('来源', max_length=30, choices=SOURCE_CHOICES) source = models.CharField('来源', max_length=30, choices=SOURCE_CHOICES)
# WR-004event_id 用 NULL 表示「无客户端事件 ID」衰减 / 管理员调整等),
# 替代旧的 '' 空字符串约定(避免空格 / 'null' 字符串等绕过 partial unique
event_id = models.CharField( event_id = models.CharField(
'事件ID', max_length=64, blank=True, db_index=True, '事件ID', max_length=64, null=True, blank=True, db_index=True,
help_text='客户端事件 UUID用于幂等去重' help_text='客户端事件 UUID建议格式UUID v4用于幂等去重'
'NULL 表示非客户端来源(衰减 / 管理员调整 / 数据迁移)'
) )
# 管理员调整专用 # 管理员调整专用
@ -525,22 +543,27 @@ class AffinityLog(models.Model):
verbose_name = '好感度变化日志' verbose_name = '好感度变化日志'
verbose_name_plural = '好感度变化日志' verbose_name_plural = '好感度变化日志'
ordering = ['-created_at'] ordering = ['-created_at']
# WR-003精简索引以降低写入开销
# 保留:(device, -created_at) — 客户端拉取最近变化主路径
# 保留created_at 单字段auto_now_add 加 db_index=True 已生成,作管理后台时间排序)
# 删除:(user, -created_at) — 可经 device 关联查询替代
# 删除:(rule_key, -created_at) — 查询频率低
# 删除:(source, -created_at) — 低基数列,配合 created_at 索引扫描即可
indexes = [ indexes = [
models.Index(fields=['device', '-created_at']), models.Index(fields=['device', '-created_at']),
models.Index(fields=['user', '-created_at']),
models.Index(fields=['rule_key', '-created_at']),
models.Index(fields=['source', '-created_at']),
] ]
constraints = [ constraints = [
# WR-004event_id 改用 NULL 语义partial unique 条件改为 isnull=False
UniqueConstraint( UniqueConstraint(
fields=['event_id'], fields=['event_id'],
condition=Q(event_id__gt=''), condition=Q(event_id__isnull=False),
name='unique_affinity_event_id', name='unique_affinity_event_id',
), ),
] ]
def __str__(self): def __str__(self):
return f"#{self.id} {self.rule_key or self.source} {self.before_value}->{self.after_value}" # IN-004未保存时 self.pk 为 None回退到 'new' 避免显示 #None
return f"#{self.pk or 'new'} {self.rule_key or self.source} {self.before_value}->{self.after_value}"
class UserAffinityDailyCounter(models.Model): class UserAffinityDailyCounter(models.Model):
@ -588,11 +611,21 @@ class UserLevelRewardGrant(models.Model):
(device, level) 唯一永久幂等防止重复发放 (device, level) 唯一永久幂等防止重复发放
衰减回升再次跨过同等级也不补发新增等级时已在区间的设备不立即触发 衰减回升再次跨过同等级也不补发新增等级时已在区间的设备不立即触发
reward_snapshot 保存发放时的奖励内容快照避免 AffinityLevel 后续修改影响审计 reward_snapshot 保存发放时的奖励内容快照避免 AffinityLevel 后续修改影响审计
WR-002 修正
- on_delete 改为 SET_NULL AffinityLog.device 保持一致 历史保留
- 新增 device_snapshot_id 冗余字段device 被删后仍可追溯原 device pk
- unique_together 改为条件唯一约束device IS NULL 时不参与唯一性
""" """
device = models.ForeignKey( device = models.ForeignKey(
'device_interaction.UserDevice', on_delete=models.CASCADE, 'device_interaction.UserDevice', on_delete=models.SET_NULL,
verbose_name='用户设备绑定', related_name='level_reward_grants' verbose_name='用户设备绑定', related_name='level_reward_grants',
null=True, blank=True,
)
device_snapshot_id = models.IntegerField(
'设备 ID 快照', null=True, blank=True, db_index=True,
help_text='发放时的 UserDevice pk 快照device 被删后仍可审计'
) )
level = models.IntegerField('等级') level = models.IntegerField('等级')
granted_at = models.DateTimeField('发放时间', auto_now_add=True) granted_at = models.DateTimeField('发放时间', auto_now_add=True)
@ -604,8 +637,22 @@ class UserLevelRewardGrant(models.Model):
class Meta: class Meta:
verbose_name = '等级奖励发放记录' verbose_name = '等级奖励发放记录'
verbose_name_plural = '等级奖励发放记录' verbose_name_plural = '等级奖励发放记录'
unique_together = [('device', 'level')]
ordering = ['-granted_at'] ordering = ['-granted_at']
constraints = [
# device 非空时 (device, level) 唯一device 已删除NULL的历史记录不参与唯一
UniqueConstraint(
fields=['device', 'level'],
condition=Q(device__isnull=False),
name='unique_grant_device_level',
),
]
def __str__(self): def __str__(self):
return f"{self.device_id}/Lv{self.level}@{self.granted_at}" ref = self.device_id if self.device_id is not None else f'snap:{self.device_snapshot_id}'
return f"{ref}/Lv{self.level}@{self.granted_at}"
def save(self, *args, **kwargs):
# 首次保存时自动填充 device_snapshot_iddevice 被删后仍可追溯原 pk
if self.device_id and not self.device_snapshot_id:
self.device_snapshot_id = self.device_id
super().save(*args, **kwargs)

View File

@ -23,11 +23,14 @@ class UserInfoSerializer(serializers.ModelSerializer):
""" """
用于展示用户信息的序列化器 用于展示用户信息的序列化器
用户自己查看 用户自己查看
WR-008移除已弃用的 favorability 字段已下沉到 UserDevice.favorability
前端需要显示好感度时应通过设备级接口查询 UserDevice 列表
""" """
class Meta: class Meta:
model = ParadiseUser model = ParadiseUser
fields = ['id', 'username', 'email', 'phone_number', 'date_joined', 'last_login', fields = ['id', 'username', 'email', 'phone_number', 'date_joined', 'last_login',
'favorability', 'gender', 'resident_city', 'birthday', 'zodiac_sign', 'gender', 'resident_city', 'birthday', 'zodiac_sign',
'mbti', 'interests', 'social_identity'] 'mbti', 'interests', 'social_identity']
read_only_fields = ['id', 'date_joined', 'last_login'] read_only_fields = ['id', 'date_joined', 'last_login']