From 88b8f023f4092f405b538057f513f135e0e41842 Mon Sep 17 00:00:00 2001 From: repair-agent Date: Mon, 9 Feb 2026 15:35:33 +0800 Subject: [PATCH] Fix app api --- CLAUDE.md | 129 ++++++++++++++ apps/admins/views.py | 4 + ...ttery_device_icon_device_is_ai_and_more.py | 136 +++++++++++++++ apps/devices/models.py | 49 ++++++ apps/devices/serializers.py | 52 +++++- apps/devices/views.py | 89 +++++++++- apps/inventory/views.py | 3 + apps/music/__init__.py | 0 apps/music/admin.py | 3 + apps/music/apps.py | 7 + apps/music/migrations/0001_initial.py | 102 +++++++++++ apps/music/migrations/__init__.py | 0 apps/music/models.py | 47 +++++ apps/music/serializers.py | 24 +++ apps/music/tests.py | 3 + apps/music/urls.py | 13 ++ apps/music/views.py | 86 +++++++++ apps/notifications/__init__.py | 0 apps/notifications/admin.py | 3 + apps/notifications/apps.py | 7 + apps/notifications/migrations/0001_initial.py | 98 +++++++++++ apps/notifications/migrations/__init__.py | 0 apps/notifications/models.py | 44 +++++ apps/notifications/serializers.py | 14 ++ apps/notifications/tests.py | 3 + apps/notifications/urls.py | 13 ++ apps/notifications/views.py | 87 +++++++++ apps/spirits/views.py | 49 ++++++ apps/stories/__init__.py | 0 apps/stories/admin.py | 3 + apps/stories/apps.py | 7 + apps/stories/migrations/0001_initial.py | 149 ++++++++++++++++ apps/stories/migrations/__init__.py | 0 apps/stories/models.py | 70 ++++++++ apps/stories/serializers.py | 50 ++++++ apps/stories/tests.py | 3 + apps/stories/urls.py | 14 ++ apps/stories/views.py | 142 +++++++++++++++ apps/system/__init__.py | 0 apps/system/admin.py | 3 + apps/system/apps.py | 7 + apps/system/migrations/0001_initial.py | 112 ++++++++++++ apps/system/migrations/__init__.py | 0 apps/system/models.py | 56 ++++++ apps/system/serializers.py | 23 +++ apps/system/tests.py | 3 + apps/system/urls.py | 14 ++ apps/system/views.py | 62 +++++++ ...day_user_deletion_requested_at_and_more.py | 87 +++++++++ apps/users/models.py | 30 ++++ apps/users/serializers.py | 25 ++- apps/users/views.py | 132 +++++++++++++- config/settings.py | 42 ++++- config/urls.py | 4 + requirements.txt | 1 + test_sms.py | 94 ++++++++++ utils/exceptions.py | 23 +++ utils/sms.py | 165 ++++++++++++++++++ 58 files changed, 2371 insertions(+), 15 deletions(-) create mode 100644 CLAUDE.md create mode 100644 apps/devices/migrations/0003_device_battery_device_icon_device_is_ai_and_more.py create mode 100644 apps/music/__init__.py create mode 100644 apps/music/admin.py create mode 100644 apps/music/apps.py create mode 100644 apps/music/migrations/0001_initial.py create mode 100644 apps/music/migrations/__init__.py create mode 100644 apps/music/models.py create mode 100644 apps/music/serializers.py create mode 100644 apps/music/tests.py create mode 100644 apps/music/urls.py create mode 100644 apps/music/views.py create mode 100644 apps/notifications/__init__.py create mode 100644 apps/notifications/admin.py create mode 100644 apps/notifications/apps.py create mode 100644 apps/notifications/migrations/0001_initial.py create mode 100644 apps/notifications/migrations/__init__.py create mode 100644 apps/notifications/models.py create mode 100644 apps/notifications/serializers.py create mode 100644 apps/notifications/tests.py create mode 100644 apps/notifications/urls.py create mode 100644 apps/notifications/views.py create mode 100644 apps/stories/__init__.py create mode 100644 apps/stories/admin.py create mode 100644 apps/stories/apps.py create mode 100644 apps/stories/migrations/0001_initial.py create mode 100644 apps/stories/migrations/__init__.py create mode 100644 apps/stories/models.py create mode 100644 apps/stories/serializers.py create mode 100644 apps/stories/tests.py create mode 100644 apps/stories/urls.py create mode 100644 apps/stories/views.py create mode 100644 apps/system/__init__.py create mode 100644 apps/system/admin.py create mode 100644 apps/system/apps.py create mode 100644 apps/system/migrations/0001_initial.py create mode 100644 apps/system/migrations/__init__.py create mode 100644 apps/system/models.py create mode 100644 apps/system/serializers.py create mode 100644 apps/system/tests.py create mode 100644 apps/system/urls.py create mode 100644 apps/system/views.py create mode 100644 apps/users/migrations/0002_smscode_user_birthday_user_deletion_requested_at_and_more.py create mode 100644 test_sms.py create mode 100644 utils/sms.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..37925fe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,129 @@ +# RTC Backend - Claude Code 项目指南 + +## 项目概述 + +物联网设备管理平台后端,支持设备绑定 AI 智能体(心灵)。 + +## 技术栈 + +- Django 6.0.1 + DRF 3.16.1 +- Python 3.12+ / MySQL / Redis +- JWT 认证 / 阿里云 OSS + +## 核心目录 + +``` +rtc_backend/ +├── config/ # Django 配置 +├── apps/ # 业务应用 +│ ├── users/ # App 用户(手机号登录) +│ ├── admins/ # 管理员(用户名密码) +│ ├── devices/ # 设备管理 +│ ├── spirits/ # AI 智能体 +│ ├── stories/ # 故事模块 +│ ├── music/ # 音乐模块 +│ ├── notifications/ # 通知模块 +│ ├── system/ # 系统模块(反馈、版本) +│ └── inventory/ # 出入库 +├── utils/ # 工具函数 +└── tests.py # 测试 +``` + +## 关键规范 + +### API 路由 + +- App 端:`/api/v1/*` +- 管理端:`/api/admin/*` + +### 响应格式 + +```python +from utils.response import success, error +from utils.exceptions import ErrorCode + +return success(data={...}) +return error(ErrorCode.DEVICE_NOT_FOUND) +``` + +### 认证 + +- `AppJWTAuthentication` - App 用户 +- `AdminJWTAuthentication` - 管理员 +- **两套认证完全隔离,Token 不互通** + +### 错误码范围 + +| 范围 | 模块 | +|------|------| +| 0 | 成功 | +| 1-99 | 通用 | +| 100-199 | 用户 | +| 200-299 | 设备 | +| 300-399 | 智能体 | +| 400-499 | 批次 | +| 500-599 | 管理员 | +| 600-699 | 故事 | +| 700-799 | 音乐 | +| 800-899 | 通知 | +| 900-999 | 系统 | + +## 常用命令 + +```bash +# 运行测试 +cd rtc_backend && python manage.py test + +# 生成迁移 +python manage.py makemigrations + +# 运行开发服务器 +python manage.py runserver + +# 查看 API 文档 +# http://localhost:8000/api/docs/ +``` + +## 开发规范 + +1. 新增 API 必须添加对应测试用例 +2. 错误码按模块范围分配 +3. 使用 `utils/response.py` 统一响应 +4. 复杂业务逻辑放入 `services.py` + +### Swagger 文档分组规范(必须遵守) + +每个 ViewSet **必须**使用 `@extend_schema(tags=['标签名'])` 装饰器标注所属模块,确保 Swagger 文档按模块分组展示。 + +```python +from drf_spectacular.utils import extend_schema + +@extend_schema(tags=['模块名']) +class MyViewSet(viewsets.ViewSet): + ... +``` + +**标签映射表**(新增 ViewSet 时按此表选择 tag): + +| 模块 | tag 名称 | 适用 ViewSet | +|------|---------|-------------| +| App 认证 | `认证` | AuthViewSet | +| App 用户 | `用户` | UserViewSet | +| 设备 | `设备` | DeviceViewSet | +| 智能体 | `智能体` | SpiritViewSet | +| 故事 | `故事` | StoryViewSet, ShelfViewSet | +| 音乐 | `音乐` | MusicViewSet | +| 通知 | `通知` | NotificationViewSet | +| 系统 | `系统` | FeedbackViewSet, VersionViewSet | +| 管理员认证 | `管理员-认证` | AdminAuthViewSet, AdminProfileViewSet | +| 管理员用户管理 | `管理员-用户管理` | AdminUserManageViewSet (users) | +| 管理员账号管理 | `管理员-账号管理` | AdminUserManageViewSet (admins) | +| 管理员库存 | `管理员-库存` | DeviceTypeViewSet, DeviceBatchViewSet | + +**新增模块时**: +1. 在 `config/settings.py` 的 `SPECTACULAR_SETTINGS['TAGS']` 中添加新标签 +2. 在 ViewSet 类上添加 `@extend_schema(tags=['新标签'])` 装饰器 + +## 参考 + +完整技术规范请使用 `/rtc-spec` 命令查看。 diff --git a/apps/admins/views.py b/apps/admins/views.py index 66c491d..248aec3 100644 --- a/apps/admins/views.py +++ b/apps/admins/views.py @@ -5,6 +5,7 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.permissions import AllowAny from rest_framework_simplejwt.tokens import RefreshToken +from drf_spectacular.utils import extend_schema from utils.response import success, error from utils.exceptions import ErrorCode @@ -19,6 +20,7 @@ from .authentication import get_admin_tokens, AdminJWTAuthentication from .permissions import IsAdminUser, IsSuperAdmin +@extend_schema(tags=['管理员-认证']) class AdminAuthViewSet(viewsets.ViewSet): """管理员认证视图集""" @@ -89,6 +91,7 @@ class AdminAuthViewSet(viewsets.ViewSet): return error(code=ErrorCode.TOKEN_EXPIRED, message='Token已过期或无效') +@extend_schema(tags=['管理员-认证']) class AdminProfileViewSet(viewsets.ViewSet): """管理员个人信息视图集""" @@ -123,6 +126,7 @@ class AdminProfileViewSet(viewsets.ViewSet): return success(message='密码修改成功') +@extend_schema(tags=['管理员-账号管理']) class AdminUserManageViewSet(viewsets.ModelViewSet): """管理员用户管理视图集(仅超级管理员可用)""" diff --git a/apps/devices/migrations/0003_device_battery_device_icon_device_is_ai_and_more.py b/apps/devices/migrations/0003_device_battery_device_icon_device_is_ai_and_more.py new file mode 100644 index 0000000..8cbf788 --- /dev/null +++ b/apps/devices/migrations/0003_device_battery_device_icon_device_is_ai_and_more.py @@ -0,0 +1,136 @@ +# Generated by Django 4.2 on 2026-02-09 06:31 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("devices", "0002_initial"), + ] + + operations = [ + migrations.AddField( + model_name="device", + name="battery", + field=models.IntegerField(default=0, verbose_name="电量百分比"), + ), + migrations.AddField( + model_name="device", + name="icon", + field=models.CharField( + blank=True, max_length=100, null=True, verbose_name="设备图标" + ), + ), + migrations.AddField( + model_name="device", + name="is_ai", + field=models.BooleanField(default=True, verbose_name="是否AI设备"), + ), + migrations.AddField( + model_name="device", + name="is_online", + field=models.BooleanField(default=False, verbose_name="是否在线"), + ), + migrations.CreateModel( + name="DeviceSettings", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "nickname", + models.CharField( + blank=True, max_length=50, null=True, verbose_name="设备昵称" + ), + ), + ( + "user_name", + models.CharField( + blank=True, max_length=50, null=True, verbose_name="用户称呼" + ), + ), + ("volume", models.IntegerField(default=50, verbose_name="音量")), + ("brightness", models.IntegerField(default=50, verbose_name="亮度")), + ( + "allow_interrupt", + models.BooleanField(default=True, verbose_name="允许打断"), + ), + ( + "privacy_mode", + models.BooleanField(default=False, verbose_name="隐私模式"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="更新时间"), + ), + ( + "device", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="settings", + to="devices.device", + verbose_name="设备", + ), + ), + ], + options={ + "verbose_name": "设备设置", + "verbose_name_plural": "设备设置", + "db_table": "device_settings", + }, + ), + migrations.CreateModel( + name="DeviceWifi", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("ssid", models.CharField(max_length=100, verbose_name="WiFi名称")), + ( + "is_connected", + models.BooleanField(default=False, verbose_name="是否已连接"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="更新时间"), + ), + ( + "device", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="wifi_list", + to="devices.device", + verbose_name="设备", + ), + ), + ], + options={ + "verbose_name": "设备WiFi", + "verbose_name_plural": "设备WiFi", + "db_table": "device_wifi", + "unique_together": {("device", "ssid")}, + }, + ), + ] diff --git a/apps/devices/models.py b/apps/devices/models.py index 0ff6a02..746941d 100644 --- a/apps/devices/models.py +++ b/apps/devices/models.py @@ -87,6 +87,10 @@ class Device(models.Model): mac_address = models.CharField('MAC地址', max_length=20, blank=True, null=True, unique=True) name = models.CharField('自定义名称', max_length=100, blank=True, default='') status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='in_stock') + battery = models.IntegerField('电量百分比', default=0) # 0-100 + is_online = models.BooleanField('是否在线', default=False) + is_ai = models.BooleanField('是否AI设备', default=True) + icon = models.CharField('设备图标', max_length=100, null=True, blank=True) firmware_version = models.CharField('固件版本', max_length=20, blank=True, default='') last_online_at = models.DateTimeField('最后在线时间', null=True, blank=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) @@ -129,3 +133,48 @@ class UserDevice(models.Model): def __str__(self): return f"{self.user.phone} - {self.device.sn}" + + +class DeviceSettings(models.Model): + """设备设置""" + device = models.OneToOneField( + Device, on_delete=models.CASCADE, + related_name='settings', verbose_name='设备' + ) + nickname = models.CharField('设备昵称', max_length=50, null=True, blank=True) + user_name = models.CharField('用户称呼', max_length=50, null=True, blank=True) + volume = models.IntegerField('音量', default=50) # 0-100 + brightness = models.IntegerField('亮度', default=50) # 0-100 + allow_interrupt = models.BooleanField('允许打断', default=True) + privacy_mode = models.BooleanField('隐私模式', default=False) + created_at = models.DateTimeField('创建时间', auto_now_add=True) + updated_at = models.DateTimeField('更新时间', auto_now=True) + + class Meta: + db_table = 'device_settings' + verbose_name = '设备设置' + verbose_name_plural = verbose_name + + def __str__(self): + return f"设置 - {self.device.sn}" + + +class DeviceWifi(models.Model): + """设备 WiFi 配置""" + device = models.ForeignKey( + Device, on_delete=models.CASCADE, + related_name='wifi_list', verbose_name='设备' + ) + ssid = models.CharField('WiFi名称', max_length=100) + is_connected = models.BooleanField('是否已连接', default=False) + created_at = models.DateTimeField('创建时间', auto_now_add=True) + updated_at = models.DateTimeField('更新时间', auto_now=True) + + class Meta: + db_table = 'device_wifi' + verbose_name = '设备WiFi' + verbose_name_plural = verbose_name + unique_together = ['device', 'ssid'] + + def __str__(self): + return f"{self.device.sn} - {self.ssid}" diff --git a/apps/devices/serializers.py b/apps/devices/serializers.py index f5629ba..f9bc1e2 100644 --- a/apps/devices/serializers.py +++ b/apps/devices/serializers.py @@ -2,7 +2,7 @@ 设备模块序列化器 """ from rest_framework import serializers -from .models import DeviceType, DeviceBatch, Device, UserDevice +from .models import DeviceType, DeviceBatch, Device, UserDevice, DeviceSettings, DeviceWifi class DeviceTypeSerializer(serializers.ModelSerializer): @@ -86,3 +86,53 @@ class QueryByMacSerializer(serializers.Serializer): # 统一MAC地址格式 value = value.upper().replace('-', ':') return value + + +class DeviceSettingsSerializer(serializers.ModelSerializer): + """设备设置序列化器""" + + class Meta: + model = DeviceSettings + fields = ['nickname', 'user_name', 'volume', 'brightness', + 'allow_interrupt', 'privacy_mode'] + + +class DeviceWifiSerializer(serializers.ModelSerializer): + """设备WiFi序列化器""" + + class Meta: + model = DeviceWifi + fields = ['ssid', 'is_connected'] + + +class DeviceDetailSerializer(serializers.ModelSerializer): + """设备详情序列化器""" + + settings = DeviceSettingsSerializer(read_only=True) + wifi_list = DeviceWifiSerializer(many=True, read_only=True) + status = serializers.SerializerMethodField() + bound_spirit = serializers.SerializerMethodField() + + class Meta: + model = Device + fields = ['id', 'sn', 'name', 'status', 'battery', 'firmware_version', + 'mac_address', 'is_ai', 'icon', 'settings', 'wifi_list', 'bound_spirit'] + + def get_status(self, obj): + return 'online' if obj.is_online else 'offline' + + def get_bound_spirit(self, obj): + user_device = self.context.get('user_device') + if user_device and user_device.spirit: + return {'id': user_device.spirit.id, 'name': user_device.spirit.name} + return None + + +class DeviceSettingsUpdateSerializer(serializers.Serializer): + """更新设备设置序列化器""" + nickname = serializers.CharField(max_length=50, required=False) + user_name = serializers.CharField(max_length=50, required=False) + volume = serializers.IntegerField(min_value=0, max_value=100, required=False) + brightness = serializers.IntegerField(min_value=0, max_value=100, required=False) + allow_interrupt = serializers.BooleanField(required=False) + privacy_mode = serializers.BooleanField(required=False) diff --git a/apps/devices/views.py b/apps/devices/views.py index 4b56375..15566ac 100644 --- a/apps/devices/views.py +++ b/apps/devices/views.py @@ -4,20 +4,24 @@ 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 +from .models import Device, UserDevice, DeviceType, DeviceSettings, DeviceWifi from .serializers import ( DeviceSerializer, UserDeviceSerializer, BindDeviceSerializer, DeviceVerifySerializer, - DeviceTypeSerializer + DeviceTypeSerializer, + DeviceDetailSerializer, + DeviceSettingsUpdateSerializer, ) +@extend_schema(tags=['设备']) class DeviceViewSet(viewsets.ViewSet): """设备视图集(App端)""" @@ -175,5 +179,84 @@ class DeviceViewSet(viewsets.ViewSet): 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']) + def 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' + ).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 配置成功') diff --git a/apps/inventory/views.py b/apps/inventory/views.py index ea70b93..d751141 100644 --- a/apps/inventory/views.py +++ b/apps/inventory/views.py @@ -6,6 +6,7 @@ from django.http import HttpResponse from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.parsers import MultiPartParser +from drf_spectacular.utils import extend_schema from utils.response import success, error from utils.exceptions import ErrorCode @@ -21,6 +22,7 @@ from apps.devices.serializers import ( from .services import generate_batch_devices, export_batch_excel, import_mac_addresses +@extend_schema(tags=['管理员-库存']) class DeviceTypeViewSet(viewsets.ModelViewSet): """设备类型管理视图集 - 管理端""" @@ -89,6 +91,7 @@ class DeviceTypeViewSet(viewsets.ModelViewSet): return error(message=str(serializer.errors)) +@extend_schema(tags=['管理员-库存']) class DeviceBatchViewSet(viewsets.ModelViewSet): """设备批次管理视图集 - 管理端""" diff --git a/apps/music/__init__.py b/apps/music/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/music/admin.py b/apps/music/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/music/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/music/apps.py b/apps/music/apps.py new file mode 100644 index 0000000..0cbd161 --- /dev/null +++ b/apps/music/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class MusicConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.music" + verbose_name = "音乐" diff --git a/apps/music/migrations/0001_initial.py b/apps/music/migrations/0001_initial.py new file mode 100644 index 0000000..9fedb5e --- /dev/null +++ b/apps/music/migrations/0001_initial.py @@ -0,0 +1,102 @@ +# Generated by Django 4.2 on 2026-02-09 06:31 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Track", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=200, verbose_name="标题")), + ( + "lyrics", + models.TextField(blank=True, default="", verbose_name="歌词"), + ), + ( + "audio_url", + models.URLField( + blank=True, default="", max_length=500, verbose_name="音频URL" + ), + ), + ( + "cover_url", + models.URLField( + blank=True, default="", max_length=500, verbose_name="封面URL" + ), + ), + ( + "mood", + models.CharField( + blank=True, + choices=[ + ("happy", "开心"), + ("sad", "悲伤"), + ("calm", "平静"), + ("energetic", "活力"), + ("romantic", "浪漫"), + ], + max_length=20, + null=True, + verbose_name="情绪标签", + ), + ), + ("duration", models.IntegerField(default=0, verbose_name="时长(秒)")), + ( + "prompt", + models.TextField(blank=True, default="", verbose_name="生成提示词"), + ), + ( + "is_favorite", + models.BooleanField(default=False, verbose_name="是否收藏"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="更新时间"), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tracks", + to=settings.AUTH_USER_MODEL, + verbose_name="用户", + ), + ), + ], + options={ + "verbose_name": "音乐曲目", + "verbose_name_plural": "音乐曲目", + "db_table": "track", + "ordering": ["-created_at"], + }, + ), + migrations.AddIndex( + model_name="track", + index=models.Index( + fields=["user", "is_favorite"], name="track_user_id_c612cb_idx" + ), + ), + ] diff --git a/apps/music/migrations/__init__.py b/apps/music/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/music/models.py b/apps/music/models.py new file mode 100644 index 0000000..213aeaa --- /dev/null +++ b/apps/music/models.py @@ -0,0 +1,47 @@ +""" +音乐模块模型 +""" +from django.db import models +from apps.users.models import User + + +class Track(models.Model): + """音乐曲目""" + + MOOD_CHOICES = [ + ('happy', '开心'), + ('sad', '悲伤'), + ('calm', '平静'), + ('energetic', '活力'), + ('romantic', '浪漫'), + ] + + user = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name='tracks', verbose_name='用户' + ) + title = models.CharField('标题', max_length=200) + lyrics = models.TextField('歌词', blank=True, default='') + audio_url = models.URLField('音频URL', max_length=500, blank=True, default='') + cover_url = models.URLField('封面URL', max_length=500, blank=True, default='') + mood = models.CharField( + '情绪标签', max_length=20, + choices=MOOD_CHOICES, null=True, blank=True + ) + duration = models.IntegerField('时长(秒)', default=0) + prompt = models.TextField('生成提示词', blank=True, default='') + is_favorite = models.BooleanField('是否收藏', default=False) + created_at = models.DateTimeField('创建时间', auto_now_add=True) + updated_at = models.DateTimeField('更新时间', auto_now=True) + + class Meta: + db_table = 'track' + verbose_name = '音乐曲目' + verbose_name_plural = verbose_name + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['user', 'is_favorite']), + ] + + def __str__(self): + return self.title diff --git a/apps/music/serializers.py b/apps/music/serializers.py new file mode 100644 index 0000000..f392632 --- /dev/null +++ b/apps/music/serializers.py @@ -0,0 +1,24 @@ +""" +音乐模块序列化器 +""" +from rest_framework import serializers +from .models import Track + + +class TrackSerializer(serializers.ModelSerializer): + """音乐曲目序列化器""" + + class Meta: + model = Track + fields = ['id', 'title', 'lyrics', 'audio_url', 'cover_url', + 'mood', 'duration', 'is_favorite', 'created_at'] + + +class GenerateMusicSerializer(serializers.Serializer): + """生成音乐序列化器""" + text = serializers.CharField(max_length=500) + mood = serializers.ChoiceField( + choices=['happy', 'sad', 'calm', 'energetic', 'romantic'], + required=False, + default='calm' + ) diff --git a/apps/music/tests.py b/apps/music/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/music/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/music/urls.py b/apps/music/urls.py new file mode 100644 index 0000000..b4c5b7f --- /dev/null +++ b/apps/music/urls.py @@ -0,0 +1,13 @@ +""" +音乐模块URL配置 +""" +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import MusicViewSet + +router = DefaultRouter() +router.register('', MusicViewSet, basename='music') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/music/views.py b/apps/music/views.py new file mode 100644 index 0000000..cc0acda --- /dev/null +++ b/apps/music/views.py @@ -0,0 +1,86 @@ +""" +音乐模块视图 - App端 +""" +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +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 Track +from .serializers import TrackSerializer, GenerateMusicSerializer + + +@extend_schema(tags=['音乐']) +class MusicViewSet(viewsets.ViewSet): + """音乐视图集(App端)""" + + authentication_classes = [AppJWTAuthentication] + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get'], url_path='playlist') + def playlist(self, request): + """ + 获取播放列表 + GET /api/v1/music/playlist/ + """ + tracks = Track.objects.filter(user=request.user) + return success(data={ + 'playlist': TrackSerializer(tracks, many=True).data, + }) + + def destroy(self, request, pk=None): + """ + 删除音乐 + DELETE /api/v1/music/{id}/ + """ + try: + track = Track.objects.get(id=pk, user=request.user) + except Track.DoesNotExist: + return error(code=ErrorCode.TRACK_NOT_FOUND, message='曲目不存在') + track.delete() + return success(message='删除成功') + + @action(detail=True, methods=['post']) + def favorite(self, request, pk=None): + """ + 收藏/取消收藏 + POST /api/v1/music/{id}/favorite/ + """ + try: + track = Track.objects.get(id=pk, user=request.user) + except Track.DoesNotExist: + return error(code=ErrorCode.TRACK_NOT_FOUND, message='曲目不存在') + + track.is_favorite = not track.is_favorite + track.save(update_fields=['is_favorite']) + + return success( + data={'is_favorite': track.is_favorite}, + message='已收藏' if track.is_favorite else '已取消收藏' + ) + + @action(detail=False, methods=['post'], url_path='generate') + def generate(self, request): + """ + 生成音乐 (SSE 流式 - 占位) + POST /api/v1/music/generate/ + """ + serializer = GenerateMusicSerializer(data=request.data) + if not serializer.is_valid(): + return error(message=str(serializer.errors)) + + # TODO: 接入 MiniMax API 实现 SSE 流式生成 + track = Track.objects.create( + user=request.user, + title='生成中...', + mood=serializer.validated_data.get('mood', 'calm'), + prompt=serializer.validated_data.get('text', ''), + ) + + return success(data={ + 'id': track.id, + 'message': '音乐生成功能待接入 MiniMax API', + }) diff --git a/apps/notifications/__init__.py b/apps/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/notifications/admin.py b/apps/notifications/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/notifications/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/notifications/apps.py b/apps/notifications/apps.py new file mode 100644 index 0000000..900d619 --- /dev/null +++ b/apps/notifications/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.notifications" + verbose_name = "通知" diff --git a/apps/notifications/migrations/0001_initial.py b/apps/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..56d6f26 --- /dev/null +++ b/apps/notifications/migrations/0001_initial.py @@ -0,0 +1,98 @@ +# Generated by Django 4.2 on 2026-02-09 06:31 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Notification", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type", + models.CharField( + choices=[ + ("system", "系统通知"), + ("device", "设备通知"), + ("activity", "活动通知"), + ], + default="system", + max_length=20, + verbose_name="通知类型", + ), + ), + ("title", models.CharField(max_length=200, verbose_name="标题")), + ( + "description", + models.CharField( + blank=True, default="", max_length=500, verbose_name="摘要" + ), + ), + ( + "content", + models.TextField(blank=True, default="", verbose_name="内容"), + ), + ( + "image_url", + models.URLField( + blank=True, default="", max_length=500, verbose_name="图片URL" + ), + ), + ( + "is_read", + models.BooleanField(default=False, verbose_name="是否已读"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="更新时间"), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to=settings.AUTH_USER_MODEL, + verbose_name="用户", + ), + ), + ], + options={ + "verbose_name": "通知", + "verbose_name_plural": "通知", + "db_table": "notification", + "ordering": ["-created_at"], + }, + ), + migrations.AddIndex( + model_name="notification", + index=models.Index( + fields=["user", "is_read"], name="notificatio_user_id_d569bc_idx" + ), + ), + migrations.AddIndex( + model_name="notification", + index=models.Index(fields=["type"], name="notificatio_type_f65c28_idx"), + ), + ] diff --git a/apps/notifications/migrations/__init__.py b/apps/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/notifications/models.py b/apps/notifications/models.py new file mode 100644 index 0000000..a829627 --- /dev/null +++ b/apps/notifications/models.py @@ -0,0 +1,44 @@ +""" +通知模块模型 +""" +from django.db import models +from apps.users.models import User + + +class Notification(models.Model): + """通知""" + + TYPE_CHOICES = [ + ('system', '系统通知'), + ('device', '设备通知'), + ('activity', '活动通知'), + ] + + user = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name='notifications', verbose_name='用户' + ) + type = models.CharField( + '通知类型', max_length=20, + choices=TYPE_CHOICES, default='system' + ) + title = models.CharField('标题', max_length=200) + description = models.CharField('摘要', max_length=500, blank=True, default='') + content = models.TextField('内容', blank=True, default='') + image_url = models.URLField('图片URL', max_length=500, blank=True, default='') + is_read = models.BooleanField('是否已读', default=False) + created_at = models.DateTimeField('创建时间', auto_now_add=True) + updated_at = models.DateTimeField('更新时间', auto_now=True) + + class Meta: + db_table = 'notification' + verbose_name = '通知' + verbose_name_plural = verbose_name + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['user', 'is_read']), + models.Index(fields=['type']), + ] + + def __str__(self): + return self.title diff --git a/apps/notifications/serializers.py b/apps/notifications/serializers.py new file mode 100644 index 0000000..13e381e --- /dev/null +++ b/apps/notifications/serializers.py @@ -0,0 +1,14 @@ +""" +通知模块序列化器 +""" +from rest_framework import serializers +from .models import Notification + + +class NotificationSerializer(serializers.ModelSerializer): + """通知序列化器""" + + class Meta: + model = Notification + fields = ['id', 'type', 'title', 'description', 'content', + 'image_url', 'is_read', 'created_at'] diff --git a/apps/notifications/tests.py b/apps/notifications/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/notifications/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/notifications/urls.py b/apps/notifications/urls.py new file mode 100644 index 0000000..731a878 --- /dev/null +++ b/apps/notifications/urls.py @@ -0,0 +1,13 @@ +""" +通知模块URL配置 +""" +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import NotificationViewSet + +router = DefaultRouter() +router.register('notifications', NotificationViewSet, basename='notification') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/notifications/views.py b/apps/notifications/views.py new file mode 100644 index 0000000..38f1d15 --- /dev/null +++ b/apps/notifications/views.py @@ -0,0 +1,87 @@ +""" +通知模块视图 - App端 +""" +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +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 Notification +from .serializers import NotificationSerializer + + +@extend_schema(tags=['通知']) +class NotificationViewSet(viewsets.ViewSet): + """通知视图集(App端)""" + + authentication_classes = [AppJWTAuthentication] + permission_classes = [IsAuthenticated] + + def list(self, request): + """ + 通知列表 + GET /api/v1/notifications/ + """ + queryset = Notification.objects.filter(user=request.user) + + # 按类型筛选 + ntype = request.query_params.get('type') + if ntype: + queryset = queryset.filter(type=ntype) + + # 分页 + page = int(request.query_params.get('page', 1)) + page_size = int(request.query_params.get('page_size', 20)) + start = (page - 1) * page_size + end = start + page_size + + total = queryset.count() + unread_count = Notification.objects.filter(user=request.user, is_read=False).count() + notifications = queryset[start:end] + + return success(data={ + 'total': total, + 'unread_count': unread_count, + 'items': NotificationSerializer(notifications, many=True).data, + }) + + def destroy(self, request, pk=None): + """ + 删除通知 + DELETE /api/v1/notifications/{id}/ + """ + try: + notification = Notification.objects.get(id=pk, user=request.user) + except Notification.DoesNotExist: + return error(code=ErrorCode.NOTIFICATION_NOT_FOUND, message='通知不存在') + notification.delete() + return success(message='删除成功') + + @action(detail=True, methods=['post']) + def read(self, request, pk=None): + """ + 标记已读 + POST /api/v1/notifications/{id}/read/ + """ + try: + notification = Notification.objects.get(id=pk, user=request.user) + except Notification.DoesNotExist: + return error(code=ErrorCode.NOTIFICATION_NOT_FOUND, message='通知不存在') + + notification.is_read = True + notification.save(update_fields=['is_read']) + return success(message='已标记为已读') + + @action(detail=False, methods=['post'], url_path='read-all') + def read_all(self, request): + """ + 全部已读 + POST /api/v1/notifications/read-all/ + """ + count = Notification.objects.filter( + user=request.user, is_read=False + ).update(is_read=True) + return success(data={'count': count}, message=f'已将{count}条通知标记为已读') diff --git a/apps/spirits/views.py b/apps/spirits/views.py index d1b6e1d..7b7bc22 100644 --- a/apps/spirits/views.py +++ b/apps/spirits/views.py @@ -2,11 +2,14 @@ 智能体模块视图 - App端 """ from rest_framework import viewsets, status +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +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 apps.devices.models import UserDevice from .models import Spirit from .serializers import ( SpiritSerializer, @@ -15,6 +18,7 @@ from .serializers import ( ) +@extend_schema(tags=['智能体']) class SpiritViewSet(viewsets.ModelViewSet): """智能体视图集(App端)""" @@ -83,3 +87,48 @@ class SpiritViewSet(viewsets.ModelViewSet): instance = self.get_object() instance.delete() return success(message='删除成功') + + @action(detail=True, methods=['post']) + def unbind(self, request, pk=None): + """ + 解绑智能体(从所有设备上移除) + POST /api/v1/spirits/{id}/unbind/ + """ + try: + spirit = Spirit.objects.get(id=pk, user=request.user) + except Spirit.DoesNotExist: + return error(code=ErrorCode.SPIRIT_NOT_FOUND, message='智能体不存在') + + # 解除所有设备与该智能体的绑定 + count = UserDevice.objects.filter( + user=request.user, spirit=spirit, is_active=True + ).update(spirit=None) + + return success(message=f'已解绑智能体,数据已保留在云端(影响 {count} 个设备)') + + @action(detail=True, methods=['post']) + def inject(self, request, pk=None): + """ + 注入智能体到设备 + POST /api/v1/spirits/{id}/inject/ + """ + try: + spirit = Spirit.objects.get(id=pk, user=request.user) + except Spirit.DoesNotExist: + return error(code=ErrorCode.SPIRIT_NOT_FOUND, message='智能体不存在') + + user_device_id = request.data.get('user_device_id') + if not user_device_id: + return error(message='请指定设备') + + try: + user_device = UserDevice.objects.get( + id=user_device_id, user=request.user, is_active=True + ) + except UserDevice.DoesNotExist: + return error(code=ErrorCode.DEVICE_NOT_FOUND, message='设备绑定记录不存在') + + user_device.spirit = spirit + user_device.save(update_fields=['spirit']) + + return success(message='智能体注入成功') diff --git a/apps/stories/__init__.py b/apps/stories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/stories/admin.py b/apps/stories/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/stories/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/stories/apps.py b/apps/stories/apps.py new file mode 100644 index 0000000..13a9e05 --- /dev/null +++ b/apps/stories/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class StoriesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.stories" + verbose_name = "故事" diff --git a/apps/stories/migrations/0001_initial.py b/apps/stories/migrations/0001_initial.py new file mode 100644 index 0000000..2297490 --- /dev/null +++ b/apps/stories/migrations/0001_initial.py @@ -0,0 +1,149 @@ +# Generated by Django 4.2 on 2026-02-09 06:31 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="StoryShelf", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, verbose_name="书架名称")), + ( + "is_locked", + models.BooleanField(default=False, verbose_name="是否加锁"), + ), + ( + "unlock_cost", + models.IntegerField(default=0, verbose_name="解锁积分"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="更新时间"), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="story_shelves", + to=settings.AUTH_USER_MODEL, + verbose_name="用户", + ), + ), + ], + options={ + "verbose_name": "故事书架", + "verbose_name_plural": "故事书架", + "db_table": "story_shelf", + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="Story", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=200, verbose_name="标题")), + ( + "content", + models.TextField(blank=True, default="", verbose_name="内容"), + ), + ( + "cover_url", + models.URLField( + blank=True, default="", max_length=500, verbose_name="封面URL" + ), + ), + ( + "has_video", + models.BooleanField(default=False, verbose_name="是否有视频"), + ), + ( + "video_url", + models.URLField( + blank=True, default="", max_length=500, verbose_name="视频URL" + ), + ), + ( + "generation_mode", + models.CharField( + choices=[("ai", "AI生成"), ("manual", "手动创建")], + default="ai", + max_length=20, + verbose_name="生成方式", + ), + ), + ( + "prompt", + models.TextField(blank=True, default="", verbose_name="生成提示词"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="更新时间"), + ), + ( + "shelf", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="stories", + to="stories.storyshelf", + verbose_name="所属书架", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="stories", + to=settings.AUTH_USER_MODEL, + verbose_name="用户", + ), + ), + ], + options={ + "verbose_name": "故事", + "verbose_name_plural": "故事", + "db_table": "story", + "ordering": ["-created_at"], + }, + ), + migrations.AddIndex( + model_name="story", + index=models.Index( + fields=["user", "shelf"], name="story_user_id_7abea4_idx" + ), + ), + ] diff --git a/apps/stories/migrations/__init__.py b/apps/stories/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/stories/models.py b/apps/stories/models.py new file mode 100644 index 0000000..7f58a43 --- /dev/null +++ b/apps/stories/models.py @@ -0,0 +1,70 @@ +""" +故事模块模型 +""" +from django.db import models +from apps.users.models import User + + +class StoryShelf(models.Model): + """故事书架""" + + user = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name='story_shelves', verbose_name='用户' + ) + name = models.CharField('书架名称', max_length=100) + is_locked = models.BooleanField('是否加锁', default=False) + unlock_cost = models.IntegerField('解锁积分', default=0) + created_at = models.DateTimeField('创建时间', auto_now_add=True) + updated_at = models.DateTimeField('更新时间', auto_now=True) + + class Meta: + db_table = 'story_shelf' + verbose_name = '故事书架' + verbose_name_plural = verbose_name + ordering = ['-created_at'] + + def __str__(self): + return self.name + + +class Story(models.Model): + """故事""" + + GENERATION_MODE_CHOICES = [ + ('ai', 'AI生成'), + ('manual', '手动创建'), + ] + + user = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name='stories', verbose_name='用户' + ) + shelf = models.ForeignKey( + StoryShelf, on_delete=models.CASCADE, + related_name='stories', verbose_name='所属书架' + ) + title = models.CharField('标题', max_length=200) + content = models.TextField('内容', blank=True, default='') + cover_url = models.URLField('封面URL', max_length=500, blank=True, default='') + has_video = models.BooleanField('是否有视频', default=False) + video_url = models.URLField('视频URL', max_length=500, blank=True, default='') + generation_mode = models.CharField( + '生成方式', max_length=20, + choices=GENERATION_MODE_CHOICES, default='ai' + ) + prompt = models.TextField('生成提示词', blank=True, default='') + created_at = models.DateTimeField('创建时间', auto_now_add=True) + updated_at = models.DateTimeField('更新时间', auto_now=True) + + class Meta: + db_table = 'story' + verbose_name = '故事' + verbose_name_plural = verbose_name + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['user', 'shelf']), + ] + + def __str__(self): + return self.title diff --git a/apps/stories/serializers.py b/apps/stories/serializers.py new file mode 100644 index 0000000..a26c393 --- /dev/null +++ b/apps/stories/serializers.py @@ -0,0 +1,50 @@ +""" +故事模块序列化器 +""" +from rest_framework import serializers +from .models import StoryShelf, Story + + +class StoryShelfSerializer(serializers.ModelSerializer): + """书架序列化器""" + story_count = serializers.IntegerField(read_only=True, default=0) + + class Meta: + model = StoryShelf + fields = ['id', 'name', 'is_locked', 'unlock_cost', 'story_count', 'created_at'] + read_only_fields = ['id', 'created_at'] + + +class CreateShelfSerializer(serializers.Serializer): + """创建书架序列化器""" + name = serializers.CharField(max_length=100) + + +class StoryListSerializer(serializers.ModelSerializer): + """故事列表序列化器""" + + class Meta: + model = Story + fields = ['id', 'title', 'cover_url', 'content', 'has_video', + 'video_url', 'created_at'] + + +class StoryDetailSerializer(serializers.ModelSerializer): + """故事详情序列化器""" + + class Meta: + model = Story + fields = ['id', 'title', 'content', 'cover_url', 'has_video', + 'video_url', 'generation_mode', 'prompt', 'shelf', + 'created_at', 'updated_at'] + + +class GenerateStorySerializer(serializers.Serializer): + """生成故事序列化器""" + mode = serializers.ChoiceField( + choices=['random', 'keyword', 'theme'], + default='random' + ) + prompt = serializers.CharField(required=False, allow_blank=True, default='') + theme = serializers.CharField(required=False, allow_blank=True, default='') + shelf_id = serializers.IntegerField(required=False, allow_null=True) diff --git a/apps/stories/tests.py b/apps/stories/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/stories/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/stories/urls.py b/apps/stories/urls.py new file mode 100644 index 0000000..7d931f2 --- /dev/null +++ b/apps/stories/urls.py @@ -0,0 +1,14 @@ +""" +故事模块URL配置 +""" +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import StoryViewSet, ShelfViewSet + +router = DefaultRouter() +router.register('shelves', ShelfViewSet, basename='shelves') +router.register('', StoryViewSet, basename='stories') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/stories/views.py b/apps/stories/views.py new file mode 100644 index 0000000..2ca2896 --- /dev/null +++ b/apps/stories/views.py @@ -0,0 +1,142 @@ +""" +故事模块视图 - App端 +""" +from django.db.models import Count +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +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 StoryShelf, Story +from .serializers import ( + StoryShelfSerializer, + CreateShelfSerializer, + StoryListSerializer, + GenerateStorySerializer, +) + + +@extend_schema(tags=['故事']) +class StoryViewSet(viewsets.ViewSet): + """故事视图集(App端)""" + + authentication_classes = [AppJWTAuthentication] + permission_classes = [IsAuthenticated] + + def list(self, request): + """ + 获取故事列表 + GET /api/v1/stories/?shelf_id=1&page=1&page_size=20 + """ + queryset = Story.objects.filter(user=request.user) + + shelf_id = request.query_params.get('shelf_id') + if shelf_id: + queryset = queryset.filter(shelf_id=shelf_id) + + # 分页 + page = int(request.query_params.get('page', 1)) + page_size = int(request.query_params.get('page_size', 20)) + start = (page - 1) * page_size + total = queryset.count() + items = queryset[start:start + page_size] + + return success(data={ + 'total': total, + 'items': StoryListSerializer(items, many=True).data, + }) + + def destroy(self, request, pk=None): + """ + 删除故事 + DELETE /api/v1/stories/{id}/ + """ + try: + story = Story.objects.get(id=pk, user=request.user) + except Story.DoesNotExist: + return error(code=ErrorCode.STORY_NOT_FOUND, message='故事不存在') + story.delete() + return success(message='删除成功') + + @action(detail=False, methods=['post'], url_path='generate') + def generate(self, request): + """ + 生成故事 (SSE 流式 - 占位) + POST /api/v1/stories/generate/ + """ + serializer = GenerateStorySerializer(data=request.data) + if not serializer.is_valid(): + return error(message=str(serializer.errors)) + + # TODO: 接入 LLM API 实现 SSE 流式生成 + shelf_id = serializer.validated_data.get('shelf_id') + shelf = None + if shelf_id: + try: + shelf = StoryShelf.objects.get(id=shelf_id, user=request.user) + except StoryShelf.DoesNotExist: + return error(code=ErrorCode.SHELF_NOT_FOUND, message='书架不存在') + + story = Story.objects.create( + user=request.user, + shelf=shelf, + title='生成中...', + content='', + generation_mode=serializer.validated_data.get('mode', 'random'), + prompt=serializer.validated_data.get('prompt', ''), + ) + + return success(data={ + 'id': story.id, + 'message': '故事生成功能待接入 LLM API', + }) + + +@extend_schema(tags=['故事']) +class ShelfViewSet(viewsets.ViewSet): + """书架视图集(App端)""" + + authentication_classes = [AppJWTAuthentication] + permission_classes = [IsAuthenticated] + + def list(self, request): + """ + 书架列表 + GET /api/v1/stories/shelves/ + """ + shelves = StoryShelf.objects.filter( + user=request.user + ).annotate(story_count=Count('stories')) + return success(data=StoryShelfSerializer(shelves, many=True).data) + + def create(self, request): + """ + 创建书架 + POST /api/v1/stories/shelves/ + """ + serializer = CreateShelfSerializer(data=request.data) + if not serializer.is_valid(): + return error(message=str(serializer.errors)) + + shelf = StoryShelf.objects.create( + user=request.user, + name=serializer.validated_data['name'], + ) + return success(data=StoryShelfSerializer(shelf).data, message='创建成功') + + def destroy(self, request, pk=None): + """ + 删除书架(故事保留,shelf_id 置 null) + DELETE /api/v1/stories/shelves/{id}/ + """ + try: + shelf = StoryShelf.objects.get(id=pk, user=request.user) + except StoryShelf.DoesNotExist: + return error(code=ErrorCode.SHELF_NOT_FOUND, message='书架不存在') + + Story.objects.filter(shelf=shelf).update(shelf=None) + shelf.delete() + return success(message='删除成功') diff --git a/apps/system/__init__.py b/apps/system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/system/admin.py b/apps/system/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/system/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/system/apps.py b/apps/system/apps.py new file mode 100644 index 0000000..e7bbc5c --- /dev/null +++ b/apps/system/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class SystemConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.system" + verbose_name = "系统" diff --git a/apps/system/migrations/0001_initial.py b/apps/system/migrations/0001_initial.py new file mode 100644 index 0000000..155871b --- /dev/null +++ b/apps/system/migrations/0001_initial.py @@ -0,0 +1,112 @@ +# Generated by Django 4.2 on 2026-02-09 06:31 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AppVersion", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "platform", + models.CharField( + choices=[("ios", "iOS"), ("android", "Android")], + max_length=20, + verbose_name="平台", + ), + ), + ("version", models.CharField(max_length=20, verbose_name="版本号")), + ( + "force_update", + models.BooleanField(default=False, verbose_name="是否强制更新"), + ), + ( + "update_url", + models.URLField( + blank=True, default="", max_length=500, verbose_name="下载地址" + ), + ), + ( + "release_notes", + models.TextField(blank=True, default="", verbose_name="更新说明"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="更新时间"), + ), + ], + options={ + "verbose_name": "App版本", + "verbose_name_plural": "App版本", + "db_table": "app_version", + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="Feedback", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("content", models.TextField(verbose_name="反馈内容")), + ( + "contact", + models.CharField( + blank=True, default="", max_length=100, verbose_name="联系方式" + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="更新时间"), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="feedbacks", + to=settings.AUTH_USER_MODEL, + verbose_name="用户", + ), + ), + ], + options={ + "verbose_name": "用户反馈", + "verbose_name_plural": "用户反馈", + "db_table": "feedback", + "ordering": ["-created_at"], + }, + ), + ] diff --git a/apps/system/migrations/__init__.py b/apps/system/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/system/models.py b/apps/system/models.py new file mode 100644 index 0000000..74b14b9 --- /dev/null +++ b/apps/system/models.py @@ -0,0 +1,56 @@ +""" +系统模块模型 +""" +from django.db import models +from apps.users.models import User + + +class Feedback(models.Model): + """用户反馈""" + + user = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name='feedbacks', verbose_name='用户' + ) + content = models.TextField('反馈内容') + contact = models.CharField('联系方式', max_length=100, blank=True, default='') + created_at = models.DateTimeField('创建时间', auto_now_add=True) + updated_at = models.DateTimeField('更新时间', auto_now=True) + + class Meta: + db_table = 'feedback' + verbose_name = '用户反馈' + verbose_name_plural = verbose_name + ordering = ['-created_at'] + + def __str__(self): + return f"{self.user.phone} - {self.content[:20]}" + + +class AppVersion(models.Model): + """App版本""" + + PLATFORM_CHOICES = [ + ('ios', 'iOS'), + ('android', 'Android'), + ] + + platform = models.CharField( + '平台', max_length=20, + choices=PLATFORM_CHOICES + ) + version = models.CharField('版本号', max_length=20) + force_update = models.BooleanField('是否强制更新', default=False) + update_url = models.URLField('下载地址', max_length=500, blank=True, default='') + release_notes = models.TextField('更新说明', blank=True, default='') + created_at = models.DateTimeField('创建时间', auto_now_add=True) + updated_at = models.DateTimeField('更新时间', auto_now=True) + + class Meta: + db_table = 'app_version' + verbose_name = 'App版本' + verbose_name_plural = verbose_name + ordering = ['-created_at'] + + def __str__(self): + return f"{self.platform} - {self.version}" diff --git a/apps/system/serializers.py b/apps/system/serializers.py new file mode 100644 index 0000000..a852829 --- /dev/null +++ b/apps/system/serializers.py @@ -0,0 +1,23 @@ +""" +系统模块序列化器 +""" +from rest_framework import serializers +from .models import Feedback, AppVersion + + +class FeedbackSerializer(serializers.ModelSerializer): + """反馈序列化器""" + + class Meta: + model = Feedback + fields = ['id', 'content', 'contact', 'created_at'] + read_only_fields = ['id', 'created_at'] + + +class AppVersionSerializer(serializers.ModelSerializer): + """App版本序列化器""" + + class Meta: + model = AppVersion + fields = ['id', 'platform', 'version', 'force_update', + 'update_url', 'release_notes', 'created_at'] diff --git a/apps/system/tests.py b/apps/system/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/system/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/system/urls.py b/apps/system/urls.py new file mode 100644 index 0000000..8087e0b --- /dev/null +++ b/apps/system/urls.py @@ -0,0 +1,14 @@ +""" +系统模块URL配置 +""" +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import FeedbackViewSet, VersionViewSet + +router = DefaultRouter() +router.register('feedback', FeedbackViewSet, basename='feedback') +router.register('version', VersionViewSet, basename='version') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/system/views.py b/apps/system/views.py new file mode 100644 index 0000000..d751518 --- /dev/null +++ b/apps/system/views.py @@ -0,0 +1,62 @@ +""" +系统模块视图 - App端 +""" +from rest_framework import viewsets +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 apps.admins.authentication import AppJWTAuthentication +from .models import Feedback, AppVersion +from .serializers import FeedbackSerializer, AppVersionSerializer + + +@extend_schema(tags=['系统']) +class FeedbackViewSet(viewsets.ViewSet): + """意见反馈视图集(App端)""" + + authentication_classes = [AppJWTAuthentication] + permission_classes = [IsAuthenticated] + + def create(self, request): + """ + 提交反馈 + POST /api/v1/feedback/ + """ + serializer = FeedbackSerializer(data=request.data) + if not serializer.is_valid(): + return error(message=str(serializer.errors)) + + serializer.save(user=request.user) + return success(data=serializer.data, message='反馈已提交,感谢您的意见') + + +@extend_schema(tags=['系统']) +class VersionViewSet(viewsets.ViewSet): + """版本检查视图集(App端)""" + + permission_classes = [AllowAny] + + @action(detail=False, methods=['get']) + def check(self, request): + """ + 检查版本更新 + GET /api/v1/version/check/?platform=ios¤t_version=1.0.0 + """ + platform = request.query_params.get('platform', 'ios') + current_version = request.query_params.get('current_version', '') + + latest = AppVersion.objects.filter( + platform=platform + ).order_by('-created_at').first() + + if not latest: + return success(data={'has_update': False}) + + has_update = latest.version != current_version + + return success(data={ + 'has_update': has_update, + 'latest_version': AppVersionSerializer(latest).data if has_update else None, + }) diff --git a/apps/users/migrations/0002_smscode_user_birthday_user_deletion_requested_at_and_more.py b/apps/users/migrations/0002_smscode_user_birthday_user_deletion_requested_at_and_more.py new file mode 100644 index 0000000..60de749 --- /dev/null +++ b/apps/users/migrations/0002_smscode_user_birthday_user_deletion_requested_at_and_more.py @@ -0,0 +1,87 @@ +# Generated by Django 4.2 on 2026-02-09 06:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="SmsCode", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("phone", models.CharField(max_length=20, verbose_name="手机号")), + ("code", models.CharField(max_length=6, verbose_name="验证码")), + ( + "is_used", + models.BooleanField(default=False, verbose_name="是否已使用"), + ), + ("expire_at", models.DateTimeField(verbose_name="过期时间")), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), + ), + ], + options={ + "verbose_name": "短信验证码", + "verbose_name_plural": "短信验证码", + "db_table": "sms_code", + }, + ), + migrations.AddField( + model_name="user", + name="birthday", + field=models.DateField(blank=True, null=True, verbose_name="生日"), + ), + migrations.AddField( + model_name="user", + name="deletion_requested_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="注销申请时间" + ), + ), + migrations.AddField( + model_name="user", + name="gender", + field=models.CharField( + choices=[("male", "男"), ("female", "女"), ("unknown", "未知")], + default="unknown", + max_length=10, + verbose_name="性别", + ), + ), + migrations.AddField( + model_name="user", + name="is_pending_deletion", + field=models.BooleanField(default=False, verbose_name="是否待注销"), + ), + migrations.AddField( + model_name="user", + name="points", + field=models.IntegerField(default=0, verbose_name="积分余额"), + ), + migrations.AddIndex( + model_name="smscode", + index=models.Index( + fields=["phone", "code"], name="sms_code_phone_e54855_idx" + ), + ), + migrations.AddIndex( + model_name="smscode", + index=models.Index( + fields=["expire_at"], name="sms_code_expire__6612d9_idx" + ), + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index 95b670e..2abba15 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -30,8 +30,17 @@ class User(AbstractBaseUser, PermissionsMixin): phone = models.CharField('手机号', max_length=20, unique=True) nickname = models.CharField('昵称', max_length=50, blank=True, default='') avatar = models.URLField('头像', max_length=500, blank=True, default='') + gender = models.CharField( + '性别', max_length=10, + choices=[('male', '男'), ('female', '女'), ('unknown', '未知')], + default='unknown' + ) + birthday = models.DateField('生日', null=True, blank=True) + points = models.IntegerField('积分余额', default=0) is_active = models.BooleanField('是否激活', default=True) is_staff = models.BooleanField('是否管理员', default=False) + is_pending_deletion = models.BooleanField('是否待注销', default=False) + deletion_requested_at = models.DateTimeField('注销申请时间', null=True, blank=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) @@ -47,3 +56,24 @@ class User(AbstractBaseUser, PermissionsMixin): def __str__(self): return self.phone + + +class SmsCode(models.Model): + """短信验证码""" + phone = models.CharField('手机号', max_length=20) + code = models.CharField('验证码', max_length=6) + is_used = models.BooleanField('是否已使用', default=False) + expire_at = models.DateTimeField('过期时间') + created_at = models.DateTimeField('创建时间', auto_now_add=True) + + class Meta: + db_table = 'sms_code' + verbose_name = '短信验证码' + verbose_name_plural = verbose_name + indexes = [ + models.Index(fields=['phone', 'code']), + models.Index(fields=['expire_at']), + ] + + def __str__(self): + return f"{self.phone} - {self.code}" diff --git a/apps/users/serializers.py b/apps/users/serializers.py index c4aec74..548c92d 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -7,10 +7,10 @@ from .models import User class UserSerializer(serializers.ModelSerializer): """用户序列化器""" - + class Meta: model = User - fields = ['id', 'phone', 'nickname', 'avatar', 'created_at'] + fields = ['id', 'phone', 'nickname', 'avatar', 'gender', 'birthday', 'created_at'] read_only_fields = ['id', 'phone', 'created_at'] @@ -38,7 +38,24 @@ class PhoneLoginSerializer(serializers.Serializer): class UpdateUserSerializer(serializers.ModelSerializer): """更新用户信息序列化器""" - + class Meta: model = User - fields = ['nickname', 'avatar'] + fields = ['nickname', 'avatar', 'gender', 'birthday'] + + +class SendCodeSerializer(serializers.Serializer): + """发送验证码序列化器""" + phone = serializers.RegexField( + regex=r'^1[3-9]\d{9}$', + error_messages={'invalid': '手机号格式不正确'} + ) + + +class CodeLoginSerializer(serializers.Serializer): + """验证码登录序列化器""" + phone = serializers.RegexField( + regex=r'^1[3-9]\d{9}$', + error_messages={'invalid': '手机号格式不正确'} + ) + code = serializers.CharField(max_length=6, min_length=6) diff --git a/apps/users/views.py b/apps/users/views.py index 528c1a5..7c3b0fa 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -6,16 +6,23 @@ from rest_framework.decorators import action from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework_simplejwt.tokens import RefreshToken from django.db.models import Count +from drf_spectacular.utils import extend_schema +from django.utils import timezone from utils.response import success, error +from utils.exceptions import ErrorCode +from utils.sms import send_sms_code, verify_sms_code +from utils.oss import get_oss_client from apps.admins.authentication import AppJWTAuthentication, AdminJWTAuthentication from apps.admins.permissions import IsAdminUser from .models import User from .serializers import ( - UserSerializer, - UserDetailSerializer, + UserSerializer, + UserDetailSerializer, PhoneLoginSerializer, - UpdateUserSerializer + UpdateUserSerializer, + SendCodeSerializer, + CodeLoginSerializer, ) @@ -35,6 +42,7 @@ def get_app_tokens(user): } +@extend_schema(tags=['认证']) class AuthViewSet(viewsets.ViewSet): """认证视图集 - App端""" @@ -70,6 +78,92 @@ class AuthViewSet(viewsets.ViewSet): 'is_new_user': created }, message='登录成功') + @action(detail=False, methods=['post'], url_path='send-code') + def send_code(self, request): + """ + 发送验证码 + POST /api/v1/auth/send-code/ + """ + serializer = SendCodeSerializer(data=request.data) + if not serializer.is_valid(): + return error(message=str(serializer.errors)) + + phone = serializer.validated_data['phone'] + ok, err = send_sms_code(phone) + if not ok: + return error(code=ErrorCode.SMS_CODE_SEND_LIMIT, message=err) + + return success( + data={'expire_in': 60}, + message='验证码已发送' + ) + + @action(detail=False, methods=['post'], url_path='code-login') + def code_login(self, request): + """ + 验证码登录 + POST /api/v1/auth/code-login/ + """ + serializer = CodeLoginSerializer(data=request.data) + if not serializer.is_valid(): + return error(message=str(serializer.errors)) + + phone = serializer.validated_data['phone'] + code = serializer.validated_data['code'] + + # 校验验证码 + valid, err = verify_sms_code(phone, code) + if not valid: + return error(code=ErrorCode.SMS_CODE_INVALID, message=err) + + # 获取或创建用户 + user, created = User.objects.get_or_create( + phone=phone, + defaults={'nickname': f'用户{phone[-4:]}'} + ) + + if not user.is_active: + return error(code=ErrorCode.USER_DISABLED, message='账号已被禁用') + + tokens = get_app_tokens(user) + + return success(data={ + 'user': UserSerializer(user).data, + 'token': tokens, + 'is_new_user': created + }, message='登录成功') + + @action(detail=False, methods=['post'], url_path='logout', + authentication_classes=[AppJWTAuthentication], + permission_classes=[IsAuthenticated]) + def logout(self, request): + """ + 退出登录 + POST /api/v1/auth/logout/ + """ + try: + refresh_token = request.data.get('refresh') + if refresh_token: + token = RefreshToken(refresh_token) + token.blacklist() + except Exception: + pass + return success(message='已退出登录') + + @action(detail=False, methods=['delete'], url_path='account', + authentication_classes=[AppJWTAuthentication], + permission_classes=[IsAuthenticated]) + def delete_account(self, request): + """ + 账号注销 + DELETE /api/v1/auth/account/ + """ + user = request.user + user.is_pending_deletion = True + user.deletion_requested_at = timezone.now() + user.save(update_fields=['is_pending_deletion', 'deletion_requested_at']) + return success(message='账号注销申请已提交,将在7个工作日内处理') + @action(detail=False, methods=['post'], url_path='refresh') def refresh_token(self, request): """ @@ -95,6 +189,7 @@ class AuthViewSet(viewsets.ViewSet): return error(code=103, message='Token已过期或无效') +@extend_schema(tags=['用户']) class UserViewSet(viewsets.ViewSet): """用户视图集 - App端""" @@ -121,7 +216,38 @@ class UserViewSet(viewsets.ViewSet): return success(data=UserSerializer(request.user).data, message='更新成功') return error(message=str(serializer.errors)) + @action(detail=False, methods=['post']) + def avatar(self, request): + """ + 上传头像 + POST /api/v1/users/avatar/ + """ + file = request.FILES.get('file') + if not file: + return error(message='请选择文件') + # 验证文件类型 + allowed_types = ['image/jpeg', 'image/png', 'image/gif'] + if file.content_type not in allowed_types: + return error(message='仅支持 JPG、PNG、GIF 格式') + + # 验证文件大小 (5MB) + if file.size > 5 * 1024 * 1024: + return error(message='文件大小不能超过 5MB') + + try: + oss_client = get_oss_client() + avatar_url = oss_client.upload_file(file, folder='avatars') + except Exception as e: + return error(message=f'上传失败: {str(e)}') + + request.user.avatar = avatar_url + request.user.save(update_fields=['avatar']) + + return success(data={'avatar_url': avatar_url}) + + +@extend_schema(tags=['管理员-用户管理']) class AdminUserManageViewSet(viewsets.ViewSet): """App用户管理视图集 - 管理端""" diff --git a/config/settings.py b/config/settings.py index f7416ce..1487abf 100644 --- a/config/settings.py +++ b/config/settings.py @@ -35,6 +35,10 @@ INSTALLED_APPS = [ 'apps.devices', 'apps.inventory', 'apps.admins', + 'apps.stories', + 'apps.music', + 'apps.notifications', + 'apps.system', ] MIDDLEWARE = [ @@ -152,25 +156,55 @@ SIMPLE_JWT = { 'AUTH_HEADER_TYPES': ('Bearer',), } +# Aliyun 公共配置(OSS 和 SMS 共用) +ALIYUN_ACCESS_KEY_ID = os.environ.get('ALIYUN_ACCESS_KEY_ID', 'LTAI5tBGAkR2rra2prTAX9yc') +ALIYUN_ACCESS_KEY_SECRET = os.environ.get('ALIYUN_ACCESS_KEY_SECRET', 'U1z3d0p5saPRD5sCxVooJYSjxSAmKB') + # Aliyun OSS Settings ALIYUN_OSS = { - 'ACCESS_KEY_ID': os.environ.get('OSS_ACCESS_KEY_ID', 'LTAI5tBGAkR2rra2prTAX9yc'), - 'ACCESS_KEY_SECRET': os.environ.get('OSS_ACCESS_KEY_SECRET', 'U1z3d0p5saPRD5sCxVooJYSjxSAmKB'), + 'ACCESS_KEY_ID': os.environ.get('OSS_ACCESS_KEY_ID', ALIYUN_ACCESS_KEY_ID), + 'ACCESS_KEY_SECRET': os.environ.get('OSS_ACCESS_KEY_SECRET', ALIYUN_ACCESS_KEY_SECRET), 'ENDPOINT': os.environ.get('OSS_ENDPOINT', 'oss-cn-beijing.aliyuncs.com'), 'BUCKET_NAME': os.environ.get('OSS_BUCKET_NAME', 'qy-rtc'), 'CUSTOM_DOMAIN': os.environ.get('OSS_CUSTOM_DOMAIN', ''), } +# Aliyun SMS Settings +ALIYUN_SMS = { + 'ACCESS_KEY_ID': os.environ.get('SMS_ACCESS_KEY_ID', ALIYUN_ACCESS_KEY_ID), + 'ACCESS_KEY_SECRET': os.environ.get('SMS_ACCESS_KEY_SECRET', ALIYUN_ACCESS_KEY_SECRET), + 'SIGN_NAME': os.environ.get('SMS_SIGN_NAME', '广州气元科技'), + 'TEMPLATE_CODE': os.environ.get('SMS_TEMPLATE_CODE', 'SMS_317100048'), + 'CODE_LENGTH': 6, # 验证码位数 + 'CODE_EXPIRE': 300, # 过期时间(秒) 5分钟 + 'SEND_INTERVAL': 60, # 发送间隔(秒) 60秒 +} + # Swagger/OpenAPI Settings SPECTACULAR_SETTINGS = { - 'TITLE': 'RTC_DEMO API', - 'DESCRIPTION': 'RTC物联网设备管理平台API文档', + 'TITLE': 'RTC API', + 'DESCRIPTION': 'RTC 物联网设备管理平台', 'VERSION': '1.0.0', 'SERVE_INCLUDE_SCHEMA': False, 'COMPONENT_SPLIT_REQUEST': True, 'SWAGGER_UI_SETTINGS': { 'persistAuthorization': True, + 'docExpansion': 'list', }, + 'TAGS': [ + {'name': '认证', 'description': 'App 用户登录、登出、Token 刷新'}, + {'name': '用户', 'description': 'App 用户个人信息管理'}, + {'name': '设备', 'description': '设备绑定、设置、WiFi 配置'}, + {'name': '智能体', 'description': 'AI 智能体 CRUD 和绑定'}, + {'name': '故事', 'description': '故事列表、书架管理、生成'}, + {'name': '音乐', 'description': '音乐播放列表、收藏、生成'}, + {'name': '通知', 'description': '通知列表、已读、删除'}, + {'name': '系统', 'description': '意见反馈、版本检查'}, + {'name': '管理员-认证', 'description': '管理员登录和个人信息'}, + {'name': '管理员-用户管理', 'description': '管理端 App 用户管理'}, + {'name': '管理员-账号管理', 'description': '管理员账号 CRUD(超级管理员)'}, + {'name': '管理员-库存', 'description': '设备类型和批次管理'}, + ], } # Logging diff --git a/config/urls.py b/config/urls.py index b4ccd3b..c41187b 100644 --- a/config/urls.py +++ b/config/urls.py @@ -14,6 +14,10 @@ app_api_patterns = [ path('', include('apps.users.urls')), path('', include('apps.spirits.urls')), path('', include('apps.devices.urls')), + path('', include('apps.stories.urls')), + path('', include('apps.music.urls')), + path('', include('apps.notifications.urls')), + path('', include('apps.system.urls')), ] # ============ Web管理端路由 (管理员,用户名密码登录) ============ diff --git a/requirements.txt b/requirements.txt index ebc3275..3bf8278 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,3 +27,4 @@ six==1.17.0 sqlparse==0.5.5 urllib3==2.6.3 drf-spectacular==0.27.1 +alibabacloud_dysmsapi20170525>=4.4.0 diff --git a/test_sms.py b/test_sms.py new file mode 100644 index 0000000..79d8025 --- /dev/null +++ b/test_sms.py @@ -0,0 +1,94 @@ +""" +短信发送测试脚本 +用法: python test_sms.py +""" +import os +import sys +import django + +# 初始化 Django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +django.setup() + +from utils.sms import get_sms_client, generate_code, send_sms_code, verify_sms_code + + +def test_direct_send(): + """测试1: 直接调用 SDK 发送""" + print('=' * 50) + print('测试1: 直接调用阿里云 SMS SDK') + print('=' * 50) + + client = get_sms_client() + if not client.client: + print('[FAIL] SMS SDK 未初始化,检查 alibabacloud_dysmsapi20170525 是否安装') + return False + + code = generate_code() + phone = '13725102796' + print(f'手机号: {phone}') + print(f'验证码: {code}') + print(f'签名: {client.sign_name}') + print(f'模板: {client.template_code}') + print('发送中...') + + ok, err = client.send_code(phone, code) + if ok: + print(f'[OK] 发送成功! 请查收短信,验证码: {code}') + else: + print(f'[FAIL] 发送失败: {err}') + return ok + + +def test_full_flow(): + """测试2: 完整流程(发送 + 校验)""" + print() + print('=' * 50) + print('测试2: 完整流程(发送 + 存库 + 校验)') + print('=' * 50) + + phone = '13725102796' + + # 发送 + print(f'[1/3] 发送验证码到 {phone}...') + ok, err = send_sms_code(phone) + if not ok: + print(f'[FAIL] 发送失败: {err}') + return False + print('[OK] 发送成功') + + # 从数据库读取验证码 + from apps.users.models import SmsCode + record = SmsCode.objects.filter(phone=phone, is_used=False).order_by('-created_at').first() + if not record: + print('[FAIL] 数据库中未找到验证码记录') + return False + print(f'[2/3] 数据库记录: code={record.code}, expire_at={record.expire_at}') + + # 校验 + valid, err = verify_sms_code(phone, record.code) + if valid: + print('[OK] 验证码校验通过') + else: + print(f'[FAIL] 校验失败: {err}') + return valid + + +if __name__ == '__main__': + print('阿里云短信服务测试') + print(f'AK: {os.environ.get("ALIYUN_ACCESS_KEY_ID", "使用默认值")}') + print() + + # 测试1: 只测 SDK 连通性 + ok = test_direct_send() + + if ok: + # 测试2: 需要数据库连通 + try: + test_full_flow() + except Exception as e: + print(f'\n[SKIP] 完整流程测试跳过(数据库连接问题): {e}') + + print() + print('测试完成') diff --git a/utils/exceptions.py b/utils/exceptions.py index dc4a446..af1900e 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -90,6 +90,9 @@ class ErrorCode: USER_DISABLED = 101 PHONE_INVALID = 102 TOKEN_EXPIRED = 103 + SMS_CODE_INVALID = 110 + SMS_CODE_EXPIRED = 111 + SMS_CODE_SEND_LIMIT = 112 # 设备模块 200-299 DEVICE_NOT_FOUND = 200 @@ -106,6 +109,26 @@ class ErrorCode: BATCH_SN_EXISTS = 401 DEVICE_TYPE_NOT_FOUND = 402 + # 管理员模块 500-599 + ADMIN_NOT_FOUND = 500 + ADMIN_PASSWORD_ERROR = 501 + + # 故事模块 600-699 + STORY_NOT_FOUND = 600 + SHELF_NOT_FOUND = 601 + SHELF_LOCKED = 602 + POINTS_NOT_ENOUGH = 603 + + # 音乐模块 700-799 + TRACK_NOT_FOUND = 700 + MUSIC_GENERATE_FAILED = 701 + + # 通知模块 800-899 + NOTIFICATION_NOT_FOUND = 800 + + # 系统模块 900-999 + FEEDBACK_SUBMIT_FAILED = 900 + def custom_exception_handler(exc, context): """自定义异常处理器""" diff --git a/utils/sms.py b/utils/sms.py new file mode 100644 index 0000000..3545912 --- /dev/null +++ b/utils/sms.py @@ -0,0 +1,165 @@ +""" +阿里云短信服务工具类 +""" +import json +import random +import logging +from datetime import timedelta +from django.conf import settings +from django.utils import timezone + +logger = logging.getLogger(__name__) + +try: + from alibabacloud_dysmsapi20170525.client import Client + from alibabacloud_tea_openapi.models import Config + from alibabacloud_tea_util.models import RuntimeOptions + from alibabacloud_dysmsapi20170525.models import SendSmsRequest + SMS_SDK_AVAILABLE = True +except ImportError: + SMS_SDK_AVAILABLE = False + + +class SMSClient: + """阿里云短信客户端""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + if not SMS_SDK_AVAILABLE: + self.client = None + self._initialized = True + return + + sms_config = settings.ALIYUN_SMS + if not sms_config.get('ACCESS_KEY_ID'): + self.client = None + self._initialized = True + return + + config = Config( + access_key_id=sms_config['ACCESS_KEY_ID'], + access_key_secret=sms_config['ACCESS_KEY_SECRET'], + endpoint='dysmsapi.aliyuncs.com', + ) + self.client = Client(config) + self.sign_name = sms_config['SIGN_NAME'] + self.template_code = sms_config['TEMPLATE_CODE'] + self._initialized = True + + def send_code(self, phone, code): + """ + 发送验证码短信 + :param phone: 手机号 + :param code: 验证码 + :return: (success: bool, error_msg: str) + """ + if not self.client: + logger.warning('SMS SDK 未配置,验证码: %s -> %s', phone, code) + return True, '' + + try: + request = SendSmsRequest( + phone_numbers=phone, + sign_name=self.sign_name, + template_code=self.template_code, + template_param=json.dumps({'code': code}), + ) + runtime = RuntimeOptions() + response = self.client.send_sms_with_options(request, runtime) + + if response.body.code == 'OK': + logger.info('短信发送成功: %s', phone) + return True, '' + else: + msg = response.body.message or response.body.code + logger.error('短信发送失败: %s, %s', phone, msg) + return False, msg + except Exception as e: + logger.error('短信发送异常: %s, %s', phone, str(e)) + return False, str(e) + + +def get_sms_client(): + """获取短信客户端单例""" + return SMSClient() + + +def generate_code(): + """生成随机验证码""" + length = settings.ALIYUN_SMS.get('CODE_LENGTH', 6) + return ''.join([str(random.randint(0, 9)) for _ in range(length)]) + + +def send_sms_code(phone): + """ + 发送短信验证码的完整流程 + :param phone: 手机号 + :return: (success: bool, error_msg: str) + """ + from apps.users.models import SmsCode + + sms_config = settings.ALIYUN_SMS + interval = sms_config.get('SEND_INTERVAL', 60) + expire_seconds = sms_config.get('CODE_EXPIRE', 300) + + # 检查发送频率 + recent = SmsCode.objects.filter( + phone=phone, + created_at__gte=timezone.now() - timedelta(seconds=interval), + ).exists() + if recent: + return False, '验证码发送过于频繁,请稍后再试' + + # 生成验证码 + code = generate_code() + + # 发送短信 + client = get_sms_client() + ok, err = client.send_code(phone, code) + if not ok: + return False, err or '短信发送失败' + + # 保存到数据库 + SmsCode.objects.create( + phone=phone, + code=code, + expire_at=timezone.now() + timedelta(seconds=expire_seconds), + ) + + return True, '' + + +def verify_sms_code(phone, code): + """ + 校验短信验证码 + :param phone: 手机号 + :param code: 验证码 + :return: (valid: bool, error_msg: str) + """ + from apps.users.models import SmsCode + + record = SmsCode.objects.filter( + phone=phone, + code=code, + is_used=False, + expire_at__gt=timezone.now(), + ).order_by('-created_at').first() + + if not record: + return False, '验证码无效或已过期' + + # 标记已使用 + record.is_used = True + record.save(update_fields=['is_used']) + + return True, ''