From 33b302c77334906da27169cca380b4e7580e98f7 Mon Sep 17 00:00:00 2001 From: pmc <740076875@qq.com> Date: Wed, 13 May 2026 10:10:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(affinity-P1):=20CR-001=20+=20IN-005=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20UserDevice=20=E8=BD=AF=E5=88=A0=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=20+=20is=5Fbound=20=E6=94=B9=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- qy_lty/CLAUDE.md | 10 ++++- ...04_rename_userdevice_is_active_is_bound.py | 44 +++++++++++++++++++ .../0005_alter_userdevice_options.py | 17 +++++++ qy_lty/device_interaction/models.py | 33 ++++++++++++-- qy_lty/device_interaction/serializers.py | 9 ++-- qy_lty/device_interaction/views.py | 17 ++++--- qy_lty/docs/修改记录.md | 22 ++++++++++ qy_lty/userapp/views.py | 5 ++- 8 files changed, 140 insertions(+), 17 deletions(-) create mode 100644 qy_lty/device_interaction/migrations/0004_rename_userdevice_is_active_is_bound.py create mode 100644 qy_lty/device_interaction/migrations/0005_alter_userdevice_options.py diff --git a/qy_lty/CLAUDE.md b/qy_lty/CLAUDE.md index 442741c..83df76a 100644 --- a/qy_lty/CLAUDE.md +++ b/qy_lty/CLAUDE.md @@ -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` 中被硬编码跳过"设备已被其他用户绑定"校验,仅供测试用 diff --git a/qy_lty/device_interaction/migrations/0004_rename_userdevice_is_active_is_bound.py b/qy_lty/device_interaction/migrations/0004_rename_userdevice_is_active_is_bound.py new file mode 100644 index 0000000..ccf2a6f --- /dev/null +++ b/qy_lty/device_interaction/migrations/0004_rename_userdevice_is_active_is_bound.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='绑定有效', + ), + ), + ] diff --git a/qy_lty/device_interaction/migrations/0005_alter_userdevice_options.py b/qy_lty/device_interaction/migrations/0005_alter_userdevice_options.py new file mode 100644 index 0000000..a999d36 --- /dev/null +++ b/qy_lty/device_interaction/migrations/0005_alter_userdevice_options.py @@ -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': '用户设备'}, + ), + ] diff --git a/qy_lty/device_interaction/models.py b/qy_lty/device_interaction/models.py index 6deaabe..e78aa31 100644 --- a/qy_lty/device_interaction/models.py +++ b/qy_lty/device_interaction/models.py @@ -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}" diff --git a/qy_lty/device_interaction/serializers.py b/qy_lty/device_interaction/serializers.py index a9c8586..96e039f 100644 --- a/qy_lty/device_interaction/serializers.py +++ b/qy_lty/device_interaction/serializers.py @@ -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 \ No newline at end of file + + return value diff --git a/qy_lty/device_interaction/views.py b/qy_lty/device_interaction/views.py index 246c392..efd9cdc 100644 --- a/qy_lty/device_interaction/views.py +++ b/qy_lty/device_interaction/views.py @@ -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 diff --git a/qy_lty/docs/修改记录.md b/qy_lty/docs/修改记录.md index 5088312..1475645 100644 --- a/qy_lty/docs/修改记录.md +++ b/qy_lty/docs/修改记录.md @@ -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/) diff --git a/qy_lty/userapp/views.py b/qy_lty/userapp/views.py index b546d8c..c3747c3 100644 --- a/qy_lty/userapp/views.py +++ b/qy_lty/userapp/views.py @@ -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,让设备端可以识别"未绑定"状态