1
0
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:
2025-11-26 21:49:42 +08:00
parent 6bf0465d05
commit a9c75a13fa
26 changed files with 7158 additions and 216 deletions

76
docs/business_purchase.md Normal file
View File

@@ -0,0 +1,76 @@
# Business 模块采购单 API
## 创建采购单并触发入库
- **URL**: `POST /api/v1/purchase-orders/`
- **权限**: 需要登录且具备员工身份
- **描述**: 创建业务模块的 `PurchaseOrder`,并异步触发库存入库任务(通过 `stock.services.StockFlowService.stock_in` 实现)。
### 请求体
```json
{
"supplier": 1,
"warehouse": 2,
"order_date": "2025-11-26",
"total_amount": "1200.50",
"remarks": "测试采购单",
"items": [
{
"product_id": 10,
"quantities": ["10.5", "6.0"]
},
{
"product_id": 18,
"quantities": ["3.25"]
}
]
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| supplier | integer | ✅ | 供应商 ID必须隶属于当前商户 |
| warehouse | integer | ✅ | 入库仓库 ID |
| order_date | string (date) | ✅ | 订单日期(`YYYY-MM-DD` |
| total_amount | string/number | ✅ | 采购总金额 |
| remarks | string | 否 | 备注 |
| items | array | ✅ | 入库明细,结构需满足 `StockFlowService` 的要求目前仅支持严谨模式product_id + quantities |
> `items` 内部字段会被直接传给 `StockFlowService`,因此:
> - 严谨/严进模式:`{'product_id': 1, 'quantities': ['10', '5']}`
> - 宽松模式/严进严出出库暂未开放,后续按需扩展。
### 响应
```json
{
"id": 35,
"message": "采购单创建成功,入库任务已排队"
}
```
创建成功即刻返回,实际入库明细由后台 Celery 任务生成,可在日志或 `stock_change` 记录中查看。
### 错误示例
| 状态码 | 示例 | 说明 |
|--------|------|------|
| 400 | `{"error": "缺少供应商 ID"}` | 请求缺失关键字段 |
| 400 | `{"error": "供应商 99 不存在"}` | 提供的供应商不属于当前商户 |
| 403 | `{"error": "无权限访问"}` | 当前用户无员工信息 |
### 关联任务business/tasks.py
`create_purchase_order_stock_entries` 任务会接收 `purchase_order_id``warehouse_id``items` 等信息,并通过 `StockFlowService.stock_in` 创建入库记录。
- 任务日志示例:`采购单 35 入库任务完成`
- 返回 payload 包含 `stock_change_record_id``created_details_count`
### 测试
`api_v1/tests.py` 中新增 `PurchaseOrderAPITestCase`,通过 eager Celery 设置验证:
1. API 请求返回 201
2. Celery 任务被成功调用patch `create_purchase_order_stock_entries.delay` 断言参数)

108
docs/celery_testing.md Normal file
View File

@@ -0,0 +1,108 @@
# Celery 任务测试指南
## 1. 自动化测试(单元测试/CI
项目新增了三个演示型任务(`api_v1/tasks.py`
| 任务 | 功能 |
|------|------|
| `ping_task(message='ping')` | 打印并返回时间戳,用于快速验证 worker 是否可接收/返回结果 |
| `merchant_product_count(merchant_id)` | 统计某商户下产品数量,示范如何在任务中访问数据库 |
| `backup_database(output_dir=None, filename_prefix='db-backup')` | 将当前数据库导出为 `.sql` 文件(数据备份),默认保存到 `BASE_DIR/data-bak/` |
对应的单元测试位于 `api_v1/tests.py`(类 `CeleryTasksTestCase`),通过
`@override_settings(CELERY_TASK_ALWAYS_EAGER=True)` 让任务在测试进程内同步执行。
运行方式:
```bash
uv run manage.py test api_v1.tests.CeleryTasksTestCase
```
若要在其他测试中调用任务,只需在测试类上使用同样的 `override_settings`
即可保证 Celery 在没有 worker 的情况下仍能同步执行。
---
## 2. 本地验证真实 workerRabbitMQ + Celery
### 2.1 启动依赖服务
```bash
# 启动基础设施Postgres/Redis/Rabbit/Celery worker/web
docker compose up -d postgres redis rabbitmq
# 启动 webDjango
docker compose up -d web
# 启动 Celery worker如已运行可跳过
docker compose up -d celery_worker
# 或者手动uv run celery -A flower worker -l info
```
> 若 worker 容器异常,可通过 `docker compose restart celery_worker` 重启。
### 2.2 发送测试任务
在另一个终端进入容器或宿主项目目录执行:
```bash
uv run python manage.py shell
```
```python
from api_v1.tasks import ping_task, merchant_product_count, backup_database
from basic_info.models import Merchant
# 发送心跳任务
async_result = ping_task.delay('hello celery')
print(async_result.get(timeout=10))
# 发送统计任务(示例:使用 ID=1 的商户)
merchant = Merchant.objects.first()
result = merchant_product_count.delay(merchant.id)
print(result.get(timeout=10))
# 触发数据库备份(输出到默认 data-bak
backup = backup_database.delay(filename_prefix='manual-backup')
print(backup.get(timeout=30)) # payload 中包含 backup_path文件为 .sql
```
### 2.3 观察执行结果
1. **Worker 日志**`docker compose logs -f celery_worker` \
可查看任务被消费、日志输出等信息。
2. **RabbitMQ 控制台**:访问 `http://localhost:15672`(默认账号 guest/guest
观察队列长度是否回落到 0。
3. **Task Result**:上面的 `result.get()` 会在任务完成时返回 payload
若超时或无法连接则会抛出异常,帮助定位问题。
---
## 3. 失败排查 Checklist
1. **环境变量**`CELERY_BROKER_URL``CELERY_RESULT_BACKEND` 是否指向
正在运行的 RabbitMQ/Redis
2. **Worker 进程**`docker compose ps` 确认 `celery_worker` 状态为 Up。
3. **队列阻塞**RabbitMQ 控制台查看是否有大量消息处于 `Unacked`
4. **日志级别**:测试中需要捕获日志时,可使用
`with self.assertLogs('api_v1.tasks', level='INFO')`.
5. **自动化测试**:若任务在测试里需要真实队列,请去掉
`CELERY_TASK_ALWAYS_EAGER` 覆盖;否则保持默认值即可同步执行。
6. **数据库备份依赖**`backup_database` 在 PostgreSQL 场景下需要系统可执行 `pg_dump`
请确保容器/宿主机已安装 PostgreSQL 客户端工具SQLite 则会使用内置 `iterdump` 生成 `.sql`
---
## 4. 常用命令速查
| 操作 | 命令 |
|------|------|
| 启动 worker | `docker compose up -d celery_worker` |
| 查看 worker 日志 | `docker compose logs -f celery_worker` |
| 重启 worker | `docker compose restart celery_worker` |
| 清空 RabbitMQ 队列 | `docker compose exec rabbitmq rabbitmqctl purge_queue flower`(示例) |
| 运行单个任务测试 | `uv run python manage.py shell` -> `ping_task.delay()` |
通过以上步骤,即可确认 Celery + RabbitMQ 在本地能够成功执行任务,并在自动化测试里保持覆盖。若需要新增业务任务,可参考 `api_v1/tasks.py` 的写法:使用 `@shared_task`,在任务内进行必要的日志记录,便于问题排查。***

