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。
This commit is contained in:
pmc 2026-05-13 10:10:14 +08:00
parent c0db8560c9
commit 33b302c773
8 changed files with 140 additions and 17 deletions

View File

@ -2,6 +2,13 @@
本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指导。
## 沟通语言(重要 — 始终生效)
- 在本仓库工作时,**所有面向用户的回复**(思考后的最终回答、状态更新、错误说明、提问、计划摘要等)**统一使用中文**
- 内部思考thinking可使用任意语言以保证推理质量但**呈现给用户的输出必须是中文**
- 工具调用参数、Git 提交信息commit message、代码注释保持原项目约定中文为主必要的英文术语、变量名、API 名等可保留)
- 此规则覆盖默认的英文输出倾向;只有当用户明确要求改用其他语言时才切换
## 项目概述
QY LTY Backend 是一个基于 Django 的综合性后端服务,提供以下功能:
@ -226,7 +233,8 @@ docker-compose up -d --build
- `UserDevice` 关联表的 `Meta.ordering = ['-bound_at']`
- **"后绑的挤掉先绑的"语义**`userapp/views.py` 的 MAC 登录显式按 `order_by('-bound_at').first()` 取最新绑定者并签发 user-token`device_interaction/views.py` 中的 `bind_status` / `rtc-token/get_by_mac` 等使用 `.first()` 隐式依赖该 ordering结果一致
- 由于 WebSocket 分组是 `device_{user_id}`**同一台设备同一时刻只有一个用户能真正控制它**——即最近一次绑定的那个用户
- 旧的 `UserDevice` 记录**不会**被自动删除,仅在控制权解析中被忽略;如需"换绑"语义请显式删除旧记录
- **必须过滤 `is_bound=True`(硬规则)**P1-08 引入 `UserDevice.is_bound`(原名 `is_active`P1 收尾因与 `Device.is_active` 命名冲突已改名)作为软删除标记。**所有控制权解析的查询MAC 登录、WS 分组、RTC 房间路由、绑定校验、`bind_status`)必须使用 `UserDevice.active.filter(...)`**`active``ActiveUserDeviceManager`,自动过滤 `is_bound=True`)。直接 `UserDevice.objects.filter(...)` 仅供管理后台 / 审计 / 数据迁移等需要历史记录的场景
- 旧的 `UserDevice` 记录**软删而非硬删**:解绑置 `is_bound=False`,重绑时可读取历史好感度值;硬删仅在运维清理场景下手工执行
- `is_primary` 是"用户视角的主设备"(每个用户最多一个),**不是**"设备视角的主控用户"——同一台设备可能出现多条 `is_primary=True` 的记录
- **测试 MAC `AA:BB:CC:DD:EE:FF`**`device_interaction/serializers.py``views.py` 中被硬编码跳过"设备已被其他用户绑定"校验,仅供测试用

View File

