lty/qy_lty/userapp/models.py
pmc 9a87f5e2b5 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。
2026-05-13 10:12:01 +08:00

610 lines
24 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.

from django.core.exceptions import ValidationError
from django.db import models
from django.conf import settings
from django.contrib.auth.models import AbstractUser, Group, Permission
from django.db.models import CheckConstraint, F, Q, UniqueConstraint
from rest_framework.authtoken.models import Token as DefaultToken
class ParadiseUser(AbstractUser):
"""
自定义用户模型
"""
phone_number = models.CharField('手机号', max_length=20, unique=True, null=True, blank=True)
email = models.EmailField('邮箱', unique=True, null=True, blank=True)
# 新增用户信息字段
GENDER_CHOICES = (
('M', ''),
('F', ''),
('O', '其他'),
)
MBTI_CHOICES = (
('INTJ', 'INTJ'), ('INTP', 'INTP'), ('ENTJ', 'ENTJ'), ('ENTP', 'ENTP'),
('INFJ', 'INFJ'), ('INFP', 'INFP'), ('ENFJ', 'ENFJ'), ('ENFP', 'ENFP'),
('ISTJ', 'ISTJ'), ('ISFJ', 'ISFJ'), ('ESTJ', 'ESTJ'), ('ESFJ', 'ESFJ'),
('ISTP', 'ISTP'), ('ISFP', 'ISFP'), ('ESTP', 'ESTP'), ('ESFP', 'ESFP'),
)
favorability = models.IntegerField('好感度', default=0)
gender = models.CharField('性别', max_length=1, choices=GENDER_CHOICES, null=True, blank=True)
resident_city = models.CharField('常驻城市', max_length=50, null=True, blank=True)
birthday = models.DateField('生日', null=True, blank=True)
zodiac_sign = models.CharField('星座', max_length=20, null=True, blank=True)
mbti = models.CharField('MBTI性格', max_length=4, choices=MBTI_CHOICES, null=True, blank=True)
interests = models.TextField('兴趣爱好', null=True, blank=True)
social_identity = models.CharField('社会身份', max_length=50, null=True, blank=True)
groups = models.ManyToManyField(
Group,
related_name="paradiseuser_set",
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_query_name="user",
)
user_permissions = models.ManyToManyField(
Permission,
related_name="paradiseuser_set",
blank=True,
help_text="Specific permissions for this user.",
related_query_name="user",
)
class Meta:
verbose_name = '用户'
verbose_name_plural = '用户'
ordering = ['-date_joined']
indexes = [
models.Index(fields=['username']), # 添加用户名索引
]
constraints = [
UniqueConstraint(
fields=["phone_number"],
condition=Q(phone_number__isnull=False),
name="unique_phone_number",
)
]
def __str__(self):
return self.username or self.phone_number or self.email
# class UserToken(DefaultToken):
# user = models.OneToOneField(
# settings.AUTH_USER_MODEL,
# related_name='auth_token',
# on_delete=models.CASCADE
# )
class AffinityRule(models.Model):
"""好感度规则:定义哪些行为可以获得好感度积分
P1-01/P1-02 扩展后字段:
rule_key: 代码标识,客户端事件通过此匹配规则
trigger_type: 触发类型 (action / companion_time / decay)
min_change/max_change: 单次变化范围([min, max] 闭区间随机取整数)
single_cap/daily_cap/cooldown_seconds: 上限保护
is_negative/is_enabled/is_deleted: 标记
min_continuous_minutes/max_count_per_day: 陪伴时长专用字段
旧字段 points/daily_limit/is_active 保留作为兼容字段,下个版本删除。
"""
TRIGGER_TYPE_CHOICES = (
('action', '动作触发'),
('companion_time', '陪伴时长'),
('decay', '衰减'),
)
# 业务字段
rule_key = models.CharField(
'规则代码', max_length=50, unique=True, null=True, blank=True,
help_text='代码标识,客户端事件通过此 key 匹配规则(如 chat/sing/dance/touch...'
)
name = models.CharField('规则名称', max_length=100)
description = models.TextField('规则描述', blank=True)
trigger_type = models.CharField(
'触发类型', max_length=20, choices=TRIGGER_TYPE_CHOICES, default='action'
)
# 变化值
min_change = models.IntegerField(
'最小变化值', default=1,
help_text='单次变化最小值,负数表示扣分'
)
max_change = models.IntegerField(
'最大变化值', default=1,
help_text='单次变化最大值(含),实际值在 [min, max] 之间随机取整数'
)
# 上限保护
single_cap = models.IntegerField(
'单次上限', default=10,
help_text='单次变化绝对值上限(保护性钳位)'
)
daily_cap = models.IntegerField(
'每日上限', default=20,
help_text='本规则在每台设备每日累计变化上限(绝对值)'
)
cooldown_seconds = models.IntegerField(
'冷却时间(秒)', default=0,
help_text='同一设备同一规则的最小触发间隔0 表示无冷却'
)
# 标记
is_negative = models.BooleanField('是否负向规则', default=False)
is_enabled = models.BooleanField('已启用', default=True)
is_deleted = models.BooleanField('已删除(软删除)', default=False)
# 陪伴时长专用字段P1-02
min_continuous_minutes = models.IntegerField(
'最小连续分钟数', null=True, blank=True,
help_text='trigger_type=companion_time 时使用:满 X 分钟触发 1 次'
)
max_count_per_day = models.IntegerField(
'每日最多次数', null=True, blank=True,
help_text='trigger_type=companion_time 时使用:每台设备每日最多触发次数'
)
# 兼容旧字段(已弃用,下个版本删除)
points = models.IntegerField(
'积分(已弃用)', default=0,
help_text='已弃用,使用 min_change/max_change'
)
daily_limit = models.IntegerField(
'每日上限(已弃用)', null=True, blank=True,
help_text='已弃用,使用 daily_cap'
)
is_active = models.BooleanField(
'已启用(已弃用)', default=True,
help_text='已弃用,使用 is_enabled'
)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True)
class Meta:
verbose_name = '好感度规则'
verbose_name_plural = '好感度规则'
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):
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):
"""好感度等级:定义不同好感度区间对应的等级和奖励
P1-03 扩展后字段:
min_affinity/max_affinity: 等级好感度区间(闭区间)
unlock_content: 解锁内容文案
reward_type: 奖励类型 (unlock / item / currency / mixed / none)
reward_currency/reward_items: 奖励内容
is_enabled/is_deleted: 标记
旧字段 required_points/rewards 保留作为兼容字段,下个版本删除。
"""
REWARD_TYPE_CHOICES = (
('unlock', '内容解锁'),
('item', '道具奖励'),
('currency', '虚拟货币'),
('mixed', '混合奖励'),
('none', '无奖励'),
)
level = models.IntegerField('等级', unique=True)
name = models.CharField('等级名称', max_length=50)
description = models.TextField('等级描述', blank=True)
# 区间P1-03
min_affinity = models.IntegerField(
'最小好感度', default=0,
help_text='等级好感度区间下限(闭区间)'
)
max_affinity = models.IntegerField(
'最大好感度', default=0,
help_text='等级好感度区间上限(闭区间)'
)
# 解锁/奖励
unlock_content = models.TextField(
'解锁内容', blank=True,
help_text='展示文案,描述该等级解锁的功能/外观/剧情等'
)
reward_type = models.CharField(
'奖励类型', max_length=10, choices=REWARD_TYPE_CHOICES, default='unlock'
)
reward_currency = models.IntegerField('虚拟货币奖励', default=0)
reward_items = models.JSONField(
'道具奖励', default=list,
help_text='道具列表,如 [{"item_id": 1, "qty": 2}]'
)
is_enabled = models.BooleanField('已启用', default=True)
is_deleted = models.BooleanField('已删除(软删除)', default=False)
# 兼容旧字段(已弃用)
required_points = models.IntegerField(
'所需积分(已弃用)', default=0,
help_text='已弃用,使用 min_affinity/max_affinity'
)
rewards = models.JSONField(
'奖励(已弃用)', default=list,
help_text='已弃用,使用 reward_currency/reward_items'
)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True)
class Meta:
verbose_name = '好感度等级'
verbose_name_plural = '好感度等级'
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):
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):
"""好感度系统全局参数(单例表)— P1-04
所有字段对应「好感度系统功能与规则设计.md」§3.2 全局参数 + §5.1 衰减字段。
通过 get_solo() 取唯一实例save() 强制单例。
"""
# 全局参数
initial_affinity = models.IntegerField(
'初始好感度', default=10,
help_text='新建 UserDevice 绑定时的初始好感度'
)
max_affinity = models.IntegerField(
'最大好感度', default=100,
help_text='好感度上限(管理员手动调整也不能突破)'
)
daily_cap = models.IntegerField(
'每日全局增长上限', default=20,
help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)'
)
# 衰减
decay_rate = models.IntegerField('全局衰减速率(点/天)', default=2)
decay_threshold = models.IntegerField(
'衰减开始天数', default=3,
help_text='设备多少天不互动后开始衰减'
)
decay_min_decay = models.IntegerField('单日衰减最小值', default=1)
decay_max_decay = models.IntegerField('单日衰减最大值', default=3)
decay_cap = models.IntegerField('单日衰减上限', default=5)
decay_min_floor = models.IntegerField(
'衰减下限', default=0,
help_text='好感度不会低于此值'
)
# 通知/奖励
enable_notify = models.BooleanField('启用变化通知', default=True)
enable_rewards = models.BooleanField('启用等级奖励', default=True)
notify_decay = models.BooleanField('衰减时通知', default=True)
# 时区
timezone = models.CharField(
'时区', max_length=50, default='Asia/Shanghai',
help_text='自然日 0 点重置基准(全用户统一)'
)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True)
class Meta:
verbose_name = '好感度系统设置'
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):
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):
# 强制单例:固定 pk=1WR-001 升级版 — 配合 DB CHECK pk=1 约束防并发重复行)
if not self.pk:
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def get_solo(cls):
"""获取单例实例,不存在则用默认值创建(强制 pk=1"""
instance, _ = cls.objects.get_or_create(pk=1)
return instance
class AffinityLog(models.Model):
"""好感度变化日志 — P1-05
记录每次好感度变动的来源、值、上下文。
rule 用 SET_NULL规则被删除后日志保留rule_key 冗余文本字段保证可读性。
event_id 用于客户端事件幂等去重(部分唯一索引)。
"""
SOURCE_CHOICES = (
('device_event', '设备端事件'),
('mobile_event', '手机端事件'),
('system_decay', '系统衰减'),
('admin_adjust_single', '管理员单次调整'),
('admin_adjust_batch', '管理员批量调整'),
)
# 关联对象
user = models.ForeignKey(
'userapp.ParadiseUser', on_delete=models.CASCADE,
verbose_name='用户', related_name='affinity_logs'
)
device = models.ForeignKey(
'device_interaction.UserDevice', on_delete=models.SET_NULL,
verbose_name='用户设备绑定', related_name='affinity_logs',
null=True, blank=True
)
rule = models.ForeignKey(
'AffinityRule', on_delete=models.SET_NULL,
verbose_name='规则', related_name='logs',
null=True, blank=True
)
rule_key = models.CharField(
'规则代码(冗余)', max_length=50, blank=True,
help_text='冗余 rule_key 文本,规则被删除后仍可读'
)
# 变化数据
change_value = models.IntegerField('变化值', help_text='正负整数,含符号')
before_value = models.IntegerField('变化前值')
after_value = models.IntegerField('变化后值')
# 来源
source = models.CharField('来源', max_length=30, choices=SOURCE_CHOICES)
event_id = models.CharField(
'事件ID', max_length=64, blank=True, db_index=True,
help_text='客户端事件 UUID用于幂等去重'
)
# 管理员调整专用
operator_admin_id = models.IntegerField(
'操作管理员ID', null=True, blank=True,
help_text='source=admin_adjust_* 时记录操作管理员'
)
reason = models.TextField(
'操作原因', blank=True,
help_text='管理员调整时填写的原因/备注'
)
# 扩展上下文
metadata = models.JSONField(
'元数据', default=dict, blank=True,
help_text='扩展字段,如对话 message_id、设备状态等'
)
created_at = models.DateTimeField('创建时间', auto_now_add=True, db_index=True)
class Meta:
verbose_name = '好感度变化日志'
verbose_name_plural = '好感度变化日志'
ordering = ['-created_at']
indexes = [
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 = [
UniqueConstraint(
fields=['event_id'],
condition=Q(event_id__gt=''),
name='unique_affinity_event_id',
),
]
def __str__(self):
return f"#{self.id} {self.rule_key or self.source} {self.before_value}->{self.after_value}"
class UserAffinityDailyCounter(models.Model):
"""好感度每日计数器(数据库兜底,热路径走 Redis— P1-06
Redis key 命名约定:
daily:{device_id}:{rule_key}:{YYYYMMDD} -> trigger_count + accumulated_change
daily:{device_id}:_global:{YYYYMMDD} -> 全局日上限(仅正向汇总)
每晚定时任务把 Redis 当日数据落库,作为审计/查询兜底。
"""
device = models.ForeignKey(
'device_interaction.UserDevice', on_delete=models.CASCADE,
verbose_name='用户设备绑定', related_name='daily_counters'
)
rule = models.ForeignKey(
'AffinityRule', on_delete=models.CASCADE,
verbose_name='规则', related_name='daily_counters'
)
date = models.DateField(
'日期', db_index=True,
help_text='Asia/Shanghai 时区的自然日'
)
accumulated_change = models.IntegerField('累计变化(含符号)', default=0)
trigger_count = models.IntegerField('触发次数', default=0)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True)
class Meta:
verbose_name = '好感度每日计数器'
verbose_name_plural = '好感度每日计数器'
unique_together = [('device', 'rule', 'date')]
indexes = [
models.Index(fields=['date']),
]
def __str__(self):
return f"{self.device_id}/{self.rule_id}/{self.date}={self.accumulated_change}"
class UserLevelRewardGrant(models.Model):
"""等级奖励发放记录 — P1-07
(device, level) 唯一,永久幂等防止重复发放。
衰减回升再次跨过同等级也不补发;新增等级时已在区间的设备不立即触发。
reward_snapshot 保存发放时的奖励内容快照,避免 AffinityLevel 后续修改影响审计。
"""
device = models.ForeignKey(
'device_interaction.UserDevice', on_delete=models.CASCADE,
verbose_name='用户设备绑定', related_name='level_reward_grants'
)
level = models.IntegerField('等级')
granted_at = models.DateTimeField('发放时间', auto_now_add=True)
reward_snapshot = models.JSONField(
'奖励快照', default=dict,
help_text='发放时的奖励内容快照(防 AffinityLevel 后续修改)'
)
class Meta:
verbose_name = '等级奖励发放记录'
verbose_name_plural = '等级奖励发放记录'
unique_together = [('device', 'level')]
ordering = ['-granted_at']
def __str__(self):
return f"{self.device_id}/Lv{self.level}@{self.granted_at}"