pmc 33b302c773 fix(affinity-P1): CR-001 + IN-005 修复 UserDevice 软删语义 + is_bound 改名
UserDevice.is_active 改名为 is_bound(消除与 Device.is_active 的命名冲突),
新增 ActiveUserDeviceManager(active manager),4 处控制权解析调用点
(MAC 登录、bind_status、绑定校验、RTC token、绑定 endpoint)切换到
UserDevice.active.filter(...),避免 P2 软删后旧绑定者被签发 user-token、
WS 分组路由错误、RTC 房间归属错乱等安全 / 越权风险。

base_manager_name='objects' 保证 admin 默认 queryset 不受 active 过滤影响。

详见 docs/REVIEW-affinity-P1.md CR-001 / IN-005。
2026-05-13 10:10:14 +08:00

172 lines
7.5 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 userapp.models import ParadiseUser
import uuid
# Create your models here.
class DeviceType(models.Model):
"""设备类型"""
name = models.CharField('类型名称', max_length=100, unique=True)
code = models.CharField('类型代码', max_length=10, unique=True)
description = models.TextField('类型描述', blank=True, null=True)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True)
class Meta:
verbose_name = '设备类型'
verbose_name_plural = '设备类型'
ordering = ['name']
def __str__(self):
return f"{self.name} ({self.code})"
class DeviceBatch(models.Model):
"""设备批次"""
device_type = models.ForeignKey(DeviceType, on_delete=models.CASCADE, verbose_name='设备类型', related_name='batches')
batch_number = models.CharField('批次号', max_length=20, unique=True)
production_date = models.DateField('生产日期')
quantity = models.PositiveIntegerField('数量')
description = models.TextField('批次描述', blank=True, null=True)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True)
class Meta:
verbose_name = '设备批次'
verbose_name_plural = '设备批次'
ordering = ['-production_date']
def __str__(self):
return f"{self.device_type.name} - {self.batch_number}"
class Device(models.Model):
"""设备表"""
device_type = models.ForeignKey(DeviceType, on_delete=models.CASCADE, verbose_name='设备类型', related_name='devices')
batch = models.ForeignKey(DeviceBatch, on_delete=models.CASCADE, verbose_name='设备批次', related_name='devices')
serial_number = models.CharField('序列号', max_length=50)
device_code = models.CharField('设备码', max_length=100, unique=True)
mac_address = models.CharField('MAC地址', max_length=17, unique=True)
is_active = models.BooleanField('是否激活', default=False)
activated_at = models.DateTimeField('激活时间', null=True, blank=True)
# 设备状态信息
STATUS_CHOICES = (
('connected', '已连接'),
('disconnected', '未连接'),
)
status = models.CharField('设备状态', max_length=20, choices=STATUS_CHOICES, default='disconnected')
battery_level = models.IntegerField('电量', default=0, help_text='电池电量百分比(0-100)')
firmware_version = models.CharField('固件版本号', max_length=50, blank=True, null=True)
# WiFi设置
wifi_name = models.CharField('WiFi名称', max_length=100, blank=True, null=True)
wifi_password = models.CharField('WiFi密码', max_length=100, blank=True, null=True)
# 设备设置
brightness = models.IntegerField('亮度', default=50, help_text='屏幕亮度(0-100)')
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']
unique_together = ['device_type', 'batch', 'serial_number']
def __str__(self):
return self.device_code
def save(self, *args, **kwargs):
# 如果没有设备码,自动生成
if not self.device_code:
self.device_code = self.generate_device_code()
super().save(*args, **kwargs)
def generate_device_code(self):
"""生成唯一设备码"""
# 格式: 类型代码-批次号-序列号
return f"{self.device_type.code}-{self.batch.batch_number}-{self.serial_number}"
class ActiveUserDeviceManager(models.Manager):
"""仅返回 is_bound=True 的有效绑定记录。
用于控制权解析MAC 登录 / WS 分组 / RTC 房间路由 / 绑定校验)等
必须忽略软删历史绑定的查询场景。
历史记录访问请使用默认 manager `UserDevice.objects`。
"""
def get_queryset(self):
return super().get_queryset().filter(is_bound=True)
class UserDevice(models.Model):
"""用户设备关联表
P1-08 扩展:好感度变更为「设备级」,每条 UserDevice 绑定独立维护
favorability: 当前好感度值
affinity_level: 当前等级缓存(由服务端计算)
last_active_at: 最近一次互动时间,用于衰减判断
is_bound: 软删除标记。解绑置为 false重绑可读取历史值
(原名 is_active因与 Device.is_active 命名冲突P1 收尾时改名)
Managers:
objects: 默认 manager返回全部记录含已解绑历史
用于审计 / 后台运维 / 数据迁移等场景。
admin 默认 queryset 也用此 managerMeta.base_manager_name 显式声明)。
active: 仅返回 is_bound=True 的绑定关系。控制权解析必须用此 manager。
"""
user = models.ForeignKey(ParadiseUser, on_delete=models.CASCADE, verbose_name='用户', related_name='devices')
device = models.ForeignKey(Device, on_delete=models.CASCADE, verbose_name='设备', related_name='users')
nickname = models.CharField('设备昵称', max_length=100, blank=True, null=True)
bound_at = models.DateTimeField('绑定时间', auto_now_add=True)
is_primary = models.BooleanField('是否主要设备', default=False)
# P1-08 好感度相关字段(设备级)
favorability = models.IntegerField(
'好感度', default=10,
help_text='设备级好感度,每台设备独立。初始值取自 AffinitySetting.initial_affinity'
)
affinity_level = models.IntegerField(
'当前等级', default=1,
help_text='缓存值,由服务端在好感度变化时同步更新'
)
last_active_at = models.DateTimeField(
'最近互动时间', null=True, blank=True, db_index=True,
help_text='用于衰减判断;服务端在每次成功 apply 时刷新'
)
is_bound = models.BooleanField(
'绑定有效', default=True,
help_text='软删除标记。解绑置为 false重绑时可读取历史值。'
'原名 is_activeP1-08 引入),因与 Device.is_active设备激活态'
'命名冲突,于 P1 收尾改名为 is_bound。'
)
# 双 manager保留历史访问但提供 active 强制语义
objects = models.Manager()
active = ActiveUserDeviceManager()
class Meta:
verbose_name = '用户设备'
verbose_name_plural = '用户设备'
ordering = ['-bound_at']
unique_together = ['user', 'device']
# 显式声明 admin / 反向关系默认使用全集 manager避免 active 影响管理后台
base_manager_name = 'objects'
def __str__(self):
return f"{self.user.username} - {self.device.device_code}"
def save(self, *args, **kwargs):
# 如果是该用户的第一个设备,设为主要设备
if not UserDevice.objects.filter(user=self.user).exists():
self.is_primary = True
# 如果设置为主要设备,取消其他设备的主要设备标记
if self.is_primary:
UserDevice.objects.filter(user=self.user).update(is_primary=False)
super().save(*args, **kwargs)