Fix app api
Some checks failed
Build and Deploy Backend / build-and-deploy (push) Failing after 1m36s

This commit is contained in:
repair-agent 2026-02-09 15:35:33 +08:00
parent 416be408fd
commit 88b8f023f4
58 changed files with 2371 additions and 15 deletions

129
CLAUDE.md Normal file
View 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` 命令查看。

View File

@ -5,6 +5,7 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from drf_spectacular.utils import extend_schema
from utils.response import success, error from utils.response import success, error
from utils.exceptions import ErrorCode from utils.exceptions import ErrorCode
@ -19,6 +20,7 @@ from .authentication import get_admin_tokens, AdminJWTAuthentication
from .permissions import IsAdminUser, IsSuperAdmin from .permissions import IsAdminUser, IsSuperAdmin
@extend_schema(tags=['管理员-认证'])
class AdminAuthViewSet(viewsets.ViewSet): class AdminAuthViewSet(viewsets.ViewSet):
"""管理员认证视图集""" """管理员认证视图集"""
@ -89,6 +91,7 @@ class AdminAuthViewSet(viewsets.ViewSet):
return error(code=ErrorCode.TOKEN_EXPIRED, message='Token已过期或无效') return error(code=ErrorCode.TOKEN_EXPIRED, message='Token已过期或无效')
@extend_schema(tags=['管理员-认证'])
class AdminProfileViewSet(viewsets.ViewSet): class AdminProfileViewSet(viewsets.ViewSet):
"""管理员个人信息视图集""" """管理员个人信息视图集"""
@ -123,6 +126,7 @@ class AdminProfileViewSet(viewsets.ViewSet):
return success(message='密码修改成功') return success(message='密码修改成功')
@extend_schema(tags=['管理员-账号管理'])
class AdminUserManageViewSet(viewsets.ModelViewSet): class AdminUserManageViewSet(viewsets.ModelViewSet):
"""管理员用户管理视图集(仅超级管理员可用)""" """管理员用户管理视图集(仅超级管理员可用)"""

View File

@ -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")},
},
),
]

View File

@ -87,6 +87,10 @@ class Device(models.Model):
mac_address = models.CharField('MAC地址', max_length=20, blank=True, null=True, unique=True) mac_address = models.CharField('MAC地址', max_length=20, blank=True, null=True, unique=True)
name = models.CharField('自定义名称', max_length=100, blank=True, default='') name = models.CharField('自定义名称', max_length=100, blank=True, default='')
status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='in_stock') 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='') firmware_version = models.CharField('固件版本', max_length=20, blank=True, default='')
last_online_at = models.DateTimeField('最后在线时间', null=True, blank=True) last_online_at = models.DateTimeField('最后在线时间', null=True, blank=True)
created_at = models.DateTimeField('创建时间', auto_now_add=True) created_at = models.DateTimeField('创建时间', auto_now_add=True)
@ -129,3 +133,48 @@ class UserDevice(models.Model):
def __str__(self): def __str__(self):
return f"{self.user.phone} - {self.device.sn}" 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}"

View File

@ -2,7 +2,7 @@
设备模块序列化器 设备模块序列化器
""" """
from rest_framework import serializers 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): class DeviceTypeSerializer(serializers.ModelSerializer):
@ -86,3 +86,53 @@ class QueryByMacSerializer(serializers.Serializer):
# 统一MAC地址格式 # 统一MAC地址格式
value = value.upper().replace('-', ':') value = value.upper().replace('-', ':')
return value 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)

View File

@ -4,20 +4,24 @@
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.permissions import IsAuthenticated, AllowAny
from drf_spectacular.utils import extend_schema
from utils.response import success, error from utils.response import success, error
from utils.exceptions import ErrorCode from utils.exceptions import ErrorCode
from apps.admins.authentication import AppJWTAuthentication from apps.admins.authentication import AppJWTAuthentication
from .models import Device, UserDevice, DeviceType from .models import Device, UserDevice, DeviceType, DeviceSettings, DeviceWifi
from .serializers import ( from .serializers import (
DeviceSerializer, DeviceSerializer,
UserDeviceSerializer, UserDeviceSerializer,
BindDeviceSerializer, BindDeviceSerializer,
DeviceVerifySerializer, DeviceVerifySerializer,
DeviceTypeSerializer DeviceTypeSerializer,
DeviceDetailSerializer,
DeviceSettingsUpdateSerializer,
) )
@extend_schema(tags=['设备'])
class DeviceViewSet(viewsets.ViewSet): class DeviceViewSet(viewsets.ViewSet):
"""设备视图集App端""" """设备视图集App端"""
@ -177,3 +181,82 @@ class DeviceViewSet(viewsets.ViewSet):
user_device.save() user_device.save()
return success(data=UserDeviceSerializer(user_device).data, message='更新成功') 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 配置成功')

View File

@ -6,6 +6,7 @@ from django.http import HttpResponse
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser from rest_framework.parsers import MultiPartParser
from drf_spectacular.utils import extend_schema
from utils.response import success, error from utils.response import success, error
from utils.exceptions import ErrorCode 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 from .services import generate_batch_devices, export_batch_excel, import_mac_addresses
@extend_schema(tags=['管理员-库存'])
class DeviceTypeViewSet(viewsets.ModelViewSet): class DeviceTypeViewSet(viewsets.ModelViewSet):
"""设备类型管理视图集 - 管理端""" """设备类型管理视图集 - 管理端"""
@ -89,6 +91,7 @@ class DeviceTypeViewSet(viewsets.ModelViewSet):
return error(message=str(serializer.errors)) return error(message=str(serializer.errors))
@extend_schema(tags=['管理员-库存'])
class DeviceBatchViewSet(viewsets.ModelViewSet): class DeviceBatchViewSet(viewsets.ModelViewSet):
"""设备批次管理视图集 - 管理端""" """设备批次管理视图集 - 管理端"""

0
apps/music/__init__.py Normal file
View File

3
apps/music/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

7
apps/music/apps.py Normal file
View 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 = "音乐"

View 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"
),
),
]

View File

47
apps/music/models.py Normal file
View 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
View 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
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

13
apps/music/urls.py Normal file
View 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
View 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',
})

View File

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View 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 = "通知"

View 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"),
),
]

View 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

View 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']

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View 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)),
]

View 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}条通知标记为已读')

View File

@ -2,11 +2,14 @@
智能体模块视图 - App端 智能体模块视图 - App端
""" """
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from drf_spectacular.utils import extend_schema
from utils.response import success, error from utils.response import success, error
from utils.exceptions import ErrorCode from utils.exceptions import ErrorCode
from apps.admins.authentication import AppJWTAuthentication from apps.admins.authentication import AppJWTAuthentication
from apps.devices.models import UserDevice
from .models import Spirit from .models import Spirit
from .serializers import ( from .serializers import (
SpiritSerializer, SpiritSerializer,
@ -15,6 +18,7 @@ from .serializers import (
) )
@extend_schema(tags=['智能体'])
class SpiritViewSet(viewsets.ModelViewSet): class SpiritViewSet(viewsets.ModelViewSet):
"""智能体视图集App端""" """智能体视图集App端"""
@ -83,3 +87,48 @@ class SpiritViewSet(viewsets.ModelViewSet):
instance = self.get_object() instance = self.get_object()
instance.delete() instance.delete()
return success(message='删除成功') 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
View File

3
apps/stories/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

7
apps/stories/apps.py Normal file
View 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 = "故事"

View 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"
),
),
]

View File

70
apps/stories/models.py Normal file
View 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

View 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
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

14
apps/stories/urls.py Normal file
View 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
View 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
View File

3
apps/system/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

7
apps/system/apps.py Normal file
View 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 = "系统"

View 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"],
},
),
]

View File

56
apps/system/models.py Normal file
View 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}"

View 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
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

14
apps/system/urls.py Normal file
View 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
View 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&current_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,
})

View File

@ -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"
),
),
]

View File

@ -30,8 +30,17 @@ class User(AbstractBaseUser, PermissionsMixin):
phone = models.CharField('手机号', max_length=20, unique=True) phone = models.CharField('手机号', max_length=20, unique=True)
nickname = models.CharField('昵称', max_length=50, blank=True, default='') nickname = models.CharField('昵称', max_length=50, blank=True, default='')
avatar = models.URLField('头像', max_length=500, 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_active = models.BooleanField('是否激活', default=True)
is_staff = models.BooleanField('是否管理员', default=False) 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) created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True) updated_at = models.DateTimeField('更新时间', auto_now=True)
@ -47,3 +56,24 @@ class User(AbstractBaseUser, PermissionsMixin):
def __str__(self): def __str__(self):
return self.phone 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}"

View File

@ -10,7 +10,7 @@ class UserSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = User 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'] read_only_fields = ['id', 'phone', 'created_at']
@ -41,4 +41,21 @@ class UpdateUserSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = User 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)

View File

@ -6,8 +6,13 @@ from rest_framework.decorators import action
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from django.db.models import Count 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.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.authentication import AppJWTAuthentication, AdminJWTAuthentication
from apps.admins.permissions import IsAdminUser from apps.admins.permissions import IsAdminUser
from .models import User from .models import User
@ -15,7 +20,9 @@ from .serializers import (
UserSerializer, UserSerializer,
UserDetailSerializer, UserDetailSerializer,
PhoneLoginSerializer, PhoneLoginSerializer,
UpdateUserSerializer UpdateUserSerializer,
SendCodeSerializer,
CodeLoginSerializer,
) )
@ -35,6 +42,7 @@ def get_app_tokens(user):
} }
@extend_schema(tags=['认证'])
class AuthViewSet(viewsets.ViewSet): class AuthViewSet(viewsets.ViewSet):
"""认证视图集 - App端""" """认证视图集 - App端"""
@ -70,6 +78,92 @@ class AuthViewSet(viewsets.ViewSet):
'is_new_user': created 'is_new_user': created
}, message='登录成功') }, 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') @action(detail=False, methods=['post'], url_path='refresh')
def refresh_token(self, request): def refresh_token(self, request):
""" """
@ -95,6 +189,7 @@ class AuthViewSet(viewsets.ViewSet):
return error(code=103, message='Token已过期或无效') return error(code=103, message='Token已过期或无效')
@extend_schema(tags=['用户'])
class UserViewSet(viewsets.ViewSet): class UserViewSet(viewsets.ViewSet):
"""用户视图集 - App端""" """用户视图集 - App端"""
@ -121,7 +216,38 @@ class UserViewSet(viewsets.ViewSet):
return success(data=UserSerializer(request.user).data, message='更新成功') return success(data=UserSerializer(request.user).data, message='更新成功')
return error(message=str(serializer.errors)) 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): class AdminUserManageViewSet(viewsets.ViewSet):
"""App用户管理视图集 - 管理端""" """App用户管理视图集 - 管理端"""

View File

@ -35,6 +35,10 @@ INSTALLED_APPS = [
'apps.devices', 'apps.devices',
'apps.inventory', 'apps.inventory',
'apps.admins', 'apps.admins',
'apps.stories',
'apps.music',
'apps.notifications',
'apps.system',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -152,25 +156,55 @@ SIMPLE_JWT = {
'AUTH_HEADER_TYPES': ('Bearer',), '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 Settings
ALIYUN_OSS = { ALIYUN_OSS = {
'ACCESS_KEY_ID': os.environ.get('OSS_ACCESS_KEY_ID', 'LTAI5tBGAkR2rra2prTAX9yc'), 'ACCESS_KEY_ID': os.environ.get('OSS_ACCESS_KEY_ID', ALIYUN_ACCESS_KEY_ID),
'ACCESS_KEY_SECRET': os.environ.get('OSS_ACCESS_KEY_SECRET', 'U1z3d0p5saPRD5sCxVooJYSjxSAmKB'), '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'), 'ENDPOINT': os.environ.get('OSS_ENDPOINT', 'oss-cn-beijing.aliyuncs.com'),
'BUCKET_NAME': os.environ.get('OSS_BUCKET_NAME', 'qy-rtc'), 'BUCKET_NAME': os.environ.get('OSS_BUCKET_NAME', 'qy-rtc'),
'CUSTOM_DOMAIN': os.environ.get('OSS_CUSTOM_DOMAIN', ''), '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 # Swagger/OpenAPI Settings
SPECTACULAR_SETTINGS = { SPECTACULAR_SETTINGS = {
'TITLE': 'RTC_DEMO API', 'TITLE': 'RTC API',
'DESCRIPTION': 'RTC物联网设备管理平台API文档', 'DESCRIPTION': 'RTC 物联网设备管理平台',
'VERSION': '1.0.0', 'VERSION': '1.0.0',
'SERVE_INCLUDE_SCHEMA': False, 'SERVE_INCLUDE_SCHEMA': False,
'COMPONENT_SPLIT_REQUEST': True, 'COMPONENT_SPLIT_REQUEST': True,
'SWAGGER_UI_SETTINGS': { 'SWAGGER_UI_SETTINGS': {
'persistAuthorization': True, '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 # Logging

View File

@ -14,6 +14,10 @@ app_api_patterns = [
path('', include('apps.users.urls')), path('', include('apps.users.urls')),
path('', include('apps.spirits.urls')), path('', include('apps.spirits.urls')),
path('', include('apps.devices.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管理端路由 (管理员,用户名密码登录) ============ # ============ Web管理端路由 (管理员,用户名密码登录) ============

View File

@ -27,3 +27,4 @@ six==1.17.0
sqlparse==0.5.5 sqlparse==0.5.5
urllib3==2.6.3 urllib3==2.6.3
drf-spectacular==0.27.1 drf-spectacular==0.27.1
alibabacloud_dysmsapi20170525>=4.4.0

94
test_sms.py Normal file
View 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('测试完成')

View File

@ -90,6 +90,9 @@ class ErrorCode:
USER_DISABLED = 101 USER_DISABLED = 101
PHONE_INVALID = 102 PHONE_INVALID = 102
TOKEN_EXPIRED = 103 TOKEN_EXPIRED = 103
SMS_CODE_INVALID = 110
SMS_CODE_EXPIRED = 111
SMS_CODE_SEND_LIMIT = 112
# 设备模块 200-299 # 设备模块 200-299
DEVICE_NOT_FOUND = 200 DEVICE_NOT_FOUND = 200
@ -106,6 +109,26 @@ class ErrorCode:
BATCH_SN_EXISTS = 401 BATCH_SN_EXISTS = 401
DEVICE_TYPE_NOT_FOUND = 402 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): def custom_exception_handler(exc, context):
"""自定义异常处理器""" """自定义异常处理器"""

165
utils/sms.py Normal file
View 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, ''