forked from erp-dev/erp
feat: big version, added tasks for backup_database and stock change, added health check api, approve sse (support channel via merchant)
This commit is contained in:
42
api_v1/views/healthy.py
Normal file
42
api_v1/views/healthy.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import platform
|
||||
from typing import Dict
|
||||
|
||||
from django.db import connections, DatabaseError
|
||||
from django.utils import timezone
|
||||
from django.utils.version import get_version
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
|
||||
class HealthCheckView(APIView):
|
||||
"""
|
||||
简单健康检查接口,返回当前服务的基础指标信息,包括:
|
||||
- 应用版本
|
||||
- Python/Django 版本
|
||||
- 服务器时间
|
||||
- 数据库连接状态
|
||||
"""
|
||||
|
||||
authentication_classes: list = []
|
||||
permission_classes: list = []
|
||||
|
||||
def get(self, request):
|
||||
db_status: Dict[str, str] = {}
|
||||
for alias in connections:
|
||||
try:
|
||||
connections[alias].cursor()
|
||||
db_status[alias] = 'ok'
|
||||
except DatabaseError as exc:
|
||||
db_status[alias] = f'error: {exc.__class__.__name__}'
|
||||
|
||||
payload = {
|
||||
'service': 'flower-api',
|
||||
'status': 'ok' if all(status == 'ok' for status in db_status.values()) else 'degraded',
|
||||
'server_time': timezone.now().isoformat(),
|
||||
'python_version': platform.python_version(),
|
||||
'django_version': get_version(),
|
||||
'platform': platform.platform(),
|
||||
'databases': db_status,
|
||||
}
|
||||
return Response(payload)
|
||||
|
||||
72
api_v1/views/purchase_order.py
Normal file
72
api_v1/views/purchase_order.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from rest_framework import status, views
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from basic_info import models as basic_models
|
||||
from business import services as business_services
|
||||
from .stock_change_views.mixins import StockChangeViewMixin
|
||||
|
||||
|
||||
class PurchaseOrderView(StockChangeViewMixin, views.APIView):
|
||||
"""创建采购订单并触发入库任务"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
if not self.check_employee_permission(request):
|
||||
return self.permission_error_response('无权限访问')
|
||||
|
||||
merchant = request.user.employee.merchant
|
||||
data = request.data or {}
|
||||
|
||||
supplier_id = data.get('supplier')
|
||||
warehouse_id = data.get('warehouse')
|
||||
order_date = data.get('order_date')
|
||||
total_amount = data.get('total_amount')
|
||||
items = data.get('items', [])
|
||||
remarks = data.get('remarks', '')
|
||||
|
||||
if not supplier_id:
|
||||
return Response({'error': '缺少供应商 ID'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not warehouse_id:
|
||||
return Response({'error': '缺少仓库 ID'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
supplier = basic_models.Supplier.objects.get(id=supplier_id, merchant=merchant)
|
||||
except basic_models.Supplier.DoesNotExist:
|
||||
return Response({'error': f'供应商 {supplier_id} 不存在'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
basic_models.WareHouse.objects.get(id=warehouse_id, merchant=merchant)
|
||||
except basic_models.WareHouse.DoesNotExist:
|
||||
return Response({'error': f'仓库 {warehouse_id} 不存在'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
total_amount_decimal = Decimal(str(total_amount))
|
||||
except (InvalidOperation, TypeError):
|
||||
return Response({'error': 'total_amount 必须为合法数值'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
purchase_order = business_services.create_purchase_order(
|
||||
merchant=merchant,
|
||||
supplier=supplier,
|
||||
order_date=order_date,
|
||||
total_amount=total_amount_decimal,
|
||||
warehouse_id=warehouse_id,
|
||||
items=items,
|
||||
remarks=remarks,
|
||||
created_by=request.user,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'id': purchase_order.id,
|
||||
'message': '采购单创建成功,入库任务已排队',
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@@ -67,6 +67,7 @@ class StockChangeViewMixin:
|
||||
"""构建明细数据"""
|
||||
return {
|
||||
'id': detail.id,
|
||||
'stock_change_record': detail.stock_change_record_id,
|
||||
'product': detail.product_id,
|
||||
'product_name': detail.product.name,
|
||||
'quantity': float(detail.quantity),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
@@ -12,6 +13,279 @@ from stock import models as stock_models, services as stock_services
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CreateStockChangeAPITestCase(TestCase):
|
||||
"""测试标准库存变动创建 API"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name='测试商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
self.category = basic_models.ProductCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='布料',
|
||||
product_prefix='FAB',
|
||||
)
|
||||
self.product = basic_models.Product.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=self.category,
|
||||
name='测试布料',
|
||||
human_id='FAB-001',
|
||||
unit=basic_models.ProductUnitEnum.METER,
|
||||
)
|
||||
self.warehouse = basic_models.WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='严谨仓库',
|
||||
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
|
||||
)
|
||||
self.other_merchant = basic_models.Merchant.objects.create(
|
||||
name='其他商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
other_category = basic_models.ProductCategory.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
name='其他布料',
|
||||
product_prefix='FABO',
|
||||
)
|
||||
self.other_product = basic_models.Product.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
category=other_category,
|
||||
name='外部布料',
|
||||
human_id='FAB-999',
|
||||
unit=basic_models.ProductUnitEnum.METER,
|
||||
)
|
||||
|
||||
self.user = User.objects.create_user(username='strict_user', password='pass123')
|
||||
self.employee = basic_models.Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
sys_user=self.user,
|
||||
name='仓管员',
|
||||
mobile='13800138002',
|
||||
status=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_create_stock_change_success(self):
|
||||
payload = {
|
||||
'type': stock_models.StockChangeTypeEnum.ADD,
|
||||
'warehouse': self.warehouse.id,
|
||||
'source_type': stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
'source_id': 10,
|
||||
'products': [
|
||||
{'product': self.product.id, 'quantity': ['10.50', '5.25']},
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stock-change/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['created_details_count'], 2)
|
||||
self.assertEqual(len(response.data['details']), 2)
|
||||
self.assertEqual(response.data['stock_change_record']['warehouse'], self.warehouse.id)
|
||||
|
||||
record_id = response.data['stock_change_record']['id']
|
||||
record = stock_models.StockChangeRecord.objects.get(id=record_id)
|
||||
self.assertEqual(record.details.count(), 2)
|
||||
|
||||
def test_create_stock_change_products_required(self):
|
||||
payload = {
|
||||
'type': stock_models.StockChangeTypeEnum.ADD,
|
||||
'warehouse': self.warehouse.id,
|
||||
'source_type': stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
'source_id': 11,
|
||||
'products': [],
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stock-change/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.data['error'], '产品列表不能为空')
|
||||
|
||||
def test_create_stock_change_product_not_visible(self):
|
||||
payload = {
|
||||
'type': stock_models.StockChangeTypeEnum.ADD,
|
||||
'warehouse': self.warehouse.id,
|
||||
'source_type': stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
'products': [
|
||||
{'product': self.other_product.id, 'quantity': ['5.00']},
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stock-change/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(
|
||||
response.data['error'],
|
||||
f'产品ID {self.other_product.id} 对当前用户不可见',
|
||||
)
|
||||
|
||||
def test_create_stock_change_no_employee_permission_denied(self):
|
||||
class DummyUser:
|
||||
def __init__(self, username):
|
||||
self.username = username
|
||||
self.is_authenticated = True
|
||||
|
||||
dummy_user = DummyUser('no_emp_user')
|
||||
self.client.force_authenticate(user=dummy_user)
|
||||
payload = {
|
||||
'type': stock_models.StockChangeTypeEnum.ADD,
|
||||
'warehouse': self.warehouse.id,
|
||||
'source_type': stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
'products': [
|
||||
{'product': self.product.id, 'quantity': ['5.00']},
|
||||
]
|
||||
}
|
||||
|
||||
logging.disable(logging.NOTSET)
|
||||
self.addCleanup(logging.disable, logging.CRITICAL)
|
||||
with self.assertLogs('api_v1.views.stock_change_views.mixins', level='ERROR') as cm:
|
||||
response = self.client.post('/api/v1/stock-change/', payload, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(response.data['error'], '无权限访问')
|
||||
self.assertTrue(any('无员工信息' in msg for msg in cm.output))
|
||||
|
||||
|
||||
class CreateStockChangeRelaxedAPITestCase(TestCase):
|
||||
"""测试宽松模式库存变动创建 API"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name='宽松商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
self.category = basic_models.ProductCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='面料',
|
||||
product_prefix='FAB',
|
||||
)
|
||||
self.product = basic_models.Product.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=self.category,
|
||||
name='宽松布料',
|
||||
human_id='FAB-100',
|
||||
unit=basic_models.ProductUnitEnum.METER,
|
||||
)
|
||||
self.relaxed_warehouse = basic_models.WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='宽进宽出仓',
|
||||
mode=basic_models.WareHouseModeEnum.UNRESTRICTED,
|
||||
)
|
||||
self.other_merchant = basic_models.Merchant.objects.create(
|
||||
name='RelaxedOther',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
other_category = basic_models.ProductCategory.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
name='外部面料',
|
||||
product_prefix='REL',
|
||||
)
|
||||
self.other_product = basic_models.Product.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
category=other_category,
|
||||
name='外部布料',
|
||||
human_id='REL-002',
|
||||
unit=basic_models.ProductUnitEnum.METER,
|
||||
)
|
||||
|
||||
self.user = User.objects.create_user(username='relaxed_user', password='pass123')
|
||||
self.employee = basic_models.Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
sys_user=self.user,
|
||||
name='宽松仓管员',
|
||||
mobile='13800138003',
|
||||
status=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_create_stock_change_relaxed_success(self):
|
||||
payload = {
|
||||
'type': stock_models.StockChangeTypeEnum.ADD,
|
||||
'warehouse': self.relaxed_warehouse.id,
|
||||
'source_type': stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
'source_id': 20,
|
||||
'products': [
|
||||
{
|
||||
'product': self.product.id,
|
||||
'quantity': {'value': '12', 'unit_count': '5'}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stock-change/relaxed/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['created_details_count'], 3)
|
||||
record_id = response.data['stock_change_record']['id']
|
||||
details = stock_models.StockChangeDetail.objects.filter(stock_change_record_id=record_id)
|
||||
self.assertEqual(details.count(), 3)
|
||||
|
||||
def test_create_stock_change_relaxed_missing_required(self):
|
||||
payload = {
|
||||
'warehouse': self.relaxed_warehouse.id,
|
||||
'source_type': stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
'products': [
|
||||
{
|
||||
'product': self.product.id,
|
||||
'quantity': {'value': '10', 'unit_count': '4'}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stock-change/relaxed/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.data['error'], '缺少必要参数')
|
||||
|
||||
def test_create_stock_change_relaxed_product_not_visible(self):
|
||||
payload = {
|
||||
'type': stock_models.StockChangeTypeEnum.ADD,
|
||||
'warehouse': self.relaxed_warehouse.id,
|
||||
'source_type': stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
'products': [
|
||||
{
|
||||
'product': self.other_product.id,
|
||||
'quantity': {'value': '8', 'unit_count': '4'}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stock-change/relaxed/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(
|
||||
response.data['error'],
|
||||
f'产品ID {self.other_product.id} 对当前用户不可见',
|
||||
)
|
||||
|
||||
def test_create_stock_change_relaxed_no_employee_permission_denied(self):
|
||||
class DummyUser:
|
||||
def __init__(self, username):
|
||||
self.username = username
|
||||
self.is_authenticated = True
|
||||
|
||||
dummy_user = DummyUser('relaxed_no_emp')
|
||||
self.client.force_authenticate(user=dummy_user)
|
||||
payload = {
|
||||
'type': stock_models.StockChangeTypeEnum.ADD,
|
||||
'warehouse': self.relaxed_warehouse.id,
|
||||
'source_type': stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
'products': [
|
||||
{
|
||||
'product': self.product.id,
|
||||
'quantity': {'value': '10', 'unit_count': '5'}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
logging.disable(logging.NOTSET)
|
||||
self.addCleanup(logging.disable, logging.CRITICAL)
|
||||
with self.assertLogs('api_v1.views.stock_change_views.mixins', level='ERROR') as cm:
|
||||
response = self.client.post('/api/v1/stock-change/relaxed/', payload, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(response.data['error'], '无权限访问')
|
||||
self.assertTrue(any('无员工信息' in msg for msg in cm.output))
|
||||
|
||||
|
||||
class StockChangeRestrictAPITestCase(TestCase):
|
||||
"""测试严进严出模式的 API"""
|
||||
|
||||
@@ -132,7 +406,7 @@ class ListStockChangeDetailsAPITestCase(TestCase):
|
||||
self.warehouse = basic_models.WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试仓库',
|
||||
mode=basic_models.WareHouseModeEnum.UNRESTRICTED,
|
||||
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
|
||||
)
|
||||
|
||||
# 创建另一个产品和仓库用于测试过滤
|
||||
@@ -146,7 +420,7 @@ class ListStockChangeDetailsAPITestCase(TestCase):
|
||||
self.warehouse2 = basic_models.WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试仓库2',
|
||||
mode=basic_models.WareHouseModeEnum.UNRESTRICTED,
|
||||
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
|
||||
)
|
||||
|
||||
self.user = User.objects.create_user(username='detail_user', password='pass123')
|
||||
@@ -209,9 +483,9 @@ class ListStockChangeDetailsAPITestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
# 应该返回仓库1中产品1的所有明细(record1和record2的明细)
|
||||
self.assertEqual(response.data['count'], 3) # record1有2条明细,record2有1条明细
|
||||
self.assertEqual(len(response.data['results']), 3)
|
||||
# 默认 direction=inbound,因此仅包含仓库1中产品1的入库明细(record1 的 2 条)
|
||||
self.assertEqual(response.data['count'], 2)
|
||||
self.assertEqual(len(response.data['results']), 2)
|
||||
|
||||
# 检查返回数据结构
|
||||
result = response.data['results'][0]
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
# 文件上传接口文档
|
||||
|
||||
## 概述
|
||||
|
||||
通用文件上传接口,用于上传无法归类到具体业务的文件。
|
||||
|
||||
**基础路径**: `/api/v1/upload/`
|
||||
|
||||
**认证要求**: 所有接口都需要 JWT Token 认证
|
||||
|
||||
**内容格式**: `multipart/form-data` (上传时) / `application/json` (响应)
|
||||
|
||||
**注意事项**:
|
||||
- 不支持列表查询(list)
|
||||
- 不支持修改操作(PUT/PATCH)
|
||||
- 仅支持单个文件查询、上传、删除操作
|
||||
|
||||
---
|
||||
|
||||
## 数据模型
|
||||
|
||||
### UploadedFile
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | integer | 文件ID |
|
||||
| path | string | 文件存储路径(随机文件名) |
|
||||
| file_url | string | 文件访问URL |
|
||||
| owner | integer | 上传者用户ID |
|
||||
| owner_username | string | 上传者用户名 |
|
||||
| is_deleted | boolean | 是否已删除(软删除标记) |
|
||||
| original_filename | string | 原始文件名 |
|
||||
| file_size | integer | 文件大小(字节) |
|
||||
| content_type | string | MIME类型(如 image/jpeg) |
|
||||
| created_at | datetime | 创建时间 |
|
||||
| updated_at | datetime | 更新时间 |
|
||||
|
||||
---
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. 上传文件
|
||||
|
||||
**请求**
|
||||
```
|
||||
POST /api/v1/upload/
|
||||
Content-Type: multipart/form-data
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**请求参数**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| file | file | 是 | 要上传的文件(最大100MB) |
|
||||
|
||||
**请求示例**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/upload/ \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-F "file=@/path/to/your/file.pdf"
|
||||
```
|
||||
|
||||
**成功响应** (201 Created)
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"path": "uploads/2025/11/17/a1b2c3d4e5f6...hex.pdf",
|
||||
"file_url": "/media/uploads/2025/11/17/a1b2c3d4e5f6...hex.pdf",
|
||||
"owner": 1,
|
||||
"owner_username": "admin",
|
||||
"is_deleted": false,
|
||||
"original_filename": "document.pdf",
|
||||
"file_size": 1048576,
|
||||
"content_type": "application/pdf",
|
||||
"created_at": "2025-11-17T10:30:00Z",
|
||||
"updated_at": "2025-11-17T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应** (400 Bad Request)
|
||||
```json
|
||||
{
|
||||
"file": ["未上传文件"]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"file": ["文件大小不能超过100MB"]
|
||||
}
|
||||
```
|
||||
|
||||
**安全特性**:
|
||||
- 文件名使用 UUID 随机化,防止文件名冲突和路径遍历攻击
|
||||
- 原始文件名保存在数据库中,不影响存储安全
|
||||
- 自动记录上传者信息
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取文件信息
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/v1/upload/{id}/
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**路径参数**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | integer | 是 | 文件ID |
|
||||
|
||||
**请求示例**
|
||||
```bash
|
||||
curl -X GET http://localhost:8000/api/v1/upload/1/ \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
**成功响应** (200 OK)
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"path": "uploads/2025/11/17/a1b2c3d4e5f6...hex.pdf",
|
||||
"file_url": "/media/uploads/2025/11/17/a1b2c3d4e5f6...hex.pdf",
|
||||
"owner": 1,
|
||||
"owner_username": "admin",
|
||||
"is_deleted": false,
|
||||
"original_filename": "document.pdf",
|
||||
"file_size": 1048576,
|
||||
"content_type": "application/pdf",
|
||||
"created_at": "2025-11-17T10:30:00Z",
|
||||
"updated_at": "2025-11-17T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应** (404 Not Found)
|
||||
```json
|
||||
{
|
||||
"detail": "未找到"
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: 已软删除的文件无法通过此接口查询
|
||||
|
||||
---
|
||||
|
||||
### 3. 软删除文件
|
||||
|
||||
**请求**
|
||||
```
|
||||
DELETE /api/v1/upload/{id}/
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**路径参数**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | integer | 是 | 文件ID |
|
||||
|
||||
**请求示例**
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8000/api/v1/upload/1/ \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
**成功响应** (200 OK)
|
||||
```json
|
||||
{
|
||||
"detail": "文件已标记为删除"
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 软删除不会物理删除文件,只是标记为已删除
|
||||
- 软删除后的文件无法通过常规接口查询
|
||||
- 可以通过恢复接口恢复文件
|
||||
|
||||
---
|
||||
|
||||
### 4. 恢复已删除文件
|
||||
|
||||
**请求**
|
||||
```
|
||||
POST /api/v1/upload/{id}/restore/
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**路径参数**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | integer | 是 | 文件ID |
|
||||
|
||||
**请求示例**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/upload/1/restore/ \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
**成功响应** (200 OK)
|
||||
```json
|
||||
{
|
||||
"detail": "文件已恢复",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"path": "uploads/2025/11/17/a1b2c3d4e5f6...hex.pdf",
|
||||
"file_url": "/media/uploads/2025/11/17/a1b2c3d4e5f6...hex.pdf",
|
||||
"owner": 1,
|
||||
"owner_username": "admin",
|
||||
"is_deleted": false,
|
||||
"original_filename": "document.pdf",
|
||||
"file_size": 1048576,
|
||||
"content_type": "application/pdf",
|
||||
"created_at": "2025-11-17T10:30:00Z",
|
||||
"updated_at": "2025-11-17T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应** (400 Bad Request)
|
||||
```json
|
||||
{
|
||||
"detail": "文件未被删除,无需恢复"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 永久删除文件
|
||||
|
||||
**请求**
|
||||
```
|
||||
DELETE /api/v1/upload/{id}/permanent_delete/
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**路径参数**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | integer | 是 | 文件ID |
|
||||
|
||||
**请求示例**
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8000/api/v1/upload/1/permanent_delete/ \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
**成功响应** (204 No Content)
|
||||
```json
|
||||
{
|
||||
"detail": "文件已永久删除"
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 永久删除会物理删除文件和数据库记录
|
||||
- 此操作不可恢复,请谨慎使用
|
||||
- 建议仅在确认不需要时使用
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### Python (requests)
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# 配置
|
||||
BASE_URL = "http://localhost:8000/api/v1"
|
||||
TOKEN = "your_jwt_token"
|
||||
headers = {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
# 1. 上传文件
|
||||
with open('document.pdf', 'rb') as f:
|
||||
files = {'file': f}
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/upload/",
|
||||
headers=headers,
|
||||
files=files
|
||||
)
|
||||
file_data = response.json()
|
||||
file_id = file_data['id']
|
||||
print(f"上传成功,文件ID: {file_id}")
|
||||
|
||||
# 2. 获取文件信息
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/upload/{file_id}/",
|
||||
headers=headers
|
||||
)
|
||||
print(f"文件信息: {response.json()}")
|
||||
|
||||
# 3. 软删除文件
|
||||
response = requests.delete(
|
||||
f"{BASE_URL}/upload/{file_id}/",
|
||||
headers=headers
|
||||
)
|
||||
print(f"软删除: {response.json()}")
|
||||
|
||||
# 4. 恢复文件
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/upload/{file_id}/restore/",
|
||||
headers=headers
|
||||
)
|
||||
print(f"恢复文件: {response.json()}")
|
||||
|
||||
# 5. 永久删除
|
||||
response = requests.delete(
|
||||
f"{BASE_URL}/upload/{file_id}/permanent_delete/",
|
||||
headers=headers
|
||||
)
|
||||
print(f"永久删除完成")
|
||||
```
|
||||
|
||||
### JavaScript (Axios)
|
||||
|
||||
```javascript
|
||||
const axios = require('axios');
|
||||
const FormData = require('form-data');
|
||||
const fs = require('fs');
|
||||
|
||||
const BASE_URL = 'http://localhost:8000/api/v1';
|
||||
const TOKEN = 'your_jwt_token';
|
||||
const headers = { Authorization: `Bearer ${TOKEN}` };
|
||||
|
||||
// 1. 上传文件
|
||||
async function uploadFile() {
|
||||
const formData = new FormData();
|
||||
formData.append('file', fs.createReadStream('document.pdf'));
|
||||
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/upload/`,
|
||||
formData,
|
||||
{ headers: { ...headers, ...formData.getHeaders() } }
|
||||
);
|
||||
|
||||
console.log('上传成功:', response.data);
|
||||
return response.data.id;
|
||||
}
|
||||
|
||||
// 2. 获取文件信息
|
||||
async function getFileInfo(fileId) {
|
||||
const response = await axios.get(
|
||||
`${BASE_URL}/upload/${fileId}/`,
|
||||
{ headers }
|
||||
);
|
||||
console.log('文件信息:', response.data);
|
||||
}
|
||||
|
||||
// 3. 软删除
|
||||
async function softDelete(fileId) {
|
||||
const response = await axios.delete(
|
||||
`${BASE_URL}/upload/${fileId}/`,
|
||||
{ headers }
|
||||
);
|
||||
console.log('软删除:', response.data);
|
||||
}
|
||||
|
||||
// 4. 恢复文件
|
||||
async function restore(fileId) {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/upload/${fileId}/restore/`,
|
||||
{},
|
||||
{ headers }
|
||||
);
|
||||
console.log('恢复:', response.data);
|
||||
}
|
||||
|
||||
// 5. 永久删除
|
||||
async function permanentDelete(fileId) {
|
||||
const response = await axios.delete(
|
||||
`${BASE_URL}/upload/${fileId}/permanent_delete/`,
|
||||
{ headers }
|
||||
);
|
||||
console.log('永久删除完成');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误码说明
|
||||
|
||||
| HTTP状态码 | 说明 |
|
||||
|-----------|------|
|
||||
| 200 | 成功 |
|
||||
| 201 | 创建成功 |
|
||||
| 204 | 删除成功(无内容) |
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未认证或认证失败 |
|
||||
| 403 | 无权限 |
|
||||
| 404 | 资源不存在 |
|
||||
| 413 | 文件过大 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **文件大小限制**: 单文件最大100MB,超过此限制会返回400错误
|
||||
2. **文件命名**: 系统自动使用UUID生成随机文件名,原始文件名保存在`original_filename`字段
|
||||
3. **软删除策略**: 建议先使用软删除,确认不需要后再使用永久删除
|
||||
4. **文件访问**: 使用返回的`file_url`字段访问文件
|
||||
5. **权限控制**: 所有接口都需要认证,上传的文件自动关联当前用户
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **不支持的操作**:
|
||||
- ❌ 列表查询 (`GET /api/v1/upload/`)
|
||||
- ❌ 批量上传
|
||||
- ❌ 修改文件 (`PUT/PATCH /api/v1/upload/{id}/`)
|
||||
|
||||
2. **文件存储**:
|
||||
- 文件按日期组织:`uploads/YYYY/MM/DD/`
|
||||
- 文件名使用32位十六进制UUID
|
||||
- 保留原始文件扩展名
|
||||
|
||||
3. **查询限制**:
|
||||
- 默认查询会过滤掉已软删除的文件
|
||||
- 要访问已删除文件,需要通过Django Admin或直接数据库查询
|
||||
|
||||
4. **安全考虑**:
|
||||
- 所有文件名随机化,防止路径遍历攻击
|
||||
- 需要JWT认证
|
||||
- 自动记录上传者信息
|
||||
Reference in New Issue
Block a user