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:
parent
c0db8560c9
commit
33b302c773
@ -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` 中被硬编码跳过"设备已被其他用户绑定"校验,仅供测试用
|
||||
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
"""把 UserDevice.is_active 改名为 is_bound — P1 收尾 IN-005 + CR-001
|
||||
|
||||
背景:
|
||||
UserDevice.is_active(P1-08 引入,表示"绑定关系有效"软删除标记)与 Device.is_active
|
||||
(表示"设备已激活")命名冲突,select_related('device') 后 ud.device.is_active vs
|
||||
ud.is_active 语义截然不同,极易拼错。审查报告 IN-005 与 CR-001 显式建议合并修复。
|
||||
|
||||
迁移行为:
|
||||
RenameField 在 PostgreSQL 上是元数据级 ALTER TABLE RENAME COLUMN,O(1) 锁,
|
||||
不需要数据拷贝;现有 is_active=True 的行经此操作变为 is_bound=True。
|
||||
|
||||
注意:
|
||||
模型层同步引入 ActiveUserDeviceManager(active 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_active(P1-08 引入),因与 Device.is_active'
|
||||
'(设备激活态)命名冲突,于 P1 收尾改名为 is_bound。',
|
||||
verbose_name='绑定有效',
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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': '用户设备'},
|
||||
),
|
||||
]
|
||||
@ -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 也用此 manager(Meta.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_active(P1-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}"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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=True,CR-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
|
||||
|
||||
@ -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-001(Critical)**: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 RENAME(O(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/)
|
||||
|
||||
@ -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,让设备端可以识别"未绑定"状态
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user