首次提交by MimoCode

This commit is contained in:
tang1219
2026-06-15 14:50:15 +08:00
parent 584b31da50
commit ead13f863c
166 changed files with 14908 additions and 1 deletions
+834
View File
@@ -0,0 +1,834 @@
# 通信协议文档
## 1. 概述
本文档定义了药箱系统前后端之间的通信协议,包括数据格式、错误处理、文件上传等内容。
## 2. 通信架构
```
┌─────────────────────────────────────────────────────────────┐
│ 前端应用 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ API 调用层 │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Axios │ │ 请求拦截器 │ │ 响应拦截器 │ │ │
│ │ │ Client │ │ (Auth) │ │ (Error) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
▼ HTTP/HTTPS
┌─────────────────────────────────────────────────────────────┐
│ 后端服务 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ FastAPI │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ 路由层 │ │ 中间件 │ │ 依赖注入 │ │ │
│ │ │ (Router) │ │ (Auth) │ │ (Deps) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## 3. 数据格式规范
### 3.1 请求格式
**Content-Type:**
- JSON: `application/json`
- 文件上传: `multipart/form-data`
- 表单: `application/x-www-form-urlencoded`
**请求头:**
```
Content-Type: application/json
Authorization: Bearer <token>
Accept: application/json
```
### 3.2 响应格式
**成功响应(单个对象):**
```json
{
"code": 200,
"message": "success",
"data": {
"id": 1,
"name": "布洛芬"
}
}
```
**成功响应(列表):**
```json
{
"code": 200,
"message": "success",
"data": {
"data": [...],
"total": 100,
"page": 1,
"page_size": 20
}
}
```
**成功响应(无数据):**
```json
{
"code": 200,
"message": "删除成功"
}
```
**错误响应:**
```json
{
"code": 400,
"message": "请求参数错误",
"detail": "name 字段不能为空"
}
```
### 3.3 HTTP 状态码
| 状态码 | 说明 | 使用场景 |
|--------|------|----------|
| 200 | OK | 请求成功 |
| 201 | Created | 创建成功 |
| 204 | No Content | 删除成功(无响应体) |
| 400 | Bad Request | 请求参数错误 |
| 401 | Unauthorized | 未认证或令牌过期 |
| 403 | Forbidden | 权限不足 |
| 404 | Not Found | 资源不存在 |
| 409 | Conflict | 资源冲突(如用户名已存在) |
| 413 | Payload Too Large | 文件过大 |
| 415 | Unsupported Media Type | 不支持的文件类型 |
| 422 | Unprocessable Entity | 请求体格式正确但语义错误 |
| 500 | Internal Server Error | 服务器内部错误 |
### 3.4 业务状态码
| 状态码 | 说明 |
|--------|------|
| 1000 | 成功 |
| 2000 | 参数错误 |
| 3000 | 认证错误 |
| 4000 | 权限错误 |
| 5000 | 业务逻辑错误 |
| 6000 | 外部服务错误 |
| 9000 | 系统错误 |
## 4. 认证协议
### 4.1 JWT Token 格式
**Header:**
```json
{
"alg": "HS256",
"typ": "JWT"
}
```
**Payload:**
```json
{
"sub": "1",
"username": "admin",
"role": "admin",
"iat": 1704067200,
"exp": 1704153600
}
```
### 4.2 Token 传递
**方式1Authorization Header(推荐)**
```
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
**方式2Query Parameter(不推荐,仅用于特殊情况)**
```
GET /api/v1/medicines?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
### 4.3 Token 过期处理
**前端处理流程:**
```
1. 发送请求
2. 收到 401 响应
3. 尝试刷新 Token(如果有 Refresh Token
4. 刷新失败 → 跳转到登录页
5. 刷新成功 → 重新发送原请求
```
**前端代码示例:**
```typescript
// api/client.ts
client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 尝试刷新 Token
const refreshToken = useAuthStore.getState().refreshToken;
if (refreshToken) {
const response = await axios.post('/api/v1/auth/refresh', {
refresh_token: refreshToken
});
const { access_token } = response.data.data;
useAuthStore.getState().setToken(access_token);
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return client(originalRequest);
}
} catch (refreshError) {
// 刷新失败,跳转到登录页
useAuthStore.getState().logout();
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
```
## 5. 文件上传协议
### 5.1 单文件上传
**请求格式:**
```http
POST /api/v1/upload/image HTTP/1.1
Host: localhost:8000
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="medicine.jpg"
Content-Type: image/jpeg
<二进制数据>
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="category"
medicine
------WebKitFormBoundary7MA4YWxkTrZu0gW--
```
**前端实现:**
```typescript
const uploadImage = async (file: File, category: string) => {
const formData = new FormData();
formData.append('file', file);
formData.append('category', category);
const response = await client.post('/upload/image', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return response.data;
};
```
### 5.2 多文件上传
**请求格式:**
```http
POST /api/v1/upload/images HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="files"; filename="image1.jpg"
Content-Type: image/jpeg
<二进制数据>
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="files"; filename="image2.jpg"
Content-Type: image/jpeg
<二进制数据>
------WebKitFormBoundary7MA4YWxkTrZu0gW--
```
### 5.3 文件大小限制
- 图片文件:最大 10MB
- 说明书图片:最大 20MB
**前端检查:**
```typescript
const validateFileSize = (file: File, maxSize: number): boolean => {
return file.size <= maxSize;
};
const validateImageFile = (file: File): boolean => {
const maxSize = 10 * 1024 * 1024; // 10MB
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!validateFileSize(file, maxSize)) {
Toast.show({ content: '文件大小不能超过10MB' });
return false;
}
if (!allowedTypes.includes(file.type)) {
Toast.show({ content: '只支持 JPG、PNG、WebP 格式' });
return false;
}
return true;
};
```
### 5.4 图片压缩
**前端压缩实现:**
```typescript
const compressImage = async (
file: File,
maxWidth: number = 1920,
quality: number = 0.8
): Promise<File> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
const compressedFile = new File([blob!], file.name, {
type: 'image/jpeg',
lastModified: Date.now()
});
resolve(compressedFile);
},
'image/jpeg',
quality
);
};
img.src = e.target?.result as string;
};
reader.readAsDataURL(file);
});
};
```
## 6. 分页协议
### 6.1 请求分页参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| page | integer | 1 | 页码(从1开始) |
| page_size | integer | 20 | 每页数量(最大100 |
**示例:**
```
GET /api/v1/medicines?page=2&page_size=10
```
### 6.2 响应分页数据
```json
{
"code": 200,
"message": "success",
"data": {
"data": [...],
"total": 100,
"page": 2,
"page_size": 10
}
}
```
### 6.3 前端分页实现
```typescript
// 使用 antd-mobile 的 InfiniteScroll
const MedicineList: React.FC = () => {
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [medicines, setMedicines] = useState<Medicine[]>([]);
const loadMore = async () => {
try {
const response = await medicineApi.list({ page, page_size: 20 });
const newData = response.data.data;
setMedicines(prev => [...prev, ...newData]);
setPage(prev => prev + 1);
setHasMore(newData.length === 20);
} catch (error) {
console.error('加载失败:', error);
}
};
return (
<InfiniteScroll loadMore={loadMore} hasMore={hasMore}>
{medicines.map(medicine => (
<MedicineCard key={medicine.id} medicine={medicine} />
))}
</InfiniteScroll>
);
};
```
## 7. 错误处理协议
### 7.1 错误响应格式
```json
{
"code": 400,
"message": "请求参数错误",
"detail": {
"field": "name",
"error": "不能为空"
}
}
```
### 7.2 前端错误处理
```typescript
// api/client.ts
client.interceptors.response.use(
(response) => {
return response.data;
},
(error) => {
const { response } = error;
if (response) {
switch (response.status) {
case 400:
Toast.show({ content: response.data.message || '请求参数错误' });
break;
case 401:
useAuthStore.getState().logout();
window.location.href = '/login';
break;
case 403:
Toast.show({ content: '权限不足' });
break;
case 404:
Toast.show({ content: '资源不存在' });
break;
case 500:
Toast.show({ content: '服务器错误,请稍后重试' });
break;
default:
Toast.show({ content: '请求失败' });
}
} else {
Toast.show({ content: '网络错误,请检查网络连接' });
}
return Promise.reject(error);
}
);
```
### 7.3 表单验证错误
**错误响应格式:**
```json
{
"code": 422,
"message": "请求体格式正确但语义错误",
"detail": [
{
"field": "name",
"message": "字段不能为空",
"type": "value_error.missing"
},
{
"field": "expiry_date",
"message": "日期格式错误",
"type": "value_error.date"
}
]
}
```
**前端处理:**
```typescript
const handleFormError = (error: any) => {
if (error.response?.status === 422) {
const details = error.response.data.detail;
if (Array.isArray(details)) {
details.forEach((item: any) => {
form.setFields([
{
name: item.field,
errors: [item.message]
}
]);
});
}
}
};
```
## 8. 搜索协议
### 8.1 关键词搜索
**请求:**
```
GET /api/v1/search?q=发烧&type=indications
```
**响应:**
```json
{
"code": 200,
"message": "success",
"data": [
{
"id": 1,
"name": "布洛芬",
"match_type": "indications",
"match_text": "用于退热",
"relevance_score": 0.95
}
]
}
```
### 8.2 自然语言搜索
**请求:**
```json
POST /api/v1/search/natural
{
"query": "孩子发烧了应该吃什么药?"
}
```
**响应:**
```json
{
"code": 200,
"message": "success",
"data": {
"results": [
{
"medicine_id": 1,
"name": "布洛芬",
"reason": "适用于退热,可缓解发热症状",
"match_score": 0.95,
"recommendation": "建议在医生指导下使用"
}
],
"ai_response": "根据您的描述,家中有布洛芬可用于退热。请注意按照说明书用量使用,如果症状持续请就医。"
}
}
```
## 9. 实时更新协议
### 9.1 轮询机制
**库存变化轮询:**
```typescript
const useInventoryPolling = (interval: number = 30000) => {
const { fetchMedicines } = useMedicineStore();
useEffect(() => {
const timer = setInterval(() => {
fetchMedicines();
}, interval);
return () => clearInterval(timer);
}, [interval]);
};
```
### 9.2 通知轮询
```typescript
const useNotificationPolling = (interval: number = 60000) => {
const { fetchNotifications } = useNotificationStore();
useEffect(() => {
const timer = setInterval(() => {
fetchNotifications({ is_read: false });
}, interval);
return () => clearInterval(timer);
}, [interval]);
};
```
## 10. 缓存协议
### 10.1 前端缓存策略
**localStorage 缓存:**
```typescript
const CACHE_KEYS = {
AUTH_TOKEN: 'auth_token',
USER_INFO: 'user_info',
SETTINGS: 'app_settings'
};
const cache = {
get: (key: string) => {
const value = localStorage.getItem(key);
return value ? JSON.parse(value) : null;
},
set: (key: string, value: any) => {
localStorage.setItem(key, JSON.stringify(value));
},
remove: (key: string) => {
localStorage.removeItem(key);
}
};
```
**Session Storage 缓存:**
```typescript
const sessionCache = {
get: (key: string) => {
const value = sessionStorage.getItem(key);
return value ? JSON.parse(value) : null;
},
set: (key: string, value: any) => {
sessionStorage.setItem(key, JSON.stringify(value));
},
remove: (key: string) => {
sessionStorage.removeItem(key);
}
};
```
### 10.2 HTTP 缓存头
**后端响应头:**
```python
@router.get("/medicines")
async def list_medicines(
# ...
response: Response
):
# 设置缓存头
response.headers["Cache-Control"] = "private, max-age=60"
response.headers["ETag"] = generate_etag(data)
return data
```
**前端缓存处理:**
```typescript
const fetchWithCache = async (url: string, options?: RequestInit) => {
const cacheKey = `cache_${url}`;
const cached = sessionCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < 60000) {
return cached.data;
}
const response = await fetch(url, options);
const data = await response.json();
sessionCache.set(cacheKey, {
data,
timestamp: Date.now()
});
return data;
};
```
## 11. WebSocket 协议(可选)
### 11.1 连接建立
```typescript
const useWebSocket = (url: string) => {
const [socket, setSocket] = useState<WebSocket | null>(null);
useEffect(() => {
const ws = new WebSocket(url);
ws.onopen = () => {
console.log('WebSocket 连接已建立');
setSocket(ws);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleMessage(data);
};
ws.onclose = () => {
console.log('WebSocket 连接已关闭');
setSocket(null);
};
return () => {
ws.close();
};
}, [url]);
return socket;
};
```
### 11.2 消息格式
**客户端发送:**
```json
{
"type": "subscribe",
"channel": "inventory_updates"
}
```
**服务端推送:**
```json
{
"type": "inventory_update",
"data": {
"medicine_id": 1,
"medicine_name": "布洛芬",
"old_quantity": 30,
"new_quantity": 25,
"action": "dispense",
"user": "admin",
"timestamp": "2024-01-01T12:00:00"
}
}
```
## 12. API 版本控制
### 12.1 URL 路径版本
```
/api/v1/medicines
/api/v2/medicines
```
### 12.2 请求头版本
```
Accept: application/vnd.yaoxiang.v1+json
```
### 12.3 版本迁移策略
```python
# 旧版本路由(v1
@router_v1.get("/medicines")
async def list_medicines_v1():
# v1 逻辑
pass
# 新版本路由(v2
@router_v2.get("/medicines")
async def list_medicines_v2():
# v2 逻辑
pass
```
## 13. 安全协议
### 13.1 CORS 配置
```python
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173", # 开发环境
"http://localhost:3000", # 生产环境
"https://your-domain.com" # 域名
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
```
### 13.2 请求限流
```python
from fastapi import Request, HTTPException
from collections import defaultdict
import time
class RateLimiter:
def __init__(self, max_requests: int = 100, window_seconds: int = 60):
self.max_requests = max_requests
self.window_seconds = window_seconds
self.requests = defaultdict(list)
def check(self, client_ip: str):
now = time.time()
window_start = now - self.window_seconds
# 清理过期记录
self.requests[client_ip] = [
req_time for req_time in self.requests[client_ip]
if req_time > window_start
]
if len(self.requests[client_ip]) >= self.max_requests:
raise HTTPException(status_code=429, detail="请求过于频繁")
self.requests[client_ip].append(now)
limiter = RateLimiter()
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
client_ip = request.client.host
limiter.check(client_ip)
response = await call_next(request)
return response
```
### 13.3 输入验证
```python
from pydantic import BaseModel, Field, validator
class MedicineCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
expiry_date: str = Field(..., pattern=r'^\d{4}-\d{2}-\d{2}$')
@validator('name')
def validate_name(cls, v):
# 防止 XSS
import html
return html.escape(v)
```