首次提交by MimoCode
This commit is contained in:
@@ -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 传递
|
||||
|
||||
**方式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. 刷新成功 → 重新发送原请求
|
||||
```
|
||||
|
||||
**前端代码示例:**
|
||||
```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)
|
||||
```
|
||||
Reference in New Issue
Block a user