lty/qy_lty/userapp/models.py
pmc 2d82b2ef7f
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 8m44s
feat: implement affinity (favorability) system
- Add affinity level/setting models and migrations
- Migrate favorability data to UserDevice
- Add management commands for userapp
- Add admin CLAUDE.md and docs
- Update affinity system design doc and task checklist
- Update device_interaction and userapp models

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:18:30 +08:00

471 lines
17 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.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}"