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) 在本仓库中工作时提供指导。
|
本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指导。
|
||||||
|
|
||||||
|
## 沟通语言(重要 — 始终生效)
|
||||||
|
|
||||||
|
- 在本仓库工作时,**所有面向用户的回复**(思考后的最终回答、状态更新、错误说明、提问、计划摘要等)**统一使用中文**
|
||||||
|
- 内部思考(thinking)可使用任意语言以保证推理质量,但**呈现给用户的输出必须是中文**
|
||||||
|
- 工具调用参数、Git 提交信息(commit message)、代码注释保持原项目约定(中文为主,必要的英文术语、变量名、API 名等可保留)
|
||||||
|
- 此规则覆盖默认的英文输出倾向;只有当用户明确要求改用其他语言时才切换
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
QY LTY Backend 是一个基于 Django 的综合性后端服务,提供以下功能:
|
QY LTY Backend 是一个基于 Django 的综合性后端服务,提供以下功能:
|
||||||
@ -226,7 +233,8 @@ docker-compose up -d --build
|
|||||||
- `UserDevice` 关联表的 `Meta.ordering = ['-bound_at']`
|
- `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,结果一致
|
- **"后绑的挤掉先绑的"语义**:`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}`,**同一台设备同一时刻只有一个用户能真正控制它**——即最近一次绑定的那个用户
|
- 由于 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` 的记录
|
- `is_primary` 是"用户视角的主设备"(每个用户最多一个),**不是**"设备视角的主控用户"——同一台设备可能出现多条 `is_primary=True` 的记录
|
||||||
- **测试 MAC `AA:BB:CC:DD:EE:FF`** 在 `device_interaction/serializers.py` 与 `views.py` 中被硬编码跳过"设备已被其他用户绑定"校验,仅供测试用
|
- **测试 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}"
|
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):
|
class UserDevice(models.Model):
|
||||||
"""用户设备关联表
|
"""用户设备关联表
|
||||||
|
|
||||||
@ -97,7 +110,14 @@ class UserDevice(models.Model):
|
|||||||
favorability: 当前好感度值
|
favorability: 当前好感度值
|
||||||
affinity_level: 当前等级缓存(由服务端计算)
|
affinity_level: 当前等级缓存(由服务端计算)
|
||||||
last_active_at: 最近一次互动时间,用于衰减判断
|
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')
|
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,
|
'最近互动时间', null=True, blank=True, db_index=True,
|
||||||
help_text='用于衰减判断;服务端在每次成功 apply 时刷新'
|
help_text='用于衰减判断;服务端在每次成功 apply 时刷新'
|
||||||
)
|
)
|
||||||
is_active = models.BooleanField(
|
is_bound = models.BooleanField(
|
||||||
'绑定有效', default=True,
|
'绑定有效', default=True,
|
||||||
help_text='软删除标记。解绑置为 false,重绑时可读取历史值。'
|
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:
|
class Meta:
|
||||||
verbose_name = '用户设备'
|
verbose_name = '用户设备'
|
||||||
verbose_name_plural = '用户设备'
|
verbose_name_plural = '用户设备'
|
||||||
ordering = ['-bound_at']
|
ordering = ['-bound_at']
|
||||||
unique_together = ['user', 'device']
|
unique_together = ['user', 'device']
|
||||||
|
# 显式声明 admin / 反向关系默认使用全集 manager,避免 active 影响管理后台
|
||||||
|
base_manager_name = 'objects'
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user.username} - {self.device.device_code}"
|
return f"{self.user.username} - {self.device.device_code}"
|
||||||
|
|||||||
@ -121,8 +121,9 @@ class DeviceBindSerializer(serializers.Serializer):
|
|||||||
except Device.DoesNotExist:
|
except Device.DoesNotExist:
|
||||||
raise serializers.ValidationError("设备不存在")
|
raise serializers.ValidationError("设备不存在")
|
||||||
|
|
||||||
# 检查设备是否已被其他用户绑定(测试 MAC 跳过此检查)
|
# 检查设备是否已被其他用户「有效」绑定(测试 MAC 跳过此检查)
|
||||||
if value != 'AA:BB:CC:DD:EE:FF' and UserDevice.objects.filter(device=device).exists():
|
# 使用 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("设备已被其他用户绑定")
|
raise serializers.ValidationError("设备已被其他用户绑定")
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|||||||
@ -459,7 +459,8 @@ class DeviceViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
device = Device.objects.get(mac_address=mac_address)
|
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:
|
if user_device:
|
||||||
return success_response(
|
return success_response(
|
||||||
@ -690,16 +691,17 @@ class UserDeviceViewSet(viewsets.ModelViewSet):
|
|||||||
# 获取设备
|
# 获取设备
|
||||||
device = Device.objects.get(mac_address=mac_address)
|
device = Device.objects.get(mac_address=mac_address)
|
||||||
|
|
||||||
# 检查是否已被当前用户绑定
|
# 检查是否已被当前用户「有效」绑定(active manager 过滤 is_bound=True,CR-001)
|
||||||
existing = UserDevice.objects.filter(device=device, user=request.user).first()
|
existing = UserDevice.active.filter(device=device, user=request.user).first()
|
||||||
if existing:
|
if existing:
|
||||||
return success_response(
|
return success_response(
|
||||||
data=UserDeviceSerializer(existing).data,
|
data=UserDeviceSerializer(existing).data,
|
||||||
message='设备已绑定'
|
message='设备已绑定'
|
||||||
)
|
)
|
||||||
|
|
||||||
# 检查是否已被其他用户绑定(测试 MAC 跳过此检查)
|
# 检查是否已被其他用户「有效」绑定(测试 MAC 跳过此检查)
|
||||||
if mac_address != 'AA:BB:CC:DD:EE:FF' and UserDevice.objects.filter(device=device).exists():
|
# 使用 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)
|
return error_response(message='设备已被其他用户绑定', code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# 激活设备
|
# 激活设备
|
||||||
@ -1155,10 +1157,11 @@ class VolcEngineTokenViewSet(viewsets.ViewSet):
|
|||||||
return not_found_response(message='设备不存在')
|
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:
|
if not user_device:
|
||||||
return error_response(message='设备未绑定给任何用户', code=status.HTTP_400_BAD_REQUEST)
|
return error_response(message='设备未绑定给任何用户', code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
user_id = str(user_device.user.id)
|
user_id = str(user_device.user.id)
|
||||||
|
|
||||||
# 生成房间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 脱敏
|
### [2026-05-08] Phase 3 — 客户端凭据槽位 GET 接口 + 阿里云日志 access_token 脱敏
|
||||||
|
|
||||||
配套 Phase:[.planning/phases/03-client-and-log-mask/](.planning/phases/03-client-and-log-mask/)
|
配套 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)
|
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:
|
if not user_device:
|
||||||
logger.warning(f"Device not bound to any user: {mac_address}")
|
logger.warning(f"Device not bound to any user: {mac_address}")
|
||||||
# 返回特定 code=4010,让设备端可以识别"未绑定"状态
|
# 返回特定 code=4010,让设备端可以识别"未绑定"状态
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user