lty/qy_lty/docs/修改指南_服务器端.md
pmc bd95ba470c feat: update admin panel, API modules, and add migrations
- Update food, outfits, props, home-decor pages and components
- Add permissions page and sidebar updates
- Update API client and all API modules (auth, food, dances, etc.)
- Add card model migrations for optional fields
- Update Django views, serializers, and authentication
- Add affinity level migrations and user app updates
- Add project documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:06:50 +08:00

684 lines
25 KiB
Markdown
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.

# 服务器端修改指南 — 设备动态绑定
> 本文档对应手机端文档 `LTY_App_Project_URP/docs/修改指南_手机端.md`
> 和设备端文档 `LTY_Project/docs/修改指南_设备端.md`
> 三个文档可以交叉验证。
>
> **注意**:手机端文档新增了**步骤 0手机号登录功能**作为前置基础,
> 后续步骤编号与本文档/设备端文档不完全对齐,请以各文档内的交叉引用为准。
---
## 步骤 1修改 MAC 登录接口,支持返回绑定用户信息
### 目标
当前 `MacAddressLoginView` 已经在做正确的事:查找设备 → 检查绑定 → 返回绑定用户的 token。但需要增强返回信息让设备端能区分"未绑定"和其他错误,并返回更多上下文。
### 修改文件
`userapp/views.py``MacAddressLoginView`
### 当前代码(第 99-142 行)
```python
def post(self, request):
mac_address = request.data.get('mac_address')
if not mac_address:
logger.warning("Attempt to login without MAC address")
return error_response(message="MAC address is required")
logger.info(f"Attempting MAC address login for device: {mac_address}")
try:
device = Device.objects.get(mac_address=mac_address)
if not device.is_active:
logger.warning(f"Device not active: {mac_address}")
return error_response(message="Device is not active")
user_device = UserDevice.objects.filter(device=device).first()
if not user_device:
logger.warning(f"Device not bound to any user: {mac_address}")
return error_response(message="Device is not bound to any user")
token = generate_token(user_device.user.id)
logger.info(f"Successfully logged in device with MAC: {mac_address}")
return success_response(
data={'token': token},
message="登录成功"
)
except Device.DoesNotExist:
logger.warning(f"Device not found: {mac_address}")
return error_response(message="Device not found")
except Exception as e:
logger.error(f"MAC address login failed: {str(e)}")
return error_response(message=f"Login failed: {str(e)}")
```
### 替换为
```python
def post(self, request):
mac_address = request.data.get('mac_address')
if not mac_address:
logger.warning("Attempt to login without MAC address")
return error_response(message="MAC address is required")
logger.info(f"Attempting MAC address login for device: {mac_address}")
try:
device = Device.objects.get(mac_address=mac_address)
# 检查设备是否已绑定给用户
user_device = UserDevice.objects.filter(device=device).first()
if not user_device:
logger.warning(f"Device not bound to any user: {mac_address}")
# 返回特定 code=4010让设备端可以识别"未绑定"状态
return error_response(
message="Device is not bound to any user",
code=4010,
data={
'device_code': device.device_code,
'mac_address': mac_address,
'bound': False
}
)
# 如果设备未激活,在登录时自动激活
if not device.is_active:
device.is_active = True
device.activated_at = timezone.now()
device.save()
logger.info(f"Device auto-activated on login: {mac_address}")
# 生成绑定用户的 token
token = generate_token(user_device.user.id)
logger.info(f"Successfully logged in device with MAC: {mac_address}, bound to user: {user_device.user.id}")
return success_response(
data={
'token': token,
'user_id': user_device.user.id,
'device_code': device.device_code,
'mac_address': mac_address,
'bound': True
},
message="登录成功"
)
except Device.DoesNotExist:
logger.warning(f"Device not found: {mac_address}")
return error_response(
message="Device not found",
code=4011,
data={
'mac_address': mac_address,
'registered': False
}
)
except Exception as e:
logger.error(f"MAC address login failed: {str(e)}")
return error_response(message=f"Login failed: {str(e)}")
```
### 需要新增的 import文件头部
`userapp/views.py` 顶部检查是否已有以下 import如果没有则添加
```python
from django.utils import timezone
```
### 验证方法
用 curl 或 Postman 测试:
```bash
# 测试1用已绑定设备的 MAC应返回 token + bound=true
curl -X POST https://qy-lty.airlabs.art/api/user/auth/mac/login/ \
-H "Content-Type: application/json" \
-d '{"mac_address": "00:55:11:44:f3:22"}'
# 期望响应:
# {"success": true, "code": 200, "data": {"token": "xxx", "user_id": 1, "device_code": "...", "bound": true}}
# 测试2用未绑定设备的 MAC应返回 code=4010 + bound=false
curl -X POST https://qy-lty.airlabs.art/api/user/auth/mac/login/ \
-H "Content-Type: application/json" \
-d '{"mac_address": "AA:BB:CC:DD:EE:FF"}'
# 期望响应:
# {"success": false, "code": 4010, "data": {"device_code": "...", "bound": false}}
# 测试3用不存在的 MAC应返回 code=4011 + registered=false
curl -X POST https://qy-lty.airlabs.art/api/user/auth/mac/login/ \
-H "Content-Type: application/json" \
-d '{"mac_address": "FF:FF:FF:FF:FF:FF"}'
# 期望响应:
# {"success": false, "code": 4011, "data": {"registered": false}}
```
### 与其他端的关联
- **设备端步骤 1** 依赖此步骤:设备端会根据 `code=4010` 判断自己未绑定,进入等待绑定状态
- **手机端步骤 0** 不依赖此步骤:手机端使用手机号登录(见手机端步骤 0登录 API 已存在无需改动
---
## 步骤 2新增设备绑定状态查询接口
### 目标
设备端需要轮询查询自己是否已被手机端绑定。新增一个不需要认证的接口,设备通过 MAC 地址查询绑定状态。
### 修改文件
`device_interaction/views.py` — 在 `DeviceViewSet` 类中新增 action
### 在 `DeviceViewSet` 类中添加方法
`update_status` 方法之后(约第 400 行后),添加:
```python
@swagger_auto_schema(
operation_summary="查询设备绑定状态",
operation_description="通过MAC地址查询设备是否已被用户绑定不需要认证",
manual_parameters=[mac_address_param],
responses={
200: openapi.Response('查询成功', get_standardized_response_schema()),
400: openapi.Response('参数错误', get_standardized_response_schema()),
404: openapi.Response('设备不存在', get_standardized_response_schema())
}
)
@action(detail=False, methods=['get'], permission_classes=[AllowAny], authentication_classes=[])
def bind_status(self, request):
"""
查询设备绑定状态
通过MAC地址查询设备是否已被用户绑定。
此接口不需要认证,供设备端轮询使用。
参数:
- mac_address: 设备MAC地址query参数
返回:
- bound: 是否已绑定
- user_id: 绑定用户ID仅已绑定时返回
- nickname: 设备昵称(仅已绑定时返回)
- device_code: 设备编码
"""
mac_address = request.query_params.get('mac_address')
if not mac_address:
return error_response(message='MAC地址不能为空', code=status.HTTP_400_BAD_REQUEST)
try:
device = Device.objects.get(mac_address=mac_address)
user_device = UserDevice.objects.filter(device=device).first()
if user_device:
return success_response(
data={
'bound': True,
'user_id': user_device.user.id,
'nickname': user_device.nickname or '',
'device_code': device.device_code,
'is_primary': user_device.is_primary
},
message='设备已绑定'
)
else:
return success_response(
data={
'bound': False,
'device_code': device.device_code,
'mac_address': mac_address
},
message='设备未绑定'
)
except Device.DoesNotExist:
return not_found_response(message='设备不存在')
```
### 验证方法
```bash
# 测试1查询已绑定设备
curl "https://qy-lty.airlabs.art/api/device/devices/bind_status/?mac_address=00:55:11:44:f3:22"
# 期望响应:
# {"success": true, "data": {"bound": true, "user_id": 1, "nickname": "...", "device_code": "..."}}
# 测试2查询未绑定设备
curl "https://qy-lty.airlabs.art/api/device/devices/bind_status/?mac_address=AA:BB:CC:DD:EE:FF"
# 期望响应:
# {"success": true, "data": {"bound": false, "device_code": "...", "mac_address": "AA:BB:CC:DD:EE:FF"}}
# 测试3查询不存在的设备
curl "https://qy-lty.airlabs.art/api/device/devices/bind_status/?mac_address=FF:FF:FF:FF:FF:FF"
# 期望响应:
# {"success": false, "message": "设备不存在"}
```
### 与其他端的关联
- **设备端步骤 1** 依赖此步骤:设备端轮询此接口等待被绑定
- **手机端步骤 2**DeviceBindManager不直接依赖但绑定后可以用此接口验证绑定结果
---
## 步骤 3新增设备自注册接口
### 目标
设备首次开机时,如果 MAC 地址在数据库中不存在,可以自动注册一个 Device 记录,无需管理员手动在后台录入。
### 修改文件
**1. `device_interaction/serializers.py`** — 新增 Serializer
在文件末尾添加:
```python
class DeviceRegisterSerializer(serializers.Serializer):
mac_address = serializers.CharField(max_length=17)
device_type_code = serializers.CharField(max_length=10, required=False, default='T01')
firmware_version = serializers.CharField(max_length=50, required=False, default='')
def validate_mac_address(self, value):
# 检查是否已存在
if Device.objects.filter(mac_address=value).exists():
raise serializers.ValidationError("设备已注册")
return value
def validate_device_type_code(self, value):
if not DeviceType.objects.filter(code=value).exists():
raise serializers.ValidationError(f"设备类型 {value} 不存在")
return value
```
**2. `device_interaction/views.py`** — 在 `DeviceViewSet` 中新增 action
在 import 区域补充(如果还没有):
```python
from .serializers import (
DeviceTypeSerializer, DeviceBatchSerializer, DeviceSerializer,
DeviceCreateSerializer, DeviceBatchCreateSerializer,
UserDeviceSerializer, DeviceBindSerializer, DeviceRegisterSerializer
)
```
`DeviceViewSet` 类中(`bind_status` 方法之后)添加:
```python
@swagger_auto_schema(
operation_summary="设备自注册",
operation_description="设备首次开机时自动注册(不需要认证)",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['mac_address'],
properties={
'mac_address': openapi.Schema(type=openapi.TYPE_STRING, description='设备MAC地址'),
'device_type_code': openapi.Schema(type=openapi.TYPE_STRING, description='设备类型代码默认T01'),
'firmware_version': openapi.Schema(type=openapi.TYPE_STRING, description='固件版本号'),
}
),
responses={
201: openapi.Response('注册成功', get_standardized_response_schema()),
400: openapi.Response('参数错误', get_standardized_response_schema()),
}
)
@action(detail=False, methods=['post'], permission_classes=[AllowAny], authentication_classes=[])
def register(self, request):
"""
设备自注册
设备首次开机时如果MAC地址不存在则自动注册设备。
如果MAC地址已存在返回已存在的设备信息。
此接口不需要认证。
"""
mac_address = request.data.get('mac_address')
if not mac_address:
return error_response(message='MAC地址不能为空', code=status.HTTP_400_BAD_REQUEST)
# 如果设备已存在,直接返回设备信息
existing_device = Device.objects.filter(mac_address=mac_address).first()
if existing_device:
return success_response(
data={
'device_code': existing_device.device_code,
'mac_address': existing_device.mac_address,
'is_new': False
},
message='设备已注册'
)
device_type_code = request.data.get('device_type_code', 'T01')
firmware_version = request.data.get('firmware_version', '')
try:
device_type = DeviceType.objects.get(code=device_type_code)
except DeviceType.DoesNotExist:
return error_response(message=f'设备类型 {device_type_code} 不存在', code=status.HTTP_400_BAD_REQUEST)
# 获取或创建一个默认批次
batch, _ = DeviceBatch.objects.get_or_create(
device_type=device_type,
batch_number=f'AUTO-{device_type_code}',
defaults={
'production_date': timezone.now().date(),
'quantity': 99999,
'description': '设备自动注册批次'
}
)
# 生成序列号
existing_count = Device.objects.filter(batch=batch).count()
serial_number = f"{existing_count + 1:05d}"
try:
device = Device(
device_type=device_type,
batch=batch,
serial_number=serial_number,
mac_address=mac_address,
firmware_version=firmware_version
)
device.save()
return created_response(
data={
'device_code': device.device_code,
'mac_address': device.mac_address,
'is_new': True
},
message='设备注册成功'
)
except Exception as e:
logger.error(f"Device registration failed: {str(e)}")
return error_response(message=f'注册失败: {str(e)}', code=status.HTTP_500_INTERNAL_SERVER_ERROR)
```
### 验证方法
```bash
# 测试1注册新设备
curl -X POST https://qy-lty.airlabs.art/api/device/devices/register/ \
-H "Content-Type: application/json" \
-d '{"mac_address": "AA:BB:CC:DD:EE:01", "device_type_code": "T01"}'
# 期望响应:
# {"success": true, "code": 201, "data": {"device_code": "T01-AUTO-T01-00001", "is_new": true}}
# 测试2重复注册应返回已存在
curl -X POST https://qy-lty.airlabs.art/api/device/devices/register/ \
-H "Content-Type: application/json" \
-d '{"mac_address": "AA:BB:CC:DD:EE:01"}'
# 期望响应:
# {"success": true, "data": {"device_code": "...", "is_new": false}}
```
### 与其他端的关联
- **设备端步骤 1** 依赖此步骤:设备端首次开机时如果 MAC 登录返回 `code=4011`(设备不存在),会调用此接口自动注册
- **手机端**不依赖此步骤
---
## 步骤 4为绑定接口增加 mac_address 返回字段
### 目标
当前 `UserDeviceSerializer` 没有返回设备的 `mac_address`,手机端需要这个字段来管理设备。
### 修改文件
`device_interaction/serializers.py``UserDeviceSerializer`
### 当前代码(第 65-78 行)
```python
class UserDeviceSerializer(serializers.ModelSerializer):
device_code = serializers.ReadOnlyField(source='device.device_code')
device_type = serializers.ReadOnlyField(source='device.device_type.name')
device_status = serializers.ReadOnlyField(source='device.status')
battery_level = serializers.ReadOnlyField(source='device.battery_level')
firmware_version = serializers.ReadOnlyField(source='device.firmware_version')
wifi_name = serializers.ReadOnlyField(source='device.wifi_name')
wifi_password = serializers.ReadOnlyField(source='device.wifi_password')
brightness = serializers.ReadOnlyField(source='device.brightness')
class Meta:
model = UserDevice
fields = '__all__'
read_only_fields = ('user', 'bound_at')
```
### 替换为
```python
class UserDeviceSerializer(serializers.ModelSerializer):
device_code = serializers.ReadOnlyField(source='device.device_code')
mac_address = serializers.ReadOnlyField(source='device.mac_address')
device_type = serializers.ReadOnlyField(source='device.device_type.name')
device_status = serializers.ReadOnlyField(source='device.status')
battery_level = serializers.ReadOnlyField(source='device.battery_level')
firmware_version = serializers.ReadOnlyField(source='device.firmware_version')
wifi_name = serializers.ReadOnlyField(source='device.wifi_name')
wifi_password = serializers.ReadOnlyField(source='device.wifi_password')
brightness = serializers.ReadOnlyField(source='device.brightness')
class Meta:
model = UserDevice
fields = '__all__'
read_only_fields = ('user', 'bound_at')
```
(仅增加了一行 `mac_address`
### 验证方法
```bash
# 用已登录用户的 token 查询绑定设备列表
curl -H "Authorization: Bearer <user_token>" \
https://qy-lty.airlabs.art/api/device/user-devices/
# 期望响应中每个设备对象现在包含 mac_address 字段
```
### 与其他端的关联
- **手机端步骤 2**DeviceBindManager依赖此步骤手机端解析设备列表时需要 mac_address
---
## 步骤 5新增 device_info WebSocket 消息类型
### 目标
设备端连上 WiFi 并建立 WebSocket 后,需要把自己的 MAC 地址、电量、固件版本等信息上报给服务器和手机端。新增 `device_info` 消息类型实现这个功能。
### 背景:手机端不需要 MAC 地址做通信
手机端通过 `user_id` 标识(来自登录 token不是 MAC 地址。手机切换 WiFi/4G/5G 不影响 WebSocket 通信。但手机端需要知道设备的状态信息(电量、在线状态等),所以设备端通过 WebSocket 上报 `device_info` 消息。
### 修改文件
`device_interaction/consumers.py`
### 在 receive 方法中添加 device_info 处理
找到 `receive` 方法中 `elif message_type == 'factory_reset':` 分支(约第 257 行),在它**之前**添加:
```python
elif message_type == 'device_info':
# 处理设备信息上报设备连WiFi后发送MAC、电量等
try:
device_data = json.loads(message) if isinstance(message, str) else message
# 更新数据库中的设备状态
mac_address = device_data.get('mac_address')
if mac_address:
await self.update_device_status(mac_address, device_data)
# 广播到 group让手机端也能收到
await self.channel_layer.group_send(
self.group_name,
{
'type': 'device_info',
'message': device_data,
'user_id': self.user_id
}
)
await self.send(text_data=json.dumps({
'status': 'success',
'message': '设备信息已更新'
}))
except Exception as e:
logger.error(f"Error processing device_info: {str(e)}")
await self.send(text_data=json.dumps({
'status': 'error',
'message': f'设备信息处理失败: {str(e)}'
}))
```
### 在 Consumer 类中添加 device_info 事件处理方法
`conversation_subtitle` 方法之后(约第 476 行后),添加:
```python
async def device_info(self, event):
"""
处理设备信息上报消息
将设备信息转发给 WebSocket手机端会收到
"""
try:
message = event['message']
user_id = event.get('user_id', '')
await self.send(text_data=json.dumps({
'type': 'device_info',
'message': message,
'user_id': user_id
}))
logger.info(f"Sent device_info to WebSocket: mac={message.get('mac_address', 'unknown')}")
except Exception as e:
logger.error(f"Error in device_info: {str(e)}")
```
### 在 Consumer 类中添加数据库更新辅助方法
`authenticate_with_token` 方法之后添加:
```python
@database_sync_to_async
def update_device_status(self, mac_address, device_data):
"""
根据设备上报信息更新数据库中的设备状态
"""
try:
from .models import Device
device = Device.objects.filter(mac_address=mac_address).first()
if device:
if 'battery_level' in device_data:
device.battery_level = device_data['battery_level']
if 'firmware_version' in device_data:
device.firmware_version = device_data['firmware_version']
if 'wifi_name' in device_data:
device.wifi_name = device_data['wifi_name']
if 'brightness' in device_data:
device.brightness = device_data['brightness']
device.status = 'connected'
device.save()
logger.info(f"Updated device status for MAC: {mac_address}")
except Exception as e:
logger.error(f"Failed to update device status: {str(e)}")
```
### 验证方法
使用 WebSocket 客户端工具(如 wscat 或浏览器控制台)连接 WebSocket 后发送:
```json
{
"type": "device_info",
"message": "{\"mac_address\":\"00:55:11:44:f3:22\",\"battery_level\":85,\"firmware_version\":\"1.0.0\",\"wifi_name\":\"TestWiFi\"}"
}
```
预期:
1. 服务器返回 `{"status": "success", "message": "设备信息已更新"}`
2. 同 group 的其他连接(手机端)收到 `{"type": "device_info", "message": {...}}`
3. 数据库中 Device 记录的 battery_level/firmware_version 等字段已更新
### 与其他端的关联
- **设备端步骤 3** 依赖此步骤:设备端 WebSocket 连接成功后会发送 `device_info` 消息
- **手机端步骤 3B** 依赖此步骤:手机端需要处理收到的 `device_info` 消息
---
## 步骤 6部署验证 & 三端联调检查清单
### 部署步骤
```bash
# 1. 拉取代码
cd /path/to/qy_lty-main
git pull
# 2. 检查是否需要 migrate本次无模型变更不需要
# 3. 重启服务
# Docker 环境:
docker-compose restart
# 或 直接 daphne
daphne -b 0.0.0.0 -p 8000 qy_lty.asgi:application
```
### API 验证清单
| # | 接口 | 方法 | 预期 |
|---|------|------|------|
| 1 | `/api/user/auth/mac/login/` | POST | 已绑定:返回 token+bound=true未绑定code=4010不存在code=4011 |
| 2 | `/api/device/devices/bind_status/` | GET | 返回 bound=true/false |
| 3 | `/api/device/devices/register/` | POST | 新设备返回 is_new=true已存在返回 is_new=false |
| 4 | `/api/device/user-devices/bind/` | POST | 传 mac_address 绑定成功 |
| 5 | `/api/device/user-devices/` | GET | 返回设备列表,含 mac_address 字段 |
| 6 | `/api/device/user-devices/{id}/` | DELETE | 解绑成功 |
| 7 | `/api/user/auth/phone/login/` | POST | 手机号登录返回 token已有无需改动 |
### 与设备端联调
> 需要**设备端步骤 1-3** 全部完成
1. 设备端开机 → 调用 MAC 登录 → 收到 `code=4010`(未绑定)→ 正确进入等待绑定状态 ✓(设备端步骤 1
2. 设备端调用 `bind_status` 轮询 → 返回 `bound=false` ✓(设备端步骤 1
3. 手机端绑定后 → 设备端轮询 `bind_status` → 返回 `bound=true` ✓(设备端步骤 1
4. 设备端重新 MAC 登录 → 返回 token + `bound=true` → 连接 WebSocket ✓(设备端步骤 1
5. 设备端连 WebSocket 后上报 `device_info` → 服务器更新数据库 → 手机端收到 ✓(设备端步骤 3
### 与手机端联调
> 需要**手机端步骤 0-4** 全部完成(步骤 0 为手机号登录功能)
1. 手机端手机号+验证码登录 → 获得 user_token ✓(手机端步骤 0
2. 手机端调用 `bind` 接口 → 绑定成功 ✓(手机端步骤 2 DeviceBindManager
3. 手机端查询设备列表 → 含 mac_address 字段 ✓(手机端步骤 2
4. 手机端连 WebSocket → 进入 `device_{user_id}` group ✓(手机端步骤 1
5. 手机端发 touch 消息 → 设备端收到 ✓
6. 手机端收到设备端 `device_info` 上报 ✓(手机端步骤 3B