217
docs/sse.md Normal file
View File

@@ -0,0 +1,217 @@
# SSE (Server-Sent Events) 模块
基于 Django 和 Django REST Framework 的服务器推送事件实现,支持多商户隔离。
## 功能特性
- ✅ 使用 DRF 处理请求和响应(非流式端点)
- ✅ 支持多种请求格式JSON、Form Data、Multipart
- ✅ 自动数据验证和序列化
- ✅ 异步支持,高并发处理
- ✅ 心跳机制保持连接活跃
- ✅ 自动清理断开的连接
- ✅ 连接状态监控
- ✅ 多商户消息隔离
- ✅ JWT认证和商户验证
## API 端点
### 1. 订阅 SSE 事件流
**端点**: `GET /sse/`
客户端连接此端点保持长连接,接收服务器推送的事件。
**认证要求**: 需要JWT认证且用户必须有关联的商户
**示例**:
```bash
# 需要提供JWT token
curl -N -H "Authorization: Bearer YOUR_JWT_TOKEN" http://localhost:8000/sse/
```
**JavaScript 示例**:
```javascript
// 需要在连接时提供认证头
const eventSource = new EventSource('/sse/', {
headers: {
'Authorization': `Bearer ${yourJwtToken}`
}
});
eventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
console.log('收到消息:', data);
console.log('商户ID:', data.merchant_id);
};
```
**注意**: 浏览器原生的EventSource不支持自定义请求头因此推荐使用EventSource polyfill或fetch实现。
---
### 2. 推送事件到当前商户的客户端
**端点**: `POST /sse/push/`
向当前用户所属商户的所有已连接客户端广播消息。
**认证要求**: 需要JWT认证且用户必须有关联的商户
**请求参数**:
- 自动使用当前用户的商户ID
- 固定发送测试消息:'订单已支付'
- 固定事件类型:'order_paid'
- 固定对象ID12345
**请求示例**:
```bash
curl -X POST http://localhost:8000/sse/push/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
**响应示例**:
```json
{
"status": "ok",
"message": "Test event broadcasted to your merchant",
"merchant_id": 1,
"clients": 3
}
```
---
### 3. 获取连接状态
**端点**: `GET /sse/status/`
查询当前 SSE 服务器的状态和连接数。
**认证要求**: 需要JWT认证且用户必须有关联的商户
**示例**:
```bash
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" http://localhost:8000/sse/status/
```
**响应示例**:
```json
{
"status": "running",
"total_clients": 10,
"merchant_clients": 3,
"merchant_id": 1,
"message": "SSE server is running with 10 total connections, 3 for your merchant"
}
```
### 4. 关闭商户连接
**端点**: `POST /sse/shutdown/`
关闭当前用户所属商户的所有SSE连接。
**认证要求**: 需要JWT认证且用户必须有关联的商户
**示例**:
```bash
curl -X POST http://localhost:8000/sse/shutdown/ \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
**响应示例**:
```json
{
"status": "ok",
"message": "Shutdown signal sent to your merchant's SSE connections",
"merchant_id": 1,
"clients": 3
}
```
## 启动服务器
使用 Uvicorn (ASGI 服务器) 启动:
```bash
# 开发环境
uvicorn flower.asgi:application --reload --host 0.0.0.0 --port 8000
# 生产环境
uvicorn flower.asgi:application --host 0.0.0.0 --port 8000 --workers 4
```
## 测试
打开 `sse_test.html` 在浏览器中测试:
1. 点击"连接 SSE"建立连接
2. 输入消息
3. 点击"发送 (JSON)"或"发送 (Form Data)"测试不同格式
4. 点击"获取连接状态"查看当前连接数
5. 打开多个浏览器标签测试广播功能
## Python 客户端示例
```python
import requests
import sseclient # pip install sseclient-py
# 订阅事件
response = requests.get('http://localhost:8000/sse/', stream=True)
client = sseclient.SSEClient(response)
for event in client.events():
print(f'收到消息: {event.data}')
```
## 技术实现
- **流式响应**: 使用 `StreamingHttpResponse` 实现 SSE 连接
- **队列机制**: 每个连接对应一个 `queue.Queue`,按商户组织
- **心跳**: 30 秒超时,自动发送心跳保持连接
- **DRF 集成**: 非流式端点使用 DRF 的 `@api_view` 和序列化器
- **多格式支持**: 自动解析 JSON、Form Data、Multipart 等格式
- **商户隔离**: 所有消息按商户隔离,确保数据安全
- **JWT认证**: 使用 DRF Simple JWT 进行身份验证
## 消息结构
### 连接成功消息
```json
{
"type": "connected",
"message": "SSE connection established",
"merchant_id": 1
}
```
### 业务事件消息
```json
{
"mode": "simple_message",
"type": "order_paid",
"message": "订单已支付",
"object_id": 12345,
"merchant_id": 1
}
```
### 服务器关闭消息
```json
{
"type": "server_shutdown",
"message": "Server shutting down your connections, please reconnect later"
}
```
## 注意事项
1. 必须使用 ASGI 服务器(如 Uvicorn、Daphne运行
2. 不支持使用传统的 WSGI 服务器(如 Gunicorn + WSGI
3. 如果使用 Nginx需要禁用缓冲`X-Accel-Buffering: no`
4. 所有SSE端点都需要JWT认证且用户必须有关联的商户
5. 浏览器原生的EventSource不支持自定义请求头推荐使用polyfill或fetch实现
6. SSE 使用 GET 请求,注意 CORS 配置

425
docs/upload.md Normal file
View File

@@ -0,0 +1,425 @@
# 文件上传接口文档
## 概述
通用文件上传接口,用于上传无法归类到具体业务的文件。
**基础路径**: `/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认证
- 自动记录上传者信息