# 服务器端修改指南 — 设备动态绑定

> 本文档对应手机端文档 `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）
