""" 设备模块视图 - App端 """ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated, AllowAny from drf_spectacular.utils import extend_schema from utils.response import success, error from utils.exceptions import ErrorCode from apps.admins.authentication import AppJWTAuthentication from .models import Device, UserDevice, DeviceType, DeviceSettings, DeviceWifi, RoleMemory from .serializers import ( DeviceSerializer, UserDeviceSerializer, BindDeviceSerializer, DeviceVerifySerializer, DeviceTypeSerializer, DeviceDetailSerializer, DeviceSettingsUpdateSerializer, DeviceReportStatusSerializer, RoleMemorySerializer, RoleMemorySettingsUpdateSerializer, RoleMemoryAgentUpdateSerializer, RoleMemoryMemoryUpdateSerializer, ) @extend_schema(tags=['设备']) class DeviceViewSet(viewsets.ViewSet): """设备视图集(App端)""" authentication_classes = [AppJWTAuthentication] permission_classes = [IsAuthenticated] @action(detail=False, methods=['get'], url_path='query-by-mac', authentication_classes=[], permission_classes=[AllowAny]) def query_by_mac(self, request): """ 通过MAC地址查询SN码(无需登录) GET /api/v1/devices/query-by-mac?mac=AA:BB:CC:DD:EE:FF """ mac = request.query_params.get('mac', '') if not mac: return error(message='MAC地址不能为空') # 统一格式 mac = mac.upper().replace('-', ':') try: device = Device.objects.select_related('device_type').get(mac_address=mac) return success(data={ 'sn': device.sn, 'mac_address': device.mac_address, 'device_type': DeviceTypeSerializer(device.device_type).data if device.device_type else None, 'status': device.status, 'is_bound': device.status == 'bound' }) except Device.DoesNotExist: return error( code=404, message='未找到对应的设备,请检查MAC地址是否正确或设备是否已完成入库', status_code=status.HTTP_404_NOT_FOUND ) @action(detail=False, methods=['get']) def latest(self, request): """ 获取用户最近使用的设备 GET /api/v1/devices/latest """ devices = list( UserDevice.objects.filter( user=request.user, is_active=True ).select_related('device', 'device__device_type', 'spirit', 'role_memory', 'role_memory__device_type') .order_by('-bind_time')[:1] ) if not devices: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='暂无绑定设备') latest = devices[0] return success(data=UserDeviceSerializer(latest).data) @action(detail=False, methods=['post']) def verify(self, request): """ 验证设备SN POST /api/v1/devices/verify """ serializer = DeviceVerifySerializer(data=request.data) if not serializer.is_valid(): return error(message=str(serializer.errors)) sn = serializer.validated_data['sn'] try: device = Device.objects.select_related('device_type').get(sn=sn) except Device.DoesNotExist: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='设备不存在') # 检查是否已被绑定 is_bindable = device.status != 'bound' return success(data={ 'sn': device.sn, 'is_bindable': is_bindable, 'device_type': DeviceTypeSerializer(device.device_type).data if device.device_type else None }) @action(detail=False, methods=['post']) def bind(self, request): """ 绑定设备 POST /api/v1/devices/bind """ serializer = BindDeviceSerializer(data=request.data) if not serializer.is_valid(): return error(message=str(serializer.errors)) sn = serializer.validated_data['sn'] spirit_id = serializer.validated_data.get('spirit_id') try: device = Device.objects.select_related('device_type').get(sn=sn) except Device.DoesNotExist: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='设备不存在') # 检查是否已被绑定 if device.status == 'bound': # 检查是否是当前用户绑定的 existing = UserDevice.objects.filter(device=device, is_active=True).first() if existing and existing.user != request.user: return error(code=ErrorCode.DEVICE_ALREADY_BOUND, message='设备已被其他用户绑定') # 创建角色记忆(始终创建新的) role_memory = None if device.device_type: role_memory = RoleMemory.objects.create( user=request.user, device_type=device.device_type, is_bound=True, prompt=device.device_type.default_prompt, voice_id=device.device_type.default_voice_id, ) # 创建绑定关系 user_device, created = UserDevice.objects.update_or_create( user=request.user, device=device, defaults={ 'spirit_id': spirit_id, 'role_memory': role_memory, 'is_active': True } ) # 更新设备状态和可选的设备名称 device_name = request.data.get('device_name') if device_name: device.name = device_name device.status = 'bound' device.save() return success( data=UserDeviceSerializer(user_device).data, message='绑定成功' if created else '更新绑定成功' ) @action(detail=False, methods=['get']) def my_devices(self, request): """ 我的设备列表 GET /api/v1/devices/my_devices """ user_devices = UserDevice.objects.filter( user=request.user, is_active=True ).select_related('device', 'device__device_type', 'spirit', 'role_memory', 'role_memory__device_type') serializer = UserDeviceSerializer(user_devices, many=True) return success(data=serializer.data) @action(detail=True, methods=['delete']) def unbind(self, request, pk=None): """ 解绑设备 DELETE /api/v1/devices/{id}/unbind """ try: user_device = UserDevice.objects.select_related('role_memory').get( id=pk, user=request.user ) except UserDevice.DoesNotExist: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='绑定记录不存在') # 更新绑定状态 user_device.is_active = False user_device.save() # 将关联的角色记忆标记为闲置 if user_device.role_memory: user_device.role_memory.is_bound = False user_device.role_memory.save(update_fields=['is_bound', 'updated_at']) # 检查设备是否还有其他活跃绑定 active_bindings = UserDevice.objects.filter(device=user_device.device, is_active=True).count() if active_bindings == 0: user_device.device.status = 'out_stock' user_device.device.save() return success(message='解绑成功') @action(detail=True, methods=['put'], url_path='update-spirit') def update_spirit(self, request, pk=None): """ 更新设备绑定的智能体 PUT /api/v1/devices/{id}/update-spirit """ try: user_device = UserDevice.objects.get(id=pk, user=request.user, is_active=True) except UserDevice.DoesNotExist: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='绑定记录不存在') spirit_id = request.data.get('spirit_id') user_device.spirit_id = spirit_id user_device.save() return success(data=UserDeviceSerializer(user_device).data, message='更新成功') @action(detail=True, methods=['get'], url_path='detail') def device_detail(self, request, pk=None): """ 获取设备详情 GET /api/v1/devices/{user_device_id}/detail/ pk 为 UserDevice 的 ID """ try: user_device = UserDevice.objects.select_related( 'device', 'spirit', 'role_memory', 'role_memory__device_type' ).get(id=pk, user=request.user, is_active=True) except UserDevice.DoesNotExist: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='绑定记录不存在') device = user_device.device serializer = DeviceDetailSerializer( device, context={'user_device': user_device} ) return success(data=serializer.data) @action(detail=True, methods=['put'], url_path='settings') def update_settings(self, request, pk=None): """ 更新设备设置 PUT /api/v1/devices/{user_device_id}/settings/ """ try: user_device = UserDevice.objects.select_related('device').get( id=pk, user=request.user, is_active=True ) except UserDevice.DoesNotExist: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='绑定记录不存在') serializer = DeviceSettingsUpdateSerializer(data=request.data) if not serializer.is_valid(): return error(message=str(serializer.errors)) device = user_device.device settings_obj, _ = DeviceSettings.objects.get_or_create(device=device) for field, value in serializer.validated_data.items(): setattr(settings_obj, field, value) settings_obj.save() return success(message='设置已保存') @action(detail=True, methods=['post'], url_path='wifi') def configure_wifi(self, request, pk=None): """ 配置设备WiFi POST /api/v1/devices/{user_device_id}/wifi/ """ try: user_device = UserDevice.objects.select_related('device').get( id=pk, user=request.user, is_active=True ) except UserDevice.DoesNotExist: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='绑定记录不存在') ssid = request.data.get('ssid') if not ssid: return error(message='WiFi名称不能为空') device = user_device.device # 将其他 WiFi 标记为未连接 DeviceWifi.objects.filter(device=device).update(is_connected=False) # 创建或更新当前 WiFi 记录 wifi, _ = DeviceWifi.objects.update_or_create( device=device, ssid=ssid, defaults={'is_connected': True} ) # TODO: 通过设备通信协议下发 WiFi 配置(password 不存库) return success(message='WiFi 配置成功') @action( detail=False, methods=['get'], url_path='stories', authentication_classes=[], permission_classes=[AllowAny] ) def stories_by_mac(self, request): """ 获取设备关联用户的随机故事(公开接口,无需认证) GET /api/v1/devices/stories/?mac_address=AA:BB:CC:DD:EE:FF 供 hw-ws-service 调用。 优先返回用户自己的故事,无则兜底返回系统默认故事(is_default=True)。 """ mac = request.query_params.get('mac_address', '').strip() if not mac: return error(message='mac_address 参数不能为空') mac = mac.upper().replace('-', ':') from apps.stories.models import Story story = None # 1. 尝试查找设备 → 绑定用户 → 用户故事 try: device = Device.objects.get(mac_address=mac) user_device = ( UserDevice.objects .filter(device=device, is_active=True, bind_type='owner') .select_related('user') .first() ) if user_device: story = ( Story.objects .filter(user=user_device.user) .exclude(audio_url='') .order_by('?') .first() ) except Device.DoesNotExist: pass # 2. 兜底:设备不存在/未绑定/用户无故事 → 使用系统默认故事 if not story: story = ( Story.objects .filter(is_default=True) .exclude(audio_url='') .order_by('?') .first() ) if not story: return error( code=ErrorCode.STORY_NOT_FOUND, message='暂无可播放的故事', status_code=status.HTTP_404_NOT_FOUND ) return success(data={ 'title': story.title, 'audio_url': story.audio_url, 'opus_url': story.opus_url, 'intro_opus_data': story.intro_opus_data, }) @action( detail=False, methods=['get'], url_path='music', authentication_classes=[], permission_classes=[AllowAny] ) def music_by_mac(self, request): """ 获取设备关联用户的随机音乐(公开接口,无需认证) GET /api/v1/devices/music/?mac_address=AA:BB:CC:DD:EE:FF 供 hw-ws-service 调用。 优先返回用户自己的音乐,无则兜底返回系统默认曲目(is_default=True)。 """ mac = request.query_params.get('mac_address', '').strip() if not mac: return error(message='mac_address 参数不能为空') mac = mac.upper().replace('-', ':') from apps.music.models import Track track = None # 1. 尝试查找设备 → 绑定用户 → 用户音乐 try: device = Device.objects.get(mac_address=mac) user_device = ( UserDevice.objects .filter(device=device, is_active=True, bind_type='owner') .select_related('user') .first() ) if user_device: track = ( Track.objects .filter(user=user_device.user, generation_status='completed') .exclude(audio_url='') .order_by('?') .first() ) except Device.DoesNotExist: pass # 2. 兜底:设备不存在/未绑定/用户无音乐 → 使用系统默认曲目 if not track: track = ( Track.objects .filter(is_default=True, generation_status='completed') .exclude(audio_url='') .order_by('?') .first() ) if not track: return error( code=ErrorCode.TRACK_NOT_FOUND, message='暂无可播放的音乐', status_code=status.HTTP_404_NOT_FOUND ) return success(data={ 'title': track.title, 'audio_url': track.audio_url, 'opus_url': track.opus_url, 'intro_opus_data': track.intro_opus_data, 'cover_url': track.cover_url, 'duration': track.duration, }) @action(detail=False, methods=['post'], url_path='report-status', authentication_classes=[], permission_classes=[AllowAny]) def report_status(self, request): """ 设备状态上报(硬件端调用,无需认证) POST /api/v1/devices/report-status """ serializer = DeviceReportStatusSerializer(data=request.data) if not serializer.is_valid(): return error(message=str(serializer.errors)) data = serializer.validated_data mac = data.pop('mac_address') try: device = Device.objects.get(mac_address=mac) except Device.DoesNotExist: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='设备不存在') # 更新 Device 表字段 device_fields_updated = False for field in ('is_online', 'battery', 'firmware_version'): if field in data: setattr(device, field, data[field]) device_fields_updated = True if data.get('is_online'): from django.utils import timezone device.last_online_at = timezone.now() device_fields_updated = True if device_fields_updated: device.save() # 更新 DeviceSettings 表字段(仅 volume / brightness) settings_data = {k: data[k] for k in ('volume', 'brightness') if k in data} if settings_data: settings_obj, _ = DeviceSettings.objects.get_or_create(device=device) for field, value in settings_data.items(): setattr(settings_obj, field, value) settings_obj.save() return success( data={'device_id': device.id, 'sn': device.sn}, message='状态上报成功' ) # ==================== 角色记忆相关端点 ==================== @action(detail=True, methods=['get'], url_path='role-memory') def role_memory_detail(self, request, pk=None): """ 获取当前设备的角色记忆 GET /api/v1/devices/{user_device_id}/role-memory/ """ try: user_device = UserDevice.objects.select_related( 'role_memory', 'role_memory__device_type' ).get(id=pk, user=request.user, is_active=True) except UserDevice.DoesNotExist: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='绑定记录不存在') if not user_device.role_memory: return error(code=ErrorCode.ROLE_MEMORY_NOT_FOUND, message='该设备暂无角色记忆') return success(data=RoleMemorySerializer(user_device.role_memory).data) @action(detail=True, methods=['put'], url_path='role-memory/settings') def role_memory_settings(self, request, pk=None): """ 更新角色记忆-设备设置 PUT /api/v1/devices/{user_device_id}/role-memory/settings/ """ try: user_device = UserDevice.objects.select_related('role_memory').get( id=pk, user=request.user, is_active=True ) except UserDevice.DoesNotExist: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='绑定记录不存在') if not user_device.role_memory: return error(code=ErrorCode.ROLE_MEMORY_NOT_FOUND, message='该设备暂无角色记忆') serializer = RoleMemorySettingsUpdateSerializer(data=request.data) if not serializer.is_valid(): return error(message=str(serializer.errors)) rm = user_device.role_memory for field, value in serializer.validated_data.items(): setattr(rm, field, value) rm.save() return success(data=RoleMemorySerializer(rm).data, message='设置已保存') @action(detail=True, methods=['put'], url_path='role-memory/agent') def role_memory_agent(self, request, pk=None): """ 更新角色记忆-Agent信息 PUT /api/v1/devices/{user_device_id}/role-memory/agent/ """ try: user_device = UserDevice.objects.select_related('role_memory').get( id=pk, user=request.user, is_active=True ) except UserDevice.DoesNotExist: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='绑定记录不存在') if not user_device.role_memory: return error(code=ErrorCode.ROLE_MEMORY_NOT_FOUND, message='该设备暂无角色记忆') serializer = RoleMemoryAgentUpdateSerializer(data=request.data) if not serializer.is_valid(): return error(message=str(serializer.errors)) rm = user_device.role_memory for field, value in serializer.validated_data.items(): setattr(rm, field, value) rm.save() return success(data=RoleMemorySerializer(rm).data, message='Agent信息已更新') @action(detail=True, methods=['put'], url_path='role-memory/memory') def role_memory_summary(self, request, pk=None): """ 更新角色记忆-聊天记忆摘要 PUT /api/v1/devices/{user_device_id}/role-memory/memory/ """ try: user_device = UserDevice.objects.select_related('role_memory').get( id=pk, user=request.user, is_active=True ) except UserDevice.DoesNotExist: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='绑定记录不存在') if not user_device.role_memory: return error(code=ErrorCode.ROLE_MEMORY_NOT_FOUND, message='该设备暂无角色记忆') serializer = RoleMemoryMemoryUpdateSerializer(data=request.data) if not serializer.is_valid(): return error(message=str(serializer.errors)) user_device.role_memory.memory_summary = serializer.validated_data['memory_summary'] user_device.role_memory.save(update_fields=['memory_summary', 'updated_at']) return success(message='聊天记忆已更新') @action(detail=False, methods=['get'], url_path='role-memories') def role_memory_list(self, request): """ 获取角色记忆列表 GET /api/v1/devices/role-memories/?device_type_id=1&is_bound=false """ qs = RoleMemory.objects.filter(user=request.user).select_related('device_type') device_type_id = request.query_params.get('device_type_id') if device_type_id: qs = qs.filter(device_type_id=device_type_id) is_bound = request.query_params.get('is_bound') if is_bound is not None: qs = qs.filter(is_bound=is_bound.lower() == 'true') return success(data=RoleMemorySerializer(qs, many=True).data) @action(detail=True, methods=['put'], url_path='switch-role-memory') def switch_role_memory(self, request, pk=None): """ 切换设备的角色记忆 PUT /api/v1/devices/{user_device_id}/switch-role-memory/ body: { "role_memory_id": 5 } """ try: user_device = UserDevice.objects.select_related( 'device', 'device__device_type', 'role_memory' ).get(id=pk, user=request.user, is_active=True) except UserDevice.DoesNotExist: return error(code=ErrorCode.DEVICE_NOT_FOUND, message='绑定记录不存在') role_memory_id = request.data.get('role_memory_id') if not role_memory_id: return error(message='role_memory_id 不能为空') try: new_rm = RoleMemory.objects.get(id=role_memory_id, user=request.user) except RoleMemory.DoesNotExist: return error(code=ErrorCode.ROLE_MEMORY_NOT_FOUND, message='该设备暂无角色记忆') # 校验: 目标记忆必须是同一设备类型 if user_device.device.device_type_id and new_rm.device_type_id != user_device.device.device_type_id: return error(code=ErrorCode.ROLE_MEMORY_TYPE_MISMATCH, message='只能切换到同类型设备的角色记忆') # 校验: 目标记忆必须是闲置状态 if new_rm.is_bound: return error(code=ErrorCode.ROLE_MEMORY_ALREADY_BOUND, message='该角色记忆正在被其他设备使用') # 执行切换:旧记忆标记闲置,新记忆标记绑定 old_rm = user_device.role_memory if old_rm: old_rm.is_bound = False old_rm.save(update_fields=['is_bound', 'updated_at']) new_rm.is_bound = True new_rm.save(update_fields=['is_bound', 'updated_at']) user_device.role_memory = new_rm user_device.save(update_fields=['role_memory']) return success(data=RoleMemorySerializer(new_rm).data, message='切换成功')