lty/qy_lty/userapp/models.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

659 lines
27 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'),
)
# 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)
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, default='')
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 时使用:每台设备每日最多触发次数'
)
# 兼容旧字段(已弃用,计划于 P2 完成后删除 — 详见 docs/好感度系统-开发任务清单.md "P1 收尾"
# IN-001显式标注预期删除版本便于后续 grep 清理;新代码禁止读写这些字段。
points = models.IntegerField(
'积分(已弃用)', default=0,
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_change/max_change 替代'
)
daily_limit = models.IntegerField(
'每日上限(已弃用)', null=True, blank=True,
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 daily_cap 替代'
)
is_active = models.BooleanField(
'已启用(已弃用)', default=True,
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 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, default='')
# 区间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)
# 兼容旧字段(已弃用,计划于 P2 完成后删除)
# IN-001显式标注预期删除版本便于后续 grep 清理;新代码禁止读写这些字段。
required_points = models.IntegerField(
'所需积分(已弃用)', default=0,
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 min_affinity/max_affinity 替代'
)
rewards = models.JSONField(
'奖励(已弃用)', default=list,
help_text='[DEPRECATED — 计划于 P2 完成后删除] 使用 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='好感度上限(管理员手动调整也不能突破)'
)
# IN-003原名 daily_cap因与 AffinityRule.daily_cap 同名易混淆,改为 global_daily_cap
global_daily_cap = models.IntegerField(
'每日全局增长上限', default=20,
help_text='每台设备每日好感度净增长上限(跨规则汇总,仅限正向)。'
'与 AffinityRule.daily_cap单规则上限区分使用'
'P2 服务层需同时校验两者。'
)
# 衰减
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(global_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.global_daily_cap <= 0:
errors['global_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', '管理员批量调整'),
# P1 收尾 CR-003data migration 用作 0006 幂等标记
('data_migration', '数据迁移'),
)
# 关联对象
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)
# WR-004event_id 用 NULL 表示「无客户端事件 ID」衰减 / 管理员调整等),
# 替代旧的 '' 空字符串约定(避免空格 / 'null' 字符串等绕过 partial unique
event_id = models.CharField(
'事件ID', max_length=64, null=True, blank=True, db_index=True,
help_text='客户端事件 UUID建议格式UUID v4用于幂等去重'
'NULL 表示非客户端来源(衰减 / 管理员调整 / 数据迁移)'
)
# 管理员调整专用
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']
# 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 = [
models.Index(fields=['device', '-created_at']),
]
constraints = [
# WR-004event_id 改用 NULL 语义partial unique 条件改为 isnull=False
UniqueConstraint(
fields=['event_id'],
condition=Q(event_id__isnull=False),
name='unique_affinity_event_id',
),
]
def __str__(self):
# 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):
"""好感度每日计数器(数据库兜底,热路径走 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 后续修改影响审计。
WR-002 修正:
- on_delete 改为 SET_NULL与 AffinityLog.device 保持一致 — 历史保留)
- 新增 device_snapshot_id 冗余字段device 被删后仍可追溯原 device pk
- unique_together 改为条件唯一约束device IS NULL 时不参与唯一性)
"""
device = models.ForeignKey(
'device_interaction.UserDevice', on_delete=models.SET_NULL,
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('等级')
granted_at = models.DateTimeField('发放时间', auto_now_add=True)
reward_snapshot = models.JSONField(
'奖励快照', default=dict,
help_text='发放时的奖励内容快照(防 AffinityLevel 后续修改)'
)
class Meta:
verbose_name = '等级奖励发放记录'
verbose_name_plural = '等级奖励发放记录'
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):
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)