Files
YaoXiang/docs/communication.md
T
2026-06-15 14:50:15 +08:00

835 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 通信协议文档
## 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)
```