首次提交by MimoCode
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
```
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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', [])
|
||||
@@ -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=["系统设置"])
|
||||
@@ -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)}")
|
||||
@@ -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": "密码修改成功"}
|
||||
@@ -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))
|
||||
@@ -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": "删除成功"}
|
||||
@@ -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": "删除成功"}
|
||||
@@ -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": "删除成功"}
|
||||
@@ -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)}"}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
@@ -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)
|
||||
@@ -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"] = []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user