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.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):
|
||||||
"""管理员用户管理视图集(仅超级管理员可用)"""
|
"""管理员用户管理视图集(仅超级管理员可用)"""
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
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}"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 配置成功')
|
||||||
|
|||||||
@ -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
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端
|
智能体模块视图 - 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
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)
|
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}"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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用户管理视图集 - 管理端"""
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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管理端路由 (管理员,用户名密码登录) ============
|
||||||
|
|||||||
@ -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
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
|
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
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