835 lines
19 KiB
Markdown
835 lines
19 KiB
Markdown
# 通信协议文档
|
||
|
||
## 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)
|
||
```
|