# 后端开发文档 ## 1. 项目结构 ``` backend/ ├── app/ │ ├── __init__.py │ ├── main.py # 应用入口 │ ├── config.py # 配置管理 │ ├── database.py # 数据库连接 │ ├── api/ # 路由层 │ │ ├── __init__.py │ │ ├── router.py # 路由汇总 │ │ └── v1/ # API 版本 v1 │ │ ├── __init__.py │ │ ├── auth.py # 认证路由 │ │ ├── medicines.py # 药品路由 │ │ ├── batches.py # 批次路由 │ │ ├── categories.py # 分类路由 │ │ ├── search.py # 搜索路由 │ │ ├── notifications.py # 通知路由 │ │ ├── ai.py # AI 识别路由 │ │ ├── users.py # 用户管理路由 │ │ └── settings.py # 设置路由 │ ├── models/ # SQLAlchemy 模型 │ │ ├── __init__.py │ │ ├── user.py # 用户模型 │ │ ├── medicine.py # 药品模型 │ │ ├── batch.py # 批次模型 │ │ ├── category.py # 分类模型 │ │ ├── audit_log.py # 审计日志模型 │ │ ├── notification.py # 通知模型 │ │ └── setting.py # 设置模型 │ ├── schemas/ # Pydantic 模型 │ │ ├── __init__.py │ │ ├── user.py # 用户 Schema │ │ ├── medicine.py # 药品 Schema │ │ ├── batch.py # 批次 Schema │ │ ├── category.py # 分类 Schema │ │ ├── audit_log.py # 审计日志 Schema │ │ ├── notification.py # 通知 Schema │ │ └── auth.py # 认证 Schema │ ├── services/ # 服务层 │ │ ├── __init__.py │ │ ├── auth.py # 认证服务 │ │ ├── user.py # 用户服务 │ │ ├── medicine.py # 药品服务 │ │ ├── batch.py # 批次服务 │ │ ├── category.py # 分类服务 │ │ ├── notification.py # 通知服务 │ │ ├── search.py # 搜索服务 │ │ └── audit.py # 审计服务 │ ├── repositories/ # 数据访问层 │ │ ├── __init__.py │ │ ├── user.py # 用户仓库 │ │ ├── medicine.py # 药品仓库 │ │ ├── batch.py # 批次仓库 │ │ ├── category.py # 分类仓库 │ │ ├── audit_log.py # 审计日志仓库 │ │ └── notification.py # 通知仓库 │ ├── ai/ # AI Provider │ │ ├── __init__.py │ │ ├── base.py # 抽象基类 │ │ ├── openai_provider.py # OpenAI 实现 │ │ ├── gemini_provider.py # Gemini 实现 │ │ ├── claude_provider.py # Claude 实现 │ │ ├── deepseek_provider.py # DeepSeek 实现 │ │ ├── ollama_provider.py # Ollama 实现 │ │ └── manager.py # Provider 管理器 │ ├── notifications/ # 通知系统 │ │ ├── __init__.py │ │ ├── base.py # 抽象基类 │ │ ├── serverchan.py # Server酱 │ │ ├── pushplus.py # PushPlus │ │ ├── bark.py # Bark │ │ ├── wechat.py # 企业微信 │ │ ├── telegram.py # Telegram │ │ ├── email.py # 邮件 │ │ └── manager.py # 通知管理器 │ ├── storage/ # 文件存储 │ │ ├── __init__.py │ │ ├── base.py # 抽象基类 │ │ ├── local.py # 本地存储 │ │ └── manager.py # 存储管理器 │ ├── core/ # 核心功能 │ │ ├── __init__.py │ │ ├── security.py # 安全工具(密码哈希、JWT) │ │ ├── deps.py # 依赖注入 │ │ └── exceptions.py # 自定义异常 │ └── tasks/ # 异步任务 │ ├── __init__.py │ ├── expiry_check.py # 到期检查任务 │ └── stock_check.py # 库存检查任务 ├── alembic/ # 数据库迁移 │ ├── versions/ │ ├── env.py │ └── script.py.mako ├── tests/ # 测试文件 │ ├── __init__.py │ ├── conftest.py │ ├── test_auth.py │ ├── test_medicines.py │ └── test_batches.py ├── migrations/ # 迁移脚本 ├── requirements.txt # 依赖配置 ├── alembic.ini # Alembic 配置 ├── Dockerfile # Docker 配置 ├── docker-compose.yml # Docker Compose 配置 ├── .env.example # 环境变量示例 ├── .env # 环境变量(不提交) ├── pytest.ini # Pytest 配置 └── README.md # 后端说明 ``` ## 2. 核心依赖 ```txt # requirements.txt # Web 框架 fastapi==0.104.1 uvicorn[standard]==0.24.0 python-multipart==0.0.6 # 数据库 sqlalchemy==2.0.23 alembic==1.13.0 aiosqlite==0.19.0 # 认证 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 python-multipart==0.0.6 # 数据验证 pydantic==2.5.2 pydantic-settings==2.1.0 # AI 服务 openai==1.6.1 google-generativeai==0.3.2 anthropic==0.8.0 httpx==0.25.2 # 通知 aiohttp==3.9.1 # 文件处理 aiofiles==23.2.1 Pillow==10.1.0 # 工具 python-dotenv==1.0.0 loguru==0.7.2 apscheduler==3.10.4 # 测试 pytest==7.4.3 pytest-asyncio==0.23.2 httpx==0.25.2 ``` ## 3. 应用入口 ```python # app/main.py from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager from app.config import settings from app.database import engine, SessionLocal from app.api.router import api_router from app.core.exceptions import register_exception_handlers @asynccontextmanager async def lifespan(app: FastAPI): """应用生命周期管理""" # 启动时 print("Starting YaoXiang API...") yield # 关闭时 print("Shutting down YaoXiang API...") app = FastAPI( title="药箱 API", description="家庭药品与应急物资管理系统 API", version="1.0.0", lifespan=lifespan, docs_url="/docs" if settings.DEBUG else None, redoc_url="/redoc" if settings.DEBUG else None, ) # CORS 配置 app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # 注册路由 app.include_router(api_router, prefix="/api") # 注册异常处理器 register_exception_handlers(app) @app.get("/health") async def health_check(): """健康检查""" return {"status": "healthy", "version": "1.0.0"} ``` ## 4. 配置管理 ```python # app/config.py from pydantic_settings import BaseSettings from typing import List from functools import lru_cache class Settings(BaseSettings): """应用配置""" # 应用配置 APP_NAME: str = "药箱" APP_VERSION: str = "1.0.0" DEBUG: bool = False # 数据库配置 DATABASE_URL: str = "sqlite+aiosqlite:///./data/yaoxiang.db" # 安全配置 JWT_SECRET_KEY: str = "your-secret-key-change-in-production" JWT_ALGORITHM: str = "HS256" JWT_EXPIRATION_HOURS: int = 24 # AI Provider 配置 AI_PROVIDER: str = "openai" OPENAI_API_KEY: str = "" OPENAI_MODEL: str = "gpt-4o" GEMINI_API_KEY: str = "" GEMINI_MODEL: str = "gemini-pro-vision" ANTHROPIC_API_KEY: str = "" ANTHROPIC_MODEL: str = "claude-3-opus-20240229" DEEPSEEK_API_KEY: str = "" DEEPSEEK_MODEL: str = "deepseek-chat" OLLAMA_BASE_URL: str = "http://localhost:11434" OLLAMA_MODEL: str = "llava" # 通知配置 NOTIFICATION_PROVIDERS: List[str] = [] SERVERCHAN_KEY: str = "" PUSHPLUS_TOKEN: str = "" BARK_URL: str = "" WECHAT_WEBHOOK_URL: str = "" TELEGRAM_BOT_TOKEN: str = "" TELEGRAM_CHAT_ID: str = "" SMTP_HOST: str = "" SMTP_PORT: int = 587 SMTP_USER: str = "" SMTP_PASSWORD: str = "" SMTP_FROM: str = "" # 文件存储配置 UPLOAD_DIR: str = "./data/uploads" MAX_UPLOAD_SIZE: int = 10485760 # 10MB # 到期提醒配置 EXPIRY_WARNING_DAYS: List[int] = [90, 30, 7] # 低库存阈值 LOW_STOCK_THRESHOLD: int = 5 # CORS 配置 CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"] # 宽限天数最大值 EXPIRY_GRACE_DAYS_MAX: int = 60 class Config: env_file = ".env" env_file_encoding = "utf-8" @lru_cache() def get_settings() -> Settings: return Settings() settings = get_settings() ``` ## 5. 数据库配置 ```python # app/database.py from sqlalchemy import create_engine from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.orm import DeclarativeBase from app.config import settings # 创建异步引擎 engine = create_async_engine( settings.DATABASE_URL, echo=settings.DEBUG, future=True, ) # 创建会话工厂 async_session_factory = async_sessionmaker( engine, class_=AsyncSession, expire_on_commit=False, ) class Base(DeclarativeBase): """模型基类""" pass async def get_db() -> AsyncSession: """获取数据库会话""" async with async_session_factory() as session: try: yield session await session.commit() except Exception: await session.rollback() raise finally: await session.close() async def init_db(): """初始化数据库""" async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) ``` ## 6. 数据模型 ### 6.1 用户模型 ```python # app/models/user.py from sqlalchemy import Column, Integer, String, Boolean, DateTime from sqlalchemy.orm import relationship from datetime import datetime from app.database import Base class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) username = Column(String(50), unique=True, index=True, nullable=False) password_hash = Column(String(255), nullable=False) role = Column(String(20), nullable=False, default="user") display_name = Column(String(100)) email = Column(String(100)) notification_level = Column(String(20), default="normal") is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # 关系 medicines = relationship("Medicine", back_populates="creator") audit_logs = relationship("AuditLog", back_populates="user") notifications = relationship("Notification", back_populates="user") ``` ### 6.2 药品模型 ```python # app/models/medicine.py from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, JSON from sqlalchemy.orm import relationship from datetime import datetime from app.database import Base class Medicine(Base): __tablename__ = "medicines" id = Column(Integer, primary_key=True, index=True) name = Column(String(200), nullable=False, index=True) generic_name = Column(String(200), index=True) brand_name = Column(String(200)) manufacturer = Column(String(200)) specification = Column(String(200)) category_id = Column(Integer, ForeignKey("categories.id")) description = Column(Text) indications = Column(Text) adult_dose = Column(Text) child_dose = Column(Text) contraindications = Column(Text) notes = Column(Text) image_front_path = Column(String(500)) image_expiry_path = Column(String(500)) image_leaflet_paths = Column(JSON) expiry_grace_days = Column(Integer, default=0) created_by = Column(Integer, ForeignKey("users.id")) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # 关系 category = relationship("Category", back_populates="medicines") creator = relationship("User", back_populates="medicines") batches = relationship("Batch", back_populates="medicine", cascade="all, delete-orphan") audit_logs = relationship("AuditLog", back_populates="medicine") ``` ### 6.3 批次模型 ```python # app/models/batch.py from sqlalchemy import Column, Integer, String, Date, Boolean, ForeignKey, DateTime from sqlalchemy.orm import relationship from datetime import datetime from app.database import Base class Batch(Base): __tablename__ = "batches" id = Column(Integer, primary_key=True, index=True) medicine_id = Column(Integer, ForeignKey("medicines.id"), nullable=False, index=True) batch_no = Column(String(100)) production_date = Column(Date) expiry_date = Column(Date, nullable=False) quantity = Column(Integer, nullable=False, default=0) location = Column(String(200)) is_expired = Column(Boolean, default=False) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # 关系 medicine = relationship("Medicine", back_populates="batches") audit_logs = relationship("AuditLog", back_populates="batch") ``` ### 6.4 分类模型 ```python # app/models/category.py from sqlalchemy import Column, Integer, String, ForeignKey, DateTime from sqlalchemy.orm import relationship from datetime import datetime from app.database import Base class Category(Base): __tablename__ = "categories" id = Column(Integer, primary_key=True, index=True) name = Column(String(100), nullable=False) parent_id = Column(Integer, ForeignKey("categories.id")) level = Column(Integer, nullable=False, default=1) icon = Column(String(50)) sort_order = Column(Integer, default=0) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # 关系 parent = relationship("Category", remote_side=[id]) children = relationship("Category", back_populates="parent") medicines = relationship("Medicine", back_populates="category") ``` ### 6.5 审计日志模型 ```python # app/models/audit_log.py from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime from sqlalchemy.orm import relationship from datetime import datetime from app.database import Base class AuditLog(Base): __tablename__ = "audit_logs" id = Column(Integer, primary_key=True, index=True) medicine_id = Column(Integer, ForeignKey("medicines.id"), nullable=False, index=True) batch_id = Column(Integer, ForeignKey("batches.id")) user_id = Column(Integer, ForeignKey("users.id")) action = Column(String(50), nullable=False) quantity_change = Column(Integer, nullable=False) quantity_after = Column(Integer, nullable=False) remark = Column(Text) created_at = Column(DateTime, default=datetime.utcnow) # 关系 medicine = relationship("Medicine", back_populates="audit_logs") batch = relationship("Batch", back_populates="audit_logs") user = relationship("User", back_populates="audit_logs") ``` ## 7. Pydantic Schema ### 7.1 用户 Schema ```python # app/schemas/user.py from pydantic import BaseModel, Field from typing import Optional from datetime import datetime class UserBase(BaseModel): username: str = Field(..., min_length=3, max_length=50) display_name: Optional[str] = None email: Optional[str] = None role: str = Field(default="user", pattern="^(admin|user|readonly)$") notification_level: str = Field(default="normal", pattern="^(none|low|normal|high)$") class UserCreate(UserBase): password: str = Field(..., min_length=6) class UserUpdate(BaseModel): display_name: Optional[str] = None email: Optional[str] = None role: Optional[str] = None notification_level: Optional[str] = None is_active: Optional[bool] = None class UserResponse(UserBase): id: int is_active: bool created_at: datetime updated_at: datetime class Config: from_attributes = True class UserLogin(BaseModel): username: str password: str class Token(BaseModel): access_token: str token_type: str = "bearer" user: UserResponse ``` ### 7.2 药品 Schema ```python # app/schemas/medicine.py from pydantic import BaseModel, Field from typing import Optional, List from datetime import datetime, date class MedicineBase(BaseModel): name: str = Field(..., min_length=1, max_length=200) generic_name: Optional[str] = None brand_name: Optional[str] = None manufacturer: Optional[str] = None specification: Optional[str] = None category_id: Optional[int] = None description: Optional[str] = None indications: Optional[str] = None adult_dose: Optional[str] = None child_dose: Optional[str] = None contraindications: Optional[str] = None notes: Optional[str] = None expiry_grace_days: int = Field(default=0, ge=0, le=60) class MedicineCreate(MedicineBase): pass class MedicineUpdate(BaseModel): name: Optional[str] = None generic_name: Optional[str] = None brand_name: Optional[str] = None manufacturer: Optional[str] = None specification: Optional[str] = None category_id: Optional[int] = None description: Optional[str] = None indications: Optional[str] = None adult_dose: Optional[str] = None child_dose: Optional[str] = None contraindications: Optional[str] = None notes: Optional[str] = None expiry_grace_days: Optional[int] = None class MedicineResponse(MedicineBase): id: int image_front_path: Optional[str] = None image_expiry_path: Optional[str] = None image_leaflet_paths: Optional[List[str]] = None created_by: Optional[int] = None created_at: datetime updated_at: datetime class Config: from_attributes = True class MedicineWithStock(MedicineResponse): total_quantity: int = 0 nearest_expiry_date: Optional[date] = None batch_count: int = 0 ``` ### 7.3 批次 Schema ```python # app/schemas/batch.py from pydantic import BaseModel, Field from typing import Optional from datetime import date, datetime class BatchBase(BaseModel): batch_no: Optional[str] = None production_date: Optional[date] = None expiry_date: date quantity: int = Field(default=0, ge=0) location: Optional[str] = None class BatchCreate(BatchBase): pass class BatchUpdate(BaseModel): batch_no: Optional[str] = None production_date: Optional[date] = None expiry_date: Optional[date] = None quantity: Optional[int] = None location: Optional[str] = None class BatchResponse(BatchBase): id: int medicine_id: int is_expired: bool created_at: datetime updated_at: datetime class Config: from_attributes = True class BatchDispense(BaseModel): quantity: int = Field(..., gt=0) class BatchAddStock(BaseModel): quantity: int = Field(..., gt=0) ``` ## 8. 服务层设计 ### 8.1 药品服务 ```python # app/services/medicine.py from typing import List, Optional from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.medicine import Medicine from app.models.batch import Batch from app.schemas.medicine import MedicineCreate, MedicineUpdate, MedicineWithStock from app.repositories.medicine import MedicineRepository class MedicineService: def __init__(self, db: AsyncSession): self.db = db self.repo = MedicineRepository(db) async def get_medicines( self, category_id: Optional[int] = None, search: Optional[str] = None, page: int = 1, page_size: int = 20 ) -> tuple[List[MedicineWithStock], int]: """获取药品列表""" medicines, total = await self.repo.get_list( category_id=category_id, search=search, page=page, page_size=page_size ) result = [] for medicine in medicines: # 计算库存 total_quantity = sum(b.quantity for b in medicine.batches if not b.is_expired) # 获取最近过期日期 nearest_expiry = None for batch in medicine.batches: if not batch.is_expired: if nearest_expiry is None or batch.expiry_date < nearest_expiry: nearest_expiry = batch.expiry_date medicine_with_stock = MedicineWithStock( **medicine.__dict__, total_quantity=total_quantity, nearest_expiry_date=nearest_expiry, batch_count=len([b for b in medicine.batches if not b.is_expired]) ) result.append(medicine_with_stock) return result, total async def get_medicine(self, medicine_id: int) -> Optional[Medicine]: """获取药品详情""" return await self.repo.get_by_id(medicine_id) async def create_medicine(self, data: MedicineCreate, user_id: int) -> Medicine: """创建药品""" medicine_data = data.model_dump() medicine_data['created_by'] = user_id return await self.repo.create(medicine_data) async def update_medicine(self, medicine_id: int, data: MedicineUpdate) -> Optional[Medicine]: """更新药品""" update_data = data.model_dump(exclude_unset=True) return await self.repo.update(medicine_id, update_data) async def delete_medicine(self, medicine_id: int) -> bool: """删除药品""" return await self.repo.delete(medicine_id) async def search_medicines(self, query: str) -> List[Medicine]: """搜索药品""" return await self.repo.search(query) ``` ### 8.2 批次服务 ```python # app/services/batch.py from typing import List, Optional from datetime import date, timedelta from sqlalchemy.ext.asyncio import AsyncSession from app.models.batch import Batch from app.schemas.batch import BatchCreate, BatchUpdate from app.repositories.batch import BatchRepository from app.services.audit import AuditService class BatchService: def __init__(self, db: AsyncSession): self.db = db self.repo = BatchRepository(db) self.audit_service = AuditService(db) async def get_batches_by_medicine(self, medicine_id: int) -> List[Batch]: """获取药品的所有批次""" return await self.repo.get_by_medicine_id(medicine_id) async def get_batch(self, batch_id: int) -> Optional[Batch]: """获取批次详情""" return await self.repo.get_by_id(batch_id) async def create_batch(self, medicine_id: int, data: BatchCreate) -> Batch: """创建批次""" batch_data = data.model_dump() batch_data['medicine_id'] = medicine_id return await self.repo.create(batch_data) async def update_batch(self, batch_id: int, data: BatchUpdate) -> Optional[Batch]: """更新批次""" update_data = data.model_dump(exclude_unset=True) return await self.repo.update(batch_id, update_data) async def delete_batch(self, batch_id: int) -> bool: """删除批次""" return await self.repo.delete(batch_id) async def dispense(self, batch_id: int, quantity: int, user_id: int) -> Optional[Batch]: """取药(扣减库存)""" batch = await self.repo.get_by_id(batch_id) if not batch: raise ValueError("批次不存在") if batch.quantity < quantity: raise ValueError("库存不足") # 记录审计日志 await self.audit_service.log_action( medicine_id=batch.medicine_id, batch_id=batch_id, user_id=user_id, action="dispense", quantity_change=-quantity, quantity_after=batch.quantity - quantity ) # 扣减库存 batch.quantity -= quantity await self.db.commit() return batch async def add_stock(self, batch_id: int, quantity: int, user_id: int) -> Optional[Batch]: """入库(增加库存)""" batch = await self.repo.get_by_id(batch_id) if not batch: raise ValueError("批次不存在") # 记录审计日志 await self.audit_service.log_action( medicine_id=batch.medicine_id, batch_id=batch_id, user_id=user_id, action="add_stock", quantity_change=quantity, quantity_after=batch.quantity + quantity ) # 增加库存 batch.quantity += quantity await self.db.commit() return batch async def check_expiring_batches(self, warning_days: List[int]) -> List[dict]: """检查即将过期的批次""" expiring = [] today = date.today() for days in warning_days: target_date = today + timedelta(days=days) batches = await self.repo.get_expiring_before(target_date) for batch in batches: expiring.append({ 'batch': batch, 'days_until_expiry': days }) return expiring ``` ## 9. AI Provider 设计 ### 9.1 抽象基类 ```python # app/ai/base.py from abc import ABC, abstractmethod from typing import Optional from pydantic import BaseModel class VisionResult(BaseModel): """视觉识别结果""" generic_name: Optional[str] = None brand_name: Optional[str] = None manufacturer: Optional[str] = None specification: Optional[str] = None class DateResult(BaseModel): """日期识别结果""" production_date: Optional[str] = None expiry_date: Optional[str] = None class LeafletResult(BaseModel): """说明书识别结果""" indications: str adult_dose: str child_dose: Optional[str] = None contraindications: str notes: Optional[str] = None class VisionProvider(ABC): """视觉模型提供者抽象基类""" @abstractmethod async def recognize_medicine(self, image_bytes: bytes) -> VisionResult: """识别药盒信息""" pass @abstractmethod async def recognize_dates(self, image_bytes: bytes) -> DateResult: """识别日期信息""" pass class TextProvider(ABC): """文本模型提供者抽象基类""" @abstractmethod async def summarize_leaflet(self, text: str) -> LeafletResult: """总结说明书内容""" pass @abstractmethod async def natural_language_search(self, query: str, medicines: list) -> list: """自然语言搜索""" pass ``` ### 9.2 OpenAI 实现 ```python # app/ai/openai_provider.py import base64 from openai import AsyncOpenAI from app.ai.base import VisionProvider, TextProvider, VisionResult, DateResult, LeafletResult from app.config import settings class OpenAIVisionProvider(VisionProvider): """OpenAI 视觉模型提供者""" def __init__(self): self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) self.model = settings.OPENAI_MODEL async def recognize_medicine(self, image_bytes: bytes) -> VisionResult: """识别药盒信息""" base64_image = base64.b64encode(image_bytes).decode('utf-8') response = await self.client.chat.completions.create( model=self.model, messages=[ { "role": "user", "content": [ { "type": "text", "text": """请识别这张药品包装图片中的信息,返回JSON格式: { "generic_name": "通用名称", "brand_name": "商品名称", "manufacturer": "生产厂家", "specification": "规格" } 只提取图片中真实出现的内容,不要猜测。""" }, { "type": "image_url", "image_url": { "url": f"data:image/jpeg;base64,{base64_image}" } } ] } ], response_format={"type": "json_object"} ) import json result = json.loads(response.choices[0].message.content) return VisionResult(**result) async def recognize_dates(self, image_bytes: bytes) -> DateResult: """识别日期信息""" base64_image = base64.b64encode(image_bytes).decode('utf-8') response = await self.client.chat.completions.create( model=self.model, messages=[ { "role": "user", "content": [ { "type": "text", "text": """请识别这张图片中的日期信息,返回JSON格式: { "production_date": "生产日期(YYYY-MM-DD格式,如果无法识别则为null)", "expiry_date": "有效期/过期日期(YYYY-MM-DD格式,如果无法识别则为null)" } 只提取图片中真实出现的日期,不要猜测或推理。""" }, { "type": "image_url", "image_url": { "url": f"data:image/jpeg;base64,{base64_image}" } } ] } ], response_format={"type": "json_object"} ) import json result = json.loads(response.choices[0].message.content) return DateResult(**result) class OpenAITextProvider(TextProvider): """OpenAI 文本模型提供者""" def __init__(self): self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) self.model = "gpt-4" async def summarize_leaflet(self, text: str) -> LeafletResult: """总结说明书内容""" response = await self.client.chat.completions.create( model=self.model, messages=[ { "role": "system", "content": "你是一个医疗信息提取助手。请从药品说明书中提取关键信息。" }, { "role": "user", "content": f"""请从以下药品说明书中提取关键信息,返回JSON格式: {{ "indications": "适应症", "adult_dose": "成人用法用量", "child_dose": "儿童用法用量(如果没有则为null)", "contraindications": "禁忌", "notes": "注意事项(如果有)" }} 说明书内容: {text}""" } ], response_format={"type": "json_object"} ) import json result = json.loads(response.choices[0].message.content) return LeafletResult(**result) async def natural_language_search(self, query: str, medicines: list) -> list: """自然语言搜索""" medicines_text = "\n".join([ f"- {m['name']}: {m.get('indications', '')}" for m in medicines ]) response = await self.client.chat.completions.create( model=self.model, messages=[ { "role": "system", "content": "你是一个药品搜索助手。根据用户描述的症状,从药品列表中找出可能适用的药品。" }, { "role": "user", "content": f"""用户描述:{query} 可用药品列表: {medicines_text} 请返回JSON格式的搜索结果: {{ "results": [ {{ "medicine_id": 药品ID, "name": "药品名称", "reason": "匹配原因" }} ] }}""" } ], response_format={"type": "json_object"} ) import json result = json.loads(response.choices[0].message.content) return result.get('results', []) ``` ### 9.3 Provider 管理器 ```python # app/ai/manager.py from typing import Optional from app.ai.base import VisionProvider, TextProvider class AIManager: """AI Provider 管理器""" _instance = None def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._initialized = False return cls._instance def __init__(self): if self._initialized: return self.vision_providers: dict[str, VisionProvider] = {} self.text_providers: dict[str, TextProvider] = {} self._initialized = True def register_vision_provider(self, name: str, provider: VisionProvider): """注册视觉模型提供者""" self.vision_providers[name] = provider def register_text_provider(self, name: str, provider: TextProvider): """注册文本模型提供者""" self.text_providers[name] = provider def get_vision_provider(self, name: str) -> Optional[VisionProvider]: """获取视觉模型提供者""" return self.vision_providers.get(name) def get_text_provider(self, name: str) -> Optional[TextProvider]: """获取文本模型提供者""" return self.text_providers.get(name) # 全局管理器实例 ai_manager = AIManager() ``` ## 10. 认证与授权 ### 10.1 安全工具 ```python # app/core/security.py from datetime import datetime, timedelta from typing import Optional from jose import JWTError, jwt from passlib.context import CryptContext from app.config import settings pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def verify_password(plain_password: str, hashed_password: str) -> bool: """验证密码""" return pwd_context.verify(plain_password, hashed_password) def get_password_hash(password: str) -> str: """获取密码哈希""" return pwd_context.hash(password) def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: """创建访问令牌""" to_encode = data.copy() if expires_delta: expire = datetime.utcnow() + expires_delta else: expire = datetime.utcnow() + timedelta(hours=settings.JWT_EXPIRATION_HOURS) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) return encoded_jwt def decode_access_token(token: str) -> Optional[dict]: """解码访问令牌""" try: payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) return payload except JWTError: return None ``` ### 10.2 依赖注入 ```python # app/core/deps.py from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.core.security import decode_access_token from app.services.user import UserService from app.models.user import User security = HTTPBearer() async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), db: AsyncSession = Depends(get_db) ) -> User: """获取当前用户""" token = credentials.credentials payload = decode_access_token(token) if payload is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证令牌" ) user_id = payload.get("sub") if user_id is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证令牌" ) user_service = UserService(db) user = await user_service.get_user(int(user_id)) if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在" ) if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="用户已被禁用" ) return user def require_role(roles: list[str]): """要求特定角色的依赖""" async def role_checker(current_user: User = Depends(get_current_user)): if current_user.role not in roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="权限不足" ) return current_user return role_checker ``` ## 11. 路由设计 ### 11.1 路由汇总 ```python # app/api/router.py from fastapi import APIRouter from app.api.v1 import auth, medicines, batches, categories, search, notifications, ai, users, settings api_router = APIRouter() api_router.include_router(auth.router, prefix="/v1/auth", tags=["认证"]) api_router.include_router(medicines.router, prefix="/v1/medicines", tags=["药品管理"]) api_router.include_router(batches.router, prefix="/v1/batches", tags=["批次管理"]) api_router.include_router(categories.router, prefix="/v1/categories", tags=["分类管理"]) api_router.include_router(search.router, prefix="/v1/search", tags=["搜索"]) api_router.include_router(notifications.router, prefix="/v1/notifications", tags=["通知"]) api_router.include_router(ai.router, prefix="/v1/ai", tags=["AI 识别"]) api_router.include_router(users.router, prefix="/v1/users", tags=["用户管理"]) api_router.include_router(settings.router, prefix="/v1/settings", tags=["系统设置"]) ``` ### 11.2 药品路由示例 ```python # app/api/v1/medicines.py from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.core.deps import get_current_user, require_role from app.models.user import User from app.schemas.medicine import MedicineCreate, MedicineUpdate, MedicineResponse, MedicineWithStock from app.services.medicine import MedicineService router = APIRouter() @router.get("/", response_model=dict) async def list_medicines( category_id: Optional[int] = Query(None), search: Optional[str] = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """获取药品列表""" service = MedicineService(db) medicines, total = await service.get_medicines( category_id=category_id, search=search, page=page, page_size=page_size ) return { "data": medicines, "total": total, "page": page, "page_size": page_size } @router.get("/{medicine_id}", response_model=MedicineResponse) async def get_medicine( medicine_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """获取药品详情""" service = MedicineService(db) medicine = await service.get_medicine(medicine_id) if not medicine: raise HTTPException(status_code=404, detail="药品不存在") return medicine @router.post("/", response_model=MedicineResponse) async def create_medicine( data: MedicineCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_role(["admin", "user"])) ): """创建药品""" service = MedicineService(db) medicine = await service.create_medicine(data, current_user.id) return medicine @router.put("/{medicine_id}", response_model=MedicineResponse) async def update_medicine( medicine_id: int, data: MedicineUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_role(["admin", "user"])) ): """更新药品""" service = MedicineService(db) medicine = await service.update_medicine(medicine_id, data) if not medicine: raise HTTPException(status_code=404, detail="药品不存在") return medicine @router.delete("/{medicine_id}") async def delete_medicine( medicine_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_role(["admin"])) ): """删除药品""" service = MedicineService(db) success = await service.delete_medicine(medicine_id) if not success: raise HTTPException(status_code=404, detail="药品不存在") return {"message": "删除成功"} ``` ## 12. 通知系统 ### 12.1 通知提供者 ```python # app/notifications/base.py from abc import ABC, abstractmethod class NotificationProvider(ABC): """通知提供者抽象基类""" @abstractmethod async def send(self, title: str, content: str) -> bool: """发送通知""" pass @abstractmethod def validate_config(self) -> bool: """验证配置""" pass ``` ### 12.2 Server酱实现 ```python # app/notifications/serverchan.py import httpx from app.notifications.base import NotificationProvider from app.config import settings class ServerChanProvider(NotificationProvider): """Server酱通知提供者""" def __init__(self): self.key = settings.SERVERCHAN_KEY def validate_config(self) -> bool: return bool(self.key) async def send(self, title: str, content: str) -> bool: """发送通知""" if not self.validate_config(): return False url = f"https://sctapi.ftqq.com/{self.key}.send" data = { "title": title, "desp": content } async with httpx.AsyncClient() as client: response = await client.post(url, data=data) return response.status_code == 200 ``` ## 13. 异步任务 ### 13.1 到期检查任务 ```python # app/tasks/expiry_check.py from apscheduler.schedulers.asyncio import AsyncIOScheduler from sqlalchemy import select from datetime import date, timedelta from app.database import async_session_factory from app.models.batch import Batch from app.models.medicine import Medicine from app.services.notification import NotificationService from app.config import settings scheduler = AsyncIOScheduler() async def check_expiring_medicines(): """检查即将过期的药品""" async with async_session_factory() as db: today = date.today() for days in settings.EXPIRY_WARNING_DAYS: target_date = today + timedelta(days=days) # 查询即将过期的批次 query = select(Batch, Medicine).join( Medicine, Batch.medicine_id == Medicine.id ).where( Batch.expiry_date <= target_date, Batch.is_expired == False, Batch.quantity > 0 ) result = await db.execute(query) batches = result.all() if batches: notification_service = NotificationService() title = f"药品过期提醒 ({days}天内)" content = "以下药品即将过期,请及时处理:\n\n" for batch, medicine in batches: content += f"- {medicine.name}: {batch.batch_no or '默认批次'} " content += f"(过期日期: {batch.expiry_date})\n" await notification_service.send_notification(title, content) def start_expiry_check_task(): """启动到期检查任务""" scheduler.add_job( check_expiring_medicines, 'cron', hour=9, minute=0, id='expiry_check' ) scheduler.start() ```