19 KiB
19 KiB
通信协议文档
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 响应格式
成功响应(单个对象):
{
"code": 200,
"message": "success",
"data": {
"id": 1,
"name": "布洛芬"
}
}
成功响应(列表):
{
"code": 200,
"message": "success",
"data": {
"data": [...],
"total": 100,
"page": 1,
"page_size": 20
}
}
成功响应(无数据):
{
"code": 200,
"message": "删除成功"
}
错误响应:
{
"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:
{
"alg": "HS256",
"typ": "JWT"
}
Payload:
{
"sub": "1",
"username": "admin",
"role": "admin",
"iat": 1704067200,
"exp": 1704153600
}
4.2 Token 传递
方式1:Authorization Header(推荐)
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
方式2:Query Parameter(不推荐,仅用于特殊情况)
GET /api/v1/medicines?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
4.3 Token 过期处理
前端处理流程:
1. 发送请求
2. 收到 401 响应
3. 尝试刷新 Token(如果有 Refresh Token)
4. 刷新失败 → 跳转到登录页
5. 刷新成功 → 重新发送原请求
前端代码示例:
// 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 单文件上传
请求格式:
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--
前端实现:
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 多文件上传
请求格式:
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
前端检查:
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 图片压缩
前端压缩实现:
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 响应分页数据
{
"code": 200,
"message": "success",
"data": {
"data": [...],
"total": 100,
"page": 2,
"page_size": 10
}
}
6.3 前端分页实现
// 使用 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 错误响应格式
{
"code": 400,
"message": "请求参数错误",
"detail": {
"field": "name",
"error": "不能为空"
}
}
7.2 前端错误处理
// 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 表单验证错误
错误响应格式:
{
"code": 422,
"message": "请求体格式正确但语义错误",
"detail": [
{
"field": "name",
"message": "字段不能为空",
"type": "value_error.missing"
},
{
"field": "expiry_date",
"message": "日期格式错误",
"type": "value_error.date"
}
]
}
前端处理:
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
响应:
{
"code": 200,
"message": "success",
"data": [
{
"id": 1,
"name": "布洛芬",
"match_type": "indications",
"match_text": "用于退热",
"relevance_score": 0.95
}
]
}
8.2 自然语言搜索
请求:
POST /api/v1/search/natural
{
"query": "孩子发烧了应该吃什么药?"
}
响应:
{
"code": 200,
"message": "success",
"data": {
"results": [
{
"medicine_id": 1,
"name": "布洛芬",
"reason": "适用于退热,可缓解发热症状",
"match_score": 0.95,
"recommendation": "建议在医生指导下使用"
}
],
"ai_response": "根据您的描述,家中有布洛芬可用于退热。请注意按照说明书用量使用,如果症状持续请就医。"
}
}
9. 实时更新协议
9.1 轮询机制
库存变化轮询:
const useInventoryPolling = (interval: number = 30000) => {
const { fetchMedicines } = useMedicineStore();
useEffect(() => {
const timer = setInterval(() => {
fetchMedicines();
}, interval);
return () => clearInterval(timer);
}, [interval]);
};
9.2 通知轮询
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 缓存:
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 缓存:
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 缓存头
后端响应头:
@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
前端缓存处理:
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 连接建立
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 消息格式
客户端发送:
{
"type": "subscribe",
"channel": "inventory_updates"
}
服务端推送:
{
"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 版本迁移策略
# 旧版本路由(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 配置
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 请求限流
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 输入验证
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)