fix(affinity-P1): CR-002 + WR-001 加 Affinity 模型 DB CHECK 约束 + 单例硬约束

AffinityRule / AffinityLevel / AffinitySetting 三表共 13 条 CheckConstraint
覆盖 min ≤ max / 各类 cap > 0 / cooldown ≥ 0 / companion_time 配套字段必填 /
decay 区间合法 / initial ≤ max 等不变量。

AffinitySetting 加 pk=1 单例硬约束(CR-002 + WR-001 联动)+ save() 强制 pk=1,
形成事实单例防御并发首次插入重复行。

模型 clean() 提供 Python 级兜底(给 DRF / admin 友好错误信息);
AffinityLevel.save() 自动 full_clean 触发跨行区间不重叠校验(为 WR-009 铺路)。

详见 docs/REVIEW-affinity-P1.md CR-002 / WR-001。
This commit is contained in:
pmc 2026-05-13 10:12:01 +08:00
parent 33b302c773
commit 9a87f5e2b5
3 changed files with 230 additions and 6 deletions

View File

@ -23,6 +23,26 @@
<!-- 新的修改记录添加在此处下方,最新的在最前面 --> <!-- 新的修改记录添加在此处下方,最新的在最前面 -->
### [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-FIX-REPORT.md](REVIEW-affinity-P1-FIX-REPORT.md)
- **文件路径**:
- `userapp/models.py`(修改 — `AffinityRule.Meta.constraints` 加 5 条 CheckConstraint + `clean()``AffinityLevel.Meta.constraints` 加 2 条 CheckConstraint + `clean()` + `save()` 自动 full_clean`AffinitySetting.Meta.constraints` 加 6 条 CheckConstraint含 pk=1 单例硬约束)+ `clean()` + `save()` 强制 pk=1imports 段补 CheckConstraint / F / ValidationError
- `userapp/migrations/0007_add_affinity_check_constraints.py`**新建** — 由 makemigrations 自动生成13 条 AddConstraint
- **修改类型**: 新增 + 修复Bug
- **修改内容**:
- **CR-002Critical**
- AffinityRule`min_change ≤ max_change` / `cooldown_seconds ≥ 0` / `single_cap > 0` / `daily_cap > 0` / 陪伴时长规则必须设置 `min_continuous_minutes > 0 ∧ max_count_per_day > 0`
- AffinityLevel`min_affinity ≤ max_affinity` / `reward_currency ≥ 0`
- AffinitySetting`decay_min_decay ≤ decay_max_decay ≤ decay_cap` / `initial_affinity ≤ max_affinity` / `decay_min_floor ≤ max_affinity` / `daily_cap > 0`
- **WR-001Warning**AffinitySetting 加 `pk=1` 单例硬约束 + save() 强制 pk=1配合形成事实单例CHECK 约束跨行不可,但能阻止任何非 1 主键的写入)
- 所有模型 `clean()` 提供 Python 级兜底,给 DRF / admin 友好错误信息DB 级 CheckConstraint 是最终防线)
- AffinityLevel.save() 自动调 full_clean 触发跨等级区间不重叠校验WR-009 多层兜底,详见 Commit D提供 `skip_clean=True` 后门给迁移 / fixture 场景
- **修改原因**: P1 审查指出(详见 REVIEW-affinity-P1.md CR-002模型字段对管理后台 / shell / 直 SQL 写入毫无保护P2 服务层 `random.randint(rule.min_change, rule.max_change)` 等运算会被脏数据击穿ValueError 抛出、冷却永久解锁、上限永远命中等);同时审查中 WR-001 指出 AffinitySetting 单例保证在并发下脆弱
- **跨项目联动**: 无 — 仅服务端 DB 约束 + 模型校验层;管理后台前端在写入前应捕获 ValidationError 显示给运营,但接口契约本身未动
### [2026-05-13] 好感度系统 P1 审查修复 A — UserDevice 软删语义修正CR-001 + IN-005 ### [2026-05-13] 好感度系统 P1 审查修复 A — UserDevice 软删语义修正CR-001 + IN-005
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)CR-001 + IN-005 配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)CR-001 + IN-005

