Fix app api
Some checks failed
Build and Deploy Backend / build-and-deploy (push) Failing after 1m36s
Some checks failed
Build and Deploy Backend / build-and-deploy (push) Failing after 1m36s
This commit is contained in:
parent
416be408fd
commit
88b8f023f4
129
CLAUDE.md
Normal file
129
CLAUDE.md
Normal file
@ -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` 命令查看。
|
||||
@ -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):
|
||||
"""管理员用户管理视图集(仅超级管理员可用)"""
|
||||
|
||||
|
||||
@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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}"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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端)"""
|
||||
|
||||
@ -177,3 +181,82 @@ class DeviceViewSet(viewsets.ViewSet):
|
||||
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 配置成功')
|
||||
|
||||
@ -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):
|
||||
"""设备批次管理视图集 - 管理端"""
|
||||
|
||||
|
||||
0
apps/music/__init__.py
Normal file
0
apps/music/__init__.py
Normal file
3
apps/music/admin.py
Normal file
3
apps/music/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
7
apps/music/apps.py
Normal file
7
apps/music/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MusicConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.music"
|
||||
verbose_name = "音乐"
|
||||
102
apps/music/migrations/0001_initial.py
Normal file
102
apps/music/migrations/0001_initial.py
Normal file
@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
0
apps/music/migrations/__init__.py
Normal file
0
apps/music/migrations/__init__.py
Normal file
47
apps/music/models.py
Normal file
47
apps/music/models.py
Normal file
@ -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
|
||||
24
apps/music/serializers.py
Normal file
24
apps/music/serializers.py
Normal file
@ -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'
|
||||
)
|
||||
3
apps/music/tests.py
Normal file
3
apps/music/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
13
apps/music/urls.py
Normal file
13
apps/music/urls.py
Normal file
@ -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)),
|
||||
]
|
||||
86
apps/music/views.py
Normal file
86
apps/music/views.py
Normal file
@ -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',
|
||||
})
|
||||
0
apps/notifications/__init__.py
Normal file
0
apps/notifications/__init__.py
Normal file
3
apps/notifications/admin.py
Normal file
3
apps/notifications/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
7
apps/notifications/apps.py
Normal file
7
apps/notifications/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.notifications"
|
||||
verbose_name = "通知"
|
||||
98
apps/notifications/migrations/0001_initial.py
Normal file
98
apps/notifications/migrations/0001_initial.py
Normal file
@ -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"),
|
||||
),
|
||||
]
|
||||
0
apps/notifications/migrations/__init__.py
Normal file
0
apps/notifications/migrations/__init__.py
Normal file
44
apps/notifications/models.py
Normal file
44
apps/notifications/models.py
Normal file
@ -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
|
||||
14
apps/notifications/serializers.py
Normal file
14
apps/notifications/serializers.py
Normal file
@ -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']
|
||||
3
apps/notifications/tests.py
Normal file
3
apps/notifications/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
13
apps/notifications/urls.py
Normal file
13
apps/notifications/urls.py
Normal file
@ -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)),
|
||||
]
|
||||
87
apps/notifications/views.py
Normal file
87
apps/notifications/views.py
Normal file
@ -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}条通知标记为已读')
|
||||
@ -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='智能体注入成功')
|
||||
|
||||
0
apps/stories/__init__.py
Normal file
0
apps/stories/__init__.py
Normal file
3
apps/stories/admin.py
Normal file
3
apps/stories/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
7
apps/stories/apps.py
Normal file
7
apps/stories/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class StoriesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.stories"
|
||||
verbose_name = "故事"
|
||||
149
apps/stories/migrations/0001_initial.py
Normal file
149
apps/stories/migrations/0001_initial.py
Normal file
@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
0
apps/stories/migrations/__init__.py
Normal file
0
apps/stories/migrations/__init__.py
Normal file
70
apps/stories/models.py
Normal file
70
apps/stories/models.py
Normal file
@ -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
|
||||
50
apps/stories/serializers.py
Normal file
50
apps/stories/serializers.py
Normal file
@ -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)
|
||||
3
apps/stories/tests.py
Normal file
3
apps/stories/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
14
apps/stories/urls.py
Normal file
14
apps/stories/urls.py
Normal file
@ -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)),
|
||||
]
|
||||
142
apps/stories/views.py
Normal file
142
apps/stories/views.py
Normal file
@ -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='删除成功')
|
||||
0
apps/system/__init__.py
Normal file
0
apps/system/__init__.py
Normal file
3
apps/system/admin.py
Normal file
3
apps/system/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
7
apps/system/apps.py
Normal file
7
apps/system/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SystemConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.system"
|
||||
verbose_name = "系统"
|
||||
112
apps/system/migrations/0001_initial.py
Normal file
112
apps/system/migrations/0001_initial.py
Normal file
@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/system/migrations/__init__.py
Normal file
0
apps/system/migrations/__init__.py
Normal file
56
apps/system/models.py
Normal file
56
apps/system/models.py
Normal file
@ -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}"
|
||||
23
apps/system/serializers.py
Normal file
23
apps/system/serializers.py
Normal file
@ -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']
|
||||
3
apps/system/tests.py
Normal file
3
apps/system/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
14
apps/system/urls.py
Normal file
14
apps/system/urls.py
Normal file
@ -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)),
|
||||
]
|
||||
62
apps/system/views.py
Normal file
62
apps/system/views.py
Normal file
@ -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,
|
||||
})
|
||||
@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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}"
|
||||
|
||||
@ -10,7 +10,7 @@ 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']
|
||||
|
||||
|
||||
@ -41,4 +41,21 @@ 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)
|
||||
|
||||
@ -6,8 +6,13 @@ 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
|
||||
@ -15,7 +20,9 @@ from .serializers import (
|
||||
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用户管理视图集 - 管理端"""
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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管理端路由 (管理员,用户名密码登录) ============
|
||||
|
||||
@ -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
|
||||
|
||||
94
test_sms.py
Normal file
94
test_sms.py
Normal file
@ -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('测试完成')
|
||||
@ -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):
|
||||
"""自定义异常处理器"""
|
||||
|
||||
165
utils/sms.py
Normal file
165
utils/sms.py
Normal file
@ -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, ''
|
||||
Loading…
x
Reference in New Issue
Block a user