- 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>
25 KiB
服务器端修改指南 — 设备动态绑定
本文档对应手机端文档
LTY_App_Project_URP/docs/修改指南_手机端.md和设备端文档LTY_Project/docs/修改指南_设备端.md三个文档可以交叉验证。注意:手机端文档新增了**步骤 0(手机号登录功能)**作为前置基础, 后续步骤编号与本文档/设备端文档不完全对齐,请以各文档内的交叉引用为准。
步骤 1:修改 MAC 登录接口,支持返回绑定用户信息
目标
当前 MacAddressLoginView 已经在做正确的事:查找设备 → 检查绑定 → 返回绑定用户的 token。但需要增强返回信息,让设备端能区分"未绑定"和其他错误,并返回更多上下文。
修改文件
userapp/views.py — MacAddressLoginView 类
当前代码(第 99-142 行)
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)}")
替换为
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,如果没有则添加:
from django.utils import timezone
验证方法
用 curl 或 Postman 测试:
# 测试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 行后),添加:
@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='设备不存在')
验证方法
# 测试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
在文件末尾添加:
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 区域补充(如果还没有):
from .serializers import (
DeviceTypeSerializer, DeviceBatchSerializer, DeviceSerializer,
DeviceCreateSerializer, DeviceBatchCreateSerializer,
UserDeviceSerializer, DeviceBindSerializer, DeviceRegisterSerializer
)
在 DeviceViewSet 类中(bind_status 方法之后)添加:
@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)
验证方法
# 测试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 行)
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')
替换为
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)
验证方法
# 用已登录用户的 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 行),在它之前添加:
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 行后),添加:
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 方法之后添加:
@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 后发送:
{
"type": "device_info",
"message": "{\"mac_address\":\"00:55:11:44:f3:22\",\"battery_level\":85,\"firmware_version\":\"1.0.0\",\"wifi_name\":\"TestWiFi\"}"
}
预期:
- 服务器返回
{"status": "success", "message": "设备信息已更新"} - 同 group 的其他连接(手机端)收到
{"type": "device_info", "message": {...}} - 数据库中 Device 记录的 battery_level/firmware_version 等字段已更新
与其他端的关联
- 设备端步骤 3 依赖此步骤:设备端 WebSocket 连接成功后会发送
device_info消息 - 手机端步骤 3B 依赖此步骤:手机端需要处理收到的
device_info消息
步骤 6:部署验证 & 三端联调检查清单
部署步骤
# 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 全部完成
- 设备端开机 → 调用 MAC 登录 → 收到
code=4010(未绑定)→ 正确进入等待绑定状态 ✓(设备端步骤 1) - 设备端调用
bind_status轮询 → 返回bound=false✓(设备端步骤 1) - 手机端绑定后 → 设备端轮询
bind_status→ 返回bound=true✓(设备端步骤 1) - 设备端重新 MAC 登录 → 返回 token +
bound=true→ 连接 WebSocket ✓(设备端步骤 1) - 设备端连 WebSocket 后上报
device_info→ 服务器更新数据库 → 手机端收到 ✓(设备端步骤 3)
与手机端联调
需要手机端步骤 0-4 全部完成(步骤 0 为手机号登录功能)
- 手机端手机号+验证码登录 → 获得 user_token ✓(手机端步骤 0)
- 手机端调用
bind接口 → 绑定成功 ✓(手机端步骤 2 DeviceBindManager) - 手机端查询设备列表 → 含 mac_address 字段 ✓(手机端步骤 2)
- 手机端连 WebSocket → 进入
device_{user_id}group ✓(手机端步骤 1) - 手机端发 touch 消息 → 设备端收到 ✓
- 手机端收到设备端
device_info上报 ✓(手机端步骤 3B)