from django.db import models from django.conf import settings from django.contrib.auth.models import AbstractUser, Group, Permission from django.db.models import UniqueConstraint, Q 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'] def __str__(self): return f"{self.name} ({self.rule_key or '-'})" 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'] def __str__(self): return f"Lv{self.level} {self.name}" 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 = '好感度系统设置' def __str__(self): return f"AffinitySetting(initial={self.initial_affinity}, max={self.max_affinity})" def save(self, *args, **kwargs): # 强制单例:新增时如果已有记录则覆盖到现有 pk if not self.pk and AffinitySetting.objects.exists(): existing = AffinitySetting.objects.first() self.pk = existing.pk super().save(*args, **kwargs) @classmethod def get_solo(cls): """获取单例实例,不存在则用默认值创建""" 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}"