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', '管理员批量调整'), # P1 收尾 CR-003:data 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) 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}"