首次提交by MimoCode

This commit is contained in:
tang1219
2026-06-15 14:50:15 +08:00
parent 584b31da50
commit ead13f863c
166 changed files with 14908 additions and 1 deletions
+53
View File
@@ -0,0 +1,53 @@
# 数据库配置
DATABASE_URL=sqlite+aiosqlite:///./data/yaoxiang.db
# 安全配置
JWT_SECRET_KEY=your-secret-key-change-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRATION_HOURS=24
# AI Provider 配置
AI_PROVIDER=openai
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o
GEMINI_API_KEY=
GEMINI_MODEL=gemini-pro-vision
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-3-opus-20240229
DEEPSEEK_API_KEY=
DEEPSEEK_MODEL=deepseek-chat
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=llava
# 通知配置
NOTIFICATION_PROVIDERS=[]
SERVERCHAN_KEY=
PUSHPLUS_TOKEN=
BARK_URL=
WECHAT_WEBHOOK_URL=
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# 文件存储配置
UPLOAD_DIR=./data/uploads
MAX_UPLOAD_SIZE=10485760
# 到期提醒配置
EXPIRY_WARNING_DAYS=90,30,7
# 低库存阈值
LOW_STOCK_THRESHOLD=5
# CORS 配置
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# 宽限天数最大值
EXPIRY_GRACE_DAYS_MAX=60
# 调试模式
DEBUG=true
+48
View File
@@ -0,0 +1,48 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.env
data/
.mypy_cache/
.pytest_cache/
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
+219
View File
@@ -0,0 +1,219 @@
# 药箱后端修改记录
## 版本历史
### v1.0.0 (2026-06-15)
#### 新增功能
**核心框架**
- 创建 FastAPI 应用入口 (`app/main.py`)
- 实现配置管理系统 (`app/config.py`)
- 实现数据库连接和会话管理 (`app/database.py`)
- 实现异常处理器 (`app/core/exceptions.py`)
**数据模型**
- 用户模型 (`app/models/user.py`)
- 药品模型 (`app/models/medicine.py`)
- 批次模型 (`app/models/batch.py`)
- 分类模型 (`app/models/category.py`)
- 审计日志模型 (`app/models/audit_log.py`)
- 通知模型 (`app/models/notification.py`)
- 设置模型 (`app/models/setting.py`)
**Pydantic 模式**
- 用户请求/响应模式 (`app/schemas/user.py`)
- 药品请求/响应模式 (`app/schemas/medicine.py`)
- 批次请求/响应模式 (`app/schemas/batch.py`)
- 分类请求/响应模式 (`app/schemas/category.py`)
- 认证请求模式 (`app/schemas/auth.py`)
**数据访问层**
- 用户仓库 (`app/repositories/user.py`)
- 药品仓库 (`app/repositories/medicine.py`)
- 批次仓库 (`app/repositories/batch.py`)
- 分类仓库 (`app/repositories/category.py`)
- 审计日志仓库 (`app/repositories/audit_log.py`)
- 通知仓库 (`app/repositories/notification.py`)
**服务层**
- 认证服务 (`app/services/auth.py`)
- 用户服务 (`app/services/user.py`)
- 药品服务 (`app/services/medicine.py`)
- 批次服务 (`app/services/batch.py`)
- 分类服务 (`app/services/category.py`)
- 审计日志服务 (`app/services/audit.py`)
- 通知服务 (`app/services/notification.py`)
**API 接口**
- 认证接口 (`app/api/v1/auth.py`)
- POST /api/v1/auth/login - 用户登录
- GET /api/v1/auth/me - 获取当前用户信息
- PUT /api/v1/auth/password - 修改密码
- 药品管理接口 (`app/api/v1/medicines.py`)
- GET /api/v1/medicines - 获取药品列表
- GET /api/v1/medicines/{id} - 获取药品详情
- POST /api/v1/medicines - 创建药品
- PUT /api/v1/medicines/{id} - 更新药品
- DELETE /api/v1/medicines/{id} - 删除药品
- 批次管理接口 (`app/api/v1/batches.py`)
- GET /api/v1/batches/medicine/{medicine_id} - 获取药品的所有批次
- GET /api/v1/batches/{id} - 获取批次详情
- POST /api/v1/batches/medicine/{medicine_id} - 创建批次
- PUT /api/v1/batches/{id} - 更新批次
- DELETE /api/v1/batches/{id} - 删除批次
- POST /api/v1/batches/{id}/dispense - 取药
- POST /api/v1/batches/{id}/add-stock - 入库
- 分类管理接口 (`app/api/v1/categories.py`)
- GET /api/v1/categories - 获取分类列表
- GET /api/v1/categories/tree - 获取分类树
- GET /api/v1/categories/{id} - 获取分类详情
- POST /api/v1/categories - 创建分类
- PUT /api/v1/categories/{id} - 更新分类
- DELETE /api/v1/categories/{id} - 删除分类
- 搜索接口 (`app/api/v1/search.py`)
- GET /api/v1/search - 关键词搜索
- POST /api/v1/search/natural - 自然语言搜索
- AI 识别接口 (`app/api/v1/ai.py`)
- POST /api/v1/ai/recognize-medicine - 识别药盒
- POST /api/v1/ai/recognize-dates - 识别日期
- POST /api/v1/ai/recognize-leaflet - 识别说明书
- 用户管理接口 (`app/api/v1/users.py`)
- GET /api/v1/users - 获取用户列表
- GET /api/v1/users/{id} - 获取用户详情
- POST /api/v1/users - 创建用户
- PUT /api/v1/users/{id} - 更新用户
- DELETE /api/v1/users/{id} - 删除用户
- POST /api/v1/users/{id}/reset-password - 重置密码
- 通知接口 (`app/api/v1/notifications.py`)
- GET /api/v1/notifications - 获取通知列表
- PUT /api/v1/notifications/{id}/read - 标记为已读
- PUT /api/v1/notifications/read-all - 全部标记为已读
- DELETE /api/v1/notifications/{id} - 删除通知
- 系统设置接口 (`app/api/v1/settings.py`)
- GET /api/v1/settings - 获取所有设置
- GET /api/v1/settings/{key} - 获取单个设置
- PUT /api/v1/settings/{key} - 更新设置
**AI Provider 模块**
- 抽象基类 (`app/ai/base.py`)
- OpenAI Provider 实现 (`app/ai/openai_provider.py`)
- Provider 管理器 (`app/ai/manager.py`)
**通知系统**
- 抽象基类 (`app/notifications/base.py`)
- Server酱 Provider (`app/notifications/serverchan.py`)
- PushPlus Provider (`app/notifications/pushplus.py`)
- 通知管理器 (`app/notifications/manager.py`)
**文件存储**
- 抽象基类 (`app/storage/base.py`)
- 本地存储实现 (`app/storage/local.py`)
- 存储管理器 (`app/storage/manager.py`)
**安全模块**
- JWT 和密码工具 (`app/core/security.py`)
- 依赖注入 (`app/core/deps.py`)
**定时任务**
- 到期检查任务 (`app/tasks/expiry_check.py`)
**项目配置**
- requirements.txt - Python 依赖
- .env.example - 环境变量示例
- .gitignore - Git 忽略文件
- pytest.ini - 测试配置
- tests/conftest.py - 测试配置
**文档**
- DEVELOPMENT.md - 开发文档
- CHANGELOG.md - 修改记录
---
## 待开发功能
### 计划中
- [ ] 文件上传接口
- [ ] 审计日志查询接口
- [ ] Gemini Provider 实现
- [ ] Claude Provider 实现
- [ ] DeepSeek Provider 实现
- [ ] Ollama Provider 实现
- [ ] Bark 通知 Provider
- [ ] 企业微信通知 Provider
- [ ] Telegram 通知 Provider
- [ ] 邮件通知 Provider
- [ ] 数据库迁移支持 (Alembic)
- [ ] 单元测试用例
- [ ] 集成测试用例
### 已知问题
- 暂无
---
## 更新说明
### 2026-06-15
**初始版本发布**
完成药箱后端系统的初始开发,包括:
1. **核心功能实现**
- 用户认证和授权 (JWT)
- 药品 CRUD 操作
- 批次管理(含取药、入库)
- 分类管理(支持二级分类)
- 搜索功能(关键词搜索)
- 通知系统框架
- AI 识别接口框架
2. **架构设计**
- 采用分层架构:路由层 → 服务层 → 数据访问层
- 使用 SQLAlchemy 2.0 异步 ORM
- 支持多种 AI Provider 扩展
- 支持多种通知渠道扩展
3. **代码规范**
- 遵循 PEP 8 编码规范
- 使用 Pydantic 进行数据验证
- 完整的类型注解
- 清晰的模块划分
4. **安全特性**
- 密码 bcrypt 加密
- JWT Token 认证
- 基于角色的访问控制
- 输入验证和过滤
---
## 贡献指南
如需提交修改,请遵循以下规范:
1. 代码风格遵循 PEP 8
2. 提交信息使用中文
3. 新增功能请添加相应的测试
4. 修改记录请更新此文档
---
## 联系方式
如有问题或建议,请通过以下方式联系:
- 项目地址: https://github.com/your-username/yaoxiang
- 问题反馈: https://github.com/your-username/yaoxiang/issues
+540
View File
@@ -0,0 +1,540 @@
# 药箱后端开发文档
## 1. 项目概述
药箱(YaoXiang)是一个家庭药品与应急物资管理系统,后端采用 FastAPI 框架,提供 RESTful API 服务。
## 2. 技术栈
| 技术 | 版本 | 说明 |
|------|------|------|
| Python | 3.11+ | 运行环境 |
| FastAPI | 0.104.1 | Web 框架 |
| SQLAlchemy | 2.0.23 | ORM 框架 |
| Alembic | 1.13.0 | 数据库迁移 |
| SQLite | - | 数据库 |
| Pydantic | 2.5.2 | 数据验证 |
| python-jose | 3.3.0 | JWT 认证 |
| passlib | 1.7.4 | 密码加密 |
| OpenAI | 1.6.1 | AI 服务 |
## 3. 项目结构
```
backend/
├── app/ # 主应用目录
│ ├── __init__.py
│ ├── main.py # FastAPI 应用入口
│ ├── config.py # 配置管理
│ ├── database.py # 数据库连接
│ │
│ ├── api/ # API 路由层
│ │ ├── router.py # 路由汇总
│ │ └── v1/ # API 版本 1
│ │ ├── auth.py # 认证接口
│ │ ├── medicines.py # 药品管理接口
│ │ ├── batches.py # 批次管理接口
│ │ ├── categories.py # 分类管理接口
│ │ ├── search.py # 搜索接口
│ │ ├── notifications.py # 通知接口
│ │ ├── ai.py # AI 识别接口
│ │ ├── users.py # 用户管理接口
│ │ └── settings.py # 系统设置接口
│ │
│ ├── models/ # SQLAlchemy 数据模型
│ │ ├── user.py # 用户模型
│ │ ├── medicine.py # 药品模型
│ │ ├── batch.py # 批次模型
│ │ ├── category.py # 分类模型
│ │ ├── audit_log.py # 审计日志模型
│ │ ├── notification.py # 通知模型
│ │ └── setting.py # 设置模型
│ │
│ ├── schemas/ # Pydantic 数据模式
│ │ ├── user.py
│ │ ├── medicine.py
│ │ ├── batch.py
│ │ ├── category.py
│ │ └── auth.py
│ │
│ ├── services/ # 业务服务层
│ │ ├── auth.py # 认证服务
│ │ ├── user.py # 用户服务
│ │ ├── medicine.py # 药品服务
│ │ ├── batch.py # 批次服务
│ │ ├── category.py # 分类服务
│ │ ├── audit.py # 审计日志服务
│ │ └── notification.py # 通知服务
│ │
│ ├── repositories/ # 数据访问层
│ │ ├── user.py
│ │ ├── medicine.py
│ │ ├── batch.py
│ │ ├── category.py
│ │ ├── audit_log.py
│ │ └── notification.py
│ │
│ ├── ai/ # AI Provider 模块
│ │ ├── base.py # 抽象基类
│ │ ├── openai_provider.py # OpenAI 实现
│ │ └── manager.py # Provider 管理器
│ │
│ ├── notifications/ # 通知系统
│ │ ├── base.py # 抽象基类
│ │ ├── serverchan.py # Server酱
│ │ ├── pushplus.py # PushPlus
│ │ └── manager.py # 通知管理器
│ │
│ ├── storage/ # 文件存储
│ │ ├── base.py # 抽象基类
│ │ ├── local.py # 本地存储
│ │ └── manager.py # 存储管理器
│ │
│ ├── core/ # 核心功能
│ │ ├── security.py # 安全工具 (JWT, 密码)
│ │ ├── deps.py # 依赖注入
│ │ └── exceptions.py # 异常处理
│ │
│ └── tasks/ # 异步任务
│ └── expiry_check.py # 到期检查任务
├── tests/ # 测试目录
├── data/ # 数据目录
├── requirements.txt # Python 依赖
├── .env.example # 环境变量示例
└── .env # 环境变量配置
```
## 4. 快速开始
### 4.1 环境准备
```bash
# 进入后端目录
cd backend
# 创建虚拟环境 (可选)
python -m venv venv
venv\Scripts\activate # Windows
# source venv/bin/activate # Linux/Mac
# 安装依赖
pip install -r requirements.txt
```
### 4.2 配置环境变量
```bash
# 复制环境变量示例文件
cp .env.example .env
# 编辑 .env 文件,配置必要的参数
```
### 4.3 启动服务
```bash
# 开发模式启动
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# 生产模式启动
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
```
### 4.4 访问 API 文档
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
- 健康检查: http://localhost:8000/health
## 5. 配置说明
### 5.1 环境变量配置
| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| DATABASE_URL | sqlite+aiosqlite:///./data/yaoxiang.db | 数据库连接字符串 |
| JWT_SECRET_KEY | your-secret-key-change-in-production | JWT 密钥 |
| JWT_ALGORITHM | HS256 | JWT 算法 |
| JWT_EXPIRATION_HOURS | 24 | JWT 过期时间(小时) |
| AI_PROVIDER | openai | AI 服务提供者 |
| OPENAI_API_KEY | - | OpenAI API Key |
| OPENAI_MODEL | gpt-4o | OpenAI 模型 |
| UPLOAD_DIR | ./data/uploads | 文件上传目录 |
| CORS_ORIGINS | http://localhost:5173 | CORS 允许的源 |
### 5.2 AI Provider 配置
支持的 AI Provider
- **openai**: OpenAI GPT-4o
- **gemini**: Google Gemini
- **claude**: Anthropic Claude
- **deepseek**: DeepSeek
- **ollama**: 本地 Ollama
## 6. 数据库设计
### 6.1 数据表
| 表名 | 说明 |
|------|------|
| users | 用户表 |
| categories | 分类表 |
| medicines | 药品表 |
| batches | 批次表 |
| audit_logs | 审计日志表 |
| notifications | 通知表 |
| settings | 系统设置表 |
### 6.2 核心表结构
#### users 表
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) DEFAULT 'user',
display_name VARCHAR(100),
email VARCHAR(100),
notification_level VARCHAR(20) DEFAULT 'normal',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
```
#### medicines 表
```sql
CREATE TABLE medicines (
id INTEGER PRIMARY KEY,
name VARCHAR(200) NOT NULL,
generic_name VARCHAR(200),
brand_name VARCHAR(200),
manufacturer VARCHAR(200),
specification VARCHAR(200),
category_id INTEGER REFERENCES categories(id),
indications TEXT,
adult_dose TEXT,
child_dose TEXT,
contraindications TEXT,
notes TEXT,
image_front_path VARCHAR(500),
image_expiry_path VARCHAR(500),
image_leaflet_paths JSON,
expiry_grace_days INTEGER DEFAULT 0,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP,
updated_at TIMESTAMP
);
```
#### batches 表
```sql
CREATE TABLE batches (
id INTEGER PRIMARY KEY,
medicine_id INTEGER REFERENCES medicines(id),
batch_no VARCHAR(100),
production_date DATE,
expiry_date DATE NOT NULL,
quantity INTEGER DEFAULT 0,
location VARCHAR(200),
is_expired BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
```
## 7. API 接口
### 7.1 认证接口
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/v1/auth/login | 用户登录 |
| GET | /api/v1/auth/me | 获取当前用户信息 |
| PUT | /api/v1/auth/password | 修改密码 |
### 7.2 药品管理接口
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| GET | /api/v1/medicines | 获取药品列表 | 登录用户 |
| GET | /api/v1/medicines/{id} | 获取药品详情 | 登录用户 |
| POST | /api/v1/medicines | 创建药品 | admin/user |
| PUT | /api/v1/medicines/{id} | 更新药品 | admin/user |
| DELETE | /api/v1/medicines/{id} | 删除药品 | admin |
### 7.3 批次管理接口
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| GET | /api/v1/batches/medicine/{medicine_id} | 获取药品的所有批次 | 登录用户 |
| GET | /api/v1/batches/{id} | 获取批次详情 | 登录用户 |
| POST | /api/v1/batches/medicine/{medicine_id} | 创建批次 | admin/user |
| PUT | /api/v1/batches/{id} | 更新批次 | admin/user |
| DELETE | /api/v1/batches/{id} | 删除批次 | admin |
| POST | /api/v1/batches/{id}/dispense | 取药 | admin/user |
| POST | /api/v1/batches/{id}/add-stock | 入库 | admin/user |
### 7.4 分类管理接口
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| GET | /api/v1/categories | 获取分类列表 | 登录用户 |
| GET | /api/v1/categories/tree | 获取分类树 | 登录用户 |
| GET | /api/v1/categories/{id} | 获取分类详情 | 登录用户 |
| POST | /api/v1/categories | 创建分类 | admin |
| PUT | /api/v1/categories/{id} | 更新分类 | admin |
| DELETE | /api/v1/categories/{id} | 删除分类 | admin |
### 7.5 搜索接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/v1/search?q=关键词 | 关键词搜索 |
| POST | /api/v1/search/natural | 自然语言搜索 |
### 7.6 AI 识别接口
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| POST | /api/v1/ai/recognize-medicine | 识别药盒 | admin/user |
| POST | /api/v1/ai/recognize-dates | 识别日期 | admin/user |
| POST | /api/v1/ai/recognize-leaflet | 识别说明书 | admin/user |
### 7.7 用户管理接口
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| GET | /api/v1/users | 获取用户列表 | admin |
| GET | /api/v1/users/{id} | 获取用户详情 | admin |
| POST | /api/v1/users | 创建用户 | admin |
| PUT | /api/v1/users/{id} | 更新用户 | admin |
| DELETE | /api/v1/users/{id} | 删除用户 | admin |
| POST | /api/v1/users/{id}/reset-password | 重置密码 | admin |
### 7.8 通知接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/v1/notifications | 获取通知列表 |
| PUT | /api/v1/notifications/{id}/read | 标记为已读 |
| PUT | /api/v1/notifications/read-all | 全部标记为已读 |
| DELETE | /api/v1/notifications/{id} | 删除通知 |
### 7.9 系统设置接口
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| GET | /api/v1/settings | 获取所有设置 | admin |
| GET | /api/v1/settings/{key} | 获取单个设置 | admin |
| PUT | /api/v1/settings/{key} | 更新设置 | admin |
## 8. 开发指南
### 8.1 添加新的 API 接口
1.`app/api/v1/` 目录下创建或修改路由文件
2. 定义请求和响应的 Pydantic 模式
3. 实现业务逻辑(服务层)
4.`app/api/router.py` 中注册路由
示例:
```python
# app/api/v1/example.py
from fastapi import APIRouter, Depends
from app.core.deps import get_current_user
router = APIRouter()
@router.get("/")
async def list_examples(current_user = Depends(get_current_user)):
return {"message": "success"}
```
### 8.2 添加新的数据模型
1.`app/models/` 目录下创建模型文件
2. 继承 `Base`
3. 定义表名和字段
4.`app/models/__init__.py` 中导入
```python
# app/models/example.py
from sqlalchemy import Column, Integer, String
from app.database import Base
class Example(Base):
__tablename__ = "examples"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
```
### 8.3 添加新的服务
1.`app/services/` 目录下创建服务文件
2. 实现业务逻辑
3. 使用 Repository 进行数据访问
```python
# app/services/example.py
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories.example import ExampleRepository
class ExampleService:
def __init__(self, db: AsyncSession):
self.db = db
self.repo = ExampleRepository(db)
async def get_example(self, example_id: int):
return await self.repo.get_by_id(example_id)
```
### 8.4 添加新的 AI Provider
1.`app/ai/` 目录下创建 Provider 文件
2. 继承 `VisionProvider``TextProvider`
3. 实现抽象方法
4.`app/ai/manager.py` 中注册
```python
# app/ai/custom_provider.py
from app.ai.base import VisionProvider, VisionResult
class CustomVisionProvider(VisionProvider):
async def recognize_medicine(self, image_bytes: bytes) -> VisionResult:
# 实现识别逻辑
return VisionResult(generic_name="药品名称")
async def recognize_dates(self, image_bytes: bytes):
# 实现日期识别
pass
```
### 8.5 添加新的通知 Provider
1.`app/notifications/` 目录下创建 Provider 文件
2. 继承 `NotificationProvider`
3. 实现 `send``validate_config` 方法
```python
# app/notifications/custom.py
from app.notifications.base import NotificationProvider
class CustomNotificationProvider(NotificationProvider):
def __init__(self):
self.config = "your-config"
def validate_config(self) -> bool:
return bool(self.config)
async def send(self, title: str, content: str) -> bool:
# 实现发送逻辑
return True
```
## 9. 测试
### 9.1 运行测试
```bash
# 运行所有测试
pytest
# 运行特定测试
pytest tests/test_auth.py
# 运行带详细输出的测试
pytest -v
```
### 9.2 编写测试
```python
# tests/test_example.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_health_check(client: AsyncClient):
response = await client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
```
## 10. 部署
### 10.1 Docker 部署
```dockerfile
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
### 10.2 生产环境配置
```bash
# 设置环境变量
export DATABASE_URL=sqlite+aiosqlite:///./data/yaoxiang.db
export JWT_SECRET_KEY=your-production-secret-key
export DEBUG=false
# 启动服务
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
```
## 11. 常见问题
### 11.1 数据库初始化失败
检查 `data/` 目录是否存在,确保有写入权限。
### 11.2 AI 服务连接失败
检查 `.env` 文件中的 API Key 配置是否正确。
### 11.3 文件上传失败
检查 `UPLOAD_DIR` 配置的目录是否存在,确保有写入权限。
## 12. 扩展开发
### 12.1 添加新的通知渠道
参考 `app/notifications/serverchan.py` 实现新的通知 Provider。
### 12.2 添加新的 AI 能力
1. 扩展 `app/ai/base.py` 中的抽象基类
2. 实现新的 Provider
3.`app/ai/manager.py` 中注册
### 12.3 添加新的定时任务
使用 APScheduler 添加定时任务:
```python
from apscheduler.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler()
async def my_task():
# 任务逻辑
pass
scheduler.add_job(my_task, 'cron', hour=9, minute=0)
scheduler.start()
```
View File
View File
+43
View File
@@ -0,0 +1,43 @@
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
+34
View File
@@ -0,0 +1,34 @@
from typing import Optional
from app.ai.base import VisionProvider, TextProvider
class AIManager:
_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()
+153
View File
@@ -0,0 +1,153 @@
import base64
import json
from openai import AsyncOpenAI
from app.ai.base import VisionProvider, TextProvider, VisionResult, DateResult, LeafletResult
from app.config import settings
class OpenAIVisionProvider(VisionProvider):
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"}
)
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"}
)
result = json.loads(response.choices[0].message.content)
return DateResult(**result)
class OpenAITextProvider(TextProvider):
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"}
)
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"}
)
result = json.loads(response.choices[0].message.content)
return result.get('results', [])
View File
+14
View File
@@ -0,0 +1,14 @@
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=["系统设置"])
View File
+115
View File
@@ -0,0 +1,115 @@
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
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.config import settings
router = APIRouter()
@router.post("/recognize-medicine")
async def recognize_medicine(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin", "user"]))
):
from app.ai.manager import ai_manager
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="请上传图片文件")
image_bytes = await file.read()
if len(image_bytes) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(status_code=413, detail="文件过大")
vision_provider = ai_manager.get_vision_provider(settings.AI_PROVIDER)
if not vision_provider:
raise HTTPException(status_code=500, detail="AI 服务未配置")
try:
result = await vision_provider.recognize_medicine(image_bytes)
return result.model_dump()
except Exception as e:
raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")
@router.post("/recognize-dates")
async def recognize_dates(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin", "user"]))
):
from app.ai.manager import ai_manager
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="请上传图片文件")
image_bytes = await file.read()
if len(image_bytes) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(status_code=413, detail="文件过大")
vision_provider = ai_manager.get_vision_provider(settings.AI_PROVIDER)
if not vision_provider:
raise HTTPException(status_code=500, detail="AI 服务未配置")
try:
result = await vision_provider.recognize_dates(image_bytes)
return result.model_dump()
except Exception as e:
raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")
@router.post("/recognize-leaflet")
async def recognize_leaflet(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin", "user"]))
):
from app.ai.manager import ai_manager
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="请上传图片文件")
image_bytes = await file.read()
if len(image_bytes) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(status_code=413, detail="文件过大")
text_provider = ai_manager.get_text_provider(settings.AI_PROVIDER)
if not text_provider:
raise HTTPException(status_code=500, detail="AI 服务未配置")
try:
import base64
base64_image = base64.b64encode(image_bytes).decode('utf-8')
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
response = await client.chat.completions.create(
model=settings.OPENAI_MODEL,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": "请识别这张说明书图片中的文字内容,返回纯文本。"
},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
}
}
]
}
]
)
ocr_text = response.choices[0].message.content
result = await text_provider.summarize_leaflet(ocr_text)
return result.model_dump()
except Exception as e:
raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")
+42
View File
@@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.core.deps import get_current_user
from app.services.auth import AuthService
from app.schemas.user import UserLogin, Token, UserResponse
from app.schemas.auth import PasswordChangeRequest
from app.services.user import UserService
router = APIRouter()
@router.post("/login", response_model=Token)
async def login(data: UserLogin, db: AsyncSession = Depends(get_db)):
service = AuthService(db)
result = await service.login(data.username, data.password)
if not result:
raise HTTPException(status_code=401, detail="用户名或密码错误")
return Token(
access_token=result["access_token"],
token_type=result["token_type"],
user=UserResponse.model_validate(result["user"])
)
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user=Depends(get_current_user)):
return UserResponse.model_validate(current_user)
@router.put("/password")
async def change_password(
data: PasswordChangeRequest,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
service = UserService(db)
success = await service.change_password(current_user.id, data.old_password, data.new_password)
if not success:
raise HTTPException(status_code=400, detail="原密码错误")
return {"message": "密码修改成功"}
+104
View File
@@ -0,0 +1,104 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException
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.batch import BatchCreate, BatchUpdate, BatchResponse, BatchDispense, BatchAddStock
from app.services.batch import BatchService
router = APIRouter()
@router.get("/medicine/{medicine_id}", response_model=List[BatchResponse])
async def list_batches_by_medicine(
medicine_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
service = BatchService(db)
batches = await service.get_batches_by_medicine(medicine_id)
return [BatchResponse.model_validate(b) for b in batches]
@router.get("/{batch_id}", response_model=BatchResponse)
async def get_batch(
batch_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
service = BatchService(db)
batch = await service.get_batch(batch_id)
if not batch:
raise HTTPException(status_code=404, detail="批次不存在")
return BatchResponse.model_validate(batch)
@router.post("/medicine/{medicine_id}", response_model=BatchResponse)
async def create_batch(
medicine_id: int,
data: BatchCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin", "user"]))
):
service = BatchService(db)
batch = await service.create_batch(medicine_id, data)
return BatchResponse.model_validate(batch)
@router.put("/{batch_id}", response_model=BatchResponse)
async def update_batch(
batch_id: int,
data: BatchUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin", "user"]))
):
service = BatchService(db)
batch = await service.update_batch(batch_id, data)
if not batch:
raise HTTPException(status_code=404, detail="批次不存在")
return BatchResponse.model_validate(batch)
@router.delete("/{batch_id}")
async def delete_batch(
batch_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
service = BatchService(db)
success = await service.delete_batch(batch_id)
if not success:
raise HTTPException(status_code=404, detail="批次不存在")
return {"message": "删除成功"}
@router.post("/{batch_id}/dispense", response_model=BatchResponse)
async def dispense_batch(
batch_id: int,
data: BatchDispense,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin", "user"]))
):
service = BatchService(db)
try:
batch = await service.dispense(batch_id, data.quantity, current_user.id)
return BatchResponse.model_validate(batch)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{batch_id}/add-stock", response_model=BatchResponse)
async def add_stock_batch(
batch_id: int,
data: BatchAddStock,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin", "user"]))
):
service = BatchService(db)
try:
batch = await service.add_stock(batch_id, data.quantity, current_user.id)
return BatchResponse.model_validate(batch)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
+83
View File
@@ -0,0 +1,83 @@
from typing import Optional, List
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.category import CategoryCreate, CategoryUpdate, CategoryResponse
from app.services.category import CategoryService
router = APIRouter()
@router.get("/")
async def list_categories(
level: Optional[int] = Query(None),
parent_id: Optional[int] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
service = CategoryService(db)
categories = await service.get_categories(level=level, parent_id=parent_id)
return [CategoryResponse.model_validate(c) for c in categories]
@router.get("/tree")
async def get_category_tree(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
service = CategoryService(db)
return await service.get_category_tree()
@router.get("/{category_id}", response_model=CategoryResponse)
async def get_category(
category_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
service = CategoryService(db)
category = await service.get_category(category_id)
if not category:
raise HTTPException(status_code=404, detail="分类不存在")
return CategoryResponse.model_validate(category)
@router.post("/", response_model=CategoryResponse)
async def create_category(
data: CategoryCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
service = CategoryService(db)
category = await service.create_category(data.model_dump())
return CategoryResponse.model_validate(category)
@router.put("/{category_id}", response_model=CategoryResponse)
async def update_category(
category_id: int,
data: CategoryUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
service = CategoryService(db)
category = await service.update_category(category_id, data.model_dump(exclude_unset=True))
if not category:
raise HTTPException(status_code=404, detail="分类不存在")
return CategoryResponse.model_validate(category)
@router.delete("/{category_id}")
async def delete_category(
category_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
service = CategoryService(db)
success = await service.delete_category(category_id)
if not success:
raise HTTPException(status_code=404, detail="分类不存在")
return {"message": "删除成功"}
+86
View File
@@ -0,0 +1,86 @@
from typing import 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("/")
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": [MedicineWithStock.model_validate(m) for m in 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 MedicineResponse.model_validate(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 MedicineResponse.model_validate(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 MedicineResponse.model_validate(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": "删除成功"}
+77
View File
@@ -0,0 +1,77 @@
from typing import 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
from app.models.user import User
from app.services.notification import NotificationService
router = APIRouter()
@router.get("/")
async def list_notifications(
is_read: Optional[bool] = Query(None),
type: 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 = NotificationService(db)
notifications = await service.get_notifications(
user_id=current_user.id,
is_read=is_read,
type=type,
page=page,
page_size=page_size
)
return [
{
"id": n.id,
"type": n.type,
"title": n.title,
"content": n.content,
"is_read": n.is_read,
"related_id": n.related_id,
"created_at": n.created_at
}
for n in notifications
]
@router.put("/{notification_id}/read")
async def mark_notification_read(
notification_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
service = NotificationService(db)
success = await service.mark_as_read(notification_id)
if not success:
raise HTTPException(status_code=404, detail="通知不存在")
return {"message": "success"}
@router.put("/read-all")
async def mark_all_notifications_read(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
service = NotificationService(db)
count = await service.mark_all_as_read(current_user.id)
return {"message": "success", "count": count}
@router.delete("/{notification_id}")
async def delete_notification(
notification_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
service = NotificationService(db)
success = await service.delete_notification(notification_id)
if not success:
raise HTTPException(status_code=404, detail="通知不存在")
return {"message": "删除成功"}
+66
View File
@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.services.medicine import MedicineService
router = APIRouter()
class NaturalSearchRequest(BaseModel):
query: str
@router.get("/")
async def search_medicines(
q: str = Query(...),
type: str = Query("name"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
service = MedicineService(db)
medicines = await service.search_medicines(q)
return [
{
"id": m.id,
"name": m.name,
"generic_name": m.generic_name,
"indications": m.indications,
"total_quantity": sum(b.quantity for b in m.batches if not b.is_expired)
}
for m in medicines
]
@router.post("/natural")
async def natural_language_search(
data: NaturalSearchRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
from app.ai.manager import ai_manager
from app.config import settings
service = MedicineService(db)
medicines = await service.search_medicines(data.query)
if not medicines:
return {"results": [], "ai_response": "未找到相关药品"}
text_provider = ai_manager.get_text_provider(settings.AI_PROVIDER)
if not text_provider:
return {"results": [], "ai_response": "AI 服务未配置"}
medicines_data = [
{"name": m.name, "indications": m.indications or ""}
for m in medicines
]
try:
results = await text_provider.natural_language_search(data.query, medicines_data)
return {"results": results, "ai_response": "搜索完成"}
except Exception as e:
return {"results": [], "ai_response": f"搜索失败: {str(e)}"}
+82
View File
@@ -0,0 +1,82 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from app.database import get_db
from app.core.deps import require_role
from app.models.user import User
from app.models.setting import Setting
router = APIRouter()
class SettingResponse(BaseModel):
key: str
value: Optional[str] = None
description: Optional[str] = None
class SettingUpdate(BaseModel):
value: str
class BulkSettingUpdate(BaseModel):
settings: List[SettingUpdate]
@router.get("/")
async def list_settings(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
result = await db.execute(select(Setting))
settings = list(result.scalars().all())
return [
{
"key": s.key,
"value": s.value,
"description": s.description
}
for s in settings
]
@router.get("/{key}", response_model=SettingResponse)
async def get_setting(
key: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
result = await db.execute(select(Setting).where(Setting.key == key))
setting = result.scalar_one_or_none()
if not setting:
raise HTTPException(status_code=404, detail="设置不存在")
return SettingResponse(
key=setting.key,
value=setting.value,
description=setting.description
)
@router.put("/{key}", response_model=SettingResponse)
async def update_setting(
key: str,
data: SettingUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
result = await db.execute(select(Setting).where(Setting.key == key))
setting = result.scalar_one_or_none()
if not setting:
setting = Setting(key=key, value=data.value)
db.add(setting)
else:
setting.value = data.value
await db.commit()
return SettingResponse(
key=setting.key,
value=setting.value,
description=setting.description
)
+94
View File
@@ -0,0 +1,94 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.database import get_db
from app.core.deps import require_role
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate, UserResponse
from app.services.user import UserService
router = APIRouter()
class ResetPasswordRequest(BaseModel):
new_password: str
@router.get("/", response_model=List[UserResponse])
async def list_users(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
service = UserService(db)
users = await service.get_all_users()
return [UserResponse.model_validate(u) for u in users]
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
service = UserService(db)
user = await service.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return UserResponse.model_validate(user)
@router.post("/", response_model=UserResponse)
async def create_user(
data: UserCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
service = UserService(db)
existing_user = await service.get_user_by_username(data.username)
if existing_user:
raise HTTPException(status_code=409, detail="用户名已存在")
user = await service.create_user(data.model_dump())
return UserResponse.model_validate(user)
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
data: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
service = UserService(db)
user = await service.update_user(user_id, data.model_dump(exclude_unset=True))
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return UserResponse.model_validate(user)
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
service = UserService(db)
success = await service.delete_user(user_id)
if not success:
raise HTTPException(status_code=404, detail="用户不存在")
return {"message": "删除成功"}
@router.post("/{user_id}/reset-password")
async def reset_password(
user_id: int,
data: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role(["admin"]))
):
service = UserService(db)
success = await service.reset_password(user_id, data.new_password)
if not success:
raise HTTPException(status_code=404, detail="用户不存在")
return {"message": "success"}
+61
View File
@@ -0,0 +1,61 @@
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: 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
EXPIRY_WARNING_DAYS: List[int] = [90, 30, 7]
LOW_STOCK_THRESHOLD: int = 5
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()
View File
+60
View File
@@ -0,0 +1,60 @@
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
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db)
):
from app.services.user import UserService
from app.models.user import 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=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
+47
View File
@@ -0,0 +1,47 @@
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse
class AppException(Exception):
def __init__(self, code: int, message: str, detail: any = None):
self.code = code
self.message = message
self.detail = detail
async def app_exception_handler(request: Request, exc: AppException):
return JSONResponse(
status_code=200,
content={
"code": exc.code,
"message": exc.message,
"detail": exc.detail
}
)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={
"code": exc.status_code,
"message": exc.detail
}
)
async def general_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={
"code": 500,
"message": "服务器内部错误"
}
)
def register_exception_handlers(app):
from fastapi import FastAPI
app.add_exception_handler(AppException, app_exception_handler)
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(Exception, general_exception_handler)
+35
View File
@@ -0,0 +1,35 @@
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
+37
View File
@@ -0,0 +1,37 @@
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():
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)
+51
View File
@@ -0,0 +1,51 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.config import settings
from app.database import init_db
from app.api.router import api_router
from app.core.exceptions import register_exception_handlers
from app.notifications.manager import notification_manager
from app.ai.manager import ai_manager
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
notification_manager.load_providers()
from app.ai.openai_provider import OpenAIVisionProvider, OpenAITextProvider
if settings.AI_PROVIDER == "openai":
ai_manager.register_vision_provider("openai", OpenAIVisionProvider())
ai_manager.register_text_provider("openai", OpenAITextProvider())
yield
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,
)
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"}
+17
View File
@@ -0,0 +1,17 @@
from app.models.user import User
from app.models.medicine import Medicine
from app.models.batch import Batch
from app.models.category import Category
from app.models.audit_log import AuditLog
from app.models.notification import Notification
from app.models.setting import Setting
__all__ = [
"User",
"Medicine",
"Batch",
"Category",
"AuditLog",
"Notification",
"Setting"
]
+23
View File
@@ -0,0 +1,23 @@
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")
+23
View File
@@ -0,0 +1,23 @@
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")
+22
View File
@@ -0,0 +1,22 @@
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")
+35
View File
@@ -0,0 +1,35 @@
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")
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, Boolean, Text, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
class Notification(Base):
__tablename__ = "notifications"
id = Column(Integer, primary_key=True, index=True)
type = Column(String(50), nullable=False)
title = Column(String(200), nullable=False)
content = Column(Text, nullable=False)
is_read = Column(Boolean, default=False)
user_id = Column(Integer, ForeignKey("users.id"))
related_id = Column(Integer)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="notifications")
+14
View File
@@ -0,0 +1,14 @@
from sqlalchemy import Column, Integer, String, Text, DateTime
from datetime import datetime
from app.database import Base
class Setting(Base):
__tablename__ = "settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, index=True, nullable=False)
value = Column(Text)
description = Column(String(500))
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+24
View File
@@ -0,0 +1,24 @@
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")
+11
View File
@@ -0,0 +1,11 @@
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
+46
View File
@@ -0,0 +1,46 @@
from typing import List, Optional
from app.notifications.base import NotificationProvider
from app.config import settings
class NotificationManager:
_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.providers: List[NotificationProvider] = []
self._initialized = True
def add_provider(self, provider: NotificationProvider):
self.providers.append(provider)
async def send_notification(self, title: str, content: str):
for provider in self.providers:
try:
await provider.send(title, content)
except Exception:
pass
def load_providers(self):
from app.notifications.serverchan import ServerChanProvider
from app.notifications.pushplus import PushPlusProvider
if "serverchan" in settings.NOTIFICATION_PROVIDERS:
provider = ServerChanProvider()
if provider.validate_config():
self.add_provider(provider)
if "pushplus" in settings.NOTIFICATION_PROVIDERS:
provider = PushPlusProvider()
if provider.validate_config():
self.add_provider(provider)
notification_manager = NotificationManager()
+27
View File
@@ -0,0 +1,27 @@
import httpx
from app.notifications.base import NotificationProvider
from app.config import settings
class PushPlusProvider(NotificationProvider):
def __init__(self):
self.token = settings.PUSHPLUS_TOKEN
def validate_config(self) -> bool:
return bool(self.token)
async def send(self, title: str, content: str) -> bool:
if not self.validate_config():
return False
url = "https://www.pushplus.plus/send"
data = {
"token": self.token,
"title": title,
"content": content
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=data)
result = response.json()
return result.get("code") == 200
+25
View File
@@ -0,0 +1,25 @@
import httpx
from app.notifications.base import NotificationProvider
from app.config import settings
class ServerChanProvider(NotificationProvider):
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
+51
View File
@@ -0,0 +1,51 @@
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.audit_log import AuditLog
class AuditLogRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, log_id: int) -> Optional[AuditLog]:
result = await self.db.execute(
select(AuditLog)
.options(selectinload(AuditLog.medicine), selectinload(AuditLog.user))
.where(AuditLog.id == log_id)
)
return result.scalar_one_or_none()
async def get_list(
self,
medicine_id: Optional[int] = None,
user_id: Optional[int] = None,
action: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> List[AuditLog]:
query = select(AuditLog).options(
selectinload(AuditLog.medicine),
selectinload(AuditLog.user)
)
if medicine_id:
query = query.where(AuditLog.medicine_id == medicine_id)
if user_id:
query = query.where(AuditLog.user_id == user_id)
if action:
query = query.where(AuditLog.action == action)
query = query.order_by(AuditLog.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await self.db.execute(query)
return list(result.scalars().all())
async def create(self, data: dict) -> AuditLog:
log = AuditLog(**data)
self.db.add(log)
await self.db.flush()
return log
+56
View File
@@ -0,0 +1,56 @@
from typing import Optional, List
from datetime import date
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.batch import Batch
class BatchRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, batch_id: int) -> Optional[Batch]:
result = await self.db.execute(select(Batch).where(Batch.id == batch_id))
return result.scalar_one_or_none()
async def get_by_medicine_id(self, medicine_id: int) -> List[Batch]:
result = await self.db.execute(
select(Batch)
.where(Batch.medicine_id == medicine_id)
.order_by(Batch.expiry_date)
)
return list(result.scalars().all())
async def get_expiring_before(self, target_date: date) -> List[Batch]:
result = await self.db.execute(
select(Batch)
.where(
Batch.expiry_date <= target_date,
Batch.is_expired == False,
Batch.quantity > 0
)
)
return list(result.scalars().all())
async def create(self, data: dict) -> Batch:
batch = Batch(**data)
self.db.add(batch)
await self.db.flush()
return batch
async def update(self, batch_id: int, data: dict) -> Optional[Batch]:
batch = await self.get_by_id(batch_id)
if not batch:
return None
for key, value in data.items():
setattr(batch, key, value)
await self.db.flush()
return batch
async def delete(self, batch_id: int) -> bool:
batch = await self.get_by_id(batch_id)
if not batch:
return False
await self.db.delete(batch)
return True
+54
View File
@@ -0,0 +1,54 @@
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.category import Category
class CategoryRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, category_id: int) -> Optional[Category]:
result = await self.db.execute(select(Category).where(Category.id == category_id))
return result.scalar_one_or_none()
async def get_all(self, level: Optional[int] = None, parent_id: Optional[int] = None) -> List[Category]:
query = select(Category)
if level:
query = query.where(Category.level == level)
if parent_id:
query = query.where(Category.parent_id == parent_id)
query = query.order_by(Category.sort_order)
result = await self.db.execute(query)
return list(result.scalars().all())
async def get_children(self, parent_id: int) -> List[Category]:
result = await self.db.execute(
select(Category)
.where(Category.parent_id == parent_id)
.order_by(Category.sort_order)
)
return list(result.scalars().all())
async def create(self, data: dict) -> Category:
category = Category(**data)
self.db.add(category)
await self.db.flush()
return category
async def update(self, category_id: int, data: dict) -> Optional[Category]:
category = await self.get_by_id(category_id)
if not category:
return None
for key, value in data.items():
setattr(category, key, value)
await self.db.flush()
return category
async def delete(self, category_id: int) -> bool:
category = await self.get_by_id(category_id)
if not category:
return False
await self.db.delete(category)
return True
+92
View File
@@ -0,0 +1,92 @@
from typing import Optional, List, Tuple
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.medicine import Medicine
class MedicineRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, medicine_id: int) -> Optional[Medicine]:
result = await self.db.execute(
select(Medicine)
.options(selectinload(Medicine.batches))
.where(Medicine.id == medicine_id)
)
return result.scalar_one_or_none()
async def get_list(
self,
category_id: Optional[int] = None,
search: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> Tuple[List[Medicine], int]:
query = select(Medicine).options(selectinload(Medicine.batches))
if category_id:
query = query.where(Medicine.category_id == category_id)
if search:
search_filter = f"%{search}%"
query = query.where(
Medicine.name.ilike(search_filter) |
Medicine.generic_name.ilike(search_filter) |
Medicine.brand_name.ilike(search_filter)
)
count_query = select(func.count()).select_from(Medicine)
if category_id:
count_query = count_query.where(Medicine.category_id == category_id)
if search:
search_filter = f"%{search}%"
count_query = count_query.where(
Medicine.name.ilike(search_filter) |
Medicine.generic_name.ilike(search_filter) |
Medicine.brand_name.ilike(search_filter)
)
total_result = await self.db.execute(count_query)
total = total_result.scalar()
query = query.offset((page - 1) * page_size).limit(page_size)
result = await self.db.execute(query)
medicines = list(result.scalars().all())
return medicines, total
async def create(self, data: dict) -> Medicine:
medicine = Medicine(**data)
self.db.add(medicine)
await self.db.flush()
return medicine
async def update(self, medicine_id: int, data: dict) -> Optional[Medicine]:
medicine = await self.get_by_id(medicine_id)
if not medicine:
return None
for key, value in data.items():
setattr(medicine, key, value)
await self.db.flush()
return medicine
async def delete(self, medicine_id: int) -> bool:
medicine = await self.get_by_id(medicine_id)
if not medicine:
return False
await self.db.delete(medicine)
return True
async def search(self, query: str) -> List[Medicine]:
search_filter = f"%{query}%"
result = await self.db.execute(
select(Medicine).options(selectinload(Medicine.batches)).where(
Medicine.name.ilike(search_filter) |
Medicine.generic_name.ilike(search_filter) |
Medicine.indications.ilike(search_filter)
)
)
return list(result.scalars().all())
+71
View File
@@ -0,0 +1,71 @@
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import Notification
class NotificationRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, notification_id: int) -> Optional[Notification]:
result = await self.db.execute(
select(Notification).where(Notification.id == notification_id)
)
return result.scalar_one_or_none()
async def get_list(
self,
user_id: Optional[int] = None,
is_read: Optional[bool] = None,
type: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> List[Notification]:
query = select(Notification)
if user_id:
query = query.where(Notification.user_id == user_id)
if is_read is not None:
query = query.where(Notification.is_read == is_read)
if type:
query = query.where(Notification.type == type)
query = query.order_by(Notification.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await self.db.execute(query)
return list(result.scalars().all())
async def create(self, data: dict) -> Notification:
notification = Notification(**data)
self.db.add(notification)
await self.db.flush()
return notification
async def mark_as_read(self, notification_id: int) -> bool:
notification = await self.get_by_id(notification_id)
if not notification:
return False
notification.is_read = True
return True
async def mark_all_as_read(self, user_id: int) -> int:
result = await self.db.execute(
select(Notification).where(
Notification.user_id == user_id,
Notification.is_read == False
)
)
notifications = list(result.scalars().all())
for notification in notifications:
notification.is_read = True
return len(notifications)
async def delete(self, notification_id: int) -> bool:
notification = await self.get_by_id(notification_id)
if not notification:
return False
await self.db.delete(notification)
return True
+44
View File
@@ -0,0 +1,44 @@
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
class UserRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, user_id: int) -> Optional[User]:
result = await self.db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def get_by_username(self, username: str) -> Optional[User]:
result = await self.db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
async def get_all(self) -> List[User]:
result = await self.db.execute(select(User))
return list(result.scalars().all())
async def create(self, data: dict) -> User:
user = User(**data)
self.db.add(user)
await self.db.flush()
return user
async def update(self, user_id: int, data: dict) -> Optional[User]:
user = await self.get_by_id(user_id)
if not user:
return None
for key, value in data.items():
setattr(user, key, value)
await self.db.flush()
return user
async def delete(self, user_id: int) -> bool:
user = await self.get_by_id(user_id)
if not user:
return False
await self.db.delete(user)
return True
+33
View File
@@ -0,0 +1,33 @@
from app.schemas.user import (
UserBase,
UserCreate,
UserUpdate,
UserResponse,
UserLogin,
Token
)
from app.schemas.medicine import (
MedicineBase,
MedicineCreate,
MedicineUpdate,
MedicineResponse,
MedicineWithStock
)
from app.schemas.batch import (
BatchBase,
BatchCreate,
BatchUpdate,
BatchResponse,
BatchDispense,
BatchAddStock
)
from app.schemas.category import (
CategoryBase,
CategoryCreate,
CategoryUpdate,
CategoryResponse
)
from app.schemas.auth import (
LoginRequest,
PasswordChangeRequest
)
+11
View File
@@ -0,0 +1,11 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class PasswordChangeRequest(BaseModel):
old_password: str
new_password: str
+42
View File
@@ -0,0 +1,42 @@
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)
+36
View File
@@ -0,0 +1,36 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
class CategoryBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
parent_id: Optional[int] = None
level: int = Field(default=1, ge=1, le=2)
icon: Optional[str] = None
sort_order: int = 0
class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(BaseModel):
name: Optional[str] = None
parent_id: Optional[int] = None
level: Optional[int] = None
icon: Optional[str] = None
sort_order: Optional[int] = None
class CategoryResponse(CategoryBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class CategoryWithChildren(CategoryResponse):
children: List["CategoryResponse"] = []
+58
View File
@@ -0,0 +1,58 @@
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
+44
View File
@@ -0,0 +1,44 @@
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
View File
+47
View File
@@ -0,0 +1,47 @@
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories.audit_log import AuditLogRepository
class AuditService:
def __init__(self, db: AsyncSession):
self.db = db
self.repo = AuditLogRepository(db)
async def log_action(
self,
medicine_id: int,
batch_id: Optional[int],
user_id: Optional[int],
action: str,
quantity_change: int,
quantity_after: int,
remark: Optional[str] = None
):
data = {
"medicine_id": medicine_id,
"batch_id": batch_id,
"user_id": user_id,
"action": action,
"quantity_change": quantity_change,
"quantity_after": quantity_after,
"remark": remark
}
return await self.repo.create(data)
async def get_audit_logs(
self,
medicine_id: Optional[int] = None,
user_id: Optional[int] = None,
action: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> List:
return await self.repo.get_list(
medicine_id=medicine_id,
user_id=user_id,
action=action,
page=page,
page_size=page_size
)
+37
View File
@@ -0,0 +1,37 @@
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.services.user import UserService
from app.core.security import create_access_token
class AuthService:
def __init__(self, db: AsyncSession):
self.db = db
self.user_service = UserService(db)
async def login(self, username: str, password: str) -> Optional[dict]:
user = await self.user_service.authenticate(username, password)
if not user:
return None
access_token = create_access_token(data={"sub": str(user.id)})
return {
"access_token": access_token,
"token_type": "bearer",
"user": user
}
async def register(self, data: dict) -> Optional[dict]:
existing_user = await self.user_service.get_user_by_username(data["username"])
if existing_user:
return None
user = await self.user_service.create_user(data)
access_token = create_access_token(data={"sub": str(user.id)})
return {
"access_token": access_token,
"token_type": "bearer",
"user": user
}
+89
View File
@@ -0,0 +1,89 @@
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
+56
View File
@@ -0,0 +1,56 @@
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.category import Category
from app.repositories.category import CategoryRepository
class CategoryService:
def __init__(self, db: AsyncSession):
self.db = db
self.repo = CategoryRepository(db)
async def get_categories(self, level: Optional[int] = None, parent_id: Optional[int] = None) -> List[Category]:
return await self.repo.get_all(level=level, parent_id=parent_id)
async def get_category(self, category_id: int) -> Optional[Category]:
return await self.repo.get_by_id(category_id)
async def get_category_with_children(self, category_id: int) -> Optional[Category]:
category = await self.repo.get_by_id(category_id)
if category:
category.children = await self.repo.get_children(category_id)
return category
async def create_category(self, data: dict) -> Category:
return await self.repo.create(data)
async def update_category(self, category_id: int, data: dict) -> Optional[Category]:
return await self.repo.update(category_id, data)
async def delete_category(self, category_id: int) -> bool:
return await self.repo.delete(category_id)
async def get_category_tree(self) -> List[dict]:
root_categories = await self.repo.get_all(level=1)
tree = []
for category in root_categories:
children = await self.repo.get_children(category.id)
tree.append({
"id": category.id,
"name": category.name,
"level": category.level,
"icon": category.icon,
"sort_order": category.sort_order,
"children": [
{
"id": child.id,
"name": child.name,
"level": child.level,
"icon": child.icon,
"sort_order": child.sort_order
}
for child in children
]
})
return tree
+64
View File
@@ -0,0 +1,64 @@
from typing import List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.medicine import Medicine
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)
+39
View File
@@ -0,0 +1,39 @@
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import Notification
from app.repositories.notification import NotificationRepository
class NotificationService:
def __init__(self, db: AsyncSession):
self.db = db
self.repo = NotificationRepository(db)
async def get_notifications(
self,
user_id: Optional[int] = None,
is_read: Optional[bool] = None,
type: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> List[Notification]:
return await self.repo.get_list(
user_id=user_id,
is_read=is_read,
type=type,
page=page,
page_size=page_size
)
async def create_notification(self, data: dict) -> Notification:
return await self.repo.create(data)
async def mark_as_read(self, notification_id: int) -> bool:
return await self.repo.mark_as_read(notification_id)
async def mark_all_as_read(self, user_id: int) -> int:
return await self.repo.mark_all_as_read(user_id)
async def delete_notification(self, notification_id: int) -> bool:
return await self.repo.delete(notification_id)
+60
View File
@@ -0,0 +1,60 @@
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.repositories.user import UserRepository
from app.core.security import get_password_hash, verify_password
class UserService:
def __init__(self, db: AsyncSession):
self.db = db
self.repo = UserRepository(db)
async def get_user(self, user_id: int) -> Optional[User]:
return await self.repo.get_by_id(user_id)
async def get_user_by_username(self, username: str) -> Optional[User]:
return await self.repo.get_by_username(username)
async def get_all_users(self) -> List[User]:
return await self.repo.get_all()
async def create_user(self, data: dict) -> User:
if "password" in data:
data["password_hash"] = get_password_hash(data.pop("password"))
return await self.repo.create(data)
async def update_user(self, user_id: int, data: dict) -> Optional[User]:
if "password" in data:
data["password_hash"] = get_password_hash(data.pop("password"))
return await self.repo.update(user_id, data)
async def delete_user(self, user_id: int) -> bool:
return await self.repo.delete(user_id)
async def authenticate(self, username: str, password: str) -> Optional[User]:
user = await self.repo.get_by_username(username)
if not user:
return None
if not verify_password(password, user.password_hash):
return None
if not user.is_active:
return None
return user
async def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
user = await self.repo.get_by_id(user_id)
if not user:
return False
if not verify_password(old_password, user.password_hash):
return False
await self.repo.update(user_id, {"password_hash": get_password_hash(new_password)})
return True
async def reset_password(self, user_id: int, new_password: str) -> bool:
user = await self.repo.get_by_id(user_id)
if not user:
return False
await self.repo.update(user_id, {"password_hash": get_password_hash(new_password)})
return True
View File
+15
View File
@@ -0,0 +1,15 @@
from abc import ABC, abstractmethod
class StorageProvider(ABC):
@abstractmethod
async def save(self, file_path: str, data: bytes) -> str:
pass
@abstractmethod
async def delete(self, file_path: str) -> bool:
pass
@abstractmethod
async def get_url(self, file_path: str) -> str:
pass
+29
View File
@@ -0,0 +1,29 @@
import os
import aiofiles
from app.storage.base import StorageProvider
from app.config import settings
class LocalStorageProvider(StorageProvider):
def __init__(self):
self.upload_dir = settings.UPLOAD_DIR
os.makedirs(self.upload_dir, exist_ok=True)
async def save(self, file_path: str, data: bytes) -> str:
full_path = os.path.join(self.upload_dir, file_path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
async with aiofiles.open(full_path, 'wb') as f:
await f.write(data)
return full_path
async def delete(self, file_path: str) -> bool:
full_path = os.path.join(self.upload_dir, file_path)
if os.path.exists(full_path):
os.remove(full_path)
return True
return False
async def get_url(self, file_path: str) -> str:
return f"/uploads/{file_path}"
+33
View File
@@ -0,0 +1,33 @@
from app.storage.base import StorageProvider
from app.storage.local import LocalStorageProvider
class StorageManager:
_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.provider: StorageProvider = LocalStorageProvider()
self._initialized = True
def set_provider(self, provider: StorageProvider):
self.provider = provider
async def save(self, file_path: str, data: bytes) -> str:
return await self.provider.save(file_path, data)
async def delete(self, file_path: str) -> bool:
return await self.provider.delete(file_path)
async def get_url(self, file_path: str) -> str:
return await self.provider.get_url(file_path)
storage_manager = StorageManager()
View File
+37
View File
@@ -0,0 +1,37 @@
from datetime import date, timedelta
from sqlalchemy import select
from app.database import async_session_factory
from app.models.batch import Batch
from app.models.medicine import Medicine
from app.config import settings
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:
from app.notifications.manager import notification_manager
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_manager.send_notification(title, content)
+2
View File
@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto
+38
View File
@@ -0,0 +1,38 @@
# 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
# 数据验证
pydantic==2.5.2
pydantic-settings==2.1.0
# AI 服务
openai==1.6.1
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
View File
+25
View File
@@ -0,0 +1,25 @@
import pytest
import asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
from app.database import init_db
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
async def setup_db():
await init_db()
@pytest.fixture
async def client(setup_db):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac