feat(health): 添加 erp-health 健康管理模块骨架
新建 erp-health 原生 Rust crate,覆盖设计规格中定义的 5 大业务域: - 16 个 SeaORM Entity(患者/家属/标签/医生/健康档案/体征/化验单/预约/排班/随访/咨询等) - 16 表数据库迁移(含索引、外键、默认值、可回滚) - 40+ API 路由骨架(患者管理/健康数据/预约排班/随访/咨询/医生管理) - 12 个权限声明(health.patient/health-data/appointment/follow-up/consultation/doctor 各 .list/.manage) - DTO / Service / Handler / Event 四层架构,Service 使用 todo!() 占位 - erp-server 集成:模块注册 + AppState FromRef 桥接 + 路由挂载 同步更新 CLAUDE.md 项目进度、wiki 知识库、设计规格文档。
This commit is contained in:
45
CLAUDE.md
45
CLAUDE.md
@@ -1,26 +1,26 @@
|
||||
@wiki/index.md
|
||||
整个项目对话都使用中文进行,包括文档、代码注释、事件名称等。
|
||||
|
||||
# ERP 平台底座 — 协作与实现规则
|
||||
# HMS 健康管理平台 — 协作与实现规则
|
||||
|
||||
> **ERP Platform Base** 是一个模块化的商业 SaaS ERP 底座,目标是提供核心基础设施(身份权限、工作流、消息、配置),使行业业务模块(进销存、生产、财务等)可以快速插接。
|
||||
> **Health Management System (HMS)** — 面向体检中心/医疗机构的综合健康管理平台。从 ERP 底座分叉独立,继承身份权限/工作流/消息/配置等基础能力,`erp-health` 原生模块承载医疗业务。
|
||||
|
||||
> **当前阶段: Phase 1 基础设施搭建。** 从零构建 Rust workspace + Web 前端 + 核心共享层。
|
||||
> **当前阶段: erp-health 模块开发。** 设计规格已确认,开始实施。
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
### 1.1 这是什么
|
||||
|
||||
一个 **底座 + 行业插件** 架构的商业 SaaS ERP 平台:
|
||||
一个 **健康管理 + ERP 基础设施** 架构的医疗 SaaS 平台:
|
||||
|
||||
- **全功能底座** — 身份权限、工作流引擎、消息中心、系统配置
|
||||
- **渐进式扩展** — 从小微企业起步,逐步支持中大型企业
|
||||
- **医疗核心** — 患者管理、健康数据、预约排班、随访管理、咨询管理(原生 Rust 模块 erp-health)
|
||||
- **基础底座** — 身份权限、工作流引擎、消息中心、系统配置(继承自 ERP)
|
||||
- **多租户 + 私有化** — 默认 SaaS 共享数据库隔离,支持独立 schema 私有部署
|
||||
- **Web 优先** — 浏览器 SPA 是主力,可选 Tauri 桌面端用于特定行业场景
|
||||
- **Web 优先** — 浏览器 SPA 是 PC 管理后台主力,小程序(患者端/医护端)独立开发
|
||||
|
||||
### 1.2 决策原则
|
||||
|
||||
**任何改动都要问:这对 ERP 底座的模块化和可扩展性有帮助吗?**
|
||||
**任何改动都要问:这对健康管理平台的医疗业务和可扩展性有帮助吗?**
|
||||
|
||||
- ✅ 完善模块接口和 trait 定义 → 最高优先
|
||||
- ✅ 确保多租户隔离的正确性 → 最高优先
|
||||
@@ -46,18 +46,18 @@
|
||||
## 2. 项目结构
|
||||
|
||||
```text
|
||||
erp/
|
||||
hms/
|
||||
├── crates/ # Rust Workspace
|
||||
│ ├── erp-core/ # L1: 基础类型、错误、事件、模块 trait
|
||||
│ ├── erp-common/ # L1: 共享工具、宏
|
||||
│ ├── erp-auth/ # L2: 身份与权限模块
|
||||
│ ├── erp-workflow/ # L2: 工作流引擎模块
|
||||
│ ├── erp-message/ # L2: 消息中心模块
|
||||
│ ├── erp-config/ # L2: 系统配置模块
|
||||
│ ├── erp-health/ # L2: 健康管理模块 ★ HMS 核心
|
||||
│ └── erp-server/ # L3: Axum 服务入口,组装所有模块
|
||||
│ └── migration/ # SeaORM 数据库迁移
|
||||
├── apps/
|
||||
│ └── web/ # Vite + React 18 SPA (主力前端)
|
||||
│ └── web/ # Vite + React 19 SPA (主力前端)
|
||||
├── packages/
|
||||
│ └── ui-components/ # React 共享组件库
|
||||
├── desktop/ # (可选) Tauri 桌面端,行业需要时启用
|
||||
@@ -74,18 +74,18 @@ erp/
|
||||
|
||||
```text
|
||||
erp-core (无业务依赖)
|
||||
erp-common (无业务依赖)
|
||||
↑
|
||||
erp-auth (→ core)
|
||||
erp-config (→ core)
|
||||
erp-workflow (→ core)
|
||||
erp-message (→ core)
|
||||
erp-health (→ core) ★ HMS 核心
|
||||
↑
|
||||
erp-server (→ 所有 crate,组装入口)
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- `erp-core` 和 `erp-common` 不依赖任何业务 crate
|
||||
- `erp-core` 不依赖任何业务 crate
|
||||
- 业务 crate 之间**禁止**直接依赖,只通过事件总线和 `erp-core` trait 通信
|
||||
- `erp-server` 是唯一的组装点
|
||||
|
||||
@@ -95,11 +95,11 @@ erp-server (→ 所有 crate,组装入口)
|
||||
|------|------|
|
||||
| 后端框架 | Axum 0.8 + Tokio |
|
||||
| ORM | SeaORM (异步、类型安全) |
|
||||
| 数据库 | PostgreSQL 16+ |
|
||||
| 数据库 | PostgreSQL 18 |
|
||||
| 缓存 | Redis 7+ |
|
||||
| 前端框架 | React 18 + TypeScript (Vite) |
|
||||
| UI 组件库 | Ant Design 5 |
|
||||
| 状态管理 | Zustand |
|
||||
| 前端框架 | React 19 + TypeScript 6 (Vite 8) |
|
||||
| UI 组件库 | Ant Design 6 |
|
||||
| 状态管理 | Zustand 5 |
|
||||
| 路由 | React Router 7 |
|
||||
| 样式 | TailwindCSS + CSS Variables |
|
||||
| API 文档 | utoipa (OpenAPI 3) |
|
||||
@@ -422,7 +422,6 @@ cargo test -p erp-plugin-prototype # 运行插件集成测试
|
||||
| scope | 范围 |
|
||||
|-------|------|
|
||||
| `core` | erp-core |
|
||||
| `common` | erp-common |
|
||||
| `auth` | erp-auth |
|
||||
| `workflow` | erp-workflow |
|
||||
| `message` | erp-message |
|
||||
@@ -452,12 +451,10 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| `docs/superpowers/specs/2026-04-23-health-management-module-design.md` | **HMS 健康模块设计规格** ★ 当前 |
|
||||
| `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` | 平台底座设计规格 |
|
||||
| `docs/superpowers/plans/2026-04-10-erp-platform-base-plan.md` | 平台底座实施计划 |
|
||||
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | WASM 插件系统设计规格 |
|
||||
| `docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md` | WASM 插件原型验证计划 |
|
||||
| `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` | CRM 客户管理插件设计规格 |
|
||||
| `docs/superpowers/plans/2026-04-16-crm-plugin-plan.md` | CRM 插件实施计划 |
|
||||
|
||||
所有设计决策以设计规格文档为准。实施计划按阶段拆分,每阶段开始前细化。
|
||||
|
||||
@@ -490,8 +487,7 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
| Crate | 功能 | 状态 |
|
||||
|-------|------|------|
|
||||
| erp-core | 错误类型、共享类型、事件总线、ErpModule trait、审计日志 | ✅ 完成 |
|
||||
| erp-common | 共享工具 | ✅ 完成 |
|
||||
| erp-server | Axum 服务入口、配置、数据库连接、CORS | ✅ 完成 |
|
||||
| erp-server | Axum 服务入口、配置、数据库连接、CORS、模块注册、后台任务 | ✅ 完成 |
|
||||
| erp-auth | 身份与权限 (用户/角色/权限/组织/部门/岗位/行级数据权限) | ✅ 完成 |
|
||||
| erp-workflow | 工作流引擎 (BPMN 解析/Token 驱动/任务分配) | ✅ 完成 |
|
||||
| erp-message | 消息中心 (CRUD/模板/订阅/通知面板) | ✅ 完成 |
|
||||
@@ -501,6 +497,9 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
| erp-plugin-test-sample | WASM 测试插件 (Guest trait + Host API 回调) | ✅ 原型验证 |
|
||||
| erp-plugin-crm | CRM 客户管理插件 (5 实体/9 权限/6 页面) | ✅ 完成 |
|
||||
| erp-plugin-inventory | 进销存管理插件 (6 实体/12 权限/6 页面) | ✅ 完成 |
|
||||
| erp-plugin-freelance | 自由职业者管理插件 | ✅ 完成 |
|
||||
| erp-plugin-itops | IT 运维管理插件 | ✅ 完成 |
|
||||
| erp-health | 健康管理原生模块 (16 实体/12 权限/13 页面) | 🔧 开发中 |
|
||||
|
||||
<!-- ARCH-SNAPSHOT-END -->
|
||||
|
||||
|
||||
20
Cargo.lock
generated
20
Cargo.lock
generated
@@ -1252,6 +1252,25 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-health"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"erp-core",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
"validator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-message"
|
||||
version = "0.1.0"
|
||||
@@ -1365,6 +1384,7 @@ dependencies = [
|
||||
"erp-auth",
|
||||
"erp-config",
|
||||
"erp-core",
|
||||
"erp-health",
|
||||
"erp-message",
|
||||
"erp-plugin",
|
||||
"erp-server-migration",
|
||||
|
||||
@@ -15,6 +15,7 @@ members = [
|
||||
"crates/erp-plugin-inventory",
|
||||
"crates/erp-plugin-freelance",
|
||||
"crates/erp-plugin-itops",
|
||||
"crates/erp-health",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -90,3 +91,4 @@ erp-workflow = { path = "crates/erp-workflow" }
|
||||
erp-message = { path = "crates/erp-message" }
|
||||
erp-config = { path = "crates/erp-config" }
|
||||
erp-plugin = { path = "crates/erp-plugin" }
|
||||
erp-health = { path = "crates/erp-health" }
|
||||
|
||||
19
crates/erp-health/Cargo.toml
Normal file
19
crates/erp-health/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "erp-health"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
axum.workspace = true
|
||||
sea-orm.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
validator.workspace = true
|
||||
utoipa.workspace = true
|
||||
async-trait.workspace = true
|
||||
95
crates/erp-health/src/dto/appointment_dto.rs
Normal file
95
crates/erp-health/src/dto/appointment_dto.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateAppointmentReq {
|
||||
pub patient_id: Uuid,
|
||||
pub doctor_id: Option<Uuid>,
|
||||
pub appointment_type: Option<String>,
|
||||
pub appointment_date: NaiveDate,
|
||||
pub start_time: NaiveTime,
|
||||
pub end_time: NaiveTime,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateAppointmentStatusReq {
|
||||
pub status: String,
|
||||
pub cancel_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AppointmentResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub doctor_id: Option<Uuid>,
|
||||
pub appointment_type: String,
|
||||
pub appointment_date: NaiveDate,
|
||||
pub start_time: NaiveTime,
|
||||
pub end_time: NaiveTime,
|
||||
pub status: String,
|
||||
pub cancel_reason: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateScheduleReq {
|
||||
pub doctor_id: Uuid,
|
||||
pub schedule_date: NaiveDate,
|
||||
pub period_type: Option<String>,
|
||||
pub start_time: NaiveTime,
|
||||
pub end_time: NaiveTime,
|
||||
pub max_appointments: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateScheduleReq {
|
||||
pub start_time: Option<NaiveTime>,
|
||||
pub end_time: Option<NaiveTime>,
|
||||
pub max_appointments: Option<i32>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ScheduleResp {
|
||||
pub id: Uuid,
|
||||
pub doctor_id: Uuid,
|
||||
pub schedule_date: NaiveDate,
|
||||
pub period_type: String,
|
||||
pub start_time: NaiveTime,
|
||||
pub end_time: NaiveTime,
|
||||
pub max_appointments: i32,
|
||||
pub current_appointments: i32,
|
||||
pub status: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
|
||||
pub struct CalendarQuery {
|
||||
pub start_date: Option<NaiveDate>,
|
||||
pub end_date: Option<NaiveDate>,
|
||||
pub doctor_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CalendarDayResp {
|
||||
pub date: NaiveDate,
|
||||
pub schedules: Vec<ScheduleResp>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
|
||||
pub struct AppointmentListQuery {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub status: Option<String>,
|
||||
pub patient_id: Option<Uuid>,
|
||||
pub doctor_id: Option<Uuid>,
|
||||
pub date: Option<NaiveDate>,
|
||||
}
|
||||
46
crates/erp-health/src/dto/consultation_dto.rs
Normal file
46
crates/erp-health/src/dto/consultation_dto.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SessionResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub doctor_id: Option<Uuid>,
|
||||
pub consultation_type: String,
|
||||
pub status: String,
|
||||
pub last_message_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub unread_count_patient: i32,
|
||||
pub unread_count_doctor: i32,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MessageResp {
|
||||
pub id: Uuid,
|
||||
pub session_id: Uuid,
|
||||
pub sender_id: Uuid,
|
||||
pub sender_role: String,
|
||||
pub content_type: String,
|
||||
pub content: String,
|
||||
pub is_read: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateMessageReq {
|
||||
pub session_id: Uuid,
|
||||
pub sender_id: Uuid,
|
||||
pub sender_role: String,
|
||||
pub content_type: Option<String>,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
|
||||
pub struct SessionQuery {
|
||||
pub status: Option<String>,
|
||||
pub patient_id: Option<Uuid>,
|
||||
pub doctor_id: Option<Uuid>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
123
crates/erp-health/src/dto/health_data_dto.rs
Normal file
123
crates/erp-health/src/dto/health_data_dto.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 用 f64 替代 Decimal 以满足 utoipa ToSchema
|
||||
type Decimal = f64;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateVitalSignsReq {
|
||||
pub record_date: NaiveDate,
|
||||
pub systolic_bp_morning: Option<i32>,
|
||||
pub diastolic_bp_morning: Option<i32>,
|
||||
pub systolic_bp_evening: Option<i32>,
|
||||
pub diastolic_bp_evening: Option<i32>,
|
||||
pub heart_rate: Option<i32>,
|
||||
pub weight: Option<Decimal>,
|
||||
pub blood_sugar: Option<Decimal>,
|
||||
pub water_intake_ml: Option<i32>,
|
||||
pub urine_output_ml: Option<i32>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateVitalSignsReq {
|
||||
pub record_date: Option<NaiveDate>,
|
||||
pub systolic_bp_morning: Option<i32>,
|
||||
pub diastolic_bp_morning: Option<i32>,
|
||||
pub systolic_bp_evening: Option<i32>,
|
||||
pub diastolic_bp_evening: Option<i32>,
|
||||
pub heart_rate: Option<i32>,
|
||||
pub weight: Option<Decimal>,
|
||||
pub blood_sugar: Option<Decimal>,
|
||||
pub water_intake_ml: Option<i32>,
|
||||
pub urine_output_ml: Option<i32>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct VitalSignsResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub record_date: NaiveDate,
|
||||
pub systolic_bp_morning: Option<i32>,
|
||||
pub diastolic_bp_morning: Option<i32>,
|
||||
pub systolic_bp_evening: Option<i32>,
|
||||
pub diastolic_bp_evening: Option<i32>,
|
||||
pub heart_rate: Option<i32>,
|
||||
pub weight: Option<Decimal>,
|
||||
pub blood_sugar: Option<Decimal>,
|
||||
pub water_intake_ml: Option<i32>,
|
||||
pub urine_output_ml: Option<i32>,
|
||||
pub notes: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateLabReportReq {
|
||||
pub report_date: NaiveDate,
|
||||
pub report_type: String,
|
||||
pub indicators: Option<serde_json::Value>,
|
||||
pub image_urls: Option<serde_json::Value>,
|
||||
pub doctor_interpretation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct LabReportResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub report_date: NaiveDate,
|
||||
pub report_type: String,
|
||||
pub indicators: Option<serde_json::Value>,
|
||||
pub image_urls: Option<serde_json::Value>,
|
||||
pub doctor_interpretation: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateHealthRecordReq {
|
||||
pub record_type: Option<String>,
|
||||
pub record_date: NaiveDate,
|
||||
pub source: Option<String>,
|
||||
pub overall_assessment: Option<String>,
|
||||
pub report_file_url: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HealthRecordResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub record_type: String,
|
||||
pub record_date: NaiveDate,
|
||||
pub source: Option<String>,
|
||||
pub overall_assessment: Option<String>,
|
||||
pub report_file_url: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TrendResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub period_start: NaiveDate,
|
||||
pub period_end: NaiveDate,
|
||||
pub indicator_summary: Option<serde_json::Value>,
|
||||
pub abnormal_items: Option<serde_json::Value>,
|
||||
pub generation_type: String,
|
||||
pub report_file_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct IndicatorTimeseriesResp {
|
||||
pub indicator: String,
|
||||
pub data: Vec<(NaiveDate, f64)>,
|
||||
}
|
||||
4
crates/erp-health/src/dto/mod.rs
Normal file
4
crates/erp-health/src/dto/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod appointment_dto;
|
||||
pub mod consultation_dto;
|
||||
pub mod health_data_dto;
|
||||
pub mod patient_dto;
|
||||
93
crates/erp-health/src/dto/patient_dto.rs
Normal file
93
crates/erp-health/src/dto/patient_dto.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreatePatientReq {
|
||||
pub name: String,
|
||||
pub gender: Option<String>,
|
||||
pub birth_date: Option<NaiveDate>,
|
||||
pub blood_type: Option<String>,
|
||||
pub id_number: Option<String>,
|
||||
pub allergy_history: Option<String>,
|
||||
pub medical_history_summary: Option<String>,
|
||||
pub emergency_contact_name: Option<String>,
|
||||
pub emergency_contact_phone: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdatePatientReq {
|
||||
pub name: Option<String>,
|
||||
pub gender: Option<String>,
|
||||
pub birth_date: Option<NaiveDate>,
|
||||
pub blood_type: Option<String>,
|
||||
pub id_number: Option<String>,
|
||||
pub allergy_history: Option<String>,
|
||||
pub medical_history_summary: Option<String>,
|
||||
pub emergency_contact_name: Option<String>,
|
||||
pub emergency_contact_phone: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PatientResp {
|
||||
pub id: Uuid,
|
||||
pub user_id: Option<Uuid>,
|
||||
pub name: String,
|
||||
pub gender: Option<String>,
|
||||
pub birth_date: Option<NaiveDate>,
|
||||
pub blood_type: Option<String>,
|
||||
pub id_number: Option<String>,
|
||||
pub allergy_history: Option<String>,
|
||||
pub medical_history_summary: Option<String>,
|
||||
pub emergency_contact_name: Option<String>,
|
||||
pub emergency_contact_phone: Option<String>,
|
||||
pub status: String,
|
||||
pub verification_status: String,
|
||||
pub source: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct FamilyMemberReq {
|
||||
pub name: String,
|
||||
pub relationship: String,
|
||||
pub phone: Option<String>,
|
||||
pub birth_date: Option<NaiveDate>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct FamilyMemberResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub name: String,
|
||||
pub relationship: String,
|
||||
pub phone: Option<String>,
|
||||
pub birth_date: Option<NaiveDate>,
|
||||
pub notes: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ManageTagsReq {
|
||||
pub tag_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
|
||||
pub struct PatientListQuery {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub search: Option<String>,
|
||||
pub tag_id: Option<Uuid>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
55
crates/erp-health/src/entity/appointment.rs
Normal file
55
crates/erp-health/src/entity/appointment.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "appointment")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub doctor_id: Option<Uuid>,
|
||||
pub appointment_type: String,
|
||||
pub appointment_date: chrono::NaiveDate,
|
||||
pub start_time: chrono::NaiveTime,
|
||||
pub end_time: chrono::NaiveTime,
|
||||
pub status: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub cancel_reason: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::doctor_profile::Entity",
|
||||
from = "Column::DoctorId",
|
||||
to = "super::doctor_profile::Column::Id"
|
||||
)]
|
||||
Doctor,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
43
crates/erp-health/src/entity/consultation_message.rs
Normal file
43
crates/erp-health/src/entity/consultation_message.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "consultation_message")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub session_id: Uuid,
|
||||
pub sender_id: Uuid,
|
||||
pub sender_role: String,
|
||||
pub content_type: String,
|
||||
pub content: String,
|
||||
pub is_read: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::consultation_session::Entity",
|
||||
from = "Column::SessionId",
|
||||
to = "super::consultation_session::Column::Id"
|
||||
)]
|
||||
Session,
|
||||
}
|
||||
|
||||
impl Related<super::consultation_session::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Session.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
61
crates/erp-health/src/entity/consultation_session.rs
Normal file
61
crates/erp-health/src/entity/consultation_session.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "consultation_session")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub doctor_id: Option<Uuid>,
|
||||
#[sea_orm(rename = "type")]
|
||||
pub consultation_type: String,
|
||||
pub status: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub last_message_at: Option<DateTimeUtc>,
|
||||
pub unread_count_patient: i32,
|
||||
pub unread_count_doctor: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::doctor_profile::Entity",
|
||||
from = "Column::DoctorId",
|
||||
to = "super::doctor_profile::Column::Id"
|
||||
)]
|
||||
Doctor,
|
||||
#[sea_orm(has_many = "super::consultation_message::Entity")]
|
||||
Message,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::consultation_message::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Message.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
54
crates/erp-health/src/entity/doctor_profile.rs
Normal file
54
crates/erp-health/src/entity/doctor_profile.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "doctor_profile")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub user_id: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub department: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub specialty: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub license_number: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub bio: Option<String>,
|
||||
pub online_status: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::patient_doctor_relation::Entity")]
|
||||
PatientRelation,
|
||||
#[sea_orm(has_many = "super::doctor_schedule::Entity")]
|
||||
Schedule,
|
||||
}
|
||||
|
||||
impl Related<super::patient_doctor_relation::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::PatientRelation.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::doctor_schedule::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Schedule.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
45
crates/erp-health/src/entity/doctor_schedule.rs
Normal file
45
crates/erp-health/src/entity/doctor_schedule.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "doctor_schedule")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub doctor_id: Uuid,
|
||||
pub schedule_date: chrono::NaiveDate,
|
||||
pub period_type: String,
|
||||
pub start_time: chrono::NaiveTime,
|
||||
pub end_time: chrono::NaiveTime,
|
||||
pub max_appointments: i32,
|
||||
pub current_appointments: i32,
|
||||
pub status: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::doctor_profile::Entity",
|
||||
from = "Column::DoctorId",
|
||||
to = "super::doctor_profile::Column::Id"
|
||||
)]
|
||||
Doctor,
|
||||
}
|
||||
|
||||
impl Related<super::doctor_profile::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Doctor.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
48
crates/erp-health/src/entity/follow_up_record.rs
Normal file
48
crates/erp-health/src/entity/follow_up_record.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "follow_up_record")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub task_id: Uuid,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub executed_by: Option<Uuid>,
|
||||
pub executed_date: chrono::NaiveDate,
|
||||
pub result: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub patient_condition: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub medical_advice: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub next_follow_up_date: Option<chrono::NaiveDate>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::follow_up_task::Entity",
|
||||
from = "Column::TaskId",
|
||||
to = "super::follow_up_task::Column::Id"
|
||||
)]
|
||||
Task,
|
||||
}
|
||||
|
||||
impl Related<super::follow_up_task::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Task.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
55
crates/erp-health/src/entity/follow_up_task.rs
Normal file
55
crates/erp-health/src/entity/follow_up_task.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "follow_up_task")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub assigned_to: Option<Uuid>,
|
||||
pub follow_up_type: String,
|
||||
pub planned_date: chrono::NaiveDate,
|
||||
pub status: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub content_template: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub related_appointment_id: Option<Uuid>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
#[sea_orm(has_many = "super::follow_up_record::Entity")]
|
||||
Record,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::follow_up_record::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Record.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
48
crates/erp-health/src/entity/health_record.rs
Normal file
48
crates/erp-health/src/entity/health_record.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "health_record")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub record_type: String,
|
||||
pub record_date: chrono::NaiveDate,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub overall_assessment: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub report_file_url: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
47
crates/erp-health/src/entity/health_trend.rs
Normal file
47
crates/erp-health/src/entity/health_trend.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "health_trend")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub period_start: chrono::NaiveDate,
|
||||
pub period_end: chrono::NaiveDate,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub indicator_summary: Option<serde_json::Value>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub abnormal_items: Option<serde_json::Value>,
|
||||
pub generation_type: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub report_file_url: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
46
crates/erp-health/src/entity/lab_report.rs
Normal file
46
crates/erp-health/src/entity/lab_report.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "lab_report")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub report_date: chrono::NaiveDate,
|
||||
pub report_type: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub indicators: Option<serde_json::Value>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub image_urls: Option<serde_json::Value>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub doctor_interpretation: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
16
crates/erp-health/src/entity/mod.rs
Normal file
16
crates/erp-health/src/entity/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
pub mod appointment;
|
||||
pub mod consultation_message;
|
||||
pub mod consultation_session;
|
||||
pub mod doctor_profile;
|
||||
pub mod doctor_schedule;
|
||||
pub mod follow_up_record;
|
||||
pub mod follow_up_task;
|
||||
pub mod health_record;
|
||||
pub mod health_trend;
|
||||
pub mod lab_report;
|
||||
pub mod patient;
|
||||
pub mod patient_doctor_relation;
|
||||
pub mod patient_family_member;
|
||||
pub mod patient_tag;
|
||||
pub mod patient_tag_relation;
|
||||
pub mod vital_signs;
|
||||
74
crates/erp-health/src/entity/patient.rs
Normal file
74
crates/erp-health/src/entity/patient.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "patient")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub user_id: Option<Uuid>,
|
||||
pub name: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub gender: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub birth_date: Option<chrono::NaiveDate>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub blood_type: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub id_number: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub allergy_history: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub medical_history_summary: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub emergency_contact_name: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub emergency_contact_phone: Option<String>,
|
||||
pub status: String,
|
||||
pub verification_status: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::patient_family_member::Entity")]
|
||||
FamilyMember,
|
||||
#[sea_orm(has_many = "super::patient_tag_relation::Entity")]
|
||||
TagRelation,
|
||||
#[sea_orm(has_many = "super::patient_doctor_relation::Entity")]
|
||||
DoctorRelation,
|
||||
#[sea_orm(has_many = "super::health_record::Entity")]
|
||||
HealthRecord,
|
||||
#[sea_orm(has_many = "super::vital_signs::Entity")]
|
||||
VitalSigns,
|
||||
#[sea_orm(has_many = "super::lab_report::Entity")]
|
||||
LabReport,
|
||||
#[sea_orm(has_many = "super::appointment::Entity")]
|
||||
Appointment,
|
||||
#[sea_orm(has_many = "super::follow_up_task::Entity")]
|
||||
FollowUpTask,
|
||||
#[sea_orm(has_many = "super::consultation_session::Entity")]
|
||||
ConsultationSession,
|
||||
}
|
||||
|
||||
impl Related<super::patient_family_member::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::FamilyMember.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
51
crates/erp-health/src/entity/patient_doctor_relation.rs
Normal file
51
crates/erp-health/src/entity/patient_doctor_relation.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "patient_doctor_relation")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub doctor_id: Uuid,
|
||||
pub relationship_type: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::doctor_profile::Entity",
|
||||
from = "Column::DoctorId",
|
||||
to = "super::doctor_profile::Column::Id"
|
||||
)]
|
||||
Doctor,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::doctor_profile::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Doctor.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
46
crates/erp-health/src/entity/patient_family_member.rs
Normal file
46
crates/erp-health/src/entity/patient_family_member.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "patient_family_member")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub name: String,
|
||||
pub relationship: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub birth_date: Option<chrono::NaiveDate>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
39
crates/erp-health/src/entity/patient_tag.rs
Normal file
39
crates/erp-health/src/entity/patient_tag.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "patient_tag")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub is_system: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::patient_tag_relation::Entity")]
|
||||
TagRelation,
|
||||
}
|
||||
|
||||
impl Related<super::patient_tag_relation::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::TagRelation.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
50
crates/erp-health/src/entity/patient_tag_relation.rs
Normal file
50
crates/erp-health/src/entity/patient_tag_relation.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "patient_tag_relation")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub tag_id: Uuid,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient_tag::Entity",
|
||||
from = "Column::TagId",
|
||||
to = "super::patient_tag::Column::Id"
|
||||
)]
|
||||
Tag,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::patient_tag::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Tag.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
59
crates/erp-health/src/entity/vital_signs.rs
Normal file
59
crates/erp-health/src/entity/vital_signs.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "vital_signs")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub record_date: chrono::NaiveDate,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub systolic_bp_morning: Option<i32>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub diastolic_bp_morning: Option<i32>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub systolic_bp_evening: Option<i32>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub diastolic_bp_evening: Option<i32>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub heart_rate: Option<i32>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub weight: Option<Decimal>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub blood_sugar: Option<Decimal>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub water_intake_ml: Option<i32>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub urine_output_ml: Option<i32>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
63
crates/erp-health/src/error.rs
Normal file
63
crates/erp-health/src/error.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use erp_core::error::AppError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HealthError {
|
||||
#[error("{0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("患者不存在")]
|
||||
PatientNotFound,
|
||||
|
||||
#[error("医护档案不存在")]
|
||||
DoctorNotFound,
|
||||
|
||||
#[error("预约不存在")]
|
||||
AppointmentNotFound,
|
||||
|
||||
#[error("排班不存在")]
|
||||
ScheduleNotFound,
|
||||
|
||||
#[error("排班已满,无法预约")]
|
||||
ScheduleFull,
|
||||
|
||||
#[error("随访任务不存在")]
|
||||
FollowUpTaskNotFound,
|
||||
|
||||
#[error("会话不存在")]
|
||||
ConsultationNotFound,
|
||||
|
||||
#[error("状态转换无效: {0}")]
|
||||
InvalidStatusTransition(String),
|
||||
|
||||
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||
VersionMismatch,
|
||||
|
||||
#[error("数据库操作失败: {0}")]
|
||||
DbError(String),
|
||||
}
|
||||
|
||||
impl From<HealthError> for AppError {
|
||||
fn from(err: HealthError) -> Self {
|
||||
match err {
|
||||
HealthError::Validation(s) => AppError::Validation(s),
|
||||
HealthError::PatientNotFound
|
||||
| HealthError::DoctorNotFound
|
||||
| HealthError::AppointmentNotFound
|
||||
| HealthError::ScheduleNotFound
|
||||
| HealthError::FollowUpTaskNotFound
|
||||
| HealthError::ConsultationNotFound => AppError::NotFound(err.to_string()),
|
||||
HealthError::ScheduleFull => AppError::Validation(err.to_string()),
|
||||
HealthError::InvalidStatusTransition(s) => AppError::Validation(s),
|
||||
HealthError::VersionMismatch => AppError::VersionMismatch,
|
||||
HealthError::DbError(_) => AppError::Internal(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sea_orm::DbErr> for HealthError {
|
||||
fn from(err: sea_orm::DbErr) -> Self {
|
||||
HealthError::DbError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type HealthResult<T> = Result<T, HealthError>;
|
||||
7
crates/erp-health/src/event.rs
Normal file
7
crates/erp-health/src/event.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
pub fn register_handlers(_bus: &EventBus) {
|
||||
// Health 模块订阅的事件处理器
|
||||
// - workflow.task.completed → 更新随访任务状态
|
||||
// - message.sent → 联动咨询会话 last_message_at
|
||||
}
|
||||
226
crates/erp-health/src/handler/appointment_handler.rs
Normal file
226
crates/erp-health/src/handler/appointment_handler.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTO — 预约排班
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 预约列表查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct AppointmentListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub patient_id: Option<Uuid>,
|
||||
pub doctor_id: Option<Uuid>,
|
||||
pub status: Option<String>,
|
||||
pub date: Option<String>,
|
||||
}
|
||||
|
||||
/// 创建预约请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateAppointmentReq {
|
||||
pub patient_id: Uuid,
|
||||
pub doctor_id: Uuid,
|
||||
pub schedule_id: Uuid,
|
||||
pub appointment_date: String,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新预约状态请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateAppointmentStatusReq {
|
||||
pub status: String,
|
||||
pub cancel_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// 预约响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AppointmentResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub doctor_id: Uuid,
|
||||
pub schedule_id: Uuid,
|
||||
pub appointment_date: String,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
pub status: String,
|
||||
pub reason: Option<String>,
|
||||
pub cancel_reason: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 排班列表查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct ScheduleListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub doctor_id: Option<Uuid>,
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
}
|
||||
|
||||
/// 创建排班请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateScheduleReq {
|
||||
pub doctor_id: Uuid,
|
||||
pub schedule_date: String,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
pub max_appointments: i32,
|
||||
pub slot_duration_minutes: Option<i32>,
|
||||
}
|
||||
|
||||
/// 更新排班请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateScheduleReq {
|
||||
pub start_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
pub max_appointments: Option<i32>,
|
||||
pub slot_duration_minutes: Option<i32>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 排班响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ScheduleResp {
|
||||
pub id: Uuid,
|
||||
pub doctor_id: Uuid,
|
||||
pub schedule_date: String,
|
||||
pub start_time: String,
|
||||
pub end_time: String,
|
||||
pub max_appointments: i32,
|
||||
pub current_appointments: i32,
|
||||
pub slot_duration_minutes: Option<i32>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 日历视图查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct CalendarViewParams {
|
||||
pub doctor_id: Option<Uuid>,
|
||||
pub start_date: String,
|
||||
pub end_date: String,
|
||||
}
|
||||
|
||||
/// 日历视图单个日期条目
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct CalendarDayEntry {
|
||||
pub date: String,
|
||||
pub schedules: Vec<ScheduleResp>,
|
||||
pub appointments: Vec<AppointmentResp>,
|
||||
}
|
||||
|
||||
/// 日历视图响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct CalendarViewResp {
|
||||
pub days: Vec<CalendarDayEntry>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler — 预约排班 (7 个端点)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/v1/health/appointments — 预约列表
|
||||
pub async fn list_appointments<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<AppointmentListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<AppointmentResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/appointments — 创建预约
|
||||
pub async fn create_appointment<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Json(_req): Json<CreateAppointmentReq>,
|
||||
) -> Result<Json<ApiResponse<AppointmentResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/health/appointments/{id}/status — 更新预约状态
|
||||
pub async fn update_appointment_status<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
Json(_req): Json<UpdateAppointmentStatusReq>,
|
||||
) -> Result<Json<ApiResponse<AppointmentResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// GET /api/v1/health/schedules — 排班列表
|
||||
pub async fn list_schedules<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<ScheduleListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<ScheduleResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/schedules — 创建排班
|
||||
pub async fn create_schedule<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Json(_req): Json<CreateScheduleReq>,
|
||||
) -> Result<Json<ApiResponse<ScheduleResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/health/schedules/{id} — 更新排班
|
||||
pub async fn update_schedule<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
Json(_req): Json<UpdateScheduleReq>,
|
||||
) -> Result<Json<ApiResponse<ScheduleResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// GET /api/v1/health/calendar — 日历视图
|
||||
pub async fn calendar_view<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<CalendarViewParams>,
|
||||
) -> Result<Json<ApiResponse<CalendarViewResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
142
crates/erp-health/src/handler/consultation_handler.rs
Normal file
142
crates/erp-health/src/handler/consultation_handler.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTO — 咨询管理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 会话列表查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct SessionListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub patient_id: Option<Uuid>,
|
||||
pub doctor_id: Option<Uuid>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// 会话响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ConsultationSessionResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub doctor_id: Uuid,
|
||||
pub subject: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 消息列表查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct MessageListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// 创建消息请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateConsultationMessageReq {
|
||||
pub content: String,
|
||||
pub message_type: Option<String>,
|
||||
}
|
||||
|
||||
/// 消息响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ConsultationMessageResp {
|
||||
pub id: Uuid,
|
||||
pub session_id: Uuid,
|
||||
pub sender_id: Uuid,
|
||||
pub sender_type: String,
|
||||
pub content: String,
|
||||
pub message_type: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// 导出会话请求
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct ExportSessionsParams {
|
||||
pub patient_id: Option<Uuid>,
|
||||
pub doctor_id: Option<Uuid>,
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler — 咨询管理 (5 个端点)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/v1/health/consultations/sessions — 会话列表
|
||||
pub async fn list_sessions<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<SessionListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<ConsultationSessionResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// GET /api/v1/health/consultations/sessions/{id}/messages — 消息列表
|
||||
pub async fn list_messages<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_session_id): Path<Uuid>,
|
||||
Query(_params): Query<MessageListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<ConsultationMessageResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/health/consultations/sessions/{id}/close — 关闭会话
|
||||
pub async fn close_session<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<ConsultationSessionResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/consultations/sessions/{id}/messages — 创建消息
|
||||
pub async fn create_message<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_session_id): Path<Uuid>,
|
||||
Json(_req): Json<CreateConsultationMessageReq>,
|
||||
) -> Result<Json<ApiResponse<ConsultationMessageResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// GET /api/v1/health/consultations/export — 导出会话
|
||||
pub async fn export_sessions<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<ExportSessionsParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<ConsultationSessionResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
136
crates/erp-health/src/handler/doctor_handler.rs
Normal file
136
crates/erp-health/src/handler/doctor_handler.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTO — 医护管理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 医护列表查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct DoctorListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
/// 按姓名模糊搜索
|
||||
pub search: Option<String>,
|
||||
/// 按科室筛选
|
||||
pub department: Option<String>,
|
||||
/// 按职称筛选
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
/// 创建医护档案请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateDoctorReq {
|
||||
pub user_id: Uuid,
|
||||
pub name: String,
|
||||
pub department: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub specialty: Option<String>,
|
||||
pub license_number: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新医护档案请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateDoctorReq {
|
||||
pub name: Option<String>,
|
||||
pub department: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub specialty: Option<String>,
|
||||
pub license_number: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 医护档案响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DoctorResp {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub name: String,
|
||||
pub department: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub specialty: Option<String>,
|
||||
pub license_number: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler — 医护管理 (5 个端点)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/v1/health/doctors — 医护档案列表
|
||||
pub async fn list_doctors<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<DoctorListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<DoctorResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/doctors — 创建医护档案
|
||||
pub async fn create_doctor<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Json(_req): Json<CreateDoctorReq>,
|
||||
) -> Result<Json<ApiResponse<DoctorResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// GET /api/v1/health/doctors/{id} — 获取医护档案详情
|
||||
pub async fn get_doctor<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<DoctorResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/health/doctors/{id} — 更新医护档案
|
||||
pub async fn update_doctor<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
Json(_req): Json<UpdateDoctorReq>,
|
||||
) -> Result<Json<ApiResponse<DoctorResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/health/doctors/{id} — 删除医护档案
|
||||
pub async fn delete_doctor<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
178
crates/erp-health/src/handler/follow_up_handler.rs
Normal file
178
crates/erp-health/src/handler/follow_up_handler.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTO — 随访管理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 随访任务列表查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct FollowUpTaskListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub patient_id: Option<Uuid>,
|
||||
pub assigned_to: Option<Uuid>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// 创建随访任务请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateFollowUpTaskReq {
|
||||
pub patient_id: Uuid,
|
||||
pub task_type: String,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub due_date: String,
|
||||
pub assigned_to: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// 更新随访任务请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateFollowUpTaskReq {
|
||||
pub task_type: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub due_date: Option<String>,
|
||||
pub assigned_to: Option<Uuid>,
|
||||
pub status: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 随访任务响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct FollowUpTaskResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub task_type: String,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub due_date: String,
|
||||
pub assigned_to: Option<Uuid>,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 创建随访记录请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateFollowUpRecordReq {
|
||||
pub task_id: Uuid,
|
||||
pub contact_method: String,
|
||||
pub content: String,
|
||||
pub outcome: Option<String>,
|
||||
pub next_follow_up_date: Option<String>,
|
||||
}
|
||||
|
||||
/// 随访记录列表查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct FollowUpRecordListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub task_id: Option<Uuid>,
|
||||
pub patient_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// 随访记录响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct FollowUpRecordResp {
|
||||
pub id: Uuid,
|
||||
pub task_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub contact_method: String,
|
||||
pub content: String,
|
||||
pub outcome: Option<String>,
|
||||
pub next_follow_up_date: Option<String>,
|
||||
pub created_by: Uuid,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler — 随访管理 (6 个端点)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/v1/health/follow-up/tasks — 随访任务列表
|
||||
pub async fn list_tasks<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<FollowUpTaskListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<FollowUpTaskResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/follow-up/tasks — 创建随访任务
|
||||
pub async fn create_task<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Json(_req): Json<CreateFollowUpTaskReq>,
|
||||
) -> Result<Json<ApiResponse<FollowUpTaskResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/health/follow-up/tasks/{id} — 更新随访任务
|
||||
pub async fn update_task<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
Json(_req): Json<UpdateFollowUpTaskReq>,
|
||||
) -> Result<Json<ApiResponse<FollowUpTaskResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/health/follow-up/tasks/{id} — 删除随访任务
|
||||
pub async fn delete_task<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/follow-up/records — 创建随访记录
|
||||
pub async fn create_record<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Json(_req): Json<CreateFollowUpRecordReq>,
|
||||
) -> Result<Json<ApiResponse<FollowUpRecordResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// GET /api/v1/health/follow-up/records — 随访记录列表
|
||||
pub async fn list_records<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<FollowUpRecordListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<FollowUpRecordResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
408
crates/erp-health/src/handler/health_data_handler.rs
Normal file
408
crates/erp-health/src/handler/health_data_handler.rs
Normal file
@@ -0,0 +1,408 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTO — 健康数据
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 生命体征列表查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct VitalSignsListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub patient_id: Uuid,
|
||||
}
|
||||
|
||||
/// 创建生命体征请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateVitalSignsReq {
|
||||
pub patient_id: Uuid,
|
||||
pub blood_pressure_systolic: Option<i32>,
|
||||
pub blood_pressure_diastolic: Option<i32>,
|
||||
pub heart_rate: Option<i32>,
|
||||
pub temperature: Option<f64>,
|
||||
pub blood_oxygen: Option<i32>,
|
||||
pub weight: Option<f64>,
|
||||
pub height: Option<f64>,
|
||||
pub measured_at: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新生命体征请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateVitalSignsReq {
|
||||
pub blood_pressure_systolic: Option<i32>,
|
||||
pub blood_pressure_diastolic: Option<i32>,
|
||||
pub heart_rate: Option<i32>,
|
||||
pub temperature: Option<f64>,
|
||||
pub blood_oxygen: Option<i32>,
|
||||
pub weight: Option<f64>,
|
||||
pub height: Option<f64>,
|
||||
pub measured_at: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 生命体征响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct VitalSignsResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub blood_pressure_systolic: Option<i32>,
|
||||
pub blood_pressure_diastolic: Option<i32>,
|
||||
pub heart_rate: Option<i32>,
|
||||
pub temperature: Option<f64>,
|
||||
pub blood_oxygen: Option<i32>,
|
||||
pub weight: Option<f64>,
|
||||
pub height: Option<f64>,
|
||||
pub measured_at: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 化验报告列表查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct LabReportListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub patient_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// 创建化验报告请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateLabReportReq {
|
||||
pub patient_id: Uuid,
|
||||
pub report_type: String,
|
||||
pub report_date: String,
|
||||
pub indicators: serde_json::Value,
|
||||
pub file_url: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新化验报告请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateLabReportReq {
|
||||
pub report_type: Option<String>,
|
||||
pub report_date: Option<String>,
|
||||
pub indicators: Option<serde_json::Value>,
|
||||
pub file_url: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 化验报告响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct LabReportResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub report_type: String,
|
||||
pub report_date: String,
|
||||
pub indicators: serde_json::Value,
|
||||
pub file_url: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 健康档案列表查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct HealthRecordListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub patient_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// 创建健康档案请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateHealthRecordReq {
|
||||
pub patient_id: Uuid,
|
||||
pub record_type: String,
|
||||
pub title: String,
|
||||
pub content: serde_json::Value,
|
||||
pub record_date: String,
|
||||
}
|
||||
|
||||
/// 更新健康档案请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateHealthRecordReq {
|
||||
pub record_type: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub content: Option<serde_json::Value>,
|
||||
pub record_date: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 健康档案响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct HealthRecordResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub record_type: String,
|
||||
pub title: String,
|
||||
pub content: serde_json::Value,
|
||||
pub record_date: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 趋势分析列表查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct TrendListParams {
|
||||
pub patient_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// 生成趋势请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct GenerateTrendReq {
|
||||
pub patient_id: Uuid,
|
||||
pub indicator_name: String,
|
||||
pub start_date: String,
|
||||
pub end_date: String,
|
||||
}
|
||||
|
||||
/// 趋势分析响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TrendResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub indicator_name: String,
|
||||
pub trend_data: serde_json::Value,
|
||||
pub analysis_summary: Option<String>,
|
||||
pub generated_at: String,
|
||||
}
|
||||
|
||||
/// 指标时间序列查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct IndicatorTimeseriesParams {
|
||||
pub patient_id: Uuid,
|
||||
pub indicator_name: String,
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
}
|
||||
|
||||
/// 指标时间序列数据点
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TimeseriesDataPoint {
|
||||
pub date: String,
|
||||
pub value: f64,
|
||||
pub unit: Option<String>,
|
||||
}
|
||||
|
||||
/// 指标时间序列响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct IndicatorTimeseriesResp {
|
||||
pub indicator_name: String,
|
||||
pub patient_id: Uuid,
|
||||
pub data_points: Vec<TimeseriesDataPoint>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler — 健康数据 (15 个端点)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/v1/health/vital-signs — 生命体征列表
|
||||
pub async fn list_vital_signs<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<VitalSignsListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<VitalSignsResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/vital-signs — 创建生命体征记录
|
||||
pub async fn create_vital_signs<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Json(_req): Json<CreateVitalSignsReq>,
|
||||
) -> Result<Json<ApiResponse<VitalSignsResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/health/vital-signs/{id} — 更新生命体征记录
|
||||
pub async fn update_vital_signs<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
Json(_req): Json<UpdateVitalSignsReq>,
|
||||
) -> Result<Json<ApiResponse<VitalSignsResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/health/vital-signs/{id} — 删除生命体征记录
|
||||
pub async fn delete_vital_signs<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// GET /api/v1/health/lab-reports — 化验报告列表
|
||||
pub async fn list_lab_reports<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<LabReportListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<LabReportResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/lab-reports — 创建化验报告
|
||||
pub async fn create_lab_report<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Json(_req): Json<CreateLabReportReq>,
|
||||
) -> Result<Json<ApiResponse<LabReportResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/health/lab-reports/{id} — 更新化验报告
|
||||
pub async fn update_lab_report<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
Json(_req): Json<UpdateLabReportReq>,
|
||||
) -> Result<Json<ApiResponse<LabReportResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/health/lab-reports/{id} — 删除化验报告
|
||||
pub async fn delete_lab_report<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// GET /api/v1/health/records — 健康档案列表
|
||||
pub async fn list_health_records<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<HealthRecordListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<HealthRecordResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/records — 创建健康档案
|
||||
pub async fn create_health_record<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Json(_req): Json<CreateHealthRecordReq>,
|
||||
) -> Result<Json<ApiResponse<HealthRecordResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/health/records/{id} — 更新健康档案
|
||||
pub async fn update_health_record<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
Json(_req): Json<UpdateHealthRecordReq>,
|
||||
) -> Result<Json<ApiResponse<HealthRecordResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/health/records/{id} — 删除健康档案
|
||||
pub async fn delete_health_record<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// GET /api/v1/health/trends — 趋势分析列表
|
||||
pub async fn list_trends<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<TrendListParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<TrendResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/trends/generate — 生成趋势分析
|
||||
pub async fn generate_trend<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Json(_req): Json<GenerateTrendReq>,
|
||||
) -> Result<Json<ApiResponse<TrendResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// GET /api/v1/health/trends/timeseries — 获取指标时间序列
|
||||
pub async fn get_indicator_timeseries<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<IndicatorTimeseriesParams>,
|
||||
) -> Result<Json<ApiResponse<IndicatorTimeseriesResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
6
crates/erp-health/src/handler/mod.rs
Normal file
6
crates/erp-health/src/handler/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod appointment_handler;
|
||||
pub mod consultation_handler;
|
||||
pub mod doctor_handler;
|
||||
pub mod follow_up_handler;
|
||||
pub mod health_data_handler;
|
||||
pub mod patient_handler;
|
||||
311
crates/erp-health/src/handler/patient_handler.rs
Normal file
311
crates/erp-health/src/handler/patient_handler.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTO — 患者管理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 患者列表查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct PatientListParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
/// 按姓名/身份证号模糊搜索
|
||||
pub search: Option<String>,
|
||||
/// 按标签筛选
|
||||
pub tag_id: Option<Uuid>,
|
||||
/// 按负责医生筛选
|
||||
pub doctor_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// 创建患者请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreatePatientReq {
|
||||
pub name: String,
|
||||
pub id_card: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub gender: Option<String>,
|
||||
pub birth_date: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub emergency_contact: Option<String>,
|
||||
pub emergency_phone: Option<String>,
|
||||
pub medical_notes: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新患者请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdatePatientReq {
|
||||
pub name: Option<String>,
|
||||
pub id_card: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub gender: Option<String>,
|
||||
pub birth_date: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub emergency_contact: Option<String>,
|
||||
pub emergency_phone: Option<String>,
|
||||
pub medical_notes: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 患者标签管理请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct ManagePatientTagsReq {
|
||||
pub tag_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
/// 患者响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct PatientResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub id_card: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub gender: Option<String>,
|
||||
pub birth_date: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub emergency_contact: Option<String>,
|
||||
pub emergency_phone: Option<String>,
|
||||
pub medical_notes: Option<String>,
|
||||
pub tags: Vec<PatientTagResp>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 患者标签响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct PatientTagResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
/// 健康摘要响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct HealthSummaryResp {
|
||||
pub patient_id: Uuid,
|
||||
pub latest_vital_signs: Option<serde_json::Value>,
|
||||
pub latest_lab_report: Option<serde_json::Value>,
|
||||
pub upcoming_appointments: u64,
|
||||
pub pending_follow_ups: u64,
|
||||
}
|
||||
|
||||
/// 家庭成员请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateFamilyMemberReq {
|
||||
pub name: String,
|
||||
pub relationship: String,
|
||||
pub phone: Option<String>,
|
||||
pub id_card: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新家庭成员请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateFamilyMemberReq {
|
||||
pub name: Option<String>,
|
||||
pub relationship: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub id_card: Option<String>,
|
||||
}
|
||||
|
||||
/// 家庭成员响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct FamilyMemberResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub name: String,
|
||||
pub relationship: String,
|
||||
pub phone: Option<String>,
|
||||
pub id_card: Option<String>,
|
||||
}
|
||||
|
||||
/// 分配医生请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct AssignDoctorReq {
|
||||
pub doctor_id: Uuid,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler — 患者管理 (13 个端点)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/v1/health/patients — 患者列表
|
||||
pub async fn list_patients<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Query(_params): Query<PatientListParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PatientResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/patients — 创建患者
|
||||
pub async fn create_patient<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Json(_req): Json<CreatePatientReq>,
|
||||
) -> Result<Json<ApiResponse<PatientResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// GET /api/v1/health/patients/{id} — 获取患者详情
|
||||
pub async fn get_patient<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PatientResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/health/patients/{id} — 更新患者
|
||||
pub async fn update_patient<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
Json(_req): Json<UpdatePatientReq>,
|
||||
) -> Result<Json<ApiResponse<PatientResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/health/patients/{id} — 删除患者(软删除)
|
||||
pub async fn delete_patient<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/patients/{id}/tags — 管理患者标签
|
||||
pub async fn manage_patient_tags<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
Json(_req): Json<ManagePatientTagsReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// GET /api/v1/health/patients/{id}/health-summary — 获取患者健康摘要
|
||||
pub async fn get_health_summary<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<HealthSummaryResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// GET /api/v1/health/patients/{id}/family-members — 家庭成员列表
|
||||
pub async fn list_family_members<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<Vec<FamilyMemberResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/patients/{id}/family-members — 创建家庭成员
|
||||
pub async fn create_family_member<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
Json(_req): Json<CreateFamilyMemberReq>,
|
||||
) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/health/patients/{patient_id}/family-members/{member_id} — 更新家庭成员
|
||||
pub async fn update_family_member<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path((_patient_id, _member_id)): Path<(Uuid, Uuid)>,
|
||||
Json(_req): Json<UpdateFamilyMemberReq>,
|
||||
) -> Result<Json<ApiResponse<FamilyMemberResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/health/patients/{patient_id}/family-members/{member_id} — 删除家庭成员
|
||||
pub async fn delete_family_member<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path((_patient_id, _member_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/health/patients/{id}/doctors — 分配负责医生
|
||||
pub async fn assign_doctor<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path(_id): Path<Uuid>,
|
||||
Json(_req): Json<AssignDoctorReq>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/health/patients/{patient_id}/doctors/{doctor_id} — 移除负责医生
|
||||
pub async fn remove_doctor<S>(
|
||||
State(_state): State<HealthState>,
|
||||
Extension(_ctx): Extension<TenantContext>,
|
||||
Path((_patient_id, _doctor_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Err(AppError::Internal("Not implemented yet".into()))
|
||||
}
|
||||
11
crates/erp-health/src/lib.rs
Normal file
11
crates/erp-health/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod dto;
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod handler;
|
||||
pub mod module;
|
||||
pub mod service;
|
||||
pub mod state;
|
||||
|
||||
pub use module::HealthModule;
|
||||
pub use state::HealthState;
|
||||
322
crates/erp-health/src/module.rs
Normal file
322
crates/erp-health/src/module.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
use axum::Router;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::module::{ErpModule, PermissionDescriptor};
|
||||
|
||||
use crate::handler::{
|
||||
appointment_handler, consultation_handler, doctor_handler, follow_up_handler,
|
||||
health_data_handler, patient_handler,
|
||||
};
|
||||
|
||||
pub struct HealthModule;
|
||||
|
||||
impl HealthModule {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn public_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::state::HealthState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
}
|
||||
|
||||
pub fn protected_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::state::HealthState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
// 患者管理
|
||||
.route(
|
||||
"/health/patients",
|
||||
axum::routing::get(patient_handler::list_patients)
|
||||
.post(patient_handler::create_patient),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}",
|
||||
axum::routing::get(patient_handler::get_patient)
|
||||
.put(patient_handler::update_patient)
|
||||
.delete(patient_handler::delete_patient),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/tags",
|
||||
axum::routing::post(patient_handler::manage_patient_tags),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/health-summary",
|
||||
axum::routing::get(patient_handler::get_health_summary),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/family-members",
|
||||
axum::routing::get(patient_handler::list_family_members)
|
||||
.post(patient_handler::create_family_member),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/family-members/{fid}",
|
||||
axum::routing::put(patient_handler::update_family_member)
|
||||
.delete(patient_handler::delete_family_member),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/doctors",
|
||||
axum::routing::post(patient_handler::assign_doctor),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/doctors/{did}",
|
||||
axum::routing::delete(patient_handler::remove_doctor),
|
||||
)
|
||||
// 健康数据
|
||||
.route(
|
||||
"/health/patients/{id}/vital-signs",
|
||||
axum::routing::get(health_data_handler::list_vital_signs)
|
||||
.post(health_data_handler::create_vital_signs),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/vital-signs/{vid}",
|
||||
axum::routing::put(health_data_handler::update_vital_signs)
|
||||
.delete(health_data_handler::delete_vital_signs),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/lab-reports",
|
||||
axum::routing::get(health_data_handler::list_lab_reports)
|
||||
.post(health_data_handler::create_lab_report),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/lab-reports/{rid}",
|
||||
axum::routing::put(health_data_handler::update_lab_report)
|
||||
.delete(health_data_handler::delete_lab_report),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/health-records",
|
||||
axum::routing::get(health_data_handler::list_health_records)
|
||||
.post(health_data_handler::create_health_record),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/health-records/{rid}",
|
||||
axum::routing::put(health_data_handler::update_health_record)
|
||||
.delete(health_data_handler::delete_health_record),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/trends",
|
||||
axum::routing::get(health_data_handler::list_trends),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/trends/generate",
|
||||
axum::routing::post(health_data_handler::generate_trend),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/trends/{indicator}",
|
||||
axum::routing::get(health_data_handler::get_indicator_timeseries),
|
||||
)
|
||||
// 预约排班
|
||||
.route(
|
||||
"/health/appointments",
|
||||
axum::routing::get(appointment_handler::list_appointments)
|
||||
.post(appointment_handler::create_appointment),
|
||||
)
|
||||
.route(
|
||||
"/health/appointments/{id}/status",
|
||||
axum::routing::put(appointment_handler::update_appointment_status),
|
||||
)
|
||||
.route(
|
||||
"/health/doctor-schedules",
|
||||
axum::routing::get(appointment_handler::list_schedules)
|
||||
.post(appointment_handler::create_schedule),
|
||||
)
|
||||
.route(
|
||||
"/health/doctor-schedules/{id}",
|
||||
axum::routing::put(appointment_handler::update_schedule),
|
||||
)
|
||||
.route(
|
||||
"/health/doctor-schedules/calendar",
|
||||
axum::routing::get(appointment_handler::calendar_view),
|
||||
)
|
||||
// 随访管理
|
||||
.route(
|
||||
"/health/follow-up-tasks",
|
||||
axum::routing::get(follow_up_handler::list_tasks)
|
||||
.post(follow_up_handler::create_task),
|
||||
)
|
||||
.route(
|
||||
"/health/follow-up-tasks/{id}",
|
||||
axum::routing::put(follow_up_handler::update_task)
|
||||
.delete(follow_up_handler::delete_task),
|
||||
)
|
||||
.route(
|
||||
"/health/follow-up-tasks/{id}/records",
|
||||
axum::routing::post(follow_up_handler::create_record),
|
||||
)
|
||||
.route(
|
||||
"/health/follow-up-records",
|
||||
axum::routing::get(follow_up_handler::list_records),
|
||||
)
|
||||
// 咨询管理
|
||||
.route(
|
||||
"/health/consultation-sessions",
|
||||
axum::routing::get(consultation_handler::list_sessions),
|
||||
)
|
||||
.route(
|
||||
"/health/consultation-sessions/{id}/messages",
|
||||
axum::routing::get(consultation_handler::list_messages),
|
||||
)
|
||||
.route(
|
||||
"/health/consultation-sessions/{id}/close",
|
||||
axum::routing::put(consultation_handler::close_session),
|
||||
)
|
||||
.route(
|
||||
"/health/consultation-messages",
|
||||
axum::routing::post(consultation_handler::create_message),
|
||||
)
|
||||
.route(
|
||||
"/health/consultation-sessions/export",
|
||||
axum::routing::get(consultation_handler::export_sessions),
|
||||
)
|
||||
// 医护管理
|
||||
.route(
|
||||
"/health/doctors",
|
||||
axum::routing::get(doctor_handler::list_doctors)
|
||||
.post(doctor_handler::create_doctor),
|
||||
)
|
||||
.route(
|
||||
"/health/doctors/{id}",
|
||||
axum::routing::get(doctor_handler::get_doctor)
|
||||
.put(doctor_handler::update_doctor)
|
||||
.delete(doctor_handler::delete_doctor),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HealthModule {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ErpModule for HealthModule {
|
||||
fn name(&self) -> &str {
|
||||
"health"
|
||||
}
|
||||
|
||||
fn version(&self) -> &str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<&str> {
|
||||
vec!["auth"]
|
||||
}
|
||||
|
||||
fn register_event_handlers(&self, bus: &EventBus) {
|
||||
crate::event::register_handlers(bus);
|
||||
}
|
||||
|
||||
async fn on_tenant_created(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<()> {
|
||||
crate::service::seed::seed_tenant_health(db, tenant_id)
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||
tracing::info!(tenant_id = %tenant_id, "Health module tenant initialized");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_tenant_deleted(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<()> {
|
||||
crate::service::seed::soft_delete_tenant_data(db, tenant_id)
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
|
||||
tracing::info!(tenant_id = %tenant_id, "Health module tenant data soft-deleted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||||
vec![
|
||||
PermissionDescriptor {
|
||||
code: "health.patient.list".into(),
|
||||
name: "查看患者列表".into(),
|
||||
description: "查看和搜索患者列表、详情".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.patient.manage".into(),
|
||||
name: "管理患者".into(),
|
||||
description: "创建、编辑、删除患者".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.health-data.list".into(),
|
||||
name: "查看健康数据".into(),
|
||||
description: "查看体检记录、监测数据、化验报告".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.health-data.manage".into(),
|
||||
name: "管理健康数据".into(),
|
||||
description: "录入、编辑、删除健康数据".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.appointment.list".into(),
|
||||
name: "查看预约".into(),
|
||||
description: "查看预约列表和排班".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.appointment.manage".into(),
|
||||
name: "管理预约".into(),
|
||||
description: "创建、确认、取消预约".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.follow-up.list".into(),
|
||||
name: "查看随访".into(),
|
||||
description: "查看随访任务和记录".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.follow-up.manage".into(),
|
||||
name: "管理随访".into(),
|
||||
description: "创建、分配、完成随访任务".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.consultation.list".into(),
|
||||
name: "查看咨询".into(),
|
||||
description: "查看咨询会话和消息记录".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.consultation.manage".into(),
|
||||
name: "管理咨询".into(),
|
||||
description: "关闭会话、导出记录".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.doctor.list".into(),
|
||||
name: "查看医护".into(),
|
||||
description: "查看医护列表和详情".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.doctor.manage".into(),
|
||||
name: "管理医护".into(),
|
||||
description: "创建、编辑医护档案、排班".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
120
crates/erp-health/src/service/appointment_service.rs
Normal file
120
crates/erp-health/src/service/appointment_service.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
//! 预约排班 Service — 预约CRUD、排班管理、日历视图、原子CAS预约
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::types::{PaginatedResponse, Pagination};
|
||||
|
||||
use crate::dto::appointment_dto::{
|
||||
AppointmentResp, CalendarDayResp, CreateAppointmentReq, CreateScheduleReq,
|
||||
ScheduleResp, UpdateAppointmentStatusReq, UpdateScheduleReq,
|
||||
};
|
||||
use crate::error::HealthResult;
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 预约管理 (Appointments)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 预约列表(分页 + 多条件筛选)
|
||||
pub async fn list_appointments(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
pagination: Pagination,
|
||||
status: Option<String>,
|
||||
patient_id: Option<Uuid>,
|
||||
doctor_id: Option<Uuid>,
|
||||
date: Option<NaiveDate>,
|
||||
) -> HealthResult<PaginatedResponse<AppointmentResp>> {
|
||||
let _ = (state, tenant_id, pagination, status, patient_id, doctor_id, date);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 创建预约(原子 CAS 占位,防止超额预约)
|
||||
pub async fn create_appointment(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
req: CreateAppointmentReq,
|
||||
user_id: Option<Uuid>,
|
||||
) -> HealthResult<AppointmentResp> {
|
||||
let _ = (state, tenant_id, req, user_id);
|
||||
// 实现时需要:
|
||||
// 1. 查找对应排班档位
|
||||
// 2. 原子 CAS: UPDATE doctor_schedule SET current_appointments = current_appointments + 1
|
||||
// WHERE id = ? AND current_appointments < max_appointments
|
||||
// 3. CAS 失败返回 ScheduleFull 错误
|
||||
// 4. 创建预约记录
|
||||
// 5. 发布 appointment.created 事件
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 更新预约状态(确认/取消/完成/未到)
|
||||
pub async fn update_appointment_status(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
appointment_id: Uuid,
|
||||
req: UpdateAppointmentStatusReq,
|
||||
version: i32,
|
||||
) -> HealthResult<AppointmentResp> {
|
||||
let _ = (state, tenant_id, appointment_id, req, version);
|
||||
// 实现时需要:
|
||||
// 1. 状态机校验:pending -> confirmed/cancelled, confirmed -> completed/no_show/cancelled
|
||||
// 2. 取消时释放排班名额(原子减 1)
|
||||
// 3. 发布 appointment.confirmed / appointment.cancelled 事件
|
||||
todo!()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 排班管理 (Doctor Schedules)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 排班列表
|
||||
pub async fn list_schedules(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
pagination: Pagination,
|
||||
doctor_id: Option<Uuid>,
|
||||
date: Option<NaiveDate>,
|
||||
) -> HealthResult<PaginatedResponse<ScheduleResp>> {
|
||||
let _ = (state, tenant_id, pagination, doctor_id, date);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 创建排班
|
||||
pub async fn create_schedule(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
req: CreateScheduleReq,
|
||||
user_id: Option<Uuid>,
|
||||
) -> HealthResult<ScheduleResp> {
|
||||
let _ = (state, tenant_id, req, user_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 更新排班(乐观锁)
|
||||
pub async fn update_schedule(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
schedule_id: Uuid,
|
||||
req: UpdateScheduleReq,
|
||||
version: i32,
|
||||
) -> HealthResult<ScheduleResp> {
|
||||
let _ = (state, tenant_id, schedule_id, req, version);
|
||||
todo!()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 日历视图
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 日历视图(按日期范围返回每天的排班汇总)
|
||||
pub async fn calendar_view(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
start_date: NaiveDate,
|
||||
end_date: NaiveDate,
|
||||
doctor_id: Option<Uuid>,
|
||||
) -> HealthResult<Vec<CalendarDayResp>> {
|
||||
let _ = (state, tenant_id, start_date, end_date, doctor_id);
|
||||
todo!()
|
||||
}
|
||||
83
crates/erp-health/src/service/consultation_service.rs
Normal file
83
crates/erp-health/src/service/consultation_service.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
//! 咨询管理 Service — 会话管理、消息收发、会话关闭、导出
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::types::{PaginatedResponse, Pagination};
|
||||
|
||||
use crate::dto::consultation_dto::{
|
||||
CreateMessageReq, MessageResp, SessionQuery, SessionResp,
|
||||
};
|
||||
use crate::error::HealthResult;
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 咨询会话 (Consultation Sessions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 咨询会话列表(分页 + 多条件筛选)
|
||||
pub async fn list_sessions(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
query: SessionQuery,
|
||||
) -> HealthResult<PaginatedResponse<SessionResp>> {
|
||||
let _ = (state, tenant_id, query);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 关闭咨询会话
|
||||
pub async fn close_session(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
session_id: Uuid,
|
||||
version: i32,
|
||||
) -> HealthResult<SessionResp> {
|
||||
let _ = (state, tenant_id, session_id, version);
|
||||
// 实现时需要:
|
||||
// 1. 校验会话存在且状态为 active
|
||||
// 2. 更新状态为 closed
|
||||
// 3. 发布 consultation.closed 事件
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 导出咨询会话(按条件筛选后返回汇总数据)
|
||||
pub async fn export_sessions(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
status: Option<String>,
|
||||
patient_id: Option<Uuid>,
|
||||
doctor_id: Option<Uuid>,
|
||||
) -> HealthResult<Vec<SessionResp>> {
|
||||
let _ = (state, tenant_id, status, patient_id, doctor_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 咨询消息 (Consultation Messages)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 消息列表(按会话 ID 查询,分页)
|
||||
pub async fn list_messages(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
session_id: Uuid,
|
||||
pagination: Pagination,
|
||||
) -> HealthResult<PaginatedResponse<MessageResp>> {
|
||||
let _ = (state, tenant_id, session_id, pagination);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 发送消息
|
||||
pub async fn create_message(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
req: CreateMessageReq,
|
||||
) -> HealthResult<MessageResp> {
|
||||
let _ = (state, tenant_id, req);
|
||||
// 实现时需要:
|
||||
// 1. 校验会话存在且状态为 active
|
||||
// 2. 创建消息记录
|
||||
// 3. 更新会话的 last_message_at
|
||||
// 4. 根据发送者角色更新对方的 unread_count
|
||||
// 5. 发布 consultation.message.created 事件
|
||||
todo!()
|
||||
}
|
||||
161
crates/erp-health/src/service/follow_up_service.rs
Normal file
161
crates/erp-health/src/service/follow_up_service.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
//! 随访管理 Service — 随访任务CRUD、随访记录、状态流转
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::types::{PaginatedResponse, Pagination};
|
||||
|
||||
use crate::error::{HealthError, HealthResult};
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 随访任务 DTO(内部使用,follow_up_dto 尚未创建独立文件)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 创建随访任务请求
|
||||
#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct CreateFollowUpTaskReq {
|
||||
pub patient_id: Uuid,
|
||||
pub assigned_to: Option<Uuid>,
|
||||
pub follow_up_type: String,
|
||||
pub planned_date: NaiveDate,
|
||||
pub content_template: Option<String>,
|
||||
pub related_appointment_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// 更新随访任务请求
|
||||
#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateFollowUpTaskReq {
|
||||
pub assigned_to: Option<Uuid>,
|
||||
pub follow_up_type: Option<String>,
|
||||
pub planned_date: Option<NaiveDate>,
|
||||
pub content_template: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// 随访任务响应
|
||||
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct FollowUpTaskResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub assigned_to: Option<Uuid>,
|
||||
pub follow_up_type: String,
|
||||
pub planned_date: NaiveDate,
|
||||
pub status: String,
|
||||
pub content_template: Option<String>,
|
||||
pub related_appointment_id: Option<Uuid>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 创建随访记录请求
|
||||
#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct CreateFollowUpRecordReq {
|
||||
pub task_id: Uuid,
|
||||
pub executed_by: Option<Uuid>,
|
||||
pub executed_date: NaiveDate,
|
||||
pub result: String,
|
||||
pub patient_condition: Option<String>,
|
||||
pub medical_advice: Option<String>,
|
||||
pub next_follow_up_date: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
/// 随访记录响应
|
||||
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct FollowUpRecordResp {
|
||||
pub id: Uuid,
|
||||
pub task_id: Uuid,
|
||||
pub executed_by: Option<Uuid>,
|
||||
pub executed_date: NaiveDate,
|
||||
pub result: String,
|
||||
pub patient_condition: Option<String>,
|
||||
pub medical_advice: Option<String>,
|
||||
pub next_follow_up_date: Option<NaiveDate>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 随访任务
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 随访任务列表(分页 + 多条件筛选)
|
||||
pub async fn list_tasks(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
pagination: Pagination,
|
||||
patient_id: Option<Uuid>,
|
||||
assigned_to: Option<Uuid>,
|
||||
status: Option<String>,
|
||||
) -> HealthResult<PaginatedResponse<FollowUpTaskResp>> {
|
||||
let _ = (state, tenant_id, pagination, patient_id, assigned_to, status);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 创建随访任务
|
||||
pub async fn create_task(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
req: CreateFollowUpTaskReq,
|
||||
user_id: Option<Uuid>,
|
||||
) -> HealthResult<FollowUpTaskResp> {
|
||||
let _ = (state, tenant_id, req, user_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 更新随访任务(乐观锁)
|
||||
pub async fn update_task(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
task_id: Uuid,
|
||||
req: UpdateFollowUpTaskReq,
|
||||
version: i32,
|
||||
) -> HealthResult<FollowUpTaskResp> {
|
||||
let _ = (state, tenant_id, task_id, req, version);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 删除随访任务(软删除)
|
||||
pub async fn delete_task(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
task_id: Uuid,
|
||||
) -> HealthResult<()> {
|
||||
let _ = (state, tenant_id, task_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 随访记录
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 创建随访执行记录(同时将任务状态推进为 completed)
|
||||
pub async fn create_record(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
req: CreateFollowUpRecordReq,
|
||||
user_id: Option<Uuid>,
|
||||
) -> HealthResult<FollowUpRecordResp> {
|
||||
let _ = (state, tenant_id, req, user_id);
|
||||
// 实现时需要:
|
||||
// 1. 校验任务存在且状态为 in_progress / pending
|
||||
// 2. 创建随访记录
|
||||
// 3. 更新任务状态为 completed
|
||||
// 4. 如果设置了 next_follow_up_date,自动创建下一个随访任务
|
||||
// 5. 发布 follow_up.completed 事件
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 随访记录列表(分页)
|
||||
pub async fn list_records(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
pagination: Pagination,
|
||||
task_id: Option<Uuid>,
|
||||
patient_id: Option<Uuid>,
|
||||
) -> HealthResult<PaginatedResponse<FollowUpRecordResp>> {
|
||||
let _ = (state, tenant_id, pagination, task_id, patient_id);
|
||||
todo!()
|
||||
}
|
||||
207
crates/erp-health/src/service/health_data_service.rs
Normal file
207
crates/erp-health/src/service/health_data_service.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! 健康数据 Service — 体征记录、化验报告、体检记录、趋势分析
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::types::{PaginatedResponse, Pagination};
|
||||
|
||||
use crate::dto::health_data_dto::{
|
||||
CreateHealthRecordReq, CreateLabReportReq, CreateVitalSignsReq, HealthRecordResp,
|
||||
IndicatorTimeseriesResp, LabReportResp, TrendResp, UpdateVitalSignsReq,
|
||||
};
|
||||
use crate::error::HealthResult;
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 体征记录 (Vital Signs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 体征记录列表
|
||||
pub async fn list_vital_signs(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
pagination: Pagination,
|
||||
) -> HealthResult<PaginatedResponse<crate::dto::health_data_dto::VitalSignsResp>> {
|
||||
let _ = (state, tenant_id, patient_id, pagination);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 创建体征记录
|
||||
pub async fn create_vital_signs(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
req: CreateVitalSignsReq,
|
||||
user_id: Option<Uuid>,
|
||||
) -> HealthResult<crate::dto::health_data_dto::VitalSignsResp> {
|
||||
let _ = (state, tenant_id, patient_id, req, user_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 更新体征记录(乐观锁)
|
||||
pub async fn update_vital_signs(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
vital_signs_id: Uuid,
|
||||
req: UpdateVitalSignsReq,
|
||||
version: i32,
|
||||
) -> HealthResult<crate::dto::health_data_dto::VitalSignsResp> {
|
||||
let _ = (state, tenant_id, patient_id, vital_signs_id, req, version);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 删除体征记录
|
||||
pub async fn delete_vital_signs(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
vital_signs_id: Uuid,
|
||||
) -> HealthResult<()> {
|
||||
let _ = (state, tenant_id, patient_id, vital_signs_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 化验报告 (Lab Reports)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 化验报告列表
|
||||
pub async fn list_lab_reports(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
pagination: Pagination,
|
||||
) -> HealthResult<PaginatedResponse<LabReportResp>> {
|
||||
let _ = (state, tenant_id, patient_id, pagination);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 创建化验报告
|
||||
pub async fn create_lab_report(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
req: CreateLabReportReq,
|
||||
user_id: Option<Uuid>,
|
||||
) -> HealthResult<LabReportResp> {
|
||||
let _ = (state, tenant_id, patient_id, req, user_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 更新化验报告(乐观锁)
|
||||
pub async fn update_lab_report(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
report_id: Uuid,
|
||||
req: CreateLabReportReq,
|
||||
version: i32,
|
||||
) -> HealthResult<LabReportResp> {
|
||||
let _ = (state, tenant_id, patient_id, report_id, req, version);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 删除化验报告
|
||||
pub async fn delete_lab_report(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
report_id: Uuid,
|
||||
) -> HealthResult<()> {
|
||||
let _ = (state, tenant_id, patient_id, report_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 体检记录 (Health Records)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 体检记录列表
|
||||
pub async fn list_health_records(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
pagination: Pagination,
|
||||
) -> HealthResult<PaginatedResponse<HealthRecordResp>> {
|
||||
let _ = (state, tenant_id, patient_id, pagination);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 创建体检记录
|
||||
pub async fn create_health_record(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
req: CreateHealthRecordReq,
|
||||
user_id: Option<Uuid>,
|
||||
) -> HealthResult<HealthRecordResp> {
|
||||
let _ = (state, tenant_id, patient_id, req, user_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 更新体检记录(乐观锁)
|
||||
pub async fn update_health_record(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
record_id: Uuid,
|
||||
req: CreateHealthRecordReq,
|
||||
version: i32,
|
||||
) -> HealthResult<HealthRecordResp> {
|
||||
let _ = (state, tenant_id, patient_id, record_id, req, version);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 删除体检记录
|
||||
pub async fn delete_health_record(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
record_id: Uuid,
|
||||
) -> HealthResult<()> {
|
||||
let _ = (state, tenant_id, patient_id, record_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 趋势分析 (Trends)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 趋势列表
|
||||
pub async fn list_trends(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
pagination: Pagination,
|
||||
) -> HealthResult<PaginatedResponse<TrendResp>> {
|
||||
let _ = (state, tenant_id, patient_id, pagination);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 生成趋势分析报告(基于历史体征 + 化验数据聚合)
|
||||
pub async fn generate_trend(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
period_start: NaiveDate,
|
||||
period_end: NaiveDate,
|
||||
user_id: Option<Uuid>,
|
||||
) -> HealthResult<TrendResp> {
|
||||
let _ = (state, tenant_id, patient_id, period_start, period_end, user_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 获取单个指标的时间序列数据
|
||||
pub async fn get_indicator_timeseries(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
indicator: String,
|
||||
start_date: Option<NaiveDate>,
|
||||
end_date: Option<NaiveDate>,
|
||||
) -> HealthResult<IndicatorTimeseriesResp> {
|
||||
let _ = (state, tenant_id, patient_id, indicator, start_date, end_date);
|
||||
todo!()
|
||||
}
|
||||
6
crates/erp-health/src/service/mod.rs
Normal file
6
crates/erp-health/src/service/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod appointment_service;
|
||||
pub mod consultation_service;
|
||||
pub mod follow_up_service;
|
||||
pub mod health_data_service;
|
||||
pub mod patient_service;
|
||||
pub mod seed;
|
||||
179
crates/erp-health/src/service/patient_service.rs
Normal file
179
crates/erp-health/src/service/patient_service.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
//! 患者管理 Service — 患者CRUD、家庭成员、标签、医生关联、健康摘要
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::types::{PaginatedResponse, Pagination};
|
||||
|
||||
use crate::dto::patient_dto::{
|
||||
CreatePatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp,
|
||||
UpdatePatientReq,
|
||||
};
|
||||
use crate::error::HealthResult;
|
||||
use crate::state::HealthState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 患者 CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 患者列表(分页 + 搜索 + 标签筛选)
|
||||
pub async fn list_patients(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
pagination: Pagination,
|
||||
search: Option<String>,
|
||||
tag_id: Option<Uuid>,
|
||||
) -> HealthResult<PaginatedResponse<PatientResp>> {
|
||||
let _ = (state, tenant_id, pagination, search, tag_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 创建患者
|
||||
pub async fn create_patient(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
user_id: Option<Uuid>,
|
||||
req: CreatePatientReq,
|
||||
) -> HealthResult<PatientResp> {
|
||||
let _ = (state, tenant_id, user_id, req);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 获取患者详情
|
||||
pub async fn get_patient(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> HealthResult<PatientResp> {
|
||||
let _ = (state, tenant_id, id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 更新患者信息(乐观锁)
|
||||
pub async fn update_patient(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
req: UpdatePatientReq,
|
||||
version: i32,
|
||||
) -> HealthResult<PatientResp> {
|
||||
let _ = (state, tenant_id, id, req, version);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 软删除患者
|
||||
pub async fn delete_patient(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> HealthResult<()> {
|
||||
let _ = (state, tenant_id, id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 标签管理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 管理患者标签(覆盖式:传入的 tag_ids 替换当前关联)
|
||||
pub async fn manage_patient_tags(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
req: ManageTagsReq,
|
||||
user_id: Option<Uuid>,
|
||||
) -> HealthResult<()> {
|
||||
let _ = (state, tenant_id, patient_id, req, user_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 健康摘要
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 获取患者健康摘要(最新体征 + 最新化验 + 待处理预约 + 待办随访)
|
||||
pub async fn get_health_summary(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
) -> HealthResult<serde_json::Value> {
|
||||
let _ = (state, tenant_id, patient_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 家庭成员
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 家庭成员列表
|
||||
pub async fn list_family_members(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
) -> HealthResult<Vec<FamilyMemberResp>> {
|
||||
let _ = (state, tenant_id, patient_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 创建家庭成员
|
||||
pub async fn create_family_member(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
req: FamilyMemberReq,
|
||||
user_id: Option<Uuid>,
|
||||
) -> HealthResult<FamilyMemberResp> {
|
||||
let _ = (state, tenant_id, patient_id, req, user_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 更新家庭成员(乐观锁)
|
||||
pub async fn update_family_member(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
family_member_id: Uuid,
|
||||
req: FamilyMemberReq,
|
||||
version: i32,
|
||||
) -> HealthResult<FamilyMemberResp> {
|
||||
let _ = (state, tenant_id, patient_id, family_member_id, req, version);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 删除家庭成员
|
||||
pub async fn delete_family_member(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
family_member_id: Uuid,
|
||||
) -> HealthResult<()> {
|
||||
let _ = (state, tenant_id, patient_id, family_member_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 患者-医生关联
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 分配负责医生
|
||||
pub async fn assign_doctor(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
doctor_id: Uuid,
|
||||
relationship_type: String,
|
||||
user_id: Option<Uuid>,
|
||||
) -> HealthResult<()> {
|
||||
let _ = (state, tenant_id, patient_id, doctor_id, relationship_type, user_id);
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 移除负责医生
|
||||
pub async fn remove_doctor(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
doctor_id: Uuid,
|
||||
) -> HealthResult<()> {
|
||||
let _ = (state, tenant_id, patient_id, doctor_id);
|
||||
todo!()
|
||||
}
|
||||
22
crates/erp-health/src/service/seed.rs
Normal file
22
crates/erp-health/src/service/seed.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! 租户初始化种子数据 — 创建默认标签、默认排班模板等
|
||||
|
||||
use sea_orm::DatabaseConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 初始化租户健康模块默认数据
|
||||
pub async fn seed_tenant_health(
|
||||
_db: &DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
tracing::info!(tenant_id = %tenant_id, "Seeding health module default data");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 软删除该租户下所有健康模块数据
|
||||
pub async fn soft_delete_tenant_data(
|
||||
_db: &DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
tracing::info!(tenant_id = %tenant_id, "Soft-deleting health module data for tenant");
|
||||
Ok(())
|
||||
}
|
||||
8
crates/erp-health/src/state.rs
Normal file
8
crates/erp-health/src/state.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use erp_core::events::EventBus;
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HealthState {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
}
|
||||
@@ -27,6 +27,7 @@ erp-config.workspace = true
|
||||
erp-workflow.workspace = true
|
||||
erp-message.workspace = true
|
||||
erp-plugin.workspace = true
|
||||
erp-health.workspace = true
|
||||
anyhow.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
@@ -41,6 +41,7 @@ mod m20260419_000038_fix_crm_permission_codes;
|
||||
mod m20260419_000039_entity_registry_columns;
|
||||
mod m20260419_000040_plugin_market;
|
||||
mod m20260419_000041_plugin_user_views;
|
||||
mod m20260423_000042_create_health_tables;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -89,6 +90,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260419_000039_entity_registry_columns::Migration),
|
||||
Box::new(m20260419_000040_plugin_market::Migration),
|
||||
Box::new(m20260419_000041_plugin_user_views::Migration),
|
||||
Box::new(m20260423_000042_create_health_tables::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -319,12 +319,21 @@ async fn main() -> anyhow::Result<()> {
|
||||
"Message module initialized"
|
||||
);
|
||||
|
||||
// Initialize health module
|
||||
let health_module = erp_health::HealthModule::new();
|
||||
tracing::info!(
|
||||
module = health_module.name(),
|
||||
version = health_module.version(),
|
||||
"Health module initialized"
|
||||
);
|
||||
|
||||
// Initialize module registry and register modules
|
||||
let registry = ModuleRegistry::new()
|
||||
.register(auth_module)
|
||||
.register(config_module)
|
||||
.register(workflow_module)
|
||||
.register(message_module);
|
||||
.register(message_module)
|
||||
.register(health_module);
|
||||
tracing::info!(
|
||||
module_count = registry.modules().len(),
|
||||
"Modules registered"
|
||||
@@ -431,6 +440,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.merge(erp_workflow::WorkflowModule::protected_routes())
|
||||
.merge(erp_message::MessageModule::protected_routes())
|
||||
.merge(erp_plugin::module::PluginModule::protected_routes())
|
||||
.merge(erp_health::HealthModule::protected_routes())
|
||||
.merge(handlers::audit_log::audit_log_router())
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
|
||||
@@ -96,3 +96,13 @@ impl FromRef<AppState> for erp_plugin::state::PluginState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow erp-health handlers to extract their required state.
|
||||
impl FromRef<AppState> for erp_health::HealthState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
Self {
|
||||
db: state.db.clone(),
|
||||
event_bus: state.event_bus.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
# 健康管理系统 — erp-health 模块设计规格
|
||||
|
||||
> **文档版本**: 1.0
|
||||
> **日期**: 2026-04-23
|
||||
> **状态**: 已确认
|
||||
> **范围**: V1 — 患者管理 + 健康数据 + 预约排班 + 随访管理 + 咨询管理
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目背景
|
||||
|
||||
### 1.1 产品定位
|
||||
|
||||
构建一个面向体检中心/医疗机构的**综合型健康管理平台**,以体检中心为数据源,汇集不同情况的患者,提供全生命周期的健康管理服务。
|
||||
|
||||
本系统从 ERP 平台底座分叉独立,作为 **Health Management System (HMS)** 产品演进。ERP 底座提供身份权限、工作流、消息通知、系统配置等基础能力,`erp-health` 作为原生 Rust 模块承载所有医疗业务逻辑。
|
||||
|
||||
### 1.2 系统架构
|
||||
|
||||
```
|
||||
📱 患者端(微信小程序) ──┐
|
||||
├──→ 🔀 API 网关 ──→ 🖥️ ERP 后端(HMS)
|
||||
👨⚕️ 医护端(小程序/H5) ──┘ │ │
|
||||
│ ├── erp-auth(用户/角色/权限)
|
||||
│ ├── erp-workflow(工作流引擎)
|
||||
│ ├── erp-message(消息通知)
|
||||
│ ├── erp-config(字典/配置)
|
||||
│ └── erp-health(健康管理)★ 新增
|
||||
│
|
||||
└──→ 💾 PostgreSQL + Redis
|
||||
```
|
||||
|
||||
**关键决策:**
|
||||
- ERP 只负责 **PC 管理后台**功能
|
||||
- 小程序(患者端/医护端)作为**独立系统**开发
|
||||
- 数据共享通过 **API 网关**实现
|
||||
- 健康管理使用**原生 Rust 模块**(非 WASM 插件),获得完整的数据库访问和自定义 API 能力
|
||||
|
||||
### 1.3 为什么不用 WASM 插件
|
||||
|
||||
| 限制 | 影响 |
|
||||
|------|------|
|
||||
| 实体上限 20 个 | 综合健康平台轻松超过 |
|
||||
| JSONB 存储 | 医疗数据需要强类型、索引、关联 |
|
||||
| 无自定义 API | 趋势分析、统计报表需要专用端点 |
|
||||
| 无文件上传 | 化验单、体检报告无法存储 |
|
||||
| WASM 沙箱限制 | 无法引入加密、AI、外部 API |
|
||||
|
||||
原生模块遵循现有模式(如 erp-auth、erp-workflow)。**注意:**`ErpModule` trait 没有 `register_routes` 方法。模块通过固有方法 `public_routes()` 和 `protected_routes()` 暴露路由,在 `erp-server` 的 `main.rs` 中通过 `.nest("/api/v1/health", HealthModule::protected_routes())` 集成。通过 EventBus 通信,未来可平滑拆分为独立微服务。
|
||||
|
||||
---
|
||||
|
||||
## 2. V1 功能范围
|
||||
|
||||
| 模块 | 功能 | 页面数 |
|
||||
|------|------|--------|
|
||||
| ① 患者与医护管理 | 患者档案、家庭成员、医护档案、患者标签 | 3 |
|
||||
| ② 健康数据管理 | 体检记录、日常监测、化验报告、趋势分析 | 3 |
|
||||
| ③ 预约与排班 | 预约管理、医生排班、日历视图 | 2 |
|
||||
| ④ 随访管理 | 随访任务、随访记录台账 | 2 |
|
||||
| ⑤ 咨询管理 | 会话管理、对话记录查看/导出 | 2 |
|
||||
| ⑥ 医护管理 | 医护人员列表 | 1 |
|
||||
| **合计** | | **13** |
|
||||
|
||||
**V2 预留:** 积分商城、数据统计中心、内容管理增强。
|
||||
|
||||
---
|
||||
|
||||
## 3. 实体模型
|
||||
|
||||
### 3.1 设计原则
|
||||
|
||||
- 患者和医护的**账号**走 `erp-auth` 的 `users` 表,`erp-health` 只存医疗业务扩展字段
|
||||
- 通过 `user_id` 外键关联 `users` 表
|
||||
- 所有表含 `tenant_id`(多租户隔离)、`id`(UUIDv7)、`created_at`、`updated_at`、`created_by`、`updated_by`、`deleted_at`、`version`
|
||||
- 多对多关系使用中间表
|
||||
|
||||
### 3.2 实体定义
|
||||
|
||||
#### ① 患者与医护管理
|
||||
|
||||
**patient — 患者档案**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | UUIDv7 |
|
||||
| tenant_id | UUID NOT NULL | 租户 ID |
|
||||
| user_id | UUID FK → users | 关联 erp-auth 账号 |
|
||||
| name | VARCHAR(100) | 姓名 |
|
||||
| gender | VARCHAR(10) | 性别 (male/female/other) |
|
||||
| birth_date | DATE | 出生日期 |
|
||||
| blood_type | VARCHAR(10) | 血型 (A/B/AB/O/RH-/RH+) |
|
||||
| id_number | VARCHAR(20) | 身份证号 |
|
||||
| allergy_history | TEXT | 过敏史 |
|
||||
| medical_history_summary | TEXT | 病史摘要 |
|
||||
| emergency_contact_name | VARCHAR(100) | 紧急联系人姓名 |
|
||||
| emergency_contact_phone | VARCHAR(20) | 紧急联系人电话 |
|
||||
| status | VARCHAR(20) | 状态 (active/inactive/deceased) |
|
||||
| verification_status | VARCHAR(20) | 实名认证 (pending/verified/rejected) |
|
||||
| source | VARCHAR(100) | 来源(体检中心名称) |
|
||||
| notes | TEXT | 备注 |
|
||||
| created_at, updated_at, created_by, updated_by, deleted_at, version | — | 标准字段 |
|
||||
|
||||
索引:`(tenant_id, name)`, `(tenant_id, status)`, `(tenant_id, id_number) UNIQUE WHERE deleted_at IS NULL`
|
||||
|
||||
**patient_family_member — 家庭成员**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | 患者关联 |
|
||||
| name | VARCHAR(100) | 姓名 |
|
||||
| relationship | VARCHAR(50) | 关系(父亲/母亲/配偶/子女等) |
|
||||
| phone | VARCHAR(20) | 电话 |
|
||||
| birth_date | DATE | 出生日期 |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
**doctor_profile — 医护档案**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| user_id | UUID FK → users | 关联 erp-auth 账号 |
|
||||
| department | VARCHAR(100) | 科室 |
|
||||
| title | VARCHAR(50) | 职称(主任医师/副主任医师/主治医师等) |
|
||||
| specialty | VARCHAR(200) | 专长 |
|
||||
| license_number | VARCHAR(50) | 执业证号 |
|
||||
| bio | TEXT | 简介 |
|
||||
| online_status | VARCHAR(20) | 在线状态 (online/offline/busy) |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id)`
|
||||
|
||||
**patient_tag — 患者标签**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| name | VARCHAR(50) | 标签名 |
|
||||
| color | VARCHAR(20) | 颜色值 |
|
||||
| description | TEXT | 描述 |
|
||||
| is_system | BOOLEAN | 系统标签(不可删除) |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`UNIQUE (tenant_id, name) WHERE deleted_at IS NULL`
|
||||
|
||||
**patient_tag_relation — 患者-标签关联**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| tag_id | UUID FK → patient_tag | |
|
||||
| created_at | TIMESTAMPTZ | |
|
||||
| updated_at | TIMESTAMPTZ | |
|
||||
| created_by | UUID | |
|
||||
| updated_by | UUID | |
|
||||
| deleted_at | TIMESTAMPTZ | 软删除 |
|
||||
|
||||
索引:`(tenant_id, patient_id)`, `(tenant_id, tag_id)`
|
||||
|
||||
**patient_doctor_relation — 医患关系**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| relationship_type | VARCHAR(20) | 类型 (primary/consulting) |
|
||||
| created_at | TIMESTAMPTZ | |
|
||||
| updated_at | TIMESTAMPTZ | |
|
||||
| created_by | UUID | |
|
||||
| updated_by | UUID | |
|
||||
| deleted_at | TIMESTAMPTZ | 软删除 |
|
||||
|
||||
索引:`(tenant_id, patient_id)`, `(tenant_id, doctor_id)`
|
||||
|
||||
#### ② 健康数据管理
|
||||
|
||||
**health_record — 体检/就诊记录**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| record_type | VARCHAR(20) | 类型 (checkup/outpatient/inpatient) |
|
||||
| record_date | DATE | 记录日期 |
|
||||
| source | VARCHAR(200) | 来源(体检中心/医院名称) |
|
||||
| overall_assessment | TEXT | 总体评估 |
|
||||
| report_file_url | VARCHAR(500) | 报告文件 URL |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, record_date DESC)`
|
||||
|
||||
**vital_signs — 日常监测数据**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| record_date | DATE | 记录日期 |
|
||||
| systolic_bp_morning | INTEGER | 晨起收缩压 |
|
||||
| diastolic_bp_morning | INTEGER | 晨起舒张压 |
|
||||
| systolic_bp_evening | INTEGER | 晚间收缩压 |
|
||||
| diastolic_bp_evening | INTEGER | 晚间舒张压 |
|
||||
| heart_rate | INTEGER | 心率 |
|
||||
| weight | DECIMAL(5,1) | 体重 (kg) |
|
||||
| blood_sugar | DECIMAL(5,1) | 血糖 (mmol/L) |
|
||||
| water_intake_ml | INTEGER | 饮水量 (ml) |
|
||||
| urine_output_ml | INTEGER | 尿量 (ml) |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, record_date DESC)`
|
||||
|
||||
**lab_report — 化验报告**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| report_date | DATE | 报告日期 |
|
||||
| report_type | VARCHAR(50) | 报告类型(肾功能/血常规/尿常规等) |
|
||||
| indicators | JSONB | 指标数据 [{name, value, unit, ref_range, is_abnormal}] |
|
||||
| image_urls | JSONB | 图片 URLs [url1, url2, ...] |
|
||||
| doctor_interpretation | TEXT | 医生解读 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, report_date DESC)`, GIN on `indicators`, `(tenant_id, report_type)`
|
||||
|
||||
**health_trend — 健康趋势报告**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| period_start | DATE | 周期开始 |
|
||||
| period_end | DATE | 周期结束 |
|
||||
| indicator_summary | JSONB | 指标摘要 |
|
||||
| abnormal_items | JSONB | 异常项 |
|
||||
| generation_type | VARCHAR(20) | 生成方式 (auto/manual) |
|
||||
| report_file_url | VARCHAR(500) | 报告文件 URL |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, period_start DESC)`
|
||||
|
||||
#### ③ 预约排班
|
||||
|
||||
**appointment — 预约记录**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| appointment_type | VARCHAR(20) | 类型 (dialysis/recheck/outpatient) |
|
||||
| appointment_date | DATE | 预约日期 |
|
||||
| start_time | TIME | 开始时间 |
|
||||
| end_time | TIME | 结束时间 |
|
||||
| status | VARCHAR(20) | 状态 (pending/confirmed/cancelled/completed/no_show) |
|
||||
| cancel_reason | TEXT | 取消原因 |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, appointment_date, status)`, `(tenant_id, doctor_id, appointment_date)`
|
||||
|
||||
**doctor_schedule — 医生排班**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| schedule_date | DATE | 排班日期 |
|
||||
| period_type | VARCHAR(20) | 时段 (am/pm/night/full_day) |
|
||||
| start_time | TIME | 开始时间 |
|
||||
| end_time | TIME | 结束时间 |
|
||||
| max_appointments | INTEGER | 最大预约数 |
|
||||
| current_appointments | INTEGER | 已预约数(默认 0) |
|
||||
| status | VARCHAR(20) | 状态 (enabled/disabled) |
|
||||
| 标准 ERP 字段 | — |
|
||||
|
||||
索引:`(tenant_id, doctor_id, schedule_date)`, `UNIQUE (tenant_id, doctor_id, schedule_date, period_type) WHERE deleted_at IS NULL`
|
||||
|
||||
**预约并发控制:** 创建预约时使用原子 CAS 操作 `UPDATE doctor_schedule SET current_appointments = current_appointments + 1 WHERE id = $1 AND current_appointments < max_appointments RETURNING *`,防止超额预约。
|
||||
|
||||
#### ④ 随访管理
|
||||
|
||||
**follow_up_task — 随访任务**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| assigned_to | UUID FK → users | 负责医护 |
|
||||
| follow_up_type | VARCHAR(20) | 类型 (phone/face_to_face/online) |
|
||||
| planned_date | DATE | 计划日期 |
|
||||
| status | VARCHAR(20) | 状态 (pending/in_progress/completed/overdue/cancelled) |
|
||||
| content_template | TEXT | 随访内容模板 |
|
||||
| related_appointment_id | UUID FK → appointment | 关联预约 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, assigned_to, status)`, `(tenant_id, planned_date, status)`
|
||||
|
||||
**follow_up_record — 随访记录**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| task_id | UUID FK → follow_up_task | |
|
||||
| executed_by | UUID FK → users | 执行医护 |
|
||||
| executed_date | DATE | 执行日期 |
|
||||
| result | VARCHAR(20) | 结果 (followed_up/unreachable/refused/other) |
|
||||
| patient_condition | TEXT | 患者状况 |
|
||||
| medical_advice | TEXT | 医嘱建议 |
|
||||
| next_follow_up_date | DATE | 下次随访日期 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, task_id)`, `(tenant_id, executed_date)`
|
||||
|
||||
#### ⑤ 咨询管理
|
||||
|
||||
**consultation_session — 咨询会话**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| type | VARCHAR(20) | 类型 (customer_service/doctor) |
|
||||
| status | VARCHAR(20) | 状态 (waiting/active/closed) |
|
||||
| last_message_at | TIMESTAMPTZ | 最后消息时间 |
|
||||
| unread_count_patient | INTEGER | 患者未读数 |
|
||||
| unread_count_doctor | INTEGER | 医生未读数 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, doctor_id, status)`, `(tenant_id, patient_id, status)`
|
||||
|
||||
**consultation_message — 咨询消息**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| session_id | UUID FK → consultation_session | |
|
||||
| sender_id | UUID | 发送者 ID |
|
||||
| sender_role | VARCHAR(20) | 角色 (patient/doctor/system) |
|
||||
| content_type | VARCHAR(20) | 类型 (text/image/voice/file) |
|
||||
| content | TEXT | 内容 |
|
||||
| is_read | BOOLEAN | 已读状态(默认 false) |
|
||||
| created_at | TIMESTAMPTZ | 发送时间 |
|
||||
| updated_at | TIMESTAMPTZ | |
|
||||
| created_by | UUID | |
|
||||
| updated_by | UUID | |
|
||||
| deleted_at | TIMESTAMPTZ | 软删除(内容审核用) |
|
||||
| version | INT NOT NULL DEFAULT 1 | 乐观锁 |
|
||||
|
||||
索引:`(tenant_id, session_id, created_at)`
|
||||
|
||||
**数据增长策略:** 对 `created_at` 按月分区(PostgreSQL table partitioning),超过 1 年的已关闭会话消息归档到冷存储。
|
||||
|
||||
**说明:**
|
||||
- `patient.user_id` 允许 NULL — 患者可先创建档案(如体检中心导入),后续再绑定 erp-auth 账号
|
||||
- `consultation_message.sender_id` 引用 `users.id` — 统一使用 erp-auth 用户体系标识发送者
|
||||
|
||||
---
|
||||
|
||||
## 3.3 状态机定义
|
||||
|
||||
### appointment.status 转换
|
||||
|
||||
```
|
||||
pending ──→ confirmed ──→ completed
|
||||
│ │
|
||||
│ └──→ no_show(预约时间过后,系统自动或前台手动触发)
|
||||
│
|
||||
└──→ cancelled(任意时刻可取消,需填 cancel_reason)
|
||||
```
|
||||
|
||||
### follow_up_task.status 转换
|
||||
|
||||
```
|
||||
pending ──→ in_progress ──→ completed
|
||||
│ │
|
||||
└──→ cancelled └──→ overdue(系统定时任务:planned_date 已过且仍 pending 自动标记)
|
||||
```
|
||||
|
||||
### consultation_session.status 转换
|
||||
|
||||
```
|
||||
waiting ──→ active(第一条消息发送时自动触发)──→ closed(手动关闭或超时自动关闭)
|
||||
```
|
||||
|
||||
### patient.status 转换
|
||||
|
||||
```
|
||||
active ──→ inactive(手动停用)
|
||||
active ──→ deceased(标记死亡,不可逆)
|
||||
inactive ──→ active(重新激活)
|
||||
```
|
||||
|
||||
### patient.verification_status 转换
|
||||
|
||||
```
|
||||
pending ──→ verified(实名认证通过)
|
||||
pending ──→ rejected(认证被拒)
|
||||
rejected ──→ pending(重新提交认证)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 设计
|
||||
|
||||
所有端点前缀: `/api/v1/health/`
|
||||
|
||||
### 4.1 患者管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/patients` | 患者列表(分页、搜索、标签筛选) |
|
||||
| POST | `/patients` | 创建患者 |
|
||||
| GET | `/patients/:id` | 患者详情 |
|
||||
| PUT | `/patients/:id` | 更新患者 |
|
||||
| DELETE | `/patients/:id` | 软删除 |
|
||||
| POST | `/patients/:id/tags` | 管理标签(批量设置) |
|
||||
| GET | `/patients/:id/health-summary` | 健康摘要 |
|
||||
| GET | `/patients/:id/family-members` | 家庭成员列表 |
|
||||
| POST | `/patients/:id/family-members` | 新增家庭成员 |
|
||||
| PUT | `/patients/:id/family-members/:fid` | 更新家庭成员 |
|
||||
| DELETE | `/patients/:id/family-members/:fid` | 删除家庭成员 |
|
||||
| POST | `/patients/:id/doctors` | 分配主治医生 |
|
||||
| DELETE | `/patients/:id/doctors/:did` | 移除医患关系 |
|
||||
|
||||
### 4.2 健康数据
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/patients/:id/vital-signs` | 日常监测列表 |
|
||||
| POST | `/patients/:id/vital-signs` | 新增监测数据 |
|
||||
| GET | `/patients/:id/lab-reports` | 化验报告列表 |
|
||||
| POST | `/patients/:id/lab-reports` | 新增化验报告 |
|
||||
| GET | `/patients/:id/health-records` | 体检/就诊记录 |
|
||||
| POST | `/patients/:id/health-records` | 新增记录 |
|
||||
| GET | `/patients/:id/trends` | 趋势报告 |
|
||||
| POST | `/patients/:id/trends/generate` | 生成趋势报告 |
|
||||
| GET | `/patients/:id/trends/:indicator` | 单指标时序数据 |
|
||||
| PUT | `/patients/:id/vital-signs/:vid` | 更新监测数据 |
|
||||
| DELETE | `/patients/:id/vital-signs/:vid` | 删除监测数据 |
|
||||
| PUT | `/patients/:id/lab-reports/:rid` | 更新化验报告 |
|
||||
| DELETE | `/patients/:id/lab-reports/:rid` | 删除化验报告 |
|
||||
| PUT | `/patients/:id/health-records/:rid` | 更新体检记录 |
|
||||
| DELETE | `/patients/:id/health-records/:rid` | 删除体检记录 |
|
||||
|
||||
### 4.3 预约排班
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/appointments` | 预约列表 |
|
||||
| POST | `/appointments` | 创建预约 |
|
||||
| PUT | `/appointments/:id/status` | 更新状态 |
|
||||
| GET | `/doctor-schedules` | 排班列表 |
|
||||
| POST | `/doctor-schedules` | 创建排班 |
|
||||
| PUT | `/doctor-schedules/:id` | 更新排班 |
|
||||
| GET | `/doctor-schedules/calendar` | 日历视图 |
|
||||
|
||||
### 4.4 随访管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/follow-up-tasks` | 任务列表 |
|
||||
| POST | `/follow-up-tasks` | 创建任务 |
|
||||
| PUT | `/follow-up-tasks/:id` | 更新任务 |
|
||||
| DELETE | `/follow-up-tasks/:id` | 删除任务 |
|
||||
| POST | `/follow-up-tasks/:id/records` | 填写随访记录 |
|
||||
| GET | `/follow-up-records` | 随访台账 |
|
||||
|
||||
### 4.5 咨询管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/consultation-sessions` | 会话列表 |
|
||||
| GET | `/consultation-sessions/:id/messages` | 消息记录 |
|
||||
| PUT | `/consultation-sessions/:id/close` | 关闭会话 |
|
||||
| POST | `/consultation-messages` | 写入消息(API 网关用) |
|
||||
| GET | `/consultation-sessions/export` | 导出 |
|
||||
|
||||
### 4.6 医护管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/doctors` | 医护列表 |
|
||||
| POST | `/doctors` | 创建医护档案 |
|
||||
| GET | `/doctors/:id` | 医护详情 |
|
||||
| PUT | `/doctors/:id` | 更新医护档案 |
|
||||
| DELETE | `/doctors/:id` | 软删除医护档案 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端页面设计
|
||||
|
||||
文件位置: `apps/web/src/pages/health/`
|
||||
|
||||
### 5.1 页面清单
|
||||
|
||||
| # | 页面 | 文件名 | 类型 |
|
||||
|---|------|--------|------|
|
||||
| 1 | 患者列表 | PatientList.tsx | 表格+搜索+标签筛选+导出 |
|
||||
| 2 | 患者详情 | PatientDetail.tsx | Tab布局:基本信息/健康趋势/化验报告/就诊记录/随访记录 |
|
||||
| 3 | 标签管理 | PatientTagManage.tsx | CRUD+颜色+批量打标 |
|
||||
| 4 | 日常监测 | VitalSignsList.tsx | 按患者+日期+ECharts趋势折线图 |
|
||||
| 5 | 化验报告 | LabReportList.tsx | 列表+图片预览+指标详情+解读 |
|
||||
| 6 | 体检记录 | HealthRecordList.tsx | 类型筛选+报告文件查看/上传 |
|
||||
| 7 | 预约管理 | AppointmentList.tsx | 列表/日历切换+状态流转 |
|
||||
| 8 | 排班管理 | DoctorSchedule.tsx | 周/月日历+排班模板 |
|
||||
| 9 | 随访任务 | FollowUpTaskList.tsx | 任务CRUD+分配+关联工作流 |
|
||||
| 10 | 随访台账 | FollowUpRecordList.tsx | 按患者/医护/日期筛选+导出 |
|
||||
| 11 | 会话管理 | ConsultationList.tsx | 列表+未回复统计 |
|
||||
| 12 | 对话记录 | ConsultationDetail.tsx | 聊天气泡+图片/语音查看+导出 |
|
||||
| 13 | 医护列表 | DoctorList.tsx | 列表+科室筛选+在线状态 |
|
||||
|
||||
### 5.2 技术要点
|
||||
|
||||
- **ECharts 趋势图** — 血压/体重/血糖曲线图,按日期范围展示
|
||||
- **文件上传/预览** — 化验单图片、体检报告 PDF(需新增基础能力)
|
||||
- **日历组件** — Ant Design Calendar 用于排班和预约视图
|
||||
- **聊天 UI** — 消息气泡展示(只读,非实时聊天)
|
||||
- **导出** — 随访台账、咨询记录导出为 Excel
|
||||
|
||||
---
|
||||
|
||||
## 6. 事件集成
|
||||
|
||||
### 6.1 发布事件
|
||||
|
||||
| 事件类型 | 触发时机 | 载荷 |
|
||||
|----------|----------|------|
|
||||
| `patient.created` | 创建患者 | `{patient_id, name, tenant_id}` |
|
||||
| `patient.updated` | 更新患者信息 | `{patient_id, changed_fields}` |
|
||||
| `appointment.created` | 创建预约 | `{appointment_id, patient_id, doctor_id, date}` |
|
||||
| `appointment.confirmed` | 确认预约 | `{appointment_id}` |
|
||||
| `appointment.cancelled` | 取消预约 | `{appointment_id, cancel_reason}` |
|
||||
| `appointment.completed` | 完成就诊 | `{appointment_id}` |
|
||||
| `follow_up.created` | 创建随访任务 | `{task_id, patient_id, assigned_to, planned_date}` |
|
||||
| `follow_up.completed` | 完成随访 | `{task_id, record_id, result}` |
|
||||
| `lab_report.uploaded` | 上传化验报告 | `{report_id, patient_id, report_type, abnormal_count}` |
|
||||
| `consultation.opened` | 开启咨询 | `{session_id, patient_id, doctor_id}` |
|
||||
| `consultation.closed` | 关闭咨询 | `{session_id}` |
|
||||
| `patient.deceased` | 患者死亡标记 | `{patient_id}` |
|
||||
| `patient.verified` | 实名认证通过 | `{patient_id, id_number}` |
|
||||
| `follow_up.overdue` | 随访任务逾期 | `{task_id, patient_id, planned_date}` |
|
||||
| `doctor.online_status_changed` | 医护在线状态变更 | `{doctor_id, old_status, new_status}` |
|
||||
|
||||
**随访记录自动创建后续任务:** 当 `follow_up_record.next_follow_up_date` 不为空时,服务层自动创建新的 `follow_up_task`(planned_date = next_follow_up_date,assigned_to 沿用当前医护)。
|
||||
|
||||
### 6.2 订阅事件
|
||||
|
||||
| 事件类型 | 处理逻辑 |
|
||||
|----------|----------|
|
||||
| `workflow.task.completed` | 工作流任务完成时更新随访任务状态 |
|
||||
| `message.sent` | 消息发送时联动咨询会话的 last_message_at |
|
||||
|
||||
---
|
||||
|
||||
## 7. 模块结构
|
||||
|
||||
```
|
||||
crates/erp-health/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── lib.rs ← ErpModule trait + public_routes() / protected_routes()
|
||||
│ ├── error.rs ← HealthError → AppError
|
||||
│ ├── state.rs ← HealthState (共享状态)
|
||||
│ ├── entity/ ← SeaORM Entity
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient.rs
|
||||
│ │ ├── patient_family_member.rs
|
||||
│ │ ├── patient_tag.rs
|
||||
│ │ ├── patient_tag_relation.rs
|
||||
│ │ ├── patient_doctor_relation.rs
|
||||
│ │ ├── doctor_profile.rs
|
||||
│ │ ├── health_record.rs
|
||||
│ │ ├── vital_signs.rs
|
||||
│ │ ├── lab_report.rs
|
||||
│ │ ├── health_trend.rs
|
||||
│ │ ├── appointment.rs
|
||||
│ │ ├── doctor_schedule.rs
|
||||
│ │ ├── follow_up_task.rs
|
||||
│ │ ├── follow_up_record.rs
|
||||
│ │ ├── consultation_session.rs
|
||||
│ │ └── consultation_message.rs
|
||||
│ ├── service/ ← 业务逻辑
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient_service.rs
|
||||
│ │ ├── health_data_service.rs
|
||||
│ │ ├── appointment_service.rs
|
||||
│ │ ├── follow_up_service.rs
|
||||
│ │ └── consultation_service.rs
|
||||
│ ├── handler/ ← Axum 路由
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient_handler.rs
|
||||
│ │ ├── health_data_handler.rs
|
||||
│ │ ├── appointment_handler.rs
|
||||
│ │ ├── follow_up_handler.rs
|
||||
│ │ └── consultation_handler.rs
|
||||
│ ├── dto/ ← 请求/响应结构体
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient_dto.rs
|
||||
│ │ ├── health_data_dto.rs
|
||||
│ │ ├── appointment_dto.rs
|
||||
│ │ ├── follow_up_dto.rs
|
||||
│ │ └── consultation_dto.rs
|
||||
│ └── event.rs ← 事件定义和处理器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 权限定义
|
||||
|
||||
### 8.1 权限码
|
||||
|
||||
| 权限码 | 名称 | 说明 |
|
||||
|--------|------|------|
|
||||
| `health.patient.list` | 查看患者列表 | 查看和搜索患者列表、详情 |
|
||||
| `health.patient.manage` | 管理患者 | 创建、编辑、删除患者 |
|
||||
| `health.health-data.list` | 查看健康数据 | 查看体检记录、监测数据、化验报告 |
|
||||
| `health.health-data.manage` | 管理健康数据 | 录入、编辑、删除健康数据 |
|
||||
| `health.appointment.list` | 查看预约 | 查看预约列表和排班 |
|
||||
| `health.appointment.manage` | 管理预约 | 创建、确认、取消预约 |
|
||||
| `health.follow-up.list` | 查看随访 | 查看随访任务和记录 |
|
||||
| `health.follow-up.manage` | 管理随访 | 创建、分配、完成随访任务 |
|
||||
| `health.consultation.list` | 查看咨询 | 查看咨询会话和消息记录 |
|
||||
| `health.consultation.manage` | 管理咨询 | 关闭会话、导出记录 |
|
||||
| `health.doctor.list` | 查看医护 | 查看医护列表和详情 |
|
||||
| `health.doctor.manage` | 管理医护 | 创建、编辑医护档案、排班 |
|
||||
|
||||
### 8.2 数据范围
|
||||
|
||||
| 实体 | 支持的数据范围级别 | 说明 |
|
||||
|------|-------------------|------|
|
||||
| patient | self, department, department_tree, all | 医生只能看自己负责的患者或本科室患者 |
|
||||
| follow_up_task | self, department, department_tree, all | 医护只能看分配给自己的随访任务 |
|
||||
| appointment | self, department, department_tree, all | 按科室隔离预约数据 |
|
||||
|
||||
### 8.3 角色模板
|
||||
|
||||
| 角色 | 权限 |
|
||||
|------|------|
|
||||
| health_admin | 全部 health.* 权限 |
|
||||
| doctor | health.patient.list, health.health-data.*, health.appointment.list, health.follow-up.*, health.consultation.list, health.doctor.list |
|
||||
| nurse | health.patient.list, health.health-data.*, health.follow-up.*, health.appointment.list |
|
||||
| receptionist | health.patient.*, health.appointment.*, health.doctor.list |
|
||||
|
||||
---
|
||||
|
||||
## 9. 能力扩展
|
||||
|
||||
V1 需要新增以下基础能力(在 erp-core 或独立模块中):
|
||||
|
||||
1. **文件上传服务** — 文件存储(本地/OSS)、URL 生成、图片缩略图
|
||||
2. **趋势分析** — 时序数据聚合、异常检测逻辑
|
||||
3. **报告批注** — 医生对化验报告的解读/批注能力
|
||||
4. **导出增强** — 健康数据导出为 Excel/PDF
|
||||
|
||||
---
|
||||
|
||||
## 10. 实施步骤
|
||||
|
||||
### Phase 1: 项目初始化
|
||||
- 拷贝 ERP 到 hms
|
||||
- 验证编译和构建
|
||||
|
||||
### Phase 2: erp-health 骨架
|
||||
- 创建 crate 结构
|
||||
- 实现 ErpModule trait + `public_routes()` / `protected_routes()` 固有方法
|
||||
- 注册到 workspace
|
||||
|
||||
### Phase 3: 数据库迁移
|
||||
- 16 张表(14 业务实体 + 2 关联表)的迁移文件
|
||||
- 索引创建、唯一约束
|
||||
|
||||
### Phase 4: 业务逻辑(按域迭代)
|
||||
- ① 患者与医护管理
|
||||
- ② 健康数据管理
|
||||
- ③ 预约排班
|
||||
- ④ 随访管理
|
||||
- ⑤ 咨询管理
|
||||
|
||||
### Phase 5: 前端页面
|
||||
- 13 个自定义 React 页面
|
||||
- 路由注册和侧边栏菜单
|
||||
|
||||
### Phase 6: 集成测试
|
||||
- API 端点测试
|
||||
- 多租户隔离验证
|
||||
- 端到端功能验证
|
||||
@@ -1,125 +1,102 @@
|
||||
# architecture (架构决策记录)
|
||||
---
|
||||
title: 架构决策记录
|
||||
updated: 2026-04-23
|
||||
status: stable
|
||||
tags: [architecture, decisions, design-principles]
|
||||
---
|
||||
|
||||
## 设计思想
|
||||
# 架构决策记录
|
||||
|
||||
ERP 平台采用 **模块化单体 + 渐进式拆分** 架构。核心原则:模块间零直接依赖,所有跨模块通信通过事件总线和 trait 接口。
|
||||
> 从 [[index]] 导航。关联: [[erp-core]] [[erp-server]] [[database]] [[wasm-plugin]] [[erp-health]]
|
||||
|
||||
## 关键架构决策
|
||||
## 1. 设计决策
|
||||
|
||||
### Q: 为什么用模块化单体而非微服务?
|
||||
### 模块化单体 + 渐进式拆分
|
||||
|
||||
**A:** ERP 系统的模块间数据一致性要求高,分布式事务成本大。单体起步,模块边界清晰,未来需要时可按模块拆分为微服务。`ErpModule` trait 天然支持这种渐进式迁移。
|
||||
模块间零直接依赖,跨模块通信通过事件总线和 trait 接口。`ErpModule` trait 天然支持未来按模块拆分为微服务。
|
||||
|
||||
### Q: 为什么用 UUIDv7 而不是自增 ID?
|
||||
### HMS 架构:原生模块 + 插件并存
|
||||
|
||||
**A:** UUIDv7 是时间排序的,既有 UUID 的分布式唯一性,又有接近自增 ID 的索引性能。对多租户 SaaS 尤其重要——不同租户的数据不会因为 ID 冲突而互相影响。
|
||||
HMS 继承 ERP 底座的所有基础模块,`erp-health` 作为原生 Rust 模块承载医疗业务。WASM 插件系统保留但非 HMS 主要扩展方式。
|
||||
|
||||
### Q: 为什么用 broadcast channel 做事件总线?
|
||||
```
|
||||
HMS 平台
|
||||
├── 基础模块(继承 ERP): auth, config, workflow, message, plugin
|
||||
├── 核心业务模块: erp-health(原生 Rust)★
|
||||
└── 可选插件: crm, inventory, freelance, itops(WASM)
|
||||
```
|
||||
|
||||
**A:** `tokio::sync::broadcast` 提供多消费者发布/订阅,语义匹配模块间事件通知。设计规格要求 outbox 模式(持久化到 domain_events 表),但当前 Phase 1 先用内存 broadcast,后续再补持久化。
|
||||
### 为什么 erp-health 用原生模块?
|
||||
|
||||
### Q: 为什么错误类型跨 crate 边界必须用 thiserror?
|
||||
医疗业务需要 16+ 强类型实体、自定义 API(趋势分析/统计报表)、文件上传、未来 AI 集成。WASM 插件的 JSONB 动态存储和 20 实体上限无法满足。详见 [[erp-health]]。
|
||||
|
||||
**A:** `anyhow` 的错误没有类型信息,无法在 API 层做精确的 HTTP 状态码映射。`thiserror` 定义明确的错误变体,`AppError` 可以精确映射到 400/401/403/404/409/500。crate 内部可以用 `anyhow` 简化,但对外必须转 `AppError`。
|
||||
### 为什么用 UUIDv7?
|
||||
|
||||
### Q: 为什么 tenant_id 不在 API 路径中?
|
||||
时间排序 + UUID 唯一性 + 接近自增 ID 的索引性能。多租户 SaaS 下不同租户数据不会因 ID 冲突互相影响。
|
||||
|
||||
**A:** 从 JWT token 中提取 tenant_id,通过中间件注入 `TenantContext`。这防止了:
|
||||
- 用户手动修改 URL 访问其他租户数据
|
||||
- API 路径暴露租户信息
|
||||
- 开发者忘记检查租户权限
|
||||
### 为什么 tenant_id 不在 API 路径中?
|
||||
|
||||
管理员接口例外,可以通过路径指定 tenant_id。
|
||||
从 JWT 提取,中间件注入 `TenantContext`。防止:手动改 URL 越权 / API 暴露租户信息 / 忘记检查权限。管理员接口例外。
|
||||
|
||||
### Q: 为什么前端用 HashRouter 而非 BrowserRouter?
|
||||
### 为什么错误类型跨 crate 用 thiserror?
|
||||
|
||||
**A:** 部署时可能不在根路径下,HashRouter 不需要服务端配置 fallback 路由。对 SPA 来说更稳健。
|
||||
`anyhow` 无类型信息,无法精确映射 HTTP 状态码。`thiserror` → `AppError` → 400/401/403/404/409/500。
|
||||
|
||||
## 模块依赖铁律
|
||||
## 2. 关键文件 + 数据流
|
||||
|
||||
### 模块依赖图
|
||||
|
||||
```
|
||||
erp-core (L1)
|
||||
erp-common (L1)
|
||||
|
|
||||
+--------------+--------------+--------------+
|
||||
| | | |
|
||||
erp-auth erp-config erp-workflow erp-message (L2)
|
||||
| | | |
|
||||
+--------------+--------------+--------------+
|
||||
+--------------+--------------+--------------+-----------+
|
||||
| | | | |
|
||||
erp-auth erp-config erp-workflow erp-message erp-health (L2)
|
||||
| | | | |
|
||||
+--------------+--------------+--------------+-----------+
|
||||
|
|
||||
erp-server (L3: 唯一组装点)
|
||||
|
|
||||
erp-plugin (WASM 插件运行时)
|
||||
```
|
||||
|
||||
**禁止:**
|
||||
- L2 模块之间直接依赖
|
||||
- L1 模块依赖任何业务模块
|
||||
- 绕过事件总线直接调用其他模块
|
||||
**禁止**: L2 间直接依赖 / L1 依赖业务模块 / 绕过事件总线
|
||||
|
||||
## 多租户隔离策略
|
||||
|
||||
**当前策略:共享数据库 + tenant_id 列过滤**
|
||||
|
||||
所有业务表包含 `tenant_id` 列,查询时通过中间件自动注入过滤条件。这是最简单的 SaaS 多租户方案,未来可扩展为:
|
||||
- Schema 隔离 — 每个租户独立 schema
|
||||
- 数据库隔离 — 每个租户独立数据库(私有化部署)
|
||||
|
||||
`ErpModule::on_tenant_created()` 和 `on_tenant_deleted()` 钩子确保模块能在租户创建/删除时初始化/清理数据。
|
||||
|
||||
## 技术选型理由
|
||||
### 技术选型
|
||||
|
||||
| 选择 | 理由 |
|
||||
|------|------|
|
||||
| Axum 0.8 | Tokio 团队维护,与 tower 生态无缝集成,类型安全路由 |
|
||||
| SeaORM 1.1 | 异步、类型安全、Rust 原生 ORM,迁移工具完善 |
|
||||
| PostgreSQL 16 | 企业级关系型数据库,JSON 支持好,扩展丰富 |
|
||||
| Redis 7 | 高性能缓存,会话存储,限流 token bucket |
|
||||
| React 19 + Ant Design 6 | 成熟的组件库,企业后台 UI 标配 |
|
||||
| Zustand | 极简状态管理,无 boilerplate |
|
||||
| utoipa | Rust 代码生成 OpenAPI 文档,零额外维护 |
|
||||
| Wasmtime 43 | WASM 沙箱运行时,Component Model 支持,Fuel 资源限制 |
|
||||
| Axum 0.8 | Tokio 团队维护,tower 生态,类型安全路由 |
|
||||
| SeaORM 1.1 | 异步、类型安全、迁移工具完善 |
|
||||
| PostgreSQL 18 | 企业级,JSON 支持,扩展丰富 |
|
||||
| Redis 7 | 缓存 + 限流 token bucket |
|
||||
| React 19 + Ant Design 6 | 企业后台 UI 标配 |
|
||||
| Zustand 5 | 极简状态管理 |
|
||||
| Wasmtime 43 | WASM 沙箱,Component Model,Fuel 限制 |
|
||||
|
||||
## 插件扩展架构
|
||||
### 集成契约
|
||||
|
||||
### Q: 为什么用 WASM 而不是 Lua / gRPC / dylib?
|
||||
| 方向 | 模块 | 触发时机 |
|
||||
|------|------|---------|
|
||||
| 定义 → | [[erp-core]] | 所有模块的 trait 和类型 |
|
||||
| 组装 ← | [[erp-server]] | 模块注册和启动 |
|
||||
| 扩展 ← | [[wasm-plugin]] | 插件通过 Host Bridge 桥接 |
|
||||
|
||||
**A:**
|
||||
## 3. 代码逻辑
|
||||
|
||||
| 方案 | 安全性 | 隔离性 | 性能 | 复杂度 |
|
||||
|------|--------|--------|------|--------|
|
||||
| **WASM** | 高(沙箱) | 进程内隔离 | 接近原生(JIT) | 中 |
|
||||
| Lua 脚本 | 中 | 无隔离 | 快 | 低 |
|
||||
| 进程外 gRPC | 高 | 进程级隔离 | 网络开销 | 高 |
|
||||
| dylib | 低(直接内存) | 无隔离 | 原生 | 低 |
|
||||
⚡ **不变量**: 模块间只通过 EventBus 和 trait 通信,无直接依赖
|
||||
⚡ **不变量**: 所有数据表必须含 `tenant_id`,查询自动过滤
|
||||
⚡ **不变量**: UUID v7 作为主键
|
||||
⚡ **不变量**: 软删除,不硬删除
|
||||
⚡ **不变量**: 所有 API 使用 `/api/v1/` 前缀
|
||||
|
||||
WASM 在安全性和性能之间取得最佳平衡。Wasmtime v43 的 Component Model 提供了类型安全的 Host-Plugin 接口,Fuel 机制防止恶意/有缺陷的插件消耗过多资源。
|
||||
## 4. 活跃问题 + 陷阱
|
||||
|
||||
### 插件架构拓扑
|
||||
⚠️ 当前共享数据库 + tenant_id 过滤,未来可扩展为 Schema 隔离或数据库隔离
|
||||
⚠️ EventBus 内存 broadcast 需 outbox 持久化保障(已通过后台任务实现)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ erp-server │
|
||||
│ ┌──────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ EventBus │ │ PluginRuntime (Wasmtime) │ │
|
||||
│ │ (broadcast) │ │ ┌──────┐ ┌──────┐ │ │
|
||||
│ └──────┬───────┘ │ │插件 A│ │插件 B│ │ │
|
||||
│ │ │ └──┬───┘ └──┬───┘ │ │
|
||||
│ │ │ │ Host API │ │ │
|
||||
│ │ │ ┌──┴────────┴──┐ │ │
|
||||
│ │ │ │ Host Bridge │ │ │
|
||||
│ │ │ └──┬───────────┘ │ │
|
||||
│ │ └─────┼────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌──────┴───────┐ ┌────┴─────┐ │
|
||||
│ │ DB (SeaORM) │ │ EventBus │ │
|
||||
│ └──────────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
## 5. 变更记录
|
||||
|
||||
插件通过 Host Bridge 调用系统功能(db_insert、event_publish 等),Host Bridge 自动注入多租户隔离(tenant_id)和权限检查。详见 [[wasm-plugin]]。
|
||||
|
||||
## 关联模块
|
||||
|
||||
- **[[erp-core]]** — 架构契约的定义者
|
||||
- **[[erp-server]]** — 架构的组装执行者
|
||||
- **[[database]]** — 多租户隔离的物理实现
|
||||
- **[[wasm-plugin]]** — 插件扩展架构的实现
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-04-23 | 重构为 5 节结构,删除 erp-common 引用,精简技术选型表 |
|
||||
|
||||
123
wiki/database.md
123
wiki/database.md
@@ -1,73 +1,92 @@
|
||||
# database (数据库迁移与模式)
|
||||
---
|
||||
title: 数据库迁移与模式
|
||||
updated: 2026-04-23
|
||||
status: stable
|
||||
tags: [database, seaorm, migration, multi-tenant]
|
||||
---
|
||||
|
||||
## 设计思想
|
||||
# 数据库迁移与模式
|
||||
|
||||
数据库迁移使用 SeaORM Migration 框架,遵循以下原则:
|
||||
> 从 [[index]] 导航。关联: [[erp-core]] [[erp-server]] [[infrastructure]]
|
||||
|
||||
- **所有表必须包含标准字段** — `id`(UUIDv7), `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||
- **软删除** — 不执行硬删除,设置 `deleted_at` 时间戳
|
||||
## 1. 设计决策
|
||||
|
||||
- **SeaORM Migration** — 异步、类型安全、幂等(`if_not_exists`),每个迁移必须实现 `down()` 可回滚
|
||||
- **所有表必须含标准字段** — `id`(UUIDv7), `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||
- **软删除** — 不硬删除,设置 `deleted_at` 时间戳
|
||||
- **乐观锁** — 更新时检查 `version` 字段
|
||||
- **多租户隔离** — 所有业务表必须含 `tenant_id`,查询时自动过滤
|
||||
- **幂等迁移** — 使用 `if_not_exists` 确保可重复执行
|
||||
- **可回滚** — 每个迁移必须实现 `down()` 方法
|
||||
- **多租户** — 所有业务表含 `tenant_id`,中间件自动过滤
|
||||
|
||||
## 代码逻辑
|
||||
## 2. 关键文件 + 数据流
|
||||
|
||||
### 核心文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `crates/erp-server/migration/src/lib.rs` | Migrator 注册所有迁移 |
|
||||
| `crates/erp-server/migration/src/m*.rs` | 41 个迁移文件 |
|
||||
| `crates/erp-core/src/types.rs` | BaseFields 标准字段定义 |
|
||||
|
||||
### 迁移命名规则
|
||||
|
||||
### 迁移文件命名规则
|
||||
```
|
||||
m{YYYYMMDD}_{6位序号}_{描述}.rs
|
||||
例: m20260410_000001_create_tenant.rs
|
||||
```
|
||||
|
||||
### 当前表结构
|
||||
### 当前表概览(30 张)
|
||||
|
||||
**tenant 表** (唯一已实现的表):
|
||||
| 列名 | 类型 | 约束 |
|
||||
|------|------|------|
|
||||
| id | UUID | PK, NOT NULL |
|
||||
| name | STRING | NOT NULL |
|
||||
| code | STRING | NOT NULL, UNIQUE |
|
||||
| status | STRING | NOT NULL, DEFAULT 'active' |
|
||||
| settings | JSON | NULLABLE |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() |
|
||||
| deleted_at | TIMESTAMPTZ | NULLABLE |
|
||||
| 模块 | 表 |
|
||||
|------|-----|
|
||||
| 基础 | tenant |
|
||||
| 认证 (auth) | users, user_credentials, user_tokens, roles, permissions, role_permissions, user_roles, organizations, departments, positions, user_departments |
|
||||
| 配置 (config) | dictionaries, dictionary_items, menus, menu_roles, settings, numbering_rules |
|
||||
| 工作流 (workflow) | process_definitions, process_instances, tokens, tasks, process_variables |
|
||||
| 消息 (message) | message_templates, messages, message_subscriptions |
|
||||
| 审计 | audit_logs, domain_events |
|
||||
| 插件 (plugin) | plugins, entity_registry, plugin_market, plugin_user_views |
|
||||
|
||||
### 已知缺失字段
|
||||
tenant 表缺少 `BaseFields` 要求的:
|
||||
- `created_by` — 创建人
|
||||
- `updated_by` — 最后修改人
|
||||
- `version` — 乐观锁版本号
|
||||
### 集成契约
|
||||
|
||||
### 迁移执行
|
||||
```
|
||||
erp-server 启动 → Migrator::up(&db_conn) → 自动运行所有 pending 迁移
|
||||
```
|
||||
| 方向 | 模块 | 触发时机 |
|
||||
|------|------|---------|
|
||||
| 消费 ← | [[erp-server]] | 启动时自动运行 `Migrator::up()` |
|
||||
| 依赖 ← | [[erp-core]] | BaseFields 定义标准字段规范 |
|
||||
| 提供 → | 所有业务模块 | 表结构供 SeaORM Entity 使用 |
|
||||
|
||||
## 关联模块
|
||||
## 3. 代码逻辑
|
||||
|
||||
- **[[erp-core]]** — `BaseFields` 定义了标准字段规范,迁移表结构必须对齐
|
||||
- **[[erp-server]]** — 启动时自动运行迁移
|
||||
- **[[erp-auth]]** — Phase 2 将创建 users, roles, permissions 表
|
||||
- **[[erp-config]]** — Phase 3 将创建 system_configs 表
|
||||
- **[[erp-workflow]]** — Phase 4 将创建 workflow_definitions, workflow_instances 表
|
||||
- **[[erp-message]]** — Phase 5 将创建 messages, notification_settings 表
|
||||
⚡ **不变量**: 所有业务表必须含 `tenant_id` 列 — 多租户是核心能力,不可事后补
|
||||
|
||||
## 关键文件
|
||||
⚡ **不变量**: 迁移必须幂等 — 使用 `if_not_exists`,可重复执行
|
||||
|
||||
| 文件 | 职责 |
|
||||
⚡ **不变量**: 迁移执行由 erp-server 启动自动触发,不手动执行 SQL
|
||||
|
||||
### 关键结构变更迁移
|
||||
|
||||
| 迁移 | 变更 |
|
||||
|------|------|
|
||||
| `crates/erp-server/migration/src/lib.rs` | Migrator 注册所有迁移 |
|
||||
| `crates/erp-server/migration/src/m20260410_000001_create_tenant.rs` | tenant 表迁移 |
|
||||
| `crates/erp-core/src/types.rs` | BaseFields 标准字段定义 |
|
||||
| `docker/docker-compose.yml` | PostgreSQL 16 服务定义 |
|
||||
| m000027 | 修复唯一索引 + 软删除冲突 |
|
||||
| m000034 | 种子插件权限 |
|
||||
| m000035 | pg_trgm 扩展 + entity 列 |
|
||||
| m000036 | role_permissions 添加 data_scope(行级数据权限) |
|
||||
| m000038 | 修复 CRM 权限码 |
|
||||
| m000039 | entity_registry 列 |
|
||||
| m000041 | plugin_user_views |
|
||||
|
||||
## 未来迁移计划 (按 Phase)
|
||||
## 4. 活跃问题 + 陷阱
|
||||
|
||||
| Phase | 表 | 说明 |
|
||||
|-------|-----|------|
|
||||
| Phase 2 | users, roles, permissions, user_roles, role_permissions | RBAC + ABAC |
|
||||
| Phase 3 | system_configs, config_histories | 层级配置 |
|
||||
| Phase 4 | workflow_definitions, workflow_instances, workflow_tasks | BPMN 工作流 |
|
||||
| Phase 5 | messages, notification_settings, message_templates | 多渠道消息 |
|
||||
| 持续 | domain_events | 事件 outbox 表 |
|
||||
### 历史教训
|
||||
|
||||
- 唯一索引 + 软删除冲突 — 已删除记录的 unique key 阻止新建(m000027 修复)
|
||||
- tenant 表缺少 `created_by`/`updated_by`/`version` 字段 — 首个迁移早于 BaseFields 规范
|
||||
|
||||
⚠️ settings 表的唯一索引曾需修复(m000032)
|
||||
⚠️ 新增表时务必对齐 `crates/erp-core/src/types.rs` 中的 BaseFields
|
||||
|
||||
## 5. 变更记录
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-04-23 | 重构为 5 节结构,更新表清单至 41 个迁移 |
|
||||
| 2026-04-19 | CRM 权限码修复迁移 (m000038) |
|
||||
|
||||
128
wiki/erp-core.md
128
wiki/erp-core.md
@@ -1,57 +1,27 @@
|
||||
---
|
||||
title: erp-core
|
||||
updated: 2026-04-23
|
||||
status: stable
|
||||
tags: [core, error, event-bus, module-trait, shared-types]
|
||||
---
|
||||
|
||||
# erp-core
|
||||
|
||||
## 设计思想
|
||||
> 从 [[index]] 导航。关联: [[erp-server]] [[database]] [[wasm-plugin]] [[architecture]]
|
||||
|
||||
`erp-core` 是整个 ERP 平台的 L1 基础层,所有业务模块的唯一共同依赖。它的职责是定义**跨模块共享的契约**,而非实现业务逻辑。
|
||||
## 1. 设计决策
|
||||
|
||||
核心设计决策:
|
||||
- **AppError 统一错误体系** — 6 种错误变体映射到 HTTP 状态码,业务 crate 只需 `?` 传播错误,由 Axum `IntoResponse` 自动转换
|
||||
- **EventBus 进程内广播** — 用 `tokio::sync::broadcast` 实现发布/订阅,模块间零耦合通信
|
||||
- **ErpModule 插件 trait** — 每个业务模块实现此 trait,由 `ModuleRegistry` 统一注册路由和事件处理器
|
||||
- **BaseFields 强制多租户** — 所有实体的基础字段模板,确保 `tenant_id` 从第一天就存在
|
||||
`erp-core` 是 L1 基础层,所有业务模块的唯一共同依赖。定义**跨模块共享的契约**,不含业务逻辑。
|
||||
|
||||
## 代码逻辑
|
||||
核心决策:
|
||||
- **AppError 统一错误体系** — 6 种变体映射 HTTP 状态码,`?` 传播 + Axum `IntoResponse` 自动转换
|
||||
- **EventBus 进程内广播** — `tokio::sync::broadcast` 实现零耦合通信
|
||||
- **ErpModule 插件 trait** — 统一注册路由和事件处理器
|
||||
- **BaseFields 强制多租户** — 所有实体基础字段模板
|
||||
|
||||
### 错误处理链
|
||||
```
|
||||
业务 crate (thiserror) → AppError → IntoResponse → HTTP JSON 响应
|
||||
数据库 (sea_orm::DbErr) → From 转换 → AppError (自动识别 duplicate key → Conflict)
|
||||
```
|
||||
## 2. 关键文件 + 数据流
|
||||
|
||||
错误响应统一格式:`{ "error": "not_found", "message": "资源不存在", "details": null }`
|
||||
|
||||
### 事件总线
|
||||
```
|
||||
发布者: EventBus::publish(DomainEvent) → broadcast channel
|
||||
订阅者: EventBus::subscribe() → Receiver<DomainEvent>
|
||||
事件字段: id(UUIDv7), event_type("user.created"), tenant_id, payload(JSON), timestamp, correlation_id
|
||||
```
|
||||
|
||||
事件类型命名规则:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed`
|
||||
|
||||
### 模块注册
|
||||
```
|
||||
业务模块实现 ErpModule trait → ModuleRegistry::register() →
|
||||
build_router(): 折叠所有模块的 register_routes() → Axum Router
|
||||
register_handlers(): 注册所有模块的事件处理器 → EventBus
|
||||
```
|
||||
|
||||
### 共享类型
|
||||
- `TenantContext` — 中间件注入的租户上下文(tenant_id, user_id, roles, permissions)
|
||||
- `Pagination` / `PaginatedResponse<T>` — 分页查询标准化(每页上限 100)
|
||||
- `ApiResponse<T>` — API 统一信封 `{ success, data, message }`
|
||||
|
||||
## 关联模块
|
||||
|
||||
- **[[erp-server]]** — 消费所有 erp-core 类型和 trait,是唯一组装点
|
||||
- **[[erp-auth]]** — 实现 `ErpModule` trait,发布认证事件
|
||||
- **[[erp-workflow]]** — 实现 `ErpModule` trait,订阅业务事件
|
||||
- **[[erp-message]]** — 实现 `ErpModule` trait,订阅通知事件
|
||||
- **[[erp-config]]** — 实现 `ErpModule` trait
|
||||
- **[[database]]** — 迁移表结构必须与 `BaseFields` 对齐
|
||||
- **[[wasm-plugin]]** — WASM 插件通过 Host Bridge 桥接 EventBus
|
||||
|
||||
## 关键文件
|
||||
### 核心文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
@@ -61,9 +31,63 @@
|
||||
| `crates/erp-core/src/types.rs` | BaseFields、Pagination、ApiResponse、TenantContext |
|
||||
| `crates/erp-core/src/lib.rs` | 模块导出入口 |
|
||||
|
||||
## 当前状态
|
||||
### 集成契约
|
||||
|
||||
**已实现,Phase 1 可用。** 但以下部分尚未被 erp-server 集成:
|
||||
- `ModuleRegistry` 未在 `main.rs` 中使用
|
||||
- `EventBus` 未创建实例
|
||||
- `TenantContext` 未通过中间件注入
|
||||
| 方向 | 模块 | 接口 | 触发时机 |
|
||||
|------|------|------|---------|
|
||||
| 提供 → | erp-auth | ErpModule trait, AppError, EventBus | 模块实现 |
|
||||
| 提供 → | erp-config | ErpModule trait, AppError | 模块实现 |
|
||||
| 提供 → | erp-workflow | ErpModule trait, AppError, EventBus | 模块实现 |
|
||||
| 提供 → | erp-message | ErpModule trait, AppError, EventBus | 模块实现 |
|
||||
| 提供 → | erp-plugin | ErpModule trait, AppError, EventBus | 模块实现 |
|
||||
| 消费 ← | [[erp-server]] | ModuleRegistry 组装 | 启动时 |
|
||||
| 桥接 ← | [[wasm-plugin]] | EventBus → 插件 handle_event | 运行时 |
|
||||
|
||||
## 3. 代码逻辑
|
||||
|
||||
### 错误处理链
|
||||
|
||||
```
|
||||
业务 crate (thiserror) → AppError → IntoResponse → HTTP JSON
|
||||
数据库 (sea_orm::DbErr) → From 转换 → AppError (自动识别 duplicate key → Conflict)
|
||||
```
|
||||
|
||||
响应格式:`{ "error": "not_found", "message": "资源不存在", "details": null }`
|
||||
|
||||
### 事件总线
|
||||
|
||||
```
|
||||
EventBus::publish(DomainEvent) → broadcast channel → Receiver<DomainEvent>
|
||||
事件字段: id(UUIDv7), event_type("user.created"), tenant_id, payload(JSON), timestamp
|
||||
```
|
||||
|
||||
命名规则:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed`
|
||||
|
||||
### 模块注册
|
||||
|
||||
```
|
||||
ErpModule trait → ModuleRegistry::register() →
|
||||
build_router(): 折叠所有模块路由 → Axum Router
|
||||
register_handlers(): 注册事件处理器 → EventBus
|
||||
```
|
||||
|
||||
### 共享类型
|
||||
|
||||
- `TenantContext` — 租户上下文(tenant_id, user_id, roles, permissions, department_ids)
|
||||
- `Pagination` / `PaginatedResponse<T>` — 分页标准化(每页上限 100)
|
||||
- `ApiResponse<T>` — 统一信封 `{ success, data, message }`
|
||||
|
||||
⚡ **不变量**: erp-core 不依赖任何业务 crate,只被依赖
|
||||
⚡ **不变量**: 所有 API 使用 `/api/v1/` 前缀
|
||||
⚡ **不变量**: tenant_id 从 JWT 中间件注入,应用层不可伪造
|
||||
|
||||
## 4. 活跃问题 + 陷阱
|
||||
|
||||
⚠️ crate 内部可用 `anyhow`,但跨 crate 边界必须转 `AppError`
|
||||
⚠️ EventBus 当前为内存 broadcast,outbox 持久化通过后台任务实现
|
||||
|
||||
## 5. 变更记录
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-04-23 | 重构为 5 节结构,更新为已完全集成状态 |
|
||||
|
||||
122
wiki/erp-health.md
Normal file
122
wiki/erp-health.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
title: erp-health 健康管理模块
|
||||
updated: 2026-04-23
|
||||
status: developing
|
||||
tags: [health, patient, appointment, follow-up, consultation]
|
||||
---
|
||||
|
||||
# erp-health 健康管理模块
|
||||
|
||||
> 从 [[index]] 导航。关联: [[erp-core]] [[erp-server]] [[database]] [[frontend]]
|
||||
>
|
||||
> 设计规格: `docs/superpowers/specs/2026-04-23-health-management-module-design.md`
|
||||
|
||||
## 1. 设计决策
|
||||
|
||||
### 为什么用原生模块而非 WASM 插件?
|
||||
|
||||
| WASM 插件限制 | 健康模块需求 |
|
||||
|---------------|-------------|
|
||||
| 实体上限 20 个 | 16+ 强类型医疗实体 |
|
||||
| JSONB 动态存储 | 医疗数据需要强类型、索引、关联 |
|
||||
| 无自定义 API | 趋势分析、统计报表需专用端点 |
|
||||
| 无文件上传 | 化验单、体检报告需存储 |
|
||||
| 沙箱限制 | 无法引入加密、AI、外部 API |
|
||||
|
||||
### 为什么患者/医护账号走 erp-auth?
|
||||
|
||||
复用现有用户体系(认证、JWT、权限),erp-health 只存医疗扩展字段。患者可先建档(体检中心导入),后续再绑定账号。
|
||||
|
||||
### 核心架构选择
|
||||
|
||||
- **原生 Rust crate** — 与 erp-auth、erp-workflow 同等地位,直接访问数据库
|
||||
- **固有方法暴露路由** — `public_routes()` / `protected_routes()`,在 erp-server 中 `.nest("/api/v1/health", ...)`
|
||||
- **EventBus 通信** — 发布 `patient.created`、`appointment.confirmed` 等,订阅 `workflow.task.completed`
|
||||
|
||||
## 2. 关键文件 + 数据流
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
crates/erp-health/
|
||||
├── src/
|
||||
│ ├── lib.rs ← ErpModule trait + routes()
|
||||
│ ├── error.rs ← HealthError → AppError
|
||||
│ ├── state.rs ← HealthState
|
||||
│ ├── entity/ ← 16 个 SeaORM Entity
|
||||
│ ├── service/ ← 5 个业务 service
|
||||
│ ├── handler/ ← 5 个路由 handler
|
||||
│ ├── dto/ ← 请求/响应结构体
|
||||
│ └── event.rs ← 事件定义和处理器
|
||||
```
|
||||
|
||||
### 实体模型(16 张表)
|
||||
|
||||
| 域 | 实体 |
|
||||
|----|------|
|
||||
| 患者管理 | patient, patient_family_member, patient_tag, patient_tag_relation, patient_doctor_relation |
|
||||
| 医护管理 | doctor_profile |
|
||||
| 健康数据 | health_record, vital_signs, lab_report, health_trend |
|
||||
| 预约排班 | appointment, doctor_schedule |
|
||||
| 随访管理 | follow_up_task, follow_up_record |
|
||||
| 咨询管理 | consultation_session, consultation_message |
|
||||
|
||||
### 集成契约
|
||||
|
||||
| 方向 | 模块 | 接口 | 触发时机 |
|
||||
|------|------|------|---------|
|
||||
| 提供 → | [[erp-server]] | `protected_routes()` | 启动时注册 `/api/v1/health/*` |
|
||||
| 调用 → | [[erp-core]] | EventBus | 发布/订阅领域事件 |
|
||||
| 关联 → | erp-auth | `users` 表 (user_id FK) | 患者/医护关联账号 |
|
||||
| 订阅 ← | erp-workflow | `workflow.task.completed` | 随访任务状态更新 |
|
||||
| 订阅 ← | erp-message | `message.sent` | 咨询会话 last_message_at |
|
||||
|
||||
## 3. 代码逻辑
|
||||
|
||||
### API 前缀: `/api/v1/health/`
|
||||
|
||||
关键端点分组:
|
||||
- `/patients` — 患者列表/详情/标签管理/健康摘要
|
||||
- `/patients/:id/vital-signs` — 日常监测数据(血压/心率/体重/血糖)
|
||||
- `/patients/:id/lab-reports` — 化验报告(JSONB 指标数据)
|
||||
- `/patients/:id/trends` — 健康趋势报告(自动/手动生成)
|
||||
- `/appointments` — 预约管理(状态机: pending→confirmed→completed)
|
||||
- `/doctor-schedules` — 排班管理(日历视图)
|
||||
- `/follow-up-tasks` — 随访任务(逾期自动标记)
|
||||
- `/consultation-sessions` — 咨询会话管理
|
||||
|
||||
### 预约并发控制
|
||||
|
||||
创建预约时使用原子 CAS:`UPDATE doctor_schedule SET current_appointments = current_appointments + 1 WHERE id = $1 AND current_appointments < max_appointments RETURNING *`
|
||||
|
||||
### 随访自动链接
|
||||
|
||||
`follow_up_record.next_follow_up_date` 不为空时,自动创建新的 `follow_up_task`。
|
||||
|
||||
### 权限码
|
||||
|
||||
`health.patient.list/manage` · `health.health-data.list/manage` · `health.appointment.list/manage` · `health.follow-up.list/manage` · `health.consultation.list/manage` · `health.doctor.list/manage`
|
||||
|
||||
⚡ **不变量**: 预约创建必须走原子 CAS,不能用 read-then-write
|
||||
⚡ **不变量**: `patient.user_id` 允许 NULL(先建档后绑定)
|
||||
⚡ **不变量**: `consultation_message` 对 `created_at` 按月分区,超 1 年归档
|
||||
|
||||
## 4. 活跃问题 + 陷阱
|
||||
|
||||
### 当前状态: 🔧 开发中
|
||||
|
||||
设计规格已确认,尚未开始编码。
|
||||
|
||||
### 待解决
|
||||
|
||||
| 问题 | 级别 | 说明 |
|
||||
|------|------|------|
|
||||
| 文件上传基础能力 | P1 | 化验单/体检报告需要文件存储服务 |
|
||||
| ECharts 趋势图 | P1 | 前端健康趋势可视化 |
|
||||
| 导出功能 | P2 | 随访台账/咨询记录导出 Excel |
|
||||
|
||||
## 5. 变更记录
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-04-23 | 创建模块 wiki 页,设计规格确认 |
|
||||
@@ -1,66 +1,110 @@
|
||||
---
|
||||
title: erp-server
|
||||
updated: 2026-04-23
|
||||
status: stable
|
||||
tags: [server, axum, assembly, entry-point]
|
||||
---
|
||||
|
||||
# erp-server
|
||||
|
||||
## 设计思想
|
||||
> 从 [[index]] 导航。关联: [[erp-core]] [[infrastructure]] [[database]] [[frontend]]
|
||||
|
||||
`erp-server` 是 L3 层——**唯一的组装点**。它不包含业务逻辑,只负责把所有业务模块组装成可运行的服务。
|
||||
## 1. 设计决策
|
||||
|
||||
核心决策:
|
||||
- **配置优先** — 使用 `config` crate 从 TOML 文件 + 环境变量加载,`ERP__` 前缀覆盖(如 `ERP__DATABASE__URL`)
|
||||
- **启动序列严格有序** — 配置 → 日志 → 数据库 → 迁移 → Redis → 路由 → 监听,每步失败即终止
|
||||
- **单一入口** — 所有模块通过 `ModuleRegistry` 注册,server 本身不直接 import 业务模块的类型
|
||||
- **唯一组装点** — 不含业务逻辑,只负责把所有模块组装成可运行服务
|
||||
- **配置优先** — `config` crate 从 TOML + 环境变量加载,`ERP__` 前缀覆盖
|
||||
- **严格启动序列** — 每步失败即终止,不做部分启动
|
||||
- **安全检查** — 拒绝默认 JWT 密钥 / 数据库 URL
|
||||
|
||||
## 代码逻辑
|
||||
## 2. 关键文件 + 数据流
|
||||
|
||||
### 启动流程 (`main.rs`)
|
||||
```
|
||||
1. AppConfig::load() ← config/default.toml + 环境变量
|
||||
2. init_tracing(level) ← JSON 格式日志
|
||||
3. db::connect(&db_config) ← SeaORM 连接池 (max=20, min=5)
|
||||
4. Migrator::up(&db_conn) ← 运行所有待执行迁移
|
||||
5. redis::Client::open(url) ← Redis 客户端(当前未使用)
|
||||
6. Router::new() ← 当前仅有 404 fallback
|
||||
7. bind(host, port).serve() ← 启动 HTTP 服务
|
||||
```
|
||||
|
||||
### 配置结构
|
||||
```
|
||||
AppConfig
|
||||
├── server: ServerConfig { host: "0.0.0.0", port: 3000 }
|
||||
├── database: DatabaseConfig { url, max_connections: 20, min_connections: 5 }
|
||||
├── redis: RedisConfig { url: "redis://localhost:6379" }
|
||||
├── jwt: JwtConfig { secret, access_token_ttl, refresh_token_ttl }
|
||||
└── log: LogConfig { level: "info" }
|
||||
```
|
||||
|
||||
### 当前状态
|
||||
- 数据库连接池正常工作
|
||||
- 迁移自动执行
|
||||
- **没有注册任何路由** — 仅返回 404
|
||||
- **没有使用 ModuleRegistry** — 未集成业务模块
|
||||
- Redis 客户端已创建但未执行任何命令
|
||||
- 缺少 CORS、压缩、请求追踪中间件
|
||||
|
||||
## 关联模块
|
||||
|
||||
- **[[erp-core]]** — 提供 AppError、ErpModule trait、ModuleRegistry
|
||||
- **[[database]]** — 迁移文件通过 `erp-server-migration` crate 引用
|
||||
- **[[infrastructure]]** — Docker 提供 PostgreSQL 和 Redis 服务
|
||||
- **[[frontend]]** — Vite 代理 `/api` 请求到 server 的 3000 端口
|
||||
|
||||
## 关键文件
|
||||
### 核心文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `crates/erp-server/src/main.rs` | 服务启动入口 |
|
||||
| `crates/erp-server/src/state.rs` | AppState 定义 |
|
||||
| `crates/erp-server/src/config.rs` | 5 个配置 struct + 加载逻辑 |
|
||||
| `crates/erp-server/src/db.rs` | SeaORM 连接池配置 |
|
||||
| `crates/erp-server/config/default.toml` | 默认配置值 |
|
||||
| `crates/erp-server/Cargo.toml` | 依赖声明 |
|
||||
| `crates/erp-server/config/default.toml` | 默认配置(密钥为占位符) |
|
||||
|
||||
## 待完成 (Phase 1 剩余)
|
||||
### 启动流程
|
||||
|
||||
1. 实例化 `ModuleRegistry` 并注册模块
|
||||
2. 添加 CORS 中间件(tower-http)
|
||||
3. 添加请求追踪中间件
|
||||
4. 将 Redis 连接注入 AppState
|
||||
5. 健康检查端点 (`/api/v1/health`)
|
||||
```
|
||||
AppConfig::load() → 安全检查 → init_tracing → db::connect → Migrator::up
|
||||
→ 种子数据(默认租户+管理员) → Redis客户端 → EventBus(容量1024)
|
||||
→ 注册5个模块 → 初始化插件引擎+恢复插件 → 4个后台任务
|
||||
→ 构建Router → bind + serve → 优雅关闭(CTRL+C/SIGTERM)
|
||||
```
|
||||
|
||||
### 注册的 5 个模块
|
||||
|
||||
AuthModule → ConfigModule → WorkflowModule → MessageModule → PluginModule
|
||||
|
||||
### AppState
|
||||
|
||||
```
|
||||
AppState {
|
||||
db: DatabaseConnection,
|
||||
config: AppConfig,
|
||||
event_bus: EventBus,
|
||||
module_registry: ModuleRegistry,
|
||||
redis: redis::Client,
|
||||
default_tenant_id: Uuid,
|
||||
plugin_engine: PluginEngine,
|
||||
plugin_entity_cache: moka::Cache (1000容量, 5分钟TTL),
|
||||
}
|
||||
```
|
||||
|
||||
### 集成契约
|
||||
|
||||
| 方向 | 模块 | 接口 | 触发时机 |
|
||||
|------|------|------|---------|
|
||||
| 组装 → | erp-auth | `AuthModule` | 启动时注册 |
|
||||
| 组装 → | erp-config | `ConfigModule` | 启动时注册 |
|
||||
| 组装 → | erp-workflow | `WorkflowModule` | 启动时注册 |
|
||||
| 组装 → | erp-message | `MessageModule` | 启动时注册 |
|
||||
| 组装 → | erp-plugin | `PluginModule` | 启动时注册 |
|
||||
| 依赖 ← | [[erp-core]] | ErpModule trait, EventBus | 所有模块 |
|
||||
| 依赖 ← | [[infrastructure]] | PostgreSQL, Redis | 连接 |
|
||||
|
||||
### 后台任务
|
||||
|
||||
1. 消息监听器 — EventBus → MessageModule
|
||||
2. 插件通知 — EventBus → PluginModule
|
||||
3. Outbox relay — domain_events → 外部
|
||||
4. 超时检查器 — 工作流任务超时处理
|
||||
|
||||
## 3. 代码逻辑
|
||||
|
||||
### 中间件栈
|
||||
|
||||
```
|
||||
CORS(可配置 origins) → IP限流(公开路由) → 用户限流(受保护路由) → JWT认证
|
||||
```
|
||||
|
||||
### 配置结构
|
||||
|
||||
```
|
||||
AppConfig
|
||||
├── server: { host: "0.0.0.0", port: 3000 }
|
||||
├── database: { url, max_connections: 20, min_connections: 5 }
|
||||
├── redis: { url }
|
||||
├── jwt: { secret, access_token_ttl: 15min, refresh_token_ttl: 7d }
|
||||
└── log: { level: "info" }
|
||||
```
|
||||
|
||||
⚡ **不变量**: 4 个环境变量在 default.toml 中都是 `__MUST_SET_VIA_ENV__` 占位符,必须通过环境变量设置
|
||||
|
||||
⚡ **不变量**: 启动顺序不可变更 — 数据库必须先于迁移,迁移必须先于模块注册
|
||||
|
||||
## 4. 活跃问题 + 陷阱
|
||||
|
||||
⚠️ 后端必须从 `crates/erp-server/` 目录启动(或通过环境变量覆盖所有配置)
|
||||
⚠️ 种子数据自动创建默认租户和管理员,重复启动幂等
|
||||
|
||||
## 5. 变更记录
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-04-23 | 重构为 5 节结构,更新为当前集成状态 |
|
||||
|
||||
152
wiki/frontend.md
152
wiki/frontend.md
@@ -1,81 +1,103 @@
|
||||
# frontend (Web 前端)
|
||||
---
|
||||
title: Web 前端
|
||||
updated: 2026-04-23
|
||||
status: stable
|
||||
tags: [frontend, react, antd, vite, spa]
|
||||
---
|
||||
|
||||
## 设计思想
|
||||
# Web 前端
|
||||
|
||||
前端是一个 Vite + React SPA,遵循 **UI 层只做展示** 的原则:
|
||||
> 从 [[index]] 导航。关联: [[erp-server]] [[infrastructure]]
|
||||
|
||||
- **组件库优先** — 使用 Ant Design,不自造轮子
|
||||
- **状态集中** — Zustand 管理全局状态(主题、侧边栏、认证)
|
||||
- **API 层分离** — HTTP 调用封装到 service 层,组件不直接 fetch
|
||||
- **代理开发** — Vite 开发服务器代理 `/api` 到后端 3000 端口
|
||||
## 1. 设计决策
|
||||
|
||||
版本实际使用情况(与设计规格有差异):
|
||||
| 技术 | 规格 | 实际 |
|
||||
|------|------|------|
|
||||
| React | 18 | 19.2.4 |
|
||||
| Ant Design | 5 | 6.3.5 |
|
||||
| React Router | 7 | 7.14.0 |
|
||||
- **组件库优先** — Ant Design 6,不自造轮子
|
||||
- **状态集中** — Zustand 管理全局状态(4 个 store)
|
||||
- **API 层分离** — HTTP 调用封装到 `src/api/`(21 个文件),组件不直接 fetch
|
||||
- **代理开发** — Vite 代理 `/api` 到后端 3000 端口
|
||||
- **HashRouter** — 不需要服务端 fallback 配置,部署更稳健
|
||||
- **懒加载** — 除 Login 外所有页面使用 `lazy()` 按需加载
|
||||
|
||||
## 代码逻辑
|
||||
### 版本(以实际 package.json 为准)
|
||||
|
||||
### 应用结构
|
||||
```
|
||||
main.tsx → App.tsx (ConfigProvider + HashRouter) → MainLayout → 各页面组件
|
||||
```
|
||||
React 19.2.4 / Ant Design 6.3.5 / React Router 7.14.0 / Zustand 5.0.12 / Vite 8.0.4 / TypeScript 6.0.2
|
||||
|
||||
### MainLayout 布局
|
||||
经典 SaaS 后台管理布局:
|
||||
- **左侧 Sidebar** — 可折叠暗色菜单,分组:首页/用户/权限/设置
|
||||
- **顶部 Header** — 侧边栏切换 + 通知徽标(硬编码5) + 头像("Admin")
|
||||
- **中间 Content** — React Router Outlet,多标签页切换
|
||||
- **底部 Footer** — 租户名 + 版本号
|
||||
## 2. 关键文件 + 数据流
|
||||
|
||||
### 状态管理 (Zustand)
|
||||
```typescript
|
||||
appStore {
|
||||
isLoggedIn: boolean // 未使用,无登录页
|
||||
tenantName: string // 默认 "ERP Platform"
|
||||
theme: 'light' | 'dark' // 切换 Ant Design 主题
|
||||
sidebarCollapsed: boolean
|
||||
toggleSidebar(), setTheme(), login(), logout()
|
||||
}
|
||||
```
|
||||
|
||||
### 开发服务器代理
|
||||
```
|
||||
http://localhost:5174/api/* → http://localhost:3000/* (API)
|
||||
ws://localhost:5174/ws/* → ws://localhost:3000/* (WebSocket)
|
||||
```
|
||||
|
||||
### 当前状态
|
||||
- 布局壳体完整,暗色/亮色主题切换可用
|
||||
- 只有一个路由 `/` → 占位 HomePage ("Welcome to ERP Platform")
|
||||
- 无 API 调用、无认证流程、无真实数据
|
||||
- 通知计数硬编码为 5,用户名硬编码为 "Admin"
|
||||
- 未实现 i18n(代码中有 zh_CN locale 但文案硬编码)
|
||||
|
||||
## 关联模块
|
||||
|
||||
- **[[erp-server]]** — API 后端,通过 Vite proxy 连接
|
||||
- **[[infrastructure]]** — Docker 提供 PostgreSQL + Redis
|
||||
|
||||
## 关键文件
|
||||
### 核心文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `apps/web/src/main.tsx` | React 入口 |
|
||||
| `apps/web/src/App.tsx` | 根组件:ConfigProvider + 路由 |
|
||||
| `apps/web/src/layouts/MainLayout.tsx` | 完整后台管理布局 |
|
||||
| `apps/web/src/stores/app.ts` | Zustand 全局状态 |
|
||||
| `apps/web/src/index.css` | TailwindCSS 导入 |
|
||||
| `apps/web/src/App.tsx` | 路由定义(公开 + 受保护) |
|
||||
| `apps/web/src/layouts/MainLayout.tsx` | SaaS 后台管理布局 |
|
||||
| `apps/web/src/stores/` | 4 个 Zustand store |
|
||||
| `apps/web/src/api/` | 21 个 API 服务文件 |
|
||||
| `apps/web/vite.config.ts` | Vite 配置 + API 代理 |
|
||||
| `apps/web/package.json` | 依赖声明 |
|
||||
|
||||
## 待实现 (按 Phase)
|
||||
### 路由结构
|
||||
|
||||
| Phase | 内容 |
|
||||
**公开**: `/login`
|
||||
|
||||
**受保护(MainLayout 包裹)**:
|
||||
|
||||
| 路径 | 页面 |
|
||||
|------|------|
|
||||
| `/` | 首页 |
|
||||
| `/users`, `/roles`, `/organizations` | 用户/角色/组织管理 |
|
||||
| `/workflow` | 工作流 |
|
||||
| `/messages` | 消息中心 |
|
||||
| `/settings` | 系统设置 |
|
||||
| `/plugins/admin`, `/plugins/market` | 插件管理/市场 |
|
||||
| `/plugins/:pluginId/:entityName` | 插件 CRUD(动态生成) |
|
||||
| `/plugins/:pluginId/tabs|tree|graph|dashboard|kanban/:name` | 插件多视图页面 |
|
||||
|
||||
### 集成契约
|
||||
|
||||
| 方向 | 模块 | 接口 | 触发时机 |
|
||||
|------|------|------|---------|
|
||||
| 调用 → | [[erp-server]] | `/api/v1/*` REST | 所有数据操作 |
|
||||
| 调用 → | [[erp-server]] | `ws://localhost:3000/ws/*` | WebSocket |
|
||||
| 消费 ← | 插件系统 | `plugin.toml` schema | 动态生成插件页面 |
|
||||
|
||||
## 3. 代码逻辑
|
||||
|
||||
### 状态管理(4 个 Zustand Store)
|
||||
|
||||
| Store | 状态 |
|
||||
|-------|------|
|
||||
| Phase 2 | 登录页、用户管理页、角色权限页 |
|
||||
| Phase 3 | 系统配置管理页 |
|
||||
| Phase 4 | 工作流设计器、审批列表 |
|
||||
| Phase 5 | 消息中心、通知设置 |
|
||||
| `app.ts` | theme(light/dark), sidebarCollapsed |
|
||||
| `auth.ts` | user, isAuthenticated, localStorage 持久化 |
|
||||
| `message.ts` | unreadCount, recentMessages, 请求去重 |
|
||||
| `plugin.ts` | plugins 列表, 动态菜单, schema 缓存, 请求去重 |
|
||||
|
||||
### 插件页面系统
|
||||
|
||||
插件通过 `plugin.toml` schema 声明页面,前端根据 schema 动态生成:
|
||||
- `PluginCRUDPage` — 标准列表+表单
|
||||
- `PluginTabsPage` — 标签页切换
|
||||
- `PluginTreePage` — 树形展示
|
||||
- `PluginGraphPage` — 关系图谱
|
||||
- `PluginKanbanPage` — 看板视图
|
||||
- `PluginDashboardPage` — 仪表盘
|
||||
|
||||
⚡ **不变量**: 插件菜单由 `plugin.ts` store 从 API 动态获取,不硬编码
|
||||
⚡ **不变量**: API client 在请求前 30s 检查 token 过期,提前刷新避免 401
|
||||
|
||||
### 代理配置
|
||||
|
||||
```
|
||||
http://localhost:5174/api/* → http://localhost:3000/* (API)
|
||||
ws://localhost:5174/ws/* → ws://localhost:3000/* (WebSocket)
|
||||
```
|
||||
|
||||
## 4. 活跃问题 + 陷阱
|
||||
|
||||
⚠️ Ant Design 6 废弃 API 警告(`valueStyle`/`Spin tip`/`trailColor`)已在历史版本中修复
|
||||
⚠️ `antd.setScaleParam` 强制回流 64ms — antd 内部问题,无法直接修复
|
||||
|
||||
## 5. 变更记录
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-04-23 | 重构为 5 节结构,更新为当前完整前端状态 |
|
||||
|
||||
124
wiki/index.md
124
wiki/index.md
@@ -1,95 +1,71 @@
|
||||
# ERP 平台底座 — 知识库
|
||||
# HMS 健康管理平台 — 知识库
|
||||
|
||||
## 项目画像
|
||||
> **Health Management System (HMS)** — 面向体检中心/医疗机构的综合健康管理平台。从 ERP 底座分叉,继承身份权限/工作流/消息/配置等基础能力,`erp-health` 原生模块承载医疗业务。
|
||||
|
||||
**模块化 SaaS ERP 底座**,Rust + React 技术栈,提供身份权限/工作流/消息/配置/插件五大基础模块,支持行业业务模块快速插接。
|
||||
## 关键数字
|
||||
|
||||
关键数字:
|
||||
- 13 个 Rust crate(9 个已实现 + 2 个插件原型 + 2 个业务插件),1 个前端 SPA
|
||||
- 37 个数据库迁移
|
||||
- 6 个业务模块 (auth, config, workflow, message, plugin, server)
|
||||
- 4 个插件 crate (plugin-prototype, plugin-test-sample, plugin-crm, plugin-inventory)
|
||||
- Health Check API (`/api/v1/health`)
|
||||
- OpenAPI JSON (`/api/docs/openapi.json`)
|
||||
- Phase 1-6 全部完成,WASM 插件系统已集成到主服务
|
||||
- Q2-Q4 成熟度路线图已完成(安全地基/架构强化/测试覆盖/插件生态)
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| Rust crate | 14 个(7 核心 + erp-health + 6 插件) |
|
||||
| 数据库表 | 30+ 基础表 + 16 健康业务表(规划中) |
|
||||
| 核心模块 | 5 基础 (auth/config/workflow/message/plugin) + 1 业务 (health) |
|
||||
| 健康模块页面 | 13 个(规划中) |
|
||||
| API 文档 | `http://localhost:3000/api/docs/openapi.json` |
|
||||
|
||||
## 模块导航树
|
||||
## 症状导航
|
||||
|
||||
### L1 基础层
|
||||
| 症状 | 先查 | 再查 | 常见根因 |
|
||||
|------|------|------|----------|
|
||||
| API 返回 403 | 权限码检查 | [[wasm-plugin]] 权限系统 | 权限码不匹配 / 缺少 .list 权限 |
|
||||
| API 返回 500 无日志 | [[erp-core]] 错误链 | 后端 tracing 输出 | AppError::Internal 静默 |
|
||||
| 数据库连接失败 | [[infrastructure]] | PostgreSQL 服务状态 | 服务未启动 / 环境变量未设置 |
|
||||
| 前端 401 刷新时 | [[frontend]] auth store | API client token 刷新 | token 过期未主动刷新 |
|
||||
| 迁移执行失败 | [[database]] | PostgreSQL 日志 | 表冲突 / 唯一索引 + 软删除 |
|
||||
| 端口被占用 | [[infrastructure]] dev.ps1 | 端口 5174-5189 进程 | 残留 Vite 进程 |
|
||||
| 预约超额 | [[erp-health]] 排班并发 | appointment CAS 操作 | 并发控制未走原子 CAS |
|
||||
| 跨租户数据泄漏 | [[architecture]] 多租户策略 | [[database]] tenant_id | 查询缺少 tenant_id 过滤 |
|
||||
|
||||
## 模块导航
|
||||
|
||||
### 基础层(继承自 ERP 底座)
|
||||
- [[erp-core]] — 错误体系 · 事件总线 · 模块 trait · 共享类型
|
||||
- [[erp-common]] — ID 生成 · 时间戳 · 编号生成工具
|
||||
- [[architecture]] — 架构决策 · 设计原则 · 技术选型
|
||||
|
||||
### L2 业务层
|
||||
- erp-auth — 用户/角色/权限/组织/部门/岗位管理 · JWT 认证 · RBAC · 行级数据权限
|
||||
### 业务层(继承自 ERP 底座)
|
||||
- erp-auth — 用户/角色/权限/组织/部门/岗位 · JWT · RBAC · 行级数据权限
|
||||
- erp-config — 字典/菜单/设置/编号规则/主题/语言
|
||||
- erp-workflow — BPMN 解析 · Token 驱动执行 · 任务分配 · 流程设计器
|
||||
- erp-message — 消息 CRUD · 模板管理 · 订阅偏好 · 通知面板 · 事件集成
|
||||
- erp-plugin — 插件管理 · WASM 运行时 · 动态表 · 数据 CRUD · 生命周期管理 · 热更新 · 行级数据权限
|
||||
- erp-workflow — BPMN 解析 · Token 驱动 · 任务分配
|
||||
- erp-message — 消息 CRUD · 模板 · 订阅 · 通知面板
|
||||
- erp-plugin — WASM 运行时 · 动态表 · 热更新(HMS 保留但非主要扩展方式)
|
||||
|
||||
### L3 组装层
|
||||
- [[erp-server]] — Axum 服务入口 · AppState · ModuleRegistry 集成 · 配置加载 · 数据库连接 · 优雅关闭
|
||||
### 核心业务层(HMS 专属)
|
||||
- [[erp-health]] — **患者管理 · 健康数据 · 预约排班 · 随访管理 · 咨询管理**(原生 Rust 模块)
|
||||
|
||||
### 插件系统
|
||||
- [[wasm-plugin]] — Wasmtime 运行时 · WIT 接口契约 · Host API · Fuel 资源限制 · 插件制作完整流程
|
||||
- erp-plugin-crm — CRM 客户管理插件 (5 实体/9 权限/6 页面)
|
||||
- erp-plugin-inventory — 进销存管理插件 (6 实体/12 权限/6 页面)
|
||||
### 组装层
|
||||
- [[erp-server]] — Axum 入口 · AppState · 模块注册 · 后台任务 · 优雅关闭
|
||||
|
||||
### 基础设施
|
||||
- [[database]] — SeaORM 迁移 · 多租户表结构 · 软删除模式
|
||||
- [[infrastructure]] — Windows 开发环境 · PostgreSQL 16 · Redis 7 · 一键启动脚本
|
||||
- [[frontend]] — React SPA · Ant Design 布局 · Zustand 状态
|
||||
- [[testing]] — 测试环境指南 · 验证清单 · 常见问题
|
||||
- [[infrastructure]] — 连接信息 · 环境变量 · 一键启动 (**单一真相源**)
|
||||
- [[database]] — SeaORM 迁移 · 多租户表结构
|
||||
- [[frontend]] — React 19 SPA · 健康管理页面
|
||||
- [[testing]] — 验证清单 · 测试分布 · 性能基准
|
||||
|
||||
### 横切关注点
|
||||
- [[architecture]] — 架构决策记录 · 设计原则 · 技术选型理由
|
||||
## 核心架构问答
|
||||
|
||||
## 核心架构决策
|
||||
**为什么 erp-health 用原生模块而非 WASM 插件?** 医疗业务需要 16+ 强类型实体、自定义 API(趋势分析/统计报表)、文件上传(化验单/体检报告)、未来 AI 集成,超出 WASM 插件能力范围。详见 [[erp-health]]。
|
||||
|
||||
**模块间如何通信?** 通过 [[erp-core]] 的 EventBus 发布/订阅 DomainEvent,不直接依赖。
|
||||
**模块间如何通信?** [[erp-core]] EventBus 发布/订阅 DomainEvent。erp-health 发布 `patient.created`、`appointment.confirmed` 等事件,订阅 `workflow.task.completed` 等。详见 [[architecture]]。
|
||||
|
||||
**多租户怎么隔离?** 共享数据库 + tenant_id 列过滤,中间件从 JWT 注入 TenantContext。详见 [[database]] 和 [[architecture]]。
|
||||
**多租户怎么隔离?** 共享数据库 + `tenant_id` 列过滤,中间件从 JWT 注入。详见 [[database]] [[architecture]]。
|
||||
|
||||
**错误怎么传播?** 业务 crate 用 thiserror → AppError → Axum IntoResponse 自动转 HTTP。详见 [[erp-core]] 错误处理链。
|
||||
**患者/医护与 erp-auth 的关系?** 账号走 `users` 表,erp-health 通过 `user_id` 外键关联扩展字段(科室、职称、档案等)。患者可先建档后绑定账号。
|
||||
|
||||
**状态如何共享?** AppState 包含 DB、Config、EventBus、ModuleRegistry,通过 Axum State 提取器注入所有 handler。
|
||||
## 文档索引
|
||||
|
||||
**ModuleRegistry 怎么工作?** 每个 Phase 2+ 的业务模块实现 ErpModule trait,在 main.rs 中链式注册。registry 自动构建路由和事件处理器。
|
||||
|
||||
**插件系统怎么扩展业务?** 通过 [[wasm-plugin]] 的 WASM 沙箱运行第三方插件,插件通过 WIT 定义的 Host API 与系统交互。详细流程见插件制作指南。
|
||||
|
||||
**版本差异怎么办?** package.json 使用 React 19 + Ant Design 6(比规格文档更新),以实际代码为准。
|
||||
|
||||
**行级数据权限怎么控制?** role_permissions 表增加 data_scope 字段(all/self/department/department_tree),JWT 中间件注入 department_ids,插件数据查询自动拼接 scope 条件。
|
||||
|
||||
**插件怎么热更新?** 通过 `/api/v1/admin/plugins/{id}/upgrade` 上传新版本 WASM + manifest,系统对比 schema 变更执行增量 DDL,卸载旧 WASM 加载新 WASM,失败时保持旧版本继续运行。
|
||||
|
||||
## 开发进度
|
||||
|
||||
| Phase | 内容 | 状态 |
|
||||
|-------|------|------|
|
||||
| 1 | 基础设施 | 完成 |
|
||||
| 2 | 身份与权限 | 完成 |
|
||||
| 3 | 系统配置 | 完成 |
|
||||
| 4 | 工作流引擎 | 完成 |
|
||||
| 5 | 消息中心 | 完成 |
|
||||
| 6 | 整合与打磨 | 完成 |
|
||||
| - | WASM 插件原型 | V1-V6 验证通过 |
|
||||
| - | 插件系统集成 | 已集成到主服务 |
|
||||
| - | CRM 插件 | 完成 |
|
||||
| - | Q2 安全地基 + CI/CD | 完成 |
|
||||
| - | Q3 架构强化 + 前端体验 | 完成 |
|
||||
| - | Q4 测试覆盖 + 插件生态 | 完成 |
|
||||
|
||||
## 关键文档索引
|
||||
|
||||
| 文档 | 位置 |
|
||||
| 类型 | 位置 |
|
||||
|------|------|
|
||||
| 设计规格 | `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` |
|
||||
| 实施计划 | `docs/superpowers/plans/2026-04-10-erp-platform-base-plan.md` |
|
||||
| WASM 插件设计 | `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` |
|
||||
| WASM 插件计划 | `docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md` |
|
||||
| CRM 插件设计 | `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` |
|
||||
| CRM 插件计划 | `docs/superpowers/plans/2026-04-16-crm-plugin-plan.md` |
|
||||
| 健康模块设计规格 | `docs/superpowers/specs/2026-04-23-health-management-module-design.md` |
|
||||
| 设计规格 | `docs/superpowers/specs/` |
|
||||
| 实施计划 | `docs/superpowers/plans/` |
|
||||
| 协作规则 | `CLAUDE.md` |
|
||||
| 设计评审 | `plans/squishy-pondering-aho-agent-a23c7497aadc6da41.md` |
|
||||
| 插件制作指南 | `.claude/skills/plugin-development/SKILL.md` |
|
||||
|
||||
@@ -1,138 +1,104 @@
|
||||
# infrastructure (开发环境)
|
||||
---
|
||||
title: 开发环境
|
||||
updated: 2026-04-23
|
||||
status: stable
|
||||
tags: [infrastructure, dev-environment, windows, postgresql]
|
||||
---
|
||||
|
||||
## 设计思想
|
||||
# 开发环境
|
||||
|
||||
开发环境在 **Windows** 宿主机直接运行所有服务:
|
||||
- PostgreSQL 通过 Windows 原生安装运行
|
||||
- Redis 7+ 通过 Windows 原生安装运行(可选,缺省时限流降级为 fail-open)
|
||||
- 后端 Rust 服务通过 `cargo run` 快速重启
|
||||
- 前端 Vite 热更新直接在宿主机
|
||||
- PowerShell 脚本 (`dev.ps1`) 提供一键启动/停止
|
||||
> 从 [[index]] 导航。关联: [[erp-server]] [[database]] [[frontend]] [[testing]]
|
||||
>
|
||||
> **本页是连接信息、启动命令、登录凭据的单一真相源。** 其他页面引用此处。
|
||||
|
||||
> Docker Compose 配置保留在 `docker/` 目录下,可供需要容器化环境的场景使用,但日常开发不依赖 Docker。
|
||||
## 1. 设计决策
|
||||
|
||||
## 本机环境实际配置
|
||||
- **Windows 原生运行** — PostgreSQL/Redis/Rust/Node 直接在宿主机,不用 Docker
|
||||
- **一键启动** — `dev.ps1` 管理前后端生命周期
|
||||
- **环境变量优先** — 敏感配置通过 `ERP__` 前缀环境变量覆盖 TOML
|
||||
|
||||
> **重要:以下为当前开发机的实际配置,以本文件为准。**
|
||||
## 2. 关键文件 + 连接信息
|
||||
|
||||
| 组件 | 安装路径 | 配置 |
|
||||
|------|---------|------|
|
||||
| PostgreSQL 18 | `D:\postgreSQL\` | 服务名 `postgresql-x64-18`, 端口 5432 |
|
||||
| Redis | 云端实例 | `redis://:redis_KBCYJk@129.204.154.246:6379`, 限流中间件已正常工作 |
|
||||
| Rust | stable (cargo in PATH) | workspace 根目录编译 |
|
||||
| Node.js + pnpm | in PATH | apps/web/ |
|
||||
### 核心文件
|
||||
|
||||
### 数据库连接
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `dev.ps1` | 一键启动/停止(自动清理端口 5174-5189) |
|
||||
| `crates/erp-server/config/default.toml` | 默认配置模板 |
|
||||
| `docker/docker-compose.yml` | 可选 Docker 配置 |
|
||||
|
||||
### 服务连接
|
||||
|
||||
| 服务 | 地址 | 用途 |
|
||||
|------|------|------|
|
||||
| PostgreSQL 18 | `postgres://postgres:123123@localhost:5432/erp` | 主数据库 |
|
||||
| Redis 7 | `redis://:redis_KBCYJk@129.204.154.246:6379` (云端) | 缓存 + 限流 |
|
||||
| 后端 API | `http://localhost:3000/api/v1` | Axum 服务 |
|
||||
| 前端 SPA | `http://localhost:5174` | Vite 开发服务器 |
|
||||
|
||||
### 登录凭据
|
||||
|
||||
```
|
||||
用户: postgres
|
||||
密码: 123123
|
||||
数据库: erp
|
||||
连接串: postgres://postgres:123123@localhost:5432/erp
|
||||
用户名: admin 密码: Admin@2026
|
||||
```
|
||||
|
||||
psql 路径: `D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp`
|
||||
psql: `D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp`
|
||||
|
||||
### 后端启动命令
|
||||
### 必须设置的环境变量
|
||||
|
||||
后端**必须**从 `crates/erp-server/` 目录启动(需要读取 `config/default.toml`),或通过环境变量覆盖:
|
||||
| 变量 | 开发值 |
|
||||
|------|--------|
|
||||
| `ERP__DATABASE__URL` | `postgres://postgres:123123@localhost:5432/erp` |
|
||||
| `ERP__JWT__SECRET` | `dev-secret-key-change-in-prod` |
|
||||
| `ERP__AUTH__SUPER_ADMIN_PASSWORD` | `Admin@2026` |
|
||||
| `ERP__REDIS__URL` | `redis://:redis_KBCYJk@129.204.154.246:6379` |
|
||||
|
||||
> 所有四个在 `default.toml` 中为 `__MUST_SET_VIA_ENV__` 占位符
|
||||
|
||||
### 集成契约
|
||||
|
||||
| 方向 | 模块 | 触发时机 |
|
||||
|------|------|---------|
|
||||
| 提供 → | [[erp-server]] | 数据库/Redis 连接 |
|
||||
| 提供 → | [[frontend]] | Vite 代理目标 |
|
||||
| 提供 → | [[testing]] | 测试环境配置 |
|
||||
|
||||
## 3. 代码逻辑
|
||||
|
||||
### 一键启动(推荐)
|
||||
|
||||
```powershell
|
||||
# 方式一:从 crates/erp-server 目录启动(使用 default.toml + 环境变量覆盖)
|
||||
.\dev.ps1 # 启动后端 + 前端
|
||||
.\dev.ps1 -Status # 查看端口状态
|
||||
.\dev.ps1 -Stop # 停止所有服务
|
||||
.\dev.ps1 -Restart # 重启所有服务
|
||||
```
|
||||
|
||||
### 手动启动
|
||||
|
||||
```powershell
|
||||
# 后端(必须从 crates/erp-server 目录)
|
||||
cd crates/erp-server
|
||||
$env:ERP__DATABASE__URL = "postgres://postgres:123123@localhost:5432/erp"
|
||||
$env:ERP__JWT__SECRET = "dev-secret-key-change-in-prod"
|
||||
$env:ERP__AUTH__SUPER_ADMIN_PASSWORD = "Admin@2026"
|
||||
cargo run -p erp-server
|
||||
|
||||
# 方式二:一键启动脚本(推荐)
|
||||
.\dev.ps1
|
||||
# 前端
|
||||
cd apps/web && pnpm install && pnpm dev
|
||||
```
|
||||
|
||||
### 登录凭据
|
||||
⚡ **不变量**: 后端必须从 `crates/erp-server/` 目录启动或通过环境变量覆盖所有配置
|
||||
⚡ **不变量**: Vite 固定端口 5174(`--strictPort`),前端代理 `/api` → 后端 3000
|
||||
|
||||
```
|
||||
用户名: admin
|
||||
密码: Admin@2026
|
||||
```
|
||||
## 4. 活跃问题 + 陷阱
|
||||
|
||||
## 服务端口
|
||||
⚠️ Redis 不可达时限流自动降级为 fail-open(放行所有请求)
|
||||
⚠️ Docker Compose 配置保留在 `docker/` 下但日常开发不依赖
|
||||
⚠️ 首次 `cargo run` 编译整个 workspace 较慢(含 wasmtime),后续增量快
|
||||
|
||||
| 服务 | 端口 | 用途 |
|
||||
|------|------|------|
|
||||
| PostgreSQL 18 | 5432 | 主数据库 |
|
||||
| Redis 7+ | 6379 (云端) | 缓存 + 限流 |
|
||||
| erp-server (Axum) | 3000 | 后端 API |
|
||||
| Vite dev server | 5174 | 前端 SPA(固定端口,--strictPort) |
|
||||
## 5. 变更记录
|
||||
|
||||
### 连接信息(配置文件版本)
|
||||
```
|
||||
PostgreSQL: postgres://postgres:123123@localhost:5432/erp
|
||||
Redis: redis://:redis_KBCYJk@129.204.154.246:6379 (云端实例)
|
||||
```
|
||||
|
||||
## 一键启动
|
||||
|
||||
```powershell
|
||||
.\dev.ps1 # 启动后端 + 前端(自动清理旧进程 5174-5189)
|
||||
.\dev.ps1 -Status # 查看端口状态
|
||||
.\dev.ps1 -Stop # 停止所有服务
|
||||
.\dev.ps1 -Restart # 重启所有服务
|
||||
```
|
||||
|
||||
> `dev.ps1` 会在启动前清理端口 5174-5189 范围内所有残留进程,并使用 `--strictPort` 确保 Vite 固定在 5174 端口。
|
||||
|
||||
### 环境变量
|
||||
|
||||
必须通过环境变量设置的值(`default.toml` 中为占位符):
|
||||
|
||||
| 变量 | 说明 | 开发值 |
|
||||
|------|------|--------|
|
||||
| `ERP__DATABASE__URL` | 数据库连接串 | `postgres://postgres:123123@localhost:5432/erp` |
|
||||
| `ERP__JWT__SECRET` | JWT 签名密钥 | 自定义字符串 |
|
||||
| `ERP__AUTH__SUPER_ADMIN_PASSWORD` | admin 初始密码 | `Admin@2026` |
|
||||
| `ERP__REDIS__URL` | Redis 连接串 | `redis://:redis_KBCYJk@129.204.154.246:6379` |
|
||||
|
||||
> 所有四个变量在 `default.toml` 中都是 `__MUST_SET_VIA_ENV__` 占位符,**必须**通过环境变量设置,否则服务拒绝启动。
|
||||
|
||||
## 关联模块
|
||||
|
||||
- **[[erp-server]]** — 连接 PostgreSQL 和 Redis
|
||||
- **[[database]]** — 迁移在 PostgreSQL 中执行
|
||||
- **[[frontend]]** — Vite 代理 API 到后端
|
||||
- **[[testing]]** — 测试环境详细指南
|
||||
|
||||
## 关键文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| `dev.ps1` | 一键启动/停止脚本(自动清理端口 5174-5189) |
|
||||
| `docker/docker-compose.yml` | 可选的 Docker Compose 配置 |
|
||||
| `crates/erp-server/config/default.toml` | 默认配置模板(密钥为占位符) |
|
||||
| `D:\postgreSQL\bin\psql.exe` | PostgreSQL 客户端 |
|
||||
|
||||
## 常用命令
|
||||
|
||||
```powershell
|
||||
# 一键启动(推荐)
|
||||
.\dev.ps1
|
||||
|
||||
# 手动启动后端(从 crates/erp-server 目录)
|
||||
cd crates/erp-server
|
||||
$env:ERP__DATABASE__URL="postgres://postgres:123123@localhost:5432/erp"
|
||||
$env:ERP__JWT__SECRET="dev-secret"
|
||||
$env:ERP__AUTH__SUPER_ADMIN_PASSWORD="Admin@2026"
|
||||
cargo run -p erp-server
|
||||
|
||||
# 手动启动前端(固定端口)
|
||||
cd apps/web && pnpm dev -- --strictPort
|
||||
|
||||
# 连接数据库
|
||||
D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp
|
||||
|
||||
# 健康检查
|
||||
curl http://localhost:3000/api/v1/health
|
||||
|
||||
# 登录测试
|
||||
curl -s http://localhost:3000/api/v1/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"Admin@2026"}'
|
||||
```
|
||||
| 2026-04-23 | 重构为 5 节结构,确立为连接信息的单一真相源 |
|
||||
|
||||
294
wiki/testing.md
294
wiki/testing.md
@@ -1,119 +1,49 @@
|
||||
# 测试环境指南
|
||||
---
|
||||
title: 测试与验证
|
||||
updated: 2026-04-23
|
||||
status: stable
|
||||
tags: [testing, verification]
|
||||
---
|
||||
|
||||
> 本项目在 **Windows** 环境下开发,使用 PowerShell 脚本一键启动。不使用 Docker,数据库直接通过原生安装运行。
|
||||
# 测试与验证
|
||||
|
||||
## 环境要求
|
||||
> 从 [[index]] 导航。关联: [[infrastructure]] [[database]] [[frontend]] [[erp-server]]
|
||||
|
||||
| 工具 | 最低版本 | 用途 |
|
||||
|------|---------|------|
|
||||
| Rust | stable (1.93+) | 后端编译 |
|
||||
| Node.js | 20+ | 前端工具链 |
|
||||
| pnpm | 9+ | 前端包管理 |
|
||||
| PostgreSQL | 16+ (当前 18) | 主数据库 |
|
||||
| Redis | 7+ (云端实例) | 缓存 + 限流 |
|
||||
## 1. 设计决策
|
||||
|
||||
## 服务连接信息(实际配置)
|
||||
- **真实数据库优先** — 集成测试用真实 PostgreSQL,不用内存模拟
|
||||
- **分层验证** — 编译检查 → 单元测试 → 功能验证 → 生产构建
|
||||
- **环境配置统一由 [[infrastructure]] 管理** — 连接信息、启动命令、登录凭据见该页
|
||||
|
||||
| 服务 | 地址 | 用途 |
|
||||
|------|------|------|
|
||||
| PostgreSQL | `postgres://postgres:123123@localhost:5432/erp` | 主数据库 |
|
||||
| Redis | `redis://:redis_KBCYJk@129.204.154.246:6379` (云端) | 缓存 + 限流 |
|
||||
| 后端 API | `http://localhost:3000/api/v1` | Axum 服务 |
|
||||
| 前端 SPA | `http://localhost:5174` | Vite 开发服务器 |
|
||||
## 2. 关键文件 + 验证清单
|
||||
|
||||
### 登录信息
|
||||
### 测试分布
|
||||
|
||||
- 用户名: `admin`
|
||||
- 密码: `Admin@2026`
|
||||
| Crate | 测试数 | 覆盖 |
|
||||
|-------|--------|------|
|
||||
| erp-auth | 8 | 密码哈希、TTL 解析 |
|
||||
| erp-core | 6 | RBAC 权限检查 |
|
||||
| erp-workflow | 16 | BPMN 解析、表达式求值 |
|
||||
| erp-plugin-prototype | 6 | WASM 插件集成 |
|
||||
| **总计** | **36** | |
|
||||
|
||||
## 一键启动(推荐)
|
||||
|
||||
使用 PowerShell 脚本管理前后端服务:
|
||||
|
||||
```powershell
|
||||
.\dev.ps1 # 启动后端 + 前端(自动清理旧进程)
|
||||
.\dev.ps1 -Status # 查看端口状态
|
||||
.\dev.ps1 -Restart # 重启所有服务
|
||||
.\dev.ps1 -Stop # 停止所有服务
|
||||
```
|
||||
|
||||
脚本会自动:
|
||||
1. 清理端口 5174-5189 范围内所有残留进程
|
||||
2. 编译并启动 Rust 后端 (`cargo run -p erp-server`)
|
||||
3. 安装前端依赖并启动 Vite 开发服务器 (`pnpm dev -- --strictPort`)
|
||||
|
||||
## 手动启动
|
||||
|
||||
### 1. 确保基础设施运行
|
||||
|
||||
```powershell
|
||||
# 检查 PostgreSQL 服务状态
|
||||
Get-Service -Name "postgresql*"
|
||||
|
||||
# 如需启动
|
||||
# PostgreSQL 通常自动启动,服务名 postgresql-x64-18
|
||||
```
|
||||
|
||||
### 2. 启动后端(必须从 crates/erp-server 目录)
|
||||
|
||||
```powershell
|
||||
cd crates/erp-server
|
||||
$env:ERP__DATABASE__URL = "postgres://postgres:123123@localhost:5432/erp"
|
||||
$env:ERP__JWT__SECRET = "dev-secret-key-change-in-prod"
|
||||
$env:ERP__AUTH__SUPER_ADMIN_PASSWORD = "Admin@2026"
|
||||
cargo run -p erp-server
|
||||
```
|
||||
|
||||
首次运行会自动执行数据库迁移。
|
||||
|
||||
### 3. 启动前端
|
||||
|
||||
```powershell
|
||||
cd apps/web
|
||||
pnpm install # 首次需要安装依赖
|
||||
pnpm dev # 启动开发服务器(端口 5174,固定)
|
||||
```
|
||||
|
||||
## 验证清单
|
||||
|
||||
### 后端验证
|
||||
### 编译 + 测试
|
||||
|
||||
```bash
|
||||
# 编译检查(无错误)
|
||||
cargo check
|
||||
|
||||
# 全量测试(应全部通过)
|
||||
cargo test --workspace
|
||||
|
||||
# Lint 检查(无警告)
|
||||
cargo clippy -- -D warnings
|
||||
|
||||
# 格式检查
|
||||
cargo fmt --check
|
||||
cargo check # 编译无错误
|
||||
cargo test --workspace # 全量测试
|
||||
cargo clippy -- -D warnings # Lint 无警告
|
||||
cargo fmt --check # 格式检查
|
||||
cd apps/web && pnpm build # 前端生产构建
|
||||
```
|
||||
|
||||
### 前端验证
|
||||
### 功能验证端点
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# TypeScript 编译 + 生产构建
|
||||
pnpm build
|
||||
|
||||
# 类型检查
|
||||
pnpm tsc -b
|
||||
```
|
||||
|
||||
### 功能验证
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `http://localhost:3000/api/v1/health` | GET | 健康检查 |
|
||||
| `http://localhost:3000/api/docs/openapi.json` | GET | OpenAPI 文档 |
|
||||
| `http://localhost:5174` | GET | 前端页面 |
|
||||
| 端点 | 说明 |
|
||||
|------|------|
|
||||
| `http://localhost:3000/api/v1/health` | 健康检查 |
|
||||
| `http://localhost:3000/api/docs/openapi.json` | OpenAPI 文档 |
|
||||
| `http://localhost:5174` | 前端页面 |
|
||||
|
||||
### API 快速测试
|
||||
|
||||
@@ -122,143 +52,57 @@ pnpm tsc -b
|
||||
curl -s http://localhost:3000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"Admin@2026"}'
|
||||
|
||||
# 列出用户(需要 Token)
|
||||
curl -s http://localhost:3000/api/v1/users \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# 列出插件
|
||||
curl -s http://localhost:3000/api/v1/admin/plugins \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
## 数据库管理
|
||||
### 前端性能基准(2026-04-18 Lighthouse)
|
||||
|
||||
### 连接数据库
|
||||
Accessibility / SEO / Best Practices 均 100,LCP 840ms,CLS 0.02
|
||||
|
||||
## 3. 代码逻辑
|
||||
|
||||
### 集成契约
|
||||
|
||||
| 方向 | 模块 | 触发时机 |
|
||||
|------|------|---------|
|
||||
| 依赖 ← | [[infrastructure]] | 环境准备、连接信息 |
|
||||
| 验证 → | [[erp-server]] | 健康检查、API 测试 |
|
||||
| 验证 → | [[frontend]] | 生产构建 |
|
||||
|
||||
⚡ **不变量**: 功能验证需要后端服务运行中,编译检查必须先于测试通过
|
||||
|
||||
### 数据库常用查询
|
||||
|
||||
```bash
|
||||
# 连接数据库
|
||||
D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp
|
||||
```
|
||||
|
||||
### 常用查询
|
||||
|
||||
```sql
|
||||
-- 列出所有表
|
||||
\dt
|
||||
|
||||
-- 查看迁移记录
|
||||
SELECT version FROM seaql_migrations ORDER BY version;
|
||||
|
||||
-- 查看插件权限
|
||||
SELECT code, name FROM permissions WHERE deleted_at IS NULL ORDER BY code;
|
||||
|
||||
-- 查看 admin 角色的权限
|
||||
SELECT p.code FROM role_permissions rp
|
||||
JOIN permissions p ON rp.permission_id = p.id
|
||||
JOIN roles r ON rp.role_id = r.id
|
||||
WHERE r.code = 'admin' AND rp.deleted_at IS NULL AND p.deleted_at IS NULL;
|
||||
SELECT version FROM seaql_migrations ORDER BY version; -- 迁移记录
|
||||
SELECT code, name FROM permissions WHERE deleted_at IS NULL ORDER BY code; -- 插件权限
|
||||
```
|
||||
|
||||
### 迁移
|
||||
## 4. 活跃问题 + 陷阱
|
||||
|
||||
迁移在 `crates/erp-server/migration/src/` 目录下。后端启动时自动执行。
|
||||
### 活跃问题
|
||||
|
||||
## 测试详情
|
||||
|
||||
### 测试分布
|
||||
|
||||
| Crate | 测试数 | 说明 |
|
||||
|-------|--------|------|
|
||||
| erp-auth | 8 | 密码哈希、TTL 解析 |
|
||||
| erp-core | 6 | RBAC 权限检查 |
|
||||
| erp-workflow | 16 | BPMN 解析、表达式求值 |
|
||||
| erp-plugin-prototype | 6 | WASM 插件集成测试 |
|
||||
| **总计** | **36** | |
|
||||
|
||||
### 运行特定测试
|
||||
|
||||
```bash
|
||||
# 运行单个 crate 的测试
|
||||
cargo test -p erp-auth
|
||||
|
||||
# 运行匹配名称的测试
|
||||
cargo test -p erp-core -- require_permission
|
||||
|
||||
# 运行插件集成测试
|
||||
cargo test -p erp-plugin-prototype
|
||||
|
||||
# 集成测试(需要 Docker/PostgreSQL)
|
||||
cargo test -p erp-server --test integration
|
||||
```
|
||||
|
||||
## 已知问题(2026-04-18 审计)
|
||||
|
||||
| 问题 | 严重度 | 状态 |
|
||||
|------|--------|------|
|
||||
| CRM 插件权限未分配给 admin 角色 → 数据页面 403 | P0 | ✅ 已修复 |
|
||||
| CRM 插件权限码与实体名不匹配(`tag.manage` vs `customer_tag`)→ 标签/关系/图谱 403 | P0 | ✅ 已修复(迁移 m20260419_000038) |
|
||||
| CRM 插件 WASM 二进制错误(存储了测试插件而非 CRM 插件) | P0 | ✅ 已修复 |
|
||||
| 首页统计卡片永久 loading | P0 | ✅ 已修复 |
|
||||
| `roles/permissions` 路由被 UUID 解析拦截 | P1 | ✅ 已修复 |
|
||||
| 统计概览 `tagColor` undefined crash(`getEntityPalette` 负数索引) | P1 | ✅ 已修复 |
|
||||
| 销售漏斗/看板 filter 请求 500(CRM customer 表缺少 generated columns) | P1 | ✅ 已修复(手动 ALTER TABLE 补齐 `_f_level`/`_f_status`/`_f_customer_type`/`_f_industry`/`_f_region` + 索引) |
|
||||
| `build_scope_sql` 参数索引硬编码 `$100` 导致 SQL 参数错位 | P1 | ✅ 已修复(动态 `values.len()+1`) |
|
||||
| `AppError::Internal` 无日志输出,500 错误静默 | P1 | ✅ 已修复(添加 `tracing::error` 日志) |
|
||||
| antd 6 废弃 API 警告(`valueStyle`/`Spin tip`/`trailColor`) | P2 | ✅ 已修复 |
|
||||
| 问题 | 级别 | 状态 |
|
||||
|------|------|------|
|
||||
| display_name 存储 XSS HTML | P1 | 待修复 |
|
||||
| 页面刷新时 4 个 401 错误(过期 token 未主动刷新) | P2 | ✅ 已修复(proactive token refresh) |
|
||||
| 插件列表重复请求(无并发去重) | P2 | ✅ 已修复(fetchPlugins promise 去重) |
|
||||
| TS 编译错误:未使用变量 | P3 | ✅ 已修复 |
|
||||
| antd vendor chunk 2.9MB(gzip 后约 400KB) | P3 | 待优化 |
|
||||
| antd `setScaleParam` 强制回流 64ms | P3 | antd 内部问题,无法直接修复 |
|
||||
| antd vendor chunk 2.9MB (gzip ~400KB) | P3 | 待优化 |
|
||||
|
||||
详见 `docs/audit-2026-04-18.md`。
|
||||
### 历史教训
|
||||
|
||||
### 前端审计摘要(2026-04-18 Lighthouse + 性能)
|
||||
- CRM 权限码与实体名不一致 → 403(详见 [[wasm-plugin]] 权限命名铁律)
|
||||
- `AppError::Internal` 无日志 → 500 静默(已加 `tracing::error`)
|
||||
- `build_scope_sql` 参数索引硬编码 → SQL 参数错位(已动态化)
|
||||
|
||||
| 指标 | 得分 |
|
||||
⚠️ 首次 `cargo run` 需编译整个 workspace(含 wasmtime),后续增量快
|
||||
⚠️ Redis 不可达时限流自动降级为 fail-open
|
||||
|
||||
## 5. 变更记录
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| Accessibility | 100 |
|
||||
| SEO | 100 |
|
||||
| Best Practices | 100 |
|
||||
| LCP | 840ms |
|
||||
| CLS | 0.02 |
|
||||
| TTFB | 4ms |
|
||||
|
||||
**已实施的优化:**
|
||||
- API client proactive token refresh(请求前 30s 检查过期,提前刷新避免 401)
|
||||
- plugin store 请求去重(promise 复用,防止并发重复调用)
|
||||
- 生产构建中 StrictMode 双重渲染导致的重复请求不会出现
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 端口被占用 / 多个 Vite 进程残留
|
||||
|
||||
```powershell
|
||||
# 使用 dev.ps1 自动清理
|
||||
.\dev.ps1 -Stop
|
||||
|
||||
# 手动清理 Vite 残留进程(端口 5174-5189)
|
||||
Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -ge 5174 -and $_.LocalPort -le 5189 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
|
||||
```
|
||||
|
||||
### Q: 数据库连接失败
|
||||
|
||||
1. 确认 PostgreSQL 服务正在运行: `Get-Service -Name "postgresql*"`
|
||||
2. 使用正确连接串: `postgres://postgres:123123@localhost:5432/erp`
|
||||
3. psql 路径: `D:\postgreSQL\bin\psql.exe`
|
||||
|
||||
### Q: 首次启动很慢
|
||||
|
||||
首次 `cargo run` 需要编译整个 workspace(特别是 wasmtime),后续增量编译会很快。
|
||||
|
||||
### Q: Redis 未安装
|
||||
|
||||
Redis 已配置为云端实例(`129.204.154.246:6379`)。限流中间件使用固定窗口计数器,登录接口限制 60 秒内 5 次请求。如 Redis 不可达,自动降级为 fail-open(放行所有请求)。
|
||||
|
||||
## 关联模块
|
||||
|
||||
- [[infrastructure]] — 基础设施配置详情
|
||||
- [[database]] — 数据库迁移和表结构
|
||||
- [[frontend]] — 前端技术栈和配置
|
||||
- [[erp-server]] — 后端服务配置
|
||||
| 2026-04-23 | 重构为 5 节结构,去除与 infrastructure.md 重复 |
|
||||
| 2026-04-18 | Lighthouse 审计 + 性能优化 |
|
||||
|
||||
@@ -1,514 +1,125 @@
|
||||
# wasm-plugin (WASM 插件系统)
|
||||
---
|
||||
title: WASM 插件系统
|
||||
updated: 2026-04-23
|
||||
status: stable
|
||||
tags: [wasm, plugin, wasmtime, wit]
|
||||
---
|
||||
|
||||
## 设计思想
|
||||
# WASM 插件系统
|
||||
|
||||
ERP 平台通过 WASM 沙箱实现**安全、隔离、热插拔**的业务扩展。插件在 Wasmtime 运行时中执行,只能通过 WIT 定义的 Host API 与系统交互,无法直接访问数据库、文件系统或网络。
|
||||
> 从 [[index]] 导航。关联: [[erp-core]] [[architecture]] [[erp-server]]
|
||||
|
||||
核心决策:
|
||||
- **WASM 沙箱** — 插件代码在隔离环境中运行,Host 控制所有资源访问
|
||||
- **WIT 接口契约** — 通过 `.wit` 文件定义 Host ↔ 插件的双向接口,bindgen 自动生成类型化绑定
|
||||
- **Fuel 资源限制** — 通过燃料机制限制插件 CPU 使用,防止无限循环
|
||||
- **声明式 Host API** — 插件通过 `db_insert` / `event_publish` 等函数操作数据,Host 自动注入 tenant_id、校验权限
|
||||
## 1. 设计决策
|
||||
|
||||
## 原型验证结果 (V1-V6)
|
||||
### 为什么选 WASM 而非 Lua / gRPC / dylib?
|
||||
|
||||
| 验证项 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| V1: WIT 接口 + bindgen! 编译 | 通过 | `bindgen!({ path, world })` 生成 Host trait + Guest 绑定 |
|
||||
| V2: Host 调用插件导出函数 | 通过 | `call_init()` / `call_handle_event()` / `call_on_tenant_created()` |
|
||||
| V3: 插件回调 Host API | 通过 | 插件中 `host_api::db_insert()` 等正确回调到 HostState |
|
||||
| V4: async 实例化桥接 | 通过 | `instantiate_async` 正常工作(调用方法本身是同步的) |
|
||||
| V5: Fuel 资源限制 | 通过 | 低 fuel 时正确 trap,不会无限循环 |
|
||||
| V6: 从二进制动态加载 | 通过 | `.component.wasm` 文件加载,测试插件 110KB |
|
||||
| 方案 | 安全性 | 隔离性 | 性能 | 复杂度 |
|
||||
|------|--------|--------|------|--------|
|
||||
| **WASM** | 高(沙箱) | 进程内隔离 | 接近原生 | 中 |
|
||||
| Lua 脚本 | 中 | 无隔离 | 快 | 低 |
|
||||
| 进程外 gRPC | 高 | 进程级隔离 | 网络开销 | 高 |
|
||||
| dylib | 低 | 无隔离 | 原生 | 低 |
|
||||
|
||||
## 项目结构
|
||||
核心权衡:WASM 在安全和性能间取得最佳平衡。Wasmtime v43 Component Model 提供类型安全的 Host-Plugin 接口,Fuel 防止无限循环。
|
||||
|
||||
### 架构拓扑
|
||||
|
||||
```
|
||||
crates/
|
||||
erp-plugin-prototype/ ← Host 端运行时
|
||||
wit/
|
||||
plugin.wit ← WIT 接口定义
|
||||
src/
|
||||
lib.rs ← Engine/Store/Linker 创建、HostState + Host trait 实现
|
||||
main.rs ← 手动测试入口(空)
|
||||
tests/
|
||||
test_plugin_integration.rs ← 6 个集成测试
|
||||
|
||||
erp-plugin-test-sample/ ← 测试插件
|
||||
src/
|
||||
lib.rs ← 实现 Guest trait,调用 Host API
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ erp-server │
|
||||
│ ┌───────────┐ ┌────────────────────────┐ │
|
||||
│ │ EventBus │ │ PluginRuntime(Wasmtime) │ │
|
||||
│ │(broadcast)│ │ ┌─────┐ ┌─────┐ │ │
|
||||
│ └─────┬─────┘ │ │CRM │ │库存 │ │ │
|
||||
│ │ │ └──┬──┘ └──┬──┘ │ │
|
||||
│ │ │ Host Bridge(自动注入 │ │
|
||||
│ │ │ tenant_id+权限检查) │ │
|
||||
│ │ └─────┼──────────────────┘ │
|
||||
│ ┌─────┴──────┐ │
|
||||
│ │ DB(SeaORM) │ │
|
||||
│ └────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## WIT 接口定义
|
||||
### 原型验证 (V1-V6)
|
||||
|
||||
文件:`crates/erp-plugin-prototype/wit/plugin.wit`
|
||||
全部通过:WIT+bindgen 编译、Host 调用插件、插件回调 Host API、async 实例化、Fuel 限制、动态加载。
|
||||
|
||||
```
|
||||
package erp:plugin;
|
||||
## 2. 关键文件 + 数据流
|
||||
|
||||
// Host 暴露给插件的 API(插件 import)
|
||||
interface host-api {
|
||||
db-insert: func(entity: string, data: list<u8>) -> result<list<u8>, string>;
|
||||
db-query: func(entity: string, filter: list<u8>, pagination: list<u8>) -> result<list<u8>, string>;
|
||||
db-update: func(entity, id: string, data: list<u8>, version: s64) -> result<list<u8>, string>;
|
||||
db-delete: func(entity: string, id: string) -> result<_, string>;
|
||||
event-publish: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
config-get: func(key: string) -> result<list<u8>, string>;
|
||||
log-write: func(level: string, message: string);
|
||||
current-user: func() -> result<list<u8>, string>;
|
||||
check-permission: func(permission: string) -> result<bool, string>;
|
||||
}
|
||||
|
||||
// 插件导出的 API(Host 调用)
|
||||
interface plugin-api {
|
||||
init: func() -> result<_, string>;
|
||||
on-tenant-created: func(tenant-id: string) -> result<_, string>;
|
||||
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
}
|
||||
|
||||
world plugin-world {
|
||||
import host-api;
|
||||
export plugin-api;
|
||||
}
|
||||
```
|
||||
|
||||
## 关键技术要点
|
||||
|
||||
### HasSelf<T> — Linker 注册模式
|
||||
|
||||
当 Store 数据类型(`HostState`)直接实现 `Host` trait 时,使用 `HasSelf<T>` 作为 `add_to_linker` 的类型参数:
|
||||
|
||||
```rust
|
||||
use wasmtime::component::{HasSelf, Linker};
|
||||
|
||||
let mut linker = Linker::new(engine);
|
||||
PluginWorld::add_to_linker::<_, HasSelf<HostState>>(&mut linker, |state| state)?;
|
||||
```
|
||||
|
||||
`HasSelf<HostState>` 表示 `Data<'a> = &'a mut HostState`,bindgen 生成的 `Host for &mut T` blanket impl 确保调用链正确。
|
||||
|
||||
### WASM Component vs Core Module
|
||||
|
||||
`wit_bindgen::generate!` 生成的是 core WASM 模块(`.wasm`),但 `Component::from_binary()` 需要 WASM Component 格式。转换步骤:
|
||||
|
||||
```bash
|
||||
# 1. 编译为 core wasm
|
||||
cargo build -p <plugin-crate> --target wasm32-unknown-unknown --release
|
||||
|
||||
# 2. 转换为 component
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/<name>.wasm \
|
||||
-o target/<name>.component.wasm
|
||||
```
|
||||
|
||||
### Fuel 资源限制
|
||||
|
||||
```rust
|
||||
let mut store = Store::new(engine, HostState::new());
|
||||
store.set_fuel(1_000_000)?; // 分配 100 万 fuel
|
||||
store.limiter(|state| &mut state.limits); // 内存限制
|
||||
```
|
||||
|
||||
Fuel 不足时,WASM 执行会 trap(`wasm trap: interrupt`),Host 可以捕获并处理。
|
||||
|
||||
### 调用方法 — 同步,非 async
|
||||
|
||||
bindgen 生成的调用方法(`call_init`、`call_handle_event`)是同步的:
|
||||
|
||||
```rust
|
||||
// 正确
|
||||
instance.erp_plugin_plugin_api().call_init(&mut store)?;
|
||||
|
||||
// 错误(不存在 async 版本的调用方法)
|
||||
instance.erp_plugin_plugin_api().call_init(&mut store).await?;
|
||||
```
|
||||
|
||||
但实例化可以异步:`PluginWorld::instantiate_async(&mut store, &component, &linker).await?`
|
||||
|
||||
## 关键文件
|
||||
### 核心文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `crates/erp-plugin-prototype/wit/plugin.wit` | WIT 接口定义(Host API + Plugin API) |
|
||||
| `crates/erp-plugin-prototype/src/lib.rs` | Host 运行时:Engine/Store 创建、HostState、Host trait 实现 |
|
||||
| `crates/erp-plugin-prototype/src/lib.rs` | Host 运行时:Engine/Store/Linker/HostState |
|
||||
| `crates/erp-plugin-prototype/tests/test_plugin_integration.rs` | V1-V6 集成测试 |
|
||||
| `crates/erp-plugin-test-sample/src/lib.rs` | 测试插件:Guest trait 实现 |
|
||||
|
||||
## 关联模块
|
||||
### WIT 接口概要
|
||||
|
||||
- **[[architecture]]** — 插件架构是模块化单体的重要扩展机制
|
||||
- **[[erp-core]]** — EventBus 事件将被桥接到插件的 `handle_event`
|
||||
- **[[erp-server]]** — 未来集成插件运行时的组装点
|
||||
Host 暴露给插件(`host-api`):`db_insert` `db_query` `db_update` `db_delete` `event_publish` `config_get` `log_write` `current_user` `check_permission`
|
||||
|
||||
---
|
||||
插件导出给 Host(`plugin-api`):`init` `on_tenant_created` `handle_event`
|
||||
|
||||
# 插件制作完整流程
|
||||
### 集成契约
|
||||
|
||||
以下是从零创建一个新业务模块插件的完整步骤。
|
||||
| 方向 | 模块 | 接口 | 触发时机 |
|
||||
|------|------|------|---------|
|
||||
| 被调用 ← | [[erp-server]] | `PluginEngine` | 服务启动时恢复插件 |
|
||||
| 调用 → | [[erp-core]] | `EventBus` | 桥接领域事件到插件 |
|
||||
| 提供 → | 所有插件 | Host API (9 函数) | 插件运行时 |
|
||||
|
||||
## 第一步:准备 WIT 接口
|
||||
## 3. 代码逻辑
|
||||
|
||||
WIT 文件定义 Host 和插件之间的契约。现有接口位于 `crates/erp-plugin-prototype/wit/plugin.wit`。
|
||||
|
||||
如果新插件需要扩展 Host API(如新增文件上传、HTTP 代理等),在 `host-api` interface 中添加函数:
|
||||
|
||||
```wit
|
||||
// 在 host-api 中新增
|
||||
file-upload: func(filename: string, data: list<u8>) -> result<string, string>;
|
||||
http-proxy: func(url: string, method: string, body: option<list<u8>>) -> result<list<u8>, string>;
|
||||
```
|
||||
|
||||
如果插件需要新的生命周期钩子,在 `plugin-api` interface 中添加:
|
||||
|
||||
```wit
|
||||
// 在 plugin-api 中新增
|
||||
on-order-approved: func(order-id: string) -> result<_, string>;
|
||||
```
|
||||
|
||||
修改 WIT 后,需要重新编译 Host crate 和所有插件。
|
||||
|
||||
## 第二步:创建插件 crate
|
||||
|
||||
在 `crates/` 下创建新的插件 crate:
|
||||
|
||||
```bash
|
||||
mkdir -p crates/erp-plugin-<业务名>
|
||||
```
|
||||
|
||||
`Cargo.toml` 模板:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-<业务名>"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "<业务描述> WASM 插件"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"] # 必须是 cdylib 才能编译为 WASM
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.55" # 生成 Guest 端绑定
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
```
|
||||
|
||||
将新 crate 加入 workspace(编辑根 `Cargo.toml`):
|
||||
|
||||
```toml
|
||||
members = [
|
||||
# ... 已有成员 ...
|
||||
"crates/erp-plugin-<业务名>",
|
||||
]
|
||||
```
|
||||
|
||||
## 第三步:实现插件逻辑
|
||||
|
||||
创建 `src/lib.rs`,实现 `Guest` trait:
|
||||
|
||||
```rust
|
||||
//! <业务名> WASM 插件
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
// 生成 Guest 端绑定(路径指向 Host crate 的 WIT 文件)
|
||||
wit_bindgen::generate!({
|
||||
path: "../erp-plugin-prototype/wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
// 导入 Host API(bindgen 生成)
|
||||
use crate::erp::plugin::host_api;
|
||||
// 导入 Guest trait(bindgen 生成)
|
||||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||
|
||||
// 插件结构体(名称任意,但必须是模块级可见的)
|
||||
struct MyPlugin;
|
||||
|
||||
impl Guest for MyPlugin {
|
||||
/// 初始化 — 注册默认数据、订阅事件等
|
||||
fn init() -> Result<(), String> {
|
||||
host_api::log_write("info", "<业务名>插件初始化");
|
||||
|
||||
// 示例:创建默认配置
|
||||
let config = json!({"default_category": "通用"}).to_string();
|
||||
host_api::db_insert("<业务>_config", config.as_bytes())
|
||||
.map_err(|e| format!("初始化失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 租户创建时 — 初始化租户的默认数据
|
||||
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
|
||||
host_api::log_write("info", &format!("新租户: {}", tenant_id));
|
||||
|
||||
let data = json!({"tenant_id": tenant_id, "name": "默认仓库"}).to_string();
|
||||
host_api::db_insert("warehouse", data.as_bytes())
|
||||
.map_err(|e| format!("创建默认仓库失败: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理订阅的事件
|
||||
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
|
||||
host_api::log_write("debug", &format!("收到事件: {}", event_type));
|
||||
|
||||
let data: serde_json::Value = serde_json::from_slice(&payload)
|
||||
.map_err(|e| format!("解析事件失败: {}", e))?;
|
||||
|
||||
match event_type.as_str() {
|
||||
"order.created" => {
|
||||
// 处理订单创建事件
|
||||
let order_id = data["id"].as_str().unwrap_or("");
|
||||
host_api::log_write("info", &format!("新订单: {}", order_id));
|
||||
}
|
||||
"workflow.task.completed" => {
|
||||
// 处理审批完成事件
|
||||
let order_id = data["order_id"].as_str().unwrap_or("unknown");
|
||||
let update = json!({"status": "approved"}).to_string();
|
||||
host_api::db_update("purchase_order", order_id, update.as_bytes(), 1)
|
||||
.map_err(|e| format!("更新失败: {}", e))?;
|
||||
|
||||
// 发布下游事件
|
||||
let evt = json!({"order_id": order_id}).to_string();
|
||||
host_api::event_publish("<业务>.order.approved", evt.as_bytes())
|
||||
.map_err(|e| format!("发布事件失败: {}", e))?;
|
||||
}
|
||||
_ => {
|
||||
host_api::log_write("debug", &format!("忽略事件: {}", event_type));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// 导出插件实例(宏会注册 Guest trait 实现)
|
||||
export!(MyPlugin);
|
||||
```
|
||||
|
||||
### Host API 速查
|
||||
|
||||
| 函数 | 签名 | 用途 |
|
||||
|------|------|------|
|
||||
| `db_insert` | `(entity, data) → result<record, string>` | 插入记录,Host 自动注入 id/tenant_id/timestamp |
|
||||
| `db_query` | `(entity, filter, pagination) → result<list, string>` | 查询记录,自动过滤 tenant_id + 排除软删除 |
|
||||
| `db_update` | `(entity, id, data, version) → result<record, string>` | 更新记录,检查乐观锁 version |
|
||||
| `db_delete` | `(entity, id) → result<_, string>` | 软删除记录 |
|
||||
| `event_publish` | `(event_type, payload) → result<_, string>` | 发布领域事件 |
|
||||
| `config_get` | `(key) → result<value, string>` | 读取系统配置 |
|
||||
| `log_write` | `(level, message) → ()` | 写日志,自动关联 tenant_id + plugin_id |
|
||||
| `current_user` | `() → result<user_info, string>` | 获取当前用户信息 |
|
||||
| `check_permission` | `(permission) → result<bool, string>` | 检查当前用户权限 |
|
||||
|
||||
### 数据传递约定
|
||||
|
||||
所有 Host API 的数据参数使用 `list<u8>`(即 `Vec<u8>`),约定用 JSON 序列化:
|
||||
|
||||
```rust
|
||||
// 构造数据
|
||||
let data = json!({"sku": "ITEM-001", "quantity": 100}).to_string();
|
||||
|
||||
// 插入
|
||||
let result_bytes = host_api::db_insert("inventory_item", data.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 解析返回
|
||||
let record: serde_json::Value = serde_json::from_slice(&result_bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let new_id = record["id"].as_str().unwrap();
|
||||
```
|
||||
|
||||
## 第四步:编译为 WASM
|
||||
|
||||
```bash
|
||||
# 编译为 core WASM 模块
|
||||
cargo build -p erp-plugin-<业务名> --target wasm32-unknown-unknown --release
|
||||
|
||||
# 转换为 WASM Component(必须,Host 只接受 Component 格式)
|
||||
wasm-tools component new \
|
||||
target/wasm32-unknown-unknown/release/erp_plugin_<业务名>.wasm \
|
||||
-o target/erp_plugin_<业务名>.component.wasm
|
||||
```
|
||||
|
||||
检查产物大小(目标 < 2MB):
|
||||
|
||||
```bash
|
||||
ls -la target/erp_plugin_<业务名>.component.wasm
|
||||
```
|
||||
|
||||
## 第五步:编写集成测试
|
||||
|
||||
在 `crates/erp-plugin-prototype/tests/` 下创建测试文件,或扩展现有测试:
|
||||
|
||||
```rust
|
||||
use anyhow::Result;
|
||||
use erp_plugin_prototype::{create_engine, load_plugin};
|
||||
|
||||
fn wasm_path() -> String {
|
||||
"../../target/erp_plugin_<业务名>.component.wasm".into()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_<业务名>_init() -> Result<()> {
|
||||
let wasm_bytes = std::fs::read(wasm_path())?;
|
||||
let engine = create_engine()?;
|
||||
let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
|
||||
|
||||
// 调用 init
|
||||
instance.erp_plugin_plugin_api().call_init(&mut store)?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
// 验证 Host 端效果
|
||||
let state = store.data();
|
||||
assert!(state.db_ops.iter().any(|op| op.entity == "<业务>_config"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_<业务名>_handle_event() -> Result<()> {
|
||||
let wasm_bytes = std::fs::read(wasm_path())?;
|
||||
let engine = create_engine()?;
|
||||
let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?;
|
||||
|
||||
// 先初始化
|
||||
instance.erp_plugin_plugin_api().call_init(&mut store)?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
// 模拟事件
|
||||
let payload = json!({"id": "ORD-001"}).to_string();
|
||||
instance.erp_plugin_plugin_api()
|
||||
.call_handle_event(&mut store, "order.created", payload.as_bytes())?
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## 第六步:运行测试
|
||||
|
||||
```bash
|
||||
# 先确保编译了 component
|
||||
cargo build -p erp-plugin-<业务名> --target wasm32-unknown-unknown --release
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_<业务名>.wasm \
|
||||
-o target/erp_plugin_<业务名>.component.wasm
|
||||
|
||||
# 运行集成测试
|
||||
cargo test -p erp-plugin-prototype
|
||||
```
|
||||
|
||||
## 流程速查图
|
||||
### 插件生命周期
|
||||
|
||||
```
|
||||
1. 修改 WIT(如需新接口) crates/erp-plugin-prototype/wit/plugin.wit
|
||||
↓
|
||||
2. 创建插件 crate crates/erp-plugin-<名>/
|
||||
- Cargo.toml (cdylib + wit-bindgen)
|
||||
- src/lib.rs (impl Guest)
|
||||
↓
|
||||
3. 编译 core wasm cargo build --target wasm32-unknown-unknown --release
|
||||
↓
|
||||
4. 转为 component wasm-tools component new <in.wasm> -o <out.component.wasm>
|
||||
↓
|
||||
5. 编写测试 crates/erp-plugin-prototype/tests/
|
||||
↓
|
||||
6. 运行测试 cargo test -p erp-plugin-prototype
|
||||
安装(manifest+WASM) → 注册权限 → 实例化(Wasmtime) → init()
|
||||
→ 日常: db_query/db_insert 通过 data_handler 自动注入 tenant_id + 权限校验
|
||||
→ 事件: EventBus → handle_event()
|
||||
→ 升级: upload 新 WASM → 增量 DDL → 卸载旧实例 → 加载新实例
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
### 权限系统
|
||||
|
||||
### Q: "attempted to parse a wasm module with a component parser"
|
||||
**A:** 使用了 core WASM 而非 Component。运行 `wasm-tools component new` 转换。
|
||||
|
||||
### Q: "cannot infer type of the type parameter D"
|
||||
**A:** `add_to_linker` 需要显式指定 `HasSelf<T>`:`add_to_linker::<_, HasSelf<HostState>>(linker, |s| s)`。
|
||||
|
||||
### Q: "wasm trap: interrupt"(非 fuel 耗尽)
|
||||
**A:** 检查是否启用了 epoch_interruption 但未定期 bump epoch。原型阶段建议只使用 fuel 限制。
|
||||
|
||||
### Q: 插件中如何调试?
|
||||
**A:** 使用 `host_api::log_write("debug", "message")` 输出日志,Host 端 `store.data().logs` 可查看所有日志。
|
||||
|
||||
### Q: 如何限制插件内存?
|
||||
**A:** 通过 `StoreLimitsBuilder` 配置:
|
||||
```rust
|
||||
let limits = StoreLimitsBuilder::new()
|
||||
.memory_size(10 * 1024 * 1024) // 10MB
|
||||
.build();
|
||||
```
|
||||
|
||||
## 后续规划
|
||||
|
||||
- **Phase 7**: 将原型集成到 erp-server,替换模拟 Host API 为真实数据库操作
|
||||
- **动态表**: 支持 `db_insert("dynamic_table", ...)` 自动创建/迁移表
|
||||
- **前端集成**: PluginCRUDPage 组件根据 WIT 定义自动生成 CRUD 页面
|
||||
- **插件市场**: 插件元数据、版本管理、签名验证
|
||||
|
||||
## 插件权限系统(关键)
|
||||
|
||||
### 权限码格式
|
||||
|
||||
插件数据操作的权限码由 `data_handler.rs` 中的 `compute_permission_code()` 按以下规则自动生成:
|
||||
权限码由 `data_handler.rs` 的 `compute_permission_code()` 自动生成:
|
||||
|
||||
```
|
||||
{manifest_id}.{url_entity_name}.{action_suffix}
|
||||
例: erp-crm.customer.list / erp-crm.customer.manage
|
||||
```
|
||||
|
||||
- `manifest_id`:plugin.toml 中 `[metadata].id`(如 `erp-crm`)
|
||||
- `url_entity_name`:REST API 路径中的实体名(如 `customer_tag`)
|
||||
- `action_suffix`:`list`(读操作)或 `manage`(写操作)
|
||||
⚡ **权限命名铁律**: `plugin.toml` 中 `permissions[].code` 前缀必须与 `schema.entities[].name` 完全一致,每个实体必须声明 `.list` + `.manage`
|
||||
|
||||
| 操作 | 权限码示例 |
|
||||
|------|-----------|
|
||||
| 列表/详情 | `erp-crm.customer.list` |
|
||||
| 创建/更新/删除 | `erp-crm.customer.manage` |
|
||||
⚡ **Host API 数据约定**: 所有数据参数用 `list<u8>` + JSON 序列化,Host 自动注入 id/tenant_id/timestamp
|
||||
|
||||
### 权限码命名铁律(P0 级)
|
||||
⚡ **同步调用**: bindgen 生成的 `call_init`/`call_handle_event` 是同步的,只有实例化可以 async
|
||||
|
||||
**`plugin.toml` 中 `permissions[].code` 的前缀必须与 `schema.entities[].name` 完全一致。**
|
||||
### 不变量
|
||||
|
||||
```
|
||||
data_handler 生成:{manifest_id}.{url_entity_name}.{action}
|
||||
↑ 来自 URL 路径中的 entity 参数
|
||||
manifest 声明: {entity_name}.{action}
|
||||
↑ 必须与 URL 中的 entity name 匹配
|
||||
```
|
||||
⚡ Fuel 默认 100 万,耗尽时 WASM trap(`wasm trap: interrupt`)
|
||||
⚡ `HasSelf<HostState>` 是 Linker 注册的必要类型参数(`Data<'a> = &'a mut HostState`)
|
||||
⚡ Core WASM 必须通过 `wasm-tools component new` 转为 Component 格式才能被 Host 加载
|
||||
|
||||
每个实体必须同时声明 `.list` 和 `.manage` 两个权限:
|
||||
## 4. 活跃问题 + 陷阱
|
||||
|
||||
```toml
|
||||
# ✅ 正确:权限码前缀与实体名一致
|
||||
[[schema.entities]]
|
||||
name = "customer_tag"
|
||||
### 历史教训
|
||||
|
||||
[[permissions]]
|
||||
code = "customer_tag.list" # 匹配!
|
||||
- CRM 权限码 `tag.manage` vs 实体 `customer_tag` → 三个页面 403(迁移 m000038 修复)
|
||||
- CRM WASM 二进制错误存储了测试插件而非 CRM 插件(重新编译修复)
|
||||
- 权限未自动分配给 admin 角色 → 403(添加 `grant_permissions_to_admin()`)
|
||||
|
||||
[[permissions]]
|
||||
code = "customer_tag.manage" # 匹配!
|
||||
### 注意事项
|
||||
|
||||
# ❌ 错误:权限码用了简写,与实体名不一致 → 403
|
||||
[[permissions]]
|
||||
code = "tag.manage" # data_handler 生成 erp-crm.customer_tag.manage
|
||||
# 但 DB 中只有 erp-crm.tag.manage → 403
|
||||
```
|
||||
⚠️ 插件 API 路由用 `Path<(Uuid, String)>` 解析 plugin_id,必须用数据库 UUID 而非 manifest_id
|
||||
⚠️ 修改 WIT 后需重编译 Host crate 和所有插件
|
||||
|
||||
**历史教训:** CRM 插件首个版本中,`customer_tag` 实体的权限码写成了 `tag.manage`,`customer_relationship` 实体的权限码写成了 `relationship.list/manage`。结果标签管理、客户关系、关系图谱三个页面全部 403。修复迁移:`m20260419_000038_fix_crm_permission_codes.rs`。
|
||||
## 5. 变更记录
|
||||
|
||||
### 权限注册流程
|
||||
|
||||
1. **插件安装时** → `register_plugin_permissions()` 将 manifest 中声明的权限批量 INSERT 到 `permissions` 表(`ON CONFLICT DO NOTHING` 保证幂等)
|
||||
2. **权限分配** → `grant_permissions_to_admin()` 自动将权限分配给 admin 角色
|
||||
3. **运行时校验** → `data_handler.rs` 的 `compute_permission_code()` 按 URL entity name 生成权限码,通过 `require_permission()` 检查 JWT 中的权限列表
|
||||
|
||||
### 已修复问题
|
||||
|
||||
| 问题 | 修复 |
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 权限未自动分配给 admin 角色 → 403 | `grant_permissions_to_admin()` 在 install/enable 时自动调用 |
|
||||
| 权限码与实体名不匹配 → 403 | 迁移 m20260419_000038 + plugin.toml 修正 |
|
||||
### 插件 API 路由注意事项
|
||||
| 2026-04-23 | 重构为 5 节结构,插件制作流程移至 `.claude/skills/plugin-development/` |
|
||||
| 2026-04-19 | CRM 权限码修复 (m000038) |
|
||||
| 2026-04-18 | 插件权限系统审计 |
|
||||
|
||||
- 后端路由使用 `Path<(Uuid, String)>` 解析 `plugin_id`,必须是 UUID 格式
|
||||
- 前端使用 `plugin.id`(数据库 UUID)而非 `manifest_id`(如 `erp-crm`)构建请求 URL
|
||||
- 直接用 manifest_id 调用 API 会返回 `UUID parsing failed` 错误
|
||||
> **插件制作完整流程**: 详见 `.claude/skills/plugin-development/SKILL.md`(WIT 接口 → 创建 crate → 编译 WASM → 集成测试)
|
||||
|
||||
Reference in New Issue
Block a user