# 通信协议文档 ## 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 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 => { 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([]); 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 ( {medicines.map(medicine => ( ))} ); }; ``` ## 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(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) ```