Files
2026-06-15 14:50:15 +08:00

44 KiB

后端开发文档

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. 核心依赖

# 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. 应用入口

# 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. 配置管理

# 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. 数据库配置

# 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 用户模型

# 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 药品模型

# 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 批次模型

# 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 分类模型

# 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 审计日志模型

# 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

# 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

# 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

# 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 药品服务

# 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 批次服务

# 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 抽象基类

# 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 实现

# 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 管理器

# 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 安全工具

# 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 依赖注入

# 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 路由汇总

# 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 药品路由示例

# 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 通知提供者

# 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酱实现

# 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 到期检查任务

# 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()