View File

@ -0,0 +1,65 @@
# Generated by Django 5.2.12 on 2026-05-13 02:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('userapp', '0006_migrate_favorability_to_userdevice'),
]
operations = [
migrations.AddConstraint(
model_name='affinitylevel',
constraint=models.CheckConstraint(condition=models.Q(('min_affinity__lte', models.F('max_affinity'))), name='affinitylevel_min_le_max'),
),
migrations.AddConstraint(
model_name='affinitylevel',
constraint=models.CheckConstraint(condition=models.Q(('reward_currency__gte', 0)), name='affinitylevel_currency_nonneg'),
),
migrations.AddConstraint(
model_name='affinityrule',
constraint=models.CheckConstraint(condition=models.Q(('min_change__lte', models.F('max_change'))), name='affinityrule_min_le_max'),
),
migrations.AddConstraint(
model_name='affinityrule',
constraint=models.CheckConstraint(condition=models.Q(('cooldown_seconds__gte', 0)), name='affinityrule_cooldown_nonneg'),
),
migrations.AddConstraint(
model_name='affinityrule',
constraint=models.CheckConstraint(condition=models.Q(('single_cap__gt', 0)), name='affinityrule_single_cap_positive'),
),
migrations.AddConstraint(
model_name='affinityrule',
constraint=models.CheckConstraint(condition=models.Q(('daily_cap__gt', 0)), name='affinityrule_daily_cap_positive'),
),
migrations.AddConstraint(
model_name='affinityrule',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('trigger_type', 'companion_time'), _negated=True), models.Q(('min_continuous_minutes__gt', 0), ('max_count_per_day__gt', 0)), _connector='OR'), name='affinityrule_companion_fields_present'),
),
migrations.AddConstraint(
model_name='affinitysetting',
constraint=models.CheckConstraint(condition=models.Q(('decay_min_decay__lte', models.F('decay_max_decay'))), name='affinitysetting_decay_min_le_max'),
),
migrations.AddConstraint(
model_name='affinitysetting',
constraint=models.CheckConstraint(condition=models.Q(('decay_max_decay__lte', models.F('decay_cap'))), name='affinitysetting_decay_within_cap'),
),
migrations.AddConstraint(
model_name='affinitysetting',
constraint=models.CheckConstraint(condition=models.Q(('initial_affinity__lte', models.F('max_affinity'))), name='affinitysetting_initial_le_max'),
),
migrations.AddConstraint(
model_name='affinitysetting',
constraint=models.CheckConstraint(condition=models.Q(('decay_min_floor__lte', models.F('max_affinity'))), name='affinitysetting_floor_le_max'),
),
migrations.AddConstraint(
model_name='affinitysetting',
constraint=models.CheckConstraint(condition=models.Q(('daily_cap__gt', 0)), name='affinitysetting_daily_cap_positive'),
),
migrations.AddConstraint(
model_name='affinitysetting',
constraint=models.CheckConstraint(condition=models.Q(('pk', 1)), name='affinitysetting_singleton'),
),
]

View File