@ -0,0 +1,44 @@
"""把 UserDevice.is_active 改名为 is_bound — P1 收尾 IN-005 + CR-001
背景
UserDevice.is_activeP1-08 引入表示"绑定关系有效"软删除标记 Device.is_active
表示"设备已激活"命名冲突select_related('device') ud.device.is_active vs
ud.is_active 语义截然不同极易拼错审查报告 IN-005 CR-001 显式建议合并修复
迁移行为
RenameField PostgreSQL 上是元数据级 ALTER TABLE RENAME COLUMNO(1)
不需要数据拷贝现有 is_active=True 的行经此操作变为 is_bound=True
注意
模型层同步引入 ActiveUserDeviceManageractive manager+ base_manager_name='objects'
保证 admin 默认 queryset 不受影响所有控制权解析调用点必须切到
`UserDevice.active.filter(...)`详见审查报告 CR-001
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('device_interaction', '0003_userdevice_affinity_level_userdevice_favorability_and_more'),
]
operations = [
migrations.RenameField(
model_name='userdevice',
old_name='is_active',
new_name='is_bound',
),
migrations.AlterField(
model_name='userdevice',
name='is_bound',
field=models.BooleanField(
default=True,
help_text='软删除标记。解绑置为 false重绑时可读取历史值。'
'原名 is_activeP1-08 引入),因与 Device.is_active'
'(设备激活态)命名冲突,于 P1 收尾改名为 is_bound。',
verbose_name='绑定有效',
),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.12 on 2026-05-13 02:09
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('device_interaction', '0004_rename_userdevice_is_active_is_bound'),
]
operations = [
migrations.AlterModelOptions(
name='userdevice',
options={'base_manager_name': 'objects', 'ordering': ['-bound_at'], 'verbose_name': '用户设备', 'verbose_name_plural': '用户设备'},
),
]

View File

@ -90,6 +90,19 @@ class Device(models.Model):
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):
"""用户设备关联表
@ -97,7 +110,14 @@ class UserDevice(models.Model):
favorability: 当前好感度值
affinity_level: 当前等级缓存由服务端计算
last_active_at: 最近一次互动时间用于衰减判断
is_active: 软删除标记解绑置为 false重绑可读取历史值
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')
@ -119,17 +139,24 @@ class UserDevice(models.Model):
'最近互动时间', null=True, blank=True, db_index=True,
help_text='用于衰减判断;服务端在每次成功 apply 时刷新'
)
is_active = models.BooleanField(
is_bound = models.BooleanField(
'绑定有效', default=True,
help_text='软删除标记。解绑置为 false重绑时可读取历史值。'
'注意:与 Device.is_active设备激活态不是同一概念'
'原名 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}"

View File

@ -121,8 +121,9 @@ class DeviceBindSerializer(serializers.Serializer):
except Device.DoesNotExist:
raise serializers.ValidationError("设备不存在")
# 检查设备是否已被其他用户绑定(测试 MAC 跳过此检查)
if value != 'AA:BB:CC:DD:EE:FF' and UserDevice.objects.filter(device=device).exists():
# 检查设备是否已被其他用户「有效」绑定(测试 MAC 跳过此检查)
# 使用 active manager 显式过滤 is_bound=True忽略软删历史绑定P1 收尾 CR-001
if value != 'AA:BB:CC:DD:EE:FF' and UserDevice.active.filter(device=device).exists():
raise serializers.ValidationError("设备已被其他用户绑定")
return value
return value

View File

@ -459,7 +459,8 @@ class DeviceViewSet(viewsets.ModelViewSet):
try:
device = Device.objects.get(mac_address=mac_address)
user_device = UserDevice.objects.filter(device=device).first()
# 使用 active manager 显式过滤 is_bound=True忽略软删历史绑定P1 收尾 CR-001
user_device = UserDevice.active.filter(device=device).order_by('-bound_at').first()
if user_device:
return success_response(
@ -690,16 +691,17 @@ class UserDeviceViewSet(viewsets.ModelViewSet):
# 获取设备
device = Device.objects.get(mac_address=mac_address)
# 检查是否已被当前用户绑定
existing = UserDevice.objects.filter(device=device, user=request.user).first()
# 检查是否已被当前用户「有效」绑定active manager 过滤 is_bound=TrueCR-001
existing = UserDevice.active.filter(device=device, user=request.user).first()
if existing:
return success_response(
data=UserDeviceSerializer(existing).data,
message='设备已绑定'
)
# 检查是否已被其他用户绑定(测试 MAC 跳过此检查)
if mac_address != 'AA:BB:CC:DD:EE:FF' and UserDevice.objects.filter(device=device).exists():
# 检查是否已被其他用户「有效」绑定(测试 MAC 跳过此检查)
# 使用 active manager 显式过滤 is_bound=True忽略软删历史绑定CR-001
if mac_address != 'AA:BB:CC:DD:EE:FF' and UserDevice.active.filter(device=device).exists():
return error_response(message='设备已被其他用户绑定', code=status.HTTP_400_BAD_REQUEST)
# 激活设备
@ -1155,10 +1157,11 @@ class VolcEngineTokenViewSet(viewsets.ViewSet):
return not_found_response(message='设备不存在')
# 检查设备是否已激活绑定给用户
user_device = UserDevice.objects.filter(device=device).first()
# 使用 active manager 显式过滤 is_bound=True忽略软删历史绑定P1 收尾 CR-001
user_device = UserDevice.active.filter(device=device).order_by('-bound_at').first()
if not user_device:
return error_response(message='设备未绑定给任何用户', code=status.HTTP_400_BAD_REQUEST)
user_id = str(user_device.user.id)
# 生成房间ID

View File

@ -23,6 +23,28 @@
<!-- 新的修改记录添加在此处下方,最新的在最前面 -->
### [2026-05-13] 好感度系统 P1 审查修复 A — UserDevice 软删语义修正CR-001 + IN-005
配套审查报告:[docs/REVIEW-affinity-P1.md](REVIEW-affinity-P1.md)CR-001 + IN-005
配套修复报告:[docs/REVIEW-affinity-P1-FIX-REPORT.md](REVIEW-affinity-P1-FIX-REPORT.md)
- **文件路径**:
- `device_interaction/models.py`(修改 — 新增 `ActiveUserDeviceManager``UserDevice.is_active` 改名为 `is_bound`;新增双 manager `objects` / `active``Meta.base_manager_name = 'objects'` 保证 admin 默认 queryset 不受 active 过滤影响)
- `device_interaction/migrations/0004_rename_userdevice_is_active_is_bound.py`**新建** — `RenameField` + `AlterField` 更新 help_text
- `device_interaction/migrations/0005_alter_userdevice_options.py`**新建** — 由 makemigrations 自动生成,记录 `base_manager_name='objects'` 的 Meta 变更)
- `userapp/views.py`(修改 — MAC 登录第 120 行 `UserDevice.objects.filter(...)``UserDevice.active.filter(...)`
- `device_interaction/views.py`(修改 — 4 处调用点切换到 `UserDevice.active``bind_status` 第 462 行、绑定 endpoint 第 694/702 行两处、RTC token 第 1158 行)
- `device_interaction/serializers.py`(修改 — 第 125 行绑定校验切到 `UserDevice.active`
- `qy_lty/CLAUDE.md`(修改 — § "设备绑定与控制权" 新增硬规则:所有控制权解析查询必须使用 `UserDevice.active.filter(...)`;解释 `is_bound` 改名背景)
- **修改类型**: 重构 + 修复Bug
- **修改内容**:
- **CR-001Critical**4 处控制权解析调用点全部加 `is_bound=True` 过滤,通过 `ActiveUserDeviceManager` 强制语义;避免 P2 软删(解绑设 is_bound=False后旧绑定者被签发 user-token / 路由到错 user_id 的 WS 分组 / RTC 房间
- **IN-005**`UserDevice.is_active``is_bound` 改名,消除与 `Device.is_active`(设备激活态)的命名冲突
- `RenameField` 在 PostgreSQL 上是元数据级 ALTER COLUMN RENAMEO(1) 锁),无数据风险
- `base_manager_name='objects'` 保证 Django admin 与反向关系(`user.devices.all()``device.users.all()`)依然返回全集,仅 `UserDevice.active.filter()` 才过滤
- **修改原因**: P1 数据层代码审查指出(详见 REVIEW-affinity-P1.md CR-001现有 4 处"按 MAC 取最新绑定者"的代码路径未过滤 `is_active`P1-08 引入),一旦 P2 实现解绑=软删,会签发已解绑用户的 user-token、WS 路由到前主人频道等安全 / 越权风险。IN-005 与之共因(两个同名 is_active 字段语义截然不同),审查报告显式建议合并修复
- **跨项目联动**: 无 — 仅服务端 ORM 层 + view 调用点变更,对客户端 / 管理后台无外显接口变化(响应 schema 未动)
### [2026-05-08] Phase 3 — 客户端凭据槽位 GET 接口 + 阿里云日志 access_token 脱敏
配套 Phase[.planning/phases/03-client-and-log-mask/](.planning/phases/03-client-and-log-mask/)

View File

@ -116,8 +116,9 @@ class MacAddressLoginView(APIView):
device = Device.objects.get(mac_address=mac_address)
# 检查设备是否已绑定给用户
# 按绑定时间倒序取最新绑定者作为设备归属人——"后绑的挤掉先绑的"
user_device = UserDevice.objects.filter(device=device).order_by('-bound_at').first()
# 按绑定时间倒序取最新「有效」绑定者作为设备归属人——"后绑的挤掉先绑的"
# 使用 active manager 显式过滤 is_bound=True忽略软删历史绑定P1 收尾 CR-001
user_device = UserDevice.active.filter(device=device).order_by('-bound_at').first()
if not user_device:
logger.warning(f"Device not bound to any user: {mac_address}")
# 返回特定 code=4010让设备端可以识别"未绑定"状态