1440 lines
44 KiB
Markdown
1440 lines
44 KiB
Markdown
# 后端开发文档
|
|
|
|
## 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()
|
|
```
|