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。
610 lines
24 KiB
Python
610 lines
24 KiB
Python
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=1(CHECK 约束不能跨行限制行数,
|
||
# 但能强制任何 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=1(WR-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}"
|