@ -1,7 +1,8 @@
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser, Group, Permission from django.contrib.auth.models import AbstractUser, Group, Permission
from django.db.models import UniqueConstraint, Q from django.db.models import CheckConstraint, F, Q, UniqueConstraint
from rest_framework.authtoken.models import Token as DefaultToken from rest_framework.authtoken.models import Token as DefaultToken
@ -166,10 +167,56 @@ class AffinityRule(models.Model):
verbose_name = '好感度规则' verbose_name = '好感度规则'
verbose_name_plural = '好感度规则' verbose_name_plural = '好感度规则'
ordering = ['-created_at'] ordering = ['-created_at']
constraints = [
CheckConstraint(
check=Q(min_change__lte=F('max_change')),
name='affinityrule_min_le_max',
),
CheckConstraint(
check=Q(cooldown_seconds__gte=0),
name='affinityrule_cooldown_nonneg',
),
CheckConstraint(
check=Q(single_cap__gt=0),
name='affinityrule_single_cap_positive',
),
CheckConstraint(
check=Q(daily_cap__gt=0),
name='affinityrule_daily_cap_positive',
),
# trigger_type='companion_time' 时必须配套 min_continuous_minutes / max_count_per_day
CheckConstraint(
check=(~Q(trigger_type='companion_time')) |
(Q(min_continuous_minutes__gt=0) & Q(max_count_per_day__gt=0)),
name='affinityrule_companion_fields_present',
),
]
def __str__(self): def __str__(self):
return f"{self.name} ({self.rule_key or '-'})" return f"{self.name} ({self.rule_key or '-'})"
def clean(self):
"""Python 级兜底校验 — 在 admin / serializer 调用 full_clean() 时触发,
给前端友好错误信息DB CheckConstraint 是最终防线详见 Meta.constraints
"""
super().clean()
errors = {}
if self.min_change > self.max_change:
errors['max_change'] = '最大变化值不能小于最小变化值'
if self.cooldown_seconds < 0:
errors['cooldown_seconds'] = '冷却时间不能为负数'
if self.single_cap <= 0:
errors['single_cap'] = '单次上限必须大于 0'
if self.daily_cap <= 0:
errors['daily_cap'] = '每日上限必须大于 0'
if self.trigger_type == 'companion_time':
if not self.min_continuous_minutes or self.min_continuous_minutes <= 0:
errors['min_continuous_minutes'] = '陪伴时长规则必须设置 min_continuous_minutes > 0'
if not self.max_count_per_day or self.max_count_per_day <= 0:
errors['max_count_per_day'] = '陪伴时长规则必须设置 max_count_per_day > 0'
if errors:
raise ValidationError(errors)
class AffinityLevel(models.Model): class AffinityLevel(models.Model):
"""好感度等级:定义不同好感度区间对应的等级和奖励 """好感度等级:定义不同好感度区间对应的等级和奖励
@ -239,10 +286,57 @@ class AffinityLevel(models.Model):
verbose_name = '好感度等级' verbose_name = '好感度等级'
verbose_name_plural = '好感度等级' verbose_name_plural = '好感度等级'
ordering = ['level'] ordering = ['level']
constraints = [
CheckConstraint(
check=Q(min_affinity__lte=F('max_affinity')),
name='affinitylevel_min_le_max',
),
CheckConstraint(
check=Q(reward_currency__gte=0),
name='affinitylevel_currency_nonneg',
),
]
def __str__(self): def __str__(self):
return f"Lv{self.level} {self.name}" return f"Lv{self.level} {self.name}"
def clean(self):
"""Python 级兜底校验 — 检查区间合法 + 与同表其它等级不重叠。
跨行不重叠约束 PostgreSQL CheckConstraint 表达不出 ExclusionConstraint +
btree_gist 扩展所以放在应用层DB 仅约束单行 min max 一致性
详见审查报告 WR-009
"""
super().clean()
errors = {}
if self.min_affinity > self.max_affinity:
errors['max_affinity'] = '上限不能小于下限'
if self.reward_currency < 0:
errors['reward_currency'] = '虚拟货币奖励不能为负'
# 与其它等级的区间重叠检查(同表多行,仅 Python 层校验)
if not errors:
overlaps = type(self).objects.exclude(pk=self.pk).filter(
is_deleted=False,
min_affinity__lte=self.max_affinity,
max_affinity__gte=self.min_affinity,
)
if overlaps.exists():
names = ', '.join(f'Lv{l.level}' for l in overlaps[:5])
errors['min_affinity'] = f'与已存在等级 {names} 区间重叠'
if errors:
raise ValidationError(errors)
def save(self, *args, **kwargs):
"""save 前触发 full_clean确保跨行约束区间不重叠在任何写入路径都生效。
迁移文件内使用历史模型apps.get_model不会触发此 save 重载
因此数据迁移可以批量写入而不被校验正常业务代码 / admin / DRF 路径会校验
"""
# 跳过自动 clean 的开关(迁移 / fixture / seed 场景可显式传 skip_clean=True
if not kwargs.pop('skip_clean', False):
self.full_clean()
super().save(*args, **kwargs)
class AffinitySetting(models.Model): class AffinitySetting(models.Model):
"""好感度系统全局参数(单例表)— P1-04 """好感度系统全局参数(单例表)— P1-04
@ -296,20 +390,65 @@ class AffinitySetting(models.Model):
class Meta: class Meta:
verbose_name = '好感度系统设置' verbose_name = '好感度系统设置'
verbose_name_plural = '好感度系统设置' verbose_name_plural = '好感度系统设置'
constraints = [
CheckConstraint(
check=Q(decay_min_decay__lte=F('decay_max_decay')),
name='affinitysetting_decay_min_le_max',
),
CheckConstraint(
check=Q(decay_max_decay__lte=F('decay_cap')),
name='affinitysetting_decay_within_cap',
),
CheckConstraint(
check=Q(initial_affinity__lte=F('max_affinity')),
name='affinitysetting_initial_le_max',
),
CheckConstraint(
check=Q(decay_min_floor__lte=F('max_affinity')),
name='affinitysetting_floor_le_max',
),
CheckConstraint(
check=Q(daily_cap__gt=0),
name='affinitysetting_daily_cap_positive',
),
# 单例硬约束:所有写入必须 pk=1CHECK 约束不能跨行限制行数,
# 但能强制任何 INSERT/UPDATE 的 pk 都是 1配合 save() 强制 pk=1 形成事实单例)
# 详见审查报告 WR-001
CheckConstraint(
check=Q(pk=1),
name='affinitysetting_singleton',
),
]
def __str__(self): def __str__(self):
return f"AffinitySetting(initial={self.initial_affinity}, max={self.max_affinity})" return f"AffinitySetting(initial={self.initial_affinity}, max={self.max_affinity})"
def clean(self):
"""Python 级兜底校验"""
super().clean()
errors = {}
if self.decay_min_decay > self.decay_max_decay:
errors['decay_max_decay'] = '单日衰减最大值不能小于最小值'
if self.decay_max_decay > self.decay_cap:
errors['decay_cap'] = '单日衰减上限不能小于最大值'
if self.initial_affinity > self.max_affinity:
errors['initial_affinity'] = '初始好感度不能超过最大好感度'
if self.decay_min_floor > self.max_affinity:
errors['decay_min_floor'] = '衰减下限不能超过最大好感度'
if self.daily_cap <= 0:
errors['daily_cap'] = '每日全局增长上限必须大于 0'
if errors:
raise ValidationError(errors)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# 强制单例:新增时如果已有记录则覆盖到现有 pk # 强制单例:固定 pk=1WR-001 升级版 — 配合 DB CHECK pk=1 约束防并发重复行)
if not self.pk and AffinitySetting.objects.exists(): if not self.pk:
existing = AffinitySetting.objects.first() self.pk = 1
self.pk = existing.pk
super().save(*args, **kwargs) super().save(*args, **kwargs)
@classmethod @classmethod
def get_solo(cls): def get_solo(cls):
"""获取单例实例,不存在则用默认值创建""" """获取单例实例,不存在则用默认值创建(强制 pk=1"""
instance, _ = cls.objects.get_or_create(pk=1) instance, _ = cls.objects.get_or_create(pk=1)
return instance return instance