feat(health): 添加 erp-health 健康管理模块骨架
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

新建 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:
iven
2026-04-23 19:59:22 +08:00
parent 5ac8e18d74
commit ca50d32f6e
61 changed files with 6853 additions and 1208 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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" }

View 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

View 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>,
}

View 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>,
}

View 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)>,
}

View File

@@ -0,0 +1,4 @@
pub mod appointment_dto;
pub mod consultation_dto;
pub mod health_data_dto;
pub mod patient_dto;

View 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>,
}

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View 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;

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View 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>;

View 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
}

View 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()))
}

View 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()))
}

View 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()))
}

View 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()))
}

View 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()))
}

View 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;

View 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()))
}

View 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;

View 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
}
}

View 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!()
}

View 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!()
}

View 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!()
}

View 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!()
}

View 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;

View 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!()
}

View 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(())
}

View 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,
}

View File

@@ -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

View File

@@ -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

View File

@@ -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(),

View File

@@ -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(),
}
}
}

View File

@@ -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_dateassigned_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 端点测试
- 多租户隔离验证
- 端到端功能验证

View File

@@ -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, itopsWASM
```
**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 ModelFuel 限制 |
## 插件扩展架构
### 集成契约
### 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 引用,精简技术选型表 |

View File

@@ -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) |

View File

@@ -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 当前为内存 broadcastoutbox 持久化通过后台任务实现
## 5. 变更记录
| 日期 | 变更 |
|------|------|
| 2026-04-23 | 重构为 5 节结构,更新为已完全集成状态 |

122
wiki/erp-health.md Normal file
View 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 页,设计规格确认 |

View File

@@ -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 节结构,更新为当前集成状态 |

View File

@@ -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 节结构,更新为当前完整前端状态 |

View File

@@ -1,95 +1,71 @@
# ERP 平台底座 — 知识库
# HMS 健康管理平台 — 知识库
## 项目画像
> **Health Management System (HMS)** — 面向体检中心/医疗机构的综合健康管理平台。从 ERP 底座分叉,继承身份权限/工作流/消息/配置等基础能力,`erp-health` 原生模块承载医疗业务。
**模块化 SaaS ERP 底座**Rust + React 技术栈,提供身份权限/工作流/消息/配置/插件五大基础模块,支持行业业务模块快速插接。
## 关键数字
关键数字:
- 13 个 Rust crate9 个已实现 + 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_treeJWT 中间件注入 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` |

View File

@@ -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 节结构,确立为连接信息的单一真相源 |

View File

@@ -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 均 100LCP 840msCLS 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 请求 500CRM 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.9MBgzip 后约 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 审计 + 性能优化 |

View File

@@ -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>;
}
// 插件导出的 APIHost 调用)
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 APIbindgen 生成)
use crate::erp::plugin::host_api;
// 导入 Guest traitbindgen 生成)
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 → 集成测试)