docs: 全局文档梳理归档 — 删除过期文件 + 归档 V1/早期设计 + wiki 数据校正 + CLAUDE.md 规则优化
**根目录清理:** - 删除 CLAUDE-1.md(ZCLAW 旧项目配置,HMS 已完全脱离) - 移动 DESIGN.md → docs/archive/(ERP 旧设计系统) - 删除 plans/ 98 个临时会话计划文件 **归档重组:** - V1 审计(12 文件)→ docs/archive/audits-v1/ - 早期 CRM/插件迭代设计(13 文件)→ docs/archive/superpowers-early/ - 已完成/已取代设计(28 文件)→ docs/archive/superpowers-completed/ - 早期讨论/测试报告 → docs/archive/discussions-early/ + test-reports-early/ - QA 重复文件清理(3 个旧版 result 文件) **wiki 数据校正:** - 迁移数 137→145,源文件 599→649,提交数 720→800+ - 小程序文件 124→163,Web 前端 297→332 - 后端测试 999→943(实际统计),权限码 75+→128 - 文档索引新增归档目录说明 **CLAUDE.md 规则优化:** - §2.5 闭环工作法:提交+文档+推送三合一 + wiki 更新触发条件 - §2.6 Feature DoD:新增文档一致性检查项 - §6 反模式:新增 wiki 更新滞后/推送不及时警告
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,702 +0,0 @@
|
||||
# WASM 插件系统实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 ERP 平台引入 WASM 运行时插件系统,使行业模块可动态安装/启用/停用。
|
||||
|
||||
**Architecture:** 基础模块(auth/config/workflow/message)保持 Rust 编译时,新增 `erp-plugin-runtime` crate 封装 Wasmtime 运行时。插件通过宿主代理 API 访问数据库和事件总线,前端使用配置驱动 UI 渲染引擎自动生成 CRUD 页面。
|
||||
|
||||
**Tech Stack:** Rust + Wasmtime 27+ / WIT (wit-bindgen 0.24+) / SeaORM / Axum 0.8 / React 19 + Ant Design 6 + Zustand 5
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### 新建文件
|
||||
|
||||
```
|
||||
crates/erp-plugin-runtime/
|
||||
├── Cargo.toml
|
||||
├── wit/
|
||||
│ └── plugin.wit # WIT 接口定义
|
||||
└── src/
|
||||
├── lib.rs # crate 入口
|
||||
├── manifest.rs # plugin.toml 解析
|
||||
├── engine.rs # Wasmtime 引擎封装
|
||||
├── host_api.rs # 宿主 API(db/event/config/log)
|
||||
├── loader.rs # 插件加载器
|
||||
├── schema.rs # 动态建表逻辑
|
||||
├── error.rs # 插件错误类型
|
||||
└── wasm_module.rs # ErpModule trait 的 WASM 适配器
|
||||
|
||||
crates/erp-server/migration/src/
|
||||
└── m20260413_000032_create_plugins_table.rs # plugins + plugin_schema_versions 表
|
||||
|
||||
crates/erp-server/src/
|
||||
└── handlers/
|
||||
└── plugin.rs # 插件管理 + 数据 CRUD handler
|
||||
|
||||
apps/web/src/
|
||||
├── api/
|
||||
│ └── plugins.ts # 插件 API service
|
||||
├── stores/
|
||||
│ └── plugin.ts # PluginStore (Zustand)
|
||||
├── pages/
|
||||
│ ├── PluginAdmin.tsx # 插件管理页面
|
||||
│ └── PluginCRUDPage.tsx # 通用 CRUD 渲染引擎
|
||||
└── components/
|
||||
└── DynamicMenu.tsx # 动态菜单组件
|
||||
```
|
||||
|
||||
### 修改文件
|
||||
|
||||
```
|
||||
Cargo.toml # 添加 erp-plugin-runtime workspace member
|
||||
crates/erp-core/src/module.rs # 升级 ErpModule trait v2
|
||||
crates/erp-core/src/events.rs # 添加 subscribe_filtered
|
||||
crates/erp-core/src/lib.rs # 导出新类型
|
||||
crates/erp-auth/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-config/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-workflow/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-message/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-server/src/main.rs # 使用新注册系统 + 加载 WASM 插件
|
||||
crates/erp-server/src/state.rs # 添加 PluginState
|
||||
crates/erp-server/migration/src/lib.rs # 注册新迁移
|
||||
apps/web/src/App.tsx # 添加动态路由 + PluginAdmin 路由
|
||||
apps/web/src/layouts/MainLayout.tsx # 使用 DynamicMenu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: ErpModule Trait v2 迁移 + EventBus 扩展
|
||||
|
||||
### Task 1: 升级 ErpModule trait
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-core/src/module.rs`
|
||||
- Modify: `crates/erp-core/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 升级 ErpModule trait — 添加新方法(全部有默认实现)**
|
||||
|
||||
在 `crates/erp-core/src/module.rs` 中,保留所有现有方法签名不变,追加新方法:
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
|
||||
// 新增类型
|
||||
pub enum ModuleType {
|
||||
Native,
|
||||
Wasm,
|
||||
}
|
||||
|
||||
pub struct ModuleHealth {
|
||||
pub status: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ModuleContext {
|
||||
pub db: sea_orm::DatabaseConnection,
|
||||
pub event_bus: crate::events::EventBus,
|
||||
pub config: Arc<serde_json::Value>,
|
||||
}
|
||||
|
||||
// 在 ErpModule trait 中追加(不改现有方法):
|
||||
fn id(&self) -> &str { self.name() } // 默认等于 name
|
||||
fn module_type(&self) -> ModuleType { ModuleType::Native }
|
||||
async fn on_startup(&self, _ctx: &ModuleContext) -> crate::error::AppResult<()> { Ok(()) }
|
||||
async fn on_shutdown(&self) -> crate::error::AppResult<()> { Ok(()) }
|
||||
async fn health_check(&self) -> crate::error::AppResult<ModuleHealth> {
|
||||
Ok(ModuleHealth { status: "ok".into(), details: None })
|
||||
}
|
||||
fn public_routes(&self) -> Option<axum::Router> { None } // 需要 axum 依赖
|
||||
fn protected_routes(&self) -> Option<axum::Router> { None }
|
||||
fn migrations(&self) -> Vec<Box<dyn sea_orm_migration::MigrationTrait>> { vec![] }
|
||||
fn config_schema(&self) -> Option<serde_json::Value> { None }
|
||||
```
|
||||
|
||||
> **注意:** `on_tenant_created/deleted` 的签名暂不改动(加 ctx 参数是破坏性变更),在 Task 2 中单独处理。
|
||||
|
||||
- [ ] **Step 2: 升级 ModuleRegistry — 添加索引 + 拓扑排序 + build_routes**
|
||||
|
||||
在同一个文件中扩展 `ModuleRegistry`:
|
||||
|
||||
```rust
|
||||
impl ModuleRegistry {
|
||||
pub fn get_module(&self, id: &str) -> Option<&Arc<dyn ErpModule>> { ... }
|
||||
pub fn build_routes(&self) -> (axum::Router, axum::Router) {
|
||||
// 遍历 modules,收集 public_routes + protected_routes
|
||||
}
|
||||
fn topological_sort(&self) -> crate::error::AppResult<Vec<Arc<dyn ErpModule>>> {
|
||||
// 基于 dependencies() 的 Kahn 算法拓扑排序
|
||||
}
|
||||
pub async fn startup_all(&self, ctx: &ModuleContext) -> crate::error::AppResult<()> {
|
||||
// 按拓扑顺序调用 on_startup
|
||||
}
|
||||
pub async fn health_check_all(&self) -> HashMap<String, ModuleHealth> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 更新 lib.rs 导出**
|
||||
|
||||
`crates/erp-core/src/lib.rs` 追加:
|
||||
```rust
|
||||
pub use module::{ModuleType, ModuleHealth, ModuleContext};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 更新 erp-core Cargo.toml 添加 axum 依赖**
|
||||
|
||||
`crates/erp-core/Cargo.toml` 的 `[dependencies]` 添加:
|
||||
```toml
|
||||
axum = { workspace = true }
|
||||
sea-orm-migration = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行 `cargo check --workspace` 确保现有模块编译通过(所有新方法有默认实现)**
|
||||
|
||||
- [ ] **Step 6: 迁移四个现有模块的 routes**
|
||||
|
||||
对 `erp-auth/src/module.rs`、`erp-config/src/module.rs`、`erp-workflow/src/module.rs`、`erp-message/src/module.rs`:
|
||||
- 将 `pub fn public_routes<S>()` 关联函数改为 `fn public_routes(&self) -> Option<Router>` trait 方法
|
||||
- 同样处理 `protected_routes`
|
||||
- 添加 `fn id()` 返回与 `name()` 相同值
|
||||
|
||||
每个模块的改动模式:
|
||||
```rust
|
||||
// 之前: pub fn public_routes<S>() -> Router<S> where ... { Router::new().route(...) }
|
||||
// 之后:
|
||||
fn public_routes(&self) -> Option<axum::Router> {
|
||||
Some(axum::Router::new().route("/auth/login", axum::routing::post(auth_handler::login)).route("/auth/refresh", axum::routing::post(auth_handler::refresh)))
|
||||
}
|
||||
fn protected_routes(&self) -> Option<axum::Router> { Some(...) }
|
||||
fn id(&self) -> &str { "auth" } // 与 name() 相同
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 更新 main.rs 使用 build_routes**
|
||||
|
||||
`crates/erp-server/src/main.rs`:
|
||||
```rust
|
||||
// 替换手动 merge 为:
|
||||
let (public_mod, protected_mod) = registry.build_routes();
|
||||
let public_routes = Router::new()
|
||||
.merge(handlers::health::health_check_router())
|
||||
.merge(public_mod) // 替代 erp_auth::AuthModule::public_routes()
|
||||
.route("/docs/openapi.json", ...)
|
||||
...;
|
||||
let protected_routes = protected_mod // 替代手动 merge 四个模块
|
||||
.merge(handlers::audit_log::audit_log_router())
|
||||
...;
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 运行 `cargo check --workspace` 确认全 workspace 编译通过**
|
||||
|
||||
- [ ] **Step 9: 运行 `cargo test --workspace` 确认测试通过**
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```
|
||||
feat(core): upgrade ErpModule trait v2 with lifecycle hooks, route methods, and auto-collection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: EventBus subscribe_filtered 扩展
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-core/src/events.rs`
|
||||
|
||||
- [ ] **Step 1: 添加类型化订阅支持**
|
||||
|
||||
在 `events.rs` 中扩展 `EventBus`:
|
||||
|
||||
```rust
|
||||
use std::sync::RwLock;
|
||||
|
||||
pub type EventHandler = Box<dyn Fn(DomainEvent) + Send + Sync>;
|
||||
pub type SubscriptionId = Uuid;
|
||||
|
||||
pub struct EventBus {
|
||||
sender: broadcast::Sender<DomainEvent>,
|
||||
handlers: Arc<RwLock<HashMap<String, Vec<(SubscriptionId, EventHandler)>>>>,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
pub fn subscribe_filtered(
|
||||
&self,
|
||||
event_type: &str,
|
||||
handler: EventHandler,
|
||||
) -> SubscriptionId {
|
||||
let id = Uuid::now_v7();
|
||||
let mut handlers = self.handlers.write().unwrap();
|
||||
handlers.entry(event_type.to_string())
|
||||
.or_default()
|
||||
.push((id, handler));
|
||||
id
|
||||
}
|
||||
|
||||
pub fn unsubscribe(&self, id: SubscriptionId) {
|
||||
let mut handlers = self.handlers.write().unwrap();
|
||||
for (_, list) in handlers.iter_mut() {
|
||||
list.retain(|(sid, _)| *sid != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
修改 `broadcast()` 方法,在广播时同时分发给 `handlers` 中匹配的处理器。
|
||||
|
||||
- [ ] **Step 2: 运行 `cargo test --workspace`**
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(core): add typed event subscription to EventBus
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 数据库迁移 + erp-plugin-runtime Crate
|
||||
|
||||
### Task 3: 插件数据库表迁移
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/migration/src/m20260413_000032_create_plugins_table.rs`
|
||||
- Modify: `crates/erp-server/migration/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 编写迁移文件**
|
||||
|
||||
创建 `plugins`、`plugin_schema_versions`、`plugin_event_subscriptions` 三张表(DDL 参见 spec §7.1)。
|
||||
|
||||
- [ ] **Step 2: 注册到 lib.rs 的迁移列表**
|
||||
|
||||
- [ ] **Step 3: 运行 `cargo run -p erp-server` 验证迁移执行**
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat(db): add plugins, plugin_schema_versions, and plugin_event_subscriptions tables
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 创建 erp-plugin-runtime crate 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-runtime/src/lib.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/error.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/manifest.rs`
|
||||
- Modify: `Cargo.toml` (workspace members)
|
||||
|
||||
- [ ] **Step 1: 创建 Cargo.toml**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core = { workspace = true }
|
||||
wasmtime = "27"
|
||||
wasmtime-wasi = "27"
|
||||
wit-bindgen = "0.24"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = "0.8"
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新根 Cargo.toml workspace members + dependencies**
|
||||
|
||||
- [ ] **Step 3: 实现 manifest.rs — PluginManifest 类型 + 解析**
|
||||
|
||||
定义 `PluginManifest`、`PluginInfo`、`PermissionSet`、`EntityDef`、`FieldDef`、`PageDef` 等结构体,实现 `fn parse(toml_str: &str) -> Result<PluginManifest>`。
|
||||
|
||||
- [ ] **Step 4: 实现 error.rs**
|
||||
|
||||
```rust
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginError {
|
||||
#[error("Manifest 解析失败: {0}")]
|
||||
ManifestParse(String),
|
||||
#[error("WASM 加载失败: {0}")]
|
||||
WasmLoad(String),
|
||||
#[error("Host API 错误: {0}")]
|
||||
HostApi(String),
|
||||
#[error("插件未找到: {0}")]
|
||||
NotFound(String),
|
||||
#[error("依赖未满足: {0}")]
|
||||
DependencyUnmet(String),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 实现 lib.rs — crate 入口 + re-exports**
|
||||
|
||||
- [ ] **Step 6: 运行 `cargo check --workspace`**
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): create erp-plugin-runtime crate with manifest parsing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: WIT 接口定义
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/wit/plugin.wit`
|
||||
|
||||
- [ ] **Step 1: 编写 WIT 文件**
|
||||
|
||||
参见 spec 附录 D.1 的完整 `plugin.wit` 内容(host interface + plugin interface + plugin-world)。
|
||||
|
||||
- [ ] **Step 2: 验证 WIT 语法**
|
||||
|
||||
```bash
|
||||
cargo install wit-bindgen-cli
|
||||
wit-bindgen rust ./crates/erp-plugin-runtime/wit/plugin.wit --out-dir /tmp/test-bindgen
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): define WIT interface for host-plugin contract
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Host API + 插件加载器
|
||||
|
||||
### Task 6: 实现 Host API 层
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/src/host_api.rs`
|
||||
|
||||
- [ ] **Step 1: 实现 PluginHostState 结构体**
|
||||
|
||||
持有 `db`、`tenant_id`、`plugin_id`、`event_bus` 等上下文。实现 `db_insert`、`db_query`、`db_update`、`db_delete`、`db_aggregate` 方法,每个方法都:
|
||||
1. 自动注入 `tenant_id` 过滤
|
||||
2. 自动注入标准字段(id, created_at 等)
|
||||
3. 参数化 SQL 防注入
|
||||
4. 自动审计日志
|
||||
|
||||
- [ ] **Step 2: 注册为 Wasmtime host functions**
|
||||
|
||||
使用 `wasmtime::Linker::func_wrap` 将 host_api 方法注册到 WASM 实例。
|
||||
|
||||
- [ ] **Step 3: 编写单元测试**
|
||||
|
||||
使用 mock 数据库测试 db_insert 自动注入 tenant_id、db_query 自动过滤。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): implement host API layer with tenant isolation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 实现插件加载器 + 动态建表
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/src/engine.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/loader.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/schema.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/wasm_module.rs`
|
||||
|
||||
- [ ] **Step 1: engine.rs — Wasmtime Engine 封装**
|
||||
|
||||
单例 Engine + Store 工厂方法,配置内存限制(64MB 默认)、fuel 消耗限制。
|
||||
|
||||
- [ ] **Step 2: schema.rs — 从 manifest 动态建表**
|
||||
|
||||
`create_entity_table(db, entity_def)` 函数:生成 `CREATE TABLE IF NOT EXISTS plugin_{name} (...)` SQL,包含所有标准字段 + tenant_id 索引。
|
||||
|
||||
- [ ] **Step 3: loader.rs — 从数据库加载 + 实例化**
|
||||
|
||||
`load_plugins(db, engine, event_bus) -> Vec<LoadedPlugin>`:查询 `plugins` 表中 status=enabled 的记录,实例化 WASM,调用 init(),注册事件处理器。
|
||||
|
||||
- [ ] **Step 4: wasm_module.rs — WasmModule(实现 ErpModule trait)**
|
||||
|
||||
包装 WASM 实例,实现 ErpModule trait 的各方法(调用 WASM 导出函数)。
|
||||
|
||||
- [ ] **Step 5: 集成测试**
|
||||
|
||||
测试完整的 load → init → db_insert → db_query 流程(使用真实 PostgreSQL)。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): implement plugin loader with dynamic schema creation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 插件管理 API + 数据 CRUD API
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/src/handlers/plugin.rs`
|
||||
- Modify: `crates/erp-server/src/main.rs`
|
||||
- Modify: `crates/erp-server/src/state.rs`
|
||||
|
||||
- [ ] **Step 1: 实现 plugin handler**
|
||||
|
||||
上传(解析 plugin.toml + 存储 wasm_binary)、列表、详情、启用(建表+写状态)、停用、卸载(软删除)。
|
||||
|
||||
- [ ] **Step 2: 实现插件数据 CRUD**
|
||||
|
||||
`GET/POST/PUT/DELETE /api/v1/plugins/{plugin_id}/{entity}` — 动态路由,从 manifest 查找 entity,调用 host_api 执行操作。
|
||||
|
||||
- [ ] **Step 3: 注册路由到 main.rs**
|
||||
|
||||
- [ ] **Step 4: 添加 PluginState 到 state.rs**
|
||||
|
||||
```rust
|
||||
impl FromRef<AppState> for erp_plugin_runtime::PluginState { ... }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行 `cargo test --workspace`**
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
feat(server): add plugin management and dynamic CRUD API endpoints
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: 前端配置驱动 UI
|
||||
|
||||
### Task 9: PluginStore + API Service
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/api/plugins.ts`
|
||||
- Create: `apps/web/src/stores/plugin.ts`
|
||||
|
||||
- [ ] **Step 1: plugins.ts API service**
|
||||
|
||||
接口类型定义 + API 函数:`listPlugins`、`getPlugin`、`uploadPlugin`、`enablePlugin`、`disablePlugin`、`uninstallPlugin`、`getPluginConfig`、`updatePluginConfig`、`getPluginData`、`createPluginData`、`updatePluginData`、`deletePluginData`。
|
||||
|
||||
- [ ] **Step 2: plugin.ts PluginStore**
|
||||
|
||||
```typescript
|
||||
interface PluginStore {
|
||||
plugins: PluginInfo[];
|
||||
loading: boolean;
|
||||
fetchPlugins(): Promise<void>;
|
||||
getPageConfigs(): PluginPageConfig[];
|
||||
}
|
||||
```
|
||||
|
||||
启动时调用 `fetchPlugins()` 加载已启用插件列表及页面配置。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(web): add plugin API service and PluginStore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: PluginCRUDPage 通用渲染引擎
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/PluginCRUDPage.tsx`
|
||||
|
||||
- [ ] **Step 1: 实现 PluginCRUDPage 组件**
|
||||
|
||||
接收 `PluginPageConfig` 作为 props,渲染:
|
||||
- **SearchBar**: 从 `filters` 配置生成 Ant Design Form.Item 搜索条件
|
||||
- **DataTable**: 从 `columns` 配置生成 Ant Design Table 列
|
||||
- **FormDialog**: 从 `form` 配置或自动推导的 `schema.entities` 字段生成新建/编辑 Modal 表单
|
||||
- **ActionBar**: 从 `actions` 配置生成操作按钮
|
||||
|
||||
API 调用统一走 `/api/v1/plugins/{plugin_id}/{entity}` 路径。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
feat(web): implement PluginCRUDPage config-driven rendering engine
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 动态路由 + 动态菜单
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/components/DynamicMenu.tsx`
|
||||
- Modify: `apps/web/src/App.tsx`
|
||||
- Modify: `apps/web/src/layouts/MainLayout.tsx`
|
||||
|
||||
- [ ] **Step 1: DynamicMenu 组件**
|
||||
|
||||
从 `usePluginStore` 读取 `getPageConfigs()`,按 `menu_group` 分组生成 Ant Design Menu.Item,追加到侧边栏。
|
||||
|
||||
- [ ] **Step 2: App.tsx 添加动态路由**
|
||||
|
||||
在 private routes 中,遍历 PluginStore 的 pageConfigs,为每个 CRUD 页面生成:
|
||||
```tsx
|
||||
<Route path={page.path} element={<PluginCRUDPage config={page} />} />
|
||||
```
|
||||
同时添加 `/plugin-admin` 路由指向 `PluginAdmin` 页面。
|
||||
|
||||
- [ ] **Step 3: MainLayout.tsx 集成 DynamicMenu**
|
||||
|
||||
替换硬编码的 `bizMenuItems`,追加插件动态菜单。
|
||||
|
||||
- [ ] **Step 4: 运行 `pnpm dev` 验证前端编译通过**
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat(web): add dynamic routing and menu generation from plugin configs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: 插件管理页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/PluginAdmin.tsx`
|
||||
|
||||
- [ ] **Step 1: 实现 PluginAdmin 页面**
|
||||
|
||||
包含:插件列表(Table)、上传按钮(Upload)、启用/停用/卸载操作、配置编辑 Modal。使用 Ant Design 组件。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
feat(web): add plugin admin page with upload/enable/disable/configure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5: 第一个行业插件(进销存)
|
||||
|
||||
### Task 13: 创建 erp-plugin-inventory
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/plugins/inventory/Cargo.toml`
|
||||
- Create: `crates/plugins/inventory/plugin.toml`
|
||||
- Create: `crates/plugins/inventory/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 创建插件项目**
|
||||
|
||||
`Cargo.toml` crate-type = ["cdylib"],依赖 wit-bindgen + serde + serde_json。
|
||||
|
||||
- [ ] **Step 2: 编写 plugin.toml**
|
||||
|
||||
完整清单(spec §4 的进销存示例):inventory_item、purchase_order 两个 entity,3 个 CRUD 页面 + 1 个 custom 页面。
|
||||
|
||||
- [ ] **Step 3: 实现 lib.rs**
|
||||
|
||||
使用 wit-bindgen 生成的绑定,实现 `init()`、`on_tenant_created()`、`handle_event()`。
|
||||
|
||||
- [ ] **Step 4: 编译为 WASM**
|
||||
|
||||
```bash
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo build -p erp-plugin-inventory --target wasm32-unknown-unknown --release
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat(inventory): create erp-plugin-inventory as first industry plugin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: 端到端集成测试
|
||||
|
||||
- [ ] **Step 1: 启动后端服务**
|
||||
|
||||
```bash
|
||||
cd docker && docker compose up -d
|
||||
cd crates/erp-server && cargo run
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 通过 API 上传进销存插件**
|
||||
|
||||
```bash
|
||||
# 打包
|
||||
cp target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm /tmp/
|
||||
# 上传
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/upload \
|
||||
-F "wasm=@/tmp/erp_plugin_inventory.wasm" \
|
||||
-F "manifest=@crates/plugins/inventory/plugin.toml"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 启用插件 + 验证建表**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/erp-inventory/enable
|
||||
docker exec erp-postgres psql -U erp -c "\dt plugin_*"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 测试 CRUD API**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/plugins/erp-inventory/inventory_item \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"sku":"ITEM001","name":"测试商品","quantity":100}'
|
||||
curl http://localhost:3000/api/v1/plugins/erp-inventory/inventory_item \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 前端验证**
|
||||
|
||||
启动 `pnpm dev`,验证:
|
||||
- 侧边栏出现"进销存"菜单组 + 子菜单
|
||||
- 点击"商品管理"显示 PluginCRUDPage
|
||||
- 可以新建/编辑/删除/搜索商品
|
||||
|
||||
- [ ] **Step 6: 测试停用 + 卸载**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/erp-inventory/disable
|
||||
curl -X DELETE http://localhost:3000/api/v1/admin/plugins/erp-inventory
|
||||
# 验证数据表仍在
|
||||
docker exec erp-postgres psql -U erp -c "\dt plugin_*"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```
|
||||
test(inventory): end-to-end integration test for plugin lifecycle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
```
|
||||
Chunk 1 (Tasks 1-2) ← 先做,所有后续依赖 trait v2 和 EventBus 扩展
|
||||
↓
|
||||
Chunk 2 (Tasks 3-5) ← 数据库表 + crate 骨架 + WIT
|
||||
↓
|
||||
Chunk 3 (Tasks 6-8) ← 核心运行时 + API(后端完成)
|
||||
↓
|
||||
Chunk 4 (Tasks 9-12) ← 前端(可与 Chunk 5 并行)
|
||||
↓
|
||||
Chunk 5 (Tasks 13-14) ← 第一个插件 + E2E 验证
|
||||
```
|
||||
|
||||
## 关键风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| Wasmtime 版本与 WIT 不兼容 | 锁定 wasmtime = "27",CI 验证 |
|
||||
| axum Router 在 erp-core 中引入重依赖 | 考虑将 trait routes 方法改为返回路由描述结构体,在 erp-server 层构建 Router |
|
||||
| 动态建表安全性 | 仅允许白名单列类型,禁止 DDL 注入 |
|
||||
| 前端 PluginCRUDPage 覆盖不足 | 先支持 text/number/date/select/currency,custom 页面后续迭代 |
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,871 +0,0 @@
|
||||
# Q2 安全地基 + CI/CD 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 消除所有 CRITICAL/HIGH 安全风险,建立 CI/CD 自动化质量门,完成审计日志补全和 Docker 生产化。
|
||||
|
||||
**Architecture:** 密钥外部化通过环境变量强制注入 + 启动检查拒绝默认值;CI/CD 使用 Gitea Actions 四 job 并行;限流改为 fail-closed;审计日志补全 IP/UA 和变更值。
|
||||
|
||||
**Tech Stack:** Rust (Axum, SeaORM), Gitea Actions, Docker Compose, PostgreSQL 16, Redis 7
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-17-platform-maturity-roadmap-design.md` §2
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Modify | `crates/erp-server/config/default.toml` | 敏感值改为占位符 |
|
||||
| Modify | `crates/erp-server/src/main.rs` | 启动时拒绝默认密钥 |
|
||||
| Modify | `crates/erp-auth/src/module.rs:149-150` | 移除密码 fallback |
|
||||
| Modify | `crates/erp-auth/src/error.rs:46-53` | 移除 `From<AppError> for AuthError` 反向映射 |
|
||||
| Modify | `crates/erp-auth/src/service/auth_service.rs:177-181` | refresh 添加 tenant_id 过滤 |
|
||||
| Modify | `crates/erp-auth/src/service/user_service.rs` | get_by_id/update/delete 改为 DB 级 tenant 过滤 |
|
||||
| Modify | `crates/erp-server/src/middleware/rate_limit.rs:122-124,135-137` | fail-closed |
|
||||
| Modify | `crates/erp-core/src/audit.rs` | `with_request_info` 类型扩展 |
|
||||
| Modify | `crates/erp-auth/src/service/auth_service.rs` | login/logout/change_password 添加审计 |
|
||||
| Modify | `crates/erp-plugin/src/data_service.rs` | CRUD 操作添加审计 |
|
||||
| Modify | `docker/docker-compose.yml` | 端口不暴露、Redis 密码、资源限制 |
|
||||
| Modify | `.gitignore` | 添加 `.test_token` |
|
||||
| Create | `.gitea/workflows/ci.yml` | CI/CD 流水线 |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: 密钥外部化与启动强制检查
|
||||
|
||||
### Task 1: 清理 `.test_token` 和 `.gitignore`
|
||||
|
||||
**Files:**
|
||||
- Modify: `.gitignore`
|
||||
- Delete: `.test_token`(仅本地文件)
|
||||
|
||||
- [ ] **Step 1: 验证 `.test_token` 是否曾提交到 git 历史**
|
||||
|
||||
```bash
|
||||
git log --all --oneline -- .test_token
|
||||
```
|
||||
|
||||
Expected: 无输出(从未提交)。如果有输出,需额外执行 BFG 清理。
|
||||
|
||||
- [ ] **Step 2: 添加 `.test_token` 到 `.gitignore`**
|
||||
|
||||
在 `.gitignore` 末尾添加:
|
||||
|
||||
```
|
||||
# Test artifacts
|
||||
.test_token
|
||||
*.heapsnapshot
|
||||
perf-trace-*.json
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitignore
|
||||
git commit -m "chore: 添加 .test_token 和测试产物到 .gitignore"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `default.toml` 敏感值改为占位符
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-server/config/default.toml`
|
||||
|
||||
- [ ] **Step 1: 替换敏感值**
|
||||
|
||||
将 `crates/erp-server/config/default.toml` 中的:
|
||||
|
||||
```toml
|
||||
url = "postgres://erp:erp_dev_2024@localhost:5432/erp"
|
||||
```
|
||||
改为:
|
||||
```toml
|
||||
url = "__MUST_SET_VIA_ENV__"
|
||||
```
|
||||
|
||||
将:
|
||||
```toml
|
||||
secret = "change-me-in-production"
|
||||
```
|
||||
改为:
|
||||
```toml
|
||||
secret = "__MUST_SET_VIA_ENV__"
|
||||
```
|
||||
|
||||
将:
|
||||
```toml
|
||||
super_admin_password = "Admin@2026"
|
||||
```
|
||||
改为:
|
||||
```toml
|
||||
super_admin_password = "__MUST_SET_VIA_ENV__"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 `.env.development` 供本地开发使用**
|
||||
|
||||
在项目根目录创建 `.env.development`(已被 `.gitignore` 中 `*.env.local` 覆盖,但需显式添加 `.env.development`):
|
||||
|
||||
```
|
||||
# .env.development — 本地开发用,不提交到仓库
|
||||
# 注意:此文件需要手动 source 或通过 dotenv 工具加载,config crate 不会自动读取
|
||||
ERP__DATABASE__URL=postgres://erp:erp_dev_2024@localhost:5432/erp
|
||||
ERP__JWT__SECRET=dev-local-secret-change-me
|
||||
ERP__SUPER_ADMIN_PASSWORD=Admin@2026
|
||||
```
|
||||
|
||||
更新 `.gitignore`,添加 `.env.development`。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/config/default.toml .gitignore
|
||||
git commit -m "fix(security): default.toml 敏感值改为占位符,强制通过环境变量注入"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 启动检查 — 拒绝默认密钥
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-server/src/main.rs`(在服务启动前添加检查)
|
||||
|
||||
- [ ] **Step 1: 在 `main.rs` 的配置加载后、服务启动前添加安全检查**
|
||||
|
||||
在配置加载完成后(`let config = ...` 之后),添加:
|
||||
|
||||
```rust
|
||||
// ── 安全检查:拒绝默认密钥 ──────────────────────────
|
||||
if config.jwt.secret == "__MUST_SET_VIA_ENV__" || config.jwt.secret == "change-me-in-production" {
|
||||
tracing::error!(
|
||||
"JWT 密钥为默认值,拒绝启动。请设置环境变量 ERP__JWT__SECRET"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
if config.database.url == "__MUST_SET_VIA_ENV__" {
|
||||
tracing::error!(
|
||||
"数据库 URL 为默认占位值,拒绝启动。请设置环境变量 ERP__DATABASE__URL"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证默认配置启动被拒绝**
|
||||
|
||||
```bash
|
||||
ERP__JWT__SECRET="__MUST_SET_VIA_ENV__" cargo run -p erp-server
|
||||
```
|
||||
|
||||
Expected: 进程退出,输出包含 "JWT 密钥为默认值,拒绝启动"
|
||||
|
||||
- [ ] **Step 3: 验证环境变量设置后正常启动**
|
||||
|
||||
```bash
|
||||
ERP__JWT__SECRET="my-real-secret" ERP__DATABASE__URL="postgres://erp:erp_dev_2024@localhost:5432/erp" ERP__AUTH__SUPER_ADMIN_PASSWORD="TestPass123" cargo run -p erp-server
|
||||
```
|
||||
|
||||
Expected: 服务正常启动(或因数据库未运行而失败,但不应因安全检查退出)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/src/main.rs
|
||||
git commit -m "fix(security): 启动时拒绝默认 JWT 密钥和数据库 URL"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 移除密码 fallback 硬编码
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/module.rs:149-150`
|
||||
|
||||
- [ ] **Step 1: 将 `unwrap_or_else` 改为显式错误处理**
|
||||
|
||||
将:
|
||||
```rust
|
||||
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD")
|
||||
.unwrap_or_else(|_| "Admin@2026".to_string());
|
||||
```
|
||||
|
||||
改为:
|
||||
```rust
|
||||
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD")
|
||||
.map_err(|_| {
|
||||
tracing::error!("环境变量 ERP__SUPER_ADMIN_PASSWORD 未设置,无法初始化租户认证");
|
||||
erp_core::error::AppError::Internal(
|
||||
"ERP__SUPER_ADMIN_PASSWORD 未设置".to_string(),
|
||||
)
|
||||
})?;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译通过**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth
|
||||
```
|
||||
|
||||
Expected: 编译成功
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/module.rs
|
||||
git commit -m "fix(security): 移除 super_admin_password 硬编码 fallback"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 移除 `From<AppError> for AuthError` 反向映射
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/error.rs:46-53`
|
||||
|
||||
- [ ] **Step 1: 删除反向映射 impl**
|
||||
|
||||
删除 `crates/erp-auth/src/error.rs` 中的整个 impl 块:
|
||||
|
||||
```rust
|
||||
// 删除以下代码
|
||||
impl From<AppError> for AuthError {
|
||||
fn from(err: AppError) -> Self {
|
||||
match err {
|
||||
AppError::VersionMismatch => AuthError::VersionMismatch,
|
||||
other => AuthError::Validation(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修复所有依赖此反向映射的调用点**
|
||||
|
||||
反向映射主要用于 `on_tenant_created` / `on_tenant_deleted` 中。检查这两个函数 — 它们已经返回 `AppResult<()>`(不是 `AuthResult`),所以不会直接受影响。
|
||||
|
||||
真正受影响的是 `auth_service.rs` 中可能从其他 crate 传入 `AppError` 并隐式转为 `AuthError` 的路径。逐一检查:
|
||||
- `auth_service.rs` — 所有 `.map_err()` 调用是否仍能编译
|
||||
- `user_service.rs` — 同上
|
||||
- 如果有编译错误,在调用点使用显式 `.map_err(|e| AuthError::Validation(e.to_string()))` 而非依赖隐式转换
|
||||
|
||||
- [ ] **Step 3: 删除反向映射的测试**
|
||||
|
||||
删除 `crates/erp-auth/src/error.rs` 测试中的 `app_error_version_mismatch_roundtrip` 和 `app_error_other_maps_to_auth_validation` 测试。
|
||||
|
||||
- [ ] **Step 4: 验证编译和测试**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth && cargo test -p erp-auth
|
||||
```
|
||||
|
||||
Expected: 编译成功,所有测试通过
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/
|
||||
git commit -m "refactor(auth): 移除 From<AppError> for AuthError 反向映射"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 多租户安全加固 + 限流 fail-closed
|
||||
|
||||
### Task 6: `auth_service::refresh()` 添加 tenant_id 过滤
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/service/auth_service.rs:177-181`
|
||||
|
||||
- [ ] **Step 1: 修改 refresh 中的用户查询**
|
||||
|
||||
将:
|
||||
```rust
|
||||
let user_model = user::Entity::find_by_id(claims.sub)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or(AuthError::TokenRevoked)?;
|
||||
```
|
||||
|
||||
改为:
|
||||
```rust
|
||||
let user_model = user::Entity::find_by_id(claims.sub)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or(AuthError::TokenRevoked)?;
|
||||
|
||||
// 验证用户属于 JWT 中声明的租户
|
||||
// 注意:JWT claims 中租户 ID 字段名为 `tid`(与 TokenService 签发时一致)
|
||||
if user_model.tenant_id != claims.tid {
|
||||
tracing::warn!(
|
||||
user_id = %claims.sub,
|
||||
jwt_tenant = %claims.tid,
|
||||
actual_tenant = %user_model.tenant_id,
|
||||
"Token tenant_id 与用户实际租户不匹配"
|
||||
);
|
||||
return Err(AuthError::TokenRevoked);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/service/auth_service.rs
|
||||
git commit -m "fix(auth): refresh token 流程添加 tenant_id 校验"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: `user_service` 改为 DB 级 tenant_id 过滤
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/service/user_service.rs`(`get_by_id`、`update`、`delete`、`assign_roles` 四个函数)
|
||||
|
||||
- [ ] **Step 1: 修改 `get_by_id`(约第 129-134 行)**
|
||||
|
||||
将 `find_by_id` + 内存 `.filter()` 模式改为数据库级查询:
|
||||
|
||||
```rust
|
||||
pub async fn get_by_id(id: Uuid, tenant_id: Uuid, db: &DatabaseConnection) -> AuthResult<user::Model> {
|
||||
user::Entity::find()
|
||||
.filter(user::Column::Id.eq(id))
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or(AuthError::Validation("用户不存在".to_string()))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 同样修改 `update`、`delete` 和 `assign_roles` 函数**
|
||||
|
||||
将这三个函数中的 `find_by_id` + 内存 `.filter()` 改为相同的 DB 级过滤模式。注意:`login`、`list` 函数已正确使用数据库级过滤,无需修改。
|
||||
|
||||
- [ ] **Step 3: 验证编译和测试**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth && cargo test -p erp-auth
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/service/user_service.rs
|
||||
git commit -m "fix(auth): get_by_id/update/delete 改为数据库级 tenant_id 过滤"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7.5: 登录租户解析
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/handler/auth_handler.rs`(登录 handler 提取租户信息)
|
||||
- Modify: `crates/erp-auth/src/service/auth_service.rs`(login 函数签名调整)
|
||||
|
||||
- [ ] **Step 1: 在 `auth_handler.rs` 的 `login` handler 中提取租户 ID**
|
||||
|
||||
从请求头 `X-Tenant-ID` 提取租户 ID,若无此头则使用默认租户(向后兼容):
|
||||
|
||||
```rust
|
||||
let tenant_id = headers
|
||||
.get("X-Tenant-ID")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| Uuid::parse_str(v).ok())
|
||||
.unwrap_or(state.default_tenant_id);
|
||||
```
|
||||
|
||||
将 `tenant_id` 传入 `AuthService::login`。
|
||||
|
||||
- [ ] **Step 2: 更新 `AuthService::login` 签名**
|
||||
|
||||
如果当前签名不含 `tenant_id` 参数,添加 `tenant_id: Uuid` 参数,替换函数内部对 `state.default_tenant_id` 的使用。
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/handler/auth_handler.rs crates/erp-auth/src/service/auth_service.rs
|
||||
git commit -m "feat(auth): 登录接口支持 X-Tenant-ID 请求头租户解析"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 限流 fail-closed
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-server/src/middleware/rate_limit.rs:122-137`
|
||||
|
||||
- [ ] **Step 1: 将 Redis 不可达时的放行改为拒绝**
|
||||
|
||||
在 `apply_rate_limit` 函数中,将三处 `return next.run(req).await;` 改为返回 429:
|
||||
|
||||
```rust
|
||||
// 第一处:Redis 不可达快速检查(约第 122-124 行)
|
||||
if !avail.should_try().await {
|
||||
tracing::warn!("Redis 不可达,启用 fail-closed 限流保护");
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "服务暂时不可用,请稍后重试".to_string(),
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
|
||||
// 第二处:连接失败(约第 135-137 行)
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Redis 连接失败,fail-closed 限流保护");
|
||||
avail.mark_failed().await;
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "服务暂时不可用,请稍后重试".to_string(),
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
|
||||
// 第三处:INCR 失败(约第 143-145 行)
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Redis INCR 失败,fail-closed 限流保护");
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "服务暂时不可用,请稍后重试".to_string(),
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
```
|
||||
|
||||
注意:`RateLimitResponse` 已在模块级别定义(第 17-20 行),无需移动。使用 `(StatusCode, Json)` 元组模式与现有代码一致。
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-server
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/src/middleware/rate_limit.rs
|
||||
git commit -m "fix(server): 限流改为 fail-closed — Redis 不可达时拒绝请求"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 审计日志补全
|
||||
|
||||
### Task 9: 登录/登出/密码修改添加审计日志
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/service/auth_service.rs`
|
||||
- Reference: `crates/erp-core/src/audit.rs`
|
||||
|
||||
- [ ] **Step 1: 在 `login` 函数成功路径添加审计**
|
||||
|
||||
在登录成功后(签发 token 之后)添加:
|
||||
|
||||
```rust
|
||||
// 审计日志:登录成功
|
||||
// AuditLog::new 签名:(tenant_id: Uuid, user_id: Option<Uuid>, action: &str, resource_type: &str)
|
||||
audit_service::record(
|
||||
audit::AuditLog::new(user_model.tenant_id, Some(user_model.id), "user.login", "user"),
|
||||
db,
|
||||
).await;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 `login` 函数失败路径添加审计**
|
||||
|
||||
失败审计需区分两种情况:
|
||||
|
||||
a) **用户不存在**(`find_by_username` 返回 None)— 此时无 `user_model`,使用 `Uuid::nil()` 作为 user_id:
|
||||
```rust
|
||||
// 在 Ok(None) => return Err(AuthError::InvalidCredentials) 之前添加
|
||||
audit_service::record(
|
||||
audit::AuditLog::new(tenant_id, None, "user.login_failed", "user")
|
||||
.with_resource_id("username", &req.username),
|
||||
db,
|
||||
).await;
|
||||
```
|
||||
|
||||
b) **密码错误** — 此时已有 `user_model`:
|
||||
```rust
|
||||
// 在密码验证失败返回 InvalidCredentials 之前添加
|
||||
audit_service::record(
|
||||
audit::AuditLog::new(tenant_id, Some(user_model.id), "user.login_failed", "user"),
|
||||
db,
|
||||
).await;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 `logout` 函数添加审计**
|
||||
|
||||
- [ ] **Step 4: 在 `change_password` 函数添加审计**
|
||||
|
||||
- [ ] **Step 5: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/service/auth_service.rs
|
||||
git commit -m "feat(auth): 登录/登出/密码修改添加审计日志"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 审计日志添加 IP 和 User-Agent
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-core/src/audit.rs`(确保 `with_request_info` 接受 IP + UA)
|
||||
- Modify: `crates/erp-auth/src/handler/auth_handler.rs`(从请求提取信息传入 service)
|
||||
|
||||
- [ ] **Step 1: 确认 `audit.rs` 中 `with_request_info` 的签名**
|
||||
|
||||
确认 `AuditLogBuilder::with_request_info(ip: String, user_agent: String)` 存在且类型正确。如果不存在则添加。
|
||||
|
||||
- [ ] **Step 2: 在 auth handler 中提取 IP 和 UA 并传给 service**
|
||||
|
||||
**必须修改**以下函数签名(不仅仅是 `login`):
|
||||
|
||||
- `AuthService::login` — 添加 `client_info: Option<ClientInfo>` 参数
|
||||
- `AuthService::logout` — 同上
|
||||
- `AuthService::change_password` — 同上
|
||||
|
||||
在 `auth_handler.rs` 中创建辅助函数提取请求信息:
|
||||
|
||||
```rust
|
||||
struct ClientInfo {
|
||||
ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
}
|
||||
|
||||
fn extract_client_info(req: &Request) -> ClientInfo {
|
||||
let ip = req.headers()
|
||||
.get("X-Forwarded-For")
|
||||
.or_else(|| req.headers().get("X-Real-IP"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string());
|
||||
let user_agent = req.headers()
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
ClientInfo { ip, user_agent }
|
||||
}
|
||||
```
|
||||
|
||||
在每个 auth handler 函数中调用 `extract_client_info` 并传给 service。
|
||||
|
||||
- [ ] **Step 3: 在审计日志记录时调用 `.with_request_info(ip, user_agent)`**
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth && cargo check -p erp-core
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/ crates/erp-core/src/audit.rs
|
||||
git commit -m "feat(audit): 审计日志添加 IP 地址和 User-Agent"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 关键实体 update 添加变更前后值
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/service/user_service.rs`(`update` 函数)
|
||||
- Modify: `crates/erp-auth/src/service/role_service.rs`(`update` 函数)
|
||||
|
||||
- [ ] **Step 1: 在 `user_service::update` 中,先查询旧值再更新**
|
||||
|
||||
在 update 函数中,获取旧模型后、执行更新前,记录:
|
||||
|
||||
```rust
|
||||
let old_json = serde_json::to_value(&old_user)
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
// ... 执行更新 ...
|
||||
let new_json = serde_json::to_value(&updated_user)
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
// AuditLog::new 签名:(tenant_id, user_id, action, resource_type)
|
||||
// with_changes 签名:(Option<Value>, Option<Value>)
|
||||
audit_service::record(
|
||||
audit::AuditLog::new(tenant_id, Some(operator_id), "user.update", "user")
|
||||
.with_resource_id("user_id", &old_user.id.to_string())
|
||||
.with_changes(Some(old_json), Some(new_json)),
|
||||
db,
|
||||
).await;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 同样修改 `role_service::update`**
|
||||
|
||||
- [ ] **Step 3: 确认 `with_changes` 方法签名**
|
||||
|
||||
实际签名为 `with_changes(mut self, old: Option<Value>, new: Option<Value>) -> Self`,已在 `audit.rs` 第 51-59 行定义。调用时用 `Some()` 包装值。
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/ crates/erp-core/src/audit.rs
|
||||
git commit -m "feat(audit): 用户/角色更新记录变更前后值"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: 插件 CRUD 添加审计日志
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-plugin/src/data_service.rs`
|
||||
|
||||
- [ ] **Step 1: 添加审计日志 import 和调用**
|
||||
|
||||
首先在 `data_service.rs` 顶部添加 import:
|
||||
```rust
|
||||
use erp_core::{audit, audit_service};
|
||||
```
|
||||
|
||||
然后在 `create_record`、`update_record`(含 `partial_update`)、`delete_record` 中添加审计日志。审计调用需要 `tenant_id` 和 `operator_id`:
|
||||
- `tenant_id` 从函数参数获取
|
||||
- `operator_id` 从函数参数获取(若函数缺少此参数则需补充)
|
||||
|
||||
示例:
|
||||
```rust
|
||||
// create_record 审计
|
||||
audit_service::record(
|
||||
audit::AuditLog::new(tenant_id, Some(operator_id), "plugin.data.create", entity_name),
|
||||
db,
|
||||
).await;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin/src/data_service.rs
|
||||
git commit -m "feat(plugin): 数据 CRUD 操作添加审计日志"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: CI/CD + Docker 生产化
|
||||
|
||||
### Task 13: 创建 Gitea Actions CI/CD 流水线
|
||||
|
||||
**Files:**
|
||||
- Create: `.gitea/workflows/ci.yml`
|
||||
|
||||
- [ ] **Step 1: 创建工作流目录**
|
||||
|
||||
```bash
|
||||
mkdir -p .gitea/workflows
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 CI 流水线文件**
|
||||
|
||||
创建 `.gitea/workflows/ci.yml`:
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
rust-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
- run: cargo fmt --check --all
|
||||
- run: cargo clippy -- -D warnings
|
||||
|
||||
rust-test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: erp_test
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
- run: cargo test --workspace
|
||||
env:
|
||||
ERP__DATABASE__URL: postgres://test:test@localhost:5432/erp_test
|
||||
ERP__JWT__SECRET: ci-test-secret
|
||||
ERP__AUTH__SUPER_ADMIN_PASSWORD: CI_Test_Pass_2026
|
||||
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: cd apps/web && corepack enable && pnpm install --frozen-lockfile
|
||||
- run: cd apps/web && pnpm build
|
||||
|
||||
security-audit:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo install cargo-audit && cargo audit
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: cd apps/web && corepack enable && pnpm install --frozen-lockfile && pnpm audit
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitea/
|
||||
git commit -m "ci: 添加 Gitea Actions CI/CD 流水线"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Docker 生产化
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker/docker-compose.yml`
|
||||
|
||||
- [ ] **Step 1: 移除端口暴露,添加 Redis 密码和资源限制**
|
||||
|
||||
将 PostgreSQL 的 `ports: "5432:5432"` 改为 `expose: ["5432"]`(仅容器网络内部可访问)。
|
||||
将 Redis 的 `ports: "6379:6379"` 改为 `expose: ["6379"]`,并添加命令 `--requirepass ${REDIS_PASSWORD:-erp_redis_dev}`。
|
||||
为两个服务添加资源限制:
|
||||
|
||||
```yaml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.0"
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建开发用 `docker-compose.override.yml`**
|
||||
|
||||
由于生产配置移除了端口暴露,本地开发需要 override 文件恢复端口访问:
|
||||
|
||||
```yaml
|
||||
# docker/docker-compose.override.yml — 本地开发用,不提交到仓库
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
redis:
|
||||
ports:
|
||||
- "6379:6379"
|
||||
```
|
||||
|
||||
将 `docker-compose.override.yml` 添加到 `.gitignore`。Docker Compose 会自动合并 `docker-compose.yml` 和 `docker-compose.override.yml`。
|
||||
|
||||
- [ ] **Step 3: 更新 `.env.example`**
|
||||
|
||||
添加 `REDIS_PASSWORD` 变量说明。
|
||||
|
||||
- [ ] **Step 3: 更新 `default.toml` 的 Redis URL 格式**
|
||||
|
||||
如果 Redis 需要密码,URL 格式改为 `redis://:password@localhost:6379`。
|
||||
|
||||
- [ ] **Step 4: 验证 Docker Compose 配置有效**
|
||||
|
||||
```bash
|
||||
cd docker && docker compose config
|
||||
```
|
||||
|
||||
Expected: 无语法错误
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docker/
|
||||
git commit -m "fix(docker): 生产化配置 — 端口不暴露、Redis 密码、资源限制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
完成所有 Task 后,执行以下验证:
|
||||
|
||||
- [ ] **V1: 默认配置拒绝启动**
|
||||
```bash
|
||||
cargo run -p erp-server
|
||||
```
|
||||
Expected: 进程退出,日志包含 "JWT 密钥为默认值,拒绝启动"
|
||||
|
||||
- [ ] **V2: 环境变量设置后正常启动**
|
||||
```bash
|
||||
ERP__JWT__SECRET="test-secret" ERP__DATABASE__URL="postgres://erp:erp_dev_2024@localhost:5432/erp" ERP__AUTH__SUPER_ADMIN_PASSWORD="TestPass123" cargo run -p erp-server
|
||||
```
|
||||
Expected: 服务正常启动
|
||||
|
||||
- [ ] **V3: 全量编译和测试**
|
||||
```bash
|
||||
cargo check && cargo test --workspace
|
||||
```
|
||||
Expected: 全部通过
|
||||
|
||||
- [ ] **V4: 前端构建**
|
||||
```bash
|
||||
cd apps/web && pnpm build
|
||||
```
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **V5: Docker Compose 正常启动**
|
||||
```bash
|
||||
cd docker && docker compose up -d && docker compose ps
|
||||
```
|
||||
Expected: PostgreSQL 和 Redis 状态 healthy
|
||||
|
||||
- [ ] **V6: Push 到远程仓库**
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
Expected: Gitea Actions 触发 CI 流水线
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,706 +0,0 @@
|
||||
# Q4 测试覆盖 + 插件生态 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 建立 Testcontainers 集成测试框架覆盖核心模块;Playwright E2E 覆盖关键用户旅程;开发进销存插件验证插件系统扩展性;实现插件热更新能力。
|
||||
|
||||
**Architecture:** Testcontainers 启动真实 PostgreSQL 容器运行迁移后执行集成测试;Playwright 驱动浏览器完成端到端验证;进销存插件复用 CRM 插件的 manifest + dynamic_table 模式;热更新通过版本对比 + 增量 DDL + 两阶段提交实现。
|
||||
|
||||
**Tech Stack:** Rust (testcontainers, testcontainers-modules), Playwright, WASM (wit-bindgen), SeaORM Migration
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-17-platform-maturity-roadmap-design.md` §4
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Create | `crates/erp-server/tests/integration/mod.rs` | 集成测试入口 |
|
||||
| Create | `crates/erp-server/tests/integration/test_db.rs` | Testcontainers 测试基座 |
|
||||
| Create | `crates/erp-server/tests/integration/auth_tests.rs` | Auth 模块集成测试 |
|
||||
| Create | `crates/erp-server/tests/integration/plugin_tests.rs` | Plugin 模块集成测试 |
|
||||
| Create | `crates/erp-server/tests/integration/workflow_tests.rs` | Workflow 模块集成测试 |
|
||||
| Create | `crates/erp-server/tests/integration/event_tests.rs` | EventBus 端到端测试 |
|
||||
| Create | `apps/web/e2e/login.spec.ts` | 登录流程 E2E |
|
||||
| Create | `apps/web/e2e/users.spec.ts` | 用户管理 E2E |
|
||||
| Create | `apps/web/e2e/plugins.spec.ts` | 插件安装 E2E |
|
||||
| Create | `apps/web/e2e/tenant-isolation.spec.ts` | 多租户隔离 E2E |
|
||||
| Create | `apps/web/playwright.config.ts` | Playwright 配置 |
|
||||
| Create | `crates/erp-plugin-inventory/` | 进销存插件 crate |
|
||||
| Modify | `Cargo.toml` | workspace 添加新 crate |
|
||||
| Modify | `crates/erp-plugin/src/engine.rs` | 热更新 upgrade 端点支持 |
|
||||
| Modify | `crates/erp-plugin/src/service.rs` | upgrade 生命周期 |
|
||||
| Modify | `crates/erp-plugin/src/handler/plugin_handler.rs` | upgrade 路由 |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: 集成测试框架
|
||||
|
||||
### Task 1: 添加 Testcontainers 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-server/Cargo.toml`(dev-dependencies)
|
||||
|
||||
- [ ] **Step 1: 在 `erp-server` 的 `[dev-dependencies]` 中添加**
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
testcontainers = "0.23"
|
||||
testcontainers-modules = { version = "0.11", features = ["postgres"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
```
|
||||
|
||||
注意:版本号需与 workspace 已有依赖兼容。如果 workspace 已有 `testcontainers`,使用 workspace 引用。
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-server
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/Cargo.toml Cargo.lock
|
||||
git commit -m "chore(server): 添加 testcontainers 开发依赖"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 创建测试基座
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/tests/integration/mod.rs`
|
||||
- Create: `crates/erp-server/tests/integration/test_db.rs`
|
||||
|
||||
- [ ] **Step 1: 创建测试模块入口**
|
||||
|
||||
```rust
|
||||
// crates/erp-server/tests/integration/mod.rs
|
||||
mod test_db;
|
||||
mod auth_tests;
|
||||
mod plugin_tests;
|
||||
```
|
||||
|
||||
注意:需要确保 `erp-server` 的 `Cargo.toml` 中有 `[[test]]` 配置或集成测试自动发现。
|
||||
|
||||
- [ ] **Step 2: 创建 Testcontainers 测试基座**
|
||||
|
||||
```rust
|
||||
// crates/erp-server/tests/integration/test_db.rs
|
||||
use testcontainers_modules::postgres::Postgres;
|
||||
use testcontainers::runners::AsyncRunner;
|
||||
use sea_orm::{Database, DatabaseConnection};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 测试数据库容器 — 使用 once_cell 确保每进程一个容器
|
||||
pub struct TestDb {
|
||||
pub db: DatabaseConnection,
|
||||
pub container: testcontainers::ContainerAsync<Postgres>,
|
||||
}
|
||||
|
||||
impl TestDb {
|
||||
pub async fn new() -> Self {
|
||||
let postgres = Postgres::default()
|
||||
.with_db_name("erp_test")
|
||||
.with_user("test")
|
||||
.with_password("test");
|
||||
|
||||
let container = postgres.start().await
|
||||
.expect("Failed to start PostgreSQL container");
|
||||
|
||||
let host_port = container.get_host_port_ipv4(5432).await
|
||||
.expect("Failed to get port");
|
||||
|
||||
let url = format!("postgres://test:test@localhost:{}/erp_test", host_port);
|
||||
let db = Database::connect(&url).await
|
||||
.expect("Failed to connect to test database");
|
||||
|
||||
// 运行所有迁移
|
||||
run_migrations(&db).await;
|
||||
|
||||
Self { db, container }
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_migrations(db: &DatabaseConnection) {
|
||||
use migration::{Migrator, MigratorTrait};
|
||||
Migrator::up(db, None).await.expect("Failed to run migrations");
|
||||
}
|
||||
```
|
||||
|
||||
注意:需要确保 `migration` crate 可被测试引用。可能需要调整 `Cargo.toml` 的依赖。
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-server
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/tests/
|
||||
git commit -m "test(server): 创建 Testcontainers 集成测试基座"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Auth 模块集成测试
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/tests/integration/auth_tests.rs`
|
||||
|
||||
- [ ] **Step 1: 编写用户 CRUD 测试**
|
||||
|
||||
```rust
|
||||
// crates/erp-server/tests/integration/auth_tests.rs
|
||||
use super::test_db::TestDb;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_crud() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = &test_db.db;
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
|
||||
// 创建用户
|
||||
let user = erp_auth::service::UserService::create(
|
||||
tenant_id,
|
||||
uuid::Uuid::new_v4(),
|
||||
erp_auth::dto::CreateUserReq {
|
||||
username: "testuser".to_string(),
|
||||
password: "TestPass123".to_string(),
|
||||
email: Some("test@example.com".to_string()),
|
||||
phone: None,
|
||||
display_name: Some("测试用户".to_string()),
|
||||
},
|
||||
db,
|
||||
&erp_core::events::EventBus::new(100),
|
||||
).await.expect("Failed to create user");
|
||||
|
||||
assert_eq!(user.username, "testuser");
|
||||
|
||||
// 查询用户
|
||||
let found = erp_auth::service::UserService::get_by_id(user.id, tenant_id, db)
|
||||
.await.expect("Failed to get user");
|
||||
assert_eq!(found.username, "testuser");
|
||||
|
||||
// 列表查询
|
||||
let (users, total) = erp_auth::service::UserService::list(
|
||||
tenant_id, erp_core::types::Pagination { page: 1, page_size: 10 }, None, db,
|
||||
).await.expect("Failed to list users");
|
||||
assert_eq!(total, 1);
|
||||
assert_eq!(users[0].username, "testuser");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写多租户隔离测试**
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_tenant_isolation() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = &test_db.db;
|
||||
let tenant_a = uuid::Uuid::new_v4();
|
||||
let tenant_b = uuid::Uuid::new_v4();
|
||||
|
||||
// 租户 A 创建用户
|
||||
let user_a = erp_auth::service::UserService::create(
|
||||
tenant_a,
|
||||
uuid::Uuid::new_v4(),
|
||||
erp_auth::dto::CreateUserReq {
|
||||
username: "user_a".to_string(),
|
||||
password: "Pass123!".to_string(),
|
||||
email: None, phone: None, display_name: None,
|
||||
},
|
||||
db,
|
||||
&erp_core::events::EventBus::new(100),
|
||||
).await.unwrap();
|
||||
|
||||
// 租户 B 查询不应看到租户 A 的用户
|
||||
let (users_b, total_b) = erp_auth::service::UserService::list(
|
||||
tenant_b, erp_core::types::Pagination { page: 1, page_size: 10 }, None, db,
|
||||
).await.unwrap();
|
||||
|
||||
assert_eq!(total_b, 0);
|
||||
assert!(users_b.is_empty());
|
||||
|
||||
// 租户 B 通过 ID 查询租户 A 的用户应返回 NotFound
|
||||
let result = erp_auth::service::UserService::get_by_id(user_a.id, tenant_b, db).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试验证**
|
||||
|
||||
```bash
|
||||
cargo test -p erp-server --test integration auth_tests
|
||||
```
|
||||
|
||||
注意:需要 Docker 运行。Windows 上可能需要 WSL2。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/tests/integration/auth_tests.rs
|
||||
git commit -m "test(auth): 添加用户 CRUD 和多租户隔离集成测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Plugin 模块集成测试
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/tests/integration/plugin_tests.rs`
|
||||
|
||||
- [ ] **Step 1: 编写插件生命周期测试**
|
||||
|
||||
测试 install → enable → data CRUD → disable → uninstall 完整流程。
|
||||
|
||||
- [ ] **Step 2: 编写 JSONB 查询测试**
|
||||
|
||||
验证 dynamic_table 的 generated column、pg_trgm 索引是否正确创建。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/tests/integration/plugin_tests.rs
|
||||
git commit -m "test(plugin): 添加插件生命周期和 JSONB 集成测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Workflow + EventBus 集成测试
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/tests/integration/workflow_tests.rs`
|
||||
- Create: `crates/erp-server/tests/integration/event_tests.rs`
|
||||
|
||||
- [ ] **Step 1: Workflow 测试 — 流程实例启动和任务完成**
|
||||
|
||||
- [ ] **Step 2: EventBus 测试 — 发布/订阅端到端 + outbox relay**
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/tests/integration/
|
||||
git commit -m "test: 添加 workflow 和 EventBus 集成测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: E2E 测试
|
||||
|
||||
### Task 6: Playwright 环境搭建
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/playwright.config.ts`
|
||||
- Modify: `apps/web/package.json`
|
||||
|
||||
- [ ] **Step 1: 安装 Playwright**
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm add -D @playwright/test && pnpm exec playwright install chromium
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 Playwright 配置**
|
||||
|
||||
```ts
|
||||
// apps/web/playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30000,
|
||||
retries: 1,
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
headless: true,
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/playwright.config.ts apps/web/package.json
|
||||
git commit -m "test(web): 搭建 Playwright E2E 测试环境"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 登录流程 E2E 测试
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/e2e/login.spec.ts`
|
||||
|
||||
- [ ] **Step 1: 编写登录 E2E 测试**
|
||||
|
||||
```ts
|
||||
// apps/web/e2e/login.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('完整登录流程', async ({ page }) => {
|
||||
await page.goto('/#/login');
|
||||
await expect(page.locator('h2, .ant-card-head-title')).toContainText('登录');
|
||||
|
||||
// 输入凭据
|
||||
await page.fill('input[placeholder*="用户名"]', 'admin');
|
||||
await page.fill('input[placeholder*="密码"]', 'Admin@2026');
|
||||
await page.click('button:has-text("登录")');
|
||||
|
||||
// 验证跳转到首页
|
||||
await page.waitForURL('**/'),
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
});
|
||||
```
|
||||
|
||||
注意:此测试需要后端服务运行。可在 CI 中使用 service container 或手动启动。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/e2e/login.spec.ts
|
||||
git commit -m "test(web): 添加登录流程 E2E 测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 用户管理 E2E 测试
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/e2e/users.spec.ts`
|
||||
|
||||
- [ ] **Step 1: 编写用户管理闭环测试**
|
||||
|
||||
创建 → 搜索 → 编辑 → 软删除 → 验证列表不显示。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/e2e/users.spec.ts
|
||||
git commit -m "test(web): 添加用户管理 E2E 测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 插件安装 + 多租户 E2E 测试
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/e2e/plugins.spec.ts`
|
||||
- Create: `apps/web/e2e/tenant-isolation.spec.ts`
|
||||
|
||||
- [ ] **Step 1: 插件安装 E2E 测试**
|
||||
|
||||
上传 → 安装 → 验证菜单 → 数据 CRUD → 卸载。
|
||||
|
||||
- [ ] **Step 2: 多租户隔离 E2E 测试**
|
||||
|
||||
租户 A 创建数据 → 切换租户 B → 验证不可见。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/e2e/
|
||||
git commit -m "test(web): 添加插件安装和多租户 E2E 测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 进销存插件
|
||||
|
||||
### Task 10: 创建插件 crate 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-inventory/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-inventory/src/lib.rs`
|
||||
- Create: `crates/erp-plugin-inventory/manifest.toml`
|
||||
- Modify: `Cargo.toml`(workspace members)
|
||||
|
||||
- [ ] **Step 1: 创建 Cargo.toml**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-inventory"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.38"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 manifest.toml**
|
||||
|
||||
定义 6 个实体(product, warehouse, stock, supplier, purchase_order, sales_order)的完整 schema,包括字段、关系、页面、权限声明。参考 CRM 插件的 `crates/erp-plugin-crm/manifest.toml`。
|
||||
|
||||
- [ ] **Step 3: 创建 lib.rs(Guest trait 实现)**
|
||||
|
||||
```rust
|
||||
// crates/erp-plugin-inventory/src/lib.rs
|
||||
wit_bindgen::generate!({
|
||||
path: "../erp-plugin-prototype/wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
struct InventoryPlugin;
|
||||
|
||||
impl Guest for InventoryPlugin {
|
||||
fn init() -> Result<(), String> { Ok(()) }
|
||||
fn on_tenant_created(_tenant_id: String) -> Result<(), String> { Ok(()) }
|
||||
fn handle_event(_event_type: String, _event_data: String) -> Result<(), String> { Ok(()) }
|
||||
}
|
||||
|
||||
export_plugin!(InventoryPlugin);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 添加到 workspace**
|
||||
|
||||
在根 `Cargo.toml` 的 `members` 中添加 `"crates/erp-plugin-inventory"`。
|
||||
|
||||
- [ ] **Step 5: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-inventory
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-inventory/ Cargo.toml
|
||||
git commit -m "feat(inventory): 创建进销存插件 crate 骨架"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 定义实体 Schema
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-plugin-inventory/manifest.toml`
|
||||
|
||||
- [ ] **Step 1: 定义 6 个实体**
|
||||
|
||||
参考 CRM 插件 manifest 格式,定义:
|
||||
|
||||
| 实体 | 关键字段 | 关联 | 页面类型 |
|
||||
|------|---------|------|---------|
|
||||
| product | code, name, spec, unit, category, price, cost | — | CRUD |
|
||||
| warehouse | code, name, address, manager, status | — | CRUD |
|
||||
| stock | product_id, warehouse_id, qty, cost, alert_line | → product, warehouse | CRUD |
|
||||
| supplier | code, name, contact, phone, address | — | CRUD |
|
||||
| purchase_order | supplier_id, total_amount, status, date | → supplier, stock | CRUD + Dashboard |
|
||||
| sales_order | customer_id, total_amount, status, date | → customer(CRM), stock | CRUD + Kanban |
|
||||
|
||||
- [ ] **Step 2: 定义 6 个页面**(4 CRUD + 1 Dashboard 库存汇总 + 1 Kanban 销售看板)
|
||||
|
||||
- [ ] **Step 3: 定义 9 个权限**(每个实体 list/create/update/delete + 全局 manage)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-inventory/manifest.toml
|
||||
git commit -m "feat(inventory): 定义 6 实体/6 页面/9 权限 manifest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: 编译 WASM 并测试安装
|
||||
|
||||
**Files:**
|
||||
- Build output: `apps/web/public/inventory.wasm`
|
||||
|
||||
- [ ] **Step 1: 编译为 WASM Component**
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-inventory --target wasm32-unknown-unknown --release
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm -o target/erp_plugin_inventory.component.wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 复制到前端 public 目录**
|
||||
|
||||
```bash
|
||||
cp target/erp_plugin_inventory.component.wasm apps/web/public/inventory.wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 通过 API 安装插件并验证**
|
||||
|
||||
使用 curl 或前端插件管理页面上传 `inventory.wasm`,验证动态表创建成功,CRUD 页面正常工作。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/public/inventory.wasm
|
||||
git commit -m "feat(inventory): 编译并部署进销存插件 WASM"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: 插件热更新
|
||||
|
||||
### Task 13: 添加 upgrade 端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-plugin/src/handler/plugin_handler.rs`
|
||||
- Modify: `crates/erp-plugin/src/service.rs`
|
||||
|
||||
- [ ] **Step 1: 在 plugin_handler 中添加 upgrade 路由**
|
||||
|
||||
```rust
|
||||
pub fn protected_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
// ... 现有路由 ...
|
||||
.route("/admin/plugins/:plugin_id/upgrade", post(upgrade_plugin))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 upgrade handler**
|
||||
|
||||
接收新 WASM 文件,调用 service 层执行升级。
|
||||
|
||||
- [ ] **Step 3: 在 service 中实现升级逻辑**
|
||||
|
||||
```rust
|
||||
pub async fn upgrade_plugin(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
new_wasm_bytes: Vec<u8>,
|
||||
db: &DatabaseConnection,
|
||||
) -> PluginResult<()> {
|
||||
// 1. 解析新 manifest
|
||||
let new_manifest = parse_manifest_from_wasm(&new_wasm_bytes)?;
|
||||
|
||||
// 2. 获取当前插件信息
|
||||
let current = find_by_id(plugin_id, tenant_id, db).await?;
|
||||
|
||||
// 3. 对比 schema 变更,生成增量 DDL
|
||||
let schema_diff = compare_schemas(¤t.manifest, &new_manifest)?;
|
||||
|
||||
// 4. 暂存新 WASM,尝试验证初始化
|
||||
// 5. 初始化成功后,在事务中执行 DDL + 状态更新
|
||||
// 6. 失败时保持旧 WASM 继续运行
|
||||
// 详见 spec §4.4 回滚策略
|
||||
todo!()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin/src/
|
||||
git commit -m "feat(plugin): 添加插件热更新 upgrade 端点"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5: 文档更新与清理
|
||||
|
||||
### Task 14: 更新 Wiki 文档
|
||||
|
||||
**Files:**
|
||||
- Modify: `wiki/frontend.md`
|
||||
- Modify: `wiki/database.md`
|
||||
- Modify: `wiki/testing.md`
|
||||
- Modify: `wiki/index.md`
|
||||
|
||||
- [ ] **Step 1: 更新 `wiki/frontend.md`**
|
||||
|
||||
更新为反映当前 16 条路由、6 种插件页面类型、Zustand stores 等实际状态。
|
||||
|
||||
- [ ] **Step 2: 更新 `wiki/testing.md`**
|
||||
|
||||
更新测试数量、添加 Testcontainers 集成测试和 Playwright E2E 描述。
|
||||
|
||||
- [ ] **Step 3: 更新 `wiki/index.md`**
|
||||
|
||||
添加进销存插件到模块导航树,更新开发进度表。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add wiki/
|
||||
git commit -m "docs: 更新 Wiki 文档到当前状态"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 15: CLAUDE.md 版本号修正 + 根目录清理
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
- Cleanup: 根目录未跟踪文件
|
||||
|
||||
- [ ] **Step 1: 修正 CLAUDE.md 版本号**
|
||||
|
||||
将 `React 18 + Ant Design 5` 改为 `React 19 + Ant Design 6`。
|
||||
|
||||
- [ ] **Step 2: 清理根目录未跟踪文件**
|
||||
|
||||
删除开发临时文件:截图、heap dump、perf trace、agent plan 文件。
|
||||
|
||||
```bash
|
||||
rm -f current-page.png home-full.png home-improved.png docs/debug-*.png
|
||||
rm -f docs/memory-snapshot-*.heapsnapshot docs/perf-trace-*.json
|
||||
rm -f test_api_auth.py test_users.py
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 处理 integration-tests/ 目录**
|
||||
|
||||
验证 `integration-tests/` 中的测试是否能编译。若已失效则删除(新的集成测试在 `crates/erp-server/tests/integration/`)。若仍有效则添加到 workspace。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: 修正 CLAUDE.md 版本号 (React 19 / AD 6) 并清理临时文件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
- [ ] **V1: 全 workspace 编译和测试**
|
||||
```bash
|
||||
cargo check && cargo test --workspace
|
||||
```
|
||||
|
||||
- [ ] **V2: 集成测试通过**
|
||||
```bash
|
||||
cargo test -p erp-server --test integration
|
||||
```
|
||||
注意:需要 Docker 运行
|
||||
|
||||
- [ ] **V3: 前端构建**
|
||||
```bash
|
||||
cd apps/web && pnpm build
|
||||
```
|
||||
|
||||
- [ ] **V4: E2E 测试**
|
||||
```bash
|
||||
cd apps/web && pnpm exec playwright test
|
||||
```
|
||||
|
||||
- [ ] **V5: 进销存插件安装验证**
|
||||
|
||||
通过 API 安装 inventory.wasm,验证动态表和 CRUD 页面正常。
|
||||
|
||||
- [ ] **V6: Wiki 文档同步**
|
||||
|
||||
确认 Wiki 描述与代码实际状态一致。
|
||||
@@ -1,484 +0,0 @@
|
||||
# 汕头市智界科技 IT 服务插件 — 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为汕头市智界科技有限公司创建 freelance(自由职业者工作台)和 itops(IT 运维服务台)两个 WASM 插件,覆盖其全部 12 条经营范围。
|
||||
|
||||
**Architecture:** 两个独立的 WASM 插件 crate,每个包含 Cargo.toml(cdylib)、src/lib.rs(Guest trait 实现)、plugin.toml(声明式 schema)。通过插件安装 API 上传到系统,平台自动创建动态表、注册权限、生成前端页面。itops 通过 ref_plugin 跨插件引用 freelance 的 client 实体。
|
||||
|
||||
**Tech Stack:** Rust (wit-bindgen 0.55, cdylib → WASM Component)、TOML manifest、Axum Host API
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: freelance 插件
|
||||
|
||||
### Task 1: 创建 crate 目录和 Cargo.toml
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-freelance/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-freelance/src/lib.rs`(空文件占位)
|
||||
|
||||
- [ ] **Step 1: 创建目录结构**
|
||||
|
||||
```bash
|
||||
mkdir -p crates/erp-plugin-freelance/src
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写 Cargo.toml**
|
||||
|
||||
创建 `crates/erp-plugin-freelance/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-freelance"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "自由职业者工作台 WASM 插件"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.55"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写 src/lib.rs**
|
||||
|
||||
创建 `crates/erp-plugin-freelance/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
//! 自由职业者工作台 WASM 插件
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "../erp-plugin-prototype/wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||
|
||||
struct FreelancePlugin;
|
||||
|
||||
impl Guest for FreelancePlugin {
|
||||
fn init() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
export!(FreelancePlugin);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 注册到 workspace**
|
||||
|
||||
编辑根 `Cargo.toml`,在 `members` 数组末尾添加:
|
||||
|
||||
```toml
|
||||
"crates/erp-plugin-freelance",
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-freelance
|
||||
```
|
||||
|
||||
Expected: 编译通过,无错误
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-freelance/ Cargo.toml
|
||||
git commit -m "feat(freelance): 创建插件 crate 骨架"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 编写 plugin.toml(freelance)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-freelance/plugin.toml`
|
||||
|
||||
- [ ] **Step 1: 从设计规格文档复制完整 plugin.toml 内容**
|
||||
|
||||
从设计规格 `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md` 中提取 2.1(元数据)+ 2.2(权限)+ 2.3(10 个实体)+ 2.4(编号规则)+ 2.5(页面声明)的所有 TOML 内容,合并为完整的 `plugin.toml` 文件。
|
||||
|
||||
文件结构:
|
||||
1. `[metadata]` 段
|
||||
2. `[[permissions]]` × 20
|
||||
3. `[[schema.entities]]` × 10(client, opportunity, quote, quote_line, contract, project, task, time_entry, invoice, expense),每个实体包含 fields 和 relations
|
||||
4. `[[numbering]]` × 3(quote_number, contract_number, invoice_number)
|
||||
5. `[[ui.pages]]` × 7(dashboard, tabs+detail+kanban for client, crud+detail for project, tabs for finance, crud for expense)
|
||||
|
||||
注意要点:
|
||||
- client 实体必须标记 `is_public = true`(被 itops 跨插件引用)
|
||||
- quote 到 quote_line 有 cascade 关系
|
||||
- project 到 task 和 time_entry 有 cascade 关系
|
||||
- 所有 uuid 引用字段使用 `ui_widget = "entity_select"` + `ref_label_field` + `ref_search_fields`
|
||||
- 所有 select 字段使用 `options = [{ label = "X", value = "x" }]` 格式
|
||||
- 长文本使用 `field_type = "string"` + `ui_widget = "textarea"`
|
||||
- 金额使用 `field_type = "decimal"`
|
||||
- 时间戳使用 `field_type = "date_time"`
|
||||
|
||||
- [ ] **Step 2: 验证 TOML 格式**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-freelance
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-freelance/plugin.toml
|
||||
git commit -m "feat(freelance): 添加 plugin.toml — 10 实体/20 权限/7 页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 编译 WASM 并安装
|
||||
|
||||
- [ ] **Step 1: 编译为 WASM**
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-freelance --target wasm32-unknown-unknown --release
|
||||
```
|
||||
|
||||
Expected: 编译成功,产出 `target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm`
|
||||
|
||||
- [ ] **Step 2: 转换为 Component**
|
||||
|
||||
```bash
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm -o target/erp_plugin_freelance.component.wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 检查产物大小**
|
||||
|
||||
```bash
|
||||
ls -la target/erp_plugin_freelance.component.wasm
|
||||
```
|
||||
|
||||
Expected: < 100KB(CRM 约 22KB)
|
||||
|
||||
- [ ] **Step 4: 启动后端服务**
|
||||
|
||||
```bash
|
||||
cd crates/erp-server && cargo run
|
||||
```
|
||||
|
||||
等待服务启动完成(看到 "listening on 0.0.0.0:3000" 日志)
|
||||
|
||||
- [ ] **Step 5: 登录获取 Token**
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:3000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin123"}' | jq -r '.data.access_token'
|
||||
```
|
||||
|
||||
保存输出的 token。
|
||||
|
||||
- [ ] **Step 6: 上传安装插件**
|
||||
|
||||
```bash
|
||||
TOKEN="<上一步的 token>"
|
||||
MANIFEST=$(cat crates/erp-plugin-freelance/plugin.toml)
|
||||
|
||||
curl -s -X POST http://localhost:3000/api/v1/admin/plugins/upload \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "wasm=@target/erp_plugin_freelance.component.wasm" \
|
||||
-F "manifest=$MANIST"
|
||||
```
|
||||
|
||||
Expected: 返回插件 ID,状态为 `installed`
|
||||
|
||||
- [ ] **Step 7: 启用插件**
|
||||
|
||||
使用上一步返回的插件 ID:
|
||||
|
||||
```bash
|
||||
PLUGIN_ID="<返回的插件 ID>"
|
||||
curl -s -X POST "http://localhost:3000/api/v1/admin/plugins/$PLUGIN_ID/enable" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
Expected: 状态变为 `running`
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(freelance): 编译 WASM 并验证安装"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 浏览器验证 freelance 插件
|
||||
|
||||
- [ ] **Step 1: 打开浏览器访问 http://localhost:5174**
|
||||
|
||||
- [ ] **Step 2: 登录后检查侧边栏**
|
||||
|
||||
Expected: 看到"自由职业者工作台"菜单组,包含:工作台、客户管理、商机看板、项目管理、项目详情、财务中心、支出管理
|
||||
|
||||
- [ ] **Step 3: 测试客户 CRUD**
|
||||
|
||||
进入客户管理 → 新增客户(填写名称、联系人、电话、行业等)→ 保存 → 列表中可见
|
||||
|
||||
- [ ] **Step 4: 测试项目 → 任务级联**
|
||||
|
||||
进入项目管理 → 新增项目 → 进入项目详情 → 新增任务 → 验证任务关联到项目
|
||||
|
||||
- [ ] **Step 5: 测试报价 → 报价明细级联**
|
||||
|
||||
进入财务中心 → 报价管理 tab → 新增报价 → 验证明细行可添加
|
||||
|
||||
- [ ] **Step 6: 测试商机看板**
|
||||
|
||||
进入商机看板 → 新增商机 → 拖拽改变阶段 → 验证数据更新
|
||||
|
||||
- [ ] **Step 7: 验证数据库表创建**
|
||||
|
||||
```bash
|
||||
PGPASSWORD=123123 "D:\postgreSQL\bin\psql.exe" -U postgres -h localhost -d erp -c "\dt plugin_erp_freelance_*"
|
||||
```
|
||||
|
||||
Expected: 看到 10 张动态表
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: itops 插件
|
||||
|
||||
### Task 5: 创建 itops 插件 crate
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-itops/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-itops/src/lib.rs`
|
||||
- Create: `crates/erp-plugin-itops/plugin.toml`
|
||||
|
||||
- [ ] **Step 1: 创建目录结构**
|
||||
|
||||
```bash
|
||||
mkdir -p crates/erp-plugin-itops/src
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写 Cargo.toml**
|
||||
|
||||
创建 `crates/erp-plugin-itops/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-itops"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "IT 运维服务台 WASM 插件"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.55"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写 src/lib.rs**
|
||||
|
||||
创建 `crates/erp-plugin-itops/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
//! IT 运维服务台 WASM 插件
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "../erp-plugin-prototype/wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||
|
||||
struct ItopsPlugin;
|
||||
|
||||
impl Guest for ItopsPlugin {
|
||||
fn init() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
export!(ItopsPlugin);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编写 plugin.toml**
|
||||
|
||||
从设计规格文档 Section 3 提取完整内容:
|
||||
1. `[metadata]` — id="erp-itops",无 dependencies(松耦合)
|
||||
2. `[[permissions]]` × 8
|
||||
3. `[[schema.entities]]` × 4(service_contract, ticket, check_plan, check_record),每个实体包含 fields 和 relations
|
||||
4. `[[numbering]]` × 1(contract_number)
|
||||
5. `[[ui.pages]]` × 4(crud+detail for service_contract, tabs for ticket center)
|
||||
|
||||
关键注意点:
|
||||
- 4 个实体的 `client_id` 字段都使用 `ref_plugin = "erp-freelance"` + `ref_fallback_label = "外部客户"`
|
||||
- `filterable` 只用于 string 类型的 status/type/category 字段,不用于 uuid 字段
|
||||
- `check_items` 和 `items_data` 使用 `field_type = "json"`
|
||||
- `responded_at` / `resolved_at` / `closed_at` 使用 `field_type = "date_time"`
|
||||
|
||||
- [ ] **Step 5: 注册到 workspace**
|
||||
|
||||
编辑根 `Cargo.toml`,在 members 数组末尾添加:
|
||||
|
||||
```toml
|
||||
"crates/erp-plugin-itops",
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-itops
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-itops/ Cargo.toml
|
||||
git commit -m "feat(itops): 创建 IT 运维服务台插件 — 4 实体/8 权限/4 页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 编译 WASM 并安装 itops
|
||||
|
||||
- [ ] **Step 1: 编译为 WASM**
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-itops --target wasm32-unknown-unknown --release
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 转换为 Component**
|
||||
|
||||
```bash
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_itops.wasm -o target/erp_plugin_itops.component.wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 上传安装插件**
|
||||
|
||||
```bash
|
||||
TOKEN="<之前获取的 token>"
|
||||
MANIFEST=$(cat crates/erp-plugin-itops/plugin.toml)
|
||||
|
||||
curl -s -X POST http://localhost:3000/api/v1/admin/plugins/upload \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "wasm=@target/erp_plugin_itops.component.wasm" \
|
||||
-F "manifest=$MANIFEST"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 启用插件**
|
||||
|
||||
```bash
|
||||
PLUGIN_ID="<返回的插件 ID>"
|
||||
curl -s -X POST "http://localhost:3000/api/v1/admin/plugins/$PLUGIN_ID/enable" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 浏览器验证 itops 插件
|
||||
|
||||
- [ ] **Step 1: 检查侧边栏**
|
||||
|
||||
Expected: 看到"IT 运维服务台"菜单组,包含:合同管理、合同详情、工单中心
|
||||
|
||||
- [ ] **Step 2: 测试维保合同 CRUD**
|
||||
|
||||
进入合同管理 → 新增维保合同(选择客户时验证:如 freelance 已安装,客户下拉显示 freelance 的客户列表)
|
||||
|
||||
- [ ] **Step 3: 测试跨插件引用**
|
||||
|
||||
场景 A(freelance 已安装):创建工单时 client_id 字段显示为下拉选择器,可搜索 freelance.client
|
||||
场景 B(freelance 未安装):client_id 降级为文本输入,显示"外部客户"
|
||||
|
||||
- [ ] **Step 4: 测试合同 → 工单 → 巡检级联**
|
||||
|
||||
进入合同详情 → 工单 tab → 新增工单 → 巡检计划 tab → 新增巡检计划 → 巡检记录 tab → 新增巡检记录
|
||||
|
||||
- [ ] **Step 5: 验证数据库表**
|
||||
|
||||
```bash
|
||||
PGPASSWORD=123123 "D:\postgreSQL\bin\psql.exe" -U postgres -h localhost -d erp -c "\dt plugin_erp_itops_*"
|
||||
```
|
||||
|
||||
Expected: 看到 4 张动态表
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 集成验证
|
||||
|
||||
### Task 8: 全链路端到端验证
|
||||
|
||||
- [ ] **Step 1: 创建客户**
|
||||
|
||||
freelance → 客户管理 → 新增客户"汕头市XX科技有限公司"
|
||||
|
||||
- [ ] **Step 2: 创建商机**
|
||||
|
||||
商机看板 → 新增商机 → 选择客户 → 填写"官网开发"→ 拖拽到"成交"阶段
|
||||
|
||||
- [ ] **Step 3: 创建报价单**
|
||||
|
||||
财务中心 → 报价管理 → 新增报价 → 选择客户 → 添加明细行 → 保存
|
||||
|
||||
- [ ] **Step 4: 创建合同**
|
||||
|
||||
财务中心 → 合同管理 → 新增合同 → 选择客户 → 填写金额和日期 → 保存
|
||||
|
||||
- [ ] **Step 5: 创建项目**
|
||||
|
||||
项目管理 → 新增项目 → 选择客户和合同 → 填写"官网开发项目" → 添加任务 → 记录工时
|
||||
|
||||
- [ ] **Step 6: 创建发票**
|
||||
|
||||
财务中心 → 发票/收款 → 新增发票 → 选择客户和项目 → 填写金额 → 标记已收款
|
||||
|
||||
- [ ] **Step 7: 创建运维工单**
|
||||
|
||||
itops → 合同管理 → 新增维保合同 → 选择客户(验证跨插件引用)→ 保存
|
||||
itops → 工单中心 → 新增工单 → 选择客户和合同 → 保存
|
||||
|
||||
- [ ] **Step 8: 记录支出**
|
||||
|
||||
freelance → 支出管理 → 新增支出 → 选择类别"云服务" → 填写金额 → 保存
|
||||
|
||||
- [ ] **Step 9: 提交并推送**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(freelance,itops): 汕头市智界科技 IT 服务行业插件验证通过"
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键参考文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `crates/erp-plugin-crm/Cargo.toml` | Cargo.toml 模板参考 |
|
||||
| `crates/erp-plugin-crm/src/lib.rs` | lib.rs 代码模式参考 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | plugin.toml 格式参考(同插件内引用) |
|
||||
| `crates/erp-plugin-inventory/plugin.toml` | 跨插件引用格式参考(ref_plugin) |
|
||||
| `crates/erp-plugin/src/manifest.rs` | PluginField/PluginFieldType 完整定义 |
|
||||
| `crates/erp-plugin-prototype/wit/plugin.wit` | WIT 接口定义 |
|
||||
| `wiki/infrastructure.md` | 数据库连接、端口、登录凭据 |
|
||||
| `wiki/wasm-plugin.md` | 插件制作完整流程 |
|
||||
@@ -1,587 +0,0 @@
|
||||
# freelance + itops 插件增强实施计划
|
||||
|
||||
> 日期: 2026-04-20
|
||||
> 对应规格: `docs/superpowers/specs/2026-04-20-freelance-itops-plugin-enhancement-design.md`
|
||||
> 前置: 两插件已部署(freelance 10 实体/20 权限,itops 4 实体/8 权限)
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| Phase | 内容 | 类型 | 依赖 |
|
||||
|-------|------|------|------|
|
||||
| P1 | freelance Layer 1 — 智能业务引擎 | 纯 plugin.toml | 无 |
|
||||
| P2 | itops Layer 1 — 智能业务引擎 | 纯 plugin.toml | 无 |
|
||||
| P3 | freelance Layer 3 — PDF 模板 | 纯 plugin.toml | 无 |
|
||||
| P4 | itops Layer 3 — PDF 模板 | 纯 plugin.toml | 无 |
|
||||
| P5 | 平台 dashboard widgets 扩展 | manifest.rs + 前端 | P1-P4 完成 |
|
||||
| P6 | freelance + itops Layer 2 — 仪表盘 | plugin.toml + 前端 | P5 完成 |
|
||||
|
||||
P1-P4 可并行,P5-P6 顺序依赖。
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: freelance Layer 1 — 智能业务引擎
|
||||
|
||||
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
|
||||
|
||||
### Task 1.1: 新增 `[settings]` 段落
|
||||
|
||||
在 `[[numbering]]` 之前插入 7 个配置项:
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
|
||||
[[settings.fields]]
|
||||
name = "company_name"
|
||||
display_name = "公司名称"
|
||||
field_type = "text"
|
||||
required = true
|
||||
group = "基本信息"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "currency_symbol"
|
||||
display_name = "货币符号"
|
||||
field_type = "text"
|
||||
default_value = "¥"
|
||||
group = "基本信息"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_tax_rate"
|
||||
display_name = "默认税率(%)"
|
||||
field_type = "number"
|
||||
default_value = 6
|
||||
range = [0.0, 100.0]
|
||||
group = "财务"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "payment_reminder_days"
|
||||
display_name = "收款提前提醒(天)"
|
||||
field_type = "number"
|
||||
default_value = 3
|
||||
range = [1.0, 30.0]
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_contract_expiring"
|
||||
display_name = "合同到期提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_payment_overdue"
|
||||
display_name = "逾期收款提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_opportunity_followup"
|
||||
display_name = "商机跟进提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
```
|
||||
|
||||
### Task 1.2: 新增 `[[trigger_events]]` 段落
|
||||
|
||||
在 `[settings]` 之后插入 5 个触发事件:
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "opportunity_stage_changed"
|
||||
display_name = "商机阶段变更"
|
||||
description = "商机阶段发生变化时通知,特别是成交或失败"
|
||||
entity = "opportunity"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "contract_status_changed"
|
||||
display_name = "合同状态变更"
|
||||
description = "合同状态变化时检查到期预警"
|
||||
entity = "contract"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "invoice_status_changed"
|
||||
display_name = "发票状态变更"
|
||||
description = "发票状态变化时检查逾期收款"
|
||||
entity = "invoice"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "task_status_changed"
|
||||
display_name = "任务状态变更"
|
||||
description = "任务完成或取消时通知"
|
||||
entity = "task"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "expense_created"
|
||||
display_name = "新支出记录"
|
||||
description = "记录新支出时通知"
|
||||
entity = "expense"
|
||||
on = "create"
|
||||
```
|
||||
|
||||
### Task 1.3: 追加 cascade 属性(5 处已有字段)
|
||||
|
||||
**1.3a** contract.opportunity_id(第 450-454 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**1.3b** contract.quote_id(第 456-460 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**1.3c** invoice.project_id(第 796-800 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**1.3d** invoice.contract_id(第 802-806 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**1.3e** time_entry.task_id(第 745-751 行)追加:
|
||||
```toml
|
||||
cascade_from = "project_id"
|
||||
cascade_filter = "project_id"
|
||||
```
|
||||
|
||||
### Task 1.4: 追加 visible_when 属性(4 处已有字段)
|
||||
|
||||
**1.4a** invoice.payment_date(第 860-863 行)追加:
|
||||
```toml
|
||||
visible_when = "status == 'paid' || status == 'partial'"
|
||||
```
|
||||
|
||||
**1.4b** contract.paid_amount(第 516-520 行)追加:
|
||||
```toml
|
||||
visible_when = "status != 'drafting'"
|
||||
```
|
||||
|
||||
**1.4c** task.actual_hours(第 727-730 行)追加:
|
||||
```toml
|
||||
visible_when = "status != 'todo'"
|
||||
```
|
||||
|
||||
**1.4d** quote.total_amount(第 357-361 行)追加:
|
||||
```toml
|
||||
visible_when = "status != 'draft'"
|
||||
```
|
||||
|
||||
### Task 1.5: 追加 validation 属性(2 处已有字段)
|
||||
|
||||
**1.5a** client.phone(第 135-138 行)追加:
|
||||
```toml
|
||||
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
|
||||
```
|
||||
|
||||
**1.5b** client.email(第 140-143 行)追加:
|
||||
```toml
|
||||
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
|
||||
```
|
||||
|
||||
### Task 1.6: 编译 WASM + 升级插件
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-freelance --target wasm32-unknown-unknown --release
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm -o target/erp_plugin_freelance.component.wasm
|
||||
```
|
||||
|
||||
通过 API 升级:
|
||||
```bash
|
||||
# 上传新版本 WASM
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/{plugin_id}/upgrade \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-F "wasm=@target/erp_plugin_freelance.component.wasm" \
|
||||
-F "manifest=@crates/erp-plugin-freelance/plugin.toml"
|
||||
```
|
||||
|
||||
### Task 1.7: 验证
|
||||
|
||||
- [ ] `cargo check` 通过
|
||||
- [ ] 重新登录获取新 JWT(权限可能变化)
|
||||
- [ ] 前端打开 freelance 插件 → 设置页面可见 7 个配置项
|
||||
- [ ] 创建客户 → phone 格式错误时提示校验信息
|
||||
- [ ] 创建客户 → email 格式错误时提示校验信息
|
||||
- [ ] 创建合同 → 选客户后 opportunity_id 和 quote_id 自动过滤
|
||||
- [ ] 创建发票 → 选客户后 project_id 和 contract_id 自动过滤
|
||||
- [ ] 创建工时 → 选项目后 task_id 自动过滤
|
||||
- [ ] invoice 状态为 pending 时,payment_date 字段不显示
|
||||
- [ ] contract 状态为 drafting 时,paid_amount 字段不显示
|
||||
- [ ] 触发事件:更新商机阶段 → 消息中心收到通知
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: itops Layer 1 — 智能业务引擎
|
||||
|
||||
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
|
||||
|
||||
### Task 2.1: 新增 `[settings]` 段落
|
||||
|
||||
在 `[[numbering]]` 之前插入 4 个配置项:
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_sla_response"
|
||||
display_name = "默认SLA响应时间(小时)"
|
||||
field_type = "number"
|
||||
default_value = 8
|
||||
range = [1.0, 72.0]
|
||||
group = "SLA"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_sla_resolve"
|
||||
display_name = "默认SLA解决时间(小时)"
|
||||
field_type = "number"
|
||||
default_value = 48
|
||||
range = [1.0, 168.0]
|
||||
group = "SLA"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_sla_breach"
|
||||
display_name = "SLA超标提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_check_due"
|
||||
display_name = "巡检到期提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
```
|
||||
|
||||
### Task 2.2: 新增 `[[trigger_events]]` 段落
|
||||
|
||||
在 `[settings]` 之后插入 4 个触发事件:
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "ticket_created"
|
||||
display_name = "新工单"
|
||||
description = "创建工单时开始SLA计时并通知"
|
||||
entity = "ticket"
|
||||
on = "create"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "ticket_status_changed"
|
||||
display_name = "工单状态变更"
|
||||
description = "工单状态变化时检查SLA是否达标"
|
||||
entity = "ticket"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "contract_status_changed"
|
||||
display_name = "维保合同状态变更"
|
||||
description = "合同状态变化时检查到期预警"
|
||||
entity = "service_contract"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "check_plan_updated"
|
||||
display_name = "巡检计划更新"
|
||||
description = "巡检计划更新时检查下次巡检日期"
|
||||
entity = "check_plan"
|
||||
on = "update"
|
||||
```
|
||||
|
||||
### Task 2.3: 追加 cascade 属性(2 处已有字段)
|
||||
|
||||
**2.3a** ticket.contract_id(第 186-192 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**2.3b** check_record.contract_id(第 398-400 行)追加:
|
||||
```toml
|
||||
cascade_from = "plan_id"
|
||||
cascade_filter = "contract_id"
|
||||
```
|
||||
|
||||
### Task 2.4: 追加 visible_when 属性(6 处已有字段)
|
||||
|
||||
**2.4a** ticket.resolution → `visible_when = "status == 'resolved' || status == 'closed'"`
|
||||
**2.4b** ticket.responded_at → `visible_when = "status != 'open'"`
|
||||
**2.4c** ticket.resolved_at → `visible_when = "status == 'resolved' || status == 'closed'"`
|
||||
**2.4d** ticket.closed_at → `visible_when = "status == 'closed'"`
|
||||
**2.4e** check_record.issues_found → `visible_when = "result == 'abnormal'"`
|
||||
**2.4f** check_record.actions_taken → `visible_when = "result == 'abnormal'"`
|
||||
|
||||
### Task 2.5: 追加 validation 属性(1 处已有字段)
|
||||
|
||||
**2.5a** service_contract.contract_number(第 73-78 行)追加:
|
||||
```toml
|
||||
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式:SC-YYYY-NNNN" }
|
||||
```
|
||||
|
||||
### Task 2.6: 编译 WASM + 升级插件
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-itops --target wasm32-unknown-unknown --release
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_itops.wasm -o target/erp_plugin_itops.component.wasm
|
||||
```
|
||||
|
||||
### Task 2.7: 验证
|
||||
|
||||
- [ ] `cargo check` 通过
|
||||
- [ ] 重新登录获取新 JWT
|
||||
- [ ] 前端打开 itops 插件 → 设置页面可见 4 个配置项
|
||||
- [ ] 创建工单 → 选客户后 contract_id 自动过滤
|
||||
- [ ] 工单状态为 open 时,resolution/resolved_at/closed_at 不显示
|
||||
- [ ] 工单状态改为 resolved → resolution 和 resolved_at 出现
|
||||
- [ ] 巡检记录结果为 normal → issues_found/actions_taken 不显示
|
||||
- [ ] 巡检记录结果改为 abnormal → issues_found/actions_taken 出现
|
||||
- [ ] 触发事件:创建工单 → 消息中心收到通知
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: freelance Layer 3 — PDF 模板
|
||||
|
||||
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
|
||||
|
||||
### Task 3.1: 新增 `[[templates]]` 段落(3 个模板)
|
||||
|
||||
在 `[[ui.pages]]` 之前插入报价单、发票、合同 3 个 PDF 模板。
|
||||
|
||||
**报价单模板** (`quote_pdf`):
|
||||
- entity = "quote"
|
||||
- 包含 Handlebars 语法:`{{quote_number}}`, `{{client.name}}`, `{{#each lines}}`
|
||||
- 表格渲染:item_name / description / quantity / unit_price / amount
|
||||
- 底部:subtotal / tax_rate / total_amount
|
||||
|
||||
**发票模板** (`invoice_pdf`):
|
||||
- entity = "invoice"
|
||||
- grid 布局:client.name / type / issue_date / due_date
|
||||
- 大字金额:`¥{{amount}}`
|
||||
- 状态 badge
|
||||
|
||||
**合同模板** (`contract_pdf`):
|
||||
- entity = "contract"
|
||||
- 签章区域:甲方/乙方
|
||||
- parties 区块:client.name / amount / paid_amount / 期限 / payment_terms
|
||||
|
||||
### Task 3.2: 编译 WASM + 升级
|
||||
|
||||
同 Task 1.6 流程。
|
||||
|
||||
### Task 3.3: 验证
|
||||
|
||||
- [ ] 前端打开报价单详情 → 可见"生成 PDF"按钮
|
||||
- [ ] 点击生成 → 下载 PDF,内容包含正确的字段值
|
||||
- [ ] 发票 PDF → 金额/客户名正确
|
||||
- [ ] 合同 PDF → 签章区域正确
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: itops Layer 3 — 维保合同 PDF 模板
|
||||
|
||||
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
|
||||
|
||||
### Task 4.1: 新增 `[[templates]]` 段落(1 个模板)
|
||||
|
||||
维保合同模板 (`service_contract_pdf`):
|
||||
- entity = "service_contract"
|
||||
- SLA 承诺框:响应/解决时间
|
||||
- grid 布局:client.name / amount / 期限 / status
|
||||
- 服务范围 / 付款条款 / 签章区
|
||||
|
||||
### Task 4.2: 编译 WASM + 升级
|
||||
|
||||
同 Task 2.6 流程。
|
||||
|
||||
### Task 4.3: 验证
|
||||
|
||||
- [ ] 前端打开维保合同详情 → 可见"生成 PDF"按钮
|
||||
- [ ] 点击生成 → 下载 PDF,SLA 承诺正确
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 平台 dashboard widgets 扩展
|
||||
|
||||
> **注意:** 此阶段需要修改平台 Rust 代码 + 前端代码,不是纯 plugin.toml 改动。
|
||||
|
||||
### Task 5.1: 扩展 manifest.rs — 定义 PluginWidget 类型
|
||||
|
||||
**目标文件:** `crates/erp-plugin/src/manifest.rs`
|
||||
|
||||
在 `PluginPageType::Dashboard` 结构体中新增 `widgets` 字段:
|
||||
|
||||
```rust
|
||||
// PluginPageType::Dashboard 新增字段
|
||||
Dashboard {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default)]
|
||||
widgets: Option<Vec<PluginWidget>>, // 新增
|
||||
},
|
||||
```
|
||||
|
||||
定义 `PluginWidget` 枚举及其子类型:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum PluginWidget {
|
||||
StatCards {
|
||||
label: String,
|
||||
cards: Vec<StatCard>,
|
||||
},
|
||||
ActionList {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
max_items: Option<u32>,
|
||||
queries: Vec<ActionQuery>,
|
||||
},
|
||||
Funnel {
|
||||
label: String,
|
||||
entity: String,
|
||||
lane_field: String,
|
||||
#[serde(default)]
|
||||
value_field: Option<String>,
|
||||
lane_order: Vec<String>,
|
||||
},
|
||||
CardList {
|
||||
label: String,
|
||||
entity: String,
|
||||
#[serde(default)]
|
||||
filter: Option<String>,
|
||||
#[serde(default)]
|
||||
max_items: Option<u32>,
|
||||
title_field: String,
|
||||
#[serde(default)]
|
||||
subtitle_field: Option<String>,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatCard {
|
||||
pub entity: String,
|
||||
#[serde(default)]
|
||||
pub aggregate: Option<String>, // count, sum
|
||||
#[serde(default)]
|
||||
pub field: Option<String>,
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionQuery {
|
||||
pub entity: String,
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sort: Option<String>,
|
||||
pub label_field: String,
|
||||
#[serde(default)]
|
||||
pub subtitle_field: Option<String>,
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### Task 5.2: 扩展插件 API — 返回 widgets 数据
|
||||
|
||||
**目标文件:** `crates/erp-plugin/src/module.rs`
|
||||
|
||||
新增 API 端点,为 dashboard widgets 提供数据:
|
||||
|
||||
- `GET /api/v1/plugins/{plugin_id}/dashboard/widgets` — 返回 widgets 定义
|
||||
- `GET /api/v1/plugins/{plugin_id}/dashboard/data` — 返回 widgets 聚合数据(调用已有 count/aggregate API)
|
||||
|
||||
### Task 5.3: 前端渲染 dashboard widgets
|
||||
|
||||
**目标目录:** `apps/web/src/`
|
||||
|
||||
新增组件:
|
||||
- `PluginDashboard.tsx` — 仪表盘容器,读取 widgets 定义并渲染
|
||||
- `StatCardsWidget.tsx` — 统计卡片组件(4 个指标卡片)
|
||||
- `ActionListWidget.tsx` — 待办列表组件
|
||||
- `FunnelWidget.tsx` — 漏斗图组件
|
||||
- `CardListWidget.tsx` — 卡片列表组件
|
||||
|
||||
### Task 5.4: 验证
|
||||
|
||||
- [ ] `cargo check` 通过
|
||||
- [ ] 前端 `pnpm build` 通过
|
||||
- [ ] manifest.rs 正确解析 widgets TOML
|
||||
- [ ] API 返回 widgets 定义和聚合数据
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: freelance + itops Layer 2 — 仪表盘 widgets
|
||||
|
||||
> **前置:** Phase 5 完成(平台支持 widgets)
|
||||
|
||||
### Task 6.1: freelance — 替换仪表盘页面为 widgets 版本
|
||||
|
||||
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
|
||||
|
||||
将现有的空仪表盘(第 949-952 行)替换为包含 4 个 widgets 的完整仪表盘:
|
||||
1. stat_cards — 财务概览(4 张卡片)
|
||||
2. action_list — 紧急待办(4 种查询)
|
||||
3. funnel — 商机漏斗
|
||||
4. card_list — 活跃项目
|
||||
|
||||
### Task 6.2: itops — 新增仪表盘页面到最前面
|
||||
|
||||
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
|
||||
|
||||
在现有页面列表最前面插入仪表盘页面(2 个 widgets):
|
||||
1. stat_cards — 运维概览(4 张卡片)
|
||||
2. action_list — 紧急待办(3 种查询)
|
||||
|
||||
### Task 6.3: 两个插件各自编译 WASM + 升级
|
||||
|
||||
### Task 6.4: 验证
|
||||
|
||||
- [ ] freelance 仪表盘 → 4 个 widget 正确渲染
|
||||
- [ ] itops 仪表盘 → 2 个 widget 正确渲染
|
||||
- [ ] 财务卡片数值正确(调用 aggregate API)
|
||||
- [ ] 紧急待办列表有数据时显示条目
|
||||
- [ ] 商机漏斗按阶段显示金额分布
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## 执行策略
|
||||
|
||||
**P1-P4 并行策略:** P1 和 P2 可以同时开始(不同文件),P3 和 P4 在 P1/P2 完成后立即跟进。每个 Phase 独立编译 WASM、独立验证、独立提交。
|
||||
|
||||
**P5-P6 顺序策略:** P5 是平台改动(Rust + 前端),P6 依赖 P5 的平台能力才能生效。
|
||||
|
||||
**预估工作量:**
|
||||
- P1: 30-40 分钟(plugin.toml 编辑 + 编译 + 验证)
|
||||
- P2: 20-30 分钟(规模小于 P1)
|
||||
- P3: 15-20 分钟(3 个模板插入)
|
||||
- P4: 10-15 分钟(1 个模板插入)
|
||||
- P5: 60-90 分钟(manifest 扩展 + API + 前端组件)
|
||||
- P6: 20-30 分钟(plugin.toml widgets 声明)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,806 +0,0 @@
|
||||
# HMS 健康模块业务改进实施计划
|
||||
|
||||
> 日期: 2026-04-25
|
||||
> 设计规格: [2026-04-25-health-module-business-analysis-design.md](../specs/2026-04-25-health-module-business-analysis-design.md)
|
||||
> 总计: 4 Phase / 28 改进项 / 12-17 人天 + 路线图
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 产品可信度修复 (P0)
|
||||
|
||||
> 预计 2-3 人天 | 影响: 管理者无法决策、患者安全风险、跨模块联动断裂
|
||||
|
||||
### 1.1 修复 Dashboard 统计数据
|
||||
|
||||
**问题**: `StatisticsDashboard.tsx` 调用的 3 个非积分统计 API 全部是伪实现(list 接口取 total + 硬编码 0)。
|
||||
|
||||
**后端改动**:
|
||||
|
||||
文件: `crates/erp-health/src/handler/health_data_handler.rs` (或新建 `stats_handler.rs`)
|
||||
|
||||
新增 3 个统计端点:
|
||||
|
||||
```
|
||||
GET /api/v1/health/admin/statistics/patients
|
||||
→ { total, new_this_month, new_this_week, active_this_month }
|
||||
|
||||
GET /api/v1/health/admin/statistics/consultations
|
||||
→ { total_sessions, pending_reply, avg_response_time_minutes, this_month }
|
||||
|
||||
GET /api/v1/health/admin/statistics/follow-ups
|
||||
→ { total_tasks, completed, pending, overdue, completion_rate }
|
||||
```
|
||||
|
||||
SQL 聚合实现 (在 `health_data_service.rs` 或新建 `stats_service.rs`):
|
||||
- `new_this_month`: `SELECT COUNT(*) FROM patient WHERE created_at >= date_trunc('month', NOW()) AND tenant_id = $1`
|
||||
- `active_this_month`: `SELECT COUNT(DISTINCT patient_id) FROM points_transaction WHERE created_at >= date_trunc('month', NOW()) AND tenant_id = $1`
|
||||
- `completion_rate`: `(completed::float / NULLIF(completed + pending + overdue, 0)) * 100`
|
||||
- `avg_response_time_minutes`: `AVG(EXTRACT(EPOCH FROM (first_message.created_at - session.created_at)) / 60)`
|
||||
- `overdue`: `SELECT COUNT(*) FROM follow_up_task WHERE status = 'overdue' AND tenant_id = $1`
|
||||
|
||||
**前端改动**:
|
||||
|
||||
文件: `apps/web/src/api/health/points.ts`
|
||||
- 修改 `getPatientStats()`/`getConsultationStats()`/`getFollowUpStats()` 调用新端点
|
||||
- 移除 `list?page_size=1` 的伪实现
|
||||
|
||||
**DTO 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/dto/` 新建 `stats_dto.rs`
|
||||
- `PatientStatisticsResp`, `ConsultationStatisticsResp`, `FollowUpStatisticsResp`
|
||||
|
||||
**验证**:
|
||||
- Dashboard 四组统计卡片均显示真实数据
|
||||
- 切换租户后数据隔离正确
|
||||
|
||||
---
|
||||
|
||||
### 1.2 补全事件发布
|
||||
|
||||
**问题**: `event.rs` 只消费事件不发布。`check_overdue_tasks` 标记逾期但不发事件通知。
|
||||
|
||||
**改动**:
|
||||
|
||||
文件: `crates/erp-health/src/event.rs`
|
||||
- 定义事件常量: `const FOLLOW_UP_OVERDUE: &str = "follow_up.overdue";`
|
||||
|
||||
文件: `crates/erp-health/src/service/follow_up_service.rs`
|
||||
- 在 `check_overdue_tasks` 函数末尾,对每个被标记为 overdue 的任务发布事件
|
||||
- 事件 payload: `{ task_id, patient_id, assigned_to, planned_date, tenant_id }`
|
||||
|
||||
文件: `crates/erp-health/src/module.rs`
|
||||
- 确保 `HealthState` 持有 `EventBus` 的 `Arc` 引用
|
||||
- 将 `EventBus` 传递给 `follow_up_service::check_overdue_tasks`
|
||||
|
||||
**验证**:
|
||||
- 创建 `planned_date = 昨天` 的 pending 任务
|
||||
- 运行逾期检查后确认事件已发布到 `domain_events` 表
|
||||
|
||||
---
|
||||
|
||||
### 1.3 合并 vital_signs 和 daily_monitoring
|
||||
|
||||
**问题**: 两张表 91% 字段重叠,命名不一致(`systolic_bp_morning` vs `morning_bp_systolic`),`trend_service.rs` 只查 `vital_signs` 忽略 `daily_monitoring`。
|
||||
|
||||
**策略**: 保留 `vital_signs` 作为主表,将 `daily_monitoring` 的独有字段迁移过来,然后废弃 `daily_monitoring`。
|
||||
|
||||
**迁移文件**: `crates/erp-server/migration/src/m20260425_000001_merge_vital_signs.rs`
|
||||
|
||||
```sql
|
||||
-- 1. 给 vital_signs 添加 daily_monitoring 的独有字段
|
||||
ALTER TABLE vital_signs ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'manual';
|
||||
-- source: 'manual' | 'device' | 'daily_monitoring'
|
||||
|
||||
-- 2. 迁移 daily_monitoring 数据到 vital_signs
|
||||
INSERT INTO vital_signs (
|
||||
id, tenant_id, patient_id, record_date,
|
||||
systolic_bp_morning, diastolic_bp_morning,
|
||||
systolic_bp_evening, diastolic_bp_evening,
|
||||
heart_rate, weight, blood_sugar,
|
||||
water_intake_ml, urine_output_ml, notes,
|
||||
source, created_at, updated_at, created_by, updated_by, version
|
||||
)
|
||||
SELECT
|
||||
id, tenant_id, patient_id, record_date,
|
||||
morning_bp_systolic, morning_bp_diastolic,
|
||||
evening_bp_systolic, evening_bp_diastolic,
|
||||
NULL, weight, blood_sugar,
|
||||
fluid_intake, urine_output, notes,
|
||||
'daily_monitoring', created_at, updated_at, created_by, updated_by, 1
|
||||
FROM daily_monitoring
|
||||
WHERE deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
```
|
||||
|
||||
**Entity 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/entity/vital_signs.rs`
|
||||
- 添加 `source` 字段 (String, 默认 "manual")
|
||||
|
||||
**Service 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/service/trend_service.rs`
|
||||
- `generate_trend` 和 `get_mini_today` 无需改动(已在查 `vital_signs`,合并后数据自然包含)
|
||||
|
||||
文件: `crates/erp-health/src/service/daily_monitoring_service.rs`
|
||||
- 改为委托 `health_data_service::create_vital_signs`,设置 `source = "daily_monitoring"`
|
||||
- 标记为 `#[deprecated]`,保留接口兼容
|
||||
|
||||
**DTO 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/dto/health_data_dto.rs`
|
||||
- `CreateVitalSignsReq` 添加 `source: Option<String>`
|
||||
|
||||
**前端改动**:
|
||||
|
||||
文件: `apps/web/src/api/health/healthData.ts`
|
||||
- 无需改动(前端已统一走 vital_signs 接口)
|
||||
|
||||
**验证**:
|
||||
- `cargo test --workspace` 通过
|
||||
- 原有 `daily_monitoring` 数据可在 `vital_signs` 查询中看到
|
||||
- 小程序 `get_mini_today` 返回合并后的数据
|
||||
|
||||
---
|
||||
|
||||
### 1.4 增加实时异常预警
|
||||
|
||||
**问题**: 体征录入时无自动异常检测。血压 180/110 等危急值不会触发报警。
|
||||
|
||||
**策略**: 在 `create_vital_signs` 和 `create_lab_report` 中增加异常检测,发布预警事件。
|
||||
|
||||
**后端改动**:
|
||||
|
||||
文件: `crates/erp-health/src/service/health_data_service.rs`
|
||||
- 新增 `check_vital_signs_alert(patient_id, data, tenant_id, event_bus)` 函数
|
||||
- 危急值阈值:
|
||||
- 收缩压 ≥ 180 或 ≤ 80
|
||||
- 舒张压 ≥ 110 或 ≤ 50
|
||||
- 心率 ≥ 150 或 ≤ 40
|
||||
- 血糖 ≥ 25 或 ≤ 2.5
|
||||
- 检测到危急值时发布 `health_data.critical_alert` 事件
|
||||
- 事件 payload: `{ patient_id, indicator, value, threshold, level: "critical", tenant_id }`
|
||||
- 在 `create_vital_signs` 末尾调用 `check_vital_signs_alert`
|
||||
|
||||
文件: `crates/erp-health/src/event.rs`
|
||||
- 添加 `health_data.critical_alert` 事件常量
|
||||
- 订阅此事件,调用 `erp-message` 发送站内通知给负责医护
|
||||
|
||||
**前端改动** (P1 延后):
|
||||
- 本次仅后端发布事件,前端告警 UI 放入 Phase 2
|
||||
|
||||
**验证**:
|
||||
- 创建收缩压 = 185 的体征记录
|
||||
- 确认 `domain_events` 表中出现 `health_data.critical_alert` 事件
|
||||
|
||||
---
|
||||
|
||||
### 1.5 增加 ICD-10 诊断编码支持
|
||||
|
||||
**问题**: 系统无结构化诊断,随访/趋势分析缺乏医学语义锚点。
|
||||
|
||||
**新建实体**: `diagnosis`
|
||||
|
||||
文件: `crates/erp-health/src/entity/diagnosis.rs`
|
||||
```rust
|
||||
// 关键字段:
|
||||
// id: Uuid (PK)
|
||||
// tenant_id: Uuid
|
||||
// patient_id: Uuid (FK -> patient)
|
||||
// health_record_id: Option<Uuid> (FK -> health_record)
|
||||
// icd_code: String (如 "I10" 高血压、"E11.9" 2型糖尿病)
|
||||
// diagnosis_name: String (中文诊断名)
|
||||
// diagnosis_type: String (primary/secondary/comorbid)
|
||||
// diagnosed_date: Date
|
||||
// status: String (active/resolved/chronic)
|
||||
// diagnosed_by: Option<Uuid> (医生 ID)
|
||||
// notes: Option<String>
|
||||
// + 标准字段 (created_at, updated_at, version, ...)
|
||||
```
|
||||
|
||||
**迁移文件**: `crates/erp-server/migration/src/m20260425_000002_diagnosis.rs`
|
||||
|
||||
**Service 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/service/` 新建 `diagnosis_service.rs`
|
||||
- CRUD: `create_diagnosis`, `list_diagnoses`, `update_diagnosis`, `delete_diagnosis`
|
||||
|
||||
**Handler 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/handler/` 新建 `diagnosis_handler.rs`
|
||||
- 端点:
|
||||
- `POST /api/v1/health/patients/{id}/diagnoses`
|
||||
- `GET /api/v1/health/patients/{id}/diagnoses`
|
||||
- `PUT /api/v1/health/diagnoses/{id}`
|
||||
- `DELETE /api/v1/health/diagnoses/{id}`
|
||||
|
||||
**DTO 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/dto/` 新建 `diagnosis_dto.rs`
|
||||
- `CreateDiagnosisReq`, `UpdateDiagnosisReq`, `DiagnosisResp`
|
||||
|
||||
**注册路由**:
|
||||
|
||||
文件: `crates/erp-health/src/module.rs`
|
||||
- 在 `protected_routes` 中注册诊断端点
|
||||
|
||||
**前端改动** (P1 延后):
|
||||
- 本次仅后端,患者详情页诊断 Tab 放入 Phase 2
|
||||
|
||||
**验证**:
|
||||
- `cargo check` 通过
|
||||
- `POST /patients/{id}/diagnoses` 创建诊断成功
|
||||
- 诊断列表按 `tenant_id` 正确过滤
|
||||
|
||||
---
|
||||
|
||||
### 1.6 实现积分过期清理定时任务
|
||||
|
||||
**问题**: `points_transaction.expires_at` 写入后无定时检查,`total_expired` 永远为 0。
|
||||
|
||||
**后端改动**:
|
||||
|
||||
文件: `crates/erp-health/src/service/points_service.rs`
|
||||
- 新增 `expire_points(state: &HealthState) -> AppResult<u64>` 函数
|
||||
- 查找所有 `expires_at < NOW() AND type = 'earn' AND expires_at IS NOT NULL` 的未处理交易
|
||||
- 计算过期积分总额
|
||||
- 扣减对应积分账户余额 (CAS with version)
|
||||
- 发布 `points.expired` 事件
|
||||
|
||||
文件: `crates/erp-health/src/module.rs`
|
||||
- 在 `on_startup` 中新增定时任务 `start_points_expiration_checker`
|
||||
- 每天凌晨 2:00 执行一次 (或使用 `tokio::time::interval(Duration::from_secs(86400))`)
|
||||
- 类似 `start_overdue_checker` 的实现模式
|
||||
|
||||
**验证**:
|
||||
- 创建 `expires_at = 昨天` 的 earn 交易
|
||||
- 运行过期清理后确认余额已扣减、`total_expired` 已更新
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 执行顺序
|
||||
|
||||
```
|
||||
1.1 Dashboard 统计 ──→ 1.6 积分过期 (独立,可并行)
|
||||
1.2 事件发布 ──→ 1.4 异常预警 (依赖 EventBus 传递)
|
||||
1.3 合并体征表 (独立)
|
||||
1.5 诊断编码 (独立)
|
||||
```
|
||||
|
||||
建议并行组:
|
||||
- 组 A: 1.1 + 1.6 (统计/定时任务,无依赖)
|
||||
- 组 B: 1.2 + 1.4 (事件链路)
|
||||
- 组 C: 1.3 (数据迁移,需谨慎)
|
||||
- 组 D: 1.5 (新实体,无依赖)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 核心业务能力补全 (P1)
|
||||
|
||||
> 预计 5-7 人天 | 影响: 临床实用性不足、患者参与度低、运营效率差
|
||||
|
||||
### 2.1 结构化随访模板系统
|
||||
|
||||
**问题**: `content_template` 是纯文本,`result`/`medical_advice` 也是自由文本,无法做统计分析。
|
||||
|
||||
**新建实体**: `follow_up_template` + `follow_up_template_field`
|
||||
|
||||
```
|
||||
follow_up_template:
|
||||
id, tenant_id, name, description, disease_type (关联 ICD),
|
||||
target_audience, frequency_days, field_count,
|
||||
+ 标准字段
|
||||
|
||||
follow_up_template_field:
|
||||
id, tenant_id, template_id, field_key, field_label,
|
||||
field_type (text/number/select/multiselect/scale),
|
||||
required, sort_order, options (JSONB, 用于 select 类型的选项),
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**Service**: 新建 `follow_up_template_service.rs`
|
||||
- `create_template`, `list_templates`, `get_template`, `update_template`, `delete_template`
|
||||
|
||||
**前端**: 新建 `FollowUpTemplateList.tsx` 页面
|
||||
- 模板 CRUD + 字段拖拽排序 + 预览
|
||||
|
||||
**关联改动**:
|
||||
- `follow_up_task` 添加 `template_id: Option<Uuid>` 字段
|
||||
- `follow_up_record` 添加 `structured_data: Option<Json>` 字段(JSONB 存储表单填写结果)
|
||||
|
||||
---
|
||||
|
||||
### 2.2 用药记录实体
|
||||
|
||||
**问题**: 小程序有 `profile/medication` 页面但后端无对应实体。
|
||||
|
||||
**新建实体**: `medication_record`
|
||||
|
||||
```
|
||||
medication_record:
|
||||
id, tenant_id, patient_id,
|
||||
medication_name, generic_name, dosage, unit,
|
||||
frequency (daily/bid/tid/qid/prn),
|
||||
route (oral/injection/topical/inhalation),
|
||||
start_date, end_date, is_current,
|
||||
prescribed_by (doctor_id), notes,
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**迁移**: `m20260425_000003_medication_record.rs`
|
||||
|
||||
**端点**:
|
||||
- `POST/GET /api/v1/health/patients/{id}/medications`
|
||||
- `PUT/DELETE /api/v1/health/medications/{id}`
|
||||
|
||||
**小程序改动**:
|
||||
- 对接 `profile/medication` 页面到新 API
|
||||
|
||||
---
|
||||
|
||||
### 2.3 透析方案管理
|
||||
|
||||
**问题**: 透析无方案管理,每次需重新输入相同参数。
|
||||
|
||||
**新建实体**: `dialysis_prescription`
|
||||
|
||||
```
|
||||
dialysis_prescription:
|
||||
id, tenant_id, patient_id,
|
||||
dialyzer_model, membrane_area,
|
||||
dialysate_potassium, dialysate_calcium, dialysate_bicarbonate,
|
||||
anticoagulation_type (heparin/lmwh/heparin_free),
|
||||
anticoagulation_dose,
|
||||
target_ultrafiltration_ml, target_dry_weight,
|
||||
blood_flow_rate, dialysate_flow_rate,
|
||||
frequency_per_week, duration_minutes,
|
||||
vascular_access_type (avf/avg/cvc),
|
||||
vascular_access_location,
|
||||
effective_from, effective_to, status (active/discontinued),
|
||||
prescribed_by, notes,
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**Service**: 新建 `dialysis_prescription_service.rs`
|
||||
|
||||
**端点**:
|
||||
- `POST/GET /api/v1/health/patients/{id}/dialysis-prescriptions`
|
||||
- `PUT/DELETE /api/v1/health/dialysis-prescriptions/{id}`
|
||||
- `GET /api/v1/health/patients/{id}/dialysis-prescriptions/current` (获取当前有效方案)
|
||||
|
||||
**关联改动**:
|
||||
- `dialysis_record` 添加 `prescription_id: Option<Uuid>` 字段
|
||||
- 创建透析记录时可选继承方案参数
|
||||
|
||||
---
|
||||
|
||||
### 2.4 体征增加体温/SpO2/血糖类型
|
||||
|
||||
**问题**: `vital_signs` 缺体温和血氧,血糖无类型标记。
|
||||
|
||||
**迁移**: `m20260425_000004_vital_signs_fields.rs`
|
||||
|
||||
```sql
|
||||
ALTER TABLE vital_signs ADD COLUMN IF NOT EXISTS body_temperature DECIMAL(4,1);
|
||||
ALTER TABLE vital_signs ADD COLUMN IF NOT EXISTS spo2 INTEGER;
|
||||
ALTER TABLE vital_signs ADD COLUMN IF NOT EXISTS blood_sugar_type VARCHAR(20) DEFAULT 'fasting';
|
||||
-- blood_sugar_type: fasting / postprandial / random / ogtt
|
||||
```
|
||||
|
||||
**Entity/DTO 改动**:
|
||||
- `vital_signs.rs`: 添加 `body_temperature`, `spo2`, `blood_sugar_type` 字段
|
||||
- `CreateVitalSignsReq`: 添加对应字段
|
||||
|
||||
**趋势分析改动**:
|
||||
- `trend_service.rs`: `generate_trend` 增加体温/SpO2 异常检测
|
||||
- 体温: < 35.0 或 > 38.5
|
||||
- SpO2: < 90%
|
||||
- 血糖: 根据类型使用不同阈值
|
||||
|
||||
**前端改动**:
|
||||
- `VitalSignsTab.tsx` 和小程序体征录入页添加新字段
|
||||
|
||||
---
|
||||
|
||||
### 2.5 消息推送集成
|
||||
|
||||
**问题**: `erp-message` 存在但 `erp-health` 不利用。
|
||||
|
||||
**策略**: 在关键业务节点发布事件,`erp-message` 订阅后发送站内通知。
|
||||
|
||||
**触发场景**:
|
||||
|
||||
| 事件 | 触发条件 | 通知对象 |
|
||||
|------|---------|---------|
|
||||
| `follow_up.due_reminder` | 随访任务到期前 1 天 | assigned_to 医护 |
|
||||
| `appointment.reminder` | 预约前 1 天 | 患者小程序 |
|
||||
| `health_data.critical_alert` | 危急值 | 负责医生 |
|
||||
| `points.expiring_soon` | 积分 7 天内过期 | 患者小程序 |
|
||||
| `lab_report.reviewed` | 化验报告审阅完成 | 患者小程序 |
|
||||
|
||||
**改动**:
|
||||
|
||||
文件: `crates/erp-health/src/module.rs`
|
||||
- 新增定时任务 `start_due_reminder_checker` (每天 8:00 执行)
|
||||
- 查询明天到期的随访任务,发布 `follow_up.due_reminder` 事件
|
||||
|
||||
文件: `crates/erp-health/src/event.rs`
|
||||
- 添加所有新事件常量和 payload 定义
|
||||
|
||||
**注意**: `erp-message` 侧的事件订阅和通知模板创建需同步进行。
|
||||
|
||||
---
|
||||
|
||||
### 2.6 批量随访操作
|
||||
|
||||
**问题**: 不支持批量创建/分配/完成,护士每天 30-50 条逐个操作效率极低。
|
||||
|
||||
**新增端点**:
|
||||
|
||||
```
|
||||
POST /api/v1/health/follow-up-tasks/batch
|
||||
Body: { patient_ids: [uuid], template_id?, assigned_to, planned_date, follow_up_type }
|
||||
→ 为多个患者批量创建随访任务
|
||||
|
||||
PUT /api/v1/health/follow-up-tasks/batch-assign
|
||||
Body: { task_ids: [uuid], assigned_to }
|
||||
→ 批量分配负责人
|
||||
|
||||
PUT /api/v1/health/follow-up-tasks/batch-complete
|
||||
Body: { task_ids: [uuid], result, patient_condition }
|
||||
→ 批量标记完成
|
||||
```
|
||||
|
||||
**Service 改动**:
|
||||
- `follow_up_service.rs` 新增 `batch_create_tasks`, `batch_assign`, `batch_complete`
|
||||
- 使用事务包裹批量操作
|
||||
|
||||
**前端改动**:
|
||||
- `FollowUpTaskList.tsx` 添加多选 + 批量操作工具栏
|
||||
|
||||
---
|
||||
|
||||
### 2.7 修复随访类型前后端不一致
|
||||
|
||||
**问题**: 后端 `phone`/`face_to_face`/`online` vs 前端 `phone`/`outpatient`/`home_visit`/`wechat`。
|
||||
|
||||
**策略**: 统一为 5 种类型:
|
||||
|
||||
```rust
|
||||
// validation.rs
|
||||
pub fn validate_follow_up_type(t: &str) -> bool {
|
||||
matches!(t, "phone" | "outpatient" | "home_visit" | "online" | "wechat")
|
||||
}
|
||||
```
|
||||
|
||||
**改动**:
|
||||
- `crates/erp-health/src/service/validation.rs`: 更新验证函数
|
||||
- `apps/web/src/pages/health/FollowUpTaskList.tsx`: 更新类型选项
|
||||
- 数据迁移: 将 `face_to_face` 更新为 `outpatient`
|
||||
|
||||
---
|
||||
|
||||
### 2.8 咨询 WebSocket 实时推送 (高复杂度)
|
||||
|
||||
**问题**: 咨询消息只有 HTTP API,无实时推送。
|
||||
|
||||
**策略**: 使用 Axum WebSocket 升级。
|
||||
|
||||
**后端改动**:
|
||||
|
||||
文件: `crates/erp-health/src/handler/consultation_handler.rs`
|
||||
- 新增 `ws_handler` 端点
|
||||
- `GET /api/v1/health/consultation/ws` → WebSocket 升级
|
||||
- 连接时验证 JWT,订阅 `consultation.{session_id}` channel
|
||||
|
||||
文件: `crates/erp-health/src/service/consultation_service.rs`
|
||||
- `create_message` 时向 channel 广播消息
|
||||
- 使用 `tokio::sync::broadcast` channel
|
||||
|
||||
**前端改动**:
|
||||
- 新建 `useConsultationWebSocket` hook
|
||||
- `ConsultationDetail.tsx` 集成 WebSocket
|
||||
|
||||
**注意**: 此项复杂度高,可拆分为独立迭代。
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 执行顺序
|
||||
|
||||
```
|
||||
2.7 随访类型修复 (快速修复,优先)
|
||||
↓
|
||||
2.4 体征字段扩展 (低复杂度)
|
||||
↓
|
||||
2.2 用药记录 + 2.3 透析方案 (新实体,可并行)
|
||||
↓
|
||||
2.6 批量随访 (依赖类型修复完成)
|
||||
↓
|
||||
2.1 随访模板 (依赖用药记录和类型修复)
|
||||
↓
|
||||
2.5 消息推送 (依赖 Phase 1 事件发布)
|
||||
↓
|
||||
2.8 WebSocket (独立迭代,最高复杂度)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 运营增强 (P2)
|
||||
|
||||
> 预计 5-7 人天 | 影响: 差异化竞争力、患者留存、商业变现
|
||||
|
||||
### 3.1 患者健康评分体系 (Health Score)
|
||||
|
||||
**新建实体**: `health_score`
|
||||
|
||||
```
|
||||
health_score:
|
||||
id, tenant_id, patient_id,
|
||||
total_score (0-100),
|
||||
dimensions: JSONB {
|
||||
vital_signs: 0-25, // 体征数据完整度 + 达标率
|
||||
follow_up: 0-25, // 随访依从性
|
||||
checkup: 0-25, // 体检按时完成率
|
||||
engagement: 0-25 // 平台活跃度(签到/咨询/数据上报)
|
||||
},
|
||||
computed_at, next_compute_at,
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**计算逻辑**: 定时任务每周重算,基于最近 90 天数据:
|
||||
- 体征: `按时录入天数 / 90 * 25`,达标率加权
|
||||
- 随访: `已完成随访 / 应完成随访 * 25`
|
||||
- 体检: `年度体检是否完成 * 25`
|
||||
- 活跃度: `(签到天数 + 数据上报次数 + 咨询次数) / 目标值 * 25`
|
||||
|
||||
**端点**: `GET /api/v1/health/patients/{id}/health-score`
|
||||
|
||||
**前端**: 患者详情页新增 Health Score 卡片 + 趋势图
|
||||
|
||||
---
|
||||
|
||||
### 3.2 会员等级和营销工具
|
||||
|
||||
**新建实体**: `membership_level` + `coupon`
|
||||
|
||||
```
|
||||
membership_level:
|
||||
id, tenant_id, name, level (1-5),
|
||||
min_score, max_score,
|
||||
benefits: JSONB { discount_rate, priority_booking, exclusive_products },
|
||||
+ 标准字段
|
||||
|
||||
coupon:
|
||||
id, tenant_id, code, type (percentage/fixed/free_shipping),
|
||||
value, min_order_amount,
|
||||
valid_from, valid_to, max_uses, used_count,
|
||||
applicable_products: Option<Json>, scope (all/specific),
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**Service**: 新建 `membership_service.rs` + `coupon_service.rs`
|
||||
|
||||
**端点**:
|
||||
- `GET /api/v1/health/patients/{id}/membership`
|
||||
- `POST/GET /api/v1/health/admin/coupons`
|
||||
- `POST /api/v1/health/coupons/{code}/redeem`
|
||||
|
||||
---
|
||||
|
||||
### 3.3 预约资源绑定
|
||||
|
||||
**新建实体**: `resource` + `resource_schedule`
|
||||
|
||||
```
|
||||
resource:
|
||||
id, tenant_id, name, type (dialysis_machine/exam_room/bed),
|
||||
location, capacity, status (available/maintenance/disabled),
|
||||
+ 标准字段
|
||||
|
||||
resource_schedule:
|
||||
id, tenant_id, resource_id, schedule_date,
|
||||
period_type (am/pm/full_day), max_appointments,
|
||||
current_appointments,
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**关联改动**:
|
||||
- `appointment` 添加 `resource_id: Option<Uuid>`
|
||||
- 预约创建时 CAS 检查资源可用性(类似医生排班的 CAS 逻辑)
|
||||
|
||||
---
|
||||
|
||||
### 3.4 个性化异常阈值配置
|
||||
|
||||
**新建实体**: `patient_threshold_config`
|
||||
|
||||
```
|
||||
patient_threshold_config:
|
||||
id, tenant_id, patient_id,
|
||||
indicator (heart_rate/blood_sugar/systolic_bp/...),
|
||||
low_threshold, high_threshold,
|
||||
alert_level (warning/critical),
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**改动**:
|
||||
- `trend_service.rs`: `compute_status` 优先查 `patient_threshold_config`,无则用默认阈值
|
||||
- `health_data_service.rs`: 危急值检测同理
|
||||
|
||||
---
|
||||
|
||||
### 3.5 化验指标标准化字典 (LOINC)
|
||||
|
||||
**新建实体**: `lab_indicator_dict`
|
||||
|
||||
```
|
||||
lab_indicator_dict:
|
||||
id, tenant_id,
|
||||
loinc_code: Option<String>,
|
||||
name_cn, name_en,
|
||||
category (blood/urine/biochemistry/...),
|
||||
default_unit, reference_low, reference_high,
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**改动**:
|
||||
- `lab_report` items 中的 `name` 关联 `lab_indicator_dict.name_cn`
|
||||
- 趋势分析按 `loinc_code` 聚合,解决"肌酐"/"CREA"不一致问题
|
||||
|
||||
---
|
||||
|
||||
### 3.6 批量排班/排班模板
|
||||
|
||||
**新建实体**: `schedule_template`
|
||||
|
||||
```
|
||||
schedule_template:
|
||||
id, tenant_id, doctor_id, name,
|
||||
periods: JSONB [{ day_of_week, period_type, max_appointments }],
|
||||
effective_from, effective_to,
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**端点**:
|
||||
- `POST /api/v1/health/schedule-templates`
|
||||
- `POST /api/v1/health/schedule-templates/{id}/generate`
|
||||
→ 按模板批量生成指定日期范围的 `doctor_schedule` 记录
|
||||
|
||||
---
|
||||
|
||||
### 3.7 小程序分析埋点后端
|
||||
|
||||
**问题**: `apps/miniprogram/src/services/analytics.ts` 的 `flushEvents` 发到 `/analytics/batch`,后端未实现。
|
||||
|
||||
**新建**: `crates/erp-health/src/handler/analytics_handler.rs`
|
||||
|
||||
```rust
|
||||
POST /api/v1/health/analytics/batch
|
||||
Body: { events: [{ event_type, page, timestamp, properties }] }
|
||||
→ 写入 analytics_events 表
|
||||
```
|
||||
|
||||
**新建实体**: `analytics_event` (轻量表,仅用于行为分析)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 执行顺序
|
||||
|
||||
```
|
||||
3.7 分析埋点后端 (独立,快速)
|
||||
↓
|
||||
3.5 化验指标字典 (Phase 1 趋势分析的增强)
|
||||
↓
|
||||
3.4 个性化阈值 + 3.1 Health Score (可并行)
|
||||
↓
|
||||
3.3 预约资源绑定 (依赖 Phase 1 预约逻辑)
|
||||
↓
|
||||
3.6 批量排班 (依赖 3.3 资源模型)
|
||||
↓
|
||||
3.2 会员等级 (依赖 3.1 Health Score)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 长期竞争力 (P3, 路线图)
|
||||
|
||||
> Q3-Q4 路线图 | 影响: 市场准入、技术领先
|
||||
|
||||
### 4.1 血管通路管理
|
||||
|
||||
新建 `vascular_access` 实体,管理透析患者通路的完整生命周期:
|
||||
通路类型 (AVF/AVG/CVC)、位置、建立日期、定期评估 (血流量/静脉压/再循环率)、并发症记录 (狭窄/血栓/感染)、介入/手术史。
|
||||
|
||||
依赖: Phase 2 透析方案管理
|
||||
|
||||
### 4.2 疾病风险评分模型
|
||||
|
||||
实现临床风险评分:
|
||||
- 心血管: Framingham / ASCVD 10 年风险
|
||||
- 肾病: KDOQI 分期 (基于 eGFR 计算)
|
||||
- 营养: MNA-SF (迷你营养评估)
|
||||
- 糖尿病: 糖尿病风险评分
|
||||
|
||||
定时任务定期重算,风险变化时触发预警。
|
||||
|
||||
依赖: Phase 1 ICD-10 + Phase 3 个性化阈值
|
||||
|
||||
### 4.3 AI 辅助诊断 (erp-ai 集成)
|
||||
|
||||
将 `erp-ai` 模块的 SSE 流式分析能力集成到健康模块:
|
||||
- 化验报告智能解读 (异常指标说明 + 建议)
|
||||
- 趋势分析自然语言描述
|
||||
- 随访记录摘要生成
|
||||
- 健康风险评估建议
|
||||
|
||||
依赖: erp-ai 模块完成 MVP
|
||||
|
||||
### 4.4 可配置表单能力
|
||||
|
||||
通过 JSON Schema 定义自定义采集表单:
|
||||
- 新建 `form_schema` 实体 (名称 + JSON Schema 定义)
|
||||
- 新建 `form_submission` 实体 (关联 patient + schema + JSONB 数据)
|
||||
- 前端: 动态表单渲染引擎
|
||||
- 适用: 不同机构自定义体检表、评估量表、随访表单
|
||||
|
||||
### 4.5 影像管理集成 (DICOM/PACS)
|
||||
|
||||
设计 DICOM proxy 或 WADO-RS 集成:
|
||||
- DICOM 文件上传和元数据提取
|
||||
- 影像预览 (通过 Cornerstone.js 或 OHIF Viewer)
|
||||
- 与 `lab_report` / `health_record` 关联
|
||||
|
||||
### 4.6 合规认证 (等保三级)
|
||||
|
||||
制定等保三级认证路线图:
|
||||
- 安全审计日志完善 (全操作覆盖)
|
||||
- 数据备份与灾难恢复方案
|
||||
- 访问控制增强 (强密码策略、MFA)
|
||||
- 网络安全 (WAF、入侵检测)
|
||||
|
||||
### 4.7 HL7 FHIR R4 数据互操作
|
||||
|
||||
设计 FHIR R4 接口层:
|
||||
- Patient → FHIR Patient
|
||||
- Diagnosis → FHIR Condition
|
||||
- VitalSigns → FHIR Observation
|
||||
- MedicationRecord → FHIR MedicationStatement
|
||||
- LabReport → FHIR DiagnosticReport
|
||||
|
||||
支持与 HIS/LIS/EMR 系统的数据交换。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
### 新增实体汇总
|
||||
|
||||
| Phase | 新增实体 |
|
||||
|-------|---------|
|
||||
| Phase 1 | `diagnosis` |
|
||||
| Phase 2 | `follow_up_template`, `follow_up_template_field`, `medication_record`, `dialysis_prescription` |
|
||||
| Phase 3 | `health_score`, `membership_level`, `coupon`, `resource`, `resource_schedule`, `patient_threshold_config`, `lab_indicator_dict`, `schedule_template`, `analytics_event` |
|
||||
| Phase 4 | `vascular_access`, `form_schema`, `form_submission` |
|
||||
|
||||
### 新增迁移文件汇总
|
||||
|
||||
| 迁移文件 | Phase |
|
||||
|---------|-------|
|
||||
| `m20260425_000001_merge_vital_signs.rs` | P1 |
|
||||
| `m20260425_000002_diagnosis.rs` | P1 |
|
||||
| `m20260425_000003_medication_record.rs` | P2 |
|
||||
| `m20260425_000004_vital_signs_fields.rs` | P2 |
|
||||
| `m20260425_000005_dialysis_prescription.rs` | P2 |
|
||||
| `m20260425_000006_follow_up_template.rs` | P2 |
|
||||
| `m20260425_000007_follow_up_template_field.rs` | P2 |
|
||||
| `m20260425_000008_follow_up_enhancements.rs` | P2 (task.template_id, record.structured_data) |
|
||||
|
||||
### 工作量估算
|
||||
|
||||
| Phase | 人天 | 新增实体 | 新增迁移 | 优先级 |
|
||||
|-------|------|---------|---------|--------|
|
||||
| Phase 1: P0 可信度修复 | 2-3 | 1 | 2 | 立即 |
|
||||
| Phase 2: P1 核心能力 | 5-7 | 4 | 5 | 本迭代 |
|
||||
| Phase 3: P2 运营增强 | 5-7 | 9 | 9 | 下迭代 |
|
||||
| Phase 4: P3 长期竞争力 | 路线图 | 3+ | 3+ | Q3-Q4 |
|
||||
| **合计** | **12-17 + 路线图** | **17+** | **19+** | |
|
||||
@@ -1,657 +0,0 @@
|
||||
# 切片 1: 按钮级权限控制 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现前端按钮级权限控制,让无权限用户看不到操作按钮(hidden 模式)。
|
||||
|
||||
**Architecture:** JWT claims 已包含 `permissions: Vec<String>`(后端登录时写入),前端复用 `client.ts` 的 `decodeJwtPayload` 提取权限码列表,存入 Zustand auth store。新增 `usePermission` hook + `AuthButton` / `AuthGuard` 声明式组件,包裹健康模块 15 个页面的操作按钮。
|
||||
|
||||
**Tech Stack:** React 19 + TypeScript + Zustand 5 + Ant Design 6
|
||||
|
||||
**设计规格:** `docs/superpowers/specs/2026-04-25-feature-completion-design.md` §2
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: 权限基础设施
|
||||
|
||||
### Task 1: 从 JWT 提取 permissions 并存入 auth store
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/stores/auth.ts`
|
||||
|
||||
**背景:** JWT payload 已包含 `permissions` 字段(string 数组)。`client.ts` 已有 `decodeJwtPayload` 函数。auth store 登录时已存 `access_token` 到 localStorage,可从中解码权限。
|
||||
|
||||
- [ ] **Step 1: 在 auth store 中添加 permissions 状态和提取逻辑**
|
||||
|
||||
在 `apps/web/src/stores/auth.ts` 中:
|
||||
|
||||
1. 新增辅助函数(文件顶部,import 之后):
|
||||
|
||||
```typescript
|
||||
function extractPermissions(): string[] {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return [];
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return [];
|
||||
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return Array.isArray(payload.permissions) ? payload.permissions : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. 修改 `restoreInitialState` 返回值,增加 `permissions`:
|
||||
|
||||
```typescript
|
||||
function restoreInitialState(): { user: UserInfo | null; isAuthenticated: boolean; permissions: string[] } {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr) as UserInfo;
|
||||
return { user, isAuthenticated: true, permissions: extractPermissions() };
|
||||
} catch {
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
return { user: null, isAuthenticated: false, permissions: [] };
|
||||
}
|
||||
```
|
||||
|
||||
3. 修改 `AuthState` 接口,增加 `permissions`:
|
||||
|
||||
```typescript
|
||||
interface AuthState {
|
||||
user: UserInfo | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
permissions: string[];
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
loadFromStorage: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
4. 修改 store 创建,初始化 `permissions`,在 login/logout 中同步更新:
|
||||
|
||||
```typescript
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: initial.user,
|
||||
isAuthenticated: initial.isAuthenticated,
|
||||
loading: false,
|
||||
permissions: initial.permissions,
|
||||
|
||||
login: async (username, password) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const resp = await apiLogin({ username, password });
|
||||
localStorage.setItem('access_token', resp.access_token);
|
||||
localStorage.setItem('refresh_token', resp.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(resp.user));
|
||||
set({ user: resp.user, isAuthenticated: true, loading: false, permissions: extractPermissions() });
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await apiLogout();
|
||||
} catch {
|
||||
// Ignore logout API errors
|
||||
}
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
set({ user: null, isAuthenticated: false, permissions: [] });
|
||||
},
|
||||
|
||||
loadFromStorage: () => {
|
||||
const state = restoreInitialState();
|
||||
set({ user: state.user, isAuthenticated: state.isAuthenticated, permissions: state.permissions });
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译通过**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 无类型错误
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/stores/auth.ts
|
||||
git commit -m "feat(web): auth store 添加 permissions 状态,从 JWT 解码提取"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 创建 usePermission hook
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/hooks/usePermission.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 usePermission hook**
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
export function usePermission(code: string): { hasPermission: boolean } {
|
||||
const permissions = useAuthStore((s) => s.permissions);
|
||||
return { hasPermission: permissions.includes(code) };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译通过**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 无类型错误
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/hooks/usePermission.ts
|
||||
git commit -m "feat(web): 添加 usePermission hook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 创建 AuthButton + AuthGuard 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/components/AuthButton.tsx`
|
||||
- Create: `apps/web/src/components/AuthGuard.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 AuthButton 组件**
|
||||
|
||||
`apps/web/src/components/AuthButton.tsx`:
|
||||
|
||||
```typescript
|
||||
import type { ReactNode } from 'react';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
|
||||
interface AuthButtonProps {
|
||||
code: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthButton({ code, children }: AuthButtonProps) {
|
||||
const { hasPermission } = usePermission(code);
|
||||
if (!hasPermission) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 AuthGuard 组件**
|
||||
|
||||
`apps/web/src/components/AuthGuard.tsx`:
|
||||
|
||||
```typescript
|
||||
import type { ReactNode } from 'react';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
|
||||
interface AuthGuardProps {
|
||||
code: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthGuard({ code, children }: AuthGuardProps) {
|
||||
const { hasPermission } = usePermission(code);
|
||||
if (!hasPermission) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译通过**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 无类型错误
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/components/AuthButton.tsx apps/web/src/components/AuthGuard.tsx
|
||||
git commit -m "feat(web): 添加 AuthButton/AuthGuard 声明式权限组件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 健康模块页面按钮权限改造
|
||||
|
||||
### Task 4: PatientList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/PatientList.tsx`
|
||||
|
||||
**改造目标:**
|
||||
- 第 304 行 `新建患者` 按钮 → `<AuthButton code="health.patient.manage">`
|
||||
- 第 242-267 行 操作列的编辑/删除按钮 → `<AuthButton code="health.patient.manage">`
|
||||
|
||||
- [ ] **Step 1: 添加 import**
|
||||
|
||||
在 PatientList.tsx 顶部 import 区域添加:
|
||||
|
||||
```typescript
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 包裹新建患者按钮**
|
||||
|
||||
将第 304 行:
|
||||
```tsx
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
新建患者
|
||||
</Button>
|
||||
```
|
||||
|
||||
改为:
|
||||
```tsx
|
||||
<AuthButton code="health.patient.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
新建患者
|
||||
</Button>
|
||||
</AuthButton>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 包裹操作列按钮**
|
||||
|
||||
将 columns 操作列的 render(第 241-270 行):
|
||||
```tsx
|
||||
render: (_: unknown, record: PatientListItem) => (
|
||||
<Space size={4}>
|
||||
<Button ... />
|
||||
<Popconfirm ...><Button ... /></Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
```
|
||||
|
||||
改为:
|
||||
```tsx
|
||||
render: (_: unknown, record: PatientListItem) => (
|
||||
<AuthButton code="health.patient.manage">
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEditModal(record);
|
||||
}}
|
||||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此患者?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDelete(record.id);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</AuthButton>
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证编译通过**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 无类型错误
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/pages/health/PatientList.tsx
|
||||
git commit -m "feat(web): PatientList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: AppointmentList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/AppointmentList.tsx`
|
||||
|
||||
**改造模式同 Task 4:**
|
||||
- 新建预约按钮 → `<AuthButton code="health.appointment.manage">`
|
||||
- 操作列(编辑/取消/状态变更) → `<AuthButton code="health.appointment.manage">`
|
||||
|
||||
- [ ] **Step 1: 读取文件,识别所有操作按钮位置**
|
||||
|
||||
Run: `grep -n "Button\|onClick\|Popconfirm" apps/web/src/pages/health/AppointmentList.tsx`
|
||||
|
||||
- [ ] **Step 2: 添加 import + 包裹所有操作按钮**
|
||||
|
||||
添加 `import { AuthButton } from '../../components/AuthButton';`
|
||||
用 `<AuthButton code="health.appointment.manage">` 包裹:
|
||||
- 顶部新建按钮
|
||||
- 表格操作列中的所有按钮
|
||||
|
||||
- [ ] **Step 3: 验证编译通过**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/pages/health/AppointmentList.tsx
|
||||
git commit -m "feat(web): AppointmentList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: DoctorList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/DoctorList.tsx`
|
||||
|
||||
**权限码:** `health.doctor.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
模式同 Task 4-5。新建按钮 + 操作列用 `<AuthButton code="health.doctor.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/DoctorList.tsx
|
||||
git commit -m "feat(web): DoctorList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: DoctorSchedule 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/DoctorSchedule.tsx`
|
||||
|
||||
**权限码:** `health.doctor.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建排班 + 操作列用 `<AuthButton code="health.doctor.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/DoctorSchedule.tsx
|
||||
git commit -m "feat(web): DoctorSchedule 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: FollowUpTaskList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/FollowUpTaskList.tsx`
|
||||
|
||||
**权限码:** `health.follow-up.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建随访 + 操作列(编辑/完成/取消)用 `<AuthButton code="health.follow-up.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/FollowUpTaskList.tsx
|
||||
git commit -m "feat(web): FollowUpTaskList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: FollowUpRecordList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/FollowUpRecordList.tsx`
|
||||
|
||||
**权限码:** `health.follow-up.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
添加记录 + 操作列用 `<AuthButton code="health.follow-up.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/FollowUpRecordList.tsx
|
||||
git commit -m "feat(web): FollowUpRecordList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: ConsultationList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/ConsultationList.tsx`
|
||||
|
||||
**权限码:** `health.consultation.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建会话 + 操作列(关闭/导出)用 `<AuthButton code="health.consultation.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/ConsultationList.tsx
|
||||
git commit -m "feat(web): ConsultationList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: ConsultationDetail 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/ConsultationDetail.tsx`
|
||||
|
||||
**权限码:** `health.consultation.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
发送消息 + 关闭会话 + 导出按钮用 `<AuthButton code="health.consultation.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/ConsultationDetail.tsx
|
||||
git commit -m "feat(web): ConsultationDetail 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: OfflineEventList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/OfflineEventList.tsx`
|
||||
|
||||
**权限码:** `health.articles.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建活动 + 操作列用 `<AuthButton code="health.articles.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/OfflineEventList.tsx
|
||||
git commit -m "feat(web): OfflineEventList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 13: PatientDetail 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/PatientDetail.tsx`
|
||||
|
||||
**权限码:** `health.patient.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
编辑患者信息按钮 + 标签管理 + 新增健康数据按钮用 `<AuthButton code="health.patient.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/PatientDetail.tsx
|
||||
git commit -m "feat(web): PatientDetail 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: PatientTagManage 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/PatientTagManage.tsx`
|
||||
|
||||
**权限码:** `health.patient.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建标签 + 编辑/删除标签用 `<AuthButton code="health.patient.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/PatientTagManage.tsx
|
||||
git commit -m "feat(web): PatientTagManage 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 15: PointsProductList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/PointsProductList.tsx`
|
||||
|
||||
**权限码:** `health.points.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建商品 + 编辑/删除/上下架用 `<AuthButton code="health.points.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/PointsProductList.tsx
|
||||
git commit -m "feat(web): PointsProductList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 16: PointsOrderList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/PointsOrderList.tsx`
|
||||
|
||||
**权限码:** `health.points.list`(只读列表,如有核销操作用 `health.points.manage`)
|
||||
|
||||
- [ ] **Step 1: 读取文件,识别是否有写操作按钮**
|
||||
|
||||
Run: `grep -n "Button\|onClick" apps/web/src/pages/health/PointsOrderList.tsx`
|
||||
|
||||
- [ ] **Step 2: 如有核销/管理按钮,用 `<AuthButton code="health.points.manage">` 包裹**
|
||||
|
||||
- [ ] **Step 3: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/PointsOrderList.tsx
|
||||
git commit -m "feat(web): PointsOrderList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 17: PointsRuleList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/PointsRuleList.tsx`
|
||||
|
||||
**权限码:** `health.points.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建规则 + 编辑/删除用 `<AuthButton code="health.points.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/PointsRuleList.tsx
|
||||
git commit -m "feat(web): PointsRuleList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 18: 集成验证
|
||||
|
||||
- [ ] **Step 1: 全量 TypeScript 编译检查**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 2: 启动前端开发服务器**
|
||||
|
||||
Run: `cd apps/web && pnpm dev`
|
||||
|
||||
- [ ] **Step 3: 功能验证**
|
||||
|
||||
1. 用管理员账号登录 → 所有按钮可见
|
||||
2. 创建一个无权限的测试角色(仅 `health.patient.list`)→ 分配给测试用户
|
||||
3. 用测试用户登录 → 仅患者列表可见,新建/编辑/删除按钮隐藏
|
||||
4. 确认表格行点击导航(如患者详情页)仍然正常
|
||||
|
||||
- [ ] **Step 4: 生产构建验证**
|
||||
|
||||
Run: `cd apps/web && pnpm build`
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **Step 5: 推送所有提交**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 权限码速查表
|
||||
|
||||
| 页面 | 文件 | 权限码 |
|
||||
|------|------|--------|
|
||||
| PatientList | PatientList.tsx | health.patient.manage |
|
||||
| PatientDetail | PatientDetail.tsx | health.patient.manage |
|
||||
| PatientTagManage | PatientTagManage.tsx | health.patient.manage |
|
||||
| AppointmentList | AppointmentList.tsx | health.appointment.manage |
|
||||
| DoctorList | DoctorList.tsx | health.doctor.manage |
|
||||
| DoctorSchedule | DoctorSchedule.tsx | health.doctor.manage |
|
||||
| FollowUpTaskList | FollowUpTaskList.tsx | health.follow-up.manage |
|
||||
| FollowUpRecordList | FollowUpRecordList.tsx | health.follow-up.manage |
|
||||
| ConsultationList | ConsultationList.tsx | health.consultation.manage |
|
||||
| ConsultationDetail | ConsultationDetail.tsx | health.consultation.manage |
|
||||
| OfflineEventList | OfflineEventList.tsx | health.articles.manage |
|
||||
| PointsProductList | PointsProductList.tsx | health.points.manage |
|
||||
| PointsOrderList | PointsOrderList.tsx | health.points.manage |
|
||||
| PointsRuleList | PointsRuleList.tsx | health.points.manage |
|
||||
| StatisticsDashboard | StatisticsDashboard.tsx | health.health-data.list (只读,无操作按钮) |
|
||||
@@ -1,884 +0,0 @@
|
||||
# 切片 2: AI 管理端 3 页面 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现 AI 模块的 PC 管理端 — Prompt 管理、分析历史、用量统计 3 个页面,以及对应的后端 API 补全。
|
||||
|
||||
**Architecture:** 后端 4 个 SSE 端点已可用,但 Prompt CRUD / 分析历史查询 / 用量统计端点为空壳或缺失。先补全后端 API(handler + service 方法),再实现前端 API 封装和 3 个管理页面,最后注册菜单和路由。
|
||||
|
||||
**Tech Stack:** Rust/Axum (后端) + React 19/TypeScript/Ant Design 6 (前端)
|
||||
|
||||
**设计规格:** `docs/superpowers/specs/2026-04-25-feature-completion-design.md` §3
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: 后端 API 补全
|
||||
|
||||
### Task 1: PromptService — 补全 CRUD 方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-ai/src/service/prompt.rs`
|
||||
|
||||
**现状:** 仅有 `get_active_prompt` + `create_prompt`。需新增 `list_prompts`、`update_prompt`、`activate_prompt`、`rollback_prompt`。
|
||||
|
||||
- [ ] **Step 1: 添加 list_prompts 方法**
|
||||
|
||||
在 `PromptService` impl 中追加:
|
||||
|
||||
```rust
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set};
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
pub async fn list_prompts(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
category: Option<String>,
|
||||
pagination: Pagination,
|
||||
) -> AiResult<(Vec<ai_prompt::Model>, u64)> {
|
||||
let mut query = ai_prompt::Entity::find()
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(cat) = category {
|
||||
query = query.filter(ai_prompt::Column::Category.eq(cat));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let items = query
|
||||
.order_by_desc(ai_prompt::Column::UpdatedAt)
|
||||
.offset(pagination.offset())
|
||||
.limit(pagination.limit())
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
Ok((items, total))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 update_prompt 方法**
|
||||
|
||||
```rust
|
||||
pub async fn update_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
system_prompt: Option<String>,
|
||||
user_prompt_template: Option<String>,
|
||||
model_config: Option<serde_json::Value>,
|
||||
description: Option<String>,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
|
||||
|
||||
if entity.tenant_id != tenant_id {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
// 创建新版本
|
||||
let new_id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
let active = ai_prompt::ActiveModel {
|
||||
id: Set(new_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(entity.name.clone()),
|
||||
description: Set(description.unwrap_or(entity.description.clone())),
|
||||
system_prompt: Set(system_prompt.unwrap_or(entity.system_prompt.clone())),
|
||||
user_prompt_template: Set(user_prompt_template.unwrap_or(entity.user_prompt_template.clone())),
|
||||
variables_schema: Set(entity.variables_schema.clone()),
|
||||
model_config: Set(model_config.unwrap_or(entity.model_config.clone())),
|
||||
version: Set(entity.version + 1),
|
||||
is_active: Set(entity.is_active),
|
||||
category: Set(entity.category.clone()),
|
||||
tags: Set(entity.tags.clone()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Some(user_id)),
|
||||
updated_by: Set(Some(user_id)),
|
||||
deleted_at: Set(None),
|
||||
version_lock: Set(1),
|
||||
};
|
||||
Ok(active.insert(&self.db).await?)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 添加 activate_prompt 方法**
|
||||
|
||||
```rust
|
||||
pub async fn activate_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
|
||||
|
||||
if entity.tenant_id != tenant_id {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
// 停用同 name + category 的其他版本
|
||||
let siblings = ai_prompt::Entity::find()
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::Name.eq(&entity.name))
|
||||
.filter(ai_prompt::Column::Category.eq(&entity.category))
|
||||
.filter(ai_prompt::Column::IsActive.eq(true))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null())
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
for sibling in siblings {
|
||||
let mut active: ai_prompt::ActiveModel = sibling.into();
|
||||
active.is_active = Set(false);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.update(&self.db).await?;
|
||||
}
|
||||
|
||||
// 激活目标
|
||||
let mut active: ai_prompt::ActiveModel = entity.into();
|
||||
active.is_active = Set(true);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
Ok(active.update(&self.db).await?)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 添加 rollback_prompt(激活指定旧版本)**
|
||||
|
||||
```rust
|
||||
pub async fn rollback_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
// 回滚 = 激活指定版本
|
||||
self.activate_prompt(id, tenant_id).await
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 编译验证**
|
||||
|
||||
Run: `cargo check -p erp-ai`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-ai/src/service/prompt.rs
|
||||
git commit -m "feat(ai): PromptService 补全 list/update/activate/rollback 方法"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: AnalysisService — 补全查询方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-ai/src/service/analysis.rs`
|
||||
|
||||
**现状:** 有 `stream_analyze`、`complete_analysis`、`fail_analysis`、`find_cached`。需新增 `list_analysis`、`get_analysis`。
|
||||
|
||||
- [ ] **Step 1: 添加 list_analysis 方法**
|
||||
|
||||
在 `AnalysisService` impl 中追加:
|
||||
|
||||
```rust
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
pub async fn list_analysis(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Option<Uuid>,
|
||||
analysis_type: Option<String>,
|
||||
pagination: Pagination,
|
||||
) -> AiResult<(Vec<ai_analysis::Model>, u64)> {
|
||||
let mut query = ai_analysis::Entity::find()
|
||||
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_analysis::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(pid) = patient_id {
|
||||
query = query.filter(ai_analysis::Column::PatientId.eq(pid));
|
||||
}
|
||||
if let Some(at) = analysis_type {
|
||||
query = query.filter(ai_analysis::Column::AnalysisType.eq(at));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let items = query
|
||||
.order_by_desc(ai_analysis::Column::CreatedAt)
|
||||
.offset(pagination.offset())
|
||||
.limit(pagination.limit())
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
Ok((items, total))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 get_analysis 方法**
|
||||
|
||||
```rust
|
||||
pub async fn get_analysis(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_analysis::Model> {
|
||||
ai_analysis::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| AiError::AnalysisNotFound(id.to_string()))
|
||||
}
|
||||
```
|
||||
|
||||
注意:上面 filter 需改写为 match 式:
|
||||
|
||||
```rust
|
||||
pub async fn get_analysis(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_analysis::Model> {
|
||||
let model = ai_analysis::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::AnalysisNotFound(id.to_string()))?;
|
||||
if model.tenant_id != tenant_id {
|
||||
return Err(AiError::AnalysisNotFound(id.to_string()));
|
||||
}
|
||||
Ok(model)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译验证**
|
||||
|
||||
Run: `cargo check -p erp-ai`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-ai/src/service/analysis.rs
|
||||
git commit -m "feat(ai): AnalysisService 补全 list/get 查询方法"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: UsageService — 补全聚合查询方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-ai/src/service/usage.rs`
|
||||
|
||||
**现状:** 仅有 `log_usage`。需新增 `get_overview`、`get_trend`、`get_by_type`。
|
||||
|
||||
**注意:** `ai_usage_logs` 表无 `created_by` 字段,用户排行从 `ai_analysis_results.created_by` 聚合。
|
||||
|
||||
- [ ] **Step 1: 添加 get_overview 方法**
|
||||
|
||||
```rust
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, FromQueryResult, QuerySelect, Func};
|
||||
use crate::entity::ai_analysis;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
pub struct UsageOverview {
|
||||
pub total_count: i64,
|
||||
pub total_input_tokens: i64,
|
||||
pub total_output_tokens: i64,
|
||||
}
|
||||
|
||||
pub async fn get_overview(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<UsageOverview> {
|
||||
let result = ai_analysis::Entity::find()
|
||||
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_analysis::Column::Status.eq("completed"))
|
||||
.filter(ai_analysis::Column::DeletedAt.is_null())
|
||||
.select_only()
|
||||
.column_as(ai_analysis::Column::Id.count(), "total_count")
|
||||
.into_model::<UsageOverview>()
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.unwrap_or(UsageOverview {
|
||||
total_count: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
});
|
||||
Ok(result)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 get_by_type 方法**
|
||||
|
||||
```rust
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
pub struct TypeCount {
|
||||
pub analysis_type: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
pub async fn get_by_type(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<Vec<TypeCount>> {
|
||||
let result = ai_analysis::Entity::find()
|
||||
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_analysis::Column::Status.eq("completed"))
|
||||
.filter(ai_analysis::Column::DeletedAt.is_null())
|
||||
.select_only()
|
||||
.column(ai_analysis::Column::AnalysisType)
|
||||
.column_as(ai_analysis::Column::Id.count(), "count")
|
||||
.group_by(ai_analysis::Column::AnalysisType)
|
||||
.into_model::<TypeCount>()
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译验证**
|
||||
|
||||
Run: `cargo check -p erp-ai`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-ai/src/service/usage.rs
|
||||
git commit -m "feat(ai): UsageService 补全 get_overview/get_by_type 聚合方法"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Handler — 补全路由端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-ai/src/handler/mod.rs`
|
||||
- Modify: `crates/erp-ai/src/module.rs` (路由注册)
|
||||
|
||||
**现状:**
|
||||
- 4 个 SSE 端点:可用
|
||||
- `list_analysis` / `get_analysis`:空壳(返回 `ApiResponse::ok(())`)
|
||||
- Prompt CRUD、用量统计:完全缺失
|
||||
|
||||
- [ ] **Step 1: 实现 list_analysis 真实查询**
|
||||
|
||||
替换 `handler/mod.rs` 中的 `list_analysis` 函数(第 272-283 行):
|
||||
|
||||
```rust
|
||||
pub async fn list_analysis<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ListAnalysisQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.list")?;
|
||||
let pagination = erp_core::types::Pagination::new(
|
||||
params.page.unwrap_or(1),
|
||||
params.page_size.unwrap_or(20),
|
||||
);
|
||||
let (items, total) = state.analysis.list_analysis(
|
||||
ctx.tenant_id,
|
||||
params.patient_id,
|
||||
params.analysis_type,
|
||||
pagination,
|
||||
).await?;
|
||||
let data = serde_json::json!({
|
||||
"data": items,
|
||||
"total": total,
|
||||
"page": pagination.page,
|
||||
"page_size": pagination.page_size,
|
||||
});
|
||||
Ok(Json(ApiResponse::ok(data)))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 get_analysis 真实查询**
|
||||
|
||||
替换 `get_analysis` 函数(第 285-296 行):
|
||||
|
||||
```rust
|
||||
pub async fn get_analysis<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<ai_analysis::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.list")?;
|
||||
let analysis = state.analysis.get_analysis(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(analysis)))
|
||||
}
|
||||
```
|
||||
|
||||
需在文件顶部添加 `use crate::entity::ai_analysis;`。
|
||||
|
||||
- [ ] **Step 3: 新增 Prompt CRUD handler 函数**
|
||||
|
||||
在 handler/mod.rs 中添加以下函数(分析历史之后):
|
||||
|
||||
```rust
|
||||
// === Prompt 管理 ===
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListPromptsQuery {
|
||||
pub category: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn list_prompts<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ListPromptsQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.list")?;
|
||||
let pagination = erp_core::types::Pagination::new(
|
||||
params.page.unwrap_or(1),
|
||||
params.page_size.unwrap_or(20),
|
||||
);
|
||||
let (items, total) = state.prompt.list_prompts(
|
||||
ctx.tenant_id, params.category, pagination,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items, "total": total,
|
||||
"page": pagination.page, "page_size": pagination.page_size,
|
||||
}))))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatePromptBody {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub system_prompt: String,
|
||||
pub user_prompt_template: String,
|
||||
pub model_config: serde_json::Value,
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
pub async fn create_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<CreatePromptBody>,
|
||||
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state.prompt.create_prompt(
|
||||
ctx.tenant_id, ctx.user_id,
|
||||
body.name, body.system_prompt, body.user_prompt_template,
|
||||
body.model_config, body.category,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
pub async fn activate_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state.prompt.activate_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
pub async fn rollback_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state.prompt.rollback_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
```
|
||||
|
||||
需在文件顶部添加 `use crate::entity::ai_prompt;`。
|
||||
|
||||
- [ ] **Step 4: 新增用量统计 handler 函数**
|
||||
|
||||
```rust
|
||||
// === 用量统计 ===
|
||||
|
||||
pub async fn usage_overview<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.usage.list")?;
|
||||
let overview = state.usage.get_overview(ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"total_count": overview.total_count,
|
||||
}))))
|
||||
}
|
||||
|
||||
pub async fn usage_by_type<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.usage.list")?;
|
||||
let types = state.usage.get_by_type(ctx.tenant_id).await?;
|
||||
let result: Vec<serde_json::Value> = types.into_iter().map(|t| {
|
||||
serde_json::json!({
|
||||
"analysis_type": t.analysis_type,
|
||||
"count": t.count,
|
||||
})
|
||||
}).collect();
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在 module.rs 注册新路由**
|
||||
|
||||
修改 `AiModule::protected_routes`,在现有路由后追加:
|
||||
|
||||
```rust
|
||||
.route("/ai/prompts", axum::routing::get(crate::handler::list_prompts))
|
||||
.route("/ai/prompts", axum::routing::post(crate::handler::create_prompt))
|
||||
.route("/ai/prompts/{id}/activate", axum::routing::post(crate::handler::activate_prompt))
|
||||
.route("/ai/prompts/{id}/rollback", axum::routing::post(crate::handler::rollback_prompt))
|
||||
.route("/ai/usage/overview", axum::routing::get(crate::handler::usage_overview))
|
||||
.route("/ai/usage/by-type", axum::routing::get(crate::handler::usage_by_type))
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 编译验证**
|
||||
|
||||
Run: `cargo check -p erp-ai && cargo check -p erp-server`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-ai/src/handler/mod.rs crates/erp-ai/src/module.rs
|
||||
git commit -m "feat(ai): 补全 Prompt CRUD + 分析历史 + 用量统计 handler 和路由"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 前端 API 封装
|
||||
|
||||
### Task 5: 创建 AI API service 文件
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/api/ai/prompts.ts`
|
||||
- Create: `apps/web/src/api/ai/analysis.ts`
|
||||
- Create: `apps/web/src/api/ai/usage.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 prompts.ts**
|
||||
|
||||
`apps/web/src/api/ai/prompts.ts`:
|
||||
|
||||
```typescript
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
export interface PromptItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
model_config: Record<string, unknown>;
|
||||
version: number;
|
||||
is_active: boolean;
|
||||
category: string;
|
||||
tags: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreatePromptReq {
|
||||
name: string;
|
||||
description?: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
model_config: Record<string, unknown>;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export const promptApi = {
|
||||
list: async (params?: { category?: string; page?: number; page_size?: number }) => {
|
||||
const resp = await client.get('/ai/prompts', { params });
|
||||
return resp.data.data as PaginatedResponse<PromptItem>;
|
||||
},
|
||||
create: async (data: CreatePromptReq) => {
|
||||
const resp = await client.post('/ai/prompts', data);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
activate: async (id: string) => {
|
||||
const resp = await client.post(`/ai/prompts/${id}/activate`);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
rollback: async (id: string) => {
|
||||
const resp = await client.post(`/ai/prompts/${id}/rollback`);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 analysis.ts**
|
||||
|
||||
`apps/web/src/api/ai/analysis.ts`:
|
||||
|
||||
```typescript
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
export interface AnalysisItem {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
analysis_type: string;
|
||||
source_ref: string;
|
||||
model_used: string;
|
||||
status: string;
|
||||
result_content: string | null;
|
||||
result_metadata: Record<string, unknown> | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const analysisApi = {
|
||||
list: async (params?: { patient_id?: string; analysis_type?: string; page?: number; page_size?: number }) => {
|
||||
const resp = await client.get('/ai/analysis/history', { params });
|
||||
return resp.data.data as PaginatedResponse<AnalysisItem>;
|
||||
},
|
||||
get: async (id: string) => {
|
||||
const resp = await client.get(`/ai/analysis/${id}`);
|
||||
return resp.data.data as AnalysisItem;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 创建 usage.ts**
|
||||
|
||||
`apps/web/src/api/ai/usage.ts`:
|
||||
|
||||
```typescript
|
||||
import client from '../client';
|
||||
|
||||
export interface UsageOverview {
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface TypeDistribution {
|
||||
analysis_type: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const usageApi = {
|
||||
overview: async () => {
|
||||
const resp = await client.get('/ai/usage/overview');
|
||||
return resp.data.data as UsageOverview;
|
||||
},
|
||||
byType: async () => {
|
||||
const resp = await client.get('/ai/usage/by-type');
|
||||
return resp.data.data as TypeDistribution[];
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/api/ai/
|
||||
git commit -m "feat(web): AI API 前端封装 — prompts/analysis/usage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 前端管理页面
|
||||
|
||||
### Task 6: AI Prompt 管理页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/health/AiPromptList.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 AiPromptList 页面**
|
||||
|
||||
使用 Ant Design Table + Modal 模式(参考 PatientList.tsx 结构):
|
||||
|
||||
核心功能:
|
||||
- 表格列:名称 / 类别 / 版本 / 状态(active/inactive) / 更新时间
|
||||
- 新建 Prompt 按钮 → Modal 表单
|
||||
- 操作列:激活 / 回滚
|
||||
- AuthButton 权限控制(`ai.prompt.manage`)
|
||||
|
||||
页面大致结构(骨架,实现时根据 Ant Design 6 API 细化):
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Table, Button, Space, Modal, Form, Input, Select, Tag, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { promptApi, type PromptItem, type CreatePromptReq } from '../../api/ai/prompts';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'lab_report_interpretation', label: '化验单解读' },
|
||||
{ value: 'health_trend_analysis', label: '趋势分析' },
|
||||
{ value: 'personalized_checkup_plan', label: '体检方案' },
|
||||
{ value: 'report_summary_generation', label: '报告摘要' },
|
||||
];
|
||||
|
||||
export default function AiPromptList() {
|
||||
// ... useState, fetchPrompts, columns 定义
|
||||
// 新建/激活/回滚按钮用 <AuthButton code="ai.prompt.manage"> 包裹
|
||||
// 表格渲染 + Modal 表单
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/AiPromptList.tsx
|
||||
git commit -m "feat(web): AI Prompt 管理页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: AI 分析历史页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/health/AiAnalysisList.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 AiAnalysisList 页面**
|
||||
|
||||
核心功能:
|
||||
- 表格列:分析类型 / 患者 ID / 状态(streaming/completed/failed) / 模型 / 创建时间
|
||||
- 状态 Tag:completed=绿色, failed=红色, streaming=蓝色
|
||||
- 详情查看:点击行展开,显示 result_content(Markdown 渲染)
|
||||
- 筛选:分析类型下拉 + 时间范围
|
||||
- AuthButton 权限控制(`ai.analysis.list`)
|
||||
|
||||
- [ ] **Step 2: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/AiAnalysisList.tsx
|
||||
git commit -m "feat(web): AI 分析历史页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: AI 用量统计页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/health/AiUsageDashboard.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 AiUsageDashboard 页面**
|
||||
|
||||
核心功能:
|
||||
- 顶部 StatCard:总分析次数
|
||||
- 饼图:分析类型分布(使用 Ant Design Charts Pie)
|
||||
- AuthButton 权限控制(`ai.usage.list`)
|
||||
|
||||
- [ ] **Step 2: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/AiUsageDashboard.tsx
|
||||
git commit -m "feat(web): AI 用量统计页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 菜单注册 + 路由配置
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/layouts/MainLayout.tsx`
|
||||
- Modify: `apps/web/src/App.tsx`
|
||||
|
||||
- [ ] **Step 1: 在 MainLayout 添加菜单项**
|
||||
|
||||
在 `healthMenuItems` 数组中追加 3 项:
|
||||
|
||||
```typescript
|
||||
{ key: '/health/ai-prompts', label: 'AI Prompt 管理', icon: ... },
|
||||
{ key: '/health/ai-analysis', label: 'AI 分析历史', icon: ... },
|
||||
{ key: '/health/ai-usage', label: 'AI 用量统计', icon: ... },
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 App.tsx 添加路由**
|
||||
|
||||
在健康模块路由区域追加:
|
||||
|
||||
```tsx
|
||||
<Route path="/health/ai-prompts" element={<AiPromptList />} />
|
||||
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
|
||||
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/layouts/MainLayout.tsx apps/web/src/App.tsx
|
||||
git commit -m "feat(web): AI 管理端菜单注册 + 路由配置"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 集成验证
|
||||
|
||||
- [ ] **Step 1: 后端编译 + 启动**
|
||||
|
||||
Run: `cargo check --workspace && cd crates/erp-server && cargo run`
|
||||
|
||||
验证:
|
||||
- `/api/v1/ai/prompts` 返回空列表(200)
|
||||
- `/api/v1/ai/analysis/history` 返回空列表(200)
|
||||
- `/api/v1/ai/usage/overview` 返回 0 计数(200)
|
||||
|
||||
- [ ] **Step 2: 前端编译 + 启动**
|
||||
|
||||
Run: `cd apps/web && pnpm build && pnpm dev`
|
||||
|
||||
验证:
|
||||
- 3 个新页面在菜单中可见
|
||||
- 页面正常加载,无白屏/报错
|
||||
- Prompt 列表为空时显示空状态
|
||||
- 分析历史列表为空时显示空状态
|
||||
|
||||
- [ ] **Step 3: 生产构建**
|
||||
|
||||
Run: `cd apps/web && pnpm build`
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **Step 4: 推送**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
@@ -1,539 +0,0 @@
|
||||
# 切片 3: 小程序 AI 报告查看 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 患者可在微信小程序查看 AI 分析报告(只读),从首页入口进入列表页,点击查看详情。
|
||||
|
||||
**Architecture:** 后端 `list_analysis` / `get_analysis` 端点在切片 2 中已补全。小程序复用现有 `services/request.ts` 的 `api` 封装,新增 `services/ai-analysis.ts` 调用后端 API。新增 2 个页面(列表 + 详情),在首页添加入口卡片。
|
||||
|
||||
**Tech Stack:** Taro 4.2 + React 18 + TypeScript
|
||||
|
||||
**设计规格:** `docs/superpowers/specs/2026-04-25-feature-completion-design.md` §4
|
||||
|
||||
**依赖:** 切片 2 Task 1-4(后端 API 补全)必须先完成
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: API 层 + 页面
|
||||
|
||||
### Task 1: 创建 AI 分析 API service
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/miniprogram/src/services/ai-analysis.ts`
|
||||
|
||||
**参考模式:** `services/report.ts`(化验报告 service)
|
||||
|
||||
- [ ] **Step 1: 创建 service 文件**
|
||||
|
||||
`apps/miniprogram/src/services/ai-analysis.ts`:
|
||||
|
||||
```typescript
|
||||
import { api } from './request';
|
||||
|
||||
export interface AiAnalysisItem {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
analysis_type: string;
|
||||
model_used: string;
|
||||
status: string;
|
||||
result_content: string | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function listAiAnalysis(page = 1, pageSize = 20) {
|
||||
return api.get<{ data: AiAnalysisItem[]; total: number }>(
|
||||
'/ai/analysis/history',
|
||||
{ page, page_size: pageSize },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAiAnalysisDetail(id: string) {
|
||||
return api.get<AiAnalysisItem>(`/ai/analysis/${id}`);
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** 后端 `list_analysis` 会根据 JWT 中的 `user_id` → `patient_id` 自动过滤(小程序端通过 `X-Patient-Id` header 传递)。若后端未自动过滤,需在请求参数中传 `patient_id`。
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
Run: `cd apps/miniprogram && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/miniprogram/src/services/ai-analysis.ts
|
||||
git commit -m "feat(miniprogram): AI 分析 API service"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: AI 报告列表页
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/miniprogram/src/pages/ai-report/list/index.tsx`
|
||||
- Create: `apps/miniprogram/src/pages/ai-report/list/index.scss`
|
||||
|
||||
**参考模式:** `pages/report/detail/index.tsx` + `pages/article/index.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建列表页组件**
|
||||
|
||||
`apps/miniprogram/src/pages/ai-report/list/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { listAiAnalysis, type AiAnalysisItem } from '@/services/ai-analysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
lab_report: '化验单解读',
|
||||
trend: '趋势分析',
|
||||
checkup_plan: '体检方案',
|
||||
report_summary: '报告摘要',
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, { text: string; className: string }> = {
|
||||
completed: { text: '已完成', className: 'status-completed' },
|
||||
streaming: { text: '分析中', className: 'status-streaming' },
|
||||
failed: { text: '失败', className: 'status-failed' },
|
||||
};
|
||||
|
||||
export default function AiReportList() {
|
||||
const [list, setList] = useState<AiAnalysisItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadList(1);
|
||||
}, []);
|
||||
|
||||
const loadList = async (p: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listAiAnalysis(p, 20);
|
||||
const items = res.data || [];
|
||||
setList(p === 1 ? items : [...list, ...items]);
|
||||
setPage(p);
|
||||
setHasMore(items.length >= 20);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goDetail = (id: string) => {
|
||||
Taro.navigateTo({ url: `/pages/ai-report/detail/index?id=${id}` });
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
if (hasMore && !loading) loadList(page + 1);
|
||||
};
|
||||
|
||||
if (loading && list.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<View className='ai-report-page'>
|
||||
<EmptyState text='暂无 AI 分析报告' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='ai-report-page'>
|
||||
<View className='page-title'>AI 分析报告</View>
|
||||
<ScrollView scrollY className='report-scroll' onScrollToLower={loadMore}>
|
||||
{list.map((item) => {
|
||||
const statusInfo = STATUS_MAP[item.status] || { text: item.status, className: '' };
|
||||
return (
|
||||
<View
|
||||
key={item.id}
|
||||
className='report-card'
|
||||
onClick={() => item.status === 'completed' && goDetail(item.id)}
|
||||
>
|
||||
<View className='card-header'>
|
||||
<Text className='card-type'>{TYPE_LABELS[item.analysis_type] || item.analysis_type}</Text>
|
||||
<Text className={`card-status ${statusInfo.className}`}>{statusInfo.text}</Text>
|
||||
</View>
|
||||
<View className='card-footer'>
|
||||
<Text className='card-time'>{new Date(item.created_at).toLocaleString('zh-CN')}</Text>
|
||||
<Text className='card-model'>{item.model_used}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{loading && <Loading />}
|
||||
{!hasMore && list.length > 0 && <Text className='no-more'>没有更多了</Text>}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建列表页样式**
|
||||
|
||||
`apps/miniprogram/src/pages/ai-report/list/index.scss`:
|
||||
|
||||
```scss
|
||||
.ai-report-page {
|
||||
min-height: 100vh;
|
||||
background: #f1f5f9;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.report-scroll {
|
||||
height: calc(100vh - 80px);
|
||||
}
|
||||
|
||||
.report-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-type {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.card-status {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
color: #16a34a;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.status-streaming {
|
||||
color: #2563eb;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
color: #dc2626;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-time {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.card-model {
|
||||
font-size: 11px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
padding: 16px 0;
|
||||
display: block;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
Run: `cd apps/miniprogram && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/miniprogram/src/pages/ai-report/list/
|
||||
git commit -m "feat(miniprogram): AI 报告列表页"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: AI 报告详情页
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/miniprogram/src/pages/ai-report/detail/index.tsx`
|
||||
- Create: `apps/miniprogram/src/pages/ai-report/detail/index.scss`
|
||||
|
||||
- [ ] **Step 1: 创建详情页组件**
|
||||
|
||||
`apps/miniprogram/src/pages/ai-report/detail/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, RichText } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
lab_report: '化验单解读',
|
||||
trend: '趋势分析',
|
||||
checkup_plan: '体检方案',
|
||||
report_summary: '报告摘要',
|
||||
};
|
||||
|
||||
function markdownToHtml(md: string): string {
|
||||
return md
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
||||
.replace(/\n\n/g, '<br/><br/>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
}
|
||||
|
||||
export default function AiReportDetail() {
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
|
||||
const [analysis, setAnalysis] = useState<AiAnalysisItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getAiAnalysisDetail(id)
|
||||
.then((data) => setAnalysis(data))
|
||||
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
if (!analysis) {
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<Text className='empty-text'>报告不存在</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const htmlContent = analysis.result_content
|
||||
? markdownToHtml(analysis.result_content)
|
||||
: '<p>暂无分析结果</p>';
|
||||
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className='detail-card'>
|
||||
<Text className='detail-type'>{TYPE_LABELS[analysis.analysis_type] || analysis.analysis_type}</Text>
|
||||
<View className='detail-meta'>
|
||||
<Text className='meta-item'>模型: {analysis.model_used}</Text>
|
||||
<Text className='meta-item'>{new Date(analysis.created_at).toLocaleString('zh-CN')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='content-card'>
|
||||
<RichText className='report-content' nodes={htmlContent} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建详情页样式**
|
||||
|
||||
`apps/miniprogram/src/pages/ai-report/detail/index.scss`:
|
||||
|
||||
```scss
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
background: #f1f5f9;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.detail-type {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.report-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
Run: `cd apps/miniprogram && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/miniprogram/src/pages/ai-report/detail/
|
||||
git commit -m "feat(miniprogram): AI 报告详情页"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 集成
|
||||
|
||||
### Task 4: 注册页面路由
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/miniprogram/src/app.config.ts`
|
||||
|
||||
- [ ] **Step 1: 在 pages 数组中注册新页面**
|
||||
|
||||
在 `app.config.ts` 的 `pages` 数组中,在 `pages/report/detail/index` 之后添加:
|
||||
|
||||
```typescript
|
||||
'pages/ai-report/list/index',
|
||||
'pages/ai-report/detail/index',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/miniprogram && npx tsc --noEmit
|
||||
git add apps/miniprogram/src/app.config.ts
|
||||
git commit -m "feat(miniprogram): 注册 AI 报告页面路由"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 首页入口卡片
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/miniprogram/src/pages/index/index.tsx`
|
||||
- Modify: `apps/miniprogram/src/pages/index/index.scss`
|
||||
|
||||
- [ ] **Step 1: 在首页添加 AI 报告入口**
|
||||
|
||||
在 `pages/index/index.tsx` 中,找到功能入口区域,添加 AI 报告卡片。
|
||||
|
||||
在页面 JSX 中添加一个导航卡片:
|
||||
|
||||
```tsx
|
||||
<View
|
||||
className='feature-card ai-card'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/ai-report/list/index' })}
|
||||
>
|
||||
<Text className='feature-icon'>🤖</Text>
|
||||
<Text className='feature-title'>AI 分析报告</Text>
|
||||
<Text className='feature-desc'>查看智能健康分析结果</Text>
|
||||
</View>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加入口卡片样式(如需要)**
|
||||
|
||||
在 `pages/index/index.scss` 中,根据现有功能卡片样式添加 `.ai-card` 样式(如果现有的 `.feature-card` 已足够,可跳过)。
|
||||
|
||||
- [ ] **Step 3: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/miniprogram && npx tsc --noEmit
|
||||
git add apps/miniprogram/src/pages/index/
|
||||
git commit -m "feat(miniprogram): 首页添加 AI 报告入口卡片"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 集成验证
|
||||
|
||||
- [ ] **Step 1: 编译检查**
|
||||
|
||||
Run: `cd apps/miniprogram && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 2: 启动后端服务**
|
||||
|
||||
Run: `cd crates/erp-server && cargo run`
|
||||
|
||||
确保 `/api/v1/ai/analysis/history` 和 `/api/v1/ai/analysis/{id}` 端点可用。
|
||||
|
||||
- [ ] **Step 3: 小程序编译**
|
||||
|
||||
Run: `cd apps/miniprogram && pnpm build:weapp`
|
||||
|
||||
Expected: 编译成功
|
||||
|
||||
- [ ] **Step 4: 功能验证(微信开发者工具)**
|
||||
|
||||
1. 登录小程序(绑定有 AI 分析记录的患者)
|
||||
2. 首页可见"AI 分析报告"入口卡片
|
||||
3. 点击进入列表页 → 显示该患者的 AI 分析记录
|
||||
4. 点击一条已完成的记录 → 进入详情页,Markdown 内容正常渲染
|
||||
5. 无记录时显示空状态
|
||||
6. 失败记录显示"失败"标签,不可点击进入详情
|
||||
|
||||
- [ ] **Step 5: 推送**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
@@ -1,106 +0,0 @@
|
||||
# 事件驱动架构增强实施计划
|
||||
|
||||
> 设计规格: `docs/superpowers/specs/2026-04-26-event-driven-architecture-design.md`
|
||||
> 日期: 2026-04-26 | 状态: draft | 总周期: 2 周
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 高优先级事件补发(Week 1)
|
||||
|
||||
### Task 1: dialysis_service 添加 dialysis_record.created/reviewed 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/dialysis_service.rs`
|
||||
|
||||
**步骤**: `create_dialysis_record()` 成功后发布 `dialysis_record.created`(data: patient_id, dialysis_type, status, dialysis_date, duration, ultrafiltration_volume)。审核状态变更时发布 `dialysis_record.reviewed`(data: patient_id, reviewer_id, complication_notes)。payload 遵循统一信封(schema_version: "v1")。发布失败仅 warn 不阻断业务。
|
||||
|
||||
**验收**: 创建/审核后 domain_events 表出现对应事件;`cargo test` 通过。
|
||||
|
||||
### Task 2: diagnosis_service 添加 diagnosis.created/updated 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/diagnosis_service.rs`
|
||||
|
||||
**步骤**: `create_diagnosis()` 后发布 `diagnosis.created`(data: patient_id, icd_code, diagnosis_name, severity)。`update_diagnosis()` 后发布 `diagnosis.updated`,计算变更 diff(changed_fields[], old_values{}, new_values{})。
|
||||
|
||||
**验收**: diagnosis.updated 事件 data 含 changed_fields 差异;`cargo test` 通过。
|
||||
|
||||
### Task 3: consent_service 添加 consent.granted/revoked 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/consent_service.rs`
|
||||
|
||||
**步骤**: 签署时发布 `consent.granted`(data: patient_id, consent_type, consent_scope, granted_by, expires_at)。撤销时发布 `consent.revoked`(data: patient_id, consent_type, revoked_by, reason)。
|
||||
|
||||
**验收**: 签署/撤销后 domain_events 表出现事件;`cargo test` 通过。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 中低优先级事件 + Outbox 优化(Week 2)
|
||||
|
||||
### Task 4: points_service 添加 points.earned/exchanged 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/points_service.rs`
|
||||
|
||||
**步骤**: earn 成功后发布 `points.earned`(data: patient_id, points, source_type, balance_after)。exchange 成功后发布 `points.exchanged`(data: patient_id, points, product_name, order_id, balance_after)。确保在事务提交后发布。
|
||||
|
||||
**验收**: 积分变动后 domain_events 出现事件;balance_after 正确反映余额。
|
||||
|
||||
### Task 5: article_service 添加 article.published/rejected 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/article_service.rs`
|
||||
|
||||
**步骤**: 审核通过发布 `article.published`(data: title, author_id, category_id, tags[])。审核驳回发布 `article.rejected`(data: title, reviewer_id, reason)。
|
||||
|
||||
**验收**: 审核操作后 domain_events 出现事件;`cargo test` 通过。
|
||||
|
||||
### Task 6: daily_monitoring_service 添加 daily_monitoring.created 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/daily_monitoring_service.rs`
|
||||
|
||||
**步骤**: 记录创建后发布 `daily_monitoring.created`(data: patient_id, monitoring_date, monitoring_type, values{})。
|
||||
|
||||
**验收**: 创建记录后 domain_events 出现事件;`cargo test` 通过。
|
||||
|
||||
### Task 7: Outbox relay 从轮询改为 LISTEN/NOTIFY
|
||||
|
||||
**涉及文件**: `crates/erp-server/src/outbox.rs`, `crates/erp-core/src/events.rs`
|
||||
|
||||
**步骤**: `EventBus::publish()` 持久化后执行 `NOTIFY outbox_channel, '<event_id>'`。outbox relay 用 `sqlx::PgListener` 监听 + `tokio::select!`(LISTEN 触发 + 30s 兜底轮询)。保留 `process_pending_events()` 不变,仅改变触发方式。PgListener 添加断线自动重连。
|
||||
|
||||
**验收**: 事件延迟 < 100ms;DB 轮询频率从 5s 降为 30s 兜底;`cargo test --workspace` 通过。
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 事件 schema 版本化 + 清理(Week 2)
|
||||
|
||||
### Task 8: 事件 payload 添加 schema_version 字段
|
||||
|
||||
**涉及文件**: `crates/erp-core/src/events.rs`, `crates/erp-health/src/service/` 下所有发布事件的 service
|
||||
|
||||
**步骤**: 在 erp-core 创建 `build_event_payload()` 辅助函数,自动填充 schema_version/timestamp/metadata。逐个 service(14 个模块)替换手动构建为调用辅助函数,统一信封格式。
|
||||
|
||||
**验收**: 所有事件 payload 含 schema_version 字段;`cargo test --workspace` 通过。
|
||||
|
||||
### Task 9: Outbox 表分区或定期清理策略
|
||||
|
||||
**涉及文件**: `migration/src/m000075_domain_events_cleanup.rs`(新增), `erp-server/src/tasks/events_cleanup.rs`(新增)
|
||||
|
||||
**步骤**: 迁移创建 `domain_events_archive` 表,添加 `cleanup_old_published_events()` SQL 函数(>90 天 published 事件迁移到归档表)。后台任务每日执行清理。归档表只读防篡改。
|
||||
|
||||
**验收**: 清理任务正确迁移 >90 天事件;`cargo test` 通过。
|
||||
|
||||
### Task 10: 消费者幂等性(dedup key 检查)
|
||||
|
||||
**涉及文件**: `migration/src/m000076_processed_events.rs`(新增), `crates/erp-core/src/events.rs`
|
||||
|
||||
**步骤**: 迁移创建 `processed_events` 表(event_id + consumer_id 联合主键 + processed_at)。erp-core 添加 `is_processed()` / `mark_processed()` 辅助函数。消费者模式:收到事件 -> 查已处理 -> 跳过或执行 -> 插入记录。添加 7 天 TTL 清理任务。
|
||||
|
||||
**验收**: 重复消费同一事件时第二次被跳过;`cargo test --workspace` 通过。
|
||||
|
||||
---
|
||||
|
||||
## 执行原则
|
||||
|
||||
1. **每 Task 完成后立即提交** — 不积压
|
||||
2. **Phase 1 优先** — P0 事件(透析/诊断)是核心医疗流程
|
||||
3. **事件发布不阻断业务** — publish 失败仅 warn,Outbox relay 兜底
|
||||
4. **统一信封格式** — 使用 `build_event_payload` 保证一致性
|
||||
5. **LISTEN/NOTIFY 保留兜底轮询** — 30s 轮询防 NOTIFY 丢失
|
||||
@@ -1,290 +0,0 @@
|
||||
# 前端工程化改进实施计划
|
||||
|
||||
> 设计规格: `docs/superpowers/specs/2026-04-26-frontend-engineering-design.md`
|
||||
> 日期: 2026-04-26 | 状态: draft | 总周期: 7 天
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 重复模式统一(Day 1-2)
|
||||
|
||||
### Task 1: 增强 useApiRequest hook,统一错误处理
|
||||
|
||||
**目标**: 补齐 loading 状态,消除组件内联 `catch (err) { message.error(...) }` 模式。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/hooks/useApiRequest.ts`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 在 `useApiRequest` 返回值中新增 `loading: boolean` 状态:
|
||||
```typescript
|
||||
interface UseApiRequestReturn {
|
||||
execute: <T>(fn: () => Promise<T>, successMsg?: string) => Promise<T | null>;
|
||||
loading: boolean;
|
||||
}
|
||||
```
|
||||
2. `execute` 内部在调用前 `setLoading(true)`,finally 中 `setLoading(false)`
|
||||
3. 保持现有调用点无需修改 — 返回值是对象解构,新增字段不影响旧代码
|
||||
4. 选取 3 个健康模块页面(PatientList、AppointmentList、FollowUpTaskList)迁移为使用 `execute` + `loading`
|
||||
|
||||
**验收标准**:
|
||||
- `pnpm build` 通过
|
||||
- 3 个迁移页面的 catch 块不再有内联 `message.error`,统一走 `handleApiError`
|
||||
- loading 状态正确绑定到页面按钮/Spin 组件
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 增强 usePaginatedData hook,健康模块页面迁移
|
||||
|
||||
**目标**: 支持泛型筛选参数,迁移 6 个健康列表页使用统一 hook。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/hooks/usePaginatedData.ts`
|
||||
- 修改: `apps/web/src/pages/health/PatientList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/OfflineEventList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/PointsProductList.tsx`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 增强 hook 签名为泛型筛选:
|
||||
```typescript
|
||||
function usePaginatedData<T, F = string>(
|
||||
fetchFn: (page: number, pageSize: number, filters: F) => Promise<{ data: T[]; total: number }>,
|
||||
options?: { pageSize?: number; defaultFilters: F; autoFetch?: boolean }
|
||||
): { data, total, page, loading, filters, setFilters, refresh }
|
||||
```
|
||||
2. 函数重载保持旧 `(fetchFn, pageSize?)` 签名兼容
|
||||
3. 新增 `filters` / `setFilters` 状态,`fetchFn` 调用时传入当前 filters
|
||||
4. 迁移 PatientList(按 status/name/gender 筛选)和 OfflineEventList(按 status/dateRange 筛选)
|
||||
|
||||
**验收标准**:
|
||||
- 旧调用点(不传 filters)行为不变
|
||||
- PatientList 和 OfflineEventList 筛选功能正常,代码行数各减少 15-25 行
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 移除 nameCache,统一用 useHealthStore
|
||||
|
||||
**目标**: 消除 AppointmentList 和 PointsOrderList 自建的 `useState<Record<string, string>>` nameCache。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/stores/health.ts`
|
||||
- 修改: `apps/web/src/pages/health/AppointmentList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/PointsOrderList.tsx`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 在 `useHealthStore` 新增批量解析方法:
|
||||
- `batchResolvePatientNames(ids: string[]): Promise<Record<string, string>>`
|
||||
- `batchResolveDoctorNames(ids: string[]): Promise<Record<string, string>>`
|
||||
2. 内部实现:去重 → 过滤已缓存 → 并发加载(限制 5 并发)→ 写入缓存并返回
|
||||
3. 在 AppointmentList 中移除 nameCache state,改用 store 方法
|
||||
4. 在 PointsOrderList 中同样迁移
|
||||
|
||||
**验收标准**:
|
||||
- 两个页面无 `useState<Record<string, string>>` nameCache 代码
|
||||
- 患者姓名/医生姓名在列表中正确显示
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 大组件拆分(Day 3-5)
|
||||
|
||||
### Task 4: PluginCRUDPage 拆分为 CRUDTable/CRUDForm/DetailDrawer/ImportExport
|
||||
|
||||
**目标**: 将 872 行的 PluginCRUDPage.tsx 拆为容器 + 展示组件。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/src/pages/plugins/components/CRUDTable.tsx` (~150 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/CRUDForm.tsx` (~180 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/DetailDrawer.tsx` (~80 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/ImportExport.tsx` (~100 行)
|
||||
- 新增: `apps/web/src/pages/plugins/hooks/usePluginData.ts` (~120 行)
|
||||
- 修改: `apps/web/src/pages/plugins/PluginCRUDPage.tsx` (缩减至 ~80 行)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 创建 `hooks/usePluginData.ts`:提取 CRUD 操作、导入导出逻辑、Drawer 可见性状态
|
||||
2. 创建 `CRUDTable.tsx`:表格列定义 + 行操作按钮,props 接收 data/onDelete/onEdit/onDetail
|
||||
3. 创建 `CRUDForm.tsx`:新增/编辑表单 + Drawer,包含校验规则
|
||||
4. 创建 `DetailDrawer.tsx`:详情展示 + 操作历史 Timeline
|
||||
5. 创建 `ImportExport.tsx`:导入面板 + 导出按钮
|
||||
6. 改写 `PluginCRUDPage.tsx` 为容器组件:调用 usePluginData hook,组装子组件
|
||||
|
||||
**验收标准**:
|
||||
- `pnpm build` 通过
|
||||
- 插件 CRUD 所有功能正常(新增、编辑、删除、详情、导入、导出)
|
||||
- PluginCRUDPage.tsx <= 100 行,无子组件超过 200 行
|
||||
|
||||
---
|
||||
|
||||
### Task 5: PluginGraphPage 抽取 useGraphCanvas hook
|
||||
|
||||
**目标**: 将 759 行的 PluginGraphPage.tsx 拆为 hook + 展示组件。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/src/pages/plugins/hooks/useGraphLayout.ts` (~100 行)
|
||||
- 新增: `apps/web/src/pages/plugins/hooks/useGraphData.ts` (~80 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/GraphCanvas.tsx` (~200 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/GraphToolbar.tsx` (~60 行)
|
||||
- 修改: `apps/web/src/pages/plugins/PluginGraphPage.tsx` (缩减至 ~60 行)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. `useGraphData.ts`:数据加载、边/节点格式转换、字段映射
|
||||
2. `useGraphLayout.ts`:Dagre/elkjs 布局算法、节点位置计算、自动布局触发
|
||||
3. `GraphCanvas.tsx`:ReactFlow 渲染、自定义节点样式、拖拽交互
|
||||
4. `GraphToolbar.tsx`:缩放控制、自动布局、布局方向切换
|
||||
5. 容器组件组装以上模块
|
||||
|
||||
**验收标准**:
|
||||
- 插件关系图页面正常渲染和交互
|
||||
- 拖拽节点、自动布局、缩放功能正常
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Organizations.tsx 抽象 TreeEntityManager
|
||||
|
||||
**目标**: 将 622 行的 Organizations.tsx 按三层模式拆分。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/src/pages/system/hooks/useOrgTree.ts` (~80 行)
|
||||
- 新增: `apps/web/src/pages/system/components/OrgTree.tsx` (~120 行)
|
||||
- 新增: `apps/web/src/pages/system/components/OrgDetail.tsx` (~150 行)
|
||||
- 新增: `apps/web/src/pages/system/components/DeptMemberList.tsx` (~100 行)
|
||||
- 修改: `apps/web/src/pages/system/Organizations.tsx` (缩减至 ~60 行)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. `useOrgTree.ts`:树数据加载、CRUD 操作、选中节点状态
|
||||
2. `OrgTree.tsx`:左侧树形选择(DirectoryTree + 搜索 + 右键菜单)
|
||||
3. `OrgDetail.tsx`:右侧组织详情/编辑表单
|
||||
4. `DeptMemberList.tsx`:部门成员列表 + 人员分配 Modal
|
||||
5. 容器组件三栏布局组装
|
||||
|
||||
**验收标准**:
|
||||
- 组织管理 CRUD 功能正常(新增/编辑/删除组织、部门、人员分配)
|
||||
- 树形选择、搜索过滤正常
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
### Task 7: StatisticsDashboard 拆分为独立卡片组件
|
||||
|
||||
**目标**: 将 580 行的 StatisticsDashboard.tsx 拆为 hook + 独立图表卡片。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/src/pages/health/hooks/useStatsData.ts` (~100 行)
|
||||
- 新增: `apps/web/src/pages/health/components/PatientTrendChart.tsx` (~80 行)
|
||||
- 新增: `apps/web/src/pages/health/components/AppointmentStats.tsx` (~80 行)
|
||||
- 新增: `apps/web/src/pages/health/components/OverviewCards.tsx` (~60 行)
|
||||
- 新增: `apps/web/src/pages/health/components/TimeRangeSelector.tsx` (~40 行)
|
||||
- 修改: `apps/web/src/pages/health/StatisticsDashboard.tsx` (缩减至 ~50 行)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. `useStatsData.ts`:五个统计 API 并行加载、loading/error 状态、时间范围变更触发刷新
|
||||
2. `PatientTrendChart.tsx`:患者趋势折线图(@ant-design/charts Line)
|
||||
3. `AppointmentStats.tsx`:预约统计饼图/柱状图
|
||||
4. `OverviewCards.tsx`:概览数字卡片组(Statistic + Card)
|
||||
5. `TimeRangeSelector.tsx`:日期范围选择 + 快捷选项(近7天/近30天/近90天)
|
||||
6. 容器组件组装,布局使用 Row + Col
|
||||
|
||||
**验收标准**:
|
||||
- 统计仪表板页面渲染正常,图表数据正确
|
||||
- 时间范围切换触发数据刷新
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Bundle 优化(Day 6-7)
|
||||
|
||||
### Task 8: vite.config.ts manualChunks 拆分重型依赖
|
||||
|
||||
**目标**: 将 @ant-design/charts、@xyflow/react、@wangeditor/editor 拆为独立 chunk,降低主 chunk 体积。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/vite.config.ts`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 在 `manualChunks` 配置中新增三条规则:
|
||||
```typescript
|
||||
if (id.includes('@ant-design/charts') || id.includes('@antv/')) return 'vendor-charts';
|
||||
if (id.includes('@xyflow/react') || id.includes('@reactflow/')) return 'vendor-flow';
|
||||
if (id.includes('@wangeditor/')) return 'vendor-editor';
|
||||
```
|
||||
2. 对应页面添加路由级 `React.lazy()`:
|
||||
- `StatisticsDashboard` → `lazy(() => import('./health/StatisticsDashboard'))`
|
||||
- `PluginGraphPage` → `lazy(() => import('./plugins/PluginGraphPage'))`
|
||||
- `ArticleEditor` → `lazy(() => import('./health/ArticleEditor'))`
|
||||
3. 将 `chunkSizeWarningLimit` 从 600 降至 500
|
||||
4. 运行 `pnpm build` 对比拆分前后各 chunk 大小
|
||||
|
||||
**验收标准**:
|
||||
- 主 chunk 体积 < 400KB(gzip 前约 600KB 以内)
|
||||
- `vendor-charts`、`vendor-flow`、`vendor-editor` 独立生成
|
||||
- `pnpm build` 无警告
|
||||
- 统计仪表板、插件关系图、文章编辑器页面功能正常(懒加载无闪烁)
|
||||
|
||||
---
|
||||
|
||||
### Task 9: columns 配置 useMemo 化
|
||||
|
||||
**目标**: 消除 PluginCRUDPage 和健康模块列表页的 columns 重复创建,减少不必要的 re-render。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/pages/plugins/components/CRUDTable.tsx`(Phase 2 Task 4 产物)
|
||||
- 修改: `apps/web/src/pages/health/PatientList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/AppointmentList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/FollowUpTaskList.tsx`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 在每个列表页中,将 `columns` 数组定义包裹在 `useMemo` 中
|
||||
2. 依赖项包含 columns 中引用的回调函数(如 onDelete、onEdit)
|
||||
3. 确保回调函数通过 `useCallback` 缓存,避免 useMemo 失效
|
||||
4. 使用 React DevTools Profiler 验证翻页/筛选时减少不必要渲染
|
||||
|
||||
**验收标准**:
|
||||
- 列表翻页时 Table 组件不因 columns 引用变化触发全量渲染
|
||||
- 所有列表页功能正常(排序、筛选、操作按钮)
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
### Task 10: API 层新代码统一为对象风格
|
||||
|
||||
**目标**: 确认新增 API 文件采用对象风格(`xxxApi.list()` 而非 `listXxx()`),修改已有文件时顺手迁移。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/api/health/` 下近期新增的 API 文件(如 `alerts.ts`、`deviceReadings.ts`)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 审计 `apps/web/src/api/` 下所有文件,标记函数风格的文件清单
|
||||
2. 近期新增的文件(alerts、deviceReadings 等)统一改为对象风格:
|
||||
```typescript
|
||||
export const alertApi = {
|
||||
list: (params) => client.get('/alerts', { params }),
|
||||
acknowledge: (id) => client.post(`/alerts/${id}/acknowledge`),
|
||||
};
|
||||
```
|
||||
3. 更新引用处的 import(页面组件中的调用方式)
|
||||
4. 旧文件不强制迁移,仅记录待迁移清单
|
||||
|
||||
**验收标准**:
|
||||
- `alerts.ts` 和 `deviceReadings.ts` 为对象风格导出
|
||||
- 对应页面功能正常
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
## 执行原则
|
||||
|
||||
1. **每 Task 完成后立即提交** — 不积压,保持可追溯
|
||||
2. **先基础设施后拆分** — Phase 1 的 hook 增强完成后再做 Phase 2 组件拆分
|
||||
3. **每步验证** — 每个 Task 完成后 `pnpm build` 验证,拆分任务额外验证页面功能
|
||||
4. **渐进迁移** — 重复模式统一采用渐进策略,不一次性全量迁移
|
||||
@@ -1,200 +0,0 @@
|
||||
# 可观测性与运维基础设施实施计划
|
||||
|
||||
> 设计规格: `docs/superpowers/specs/2026-04-26-observability-and-ops-design.md`
|
||||
> 日期: 2026-04-26 | 总周期: 7-9 天
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 健康检查 + Prometheus 指标(Day 1-2)
|
||||
|
||||
### Task 1: 深度健康检查端点
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `crates/erp-server/src/handlers/health.rs`
|
||||
|
||||
**步骤**:
|
||||
1. 拆分为两个端点:
|
||||
- `GET /health/live` — 存活探针(仅返回 `{ status: "ok" }`,不依赖任何外部服务)
|
||||
- `GET /health/ready` — 就绪探针(验证 DB ping + Redis ping + 模块状态)
|
||||
2. `/health/ready` 实现:
|
||||
```rust
|
||||
async fn health_ready(State(state): State<AppState>) -> Json<HealthResponse> {
|
||||
let db_ok = sql_query("SELECT 1").execute(&state.db).await.is_ok();
|
||||
let redis_ok = state.redis.ping().await.is_ok();
|
||||
Json(HealthResponse { status: if db_ok && redis_ok { "ok" } else { "degraded" }, db: db_ok, redis: redis_ok, ... })
|
||||
}
|
||||
```
|
||||
3. 保持旧 `GET /health` 兼容(重定向到 `/health/ready`)
|
||||
|
||||
**验收**: `/health/ready` 在 DB/Redis 正常时返回 200,任一不可达时返回 503 + 降级详情
|
||||
|
||||
### Task 2: Prometheus 指标基础
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `crates/erp-server/Cargo.toml`(添加 `metrics` + `metrics-exporter-prometheus` 依赖)
|
||||
- 新增: `crates/erp-server/src/middleware/metrics.rs`
|
||||
- 修改: `crates/erp-server/src/main.rs`(注册 metrics middleware + 路由)
|
||||
|
||||
**步骤**:
|
||||
1. 在 `Cargo.toml` 添加:
|
||||
```toml
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = "0.16"
|
||||
```
|
||||
2. 创建 `metrics.rs` Axum middleware:
|
||||
- 记录每个请求的 `http_request_duration_seconds`(直方图,按 method/path/status 标签)
|
||||
- 记录 `http_requests_total`(计数器)
|
||||
3. 在 `main.rs` 启动 Prometheus exporter(`/metrics` 端点,端口 9090)
|
||||
4. 在 AppState 中注册 metrics recorder
|
||||
|
||||
**验收**: `curl localhost:9090/metrics` 返回 Prometheus 格式指标,包含请求延迟直方图
|
||||
|
||||
### Task 3: DB 连接池 + EventBus 积压指标
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `crates/erp-server/src/main.rs`
|
||||
- 修改: `crates/erp-core/src/events.rs`
|
||||
|
||||
**步骤**:
|
||||
1. DB 连接池指标:每 30 秒采样 `db_pool_connections_active` / `db_pool_connections_idle`
|
||||
2. EventBus 积压指标:在 `publish()` 中递增 `eventbus_pending_total`,在 relay 处理后递减
|
||||
3. 在 `/metrics` 端点暴露
|
||||
|
||||
**验收**: `/metrics` 包含 DB 连接池使用率和事件积压计数
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: OpenTelemetry + 生产 Docker(Day 3-5)
|
||||
|
||||
### Task 4: OpenTelemetry 条件集成
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `crates/erp-server/Cargo.toml`(添加 `opentelemetry` + `tracing-opentelemetry` + `opentelemetry-otlp`,optional feature)
|
||||
- 新增: `crates/erp-server/src/telemetry.rs`
|
||||
- 修改: `crates/erp-server/src/main.rs`
|
||||
|
||||
**步骤**:
|
||||
1. 添加 optional 依赖:
|
||||
```toml
|
||||
[features]
|
||||
tracing = ["opentelemetry", "tracing-opentelemetry", "opentelemetry-otlp"]
|
||||
```
|
||||
2. 创建 `telemetry.rs`:条件初始化 OpenTelemetry tracer(环境变量 `ERP__TELEMETRY__ENABLED=true` 时启用)
|
||||
3. 配置 OTLP exporter(默认 `http://localhost:4317`,可通过环境变量覆盖)
|
||||
4. 在 `main.rs` 的 tracing subscriber 中条件注册 OpenTelemetry layer
|
||||
5. 在 SeaORM 的 `DatabaseConnection` 包装中添加 span(记录查询耗时)
|
||||
|
||||
**验收**: 启用后 Jaeger/Tempo 可看到请求 → SQL 查询 → 事件发布的完整链路;不启用时零开销
|
||||
|
||||
### Task 5: 生产 Docker 多阶段构建
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `Dockerfile`(项目根目录)
|
||||
- 新增: `docker/docker-compose.production.yml`
|
||||
|
||||
**步骤**:
|
||||
1. 多阶段 Dockerfile:
|
||||
```dockerfile
|
||||
# Stage 1: Build
|
||||
FROM rust:1.82-bookworm AS builder
|
||||
COPY . /app
|
||||
RUN cargo build --release -p erp-server
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM debian:bookworm-slim
|
||||
COPY --from=builder /app/target/release/erp-server /usr/local/bin/
|
||||
COPY --from=builder /app/crates/erp-server/config/default.toml /etc/erp/config.toml
|
||||
EXPOSE 3000 9090
|
||||
CMD ["erp-server"]
|
||||
```
|
||||
2. `docker-compose.production.yml`:
|
||||
- erp-server 服务(限制 1 CPU / 512MB)
|
||||
- PostgreSQL 16 + Redis 7 作为独立服务
|
||||
- 健康检查配置(使用 /health/ready)
|
||||
- 环境变量注入(JWT secret / DB URL / Redis URL 通过 secrets)
|
||||
|
||||
**验收**: `docker build -t hms-server .` 成功,运行时镜像 < 80MB
|
||||
|
||||
### Task 6: 前端生产构建 + Nginx
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/Dockerfile`
|
||||
- 新增: `apps/web/nginx.conf`
|
||||
|
||||
**步骤**:
|
||||
1. 多阶段构建:node 构建 → nginx 运行
|
||||
2. Nginx 配置:SPA fallback + `/api` 代理到后端 3000 端口
|
||||
|
||||
**验收**: Docker 内前端可正常访问,API 代理工作
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 日志聚合 + 告警(Day 5-7)
|
||||
|
||||
### Task 7: Grafana Loki 日志集成
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `docker/loki-config.yaml`
|
||||
- 修改: `docker/docker-compose.production.yml`
|
||||
|
||||
**步骤**:
|
||||
1. 在 production compose 中添加 Loki + Promtail 服务
|
||||
2. Promtail 配置:读取 erp-server 的 JSON 日志输出
|
||||
3. Grafana 数据源配置:Loki + Prometheus
|
||||
|
||||
**验收**: Grafana 可查询和过滤后端日志
|
||||
|
||||
### Task 8: Prometheus 告警规则
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `docker/alert-rules.yml`
|
||||
|
||||
**步骤**:
|
||||
1. 定义 5 条告警规则:
|
||||
```yaml
|
||||
- alert: HighRequestLatency # P95 > 2s 持续 5 分钟
|
||||
- alert: HighErrorRate # 5xx 比率 > 5% 持续 3 分钟
|
||||
- alert: EventBusBacklog # 积压事件 > 100 持续 5 分钟
|
||||
- alert: DatabasePoolExhausted # 活跃连接 > 90% 持续 2 分钟
|
||||
- alert: HealthCheckDegraded # /health/ready 非 ok 持续 1 分钟
|
||||
```
|
||||
2. 配置 Alertmanager 通知渠道(Webhook/邮件)
|
||||
|
||||
**验收**: 触发告警条件时 Alertmanager 发送通知
|
||||
|
||||
### Task 9: Grafana Dashboard 模板
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `docker/grafana/dashboards/hms-overview.json`
|
||||
|
||||
**步骤**:
|
||||
1. 创建 HMS Overview Dashboard,包含面板:
|
||||
- 请求速率 + 延迟分布(P50/P95/P99)
|
||||
- 错误率趋势(按 status code 分组)
|
||||
- DB 连接池使用率
|
||||
- EventBus 发布/消费速率
|
||||
- 健康检查状态
|
||||
|
||||
**验收**: Dashboard 展示实时指标
|
||||
|
||||
### Task 10: 运维文档
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `wiki/observability.md`
|
||||
|
||||
**步骤**:
|
||||
1. 记录监控端点(/health/live, /health/ready, /metrics)
|
||||
2. 记录告警规则和响应流程
|
||||
3. 记录日志查询方法(Grafana Loki)
|
||||
4. 记录 Docker 部署命令
|
||||
|
||||
**验收**: 新团队成员可通过文档独立部署和排查问题
|
||||
|
||||
---
|
||||
|
||||
## 执行原则
|
||||
|
||||
1. **条件编译** — OpenTelemetry 使用 feature gate,不启用时零开销
|
||||
2. **渐进式** — Phase 1 可独立上线(无外部依赖),Phase 2/3 需要 Docker 环境
|
||||
3. **性能优先** — 指标收集使用 `metrics` crate 的无锁实现,不影响请求延迟
|
||||
4. **端口分离** — 业务 API (3000) + Metrics (9090) 分离,避免暴露内部指标
|
||||
@@ -1,714 +0,0 @@
|
||||
# 架构反思实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 落地架构反思三个结论 — WASM 评估量表插件、透析模块独立、P1 事件消费者补全。
|
||||
|
||||
**Architecture:** 三条独立工作线可并行推进。WASM 插件遵循 erp-plugin-test-sample 模式;透析模块拆分参照 erp-points 拆 crate 模式;事件消费者补全遵循现有 subscribe_filtered + tokio::spawn 模式。
|
||||
|
||||
**Tech Stack:** Rust/SeaORM/Axum/WASM(wit-bindgen 0.55)
|
||||
|
||||
**Spec:** `docs/discussions/2026-04-28-architecture-retrospective.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: WASM 评估量表插件(PHQ-9)
|
||||
|
||||
### Task 1: 创建插件 crate 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-assessment/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-assessment/src/lib.rs`
|
||||
- Create: `crates/erp-plugin-assessment/plugin.toml`
|
||||
|
||||
- [ ] **Step 1: 创建 Cargo.toml**
|
||||
|
||||
参照 `crates/erp-plugin-test-sample/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-assessment"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.55"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 plugin.toml**
|
||||
|
||||
```toml
|
||||
[metadata]
|
||||
id = "assessment"
|
||||
name = "评估量表"
|
||||
version = "0.1.0"
|
||||
description = "标准化医学评估量表(PHQ-9、GAD-7 等)"
|
||||
author = "HMS"
|
||||
min_platform_version = "0.1.0"
|
||||
|
||||
[[permissions]]
|
||||
code = "assessment_scale.list"
|
||||
name = "查看评估量表"
|
||||
description = "查看评估量表列表和详情"
|
||||
|
||||
[[permissions]]
|
||||
code = "assessment_scale.manage"
|
||||
name = "管理评估量表"
|
||||
description = "创建、编辑、删除评估量表"
|
||||
|
||||
[[permissions]]
|
||||
code = "assessment_response.list"
|
||||
name = "查看评估结果"
|
||||
description = "查看患者评估答卷"
|
||||
|
||||
[[permissions]]
|
||||
code = "assessment_response.manage"
|
||||
name = "管理评估结果"
|
||||
description = "提交、编辑评估答卷"
|
||||
|
||||
[[schema.entities]]
|
||||
name = "assessment_scale"
|
||||
display_name = "评估量表"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "scale_code"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "量表编码"
|
||||
unique = true
|
||||
ui_widget = "select"
|
||||
options = ["PHQ-9", "GAD-7", "SF-36", "MMSE", "ADL", "IADL"]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "title"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "量表名称"
|
||||
searchable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "description"
|
||||
field_type = "string"
|
||||
display_name = "描述"
|
||||
ui_widget = "textarea"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "questions_json"
|
||||
field_type = "json"
|
||||
required = true
|
||||
display_name = "题目定义(JSON)"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "scoring_rules_json"
|
||||
field_type = "json"
|
||||
required = true
|
||||
display_name = "评分规则(JSON)"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "status"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "状态"
|
||||
default = "active"
|
||||
ui_widget = "select"
|
||||
options = ["active", "inactive"]
|
||||
|
||||
[[schema.entities]]
|
||||
name = "assessment_response"
|
||||
display_name = "评估答卷"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "scale_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "量表"
|
||||
ui_widget = "entity_select"
|
||||
ref_entity = "assessment_scale"
|
||||
ref_plugin = "assessment"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "patient_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "患者 ID"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "answers_json"
|
||||
field_type = "json"
|
||||
required = true
|
||||
display_name = "答案(JSON)"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "total_score"
|
||||
field_type = "integer"
|
||||
required = true
|
||||
display_name = "总分"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "severity_level"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "严重程度"
|
||||
ui_widget = "select"
|
||||
options = ["normal", "mild", "moderate", "severe"]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "assessed_by"
|
||||
field_type = "uuid"
|
||||
display_name = "评估人"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "status"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "状态"
|
||||
default = "completed"
|
||||
ui_widget = "select"
|
||||
options = ["draft", "completed", "reviewed"]
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "assessment_scale"
|
||||
foreign_key = "scale_id"
|
||||
on_delete = "restrict"
|
||||
name = "scale"
|
||||
type = "belongs_to"
|
||||
display_field = "title"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "assessment_completed"
|
||||
display_name = "评估完成"
|
||||
description = "患者完成评估量表,触发评分计算和后续流程"
|
||||
entity = "assessment_response"
|
||||
on = "create"
|
||||
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
label = "评估量表"
|
||||
icon = "FormOutlined"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 创建 src/lib.rs**
|
||||
|
||||
```rust
|
||||
// crates/erp-plugin-assessment/src/lib.rs
|
||||
//! 评估量表插件 — 标准化医学评估(PHQ-9, GAD-7 等)
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "../../crates/erp-plugin/src/wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||
use crate::erp::plugin::host_api::*;
|
||||
|
||||
struct AssessmentPlugin;
|
||||
|
||||
impl Guest for AssessmentPlugin {
|
||||
fn init() -> Result<(), String> {
|
||||
log_write("info", "AssessmentPlugin initialized");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
|
||||
log_write("info", &format!("AssessmentPlugin: tenant {} created", tenant_id));
|
||||
// 可以为新租户插入默认量表(PHQ-9、GAD-7)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(
|
||||
event_type: String,
|
||||
_event_id: String,
|
||||
_tenant_id: String,
|
||||
_payload: String,
|
||||
) -> Result<(), String> {
|
||||
log_write("debug", &format!("AssessmentPlugin received: {}", event_type));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
export!(AssessmentPlugin);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 注册到 workspace**
|
||||
|
||||
在根 `Cargo.toml` 的 `workspace.members` 中添加 `"crates/erp-plugin-assessment"`。
|
||||
|
||||
- [ ] **Step 5: 编译验证**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-assessment
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-assessment/ Cargo.toml
|
||||
git commit -m "feat(plugin): 评估量表插件骨架 — assessment_scale + assessment_response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: PHQ-9 默认量表数据 + 评分逻辑
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-plugin-assessment/src/lib.rs`(on_tenant_created 插入默认量表)
|
||||
|
||||
- [ ] **Step 1: 在 on_tenant_created 中插入 PHQ-9 默认数据**
|
||||
|
||||
PHQ-9 的 9 道题(每题 0-3 分)和评分规则:
|
||||
|
||||
```json
|
||||
// questions_json
|
||||
[
|
||||
{"id": 1, "text": "做事时提不起劲或没有兴趣", "options": [{"label": "完全不会", "score": 0}, {"label": "好几天", "score": 1}, {"label": "一半以上的天数", "score": 2}, {"label": "几乎每天", "score": 3}]},
|
||||
// ... 共 9 题
|
||||
]
|
||||
|
||||
// scoring_rules_json
|
||||
[
|
||||
{"min": 0, "max": 4, "level": "normal", "label": "无抑郁症状"},
|
||||
{"min": 5, "max": 9, "level": "mild", "label": "轻度抑郁"},
|
||||
{"min": 10, "max": 14, "level": "moderate", "label": "中度抑郁"},
|
||||
{"min": 15, "max": 19, "level": "moderate_severe", "label": "中重度抑郁"},
|
||||
{"min": 20, "max": 27, "level": "severe", "label": "重度抑郁"}
|
||||
]
|
||||
```
|
||||
|
||||
通过 `db_insert` host API 在 `on_tenant_created` 中插入。
|
||||
|
||||
- [ ] **Step 2: 编译 + 验证**
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(plugin): PHQ-9 默认量表数据 + 评分规则"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 编译 WASM + 注册到 erp-server
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-server/src/main.rs`(插件注册,如需手动加载)
|
||||
- Verify: WASM 编译输出
|
||||
|
||||
- [ ] **Step 1: 编译为 WASM Component**
|
||||
|
||||
```bash
|
||||
cd crates/erp-plugin-assessment
|
||||
cargo build --target wasm32-unknown-unknown --release
|
||||
# 或使用项目内的 WASM 编译脚本
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证插件加载**
|
||||
|
||||
启动后端,确认插件系统识别 assessment 插件,动态表创建成功。
|
||||
|
||||
- [ ] **Step 3: 通过 API 测试评估量表 CRUD**
|
||||
|
||||
```bash
|
||||
# 创建量表
|
||||
curl -X POST /api/v1/plugin/assessment/assessment_scale \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"scale_code": "PHQ-9", ...}'
|
||||
|
||||
# 提交答卷
|
||||
curl -X POST /api/v1/plugin/assessment/assessment_response \
|
||||
-d '{"scale_id": "...", "patient_id": "...", "answers_json": [...]}'
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(plugin): 评估量表 WASM 编译 + 端到端验证"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 透析模块拆分为 erp-dialysis
|
||||
|
||||
### Task 4: 创建 erp-dialysis crate 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-dialysis/Cargo.toml`
|
||||
- Create: `crates/erp-dialysis/src/{lib,module,state,error}.rs`
|
||||
- Modify: `Cargo.toml`(workspace members)
|
||||
|
||||
- [ ] **Step 1: 创建 Cargo.toml**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-dialysis"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
sea-orm.workspace = true
|
||||
tokio.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
axum.workspace = true
|
||||
utoipa.workspace = true
|
||||
validator.workspace = true
|
||||
async-trait.workspace = true
|
||||
tracing.workspace = true
|
||||
rust_decimal.workspace = true
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建标准模块文件**
|
||||
|
||||
```rust
|
||||
// crates/erp-dialysis/src/lib.rs
|
||||
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::DialysisModule;
|
||||
pub use state::DialysisState;
|
||||
```
|
||||
|
||||
```rust
|
||||
// crates/erp-dialysis/src/module.rs
|
||||
//! ErpModule trait 实现
|
||||
|
||||
use async_trait::async_trait;
|
||||
use erp_core::module::{ErpModule, ModuleContext, ModuleType, PermissionDescriptor};
|
||||
use erp_core::events::EventBus;
|
||||
use crate::state::DialysisState;
|
||||
|
||||
pub struct DialysisModule {
|
||||
state: DialysisState,
|
||||
}
|
||||
|
||||
impl DialysisModule {
|
||||
pub fn new(db: sea_orm::DatabaseConnection, event_bus: EventBus) -> Self {
|
||||
Self { state: DialysisState { db, event_bus } }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ErpModule for DialysisModule {
|
||||
fn name(&self) -> &str { "透析管理" }
|
||||
fn id(&self) -> &str { "erp-dialysis" }
|
||||
fn version(&self) -> &str { "0.1.0" }
|
||||
fn module_type(&self) -> ModuleType { ModuleType::Builtin }
|
||||
|
||||
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||||
vec![
|
||||
PermissionDescriptor { code: "dialysis.record.list".into(), name: "查看透析记录".into() },
|
||||
PermissionDescriptor { code: "dialysis.record.manage".into(), name: "管理透析记录".into() },
|
||||
PermissionDescriptor { code: "dialysis.prescription.list".into(), name: "查看透析处方".into() },
|
||||
PermissionDescriptor { code: "dialysis.prescription.manage".into(), name: "管理透析处方".into() },
|
||||
]
|
||||
}
|
||||
|
||||
fn on_startup(&self, ctx: &ModuleContext) {
|
||||
crate::event::register_handlers_with_state(self.state.clone());
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 注册到 workspace**
|
||||
|
||||
在根 `Cargo.toml` 的 members 中添加 `"crates/erp-dialysis"`。
|
||||
|
||||
- [ ] **Step 4: 编译验证**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-dialysis
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(dialysis): 创建 erp-dialysis crate 骨架 + ErpModule 实现"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 迁移透析 Entity + Service + Handler + DTO
|
||||
|
||||
**Files:**
|
||||
- Move: 6 个文件从 `erp-health` → `erp-dialysis`
|
||||
- Modify: `crates/erp-health/src/{entity,service,handler,dto}/mod.rs`(删除透析导出)
|
||||
- Modify: 迁移文件中的 `crate::` 引用改为 `erp_core::` 或 erp-dialysis 内部引用
|
||||
|
||||
**待迁移文件清单:**
|
||||
|
||||
| 来源 | 目标 | 行数 |
|
||||
|------|------|------|
|
||||
| `erp-health/src/entity/dialysis_record.rs` | `erp-dialysis/src/entity/` | 82 |
|
||||
| `erp-health/src/entity/dialysis_prescription.rs` | `erp-dialysis/src/entity/` | 78 |
|
||||
| `erp-health/src/service/dialysis_service.rs` | `erp-dialysis/src/service/` | 333 |
|
||||
| `erp-health/src/service/dialysis_prescription_service.rs` | `erp-dialysis/src/service/` | 274 |
|
||||
| `erp-health/src/handler/dialysis_handler.rs` | `erp-dialysis/src/handler/` | 145 |
|
||||
| `erp-health/src/handler/dialysis_prescription_handler.rs` | `erp-dialysis/src/handler/` | 120 |
|
||||
| `erp-health/src/dto/dialysis_dto.rs` | `erp-dialysis/src/dto/` | 125 |
|
||||
| `erp-health/src/dto/dialysis_prescription_dto.rs` | `erp-dialysis/src/dto/` | 107 |
|
||||
|
||||
- [ ] **Step 1: 复制文件到 erp-dialysis**
|
||||
|
||||
```bash
|
||||
# Entity
|
||||
cp crates/erp-health/src/entity/dialysis_record.rs crates/erp-dialysis/src/entity/
|
||||
cp crates/erp-health/src/entity/dialysis_prescription.rs crates/erp-dialysis/src/entity/
|
||||
# Service
|
||||
cp crates/erp-health/src/service/dialysis_service.rs crates/erp-dialysis/src/service/
|
||||
cp crates/erp-health/src/service/dialysis_prescription_service.rs crates/erp-dialysis/src/service/
|
||||
# Handler
|
||||
cp crates/erp-health/src/handler/dialysis_handler.rs crates/erp-dialysis/src/handler/
|
||||
cp crates/erp-health/src/handler/dialysis_prescription_handler.rs crates/erp-dialysis/src/handler/
|
||||
# DTO
|
||||
cp crates/erp-health/src/dto/dialysis_dto.rs crates/erp-dialysis/src/dto/
|
||||
cp crates/erp-health/src/dto/dialysis_prescription_dto.rs crates/erp-dialysis/src/dto/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新 crate 内引用**
|
||||
|
||||
全局替换:
|
||||
- `crate::state::HealthState` → `crate::state::DialysisState`
|
||||
- `crate::error::{HealthError, HealthResult}` → `crate::error::{DialysisError, DialysisResult}`
|
||||
- `crate::entity::` → 保持不变(同 crate 内)
|
||||
- `crate::dto::` → 保持不变
|
||||
- `crate::service::` → 保持不变
|
||||
|
||||
- [ ] **Step 3: 创建 error.rs**
|
||||
|
||||
```rust
|
||||
// crates/erp-dialysis/src/error.rs
|
||||
use erp_core::error::AppError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DialysisError {
|
||||
#[error("透析记录未找到: {0}")]
|
||||
RecordNotFound(uuid::Uuid),
|
||||
#[error("处方未找到: {0}")]
|
||||
PrescriptionNotFound(uuid::Uuid),
|
||||
#[error("状态转换无效: {0} → {1}")]
|
||||
InvalidStatusTransition(String, String),
|
||||
#[error("版本冲突")]
|
||||
VersionConflict,
|
||||
}
|
||||
|
||||
impl From<DialysisError> for AppError {
|
||||
fn from(e: DialysisError) -> Self { AppError::Business(e.to_string()) }
|
||||
}
|
||||
|
||||
pub type DialysisResult<T> = Result<T, DialysisError>;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 从 erp-health 删除透析代码**
|
||||
|
||||
从以下 mod.rs 中移除透析相关 `pub mod` 声明:
|
||||
- `crates/erp-health/src/entity/mod.rs`
|
||||
- `crates/erp-health/src/service/mod.rs`
|
||||
- `crates/erp-health/src/handler/mod.rs`
|
||||
- `crates/erp-health/src/dto/mod.rs`
|
||||
|
||||
- [ ] **Step 5: 在 erp-server 注册新模块**
|
||||
|
||||
在 `crates/erp-server/src/main.rs` 中:
|
||||
- 添加 `use erp_dialysis::DialysisModule;`
|
||||
- 在 registry 链中 `.register(dialysis_module)`
|
||||
- 在路由 merge 中 `.merge(erp_dialysis::DialysisModule::protected_routes())`
|
||||
|
||||
- [ ] **Step 6: 编译 + 全链路验证**
|
||||
|
||||
```bash
|
||||
cargo check --workspace
|
||||
# 启动后端,验证透析相关 API 正常
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor: 透析模块拆分为独立 erp-dialysis crate(2 Entity + 2 Service)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: P1 事件消费者补全
|
||||
|
||||
### Task 6: patient.created → 欢迎消息 + 默认随访
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-health/src/event.rs`(添加消费者)
|
||||
|
||||
- [ ] **Step 1: 在 register_handlers_with_state 中添加 patient.created 消费者**
|
||||
|
||||
```rust
|
||||
// 在 register_handlers_with_state() 中新增:
|
||||
let (mut patient_rx, _) = state.event_bus.subscribe_filtered("patient.".to_string());
|
||||
let patient_db = state.db.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match patient_rx.recv().await {
|
||||
Some(event) if event.event_type == PATIENT_CREATED => {
|
||||
if erp_core::events::is_event_processed(&patient_db, event.id, "patient_welcome").await.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| Uuid::parse_str(s).ok());
|
||||
if let Some(pid) = patient_id {
|
||||
// 1. 发布欢迎消息事件(消息模块消费后发送站内通知)
|
||||
let welcome_event = DomainEvent::new(
|
||||
"message.send",
|
||||
event.tenant_id,
|
||||
erp_core::events::build_event_payload(serde_json::json!({
|
||||
"template": "patient_welcome",
|
||||
"recipient_type": "patient",
|
||||
"recipient_id": pid,
|
||||
})),
|
||||
);
|
||||
// 2. TODO: 创建默认随访计划(后续迭代)
|
||||
tracing::info!(patient_id = %pid, "新患者欢迎流程触发");
|
||||
}
|
||||
let _ = erp_core::events::mark_event_processed(&patient_db, event.id, "patient_welcome").await;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译验证**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-health
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(health): patient.created 消费者 — 新患者欢迎消息"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: appointment.confirmed/cancelled → 通知 + 号源
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-health/src/event.rs`
|
||||
|
||||
- [ ] **Step 1: 添加 appointment 事件消费者**
|
||||
|
||||
```rust
|
||||
let (mut appt_rx, _) = state.event_bus.subscribe_filtered("appointment.".to_string());
|
||||
let appt_db = state.db.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match appt_rx.recv().await {
|
||||
Some(event) if event.event_type == "appointment.confirmed" => {
|
||||
if erp_core::events::is_event_processed(&appt_db, event.id, "appointment_notifier").await.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
// 通知医生
|
||||
let doctor_id = event.payload.get("doctor_id").and_then(|v| v.as_str());
|
||||
let patient_id = event.payload.get("patient_id").and_then(|v| v.as_str());
|
||||
if let (Some(did), Some(pid)) = (doctor_id, patient_id) {
|
||||
tracing::info!(doctor_id = did, patient_id = pid, "预约确认通知触发");
|
||||
// 发布通知事件
|
||||
}
|
||||
let _ = erp_core::events::mark_event_processed(&appt_db, event.id, "appointment_notifier").await;
|
||||
}
|
||||
Some(event) if event.event_type == "appointment.cancelled" => {
|
||||
// 释放号源 + 通知排队患者
|
||||
tracing::info!(event_id = %event.id, "预约取消,号源释放");
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译 + 提交**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-health
|
||||
git commit -m "feat(health): appointment 事件消费者 — 预约确认/取消通知"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: follow_up.overdue → 升级通知
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-health/src/event.rs`
|
||||
|
||||
- [ ] **Step 1: 添加 follow_up.overdue 消费者**
|
||||
|
||||
```rust
|
||||
// 在 register_handlers_with_state 中:
|
||||
// 注意:follow_up 事件的前缀是 "follow_up."
|
||||
let (mut fu_rx, _) = state.event_bus.subscribe_filtered("follow_up.".to_string());
|
||||
let fu_db = state.db.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match fu_rx.recv().await {
|
||||
Some(event) if event.event_type == FOLLOW_UP_OVERDUE => {
|
||||
if erp_core::events::is_event_processed(&fu_db, event.id, "follow_up_escalator").await.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let task_id = event.payload.get("task_id").and_then(|v| v.as_str());
|
||||
let assigned_to = event.payload.get("assigned_to").and_then(|v| v.as_str());
|
||||
if let (Some(tid), Some(uid)) = (task_id, assigned_to) {
|
||||
// 通知随访负责人 + 科室主管
|
||||
tracing::warn!(task_id = tid, assigned_to = uid, "随访逾期升级通知");
|
||||
}
|
||||
let _ = erp_core::events::mark_event_processed(&fu_db, event.id, "follow_up_escalator").await;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译 + 提交**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-health
|
||||
git commit -m "feat(health): follow_up.overdue 消费者 — 逾期随访升级通知"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
| Chunk | Tasks | 内容 | 预估 |
|
||||
|-------|-------|------|------|
|
||||
| 1 | T1-T3 | WASM 评估量表插件(PHQ-9) | 1-2 天 |
|
||||
| 2 | T4-T5 | 透析模块拆 erp-dialysis | 1 天 |
|
||||
| 3 | T6-T8 | P1 事件消费者补全(3 个) | 0.5-1 天 |
|
||||
|
||||
**总计 8 个 Task,预估 2.5-4 天。**
|
||||
|
||||
**依赖关系:**
|
||||
- T1→T2→T3 串行(WASM 插件逐层构建)
|
||||
- T4→T5 串行(先骨架再迁移)
|
||||
- T6/T7/T8 可并行(独立消费者)
|
||||
- Chunk 1/2/3 相互独立,可完全并行
|
||||
|
||||
**与技术债计划的关系:**
|
||||
- 本计划的 Chunk 3(事件消费者)应在技术债批次 B(EventBus dead-letter)之后执行
|
||||
- Chunk 2(透析拆分)应在技术债批次 A(安全修复)之后执行,避免合并冲突
|
||||
- Chunk 1(WASM 插件)完全独立,随时可执行
|
||||
@@ -1,476 +0,0 @@
|
||||
# V1 客户演示准备 — 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 修复已知 CRITICAL 问题,预置演示数据,完成 DRY RUN 验证,确保 V1 客户演示 7 个场景端到端无阻塞。
|
||||
|
||||
**Architecture:** 按依赖关系分 6 个 Task:先修 CRITICAL(Token 竞态),再验证关键链路(告警、AI),然后预置数据,最后全链路冒烟。每个 Task 独立可提交。
|
||||
|
||||
**Tech Stack:** Rust (SeaORM + Axum), TypeScript/React (Web 前端), SQL (数据预置), Taro (小程序)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-09-v1-customer-demo-plan-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Modify | `crates/erp-auth/src/service/token_service.rs:156-176` | revoke 改为原子操作 |
|
||||
| Modify | `crates/erp-auth/src/service/auth_service.rs:187-258` | refresh 流程使用原子 revoke |
|
||||
| Create | `crates/erp-server/tests/integration/auth_concurrent_tests.rs` | 并发刷新测试 |
|
||||
| Create | `scripts/demo-seed.sql` | 演示数据预置脚本 |
|
||||
| Verify | `crates/erp-health/src/service/seed.rs` | 确认告警规则覆盖演示场景 |
|
||||
| Verify | `apps/web/src/pages/health/components/LabReportsTab.tsx:36-57` | 确认 AI 触发按钮可用 |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Token 刷新竞态修复
|
||||
|
||||
### Task 1: 修复 Token 刷新并发竞态(CRITICAL)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/service/token_service.rs` — 新增 `revoke_by_hash_atomic` 方法
|
||||
- Modify: `crates/erp-auth/src/service/auth_service.rs:193-197` — refresh 中改用原子操作
|
||||
- Create: `crates/erp-server/tests/integration/auth_concurrent_tests.rs`
|
||||
|
||||
**设计说明:** JWT claims 中没有 token 数据库 ID(`id` 列),只有 `sub`(user_id) 和 `tid`(tenant_id)。因此原子 CAS 应该使用 `token_hash` 作为匹配条件——先用 JWT 解码获取原始 token,计算 SHA-256 哈希,再用 `UPDATE WHERE token_hash = ? AND revoked_at IS NULL` 做原子操作。这样不需要修改 JWT 结构。
|
||||
|
||||
- [ ] **Step 1: 在 token_service.rs 新增 `revoke_by_hash_atomic` 方法**
|
||||
|
||||
在 `crates/erp-auth/src/service/token_service.rs` 第 176 行(`revoke_token` 方法之后)新增:
|
||||
|
||||
```rust
|
||||
/// 原子操作:通过 token_hash 验证并撤销 refresh token。
|
||||
/// 如果 token 已被撤销(rows_affected == 0),返回 AuthError::TokenRevoked。
|
||||
pub async fn revoke_by_hash_atomic(
|
||||
db: &DatabaseConnection,
|
||||
token_hash: &str,
|
||||
user_id: Uuid,
|
||||
) -> AuthResult<()> {
|
||||
use user_token::Entity as UserToken;
|
||||
let result = UserToken::update_many()
|
||||
.col_expr(
|
||||
user_token::Column::RevokedAt,
|
||||
sea_orm::sea_query::Expr::value(Some(chrono::Utc::now().naive_utc())),
|
||||
)
|
||||
.filter(user_token::Column::TokenHash.eq(token_hash))
|
||||
.filter(user_token::Column::UserId.eq(user_id))
|
||||
.filter(user_token::Column::RevokedAt.is_null())
|
||||
.exec(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(AuthError::TokenRevoked);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
需要新增导入:`use sea_orm::sea_query::Expr;`(参考 `consultation_service.rs:683` 的模式)
|
||||
|
||||
- [ ] **Step 2: 改造 auth_service.rs 的 refresh 流程**
|
||||
|
||||
在 `crates/erp-auth/src/service/auth_service.rs:193-197`,将当前的 validate + revoke 两步替换:
|
||||
|
||||
```rust
|
||||
// 旧代码(第 193-197 行):
|
||||
// let claims = TokenService::validate_refresh_token(&self.token, &self.db).await?;
|
||||
// TokenService::revoke_token(&self.db, &claims.token_id, claims.user_id).await?;
|
||||
|
||||
// 新代码:
|
||||
// 1. JWT 解码获取 claims(不查数据库)
|
||||
let claims = TokenService::decode_refresh_token(&self.token)?;
|
||||
// 2. 计算 token 的 SHA-256 哈希
|
||||
let token_hash = TokenService::hash_token(&self.token);
|
||||
// 3. 原子操作:通过 hash 验证 + 撤销(CAS)
|
||||
TokenService::revoke_by_hash_atomic(&self.db, &token_hash, claims.sub.parse()?).await?;
|
||||
// 4. 后续:查询用户角色权限(第 200-201 行,不变)
|
||||
```
|
||||
|
||||
注意:需要确认 `decode_refresh_token`(仅 JWT 解码)和 `hash_token`(SHA-256 计算)是否已是公开方法。如果 `validate_refresh_token` 内部已有这些逻辑,需要拆分为独立方法。
|
||||
|
||||
- [ ] **Step 3: 编译检查**
|
||||
|
||||
Run: `cargo check --package erp-auth`
|
||||
Expected: 编译通过,无错误
|
||||
|
||||
- [ ] **Step 4: 写并发刷新测试**
|
||||
|
||||
在 `crates/erp-server/tests/integration/auth_concurrent_tests.rs` 中:
|
||||
|
||||
```rust
|
||||
use crate::test_db::TestDb;
|
||||
use erp_auth::service::auth_service::AuthService;
|
||||
use erp_core::config::JwtConfig;
|
||||
|
||||
async fn setup_test_user(db: &TestDb) -> (Uuid, String, String) {
|
||||
// 创建测试用户,返回 (user_id, access_token, refresh_token)
|
||||
// 复用现有集成测试中的用户创建逻辑
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_rotates_token() {
|
||||
let db = TestDb::new().await;
|
||||
let (_user_id, _, refresh_token) = setup_test_user(&db).await;
|
||||
let jwt_config = JwtConfig::default();
|
||||
|
||||
let svc = AuthService::new(db.conn(), &jwt_config);
|
||||
// 第一次 refresh → 成功
|
||||
let result = svc.refresh(&refresh_token).await;
|
||||
assert!(result.is_ok(), "第一次 refresh 应成功");
|
||||
let new_tokens = result.unwrap();
|
||||
// 用旧 refresh_token 再次 refresh → 必须失败
|
||||
let result2 = svc.refresh(&refresh_token).await;
|
||||
assert!(result2.is_err(), "旧 token 必须不可用");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_refresh_token_reuse() {
|
||||
let db = TestDb::new().await;
|
||||
let (_user_id, _, refresh_token) = setup_test_user(&db).await;
|
||||
let jwt_config = JwtConfig::default();
|
||||
|
||||
let svc = AuthService::new(db.conn(), &jwt_config);
|
||||
let token_clone = refresh_token.clone();
|
||||
let svc_clone = // 需要确认 AuthService 是否可 Clone 或用 Arc
|
||||
|
||||
// 使用 tokio::spawn 并发发两个 refresh
|
||||
let handle1 = tokio::spawn(async move { svc.refresh(&refresh_token).await });
|
||||
let handle2 = tokio::spawn(async move { svc_clone.refresh(&token_clone).await });
|
||||
|
||||
let r1 = handle1.await.unwrap();
|
||||
let r2 = handle2.await.unwrap();
|
||||
|
||||
// 恰好一个成功、一个失败
|
||||
let ok_count = [&r1, &r2].iter().filter(|r| r.is_ok()).count();
|
||||
assert_eq!(ok_count, 1, "并发 refresh 中恰好一个成功,另一个失败");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行全部认证测试**
|
||||
|
||||
Run: `cargo test --package erp-auth`
|
||||
Expected: 全部通过
|
||||
|
||||
Run: `cargo test --package erp-server --test integration auth`
|
||||
Expected: 全部通过
|
||||
|
||||
Run: `cargo test --package erp-server --test integration auth_concurrent -- --nocapture`
|
||||
Expected: 两个测试 PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/service/token_service.rs \
|
||||
crates/erp-auth/src/service/auth_service.rs \
|
||||
crates/erp-server/tests/integration/auth_concurrent_tests.rs
|
||||
git commit -m "fix(auth): 修复 Token 刷新并发竞态条件
|
||||
|
||||
使用原子 CAS(UPDATE WHERE token_hash = ? AND revoked_at IS NULL)
|
||||
替代先查后改的非原子操作,防止同一 refresh token 被并发使用两次。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 演示链路验证
|
||||
|
||||
### Task 2: 验证告警链路(场景 5 依赖)
|
||||
|
||||
**Files:**
|
||||
- Verify: `crates/erp-health/src/service/seed.rs` — 确认告警规则
|
||||
- Verify: `apps/web/src/pages/health/AlertDashboard.tsx:51` — 确认权限码
|
||||
- Verify: `crates/erp-health/src/handler/alert_handler.rs:82-115` — 确认操作端点
|
||||
|
||||
- [ ] **Step 1: 启动后端服务**
|
||||
|
||||
Run: `cd crates/erp-server && cargo run`
|
||||
Expected: 服务无 panic 启动在 localhost:3000
|
||||
|
||||
- [ ] **Step 2: 查询已有告警规则**
|
||||
|
||||
Run: `curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:3000/api/v1/health/alert-rules | jq '.data.items[] | {name, metric, operator, threshold}'`
|
||||
|
||||
Expected: 返回 10 条默认规则,包括:
|
||||
- 收缩压偏高 (>=140)
|
||||
- 收缩压危急 (>=180)
|
||||
|
||||
场景 5 需要"张大爷录入血压 168 触发告警"→ 使用已有的"收缩压危急 >=180"不够,需要调整场景 5 话术用血压 185,或添加一条 >=160 的规则。
|
||||
|
||||
- [ ] **Step 3: 手动测试告警触发**
|
||||
|
||||
```
|
||||
1. 以 nurse1 登录 Web
|
||||
2. 找到张大爷患者详情页
|
||||
3. 录入体征:收缩压 185 / 舒张压 95
|
||||
4. 切到告警仪表盘页面
|
||||
5. 确认出现告警条目
|
||||
6. 点击「确认」→ 状态变为已确认
|
||||
7. 点击「处理」→ 输入备注 → 状态变为已处理
|
||||
```
|
||||
|
||||
Expected: 全流程无 403、无 500
|
||||
|
||||
- [ ] **Step 4: 记录验证结果**
|
||||
|
||||
在文件头部注释验证结果。如果告警权限码正确(`health.alerts.manage`),记录为 ✅。
|
||||
如果发现任何问题,记录具体报错信息,新建 Task 修复。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 验证 AI 分析触发(场景 2 依赖)
|
||||
|
||||
**Files:**
|
||||
- Verify: `apps/web/src/pages/health/components/LabReportsTab.tsx:177-183`
|
||||
- Verify: `apps/web/src/api/ai/analysisSse.ts`
|
||||
|
||||
- [ ] **Step 1: 确认 Ollama 模型就绪**
|
||||
|
||||
Run: `ollama list`
|
||||
Expected: 输出包含 `qwen3:4b`
|
||||
|
||||
如果没有:`ollama pull qwen3:4b`
|
||||
|
||||
- [ ] **Step 2: 手动触发 AI 分析**
|
||||
|
||||
```
|
||||
1. 以 admin 登录 Web
|
||||
2. 进入张大爷患者详情页
|
||||
3. 切到「化验报告」Tab
|
||||
4. 找到一条化验报告
|
||||
5. 点击「AI 解读」按钮
|
||||
6. 等待 SSE 流式输出
|
||||
```
|
||||
|
||||
Expected: AI 分析结果流式显示,无 500 错误
|
||||
|
||||
如果 AI 解读按钮不存在或化验报告为空 → 使用预案(预置截图),在脚本中标注
|
||||
|
||||
- [ ] **Step 3: 预置 AI 分析截图(预案)**
|
||||
|
||||
如果 AI 分析成功:截图保存到 `docs/demo/screenshots/ai-analysis.png`
|
||||
如果 AI 分析失败:在实施计划中标注使用备用话术
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 验证 health_manager 测试账号
|
||||
|
||||
**Files:**
|
||||
- Verify: 数据库 `users` 表
|
||||
- Reference: `crates/erp-server/migration/src/m20260506_000125_restructure_menus_and_roles.rs:123-126`
|
||||
|
||||
- [ ] **Step 1: 查询 health_manager 角色是否存在**
|
||||
|
||||
Run: `docker exec erp-postgres psql -U erp -c "SELECT id, name, code FROM roles WHERE code = 'health_manager'"`
|
||||
|
||||
Expected: 返回 1 行
|
||||
|
||||
- [ ] **Step 2: 查询是否有测试用户关联此角色**
|
||||
|
||||
Run: `docker exec erp-postgres psql -U erp -c "SELECT u.username, r.code FROM users u JOIN user_roles ur ON u.id = ur.user_id JOIN roles r ON ur.role_id = r.id WHERE r.code = 'health_manager'"`
|
||||
|
||||
Expected: 返回至少 1 个用户
|
||||
|
||||
如果没有用户:需要通过 Web 管理界面创建一个 `health_mgr` 用户并分配 `health_manager` 角色
|
||||
|
||||
- [ ] **Step 3: 验证 health_manager 用户可登录**
|
||||
|
||||
用 health_manager 用户名 + `Admin@2026` 密码尝试登录 Web 端。
|
||||
Expected: 成功登录,工作台显示「任务工作台」
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 演示数据预置
|
||||
|
||||
### Task 5: 编写演示数据预置脚本
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/demo-seed.sql`
|
||||
|
||||
脚本目标:一键预置以下数据(幂等,可重复执行):
|
||||
|
||||
- 张建国患者档案 + 2 份历史化验单(肌酐 88→102)
|
||||
- 20-30 个背景患者(让仪表盘有数据)
|
||||
- 若干随访任务/告警记录(让仪表盘统计有意义)
|
||||
- 3 篇 CKD 健康科普文章
|
||||
- 收缩压 >=160 告警规则(如 seed 中没有)
|
||||
|
||||
- [ ] **Step 1: 编写 SQL 脚本骨架**
|
||||
|
||||
在 `scripts/demo-seed.sql` 中:
|
||||
|
||||
```sql
|
||||
-- HMS V1 Demo Data Seed
|
||||
-- 用法: docker exec -i erp-postgres psql -U erp < scripts/demo-seed.sql
|
||||
-- 幂等:使用 ON CONFLICT DO NOTHING
|
||||
|
||||
-- 1. 确保租户 ID(从现有租户获取)
|
||||
-- 2. 张建国患者档案
|
||||
-- 3. 2 份历史化验单(3 个月前 肌酐 88,1 个月前 肌酐 102)
|
||||
-- 4. 20 个背景患者(随机姓名,基础体征数据)
|
||||
-- 5. 若干随访任务(不同状态:pending/completed)
|
||||
-- 6. 若干告警记录(不同状态:pending/acknowledged/resolved)
|
||||
-- 7. 3 篇 CKD 科普文章
|
||||
-- 8. 收缩压 >=160 告警规则
|
||||
```
|
||||
|
||||
注意:所有 INSERT 需包含 `tenant_id`、`created_at`、`updated_at`、`created_by`、`updated_by`、`version`、`id`(UUID v7)字段。参考现有 Entity 的字段结构。
|
||||
|
||||
- [ ] **Step 2: 编写张建国患者 + 化验单数据**
|
||||
|
||||
```sql
|
||||
-- 患者档案
|
||||
INSERT INTO patients (id, tenant_id, name, gender, birth_date, phone, ...)
|
||||
VALUES (
|
||||
'019dcd34-bc4d-72c1-8c19-77ce1f4839d6', -- 使用已知测试患者 ID
|
||||
(SELECT id FROM tenants LIMIT 1),
|
||||
'张建国', 'male', '1961-03-15', '13800138001', ...
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 化验单 1:3 个月前 肌酐 88
|
||||
INSERT INTO lab_reports (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
|
||||
-- 化验单 1 的 items:肌酐 88 μmol/L
|
||||
INSERT INTO lab_report_items (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 化验单 2:1 个月前 肌酐 102
|
||||
INSERT INTO lab_reports (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
|
||||
-- 化验单 2 的 items:肌酐 102 μmol/L
|
||||
INSERT INTO lab_report_items (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写背景患者批量数据**
|
||||
|
||||
使用 SQL generate_series 生成 20-30 个虚拟患者:
|
||||
|
||||
```sql
|
||||
INSERT INTO patients (id, tenant_id, name, gender, birth_date, ...)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
(SELECT id FROM tenants LIMIT 1),
|
||||
'测试患者' || i,
|
||||
CASE WHEN i % 2 = 0 THEN 'male' ELSE 'female' END,
|
||||
CURRENT_DATE - (30 + (i * 37) % 50) * INTERVAL '1 year',
|
||||
...
|
||||
FROM generate_series(1, 25) AS i
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编写随访任务和告警记录**
|
||||
|
||||
为背景患者生成不同状态的随访任务和告警记录,让仪表盘统计有意义。
|
||||
|
||||
- [ ] **Step 5: 编写科普文章和告警规则**
|
||||
|
||||
```sql
|
||||
-- 3 篇 CKD 科普文章
|
||||
INSERT INTO articles (title, content, category, status, ...) VALUES
|
||||
('慢性肾病患者的饮食指南', '...', 'nutrition', 'published', ...),
|
||||
('CKD 患者运动建议', '...', 'exercise', 'published', ...),
|
||||
('慢性肾病常用药物说明', '...', 'medication', 'published', ...)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 收缩压 >=160 告警规则(如果 seed 中没有)
|
||||
INSERT INTO alert_rules (name, metric, operator, threshold, ...)
|
||||
VALUES ('收缩压偏高(演示用)', 'systolic_bp', '>=', 160, ...)
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 执行脚本验证**
|
||||
|
||||
Run: `docker exec -i erp-postgres psql -U erp < scripts/demo-seed.sql`
|
||||
Expected: 无错误,所有 INSERT 成功或 ON CONFLICT 跳过
|
||||
|
||||
- [ ] **Step 7: 验证数据完整性**
|
||||
|
||||
```
|
||||
1. 查询张建国患者:SELECT * FROM patients WHERE name = '张建国'
|
||||
2. 查询化验单数量:SELECT count(*) FROM lab_reports WHERE patient_id = ...
|
||||
3. 查询背景患者数:SELECT count(*) FROM patients WHERE name LIKE '测试患者%'
|
||||
4. 查询文章数:SELECT count(*) FROM articles WHERE status = 'published'
|
||||
Expected: 1 张建国 + 2 化验单 + 25 背景患者 + 3 文章
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/demo-seed.sql
|
||||
git commit -m "chore(demo): V1 演示数据预置脚本
|
||||
|
||||
一键预置张建国患者+化验单+25背景患者+随访+告警+科普文章。
|
||||
幂等设计,可重复执行。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: 全链路 DRY RUN
|
||||
|
||||
### Task 6: 端到端 DRY RUN(7 个场景)
|
||||
|
||||
**前置条件:** Task 1-5 全部完成
|
||||
|
||||
- [ ] **Step 1: 环境启动检查**
|
||||
|
||||
```bash
|
||||
# 1. PostgreSQL
|
||||
docker exec erp-postgres pg_isready
|
||||
|
||||
# 2. 后端
|
||||
curl -s http://localhost:3000/api/v1/auth/health | jq .
|
||||
|
||||
# 3. Web 前端
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:5174
|
||||
|
||||
# 4. Ollama
|
||||
ollama list | grep qwen3
|
||||
```
|
||||
|
||||
Expected: 全部 200/ready
|
||||
|
||||
- [ ] **Step 2: 场景 1 — 护士建档**
|
||||
|
||||
登录 nurse1 → 新建患者/查找张建国 → 录入体征 → 查看化验报告
|
||||
Expected: 全流程无报错
|
||||
|
||||
- [ ] **Step 3: 场景 2 — AI 分析**
|
||||
|
||||
进入张建国化验报告 → 点击 AI 解读(或展示预置结果)
|
||||
Expected: AI 输出正常或截图备用
|
||||
|
||||
- [ ] **Step 4: 场景 3 — 医生审批**
|
||||
|
||||
登录 doctor1 → 查看 AI 建议 → 同意 → 查看随访任务
|
||||
Expected: 随访任务自动生成
|
||||
|
||||
- [ ] **Step 5: 场景 4 — 小程序**
|
||||
|
||||
打开小程序(开发者工具)→ 查看消息/随访 → 填写问卷 → 查看趋势
|
||||
Expected: 页面正常渲染,数据正确
|
||||
|
||||
- [ ] **Step 6: 场景 5 — 告警**
|
||||
|
||||
小程序录入血压 185/95 → Web nurse1 查看告警 → 确认 → 处理
|
||||
Expected: 告警实时出现,可操作
|
||||
|
||||
- [ ] **Step 7: 场景 6 — 随访**
|
||||
|
||||
登录 health_manager → 查看随访任务 → 执行 → 录入记录
|
||||
Expected: 随访完成,状态更新
|
||||
|
||||
- [ ] **Step 8: 场景 7 — 仪表盘**
|
||||
|
||||
登录 admin → 查看统计仪表盘 → 查看文章 → 查看积分
|
||||
Expected: 数据有意义(非零)
|
||||
|
||||
- [ ] **Step 9: 记录 DRY RUN 结果**
|
||||
|
||||
在 `docs/qa/demo-dry-run-results.md` 中记录每个场景的结果:
|
||||
- ✅ 通过 / ❌ 失败(附具体错误)
|
||||
- 阻塞问题 → 新建 Task 修复
|
||||
- 可跳过场景标注
|
||||
|
||||
- [ ] **Step 10: Commit DRY RUN 报告**
|
||||
|
||||
```bash
|
||||
git add docs/qa/demo-dry-run-results.md
|
||||
git commit -m "docs: V1 Demo DRY RUN 结果报告"
|
||||
```
|
||||
@@ -1,618 +0,0 @@
|
||||
# ERP Platform Base - Design Specification
|
||||
|
||||
**Date:** 2026-04-10
|
||||
**Status:** Draft (Review Round 2)
|
||||
**Author:** Claude + User
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Build a commercial SaaS ERP product from scratch using a "platform base + industry plugins" architecture. The base provides core infrastructure (auth, workflow, messaging, configuration), enabling rapid deployment of industry-specific modules (inventory, manufacturing, finance, HR, etc.) on top.
|
||||
|
||||
The system targets progressive scaling: start with small businesses, expand to mid and large enterprises. Multi-tenant SaaS deployment is the default, with private deployment as an option.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Overall: Modular Monolith (Progressive)
|
||||
|
||||
Start as a single Rust backend service with well-defined module boundaries. Modules communicate through an internal event bus and shared traits. When a module needs independent scaling, it can be extracted into a standalone service without changing interfaces.
|
||||
|
||||
### System Layers
|
||||
|
||||
```
|
||||
Web Frontend (Vite + React 18 + Ant Design 5)
|
||||
├── Shell / Layout / Navigation
|
||||
└── Module UI (dynamically loaded per tenant config)
|
||||
│
|
||||
API Layer (REST + WebSocket)
|
||||
│
|
||||
Rust Backend Service (Axum + Tokio)
|
||||
├── Auth Module (identity, roles, permissions, tenants)
|
||||
├── Workflow Engine (BPMN processes, tasks, approvals)
|
||||
├── Message Center (notifications, templates, channels)
|
||||
└── Config Module (menus, dictionaries, settings, numbering)
|
||||
│
|
||||
Core Shared Layer (tenant context, audit, events, caching)
|
||||
│
|
||||
PostgreSQL (primary) + Redis (cache + session + pub/sub)
|
||||
```
|
||||
|
||||
> **Note:** Tauri 桌面端为可选方案,未来行业模块(如工厂仓库)需要硬件集成时启用。主力前端为 Web SPA。
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Module isolation**: Each business module is an independent Rust crate, interfaces defined via traits
|
||||
2. **Multi-tenant built-in**: All data tables include `tenant_id`, middleware auto-injects tenant context
|
||||
3. **Event-driven**: Modules communicate via event bus, no direct coupling
|
||||
4. **Plugin extensibility**: Industry modules register through standard interfaces, support dynamic enable/disable
|
||||
|
||||
### Error Handling Strategy
|
||||
|
||||
```rust
|
||||
// erp-core defines the unified error hierarchy
|
||||
// Uses thiserror for typed errors across crate boundaries
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
// Axum IntoResponse impl maps to HTTP status codes
|
||||
// Validation errors include field-level detail for UI rendering
|
||||
```
|
||||
|
||||
**Decision**: `thiserror` for crate boundaries (typed, catchable), `anyhow` never crosses crate boundaries (internal use only for prototyping). Each module defines its own error variants that wrap `AppError`.
|
||||
|
||||
### Event Bus Specification
|
||||
|
||||
```
|
||||
EventBus (tokio::sync::broadcast based, in-process)
|
||||
|
||||
Event {
|
||||
id: UUID v7
|
||||
event_type: String (e.g., "user.created", "workflow.task.completed")
|
||||
tenant_id: UUID
|
||||
payload: serde_json::Value
|
||||
timestamp: DateTime<Utc>
|
||||
correlation_id: UUID (for tracing)
|
||||
}
|
||||
|
||||
Delivery Guarantees:
|
||||
- At-least-once delivery within the process
|
||||
- Events persisted to `domain_events` table before dispatch (outbox pattern)
|
||||
- Failed handlers log to dead-letter storage, trigger alert
|
||||
- No cross-process delivery in Phase 1 (single binary)
|
||||
```
|
||||
|
||||
### Plugin / Module Registration Interface
|
||||
|
||||
```rust
|
||||
// erp-core defines the plugin trait
|
||||
pub trait ErpModule: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> &str;
|
||||
fn dependencies(&self) -> Vec<&str>; // required modules
|
||||
|
||||
fn register_routes(&self, router: Router) -> Router;
|
||||
fn register_event_handlers(&self, bus: &EventBus);
|
||||
fn on_tenant_created(&self, tenant_id: Uuid) -> Result<()>;
|
||||
fn on_tenant_deleted(&self, tenant_id: Uuid) -> Result<()>;
|
||||
}
|
||||
|
||||
// erp-server assembles modules at startup
|
||||
fn build_app(modules: Vec<Box<dyn ErpModule>>) -> Router { ... }
|
||||
```
|
||||
|
||||
Industry modules implement `ErpModule` and are discovered via configuration, not compile-time.
|
||||
|
||||
### API Versioning & Contract Governance
|
||||
|
||||
- Code-first with utoipa: derive OpenAPI from Rust types
|
||||
- Auto-generated Swagger UI at `/docs` in development
|
||||
- `/api/v1/` prefix for all endpoints; v2 only when breaking changes needed
|
||||
- Client sends `X-API-Version: 1` header; server rejects unsupported versions
|
||||
- Tauri client version and server version must be compatible (checked on connect)
|
||||
|
||||
### Concurrency & Transaction Strategy
|
||||
|
||||
- **Optimistic locking**: All mutable entities carry `version` column; updates fail on mismatch
|
||||
- **Idempotency**: Write endpoints accept optional `Idempotency-Key` header
|
||||
- **Cross-module transactions**: Avoided by design; event bus + saga pattern for consistency
|
||||
- **Numbering sequences**: PostgreSQL sequences with `advisory_lock` per tenant per rule
|
||||
|
||||
### Audit Logging
|
||||
|
||||
```
|
||||
AuditLog {
|
||||
id: UUID v7
|
||||
tenant_id: UUID
|
||||
user_id: UUID
|
||||
action: String (e.g., "user.update", "role.create")
|
||||
resource_type: String
|
||||
resource_id: UUID
|
||||
changes: JSONB { before: {}, after: {} }
|
||||
ip_address: String
|
||||
user_agent: String
|
||||
timestamp: DateTime<Utc>
|
||||
}
|
||||
|
||||
Retention: 90 days hot, archive to cold storage after
|
||||
Query API: GET /api/v1/audit-logs?resource_type=user&from=...
|
||||
```
|
||||
|
||||
### Frontend-Backend Communication
|
||||
|
||||
| Aspect | Decision |
|
||||
|--------|----------|
|
||||
| Auth flow | Login via REST → JWT stored in httpOnly cookie (web) → sent as Bearer header |
|
||||
| REST calls | Standard fetch/axios from browser to backend |
|
||||
| WebSocket | Connect on page load, auth via first message with JWT, auto-reconnect with exponential backoff |
|
||||
| File upload/download | Standard HTTP multipart + blob download |
|
||||
| CORS | Whitelist per tenant, deny by default |
|
||||
|
||||
### Security Measures
|
||||
|
||||
- CORS: Whitelist per tenant, deny by default
|
||||
- Rate limiting: Per-IP + per-user via Redis token bucket
|
||||
- Secret management: Environment variables + vault (HashiCorp Vault for production)
|
||||
- Data encryption: TLS in transit, AES-256 at rest for PII fields (optional per tenant config)
|
||||
- Input validation: Schema-based (JSON Schema for complex inputs, types for simple)
|
||||
- SQL injection: Prevented by SeaORM parameterized queries
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend (Rust)
|
||||
|
||||
| Component | Choice | Rationale |
|
||||
|-----------|--------|-----------|
|
||||
| Web framework | Axum 0.8 | Tokio-team maintained, best ecosystem |
|
||||
| Async runtime | Tokio | Rust async standard |
|
||||
| ORM | SeaORM | Async, type-safe, migration support |
|
||||
| DB migration | SeaORM Migration | Versioned schema management |
|
||||
| Cache | redis-rs | Official Redis client |
|
||||
| JWT | jsonwebtoken | Lightweight, reliable |
|
||||
| Serialization | serde + serde_json | Rust standard |
|
||||
| Logging | tracing + tracing-subscriber | Structured logging |
|
||||
| Config | config-rs | Multi-format support |
|
||||
| API docs | utoipa (OpenAPI 3) | Auto-generate Swagger |
|
||||
| Testing | Built-in + tokio-test | Unit + integration |
|
||||
|
||||
### Web Frontend
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Build tool | Vite 6 |
|
||||
| UI framework | React 18 + TypeScript |
|
||||
| Component library | Ant Design 5 |
|
||||
| State management | Zustand |
|
||||
| Routing | React Router 7 |
|
||||
| Styling | TailwindCSS + CSS Variables |
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| Primary database | PostgreSQL 16+ |
|
||||
| Cache / Session / PubSub | Redis 7+ |
|
||||
| Containerization | Docker + Docker Compose (dev) |
|
||||
|
||||
---
|
||||
|
||||
## Crate Structure
|
||||
|
||||
```
|
||||
erp/
|
||||
├── crates/
|
||||
│ ├── erp-core/ # Shared: error handling, types, traits, events
|
||||
│ ├── erp-auth/ # Identity & permissions module
|
||||
│ ├── erp-workflow/ # Workflow engine module
|
||||
│ ├── erp-message/ # Message center module
|
||||
│ ├── erp-config/ # System configuration module
|
||||
│ ├── erp-server/ # Axum server entry, assembles all modules
|
||||
│ └── erp-common/ # Shared utilities, macros
|
||||
├── apps/
|
||||
│ └── web/ # Vite + React SPA (primary frontend)
|
||||
├── desktop/ # (Optional) Tauri desktop, enabled per industry need
|
||||
├── packages/
|
||||
│ └── ui-components/ # React shared component library
|
||||
├── migrations/ # Database migrations
|
||||
├── docs/ # Documentation
|
||||
└── docker/ # Docker configurations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module 1: Identity & Permissions (Auth)
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
Tenant
|
||||
├── Organization
|
||||
│ └── Department
|
||||
│ └── Position
|
||||
├── User
|
||||
│ ├── UserCredential (password / OAuth / SSO)
|
||||
│ ├── UserProfile
|
||||
│ └── UserToken (session)
|
||||
├── Role
|
||||
│ └── Permission
|
||||
└── Policy (ABAC rules)
|
||||
```
|
||||
|
||||
### Permission Model: RBAC + ABAC Hybrid
|
||||
|
||||
- **RBAC**: User -> Role -> Permission, for standard scenarios
|
||||
- **ABAC**: Attribute-based rules (e.g., "department manager can only approve own department's requests")
|
||||
- **Data-level**: Row filtering (e.g., "only see own department's data")
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|------------|
|
||||
| Username/Password | Basic auth, Argon2 hash |
|
||||
| OAuth 2.0 | Third-party login (WeChat, DingTalk, WeCom) |
|
||||
| SSO (SAML/OIDC) | Enterprise SSO, required for private deployment |
|
||||
| TOTP | Two-factor authentication |
|
||||
|
||||
### Key APIs
|
||||
|
||||
```
|
||||
POST /api/v1/auth/login
|
||||
POST /api/v1/auth/logout
|
||||
POST /api/v1/auth/refresh
|
||||
POST /api/v1/auth/revoke # Revoke a specific token
|
||||
GET /api/v1/users
|
||||
POST /api/v1/users
|
||||
PUT /api/v1/users/:id
|
||||
DELETE /api/v1/users/:id (soft delete)
|
||||
GET /api/v1/roles
|
||||
POST /api/v1/roles
|
||||
PUT /api/v1/roles/:id
|
||||
DELETE /api/v1/roles/:id
|
||||
POST /api/v1/roles/:id/permissions
|
||||
GET /api/v1/permissions # List all available permissions
|
||||
GET /api/v1/tenants/:id/users
|
||||
GET /api/v1/organizations
|
||||
POST /api/v1/organizations
|
||||
PUT /api/v1/organizations/:id
|
||||
DELETE /api/v1/organizations/:id
|
||||
GET /api/v1/organizations/:id/departments
|
||||
POST /api/v1/organizations/:id/departments
|
||||
GET /api/v1/positions
|
||||
POST /api/v1/positions
|
||||
GET /api/v1/policies
|
||||
POST /api/v1/policies
|
||||
PUT /api/v1/policies/:id
|
||||
DELETE /api/v1/policies/:id
|
||||
```
|
||||
|
||||
### Multi-tenant Isolation
|
||||
|
||||
- **Default**: Shared database + `tenant_id` column isolation (cost-optimal)
|
||||
- **Switchable**: Independent schema per tenant (for private deployment)
|
||||
- Middleware auto-injects `tenant_id`, application code is tenant-agnostic
|
||||
|
||||
### Multi-tenant Migration Strategy
|
||||
|
||||
- Schema migrations run once globally, affect all tenants' rows
|
||||
- New tenant provisioning: seed data script (default roles, admin user, org structure, menus)
|
||||
- Migrations are versioned and idempotent; failed migrations halt startup
|
||||
- Per-tenant data migrations (e.g., adding default config) trigger on `on_tenant_created` hook
|
||||
|
||||
---
|
||||
|
||||
## Module 2: Workflow Engine
|
||||
|
||||
### Design Goals
|
||||
|
||||
- BPMN 2.0 **subset** compatible visual process designer
|
||||
- Low latency, high throughput (Rust advantage)
|
||||
- Support conditional branches, parallel gateways, sub-processes
|
||||
- Embeddable into any business module
|
||||
|
||||
### BPMN Subset Scope (Phase 4)
|
||||
|
||||
**Included in Phase 4:**
|
||||
- Start/End events
|
||||
- User Tasks (with assignee, candidate groups)
|
||||
- Service Tasks (HTTP call, script execution)
|
||||
- Exclusive Gateways (conditional branching)
|
||||
- Parallel Gateways (fork/join)
|
||||
- Sequence Flows with conditions
|
||||
- Process variables (basic types: string, number, boolean, date)
|
||||
|
||||
**Deferred to later phases:**
|
||||
- Inclusive Gateways
|
||||
- Sub-Processes (call activity)
|
||||
- Timer events (intermediate, boundary)
|
||||
- Signal/Message events
|
||||
- Error boundary events
|
||||
- Multi-instance (loop) activities
|
||||
- Data objects and stores
|
||||
|
||||
### Core Concepts
|
||||
|
||||
```
|
||||
ProcessDefinition
|
||||
├── Node Types
|
||||
│ ├── StartNode
|
||||
│ ├── EndNode
|
||||
│ ├── UserTask (human task)
|
||||
│ ├── ServiceTask (system task)
|
||||
│ ├── Gateway (exclusive / parallel / inclusive)
|
||||
│ └── SubProcess
|
||||
├── Flow (connections)
|
||||
│ └── Condition (expressions)
|
||||
└── ProcessInstance
|
||||
├── Token (tracks execution position)
|
||||
├── Task (pending tasks)
|
||||
└── Variable (process variables)
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|------------|
|
||||
| Visual designer | React flowchart editor, drag-and-drop |
|
||||
| Condition expressions | EL expressions: `amount > 10000 && dept == "finance"` |
|
||||
| Countersign / Or-sign | Multi-person approval: all approve / any approve |
|
||||
| Delegate / Transfer | Tasks can be delegated to others |
|
||||
| Reminder / Timeout | Auto-remind, auto-handle on timeout |
|
||||
| Version management | Process definitions versioned, running instances use old version |
|
||||
|
||||
### Key APIs
|
||||
|
||||
```
|
||||
POST /api/v1/workflow/definitions
|
||||
GET /api/v1/workflow/definitions/:id
|
||||
PUT /api/v1/workflow/definitions/:id
|
||||
POST /api/v1/workflow/instances
|
||||
GET /api/v1/workflow/instances/:id
|
||||
GET /api/v1/workflow/tasks (my pending)
|
||||
POST /api/v1/workflow/tasks/:id/approve
|
||||
POST /api/v1/workflow/tasks/:id/reject
|
||||
POST /api/v1/workflow/tasks/:id/delegate
|
||||
GET /api/v1/workflow/instances/:id/diagram (highlighted)
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
- **Auth**: Task assignment based on roles/org structure
|
||||
- **Message**: Pending task notifications, reminders, approval results
|
||||
- **Config**: Process categories, numbering rules
|
||||
|
||||
---
|
||||
|
||||
## Module 3: Message Center
|
||||
|
||||
### Message Channels
|
||||
|
||||
| Channel | Use Case |
|
||||
|---------|----------|
|
||||
| In-app notifications | Foundation for all messages |
|
||||
| WebSocket | Real-time push, instant desktop alerts |
|
||||
| Email | Important approvals, scheduled reports |
|
||||
| SMS | Verification codes, urgent alerts |
|
||||
| WeCom / DingTalk | Enterprise messaging integration |
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
MessageTemplate
|
||||
├── Channel type
|
||||
├── Template content (variable interpolation: {{user_name}})
|
||||
└── Multi-language versions
|
||||
|
||||
Message
|
||||
├── Sender (system / user)
|
||||
├── Recipient (user / role / department / all)
|
||||
├── Priority (normal / important / urgent)
|
||||
├── Read status
|
||||
└── Business reference (deep link to specific page)
|
||||
|
||||
MessageSubscription
|
||||
├── User notification preferences
|
||||
├── Do-not-disturb periods
|
||||
└── Channel preferences (e.g., approvals via in-app + WeCom, reports via email)
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Message aggregation**: Group similar messages (e.g., "You have 5 pending approvals")
|
||||
- **Read/unread**: Read receipts, unread count query
|
||||
- **Message recall**: Sender can recall unread messages
|
||||
- **Scheduled sending**: Set delivery time
|
||||
- **Message archive**: Auto-archive history, searchable
|
||||
|
||||
### Key APIs
|
||||
|
||||
```
|
||||
GET /api/v1/messages (list with pagination)
|
||||
GET /api/v1/messages/unread-count
|
||||
PUT /api/v1/messages/:id/read
|
||||
PUT /api/v1/messages/read-all
|
||||
DELETE /api/v1/messages/:id
|
||||
POST /api/v1/messages/send
|
||||
GET /api/v1/message-templates
|
||||
POST /api/v1/message-templates
|
||||
PUT /api/v1/message-subscriptions (update preferences)
|
||||
WS /ws/v1/messages (real-time push)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module 4: System Configuration
|
||||
|
||||
### Configuration Hierarchy
|
||||
|
||||
```
|
||||
Platform (global)
|
||||
└── Tenant
|
||||
└── Organization
|
||||
└── User
|
||||
```
|
||||
|
||||
Lower-level overrides higher-level. Priority: User > Organization > Tenant > Platform.
|
||||
|
||||
### Capabilities
|
||||
|
||||
| Capability | Description |
|
||||
|-----------|------------|
|
||||
| Dynamic menus | Tenants customize menu structure, display by role |
|
||||
| Data dictionaries | System-level and tenant-level enum management |
|
||||
| Numbering rules | Document number generation with concurrency-safe sequences |
|
||||
| Multi-language | i18n resource management, runtime switching |
|
||||
| System parameters | Key-value general configuration |
|
||||
| Theme customization | Tenant-level UI theme (colors, logo) |
|
||||
|
||||
### Key APIs
|
||||
|
||||
```
|
||||
GET /api/v1/config/menus # Tenant from middleware
|
||||
PUT /api/v1/config/menus
|
||||
GET /api/v1/config/dictionaries
|
||||
POST /api/v1/config/dictionaries
|
||||
PUT /api/v1/config/dictionaries/:id
|
||||
GET /api/v1/config/settings/:key
|
||||
PUT /api/v1/config/settings/:key
|
||||
GET /api/v1/config/numbering-rules
|
||||
POST /api/v1/config/numbering-rules
|
||||
PUT /api/v1/config/numbering-rules/:id
|
||||
GET /api/v1/config/languages
|
||||
PUT /api/v1/config/languages/:code
|
||||
GET /api/v1/config/themes # Tenant theme
|
||||
PUT /api/v1/config/themes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Design Principles
|
||||
|
||||
- All tables include: `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`
|
||||
- Soft delete via `deleted_at` (no hard deletes)
|
||||
- UUID v7 as primary keys (time-sortable + unique)
|
||||
- JSONB columns for flexible extension data
|
||||
- Indexes on `tenant_id` + business keys for multi-tenant queries
|
||||
|
||||
---
|
||||
|
||||
## Web UI Design
|
||||
|
||||
### Layout
|
||||
|
||||
Classic SaaS admin panel layout (responsive, mobile-friendly):
|
||||
|
||||
```
|
||||
+----------------------------------------------+
|
||||
| LOGO Search... 🔔 5 👤 Admin ▾ | ← Top nav bar
|
||||
+--------+-------------------------------------+
|
||||
| Home | |
|
||||
| Users | Main Content Area |
|
||||
| Roles | (Dynamic per menu selection) |
|
||||
| Flows | Multi-tab support |
|
||||
| Messages| |
|
||||
| Settings| |
|
||||
|--------| |
|
||||
| Inv. | |
|
||||
| Mfg. | |
|
||||
| Finance| |
|
||||
|--------| |
|
||||
| More > | |
|
||||
+--------+-------------------------------------+
|
||||
```
|
||||
|
||||
### Key UI Features
|
||||
|
||||
- **Collapsible sidebar**: Multi-level menus, grouped (base modules / industry modules)
|
||||
- **Multi-tab content**: Switch between open pages like browser tabs
|
||||
- **Global search**: Search menus, users, documents
|
||||
- **Notification panel**: Click bell icon to expand message list
|
||||
- **Dark/Light theme**: Toggle support, follow system preference
|
||||
- **Responsive**: Mobile/tablet adaptive layout
|
||||
- **Browser notifications**: Web Notification API for real-time alerts
|
||||
|
||||
---
|
||||
|
||||
## Development Roadmap
|
||||
|
||||
### Phase 1 - Foundation (2-3 weeks)
|
||||
- Rust workspace scaffolding + Vite + React setup
|
||||
- erp-core: error types, shared types, trait definitions, event bus
|
||||
- ErpModule trait + module registration system
|
||||
- Database migration framework (SeaORM) with tenant provisioning
|
||||
- Docker dev environment (PostgreSQL + Redis)
|
||||
- CI/CD pipeline setup
|
||||
|
||||
### Phase 2 - Identity & Permissions (2-3 weeks)
|
||||
- User, Role, Organization, Department, Position CRUD
|
||||
- RBAC + ABAC permission model
|
||||
- JWT auth (access + refresh tokens, token revocation)
|
||||
- httpOnly cookie for web JWT storage
|
||||
- Multi-tenant middleware
|
||||
- Login page UI + user management pages
|
||||
|
||||
### Phase 3 - System Configuration (1-2 weeks)
|
||||
- Data dictionaries
|
||||
- Dynamic menus
|
||||
- System parameters (hierarchical override)
|
||||
- Numbering rules (concurrency-safe PostgreSQL sequences)
|
||||
- i18n framework
|
||||
- Settings pages UI
|
||||
|
||||
### Phase 4 - Workflow Engine (4-6 weeks)
|
||||
- Process definition storage and versioning
|
||||
- BPMN subset parser (start/end, user/service tasks, exclusive/parallel gateways)
|
||||
- Execution engine with token tracking
|
||||
- Task assignment, countersign, delegation
|
||||
- Condition expression evaluator
|
||||
- React visual flowchart designer
|
||||
- Process diagram viewer (highlighted current node)
|
||||
- Reminder and timeout handling
|
||||
|
||||
### Phase 5 - Message Center (2 weeks)
|
||||
- Message templates with variable interpolation
|
||||
- In-app notification CRUD
|
||||
- WebSocket real-time push (auth, reconnect)
|
||||
- Notification panel UI
|
||||
- Message aggregation and read tracking
|
||||
|
||||
### Phase 6 - Integration & Polish (2-3 weeks)
|
||||
- Cross-module integration testing
|
||||
- Audit logging verification
|
||||
- Web app deployment and optimization
|
||||
- Performance optimization
|
||||
- Documentation
|
||||
|
||||
---
|
||||
|
||||
## Verification Plan
|
||||
|
||||
1. **Unit tests**: Each module has comprehensive unit tests (80%+ coverage target)
|
||||
2. **Integration tests**: API endpoint tests against real PostgreSQL/Redis
|
||||
3. **E2E tests**: Desktop client test automation via Tauri WebDriver
|
||||
4. **Multi-tenant tests**: Verify data isolation between tenants
|
||||
5. **Workflow tests**: Full process lifecycle (define -> start -> approve -> complete)
|
||||
6. **Performance benchmarks**: API response time < 100ms (p99), WebSocket push < 50ms
|
||||
7. **Security audit**: OWASP top 10 check before release
|
||||
@@ -1,985 +0,0 @@
|
||||
# WASM 插件系统设计规格
|
||||
|
||||
> 日期:2026-04-13
|
||||
> 状态:审核通过 (v2 — 修复安全/多租户/迁移问题)
|
||||
> 关联:`docs/superpowers/specs/2026-04-10-erp-platform-base-design.md`
|
||||
> Review 历史:v1 首次审核 → 修复 C1-C4 关键问题 + I1-I5 重要问题 → v2 审核通过
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
ERP 平台底座 Phase 1-6 已全部完成,包含 auth、config、workflow、message 四大基础模块。
|
||||
当前系统是一个"模块化形状的单体"——模块以独立 crate 存在,但集成方式是编译时硬编码(main.rs 手动注册路由、合并迁移、启动后台任务)。
|
||||
|
||||
**核心矛盾:** Rust 的静态编译特性不支持运行时热插拔,但产品目标是"通用基座 + 行业插件"架构。
|
||||
|
||||
**本设计的目标:** 引入 WASM 运行时插件系统,使行业模块(进销存、生产、财务等)可以动态安装、启用、停用,无需修改基座代码。
|
||||
|
||||
## 2. 设计决策
|
||||
|
||||
| 决策点 | 选择 | 理由 |
|
||||
|--------|------|------|
|
||||
| 插件范围 | 仅行业模块动态化,基础模块保持 Rust 编译时 | 基础模块变更频率低、可靠性要求高,适合编译时保证 |
|
||||
| 插件技术 | WebAssembly (Wasmtime) | Rust 原生运行时,性能接近原生,沙箱安全 |
|
||||
| 数据库访问 | 宿主代理 API | 宿主自动注入 tenant_id、软删除、审计日志,插件无法绕过 |
|
||||
| 前端 UI | 配置驱动 | ERP 80% 页面是 CRUD,配置驱动覆盖大部分场景 |
|
||||
| 插件管理 | 内置插件商店 | 类似 WordPress 模型,管理后台上传 WASM 包 |
|
||||
| WASM 运行时 | Wasmtime | Bytecode Alliance 维护,Rust 原生,Cranelift JIT |
|
||||
|
||||
## 3. 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ erp-server │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ ModuleRegistry v2 │ │
|
||||
│ │ ┌─────────────────┐ ┌──────────────────────────┐│ │
|
||||
│ │ │ Native Modules │ │ Wasmtime Runtime ││ │
|
||||
│ │ │ ┌──────┐┌──────┐│ │ ┌──────┐┌──────┐┌──────┐││ │
|
||||
│ │ │ │ auth ││config ││ │ │进销存 ││ 生产 ││ 财务 │││ │
|
||||
│ │ │ ├──────┤├──────┤│ │ └──┬───┘└──┬───┘└──┬───┘││ │
|
||||
│ │ │ │workflow│msg ││ │ └────────┼────────┘ ││ │
|
||||
│ │ │ └──────┘└──────┘│ │ Host API Layer ││ │
|
||||
│ │ └─────────────────┘ └──────────────────────────┘│ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ ↕ EventBus │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ 统一 Axum Router │ │
|
||||
│ │ /api/v1/auth/* /api/v1/plugins/{id}/* │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
↕
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React SPA) │
|
||||
│ ┌──────────────┐ ┌──────────────────────────────────┐ │
|
||||
│ │ 固定路由 │ │ 动态路由 (PluginRegistry Store) │ │
|
||||
│ │ /users /roles │ │ /inventory/* /production/* │ │
|
||||
│ └──────────────┘ └──────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────┐│
|
||||
│ │ PluginCRUDPage — 配置驱动的通用 CRUD 渲染引擎 ││
|
||||
│ └──────────────────────────────────────────────────────┘│
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. 插件清单 (Plugin Manifest)
|
||||
|
||||
每个 WASM 插件包含一个 `plugin.toml` 清单文件:
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
id = "erp-inventory" # 全局唯一 ID,kebab-case
|
||||
name = "进销存管理" # 显示名称
|
||||
version = "1.0.0" # 语义化版本
|
||||
description = "商品/采购/销售/库存管理"
|
||||
author = "ERP Team"
|
||||
min_platform_version = "1.0.0" # 最低基座版本要求
|
||||
|
||||
[dependencies]
|
||||
modules = ["auth", "workflow"] # 依赖的基础模块 ID 列表
|
||||
|
||||
[permissions]
|
||||
database = true # 需要数据库访问
|
||||
events = true # 需要发布/订阅事件
|
||||
config = true # 需要读取系统配置
|
||||
files = false # 是否需要文件存储
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "inventory_item"
|
||||
fields = [
|
||||
{ name = "sku", type = "string", required = true, unique = true },
|
||||
{ name = "name", type = "string", required = true },
|
||||
{ name = "quantity", type = "integer", default = 0 },
|
||||
{ name = "unit", type = "string", default = "个" },
|
||||
{ name = "category_id", type = "uuid", nullable = true },
|
||||
{ name = "unit_price", type = "decimal", precision = 10, scale = 2 },
|
||||
]
|
||||
indexes = [["sku"], ["category_id"]]
|
||||
|
||||
[[schema.entities]]
|
||||
name = "purchase_order"
|
||||
fields = [
|
||||
{ name = "order_no", type = "string", required = true, unique = true },
|
||||
{ name = "supplier_id", type = "uuid" },
|
||||
{ name = "status", type = "string", default = "draft" },
|
||||
{ name = "total_amount", type = "decimal", precision = 12, scale = 2 },
|
||||
{ name = "order_date", type = "date" },
|
||||
]
|
||||
|
||||
[events]
|
||||
published = ["inventory.stock.low", "purchase_order.created", "purchase_order.approved"]
|
||||
subscribed = ["workflow.task.completed"]
|
||||
|
||||
[ui]
|
||||
[[ui.pages]]
|
||||
name = "商品管理"
|
||||
path = "/inventory/items"
|
||||
entity = "inventory_item"
|
||||
type = "crud"
|
||||
icon = "ShoppingOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[ui.pages]]
|
||||
name = "采购管理"
|
||||
path = "/inventory/purchase"
|
||||
entity = "purchase_order"
|
||||
type = "crud"
|
||||
icon = "ShoppingCartOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[ui.pages]]
|
||||
name = "库存盘点"
|
||||
path = "/inventory/stocktaking"
|
||||
type = "custom"
|
||||
menu_group = "进销存"
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- `schema.entities` 声明的表自动注入标准字段:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||
- `permissions` 控制插件可调用的宿主 API 范围(最小权限原则)
|
||||
- `ui.pages.type` 为 `crud` 时由通用渲染引擎自动生成页面,`custom` 时由插件处理渲染逻辑
|
||||
- 插件事件命名使用 `{plugin_id}.{entity}.{action}` 三段式,避免与基础模块的 `{module}.{action}` 二段式冲突
|
||||
- 动态创建的表使用 `plugin_{entity_name}` 格式,所有租户共享同一张表,通过 `tenant_id` 列实现行级隔离(与现有表模式一致)
|
||||
|
||||
## 5. 宿主 API (Host Functions)
|
||||
|
||||
WASM 插件通过宿主暴露的函数访问系统资源,这是插件与外部世界的唯一通道:
|
||||
|
||||
### 5.1 API 定义
|
||||
|
||||
```rust
|
||||
/// 宿主暴露给 WASM 插件的 API 接口
|
||||
/// 通过 Wasmtime Linker 注册为 host functions
|
||||
trait PluginHostApi {
|
||||
// === 数据库操作 ===
|
||||
|
||||
/// 插入记录(宿主自动注入 tenant_id, id, created_at 等标准字段)
|
||||
fn db_insert(&mut self, entity: &str, data: &[u8]) -> Result<Vec<u8>>;
|
||||
|
||||
/// 查询记录(自动注入 tenant_id 过滤 + 软删除过滤)
|
||||
fn db_query(&mut self, entity: &str, filter: &[u8], pagination: &[u8]) -> Result<Vec<u8]>;
|
||||
|
||||
/// 更新记录(自动检查 version 乐观锁)
|
||||
fn db_update(&mut self, entity: &str, id: &str, data: &[u8], version: i64) -> Result<Vec<u8]>;
|
||||
|
||||
/// 软删除记录
|
||||
fn db_delete(&mut self, entity: &str, id: &str) -> Result<()>;
|
||||
|
||||
/// 原始查询(仅允许 SELECT,自动注入 tenant_id 过滤)
|
||||
fn db_raw_query(&mut self, sql: &str, params: &[u8]) -> Result<Vec<u8]>;
|
||||
|
||||
// === 事件总线 ===
|
||||
|
||||
/// 发布领域事件
|
||||
fn event_publish(&mut self, event_type: &str, payload: &[u8]) -> Result<()>;
|
||||
|
||||
// === 配置 ===
|
||||
|
||||
/// 读取系统配置(插件作用域内)
|
||||
fn config_get(&mut self, key: &str) -> Result<Vec<u8]>;
|
||||
|
||||
// === 日志 ===
|
||||
|
||||
/// 写日志(自动关联 tenant_id + plugin_id)
|
||||
fn log_write(&mut self, level: &str, message: &str);
|
||||
|
||||
// === 用户/权限 ===
|
||||
|
||||
/// 获取当前用户信息
|
||||
fn current_user(&mut self) -> Result<Vec<u8]>;
|
||||
|
||||
/// 检查当前用户权限
|
||||
fn check_permission(&mut self, permission: &str) -> Result<bool>;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 安全边界
|
||||
|
||||
插件运行在 WASM 沙箱中,安全策略如下:
|
||||
|
||||
1. **权限校验** — 插件只能调用清单 `permissions` 中声明的宿主函数,未声明的调用在加载时被拦截
|
||||
2. **租户隔离** — 所有 `db_*` 操作自动注入 `tenant_id`,插件无法绕过多租户隔离。使用行级隔离(共享表 + tenant_id 过滤),与现有基础模块保持一致
|
||||
3. **资源限制** — 每个插件有独立的资源配额(内存上限、CPU 时间、API 调用频率)
|
||||
4. **审计记录** — 所有写操作自动记录审计日志
|
||||
5. **SQL 安全** — 不暴露原始 SQL 接口,`db_aggregate` 使用结构化查询对象,宿主层安全构建参数化 SQL
|
||||
6. **文件/网络隔离** — 插件不能直接访问文件系统或网络
|
||||
|
||||
### 5.3 数据流
|
||||
|
||||
```
|
||||
WASM 插件 宿主安全层 PostgreSQL
|
||||
┌──────────┐ ┌───────────────┐ ┌──────────┐
|
||||
│ 调用 │ ── Host Call ──→ │ 1. 权限校验 │ │ │
|
||||
│ db_insert │ │ 2. 注入标准字段 │ ── SQL ──→ │ INSERT │
|
||||
│ │ │ 3. 注入 tenant │ │ INTO │
|
||||
│ │ ←─ JSON 结果 ── │ 4. 写审计日志 │ │ │
|
||||
└──────────┘ └───────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## 6. 插件生命周期
|
||||
|
||||
### 6.1 状态机
|
||||
|
||||
```
|
||||
上传 WASM 包
|
||||
│
|
||||
▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ uploaded │───→│ installed │───→│ enabled │───→│ running │
|
||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
│ │
|
||||
│ ┌──────────┘
|
||||
│ ▼
|
||||
┌──────────┐
|
||||
│ disabled │←── 运行时错误自动停用
|
||||
└──────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│uninstalled│ ── 软删除插件记录,保留数据表和数据
|
||||
└──────────┘
|
||||
│
|
||||
▼ (可选,需管理员二次确认)
|
||||
┌──────────┐
|
||||
│ purged │ ── 真正删除数据表 + 数据导出备份
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### 6.2 各阶段操作
|
||||
|
||||
| 阶段 | 操作 |
|
||||
|------|------|
|
||||
| uploaded → installed | 校验清单格式、验证依赖模块存在、检查 min_platform_version |
|
||||
| installed → enabled | 根据 `schema.entities` 创建数据表(带 `plugin_` 前缀)、写入启用状态 |
|
||||
| enabled → running | 服务启动时:Wasmtime 实例化、注册 Host Functions、调用 `init()`、注册事件处理器、注册前端路由 |
|
||||
| running → disabled | 停止 WASM 实例、注销事件处理器、注销路由 |
|
||||
| disabled → uninstalled | 软删除插件记录(设置 `deleted_at`),**保留数据表和数据不变**,清理事件订阅记录 |
|
||||
| uninstalled → purged | 数据导出备份后,删除 `plugin_*` 数据表。**需要管理员二次确认 + 数据导出完成** |
|
||||
|
||||
### 6.3 启动加载流程
|
||||
|
||||
```rust
|
||||
async fn load_plugins(db: &DatabaseConnection) -> Vec<LoadedPlugin> {
|
||||
// 1. 查询所有 enabled 状态的插件
|
||||
let plugins = Plugin::find()
|
||||
.filter(status.eq("enabled"))
|
||||
.filter(deleted_at.is_null())
|
||||
.all(db).await?;
|
||||
|
||||
let mut loaded = Vec::new();
|
||||
for plugin in plugins {
|
||||
// 2. 初始化 Wasmtime Engine(复用全局 Engine)
|
||||
let module = Module::from_binary(&engine, &plugin.wasm_binary)?;
|
||||
|
||||
// 3. 创建 Linker,根据 permissions 注册对应的 Host Functions
|
||||
let mut linker = Linker::new(&engine);
|
||||
register_host_functions(&mut linker, &plugin.permissions)?;
|
||||
|
||||
// 4. 实例化
|
||||
let instance = linker.instantiate_async(&mut store, &module).await?;
|
||||
|
||||
// 5. 调用插件的 init() 入口函数
|
||||
if let Ok(init) = instance.get_typed_func::<(), ()>(&mut store, "init") {
|
||||
init.call_async(&mut store, ()).await?;
|
||||
}
|
||||
|
||||
// 6. 注册事件处理器
|
||||
for sub in &plugin.manifest.events.subscribed {
|
||||
event_bus.subscribe_filtered(sub, plugin_handler(plugin.id, instance.clone()));
|
||||
}
|
||||
|
||||
loaded.push(LoadedPlugin { plugin, instance, store });
|
||||
}
|
||||
|
||||
// 7. 依赖排序验证
|
||||
validate_dependencies(&loaded)?;
|
||||
|
||||
Ok(loaded)
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 数据库 Schema
|
||||
|
||||
### 7.1 新增表
|
||||
|
||||
```sql
|
||||
-- 插件注册表
|
||||
CREATE TABLE plugins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
plugin_id VARCHAR(100) NOT NULL, -- 清单中的唯一 ID
|
||||
name VARCHAR(200) NOT NULL,
|
||||
plugin_version VARCHAR(20) NOT NULL, -- 插件语义化版本(避免与乐观锁 version 混淆)
|
||||
description TEXT,
|
||||
manifest JSONB NOT NULL, -- 完整清单 JSON
|
||||
wasm_binary BYTEA NOT NULL, -- 编译后的 WASM 二进制
|
||||
status VARCHAR(20) DEFAULT 'installed',
|
||||
-- uploaded / installed / enabled / disabled / error
|
||||
permissions JSONB NOT NULL,
|
||||
error_message TEXT,
|
||||
schema_version INTEGER DEFAULT 1, -- 插件数据 schema 版本
|
||||
config JSONB DEFAULT '{}', -- 插件配置
|
||||
-- 标准字段
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID,
|
||||
updated_by UUID,
|
||||
deleted_at TIMESTAMPTZ, -- 软删除(卸载不删数据)
|
||||
row_version INTEGER NOT NULL DEFAULT 1, -- 乐观锁版本
|
||||
UNIQUE(tenant_id, plugin_id)
|
||||
);
|
||||
|
||||
-- 插件 schema 版本跟踪(用于动态表的版本管理)
|
||||
CREATE TABLE plugin_schema_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plugin_id VARCHAR(100) NOT NULL, -- 全局唯一的插件 ID
|
||||
entity_name VARCHAR(100) NOT NULL, -- 实体名
|
||||
schema_version INTEGER NOT NULL DEFAULT 1, -- 当前 schema 版本
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(plugin_id, entity_name)
|
||||
);
|
||||
|
||||
-- 插件事件订阅记录
|
||||
CREATE TABLE plugin_event_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
plugin_id VARCHAR(100) NOT NULL,
|
||||
event_type VARCHAR(200) NOT NULL,
|
||||
handler_name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### 7.2 动态数据表
|
||||
|
||||
插件安装时根据 `manifest.schema.entities` 自动创建数据表:
|
||||
|
||||
- 表名格式:`plugin_{entity_name}`
|
||||
- **行级隔离模式**:所有租户共享同一张 `plugin_*` 表,通过 `tenant_id` 列过滤实现隔离(与现有基础模块的表保持一致)
|
||||
- 首次创建表时使用 `IF NOT EXISTS`(幂等),后续租户安装同一插件时复用已有表
|
||||
- 自动包含标准字段:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||
- 索引自动创建:主键 + `tenant_id`(必选)+ 清单中声明的自定义索引
|
||||
- **注意**:此方式绕过 SeaORM Migration 系统,属于合理偏差——插件是运行时动态加载的,其 schema 无法在编译时通过静态迁移管理。宿主维护 `plugin_schema_versions` 表跟踪每个插件的 schema 版本
|
||||
|
||||
## 8. 配置驱动 UI
|
||||
|
||||
### 8.1 前端架构
|
||||
|
||||
```
|
||||
插件 manifest.ui.pages
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ PluginStore │ Zustand Store,从 /api/v1/plugins/:id/pages 加载
|
||||
│ (前端插件注册表) │ 缓存所有已启用插件的页面配置
|
||||
└───────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ DynamicRouter │ React Router,根据 PluginStore 自动生成路由
|
||||
│ (动态路由层) │ 懒加载 PluginCRUDPage / PluginDashboard
|
||||
└───────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ PluginCRUDPage │ 通用 CRUD 页面组件
|
||||
│ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ SearchBar │ │ 根据 filters 配置自动生成搜索条件
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ DataTable │ │ 根据 columns 配置渲染 Ant Design Table
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ FormDialog │ │ 根据 form 配置渲染新建/编辑表单
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ ActionBar │ │ 根据 actions 配置渲染操作按钮
|
||||
│ └─────────────┘ │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 页面配置类型
|
||||
|
||||
```typescript
|
||||
interface PluginPageConfig {
|
||||
name: string;
|
||||
path: string;
|
||||
entity: string;
|
||||
type: "crud" | "dashboard" | "custom";
|
||||
icon?: string;
|
||||
menu_group: string;
|
||||
|
||||
// CRUD 配置(可选,不提供时从 schema.entities 自动推导)
|
||||
// columns 未指定时:从 entity 的 fields 生成,type=select 需显式指定 options
|
||||
// form 未指定时:从 entity 的 fields 生成表单,required 字段为必填
|
||||
columns?: ColumnDef[];
|
||||
filters?: FilterDef[];
|
||||
actions?: ActionDef[];
|
||||
form?: FormDef;
|
||||
}
|
||||
|
||||
interface ColumnDef {
|
||||
field: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "date" | "datetime" | "select"
|
||||
| "multiselect" | "currency" | "status" | "link";
|
||||
width?: number;
|
||||
sortable?: boolean;
|
||||
hidden?: boolean;
|
||||
options?: { label: string; value: string; color?: string }[];
|
||||
}
|
||||
|
||||
interface FormDef {
|
||||
groups?: FormGroup[];
|
||||
fields: FormField[];
|
||||
rules?: ValidationRule[];
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 动态菜单生成
|
||||
|
||||
前端侧边栏从 PluginStore 动态生成菜单项:
|
||||
|
||||
- 基础模块菜单固定(用户、权限、组织、工作流、消息、设置)
|
||||
- 插件菜单按 `menu_group` 分组,动态追加到侧边栏
|
||||
- 菜单数据来自 `/api/v1/plugins/installed` API,启动时加载
|
||||
|
||||
### 8.4 插件 API 路由
|
||||
|
||||
插件的 CRUD API 由宿主自动生成:
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{plugin_id}/{entity} # 列表查询
|
||||
GET /api/v1/plugins/{plugin_id}/{entity}/{id} # 详情
|
||||
POST /api/v1/plugins/{plugin_id}/{entity} # 新建
|
||||
PUT /api/v1/plugins/{plugin_id}/{entity}/{id} # 更新
|
||||
DELETE /api/v1/plugins/{plugin_id}/{entity}/{id} # 删除
|
||||
```
|
||||
|
||||
宿主自动注入 tenant_id、处理分页、乐观锁、软删除。
|
||||
|
||||
### 8.5 自定义页面
|
||||
|
||||
`type: "custom"` 的页面需要额外的渲染指令:
|
||||
|
||||
- 插件 WASM 可以导出 `render_page` 函数,返回 UI 指令 JSON
|
||||
- 宿主前端解析指令并渲染(支持:条件显示、自定义操作、复杂布局)
|
||||
- 复杂交互(如库存盘点)通过事件驱动:前端发送 action → 后端 WASM 处理 → 返回新的 UI 状态
|
||||
|
||||
## 9. 升级后的模块注册系统
|
||||
|
||||
### 9.1 ErpModule trait v2
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait ErpModule: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
|
||||
fn dependencies(&self) -> Vec<&str> { vec![] }
|
||||
fn module_type(&self) -> ModuleType;
|
||||
|
||||
// 生命周期
|
||||
async fn on_startup(&self, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
async fn on_shutdown(&self) -> AppResult<()> { Ok(()) }
|
||||
async fn health_check(&self) -> AppResult<ModuleHealth> {
|
||||
Ok(ModuleHealth { status: "ok".into(), details: None })
|
||||
}
|
||||
|
||||
// 路由
|
||||
fn public_routes(&self) -> Option<Router> { None }
|
||||
fn protected_routes(&self) -> Option<Router> { None }
|
||||
|
||||
// 数据库
|
||||
fn migrations(&self) -> Vec<Box<dyn MigrationTrait>> { vec![] }
|
||||
|
||||
// 事件
|
||||
fn register_event_handlers(&self, bus: &EventBus) {}
|
||||
|
||||
// 租户
|
||||
async fn on_tenant_created(&self, tenant_id: Uuid, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
async fn on_tenant_deleted(&self, tenant_id: Uuid, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
|
||||
// 配置
|
||||
fn config_schema(&self) -> Option<serde_json::Value> { None }
|
||||
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
|
||||
pub enum ModuleType { Native, Wasm }
|
||||
|
||||
pub struct ModuleHealth {
|
||||
pub status: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ModuleContext {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
pub config: Arc<AppConfig>,
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 ModuleRegistry v2
|
||||
|
||||
```rust
|
||||
pub struct ModuleRegistry {
|
||||
modules: Arc<Vec<Arc<dyn ErpModule>>>,
|
||||
wasm_runtime: Arc<WasmPluginRuntime>,
|
||||
index: Arc<HashMap<String, usize>>,
|
||||
}
|
||||
|
||||
impl ModuleRegistry {
|
||||
pub fn new() -> Self;
|
||||
|
||||
// 注册 Rust 原生模块
|
||||
pub fn register(self, module: impl ErpModule + 'static) -> Self;
|
||||
|
||||
// 从数据库加载 WASM 插件
|
||||
pub async fn load_wasm_plugins(&mut self, db: &DatabaseConnection) -> AppResult<()>;
|
||||
|
||||
// 按依赖顺序启动所有模块
|
||||
pub async fn startup_all(&self, ctx: &ModuleContext) -> AppResult<()>;
|
||||
|
||||
// 聚合健康状态
|
||||
pub async fn health_check_all(&self) -> HashMap<String, ModuleHealth>;
|
||||
|
||||
// 自动收集所有路由
|
||||
pub fn build_routes(&self) -> (Router, Router);
|
||||
|
||||
// 自动收集所有迁移
|
||||
pub fn collect_migrations(&self) -> Vec<Box<dyn MigrationTrait>>;
|
||||
|
||||
// 拓扑排序(基于 dependencies)
|
||||
fn topological_sort(&self) -> AppResult<Vec<Arc<dyn ErpModule>>>;
|
||||
|
||||
// 按 ID 查找模块
|
||||
pub fn get_module(&self, id: &str) -> Option<&Arc<dyn ErpModule>>;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 升级后的 main.rs
|
||||
|
||||
```rust
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// 初始化 DB、Config、EventBus ...
|
||||
|
||||
// 1. 注册 Rust 原生模块
|
||||
let mut registry = ModuleRegistry::new()
|
||||
.register(AuthModule::new())
|
||||
.register(ConfigModule::new())
|
||||
.register(WorkflowModule::new())
|
||||
.register(MessageModule::new());
|
||||
|
||||
// 2. 从数据库加载 WASM 插件
|
||||
registry.load_wasm_plugins(&db).await?;
|
||||
|
||||
// 3. 依赖排序 + 启动所有模块
|
||||
let ctx = ModuleContext { db: db.clone(), event_bus: event_bus.clone(), config: config.clone() };
|
||||
registry.startup_all(&ctx).await?;
|
||||
|
||||
// 4. 自动收集路由(无需手动 merge)
|
||||
let (public, protected) = registry.build_routes();
|
||||
|
||||
// 5. 构建 Axum 服务
|
||||
let app = Router::new()
|
||||
.nest("/api/v1", public.merge(protected))
|
||||
.with_state(app_state);
|
||||
|
||||
// 启动服务 ...
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 插件开发体验
|
||||
|
||||
### 10.1 插件项目结构
|
||||
|
||||
```
|
||||
erp-plugin-inventory/
|
||||
├── Cargo.toml # crate 类型为 cdylib (WASM)
|
||||
├── plugin.toml # 插件清单
|
||||
└── src/
|
||||
└── lib.rs # 插件入口
|
||||
```
|
||||
|
||||
### 10.2 插件 Cargo.toml
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-inventory"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.24" # WIT 接口绑定生成
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
### 10.3 插件代码示例
|
||||
|
||||
```rust
|
||||
use wit_bindgen::generate::Guest;
|
||||
|
||||
// 自动生成宿主 API 绑定
|
||||
export!(Plugin);
|
||||
|
||||
struct Plugin;
|
||||
|
||||
impl Guest for Plugin {
|
||||
fn init() -> Result<(), String> {
|
||||
host::log_write("info", "进销存插件初始化完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
|
||||
// 初始化默认商品分类等
|
||||
host::db_insert("inventory_category", br#"{"name": "默认分类"}"#)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
|
||||
match event_type.as_str() {
|
||||
"workflow.task.completed" => {
|
||||
// 采购审批通过,更新采购单状态
|
||||
let data: serde_json::Value = serde_json::from_slice(&payload)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let order_id = data["business_id"].as_str().unwrap();
|
||||
host::db_update("purchase_order", order_id,
|
||||
br#"{"status": "approved"}"#, 1)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.4 构建与发布
|
||||
|
||||
```bash
|
||||
# 编译为 WASM
|
||||
cargo build --target wasm32-unknown-unknown --release
|
||||
|
||||
# 打包(WASM 二进制 + 清单文件)
|
||||
erp-plugin pack ./target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm \
|
||||
--manifest ./plugin.toml \
|
||||
--output ./erp-inventory-1.0.0.erp-plugin
|
||||
|
||||
# 上传到平台(通过管理后台或 API)
|
||||
curl -X POST /api/v1/admin/plugins/upload \
|
||||
-F "plugin=@./erp-inventory-1.0.0.erp-plugin"
|
||||
```
|
||||
|
||||
## 11. 管理后台 API
|
||||
|
||||
### 11.1 插件管理接口
|
||||
|
||||
```
|
||||
POST /api/v1/admin/plugins/upload # 上传插件包
|
||||
GET /api/v1/admin/plugins # 列出所有插件
|
||||
GET /api/v1/admin/plugins/{plugin_id} # 插件详情
|
||||
POST /api/v1/admin/plugins/{plugin_id}/enable # 启用插件
|
||||
POST /api/v1/admin/plugins/{plugin_id}/disable # 停用插件
|
||||
DELETE /api/v1/admin/plugins/{plugin_id} # 卸载插件
|
||||
GET /api/v1/admin/plugins/{plugin_id}/health # 插件健康检查
|
||||
PUT /api/v1/admin/plugins/{plugin_id}/config # 更新插件配置
|
||||
POST /api/v1/admin/plugins/{plugin_id}/upgrade # 升级插件版本
|
||||
```
|
||||
|
||||
### 11.2 插件数据接口(自动生成)
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{plugin_id}/{entity} # 列表查询
|
||||
GET /api/v1/plugins/{plugin_id}/{entity}/{id} # 详情
|
||||
POST /api/v1/plugins/{plugin_id}/{entity} # 新建
|
||||
PUT /api/v1/plugins/{plugin_id}/{entity}/{id} # 更新
|
||||
DELETE /api/v1/plugins/{plugin_id}/{entity}/{id} # 删除
|
||||
```
|
||||
|
||||
## 12. 实施路径
|
||||
|
||||
### Phase 7: 插件系统核心
|
||||
|
||||
1. **引入 Wasmtime 依赖**,创建 `erp-plugin-runtime` crate
|
||||
2. **定义 WIT 接口文件**,描述宿主-插件合约
|
||||
3. **实现 Host API 层** — db_insert/query/update/delete、event_publish、config_get 等
|
||||
4. **实现插件加载器** — 从数据库读取 WASM 二进制、实例化、注册路由
|
||||
5. **升级 ErpModule trait** — 添加 lifecycle hooks、routes、migrations 方法
|
||||
6. **升级 ModuleRegistry** — 拓扑排序、自动路由收集、WASM 插件注册
|
||||
7. **插件管理 API** — 上传、启用、停用、卸载
|
||||
8. **插件数据库表** — plugins、plugin_event_subscriptions + 动态建表逻辑
|
||||
|
||||
### Phase 8: 前端配置驱动 UI
|
||||
|
||||
1. **PluginStore** (Zustand) — 管理已安装插件的页面配置
|
||||
2. **DynamicRouter** — 根据 PluginStore 自动生成 React Router 路由
|
||||
3. **PluginCRUDPage** — 通用 CRUD 渲染引擎(表格 + 搜索 + 表单 + 操作)
|
||||
4. **动态菜单** — 从 PluginStore 生成侧边栏菜单
|
||||
5. **插件管理页面** — 上传、启用/停用、配置的管理后台
|
||||
|
||||
### Phase 9: 第一个行业插件(进销存)
|
||||
|
||||
1. 创建 `erp-plugin-inventory` 作为参考实现
|
||||
2. 实现商品、采购、库存管理的核心业务逻辑
|
||||
3. 配置驱动页面覆盖 80% 的 CRUD 场景
|
||||
4. 验证端到端流程:安装 → 启用 → 使用 → 停用 → 卸载
|
||||
|
||||
## 13. 风险与缓解
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|----------|
|
||||
| WASM 插件性能不足 | 低 | 高 | 性能基准测试,关键路径保留 Rust 原生 |
|
||||
| 插件安全问题 | 中 | 高 | 沙箱隔离 + 最小权限 + 审计日志 |
|
||||
| 配置驱动 UI 覆盖不足 | 中 | 中 | 保留 custom 页面类型作为兜底 |
|
||||
| 插件间依赖冲突 | 中 | 中 | 拓扑排序 + 版本约束 + 冲突检测 |
|
||||
| Wasmtime 版本兼容性 | 低 | 中 | 锁定 Wasmtime 大版本,CI 验证 |
|
||||
|
||||
## 附录 A: ErpModule Trait 迁移策略
|
||||
|
||||
### A.1 向后兼容原则
|
||||
|
||||
`ErpModule` trait v2 的所有新增方法均提供**默认实现(no-op)**,确保现有四个模块(AuthModule、ConfigModule、WorkflowModule、MessageModule)无需修改即可编译通过。
|
||||
|
||||
### A.2 迁移清单
|
||||
|
||||
| 现有方法 | v2 变化 | 迁移操作 |
|
||||
|----------|---------|----------|
|
||||
| `fn name(&self) -> &str` | 保留不变,新增 `fn id()` 返回相同值 | 在各模块 impl 中添加 `fn id()` |
|
||||
| `fn version()` | 保留不变 | 无需改动 |
|
||||
| `fn dependencies()` | 保留不变 | 无需改动 |
|
||||
| `fn register_event_handlers()` | 签名不变 | 无需改动 |
|
||||
| `fn on_tenant_created(tenant_id)` | 签名变为 `on_tenant_created(tenant_id, ctx)` | 更新签名,添加 `ctx` 参数 |
|
||||
| `fn on_tenant_deleted(tenant_id)` | 签名变为 `on_tenant_deleted(tenant_id, ctx)` | 更新签名,添加 `ctx` 参数 |
|
||||
| `fn as_any()` | 保留不变 | 无需改动 |
|
||||
| (新增)`fn module_type()` | 默认返回 `ModuleType::Native` | 无需改动 |
|
||||
| (新增)`fn on_startup()` | 默认 no-op | 可选实现 |
|
||||
| (新增)`fn on_shutdown()` | 默认 no-op | 可选实现 |
|
||||
| (新增)`fn health_check()` | 默认返回 ok | 可选实现 |
|
||||
| (新增)`fn public_routes()` | 默认 None | 将现有关联函数迁移到此方法 |
|
||||
| (新增)`fn protected_routes()` | 默认 None | 将现有关联函数迁移到此方法 |
|
||||
| (新增)`fn migrations()` | 默认空 vec | 可选实现 |
|
||||
| (新增)`fn config_schema()` | 默认 None | 可选实现 |
|
||||
|
||||
### A.3 迁移后的 main.rs 变化
|
||||
|
||||
迁移后,main.rs 从手动路由合并变为自动收集:
|
||||
|
||||
```rust
|
||||
// 迁移前(手动)
|
||||
let protected_routes = erp_auth::AuthModule::protected_routes()
|
||||
.merge(erp_config::ConfigModule::protected_routes())
|
||||
.merge(erp_workflow::WorkflowModule::protected_routes())
|
||||
.merge(erp_message::MessageModule::protected_routes());
|
||||
|
||||
// 迁移后(自动)
|
||||
let (public, protected) = registry.build_routes();
|
||||
```
|
||||
|
||||
## 附录 B: EventBus 类型化订阅扩展
|
||||
|
||||
### B.1 现有 EventBus 扩展
|
||||
|
||||
现有的 `EventBus`(`erp-core/src/events.rs`)只有 `subscribe()` 方法返回全部事件的 `Receiver`。需要添加类型化过滤订阅:
|
||||
|
||||
```rust
|
||||
impl EventBus {
|
||||
/// 订阅特定事件类型
|
||||
/// 内部使用 mpmc 通道,为每个事件类型维护独立的分发器
|
||||
pub fn subscribe_filtered(
|
||||
&self,
|
||||
event_type: &str,
|
||||
handler: Box<dyn Fn(DomainEvent) + Send + Sync>,
|
||||
) -> SubscriptionHandle {
|
||||
// 在内部 HashMap<String, Vec<Handler>> 中注册
|
||||
// publish() 时根据 event_type 分发到匹配的 handler
|
||||
}
|
||||
|
||||
/// 取消订阅(用于插件停用时清理)
|
||||
pub fn unsubscribe(&self, handle: SubscriptionHandle) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### B.2 插件事件处理器包装
|
||||
|
||||
```rust
|
||||
struct PluginEventHandler {
|
||||
plugin_id: String,
|
||||
handler_fn: Box<dyn Fn(DomainEvent) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl PluginEventHandler {
|
||||
fn handle(&self, event: DomainEvent) {
|
||||
// 捕获 panic,防止插件崩溃影响宿主
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
(self.handler_fn)(event)
|
||||
});
|
||||
if let Err(_) = result {
|
||||
tracing::error!("插件 {} 事件处理器崩溃", self.plugin_id);
|
||||
// 通知 PluginManager 标记插件为 error 状态
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 C: 管理后台 API 权限控制
|
||||
|
||||
### C.1 权限模型
|
||||
|
||||
| API 端点 | 所需权限 | 角色范围 |
|
||||
|----------|---------|----------|
|
||||
| `POST /admin/plugins/upload` | `plugin:admin` | 仅平台超级管理员 |
|
||||
| `POST /admin/plugins/{id}/enable` | `plugin:manage` | 平台管理员或租户管理员(仅限自己租户的插件) |
|
||||
| `POST /admin/plugins/{id}/disable` | `plugin:manage` | 平台管理员或租户管理员 |
|
||||
| `DELETE /admin/plugins/{id}` | `plugin:manage` | 租户管理员(软删除) |
|
||||
| `DELETE /admin/plugins/{id}/purge` | `plugin:admin` | 仅平台超级管理员 |
|
||||
| `GET /admin/plugins` | `plugin:view` | 租户管理员(仅看到自己租户的插件) |
|
||||
| `PUT /admin/plugins/{id}/config` | `plugin:configure` | 租户管理员 |
|
||||
| `GET /admin/plugins/{id}/health` | `plugin:view` | 租户管理员 |
|
||||
|
||||
### C.2 租户隔离
|
||||
|
||||
- 插件管理 API 自动注入 `tenant_id` 过滤(从 JWT 中提取)
|
||||
- 平台超级管理员可以通过 `/admin/platform/plugins` 查看所有租户的插件
|
||||
- 租户管理员只能管理自己租户安装的插件
|
||||
- 插件上传为平台级操作(所有租户共享同一个 WASM 二进制),但启用/配置为租户级操作
|
||||
|
||||
## 附录 D: WIT 接口定义
|
||||
|
||||
### D.1 插件接口 (`plugin.wit`)
|
||||
|
||||
```wit
|
||||
package erp:plugin;
|
||||
|
||||
interface host {
|
||||
/// 数据库操作
|
||||
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: string, id: string, data: list<u8>, version: s64) -> result<list<u8>, string>;
|
||||
db-delete: func(entity: string, id: string) -> result<_, string>;
|
||||
db-aggregate: func(entity: string, query: list<u8>) -> result<list<u8>, 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>;
|
||||
}
|
||||
|
||||
interface plugin {
|
||||
/// 插件初始化(加载时调用一次)
|
||||
init: func() -> result<_, string>;
|
||||
|
||||
/// 租户创建时调用
|
||||
on-tenant-created: func(tenant-id: string) -> result<_, string>;
|
||||
|
||||
/// 处理订阅的事件
|
||||
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
|
||||
/// 自定义页面渲染(仅 type=custom 页面)
|
||||
render-page: func(page-path: string, params: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 自定义页面操作处理
|
||||
handle-action: func(page-path: string, action: string, data: list<u8>) -> result<list<u8>, string>;
|
||||
}
|
||||
|
||||
world plugin-world {
|
||||
import host;
|
||||
export plugin;
|
||||
}
|
||||
```
|
||||
|
||||
### D.2 使用方式
|
||||
|
||||
插件开发者使用 `wit-bindgen` 生成绑定代码:
|
||||
|
||||
```bash
|
||||
# 生成 Rust 插件绑定
|
||||
wit-bindgen rust ./plugin.wit --out-dir ./src/generated
|
||||
```
|
||||
|
||||
宿主使用 `wasmtime` 的 `bindgen!` 宏生成调用端代码:
|
||||
|
||||
```rust
|
||||
// 在 erp-plugin-runtime crate 中
|
||||
wasmtime::component::bindgen!({
|
||||
path: "./plugin.wit",
|
||||
world: "plugin-world",
|
||||
async: true,
|
||||
});
|
||||
```
|
||||
|
||||
## 附录 E: 插件崩溃恢复策略
|
||||
|
||||
### E.1 崩溃检测与恢复
|
||||
|
||||
| 场景 | 检测方式 | 恢复策略 |
|
||||
|------|---------|----------|
|
||||
| WASM 执行 panic | `catch_unwind` 捕获 | 记录错误日志,该请求返回 500,插件继续运行 |
|
||||
| 插件 init() 失败 | 返回 Err | 标记插件为 `error` 状态,不加载 |
|
||||
| 事件处理器崩溃 | `catch_unwind` 捕获 | 记录错误日志,事件丢弃(不重试) |
|
||||
| 连续崩溃(>5次/分钟) | 计数器检测 | 自动停用插件,标记 `error`,通知管理员 |
|
||||
| 服务重启 | 启动流程 | 重新加载所有 `enabled` 状态的插件 |
|
||||
|
||||
### E.2 僵尸状态处理
|
||||
|
||||
插件在数据库中为 `enabled` 但实际未运行的情况:
|
||||
|
||||
1. 服务启动时,所有 `enabled` 插件尝试加载
|
||||
2. 加载失败的插件自动标记为 `error`,`error_message` 记录原因
|
||||
3. 管理后台显示 `error` 状态的插件,提供"重试"按钮
|
||||
4. 重试成功后恢复为 `enabled`,重试失败保持 `error`
|
||||
|
||||
### E.3 插件健康检查
|
||||
|
||||
```rust
|
||||
/// 定期健康检查(每 60 秒)
|
||||
async fn health_check_loop(registry: &ModuleRegistry) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let results = registry.health_check_all().await;
|
||||
for (id, health) in results {
|
||||
if health.status != "ok" {
|
||||
tracing::warn!("模块 {} 健康检查异常: {:?}", id, health.details);
|
||||
// 通知管理后台
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 F: Crate 依赖图更新
|
||||
|
||||
```
|
||||
erp-core (无业务依赖)
|
||||
erp-common (无业务依赖)
|
||||
↑
|
||||
erp-auth (→ core)
|
||||
erp-config (→ core)
|
||||
erp-workflow (→ core)
|
||||
erp-message (→ core)
|
||||
erp-plugin-runtime (→ core, wasmtime) ← 新增
|
||||
↑
|
||||
erp-server (→ 所有 crate,组装入口)
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- `erp-plugin-runtime` 依赖 `erp-core`(使用 EventBus、ErpModule trait、AppError)
|
||||
- `erp-plugin-runtime` 依赖 `wasmtime`(WASM 运行时)
|
||||
- `erp-plugin-runtime` 不依赖任何业务 crate(auth/config/workflow/message)
|
||||
- `erp-server` 在组装时引入 `erp-plugin-runtime`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,767 +0,0 @@
|
||||
# CRM 插件基座升级设计规格 v1.0
|
||||
|
||||
> **文档状态:** v1.1 — 已修复评审问题
|
||||
> **创建日期:** 2026-04-17
|
||||
> **范围:** JSONB 存储优化 + 数据完整性框架 + 行级数据权限 + 前端页面能力增强
|
||||
> **评审记录:** code-reviewer 子代理评审通过一轮修复(3 Critical + 7 Important)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
CRM 插件是 ERP 平台的第一个 WASM 行业插件,已完成 3 阶段 24 任务,包含 5 实体、9 权限、7 页面类型。经 6 个专家组深度评审,发现以下结构性问题需要优先解决:
|
||||
|
||||
| 问题 | 严重级别 | 影响 |
|
||||
|------|---------|------|
|
||||
| JSONB 动态表类型安全缺失、排序全表扫描 | High | 万级数据以上性能崩溃 |
|
||||
| JSONB 零外键完整性、零级联策略 | High | 数据"脏"掉,引用断裂 |
|
||||
| 行级数据权限缺失 | Critical | 销售A能看到销售B的所有客户 |
|
||||
| plugin.admin 权限 fallback 过宽 | Critical | 超级用户权限泄露 |
|
||||
| 无关联选择器 (entity_select) | High | UX 极差,客户ID手动输入 |
|
||||
| 无看板/批量操作/图表等页面能力 | Medium | CRM 功能不完整 |
|
||||
|
||||
**核心原则:** 基座优先。所有改进沉淀为插件平台通用能力,CRM 作为第一受益者而非唯一受益者。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计目标
|
||||
|
||||
1. **JSONB 存储优化** — 百万级数据下列表查询 p95 < 200ms,搜索 p95 < 300ms
|
||||
2. **数据完整性框架** — 应用层外键校验、级联策略、字段校验、循环引用检测
|
||||
3. **行级数据权限** — 支持 self/department/department_tree/all 四级数据范围
|
||||
4. **前端页面能力增强** — 关联选择器、看板页面、批量操作、Dashboard 图表、visible_when 增强
|
||||
|
||||
---
|
||||
|
||||
## 3. JSONB 存储优化
|
||||
|
||||
### 3.1 Generated Column 混合存储
|
||||
|
||||
利用 PostgreSQL 12+ 的 `GENERATED ALWAYS AS ... STORED` 列,自动从 JSONB `data` 列提取高频字段到独立列。数据只存一份(在 JSONB 中),Generated Column 是自动派生的,零维护成本。
|
||||
|
||||
**提取规则(在 `dynamic_table.rs` 的 `create_table` 中自动判断):**
|
||||
|
||||
| 字段特征 | 提取策略 | 原因 |
|
||||
|----------|---------|------|
|
||||
| `unique == true` | Generated Column + UNIQUE INDEX | 需要精确唯一性约束 |
|
||||
| `required == true && (sortable \|\| filterable)` | Generated Column + INDEX | 需要类型化排序/筛选 |
|
||||
| `sortable == true` | Generated Column + INDEX | ORDER BY 走 B-tree |
|
||||
| `filterable == true` | Generated Column + INDEX | WHERE 走索引扫描 |
|
||||
| `searchable == true` | 保留 JSONB + pg_trgm GIN 索引 | 模糊搜索用三元组索引 |
|
||||
| 其他字段 | 保留 JSONB | 无需索引 |
|
||||
|
||||
**生成的 DDL 示例:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE plugin_erp_crm_customer (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
-- Generated Columns
|
||||
_f_code TEXT GENERATED ALWAYS AS (data->>'code') STORED,
|
||||
_f_name TEXT GENERATED ALWAYS AS (data->>'name') STORED,
|
||||
_f_customer_type TEXT GENERATED ALWAYS AS (data->>'customer_type') STORED,
|
||||
_f_status TEXT GENERATED ALWAYS AS (data->>'status') STORED,
|
||||
_f_level TEXT GENERATED ALWAYS AS (data->>'level') STORED,
|
||||
-- 标准字段
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID,
|
||||
updated_by UUID,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
version INT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- 复合索引(tenant_id 在前,支持多租户过滤)
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_tenant_cover
|
||||
ON "{t}" (tenant_id, created_at DESC)
|
||||
INCLUDE (id, data, updated_at, version)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_f_name_sort
|
||||
ON "{t}" (tenant_id, _f_name)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_{t}_f_code_uniq
|
||||
ON "{t}" (tenant_id, _f_code)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_f_type_filter
|
||||
ON "{t}" (tenant_id, _f_customer_type)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**SQL 查询路由:** 在 `dynamic_table.rs` 中新增 `GeneratedColumnInfo` 结构,记录哪些字段被提取为 Generated Column。`build_filtered_query_sql` 和 `build_aggregate_sql` 检测到对应 Generated Column 存在时,自动将 `data->>'field'` 替换为 `_f_{field}`。
|
||||
|
||||
**类型映射:** `data->>'field'` 始终返回 TEXT。对于非字符串类型,Generated Column 需要类型转换以支持正确的排序和比较:
|
||||
|
||||
| field_type | SQL 类型 | Generated Column 表达式 |
|
||||
|------------|---------|------------------------|
|
||||
| String | TEXT | `data->>'field'` |
|
||||
| Integer | INTEGER | `(data->>'field')::INTEGER` |
|
||||
| Float | DOUBLE PRECISION | `(data->>'field')::DOUBLE PRECISION` |
|
||||
| Decimal | NUMERIC(18,4) | `(data->>'field')::NUMERIC` |
|
||||
| Boolean | BOOLEAN | `(data->>'field')::BOOLEAN` |
|
||||
| Date | DATE | `(data->>'field')::DATE` |
|
||||
| DateTime | TIMESTAMPTZ | `(data->>'field')::TIMESTAMPTZ` |
|
||||
| Uuid | UUID | `(data->>'field')::UUID` |
|
||||
|
||||
`dynamic_table.rs` 的 `create_table` 根据 `PluginField.field_type` 自动选择正确的 SQL 类型和类型转换表达式。
|
||||
|
||||
**元数据表:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS plugin_entity_columns (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL, -- 多租户标准字段
|
||||
plugin_entity_id UUID NOT NULL REFERENCES plugin_entities(id),
|
||||
field_name VARCHAR(100) NOT NULL,
|
||||
column_name VARCHAR(100) NOT NULL, -- 如 _f_name
|
||||
sql_type VARCHAR(50) NOT NULL, -- 如 TEXT, INTEGER, UUID
|
||||
is_generated BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Schema 演变策略(重新安装/字段变更):**
|
||||
|
||||
当前 `service.rs` 的 `install` 使用 `CREATE TABLE IF NOT EXISTS`。引入 Generated Column 后,安装流程改为:
|
||||
|
||||
1. **首次安装**:`CREATE TABLE` 包含所有 Generated Column。
|
||||
2. **重新安装(同版本)**:`IF NOT EXISTS` 跳过表创建。比对 `plugin_entity_columns` 元数据与当前 manifest 的字段列表,执行增量 ALTER:
|
||||
- 新增字段:`ALTER TABLE ADD COLUMN _f_{name} {type} GENERATED ALWAYS AS (...) STORED`
|
||||
- 删除字段:`ALTER TABLE DROP COLUMN _f_{name}`(仅删除 Generated Column,JSONB data 中的原始值保留)
|
||||
- 类型变更:PostgreSQL 不支持 ALTER GENERATED COLUMN 的表达式,需 DROP + ADD
|
||||
3. **插件卸载时**:表被删除,元数据自动清理。
|
||||
|
||||
`dynamic_table.rs` 新增 `migrate_table` 方法,接受已有列列表和目标列列表,生成增量 DDL。
|
||||
|
||||
### 3.2 pg_trgm 模糊搜索加速
|
||||
|
||||
**迁移文件:** 在 `erp-server/migration` 中新增迁移启用 pg_trgm 扩展:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
```
|
||||
|
||||
**索引创建:** `create_table` 中 searchable 字段的索引从普通 B-tree 改为 GIN 三元组:
|
||||
|
||||
```sql
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_{f}_trgm
|
||||
ON "{t}" USING GIN ((data->>'{f}') gin_trgm_ops)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
启用后 `ILIKE '%keyword%'` 从全表扫描退化为索引扫描,百万级数据搜索从 2-5s 降至 50-200ms。
|
||||
|
||||
### 3.3 Keyset Pagination
|
||||
|
||||
**向后兼容设计:** API 同时支持 OFFSET 和 cursor 两种分页模式。
|
||||
|
||||
`data_dto.rs` 中 `PluginDataListParams` 新增 `cursor` 字段:
|
||||
|
||||
```rust
|
||||
pub struct PluginDataListParams {
|
||||
pub page: Option<u64>, // 保留,向后兼容
|
||||
pub page_size: Option<u64>,
|
||||
pub cursor: Option<String>, // 新增:Base64 编码的游标
|
||||
pub search: Option<String>,
|
||||
pub filter: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
`dynamic_table.rs` 中 SQL 构建逻辑:当 `cursor` 存在时使用 keyset 分页:
|
||||
|
||||
**游标编码格式:** JSON 结构 `{ "v": [value1, value2, ...], "id": "uuid" }`,Base64 编码。`v` 数组存储排序字段的值(与 sort_by 顺序一致),`id` 是记录主键作为最终 tiebreaker。多列排序时 `v` 包含多个值。字段值为 null 时存储 JSON null。
|
||||
|
||||
客户端必须在每次请求中同时发送 `cursor` 和 `sort_by`/`sort_order`(游标不嵌入排序信息,保持无状态)。
|
||||
|
||||
```sql
|
||||
-- 第一页
|
||||
SELECT ... ORDER BY _f_name ASC, id ASC LIMIT 20;
|
||||
|
||||
-- 后续页(cursor 解码后)
|
||||
SELECT ... WHERE (_f_name, id) > ($cursor_sort_val, $cursor_id)
|
||||
ORDER BY _f_name ASC, id ASC LIMIT 20;
|
||||
```
|
||||
|
||||
### 3.4 Schema 缓存
|
||||
|
||||
在 `PluginState` 中添加 `moka` LRU 缓存,消除每次数据请求的 `resolve_entity_info` 查库:
|
||||
|
||||
```rust
|
||||
pub entity_cache: Cache<String, EntityInfo>, // key: "{plugin_id}:{entity_name}:{tenant_id}"
|
||||
```
|
||||
|
||||
TTL 5 分钟,容量 1000 条。
|
||||
|
||||
### 3.5 聚合 Redis 缓存
|
||||
|
||||
`data_service.rs` 的 create/update/delete 成功后增量更新 Redis 统计:
|
||||
|
||||
```
|
||||
plugin:{plugin_id}:{entity}:count:{tenant_id} → 计数值
|
||||
plugin:{plugin_id}:{entity}:agg:{field}:{tenant_id} → JSON {key: count}
|
||||
```
|
||||
|
||||
Dashboard 查询直接从 Redis 读取,TTL 5 分钟兜底。
|
||||
|
||||
### 3.6 性能 SLA 目标
|
||||
|
||||
**测试条件:** PostgreSQL 与应用同机部署(Redis localhost 延迟 < 1ms)。SLA 包含 Redis 往返(schema 缓存 + 部门缓存)。冷启动(Redis 缓存未命中)首次查询允许 3x SLA 宽限。
|
||||
|
||||
| 查询场景 | 数据量 | p50 | p95 | p99 |
|
||||
|----------|--------|-----|-----|-----|
|
||||
| 按 ID 获取单条 | 100万 | < 5ms | < 10ms | < 20ms |
|
||||
| 列表查询(默认排序) | 100万 | < 20ms | < 50ms | < 100ms |
|
||||
| 列表查询(字段排序) | 100万 | < 30ms | < 100ms | < 200ms |
|
||||
| 搜索(ILIKE) | 100万 | < 50ms | < 100ms | < 300ms |
|
||||
| 聚合查询 | 100万 | < 50ms (缓存) | < 500ms (实时) | - |
|
||||
| Dashboard 全量加载 | 100万 | < 200ms | < 500ms | - |
|
||||
|
||||
### 3.7 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | 主要改动 — Generated Column DDL、索引策略、SQL 路由、keyset 分页 |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 缓存逻辑、聚合 Redis 缓存 |
|
||||
| `crates/erp-plugin/src/data_dto.rs` | 新增 cursor 参数 |
|
||||
| `crates/erp-plugin/src/state.rs` | 新增 entity_cache |
|
||||
| `crates/erp-plugin/src/manifest.rs` | PluginEntityColumns 元数据 |
|
||||
| `crates/erp-server/migration/src/` | pg_trgm 扩展 + plugin_entity_columns 表 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据完整性框架
|
||||
|
||||
### 4.1 外键引用声明
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `ref_entity` 字段:
|
||||
|
||||
```rust
|
||||
pub struct PluginField {
|
||||
pub name: String,
|
||||
pub field_type: PluginFieldType,
|
||||
// ...已有字段...
|
||||
pub ref_entity: Option<String>, // 新增:引用的实体名
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "所属客户"
|
||||
ref_entity = "customer" # 声明外键引用
|
||||
```
|
||||
|
||||
### 4.2 应用层外键校验
|
||||
|
||||
在 `data_service.rs` 的 `validate_data` 函数中扩展:
|
||||
|
||||
```
|
||||
create/update 时:
|
||||
遍历 fields,如果 field.ref_entity 存在:
|
||||
1. 从 data 中取出该字段的 UUID 值
|
||||
2. 如果值为 null 或空字符串且 required == false → 跳过校验
|
||||
3. 如果是自引用(ref_entity == 当前实体名)且为 create 操作:
|
||||
a. 如果引用的是自身 ID → 跳过(记录尚不存在,无法校验)
|
||||
b. 如果引用的是其他记录 → 正常校验
|
||||
4. 查询 ref_entity 对应的动态表,验证该记录存在且未删除
|
||||
5. 不存在则返回 ValidationError
|
||||
|
||||
TOCTOU 竞态说明:
|
||||
外键校验与引用记录删除之间存在理论上的竞态窗口。
|
||||
对于 JSONB 动态表,这是可接受的风险——应用层校验已大幅降低孤立引用概率。
|
||||
如果未来需要严格保证,可在 flush_ops 中增加二次校验(事务内 SELECT FOR UPDATE)。
|
||||
```
|
||||
|
||||
### 4.3 级联删除策略
|
||||
|
||||
`manifest.rs` 新增 `PluginRelation` 结构:
|
||||
|
||||
```rust
|
||||
pub struct PluginRelation {
|
||||
pub entity: String, // 关联实体名
|
||||
pub foreign_key: String, // 关联实体中的外键字段名
|
||||
pub on_delete: OnDeleteStrategy, // 级联策略
|
||||
}
|
||||
|
||||
pub enum OnDeleteStrategy {
|
||||
Nullify, // 置空外键字段
|
||||
Cascade, // 级联软删除
|
||||
Restrict, // 存在关联时拒绝删除
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[schema.entities.relations]]
|
||||
entity = "contact"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "nullify"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "communication"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer_tag"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
```
|
||||
|
||||
`data_service.rs` 的 `delete` 方法中,在软删除记录之前:
|
||||
|
||||
```
|
||||
1. 从 manifest 中查找该实体声明的所有 relations
|
||||
2. 对每个 relation:
|
||||
- Restrict: 查询关联实体是否有引用 → 有则拒绝删除
|
||||
- Nullify: 批量 UPDATE 关联记录,将 foreign_key 设为 null
|
||||
- Cascade: 批量软删除关联记录(级联深度上限 3 层,防止 A→B→C→D 无限递归)
|
||||
```
|
||||
|
||||
### 4.4 字段校验规则
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `validation` 子结构:
|
||||
|
||||
```rust
|
||||
pub struct FieldValidation {
|
||||
pub pattern: Option<String>, // 正则表达式
|
||||
pub message: Option<String>, // 校验失败提示
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "phone"
|
||||
field_type = "string"
|
||||
display_name = "手机号"
|
||||
validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" }
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "email"
|
||||
field_type = "string"
|
||||
display_name = "邮箱"
|
||||
validation = { pattern = "^[\\w.-]+@[\\w.-]+\\.\\w+$", message = "邮箱格式不正确" }
|
||||
```
|
||||
|
||||
`validate_data` 扩展:对有 `validation.pattern` 的字段,使用 `regex` crate 做正则匹配。
|
||||
|
||||
### 4.5 循环引用检测
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `no_cycle` 字段:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "parent_id"
|
||||
field_type = "uuid"
|
||||
ref_entity = "customer"
|
||||
no_cycle = true # 声明不允许循环引用
|
||||
```
|
||||
|
||||
`data_service.rs` 的 `update` 方法中,当 `no_cycle == true` 的字段被修改时:
|
||||
|
||||
```
|
||||
1. 从 data 中取出新值 (new_parent_id)
|
||||
2. 初始化 visited = {record_id}
|
||||
3. 循环:查询 current 的 parent_id → 如果在 visited 中则报错 → 加入 visited
|
||||
4. 直到 parent_id 为 null 或到达根节点
|
||||
```
|
||||
|
||||
### 4.6 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-plugin/src/manifest.rs` | 新增 ref_entity / PluginRelation / FieldValidation / no_cycle |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 外键校验 / 级联删除 / 字段校验 / 循环检测 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | 为现有字段添加 ref_entity / relations / validation / no_cycle 声明 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 行级数据权限
|
||||
|
||||
### 5.1 数据范围模型
|
||||
|
||||
在实体级别声明是否启用行级数据权限,在权限级别声明数据范围等级。
|
||||
|
||||
**manifest 扩展:**
|
||||
|
||||
```toml
|
||||
[[schema.entities]]
|
||||
name = "customer"
|
||||
display_name = "客户"
|
||||
data_scope = true # 启用行级数据权限
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "owner_id"
|
||||
field_type = "uuid"
|
||||
display_name = "负责人"
|
||||
scope_role = "owner" # 标记为数据权限的"所有者"字段
|
||||
```
|
||||
|
||||
**权限声明扩展:**
|
||||
|
||||
```toml
|
||||
[[permissions]]
|
||||
code = "customer.list"
|
||||
name = "查看客户"
|
||||
data_scope_levels = ["self", "department", "department_tree", "all"]
|
||||
```
|
||||
|
||||
### 5.2 数据范围等级定义
|
||||
|
||||
| 等级 | 含义 | SQL 条件 |
|
||||
|------|------|---------|
|
||||
| `self` | 只看自己负责/创建的 | `data->>'owner_id' = current_user_id OR created_by = current_user_id` |
|
||||
| `department` | 看本部门所有人的 | `data->>'owner_id' IN (部门用户列表)` |
|
||||
| `department_tree` | 看本部门及下级部门 | `data->>'owner_id' IN (部门树用户列表)` |
|
||||
| `all` | 看全部 | 无额外条件 |
|
||||
|
||||
### 5.3 实现路径
|
||||
|
||||
**TenantContext 扩展:** `erp-core` 的 `TenantContext` 结构新增 `department_ids: Vec<Uuid>` 字段(注意:用户可通过岗位属于多个部门)。JWT claims 中新增 `dept_ids` 字段,JWT 中间件在构造 TenantContext 时填充。
|
||||
|
||||
**多部门用户处理:** 用户通过 Position 关联到多个 Department。`department` 级别取所有所属部门的并集;`department_tree` 取所有所属部门及其下级部门的并集。没有岗位/部门的用户在 `department` 和 `department_tree` 级别下只能看到自己创建的数据(降级为 self)。
|
||||
|
||||
**角色权限表扩展:** `role_permissions` 表新增 `data_scope` 字段(VARCHAR(32),默认值 `'all'`)。新增迁移文件 `m20260418_*_add_data_scope_to_role_permissions.rs`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE role_permissions ADD COLUMN IF NOT EXISTS data_scope VARCHAR(32) NOT NULL DEFAULT 'all';
|
||||
```
|
||||
|
||||
**管理界面适配:** 角色权限分配界面新增"数据范围"下拉选项,管理员为每个权限分配时选择 self/department/department_tree/all。
|
||||
|
||||
**查询注入:** `data_service.rs` 的 `list` / `count` / `aggregate` 方法中:
|
||||
|
||||
```
|
||||
1. 从权限检查结果中获取该权限对应的 data_scope 等级
|
||||
2. 如果实体启用了 data_scope:
|
||||
- self: 注入 owner_id / created_by 过滤条件
|
||||
- department: 查询用户所在部门的所有用户 ID,注入 IN 条件
|
||||
- department_tree: 递归查询部门树,注入 IN 条件
|
||||
- all: 无额外条件
|
||||
3. 将条件追加到 dynamic_table 的 SQL 构建中
|
||||
```
|
||||
|
||||
**部门用户缓存:** 使用 Redis 缓存部门-用户映射关系,TTL 10 分钟,避免每次查询都递归查部门树。当部门分配变更时通过 EventBus 事件 (`department.member_changed`) 失效缓存。
|
||||
|
||||
### 5.4 权限 fallback 收紧
|
||||
|
||||
**当前行为(危险):** `data_handler.rs` 中,如果没有实体级权限,fallback 到 `plugin.admin`,获得所有数据访问权。
|
||||
|
||||
**修改后:** 移除 fallback 逻辑。权限检查链改为:
|
||||
|
||||
```
|
||||
1. 检查实体级权限 ({manifest_id}.{entity}.{action})
|
||||
2. 存在 → 通过,附带 data_scope
|
||||
3. 不存在 → 拒绝 (403)
|
||||
```
|
||||
|
||||
`plugin.admin` 只管理插件生命周期(上传/安装/启用/禁用/卸载),不自动获得数据访问权。需要显式分配实体级权限。
|
||||
|
||||
**迁移策略(避免现有管理员失去访问):** 在收紧 fallback 的迁移中,同时执行以下补偿:
|
||||
|
||||
```sql
|
||||
-- 为所有拥有 plugin.admin 权限的角色,自动分配所有已安装插件的实体级权限
|
||||
-- data_scope 默认设为 'all'(管理员级别)
|
||||
INSERT INTO role_permissions (id, role_id, permission_id, tenant_id, data_scope, ...)
|
||||
SELECT gen_random_uuid(), rp.role_id, p.id, rp.tenant_id, 'all', ...
|
||||
FROM role_permissions rp
|
||||
JOIN permissions p ON p.tenant_id = rp.tenant_id
|
||||
WHERE rp.permission_id = (SELECT id FROM permissions WHERE code = 'plugin.admin')
|
||||
AND p.code LIKE 'erp-%' -- 所有插件实体权限
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp2
|
||||
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = p.id
|
||||
);
|
||||
```
|
||||
|
||||
这确保现有管理员在 fallback 收紧后仍保持完整的数据访问能力。
|
||||
|
||||
### 5.5 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-core/src/types.rs` | TenantContext 新增 department_ids 字段 |
|
||||
| `crates/erp-auth/src/middleware/jwt_auth.rs` | JWT claims 解析 department_ids |
|
||||
| `crates/erp-plugin/src/manifest.rs` | data_scope / scope_role / data_scope_levels |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 查询条件注入 |
|
||||
| `crates/erp-plugin/src/handler/data_handler.rs` | 移除权限 fallback |
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | SQL 构建支持数据范围条件 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | customer 实体添加 data_scope / owner_id |
|
||||
| `crates/erp-server/migration/src/` | 新增 data_scope 列 + 权限补偿迁移 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端页面能力增强
|
||||
|
||||
### 6.1 关联选择器 (entity_select)
|
||||
|
||||
**Schema 扩展:** `PluginFieldSchema` 新增字段:
|
||||
|
||||
```typescript
|
||||
interface PluginFieldSchema {
|
||||
// ...已有字段...
|
||||
ref_entity?: string; // 引用的实体名
|
||||
ref_label_field?: string; // 显示字段
|
||||
ref_search_fields?: string[]; // 搜索字段
|
||||
cascade_from?: string; // 级联过滤来源字段
|
||||
cascade_filter?: string; // 级联过滤目标字段
|
||||
}
|
||||
```
|
||||
|
||||
**新增组件:** `EntitySelect.tsx` — 通用远程搜索选择器
|
||||
|
||||
```
|
||||
Props: pluginId, entity, labelField, searchFields, cascadeFrom?, cascadeFilter?, value?, onChange?
|
||||
内部: listPluginData(pluginId, entity, {search, filter}) → Ant Design Select + showSearch
|
||||
```
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
display_name = "所属客户"
|
||||
ui_widget = "entity_select"
|
||||
ref_entity = "customer"
|
||||
ref_label_field = "name"
|
||||
ref_search_fields = ["name", "code"]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "contact_id"
|
||||
field_type = "uuid"
|
||||
display_name = "关联联系人"
|
||||
ui_widget = "entity_select"
|
||||
ref_entity = "contact"
|
||||
ref_label_field = "name"
|
||||
ref_search_fields = ["name"]
|
||||
cascade_from = "customer_id" # 选了客户后自动过滤
|
||||
cascade_filter = "customer_id"
|
||||
```
|
||||
|
||||
### 6.2 Kanban 看板页面
|
||||
|
||||
**Schema 扩展:** `PluginPageType` 新增 `Kanban` 变体。
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "kanban"
|
||||
entity = "customer"
|
||||
label = "销售漏斗"
|
||||
icon = "swap"
|
||||
lane_field = "level"
|
||||
lane_order = ["potential", "normal", "vip", "svip"]
|
||||
card_title_field = "name"
|
||||
card_subtitle_field = "code"
|
||||
card_fields = ["name", "code", "region", "status"]
|
||||
enable_drag = true
|
||||
```
|
||||
|
||||
**新增组件:** `PluginKanbanPage.tsx`
|
||||
|
||||
- 使用 `@dnd-kit/core` + `@dnd-kit/sortable` 实现跨列拖拽
|
||||
- 每列使用 Ant Design Card 渲染卡片
|
||||
- 每列内支持虚拟滚动(节点数 > 50 时)
|
||||
- 拖拽结束调用 `PATCH /plugins/{id}/{entity}/{recordId}` 更新 lane_field 值
|
||||
|
||||
**后端新增:** `PATCH` 部分更新端点(当前只有 PUT 全量更新):
|
||||
|
||||
```
|
||||
PATCH /api/v1/plugins/{plugin_id}/{entity}/{id}
|
||||
Body: { "data": { "level": "vip" }, "version": 3 }
|
||||
```
|
||||
|
||||
与 PUT 的区别:PATCH 只更新 data 中提供的字段,未提供的字段保持不变。
|
||||
|
||||
### 6.3 批量操作
|
||||
|
||||
**CRUD 页面增强:** `PluginCRUDPage.tsx` 新增 `rowSelection` 和批量操作栏。
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = "customer"
|
||||
enable_batch = true
|
||||
|
||||
[[ui.pages.batch_actions]]
|
||||
label = "批量删除"
|
||||
action = "batch_delete"
|
||||
permission = "customer.manage"
|
||||
confirm = true
|
||||
|
||||
[[ui.pages.batch_actions]]
|
||||
label = "批量修改状态"
|
||||
action = "batch_update"
|
||||
update_field = "status"
|
||||
permission = "customer.manage"
|
||||
```
|
||||
|
||||
**后端新增:** `POST /api/v1/plugins/{id}/{entity}/batch`
|
||||
|
||||
```rust
|
||||
pub enum BatchAction {
|
||||
BatchDelete { ids: Vec<Uuid> },
|
||||
BatchUpdate { ids: Vec<Uuid>, data: serde_json::Value },
|
||||
}
|
||||
```
|
||||
|
||||
批量操作在单个事务中执行,有上限(默认 100 条)。
|
||||
|
||||
### 6.4 visible_when 表达式增强
|
||||
|
||||
**当前:** 只支持 `field == 'value'` 单一等式。
|
||||
|
||||
**增强后支持:**
|
||||
|
||||
```toml
|
||||
visible_when = "customer_type == 'enterprise'"
|
||||
visible_when = "customer_type == 'enterprise' AND level == 'vip'"
|
||||
visible_when = "status == 'active' OR status == 'pending'"
|
||||
visible_when = "NOT status == 'blacklist'"
|
||||
visible_when = "customer_type == 'enterprise' AND (level == 'vip' OR level == 'svip')"
|
||||
```
|
||||
|
||||
**前端实现:** 新建 `exprEvaluator.ts`,约 100 行递归下降表达式解析器:
|
||||
|
||||
```typescript
|
||||
interface ExprNode {
|
||||
type: 'eq' | 'and' | 'or' | 'not';
|
||||
field?: string;
|
||||
value?: string;
|
||||
left?: ExprNode;
|
||||
right?: ExprNode;
|
||||
operand?: ExprNode;
|
||||
}
|
||||
|
||||
function parseExpr(input: string): ExprNode;
|
||||
function evaluateExpr(node: ExprNode, values: Record<string, unknown>): boolean;
|
||||
```
|
||||
|
||||
不引入外部依赖,不使用 eval。
|
||||
|
||||
### 6.5 Dashboard 图表增强
|
||||
|
||||
**Schema 扩展:** Dashboard 页面支持 widgets 声明:
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "dashboard"
|
||||
label = "统计概览"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "stat_card"
|
||||
entity = "customer"
|
||||
title = "客户总数"
|
||||
icon = "team"
|
||||
color = "#4F46E5"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "bar_chart"
|
||||
entity = "customer"
|
||||
title = "客户地区分布"
|
||||
dimension_field = "region"
|
||||
metric = "count"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "pie_chart"
|
||||
entity = "customer"
|
||||
title = "客户类型分布"
|
||||
dimension_field = "customer_type"
|
||||
metric = "count"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "funnel_chart"
|
||||
entity = "customer"
|
||||
title = "客户等级漏斗"
|
||||
dimension_field = "level"
|
||||
dimension_order = ["potential", "normal", "vip", "svip"]
|
||||
metric = "count"
|
||||
```
|
||||
|
||||
**图表库:** 使用 `@ant-design/charts`(Ant Design 生态一致,支持按需引入)。
|
||||
|
||||
**后端新增:** timeseries 聚合 API:
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{id}/{entity}/timeseries?time_field=occurred_at&time_grain=week&start=2026-01-01&end=2026-04-17
|
||||
|
||||
响应:{ "data": [{ "period": "2026-W01", "count": 12 }, ...] }
|
||||
```
|
||||
|
||||
SQL 实现:`date_trunc('week', (data->>'occurred_at')::timestamp)`
|
||||
|
||||
**数据钻取:** 图表点击维度值时跳转到 CRUD 页面并自动带上筛选条件。`PluginCRUDPage` 支持从 URL query 参数初始化筛选。
|
||||
|
||||
### 6.6 前端文件拆分
|
||||
|
||||
| 当前文件 | 行数 | 拆分方案 |
|
||||
|---------|------|---------|
|
||||
| `PluginGraphPage.tsx` | 1081 | → `graphRenderer.ts` + `graphLayout.ts` + `graphInteraction.ts` |
|
||||
| `PluginCRUDPage.tsx` | 617 | → `CrudTable.tsx` + `CrudForm.tsx` + `CrudDetail.tsx` |
|
||||
| `PluginDashboardPage.tsx` | 647 | → `DashboardWidgets.tsx` + `dashboardTypes.ts` |
|
||||
|
||||
拆分后每个文件控制在 400 行以内。
|
||||
|
||||
### 6.7 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `apps/web/src/components/EntitySelect.tsx` | 新增 |
|
||||
| `apps/web/src/pages/PluginKanbanPage.tsx` | 新增 |
|
||||
| `apps/web/src/utils/exprEvaluator.ts` | 新增 |
|
||||
| `apps/web/src/pages/PluginCRUDPage.tsx` | 重构 — 拆分 + 批量操作 + entity_select + visible_when |
|
||||
| `apps/web/src/pages/PluginGraphPage.tsx` | 重构 — 拆分 |
|
||||
| `apps/web/src/pages/PluginDashboardPage.tsx` | 重构 — 图表 + 拆分 |
|
||||
| `apps/web/src/pages/PluginTreePage.tsx` | 优化 — 懒加载 |
|
||||
| `apps/web/src/api/plugins.ts` | Schema 类型扩展 |
|
||||
| `apps/web/src/api/pluginData.ts` | 新增 batch / timeseries / cursor API |
|
||||
| `apps/web/src/App.tsx` | Kanban 路由注册 |
|
||||
| `crates/erp-plugin/src/handler/data_handler.rs` | 新增 PATCH / batch 端点 |
|
||||
| `crates/erp-plugin/src/data_service.rs` | batch / timeseries / partial update(PATCH 只合并 data 中的字段) |
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | 新增 `build_patch_sql` 部分更新 SQL 构建器 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与缓解
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| Generated Column 的 ALTER TABLE 锁表 | 中 | 中 | 插件安装时在低峰期执行;万级数据以内锁表时间 < 1s |
|
||||
| pg_trgm 索引空间开销(约 2-3x 原始文本) | 低 | 低 | 只为 searchable 的短文本字段创建 |
|
||||
| 行级权限的部门查询性能 | 中 | 中 | Redis 缓存部门树,TTL 10 分钟 |
|
||||
| 批量操作事务过大 | 低 | 中 | 上限 100 条;超过则分批执行 |
|
||||
| 前端重构引入回归 | 中 | 高 | 逐文件拆分,每步验证现有功能不变 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 不在范围内(后续版本)
|
||||
|
||||
以下内容在本次设计中**不涉及**,记录为已知需求:
|
||||
|
||||
- WASM Guest 业务逻辑增强 (L2/L3 插件模型)
|
||||
- 插件版本升级迁移框架
|
||||
- 跨插件通信 (事件契约 + 只读查询)
|
||||
- 插件间 RPC / 自定义 API 端点
|
||||
- 插件市场 / 分发架构
|
||||
- CRM 新增实体 (lead / opportunity / activity)
|
||||
- WIT 接口版本化
|
||||
- 图谱 LOD + WebGL 渲染
|
||||
- Iframe / Web Component 自定义 UI
|
||||
|
||||
这些将在后续的设计规格中详细展开。
|
||||
@@ -1,456 +0,0 @@
|
||||
# ERP 平台底座 — 全面成熟度提升路线图
|
||||
|
||||
> 创建日期:2026-04-17
|
||||
> 状态:审查修订完成
|
||||
> 范围:安全、架构、测试、前端体验、插件生态 — 3 季度分层推进
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 项目现状
|
||||
|
||||
ERP 平台底座已完成 Phase 1-6 基础设施建设 + WASM 插件系统集成 + CRM 客户管理插件。当前具备:
|
||||
|
||||
- 6 个业务模块(auth, config, workflow, message, plugin, server)
|
||||
- 36 个数据库迁移
|
||||
- 完整的 WASM 插件运行时
|
||||
- Schema 驱动的动态前端(6 种页面类型)
|
||||
- React 19 + Ant Design 6 + Zustand 5 前端 SPA
|
||||
|
||||
### 1.2 分析发现摘要
|
||||
|
||||
| 维度 | 评分 | 关键问题 |
|
||||
|------|------|---------|
|
||||
| 架构健壮性 | 8/10 | ErpModule trait 死代码、路由注册未自动化 |
|
||||
| 代码质量 | 7/10 | N+1 查询、错误映射过宽、 oversized 组件 |
|
||||
| 安全性 | 5/10 | 3 个 CRITICAL(硬编码密钥/密码)、4 个 HIGH |
|
||||
| 测试覆盖 | 4/10 | 零数据库集成测试、关键流程未覆盖 |
|
||||
| 前端体验 | 7/10 | 无 i18n、无 Error Boundary、无虚拟滚动 |
|
||||
| 基础设施 | 4/10 | 无 CI/CD、Wiki 过时、大量未跟踪文件 |
|
||||
|
||||
### 1.3 目标
|
||||
|
||||
通过 3 个季度的分层改进,将平台从"功能完整"推进到"生产就绪":
|
||||
|
||||
- **Q2(4-5月)**:消除安全风险,建立自动化质量门
|
||||
- **Q3(6-8月)**:强化架构,提升前端工程化水平
|
||||
- **Q4(9-11月)**:补齐测试覆盖,扩展插件生态
|
||||
|
||||
### 1.4 约束
|
||||
|
||||
- **独立开发者** + Claude 辅助 — 每季度聚焦单一维度
|
||||
- **SaaS 优先**部署 — 多租户安全是硬性要求
|
||||
- **不破坏现有功能** — 所有改进必须向后兼容
|
||||
|
||||
---
|
||||
|
||||
## 2. Q2:安全地基 + CI/CD(4-5月)
|
||||
|
||||
### 2.1 密钥外部化与启动强制检查
|
||||
|
||||
**问题:**
|
||||
- JWT 密钥 `"change-me-in-production"` 硬编码在 `crates/erp-server/config/default.toml`
|
||||
- 管理员密码 `"Admin@2026"` 硬编码 + fallback
|
||||
- 数据库凭据 `postgres://erp:erp_dev_2024@...` 硬编码
|
||||
- `.test_token` 含有效 admin JWT 提交到仓库
|
||||
|
||||
**方案:**
|
||||
|
||||
1. **配置强制化**:`default.toml` 只保留开发环境默认值。生产敏感值通过环境变量 `ERP__` 前缀注入(已有机制)
|
||||
2. **启动检查**:服务启动时检测 JWT 密钥是否为默认值,若是则 **拒绝启动**(返回错误退出码,不只是警告)
|
||||
3. **密码初始化**:`seed_tenant_auth` 从环境变量 `ERP__SUPER_ADMIN_PASSWORD` 读取初始密码(与现有 `module.rs:149` 中的变量名一致),未设置则拒绝初始化(移除 fallback 到硬编码值的逻辑)
|
||||
4. **清理 `.test_token`**:立即加入 `.gitignore`。验证该文件是否曾被提交到 git 历史 — 如果曾提交,需使用 BFG Repo-Cleaner 清理历史(因包含用硬编码密钥签名的 admin JWT,等同于密钥泄露)
|
||||
5. **`default.toml` 占位符**:敏感字段改为 `"__MUST_SET_VIA_ENV__"` 之类的明显占位值
|
||||
|
||||
**验证标准:**
|
||||
- 默认配置启动时服务拒绝运行
|
||||
- 环境变量设置后正常启动
|
||||
- `.test_token` 不再出现在仓库中
|
||||
|
||||
### 2.2 Gitea Actions CI/CD
|
||||
|
||||
**流水线设计:**
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
rust-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo fmt --check --all
|
||||
- run: cargo clippy -- -D warnings
|
||||
|
||||
rust-test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env: { POSTGRES_DB: erp_test, POSTGRES_USER: test, POSTGRES_PASSWORD: test }
|
||||
ports: ["5432:5432"]
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --workspace
|
||||
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- run: cd apps/web && pnpm install && pnpm build
|
||||
|
||||
security-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo audit
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- run: cd apps/web && pnpm audit
|
||||
```
|
||||
|
||||
**关键决策:**
|
||||
- 使用 Gitea Actions(与 GitHub Actions 语法兼容)
|
||||
- 每个 job 包含 `actions/checkout@v4` + 对应语言 toolchain setup
|
||||
- Rust 使用 `Swatinem/rust-cache@v2` 缓存编译产物,避免每次全量编译
|
||||
- PostgreSQL 通过 service 容器提供
|
||||
- 四个 job 并行运行,互不依赖
|
||||
- 后续可扩展:Redis service、Playwright E2E、Docker 镜像构建推送
|
||||
|
||||
### 2.3 审计日志补全
|
||||
|
||||
**当前缺口与改进:**
|
||||
|
||||
| 缺口 | 改进方案 |
|
||||
|------|---------|
|
||||
| 登录/登出只发 DomainEvent,不写审计日志 | 在 `auth_service` 的 login/logout/change_password 中调用 `audit_service::record()` |
|
||||
| 审计日志缺少 `old_value`/`new_value` | 关键实体(user/role/permission/org)的 update 操作添加 `.with_changes(old, new)`。序列化完整的旧模型和新模型为 JSON,由审计日志消费者计算 diff — 比应用层计算细粒度 diff 更简单健壮 |
|
||||
| 缺少 IP 地址和 User-Agent | `AuditLogBuilder::with_request_info()` 在 handler 层传入请求上下文 |
|
||||
| 插件 CRUD 无审计 | `data_service` 的 create/update/delete 操作添加审计日志记录 |
|
||||
| 登录失败无记录 | 添加失败登录审计(含尝试的用户名/IP),用于入侵检测 |
|
||||
|
||||
**验证标准:**
|
||||
- 登录成功/失败均写入审计日志
|
||||
- 用户更新操作记录变更前后值
|
||||
- 审计日志包含 IP 和 User-Agent
|
||||
|
||||
### 2.4 Docker 生产化
|
||||
|
||||
| 改进项 | 当前 | 目标 |
|
||||
|--------|------|------|
|
||||
| PostgreSQL 端口 | `ports: "5432:5432"` 暴露到宿主机 | 移除 `ports:`,使用 Docker 网络内部通信 |
|
||||
| Redis 端口 | `ports: "6379:6379"` 无认证 | 移除 `ports:`,添加 `--requirepass` |
|
||||
| 容器资源限制 | 无 | CPU 1核 / 内存 512MB |
|
||||
| 应用镜像 | 无 Dockerfile | 多阶段构建:Rust build → 精简 runtime 镜像 |
|
||||
| Redis 宕机时限流 | fail-open(无限流) | fail-closed(拒绝请求) |
|
||||
|
||||
**限流 fail-closed 改动:**
|
||||
`crates/erp-server/src/middleware/rate_limit.rs` 中 Redis 不可用时,返回 `429 Too Many Requests` 而非放行。
|
||||
|
||||
### 2.5 多租户安全加固
|
||||
|
||||
| 问题 | 改进方案 |
|
||||
|------|---------|
|
||||
| 登录使用硬编码 `default_tenant_id` | 登录接口增加租户解析(从子域名/请求头 `X-Tenant-ID`) |
|
||||
| `auth_service::refresh()` 用户查询缺少 tenant_id(`auth_service.rs:177`) | `find_by_id` 添加 `.filter(user::Column::TenantId.eq(claims.tenant_id))` |
|
||||
| 内存级 tenant_id 过滤(`user_service.rs` 的 `get_by_id`/`update`/`delete`) | 改为数据库级 `.filter(Column::TenantId.eq(tenant_id))` 查询。注意:`login`/`list`/`assign_roles` 已正确使用数据库级过滤,无需修改 |
|
||||
|
||||
**涉及文件:**
|
||||
- `crates/erp-auth/src/handler/auth_handler.rs`
|
||||
- `crates/erp-auth/src/service/auth_service.rs`
|
||||
- `crates/erp-auth/src/service/user_service.rs`
|
||||
- `crates/erp-auth/src/middleware/jwt_auth.rs`
|
||||
|
||||
---
|
||||
|
||||
## 3. Q3:架构强化 + 前端体验(6-8月)
|
||||
|
||||
### 3.1 ErpModule Trait 重构
|
||||
|
||||
**当前问题:**
|
||||
- `register_event_handlers` 是死代码 — 所有模块实现为空操作
|
||||
- 路由注册需在 `main.rs` 手动编辑两处
|
||||
- 事件订阅在 `main.rs` 中手动调用,绕过 trait
|
||||
|
||||
**改进方案:**
|
||||
|
||||
基于当前 trait 签名(`erp-core/src/module.rs`),新增双路由注册和权限声明。保持与现有 `ModuleContext` 参数一致,不引入 `AppState` 依赖(避免 `erp-core` → `erp-server` 反向依赖):
|
||||
|
||||
```rust
|
||||
pub trait ErpModule: Send + Sync + 'static {
|
||||
// 保留已有方法
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> &str;
|
||||
fn module_type(&self) -> &str { "business" }
|
||||
fn dependencies(&self) -> Vec<&str> { vec![] }
|
||||
fn id(&self) -> Uuid { /* 默认实现 */ }
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
|
||||
// 新增:双路由注册(匹配现有 public/protected 分离模式)
|
||||
fn register_public_routes(&self, router: Router) -> Router { router }
|
||||
fn register_protected_routes(&self, router: Router) -> Router { router }
|
||||
|
||||
// 重构:事件订阅真正生效(当前所有模块实现为空操作)
|
||||
fn register_event_handlers(&self, bus: &EventBus) {}
|
||||
|
||||
// 新增:模块权限声明
|
||||
fn permissions(&self) -> Vec<PermissionDef> { vec![] }
|
||||
|
||||
// 保留已有生命周期钩子(保持 ModuleContext 参数签名)
|
||||
async fn on_startup(&self, _ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
async fn on_shutdown(&self) -> AppResult<()> { Ok(()) }
|
||||
async fn on_tenant_created(&self, _tenant_id: Uuid, _db: &DatabaseConnection, _bus: &EventBus) -> AppResult<()> { Ok(()) }
|
||||
async fn on_tenant_deleted(&self, _tenant_id: Uuid, _db: &DatabaseConnection, _bus: &EventBus) -> AppResult<()> { Ok(()) }
|
||||
async fn health_check(&self) -> AppResult<serde_json::Value> { Ok(serde_json::json!({})) }
|
||||
}
|
||||
```
|
||||
|
||||
`ModuleRegistry::build()` 自动收集路由、事件处理器和权限,`main.rs` 简化为:
|
||||
|
||||
```rust
|
||||
let (registry, public_routes, protected_routes) = ModuleRegistry::new()
|
||||
.register(auth_module)
|
||||
.register(config_module)
|
||||
.register(workflow_module)
|
||||
.register(message_module)
|
||||
.register(plugin_module)
|
||||
.build();
|
||||
|
||||
// 自动组合:public_routes 直接挂载,protected_routes 包裹 JWT 中间件
|
||||
let app = Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(protected_routes.layer(jwt_middleware));
|
||||
```
|
||||
|
||||
**迁移策略:** 逐模块迁移 — 每个模块从静态 `public_routes()`/`protected_routes()` 函数改为 trait 方法实现,`main.rs` 逐步简化。
|
||||
|
||||
**已知例外:** PluginModule 的两阶段初始化(先注册再启动事件监听器)在初期保持独立处理,不强行纳入自动化。`MessageModule::start_event_listener`、`WorkflowModule::start_timeout_checker`、`outbox::start_outbox_relay` 等独立生命周期钩子作为范围排除项,后续迭代再统一。
|
||||
|
||||
**迁移策略:** 逐模块迁移 — 每个模块从静态函数改为 trait 方法实现,`main.rs` 逐步简化。
|
||||
|
||||
### 3.2 错误映射修正 + N+1 查询优化
|
||||
|
||||
**错误映射修正:**
|
||||
|
||||
当前 `erp-auth` 服务中直接 `.map_err(|e| AuthError::Validation(e.to_string()))` 将所有 `DbErr` 映射为 `Validation`,绕过了 `erp-core` 中已有的 `From<DbErr> for AppError` 语义映射(该映射已正确处理 `RecordNotFound` → `NotFound`、重复键 → `Conflict`)。
|
||||
|
||||
**修复策略:** `erp-auth` 服务层停止手动包装 `DbErr`,改为通过 `?` 操作符依赖 `DbErr → AppError` 的核心映射,通过现有的 `From<AuthError> for AppError` 转换传播。这样数据库连接错误会正确显示为 `Internal`,唯一约束冲突会正确显示为 `Conflict`。
|
||||
|
||||
移除 `From<AppError> for AuthError` 的反向映射(当前是 lossy wrapping — `AppError::NotFound` 变为 `AuthError::Validation`,丢失语义信息)。
|
||||
|
||||
**N+1 查询优化:**
|
||||
|
||||
`user_service.rs` 的 `list()` 方法改为批量查询:
|
||||
1. 先查询当前页用户列表
|
||||
2. 收集所有 `user_id`
|
||||
3. 一次 `WHERE user_id IN (...)` 查询 `user_role` + `role`
|
||||
4. 内存中按 `user_id` 分组组装
|
||||
|
||||
从 N+1 查询降为 3 次固定查询(用户列表 + 角色关联 + 角色详情)。
|
||||
|
||||
### 3.3 前端 Error Boundary + hooks 提取
|
||||
|
||||
**Error Boundary:**
|
||||
- `App.tsx` 根组件包裹全局 Error Boundary(捕获未预期崩溃)
|
||||
- 每个懒加载页面外包裹页面级 Error Boundary(隔离单页面崩溃)
|
||||
- 失败时展示友好错误页面 + 重试按钮
|
||||
|
||||
**hooks 提取:**
|
||||
|
||||
| Hook | 提取来源 | 用途 |
|
||||
|------|---------|------|
|
||||
| `usePaginatedData<T>` | 6+ 页面的分页加载逻辑 | 统一分页/搜索/加载状态 |
|
||||
| `useDarkMode` | 8+ 文件的 `token.colorBgContainer` 字符串比较 | 提供可靠的 boolean 暗色模式判断 |
|
||||
| `useCountUp` | Home.tsx + DashboardWidgets 重复实现 | 计数动画复用 |
|
||||
| `useDebouncedValue` | Users.tsx 等搜索输入 | 防抖搜索,避免每次按键触发 API |
|
||||
| `useApiRequest` | 所有页面的 try/catch + message.error | 统一 API 错误处理和消息提示 |
|
||||
|
||||
### 3.4 i18n 基础设施搭建
|
||||
|
||||
**方案:react-i18next**
|
||||
|
||||
- 安装 `react-i18next` + `i18next`
|
||||
- 创建 `locales/zh-CN.json`,提取所有硬编码中文为 key
|
||||
- 配置 i18next 初始化,默认 `zh-CN`
|
||||
- 用 `useTranslation()` hook 替换硬编码字符串
|
||||
|
||||
**实施策略:** 增量式 — 新页面强制使用 i18n,旧页面按模块逐步迁移。不强求一次性替换。
|
||||
|
||||
**命名规范:**
|
||||
- 页面文案:`{module}.{page}.{element}` 如 `auth.login.username`
|
||||
- 通用文案:`common.{action}` 如 `common.save`, `common.cancel`
|
||||
- 错误消息:`error.{type}` 如 `error.network`, `error.unauthorized`
|
||||
|
||||
### 3.5 行级数据权限接线
|
||||
|
||||
**当前状态:** 数据库列、SQL 条件构建器、manifest 声明已就绪,handler 层有 TODO 未实现。
|
||||
|
||||
**完成步骤:**
|
||||
1. JWT 中间件注入 `department_ids`(完成 `jwt_auth.rs:50` 的 TODO)
|
||||
2. `data_handler` 查询接口注入 data scope 条件
|
||||
3. 前端角色权限编辑页添加 `data_scope` 选择控件
|
||||
4. 端到端验证:创建测试角色 → 设置数据范围 → 验证查询过滤
|
||||
|
||||
### 3.6 前端共享类型统一
|
||||
|
||||
- `PaginatedResponse<T>` 从 `users.ts` 提取到 `api/types.ts`
|
||||
- 错误提取工具函数 `extractErrorMessage(err: unknown): string` → `api/errors.ts`
|
||||
- 插件 Schema 类型定义集中到 `types/plugin.ts`
|
||||
- 移除 `api/client.ts` 中已废弃的 `CancelToken`,改用 `AbortController`
|
||||
|
||||
---
|
||||
|
||||
## 4. Q4:测试覆盖 + 插件生态(9-11月)
|
||||
|
||||
### 4.1 Q4 范围调整说明
|
||||
|
||||
Q4 原始范围较大(Testcontainers + Playwright + 进销存插件 + 热更新 + 文档清理)。调整为两个子阶段:
|
||||
|
||||
- **Q4a(9-10月)**:测试基础设施 — Testcontainers 集成测试框架 + Playwright E2E + 文档清理
|
||||
- **Q4b(11月+)**:插件生态 — 进销存插件 + 热更新
|
||||
|
||||
热更新功能可视 Q4a 进度推迟到 Q1 2027,避免在单季度内承载过多工作。
|
||||
|
||||
### 4.2 数据库集成测试框架
|
||||
|
||||
**方案:Testcontainers + PostgreSQL**
|
||||
|
||||
创建 `crates/erp-server/tests/integration/` 目录,使用 `testcontainers` crate 启动真实 PostgreSQL 容器。
|
||||
|
||||
**测试基座:**
|
||||
- 每个测试套件共享一个 PostgreSQL 容器
|
||||
- 自动运行所有迁移
|
||||
- 提供 `setup_test_db()` 辅助函数返回连接池
|
||||
- 测试结束自动清理
|
||||
|
||||
**覆盖优先级:**
|
||||
|
||||
| 优先级 | 模块 | 测试场景 |
|
||||
|--------|------|---------|
|
||||
| P0 | erp-auth | 用户 CRUD、角色权限分配、登录/JWT 完整流程 |
|
||||
| P0 | erp-auth | 多租户隔离 — 租户 A 数据对租户 B 不可见 |
|
||||
| P0 | erp-plugin | 插件生命周期(install→enable→disable→uninstall) |
|
||||
| P1 | erp-auth | 乐观锁并发冲突、软删除恢复 |
|
||||
| P1 | erp-plugin | 行级数据权限过滤、JSONB 查询/索引 |
|
||||
| P1 | erp-plugin | 动态表 DDL 正确性(generated column、pg_trgm 索引) |
|
||||
| P1 | erp-workflow | 流程实例启动、任务完成、网关分支 |
|
||||
| P1 | erp-core | 事件总线发布/订阅端到端、outbox relay 补偿 |
|
||||
|
||||
### 4.2 核心流程 E2E 测试
|
||||
|
||||
**方案:Playwright**
|
||||
|
||||
放在 `apps/web/e2e/` 目录,CI 中作为独立 job 运行。
|
||||
|
||||
**覆盖场景(4 个关键旅程):**
|
||||
|
||||
| 场景 | 步骤 |
|
||||
|------|------|
|
||||
| 完整登录流程 | 打开登录页 → 输入密码 → 验证 token → 刷新 token → 登出 → 验证跳转 |
|
||||
| 用户管理闭环 | 创建用户 → 分配角色 → 搜索用户 → 编辑 → 软删除 → 验证列表不显示 |
|
||||
| 插件安装流程 | 上传 WASM → 安装 → 验证菜单出现 → 数据 CRUD → 卸载 → 验证菜单消失 |
|
||||
| 多租户隔离 | 租户 A 创建用户 → 切换租户 B → 验证查询结果为空 |
|
||||
|
||||
### 4.3 第二个行业插件 — 进销存(Inventory)
|
||||
|
||||
**选择理由:**
|
||||
- 与 CRM 有天然关联(客户 → 订单 → 出库)
|
||||
- 实体数量适中(5-8 个),复杂度可控
|
||||
- 能验证插件系统的复用性和跨实体关联能力
|
||||
- 为后续财务模块铺垫
|
||||
|
||||
**实体设计:**
|
||||
|
||||
| 实体 | 字段 | 关联 |
|
||||
|------|------|------|
|
||||
| product 商品 | 名称/编码/规格/单位/分类/售价/成本价 | — |
|
||||
| warehouse 仓库 | 名称/地址/负责人/状态 | — |
|
||||
| stock 库存 | 商品/仓库/数量/成本/预警线 | → product, warehouse |
|
||||
| purchase_order 采购单 | 供应商/总金额/状态/日期 | → supplier(CRM), stock |
|
||||
| sales_order 销售单 | 客户/总金额/状态/日期 | → customer(CRM), stock |
|
||||
| supplier 供应商 | 名称/编码/联系方式/地址 | — |
|
||||
|
||||
**需要验证的插件能力:**
|
||||
- 跨实体关联(订单 → 商品 → 库存联动)
|
||||
- 事务性事件(库存扣减在订单确认时原子执行)
|
||||
- 页面间导航(从订单跳转客户详情)
|
||||
- 报表/统计页面(库存汇总、进销存明细)
|
||||
|
||||
### 4.4 插件热更新能力
|
||||
|
||||
**当前限制:** 更新插件需要完整 uninstall/reinstall。
|
||||
|
||||
**改进方案:**
|
||||
- 新增 `POST /api/v1/admin/plugins/{id}/upgrade` 端点
|
||||
- 升级流程:上传新 WASM → 对比 manifest schema → 增量 DDL(ADD COLUMN 等) → 热替换 WASM 模块
|
||||
- 数据安全:`tenant_id` 数据不丢失
|
||||
- 版本兼容性检查:新版本必须向后兼容或提供迁移脚本
|
||||
|
||||
**回滚策略:** 升级前创建 schema 备份点。升级流程分两步执行:
|
||||
1. 先暂存新 WASM 并尝试验证初始化(不应用 DDL)
|
||||
2. 初始化成功后,在单事务中执行 DDL 变更 + 状态转换
|
||||
3. 如果新 WASM 初始化失败,保持旧 WASM 继续运行,回滚暂存状态
|
||||
4. DDL 已应用但 WASM 运行异常时,保留旧 WASM 可加载作为 fallback
|
||||
|
||||
### 4.5 文档更新与清理
|
||||
|
||||
| 项目 | 改进 |
|
||||
|------|------|
|
||||
| Wiki 文档 | 全面更新到当前状态(前端路由、测试数量、模块能力、插件系统) |
|
||||
| CLAUDE.md | 版本号修正(React 19 / Ant Design 6) |
|
||||
| 根目录清理 | 删除未跟踪的开发临时文件(截图、heap dump、perf trace、agent plan 文件) |
|
||||
| integration-tests/ | 验证现有测试是否能编译。若已失效则删除,用新的 Testcontainers 框架替代;若仍有效则纳入 Cargo workspace |
|
||||
| N+1 查询(plugin) | `plugin_service.rs` 的列表查询也存在 N+1 问题(每条插件单独查询 entities),需一并优化 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险与缓解
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| 安全修复引入新 bug | 中 | 高 | 每个修复配有对应的测试用例 |
|
||||
| ErpModule trait 重构影响所有模块 | 高 | 中 | 逐模块迁移,每步验证 `cargo test` |
|
||||
| i18n 迁移工作量大 | 中 | 低 | 增量式,不追求一次性完成 |
|
||||
| Testcontainers 在 CI 环境不稳定 | 低 | 中 | 本地开发可跳过集成测试,CI 用 service container 兜底 |
|
||||
| Testcontainers 在 Windows (WSL2) 上兼容性 | 中 | 中 | 主开发环境为 Windows 11,Testcontainers 对 Windows 支持有限。本地开发可依赖 CI service container 运行集成测试,或使用 WSL2 环境 |
|
||||
| 进销存插件实体设计变更 | 中 | 低 | 先完成最小实体集,后续迭代扩展 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 成功标准
|
||||
|
||||
**Q2 完成标准:**
|
||||
- [ ] 3 个 CRITICAL 安全问题全部修复
|
||||
- [ ] Gitea Actions CI/CD 流水线运行通过
|
||||
- [ ] 默认配置启动被拒绝
|
||||
- [ ] 登录/登出写入审计日志
|
||||
- [ ] Docker 生产化配置就绪
|
||||
|
||||
**Q3 完成标准:**
|
||||
- [ ] ErpModule trait 路由注册自动化
|
||||
- [ ] N+1 查询优化,用户列表查询次数固定为 3
|
||||
- [ ] 前端 Error Boundary 覆盖全局 + 页面级
|
||||
- [ ] 5 个自定义 hooks 提取完成
|
||||
- [ ] i18n 基础设施可用,至少 1 个页面完成迁移
|
||||
- [ ] 行级数据权限端到端验证通过
|
||||
|
||||
**Q4 完成标准:**
|
||||
- [ ] 集成测试覆盖 auth + plugin 核心流程
|
||||
- [ ] 4 个 E2E 测试场景通过
|
||||
- [ ] 进销存插件 6 个实体可用
|
||||
- [ ] 插件热更新功能可用
|
||||
- [ ] Wiki 文档与代码同步
|
||||
@@ -1,604 +0,0 @@
|
||||
# CRM 插件平台标杆 — P0 基础能力设计规格
|
||||
|
||||
> **版本**: v1.1 (修正版 — 基于代码审查发现,对齐现有实现)
|
||||
> **日期**: 2026-04-18
|
||||
> **状态**: Draft
|
||||
> **定位**: 插件平台标杆 — CRM 是试金石,打磨通用能力
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
### 1.1 为什么要做这个
|
||||
|
||||
CRM 插件是 ERP 平台的第一个行业插件,当前状态是"客户通讯录 + 标签 + 关系图谱",距离一流 CRM(Salesforce/HubSpot/Pipedrive)有显著差距。但更大的问题是:**CRM 暴露的差距不在于 CRM 本身,而在于插件平台的基础能力缺失。**
|
||||
|
||||
具体来说:
|
||||
- ~~5 个实体之间有明确的 FK 关系,但 manifest 无法声明~~ → **已有 `PluginRelation` + 级联删除**,但缺少 `name`/`display_field`/关系类型等前端渲染信息
|
||||
- 35+ 字段有 required/unique/pattern 校验,但缺少 `min_length`/`max_length`/`min_value`/`max_value` 扩展校验
|
||||
- Dashboard/Graph 页面硬编码了 CRM 专属颜色和标题,第二个插件无法复用
|
||||
- CRM 的 `plugin.toml` 没有声明 `relations`,导致现有级联能力未被使用
|
||||
- 批量删除和 PATCH 部分更新绕过了现有校验
|
||||
|
||||
如果不在 P0 阶段补齐这些基础,所有后续业务功能(商机、合同、报价)都会建在不稳固的地基上。
|
||||
|
||||
### 1.2 设计原则
|
||||
|
||||
| 原则 | 含义 |
|
||||
|------|------|
|
||||
| **平台优先** | 每个能力都是平台层的,CRM 只是第一个使用者 |
|
||||
| **零改动复用** | inventory/生产/财务插件不应为这些能力写任何额外代码 |
|
||||
| **Manifest 驱动** | 所有行为由 plugin.toml 声明驱动,不写硬编码 |
|
||||
| **双层保障** | 前端即时反馈 + 后端最终防线,缺一不可 |
|
||||
|
||||
### 1.3 一流 CRM 差距分析摘要
|
||||
|
||||
| 类别 | 差距 | 本规格是否覆盖 |
|
||||
|------|------|--------------|
|
||||
| 实体关系 + 级联删除 | 致命 — 删除客户产生孤儿数据 | **P0-1 覆盖** |
|
||||
| 字段校验 + FK 完整性 | 严重 — 数据质量无保障 | **P0-2 覆盖** |
|
||||
| 前端通用化 | 中等 — 第二个插件无法复用 Dashboard/Graph | **P0-3 覆盖** |
|
||||
| 商机/漏斗/合同 | 严重 — 核心业务缺失 | P2(本规格不覆盖) |
|
||||
| 导入导出/批量操作 | 中等 — ERP 刚需 | P1(后续规格) |
|
||||
| 全局搜索/保存视图 | 中等 — UX 缺失 | P1(后续规格) |
|
||||
| WASM 活化 | 低 — 当前空操作不影响功能 | P2(后续规格) |
|
||||
|
||||
---
|
||||
|
||||
## 2. P0-1: 实体关系声明 + ref_entity + 级联策略
|
||||
|
||||
### 2.1 Manifest Schema 扩展
|
||||
|
||||
**现有基础**:`PluginRelation` 已存在(`manifest.rs:184-189`),包含 `entity`、`foreign_key`、`on_delete` 三个字段。级联删除已在 `data_service.rs:330-395` 中实现。
|
||||
|
||||
**扩展方向**:在现有结构上新增字段,保持向后兼容。
|
||||
|
||||
```toml
|
||||
# === 一对多关系 (customer → contacts) ===
|
||||
[[schema.entities.relations]]
|
||||
entity = "contact" # 目标实体 (已有字段)
|
||||
foreign_key = "customer_id" # FK 字段 (已有字段)
|
||||
on_delete = "cascade" # cascade | nullify | restrict (已有枚举)
|
||||
# ↓ 新增字段 (可选,向后兼容)
|
||||
name = "contacts" # 关系显示名,用于前端标签
|
||||
type = "one_to_many" # 关系类型 (one_to_many | many_to_one | many_to_many)
|
||||
display_field = "name" # EntitySelect 下拉显示字段
|
||||
|
||||
# === 多对一关系 (contact → customer,含自引用) ===
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer"
|
||||
foreign_key = "parent_id"
|
||||
on_delete = "nullify"
|
||||
name = "parent"
|
||||
type = "many_to_one"
|
||||
display_field = "name"
|
||||
|
||||
# === 多对多关系 (customer ↔ customer,通过中间表) ===
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer"
|
||||
foreign_key = "from_customer_id" # 中间表中的源 FK
|
||||
on_delete = "nullify"
|
||||
name = "related_customers"
|
||||
type = "many_to_many"
|
||||
through_entity = "customer_relationship"
|
||||
through_source_field = "from_customer_id"
|
||||
through_target_field = "to_customer_id"
|
||||
```
|
||||
|
||||
#### 关系类型定义 (新增 `type` 字段)
|
||||
|
||||
| 类型 | 含义 | foreign_key 位置 | CRM 场景 |
|
||||
|------|------|-----------------|---------|
|
||||
| `one_to_many` | 一个父 → 多个子 | 子实体上 | customer → contacts |
|
||||
| `many_to_one` | 多个子 → 一个父 | 本实体上 | contact → customer |
|
||||
| `many_to_many` | 双向多对多 | 中间表上 | customer ↔ customer |
|
||||
|
||||
> `type` 字段为 `Option<RelationType>`,默认 `OneToMany`。不声明则现有行为不变。
|
||||
|
||||
#### 级联策略 (保持现有枚举不变)
|
||||
|
||||
| 策略 | TOML 值 | 行为 | 适用场景 |
|
||||
|------|---------|------|---------|
|
||||
| `Cascade` | `"cascade"` | 子记录 `deleted_at = now()` | 强所有权:客户→联系人 |
|
||||
| `Nullify` | `"nullify"` | FK 字段设 NULL | 弱引用:联系人→上级客户 |
|
||||
| `Restrict` | `"restrict"` | 有子记录时阻止删除(409) | 关键数据:不允许孤立 |
|
||||
|
||||
### 2.2 后端实现
|
||||
|
||||
#### 数据结构扩展 (`manifest.rs`)
|
||||
|
||||
**在现有 `PluginRelation` 上新增字段**(不替换):
|
||||
|
||||
```rust
|
||||
// 现有字段保持不变
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginRelation {
|
||||
pub entity: String, // 已有
|
||||
pub foreign_key: String, // 已有
|
||||
pub on_delete: OnDeleteStrategy, // 已有 (Cascade | Nullify | Restrict)
|
||||
// ↓ 新增可选字段
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub relation_type: Option<RelationType>,
|
||||
#[serde(default)]
|
||||
pub display_field: Option<String>,
|
||||
// many_to_many 专属
|
||||
#[serde(default)]
|
||||
pub through_entity: Option<String>,
|
||||
#[serde(default)]
|
||||
pub through_source_field: Option<String>,
|
||||
#[serde(default)]
|
||||
pub through_target_field: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RelationType {
|
||||
#[default]
|
||||
OneToMany,
|
||||
ManyToOne,
|
||||
ManyToMany,
|
||||
}
|
||||
```
|
||||
|
||||
#### 级联删除 (已有,需增强)
|
||||
|
||||
`data_service.rs:330-395` 已实现 `Restrict`/`Nullify`/`Cascade` 三种策略。需增强:
|
||||
|
||||
1. **级联影响信息返回**:Restrict 时返回 `affected_count` 和 `relation.name`,方便前端展示
|
||||
2. **批量删除级联**:`batch_delete` (data_service.rs:417-520) 当前绕过级联,需补充
|
||||
3. **many_to_many 级联**:基于 `through_entity` 清理中间表记录
|
||||
|
||||
#### 级联策略执行 (已有,需增强错误信息)
|
||||
|
||||
现有 `data_service.rs:330-395` 已实现。增强点:
|
||||
|
||||
1. **Restrict 错误增强**:返回 `affected_count` 和 `relation.name`
|
||||
2. **批量删除级联**:`batch_delete` (data_service.rs:417-520) 当前绕过级联,需补充
|
||||
3. **PATCH 校验**:`partial_update` (data_service.rs:291-327) 当前绕过 `validate_data`,需补充
|
||||
4. **many_to_many 级联**:基于 `through_entity` 清理中间表记录
|
||||
|
||||
#### FK 存在性校验 (已有 `validate_ref_entities`)
|
||||
|
||||
`data_service.rs:834-899` 已实现 `validate_ref_entities`。需确保 `partial_update` (PATCH) 也调用此函数。
|
||||
|
||||
### 2.3 前端实现
|
||||
|
||||
#### 前端类型扩展
|
||||
|
||||
`apps/web/src/api/plugins.ts` 需更新:
|
||||
|
||||
```typescript
|
||||
// PluginEntitySchema 新增
|
||||
interface PluginEntitySchema {
|
||||
// ... existing fields
|
||||
relations?: PluginRelationSchema[];
|
||||
}
|
||||
|
||||
interface PluginRelationSchema {
|
||||
entity: string;
|
||||
foreign_key: string;
|
||||
on_delete: 'cascade' | 'nullify' | 'restrict';
|
||||
name?: string;
|
||||
type?: 'one_to_many' | 'many_to_one' | 'many_to_many';
|
||||
display_field?: string;
|
||||
}
|
||||
|
||||
// PluginFieldSchema 新增 validation 属性
|
||||
interface PluginFieldSchema {
|
||||
// ... existing fields
|
||||
validation?: {
|
||||
pattern?: string;
|
||||
message?: string;
|
||||
min_length?: number;
|
||||
max_length?: number;
|
||||
min_value?: number;
|
||||
max_value?: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### EntitySelect 增强 (已有基础)
|
||||
|
||||
字段有 `ref_entity` 属性时,CRUD 表单已自动渲染为 EntitySelect。增强点:
|
||||
- 优先使用 `relation.display_field` 作为下拉显示字段(fallback 到现有 `ref_label_field`)
|
||||
- 关联子表标题使用 `relation.name`
|
||||
|
||||
#### 详情页关联子表自动渲染
|
||||
|
||||
Entity 的 `one_to_many` relations 自动在详情页渲染为内嵌 CRUD 表格:
|
||||
- Compact 模式 + 自动过滤 `fk = parent_record.id`
|
||||
- 支持新增/编辑/删除子记录
|
||||
- 标题使用 `relation.name`
|
||||
|
||||
#### 级联删除确认
|
||||
|
||||
删除有 incoming relations 的记录时,弹出确认:
|
||||
```
|
||||
确定删除客户「{name}」?
|
||||
此操作将同时删除:
|
||||
- 3 条联系人记录
|
||||
- 5 条沟通记录
|
||||
- 2 条标签记录
|
||||
```
|
||||
|
||||
### 2.4 CRM plugin.toml 改造
|
||||
|
||||
为 customer 实体补充 relations:
|
||||
|
||||
```toml
|
||||
[[schema.entities.relations]]
|
||||
entity = "contact"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
name = "contacts"
|
||||
type = "one_to_many"
|
||||
display_field = "name"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "communication"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
name = "communications"
|
||||
type = "one_to_many"
|
||||
display_field = "subject"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer_tag"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
name = "tags"
|
||||
type = "one_to_many"
|
||||
display_field = "tag_name"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer"
|
||||
foreign_key = "parent_id"
|
||||
on_delete = "nullify"
|
||||
name = "parent"
|
||||
type = "many_to_one"
|
||||
display_field = "name"
|
||||
```
|
||||
|
||||
为 contact 实体补充 relations:
|
||||
|
||||
```toml
|
||||
[[schema.entities.relations]]
|
||||
entity = "communication"
|
||||
foreign_key = "contact_id"
|
||||
on_delete = "cascade"
|
||||
name = "communications"
|
||||
type = "one_to_many"
|
||||
display_field = "subject"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. P0-2: 字段校验层
|
||||
|
||||
### 3.1 现有基础
|
||||
|
||||
**已有实现**:
|
||||
- `validate_data` (`data_service.rs:797-831`): required + pattern 正则校验
|
||||
- `validate_ref_entities` (`data_service.rs:834-899`): FK 引用存在性校验
|
||||
- `FieldValidation` (`manifest.rs:53-57`): `pattern` + `message` 字段
|
||||
- unique 检查已在 `create`/`update` 流程中实现
|
||||
|
||||
**缺失部分**:
|
||||
- `min_length` / `max_length` 校验器
|
||||
- `min_value` / `max_value` 校验器
|
||||
- PATCH (partial_update) 绕过所有校验
|
||||
- 前端 TypeScript 类型缺少 `validation` 属性
|
||||
|
||||
### 3.2 Manifest Schema 扩展
|
||||
|
||||
在现有 `[validation]` 上新增字段(`manifest.rs:53-57` 已有 `pattern` + `message`):
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "phone"
|
||||
field_type = "string"
|
||||
display_name = "手机号"
|
||||
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^1[3-9]\\d{9}$"
|
||||
message = "请输入有效的手机号码"
|
||||
min_length = 11
|
||||
max_length = 11
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "credit_limit"
|
||||
field_type = "decimal"
|
||||
|
||||
[schema.entities.fields.validation]
|
||||
min_value = 0
|
||||
max_value = 99999999
|
||||
message = "信用额度必须在 0-99999999 之间"
|
||||
```
|
||||
|
||||
#### 校验类型定义
|
||||
|
||||
| 校验器 | manifest 字段 | 状态 | 说明 |
|
||||
|--------|-------------|------|------|
|
||||
| `required` | `field.required` | **已有** | 值不能为 null/空字符串 |
|
||||
| `unique` | `field.unique` | **已有** | 同 tenant 内值唯一 |
|
||||
| `pattern` | `validation.pattern` + `validation.message` | **已有** | 正则匹配 |
|
||||
| `ref_exists` | `field.ref_entity` | **已有** | FK 指向的记录存在且未删除 |
|
||||
| `min_length` / `max_length` | `validation.min_length` / `validation.max_length` | **新增** | 字符串长度范围 |
|
||||
| `min_value` / `max_value` | `validation.min_value` / `validation.max_value` | **新增** | 数值范围 |
|
||||
|
||||
### 3.3 后端实现
|
||||
|
||||
#### 扩展 `FieldValidation` (`manifest.rs:53-57`)
|
||||
|
||||
在现有结构上新增 4 个可选字段:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FieldValidation {
|
||||
pub pattern: Option<String>, // 已有
|
||||
pub message: Option<String>, // 已有
|
||||
// ↓ 新增
|
||||
pub min_length: Option<usize>,
|
||||
pub max_length: Option<usize>,
|
||||
pub min_value: Option<f64>,
|
||||
pub max_value: Option<f64>,
|
||||
}
|
||||
```
|
||||
|
||||
#### 扩展 `validate_data` (`data_service.rs:797-831`)
|
||||
|
||||
在现有函数中追加 min_length/max_length/min_value/max_value 检查:
|
||||
|
||||
```rust
|
||||
// 现有: required + pattern 检查 (已实现)
|
||||
// 新增:
|
||||
if let Some(validation) = &field.validation {
|
||||
// min_length / max_length
|
||||
if let Some(str_val) = val.as_str() {
|
||||
if let Some(min) = validation.min_length {
|
||||
if str_val.len() < min { return Err(...); }
|
||||
}
|
||||
if let Some(max) = validation.max_length {
|
||||
if str_val.len() > max { return Err(...); }
|
||||
}
|
||||
}
|
||||
// min_value / max_value (适用于 number/integer/decimal)
|
||||
if let Some(num_val) = val.as_f64() {
|
||||
if let Some(min) = validation.min_value {
|
||||
if num_val < min { return Err(...); }
|
||||
}
|
||||
if let Some(max) = validation.max_value {
|
||||
if num_val > max { return Err(...); }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 修复 PATCH 校验缺失
|
||||
|
||||
`partial_update` (`data_service.rs:291-327`) 需要添加 `validate_data` 和 `validate_ref_entities` 调用,与 `update` 保持一致。
|
||||
|
||||
**执行位置:** `data_service.rs` 的 `create_record` 和 `update_record` 方法中,数据写入前调用 `validate_record`。
|
||||
|
||||
**错误响应格式:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "数据验证失败",
|
||||
"details": [
|
||||
{ "field": "phone", "message": "请输入有效的手机号码" },
|
||||
{ "field": "customer_id", "message": "引用的客户不存在" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 前端实现
|
||||
|
||||
从 schema 自动生成 Ant Design Form rules(需先修复 TypeScript 类型缺失):
|
||||
|
||||
```typescript
|
||||
function generateFormRules(field: PluginFieldSchema): Rule[] {
|
||||
const rules: Rule[] = [];
|
||||
|
||||
if (field.required) {
|
||||
rules.push({ required: true, message: `${field.display_name}不能为空` });
|
||||
}
|
||||
|
||||
if (field.validation?.pattern) {
|
||||
rules.push({
|
||||
pattern: new RegExp(field.validation.pattern),
|
||||
message: field.validation.message || `${field.display_name}格式不正确`,
|
||||
});
|
||||
}
|
||||
|
||||
if (field.validation?.min_length || field.validation?.max_length) {
|
||||
rules.push({
|
||||
min: field.validation.min_length,
|
||||
max: field.validation.max_length,
|
||||
message: field.validation.message || `${field.display_name}长度不正确`,
|
||||
});
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 CRM plugin.toml 补充校验
|
||||
|
||||
```toml
|
||||
# phone 字段
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^1[3-9]\\d{9}$"
|
||||
message = "请输入有效的手机号码"
|
||||
|
||||
# email 字段
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"
|
||||
message = "请输入有效的邮箱地址"
|
||||
|
||||
# credit_code 字段
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^[0-9A-HJ-NP-RTUW-Y]{2}\\d{6}[0-9A-HJ-NP-RTUW-Y]{10}$"
|
||||
message = "请输入有效的统一社会信用代码"
|
||||
|
||||
# website 字段
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^https?://[\\w.-]+(?:\\.[\\w.-]+)+[/#?]?.*$"
|
||||
message = "请输入有效的网址"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. P0-3: 前端去硬编码
|
||||
|
||||
### 4.1 Dashboard 通用化
|
||||
|
||||
**涉及文件:**
|
||||
- `apps/web/src/pages/dashboard/dashboardConstants.tsx`
|
||||
- `apps/web/src/pages/dashboard/DashboardWidgets.tsx`
|
||||
- `apps/web/src/pages/PluginDashboardPage.tsx`
|
||||
|
||||
**改造方案:**
|
||||
|
||||
| 当前硬编码 | 通用化方案 |
|
||||
|-----------|-----------|
|
||||
| `ENTITY_COLORS`: customer→indigo, contact→green, ... | 8 色调色板按 entity 顺序自动分配 |
|
||||
| `ENTITY_ICONS`: customer→TeamOutlined, ... | 从 page schema 的 icon 字段读取 |
|
||||
| 标题 "CRM 数据全景视图" | `{manifest.name} 统计概览` |
|
||||
| 副标题 "实时掌握业务动态" | `{manifest.description}` 截取前 50 字 |
|
||||
|
||||
**通用调色板:**
|
||||
|
||||
```typescript
|
||||
const UNIVERSAL_PALETTE = [
|
||||
'#6366f1', // indigo
|
||||
'#22c55e', // green
|
||||
'#f59e0b', // amber
|
||||
'#8b5cf6', // violet
|
||||
'#ef4444', // red
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
'#ec4899', // pink
|
||||
];
|
||||
```
|
||||
|
||||
### 4.2 Graph 通用化
|
||||
|
||||
**涉及文件:** `apps/web/src/pages/plugins/graph/graphConstants.ts`
|
||||
|
||||
**改造方案:**
|
||||
|
||||
| 当前硬编码 | 通用化方案 |
|
||||
|-----------|-----------|
|
||||
| `RELATIONSHIP_COLORS`: parent_child→indigo, ... | 调色板按 option 顺序循环 |
|
||||
| `RELATIONSHIP_LABELS`: parent_child→"母子", ... | 从 field.options[].label 读取 |
|
||||
| `RELATIONSHIP_TYPES` 固定 5 种 | 从 schema 动态生成 |
|
||||
|
||||
### 4.3 CRUD 表格列可配置
|
||||
|
||||
**涉及文件:** `apps/web/src/pages/PluginCRUDPage.tsx`
|
||||
|
||||
**改造方案:**
|
||||
|
||||
manifest page 新增可选字段 `table_columns`:
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = "customer"
|
||||
table_columns = ["code", "name", "customer_type", "level", "status", "owner_id", "region", "industry"]
|
||||
```
|
||||
|
||||
不声明时默认行为:
|
||||
- 取前 8 个非 hidden 非 FK 字段
|
||||
- 替换当前 `fields.slice(0, 5)` 硬编码
|
||||
|
||||
### 4.4 验证标准
|
||||
|
||||
> **测试: 将 CRM 插件替换为 inventory 插件,Dashboard/Graph/CRUD 页面应零改动正确渲染。**
|
||||
|
||||
具体验证:
|
||||
1. Dashboard 显示 inventory 的 6 个实体统计,颜色按顺序分配
|
||||
2. Graph 如果 inventory 有关系数据,渲染正确(无数据则显示空状态)
|
||||
3. CRUD 表格按 `table_columns` 或默认 8 列显示
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键文件清单
|
||||
|
||||
### 后端 Rust
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `crates/erp-plugin/src/manifest.rs` | 修改 | `PluginRelation` 新增 name/type/display_field/through_* 字段;`FieldValidation` 新增 min_length/max_length/min_value/max_value |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 修改 | 扩展 `validate_data` 增加 min/max 校验;`partial_update` 补充校验调用;`batch_delete` 补充级联 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | 修改 | 补充 relations 声明 + validation 规则 |
|
||||
|
||||
> 注意:不新建 `validation.rs`,直接扩展现有 `validate_data` 和 `validate_ref_entities`。
|
||||
|
||||
### 前端 TypeScript
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `apps/web/src/api/plugins.ts` | 修改 | `PluginEntitySchema` 新增 `relations`;`PluginFieldSchema` 新增 `validation` |
|
||||
| `apps/web/src/pages/dashboard/dashboardConstants.tsx` | 修改 | 去硬编码,通用调色板自动分配 |
|
||||
| `apps/web/src/pages/dashboard/DashboardWidgets.tsx` | 修改 | schema 驱动颜色/图标 |
|
||||
| `apps/web/src/pages/PluginDashboardPage.tsx` | 修改 | 通用标题/副标题 |
|
||||
| `apps/web/src/pages/plugins/graph/graphConstants.ts` | 修改 | 关系类型从 options 动态读取 |
|
||||
| `apps/web/src/pages/PluginCRUDPage.tsx` | 修改 | 可配置列数 + Form rules 自动生成 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证方案
|
||||
|
||||
### 6.1 编译与测试
|
||||
|
||||
```bash
|
||||
cargo check # 全 workspace 编译
|
||||
cargo test --workspace # 全量测试
|
||||
```
|
||||
|
||||
### 6.2 单元测试
|
||||
|
||||
- `validation.rs`: 每种校验器独立测试 (required/unique/pattern/ref_exists/length/value range)
|
||||
- `data_service.rs`: 级联策略测试 (cascade_soft_delete/set_null/restrict)
|
||||
|
||||
### 6.3 集成测试 (Testcontainers)
|
||||
|
||||
- 删除客户 → 验证联系人/沟通记录/标签级联软删除
|
||||
- 删除有 restrict 关系的记录 → 验证 409 响应
|
||||
- 创建联系人 → customer_id 不存在时验证 400
|
||||
- 创建客户 → phone 格式不正确时验证 400 + 错误详情
|
||||
- 创建客户 → code 已存在时验证 409
|
||||
|
||||
### 6.4 功能验证
|
||||
|
||||
1. 重新安装 CRM 插件,确认 5 个 relation 正确注册到 entity metadata
|
||||
2. 删除客户 → 确认关联数据正确级联
|
||||
3. 手机号/邮箱格式校验 → 确认前后端双重拦截
|
||||
4. Dashboard → 确认标题/颜色从 schema 动态生成
|
||||
5. 切换 inventory 插件 → Dashboard/Graph 零改动渲染
|
||||
|
||||
### 6.5 前端验证
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm dev
|
||||
```
|
||||
|
||||
手动测试所有 CRM 页面,确认无回归。
|
||||
|
||||
---
|
||||
|
||||
## 7. 不在本规格范围内
|
||||
|
||||
| 项 | 原因 | 计划 |
|
||||
|----|------|------|
|
||||
| 商机 (Opportunity) / 销售漏斗 | CRM 业务功能,P2 范畴 | 后续规格 |
|
||||
| 数据导入导出 (Excel) | 平台能力但工作量大 | P1 规格 |
|
||||
| 通知规则 + 消息中心联动 | 需要跨模块协作 | P1 规格 |
|
||||
| WASM 校验/计算 Hook | 平台能力但依赖 WASM 运行时增强 | P2 规格 |
|
||||
| 全局搜索 / 保存视图 | UX 增强 | P1 规格 |
|
||||
| Lead 线索实体 | CRM 业务功能 | P2 规格 |
|
||||
@@ -1,337 +0,0 @@
|
||||
# ERP 插件平台演进路线图 — 设计规格
|
||||
|
||||
> 日期: 2026-04-18
|
||||
> 来源: 无主题发散式互动探讨
|
||||
> 状态: Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
ERP 平台已完成 Phase 1-6 核心开发和 Q2-Q4 成熟度路线图。当前有两个行业插件(CRM + 进销存)运行在 WASM 插件系统上。通过分析发现四大系统性缺口:
|
||||
|
||||
1. **跨插件数据引用完全不支持** — 进销存的 `customer_id` 只能存裸 UUID
|
||||
2. **插件无通用业务能力** — 导入导出/打印/配置/视图每个插件都要自己实现
|
||||
3. **无质量保障机制** — 第三方插件的安全性和性能无法保证
|
||||
4. **无发现和分发渠道** — 用户无法自助发现和安装插件
|
||||
|
||||
**目标:** 通过搭建财务/应收插件来验证和推动这些平台能力的实现。
|
||||
|
||||
**核心设计原则:**
|
||||
- 插件间**完全独立**,任何插件可自由安装/卸载,不受其他插件影响
|
||||
- 跨插件引用**声明式**,通过 plugin.toml 零代码实现
|
||||
- 通用业务能力**平台层提供**,插件声明式接入
|
||||
- 外部引用问题永远是**软警告**,永不硬阻塞用户操作
|
||||
|
||||
---
|
||||
|
||||
## 2. 跨插件数据引用系统
|
||||
|
||||
### 2.1 Entity Registry (平台实体注册表)
|
||||
|
||||
插件安装时将其所有实体注册到平台级 Entity Registry,其他插件通过 registry 动态发现和引用。
|
||||
|
||||
**数据结构:**
|
||||
|
||||
```
|
||||
entity_registry:
|
||||
- entity_name: string # 实体名 (如 "customer")
|
||||
- plugin_id: string # 注册该实体的插件 ID
|
||||
- display_fields: string[] # 用于下拉显示的字段列表
|
||||
- search_fields: string[] # 用于搜索的字段列表
|
||||
- status: active | inactive # 插件卸载时标记 inactive
|
||||
- registered_at: timestamp
|
||||
- tenant_id: uuid # 多租户隔离
|
||||
```
|
||||
|
||||
**生命周期:**
|
||||
- 插件安装 → 注册所有 entities 到 registry
|
||||
- 插件启用 → status = active
|
||||
- 插件禁用 → status = inactive(数据保留)
|
||||
- 插件卸载 → status = inactive + 标记为 orphaned
|
||||
|
||||
### 2.2 plugin.toml 扩展
|
||||
|
||||
```toml
|
||||
# 可选依赖声明
|
||||
[dependencies.crm]
|
||||
optional = true
|
||||
description = "客户管理 — 自动关联客户数据,未安装时客户字段为手动输入"
|
||||
|
||||
[dependencies.inventory]
|
||||
optional = true
|
||||
description = "进销存 — 自动关联商品数据"
|
||||
|
||||
# 跨插件引用字段
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
display_name = "客户"
|
||||
ref_entity = "customer" # 目标实体名
|
||||
ref_scope = "external" # "internal" (默认) | "external"
|
||||
ref_display_field = "name" # 下拉框显示字段
|
||||
ref_search_fields = ["name", "phone"] # 搜索字段
|
||||
ref_fallback_label = "外部客户" # 降级时显示文本
|
||||
```
|
||||
|
||||
### 2.3 运行时行为
|
||||
|
||||
**写入时校验:**
|
||||
|
||||
| 源插件状态 | 写入行为 | 读取行为 | 前端展示 |
|
||||
|-----------|---------|---------|---------|
|
||||
| 已安装 (active) | 强校验 UUID 存在性 | JOIN 富化 display_field | ✅ 绿色链接 "张三" |
|
||||
| 未安装 (inactive) | 无校验,接受任意 UUID | 返回原始 UUID | ⬜ 灰色 "外部客户" |
|
||||
| 刚重新启用 | 新写入强校验,不回溯已有 | 后台对账扫描 | ⚠️ 黄色警告 (悬空) |
|
||||
|
||||
**悬空引用处理 (插件重新启用时):**
|
||||
1. 后台扫描所有 `ref_scope=external` 且指向本插件实体的字段
|
||||
2. 验证每个 UUID 是否存在于本插件表中
|
||||
3. 生成对账报告: `{ valid: N, dangling: M, details: [...] }`
|
||||
4. 前端展示对账结果,用户逐条处理(映射/清空/忽略)
|
||||
5. 永不硬阻塞用户操作
|
||||
|
||||
### 2.4 需要改造的文件
|
||||
|
||||
| 文件 | 改动 | 复杂度 |
|
||||
|------|------|--------|
|
||||
| `crates/erp-plugin/src/manifest.rs` | 新增 `ref_scope`, `ref_display_field`, `ref_search_fields`, `ref_fallback_label`; 新增 `DependenciesSection` | 低 |
|
||||
| `crates/erp-plugin/src/entity_registry.rs` (新) | 实体注册/发现/inactive 标记/对账 | 中 |
|
||||
| `crates/erp-plugin/src/data_service.rs` | `validate_ref_entities` 支持运行时发现外部引用 | 中 |
|
||||
| `crates/erp-plugin/src/host.rs` | 新增 `resolve_ref_entity` Host API | 中 |
|
||||
| `crates/erp-plugin/wit/plugin.wit` | 新增 `resolve-ref-entity` 接口 | 低 |
|
||||
| `crates/erp-plugin/src/service.rs` | 插件安装/卸载时维护 Entity Registry | 中 |
|
||||
| `apps/web/src/` 前端 | entity_select 组件支持跨插件数据源 + 降级显示 + 对账 UI | 高 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 插件平台通用服务层 (P1)
|
||||
|
||||
### 3.1 数据导入导出服务
|
||||
|
||||
插件在 plugin.toml 中声明哪些实体支持导入导出,平台提供统一的导入导出 UI 和引擎。
|
||||
|
||||
```toml
|
||||
[[schema.entities]]
|
||||
name = "invoice"
|
||||
display_name = "发票"
|
||||
importable = true
|
||||
exportable = true
|
||||
import_template = "invoice_import_template.xlsx"
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- 自动生成导入模板(基于 schema entities fields)
|
||||
- Excel/CSV 解析 + schema 字段校验
|
||||
- 批量写入(支持事务 + 错误行级报告)
|
||||
- 导出为 Excel/CSV(支持筛选条件)
|
||||
- 导入历史记录 + 回滚
|
||||
|
||||
**实现位置:** `crates/erp-plugin/src/import_export.rs` + 前端 `ImportExportModal` 通用组件
|
||||
|
||||
### 3.2 打印模板引擎
|
||||
|
||||
平台提供 HTML → PDF 的模板渲染能力,插件定义模板和字段映射。
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "invoice_pdf"
|
||||
display_name = "发票"
|
||||
entity = "invoice"
|
||||
format = "pdf"
|
||||
template_file = "templates/invoice.html"
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- HTML 模板渲染 → PDF 下载
|
||||
- 模板变量替换(基于实体字段)
|
||||
- 租户级模板自定义(覆盖默认模板)
|
||||
- 打印预览
|
||||
|
||||
### 3.3 插件配置 UI
|
||||
|
||||
插件在 plugin.toml 中声明配置项,平台自动生成配置页面。
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
[[settings.fields]]
|
||||
name = "default_tax_rate"
|
||||
display_name = "默认税率"
|
||||
field_type = "number"
|
||||
default_value = 0.13
|
||||
|
||||
[[settings.fields]]
|
||||
name = "invoice_prefix"
|
||||
display_name = "发票前缀"
|
||||
field_type = "text"
|
||||
default_value = "INV"
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- 根据 settings 声明自动生成配置表单
|
||||
- 配置数据存储在 `plugin_settings` 表(tenant_id + plugin_id + key/value)
|
||||
- 配置变更时通知插件(通过事件)
|
||||
- 支持配置权限控制(仅管理员可改)
|
||||
|
||||
### 3.4 自定义视图
|
||||
|
||||
用户可以保存列表页的列配置和筛选条件。
|
||||
|
||||
```
|
||||
user_views:
|
||||
- id: uuid
|
||||
- user_id: uuid
|
||||
- plugin_id: string
|
||||
- entity_name: string
|
||||
- view_name: string
|
||||
- columns: string[]
|
||||
- filters: json
|
||||
- sort: json
|
||||
- is_default: boolean
|
||||
```
|
||||
|
||||
### 3.5 通知规则
|
||||
|
||||
插件在 plugin.toml 中声明可触发的事件,平台提供通知规则配置 UI。
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "invoice.overdue"
|
||||
display_name = "发票逾期"
|
||||
description = "发票超过付款期限未收款"
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- 规则引擎: WHEN event THEN notify [user/role/department]
|
||||
- 复用 erp-message 的通知渠道
|
||||
- 租户级规则配置
|
||||
|
||||
### 3.6 编号规则 (已有基础扩展)
|
||||
|
||||
复用 erp-config 的编号规则服务,扩展为插件可接入。
|
||||
|
||||
```toml
|
||||
[[numbering]]
|
||||
entity = "invoice"
|
||||
prefix = "INV"
|
||||
format = "{PREFIX}-{YEAR}-{SEQ:4}"
|
||||
reset_rule = "yearly"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 插件质量保障
|
||||
|
||||
### 4.1 上传时校验
|
||||
|
||||
```
|
||||
插件上传 → Schema 校验 → WASM 二进制验证 → 安全扫描 → 性能基准 → 发布/拒绝
|
||||
```
|
||||
|
||||
| 阶段 | 校验内容 | 现状 |
|
||||
|------|---------|------|
|
||||
| Schema 校验 | plugin.toml 格式、字段类型、权限码一致性 | 部分已有 |
|
||||
| WASM 验证 | 二进制格式、WIT 兼容性、导出函数检查 | 已有 |
|
||||
| 安全扫描 | 动态表 SQL 注入风险、Fuel 耗尽、内存泄漏 | 缺失 |
|
||||
| 性能基准 | 标准 CRUD 操作在 N 条数据下的响应时间 | 缺失 |
|
||||
| 兼容性 | 平台版本匹配、依赖插件版本兼容 | 缺失 |
|
||||
|
||||
### 4.2 运行时监控
|
||||
|
||||
```
|
||||
plugin_runtime_metrics:
|
||||
- plugin_id: string
|
||||
- error_rate: float
|
||||
- avg_response_ms: float
|
||||
- fuel_consumption: float
|
||||
- memory_peak_mb: float
|
||||
- active_instances: int
|
||||
```
|
||||
|
||||
**告警规则:** 错误率 > 5% / 平均响应 > 2s / Fuel 消耗异常 / 内存持续增长
|
||||
|
||||
---
|
||||
|
||||
## 5. 插件市场/商店
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 插件目录 | 按行业/功能分类浏览 |
|
||||
| 搜索 | 按名称/标签/行业搜索 |
|
||||
| 详情页 | 截图、演示、功能描述、权限说明 |
|
||||
| 一键安装 | 上传 → 自动安装 → 配置 → 启用 |
|
||||
| 评分/评论 | 用户评分和使用反馈 |
|
||||
| 版本管理 | 版本列表、更新日志、回滚 |
|
||||
| 依赖提示 | 安装时提示可选依赖 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证计划 — 财务/应收插件
|
||||
|
||||
### 6.1 实体设计
|
||||
|
||||
| 实体 | 字段概要 | 跨插件引用 |
|
||||
|------|---------|-----------|
|
||||
| invoice (发票) | 编号/客户/金额/税额/状态/到期日 | customer_id → CRM.customer |
|
||||
| invoice_line (发票行) | 发票/商品/数量/单价/税额 | product_id → Inventory.product |
|
||||
| payment (收款) | 发票/金额/方式/日期/状态 | invoice_id → 本插件内部 |
|
||||
| quote (报价单) | 编号/客户/有效期/状态 | customer_id → CRM.customer |
|
||||
| quote_line (报价行) | 报价单/商品/数量/单价 | product_id → Inventory.product |
|
||||
|
||||
### 6.2 验证矩阵
|
||||
|
||||
| 能力 | 验证方式 | 预期结果 |
|
||||
|------|---------|---------|
|
||||
| 跨插件引用 (CRM 安装) | 创建发票时选择客户 | entity_select 下拉显示 CRM 客户列表 |
|
||||
| 跨插件引用 (CRM 卸载) | 创建发票时输入客户 | 降级为文本输入,不阻塞 |
|
||||
| 悬空引用对账 | CRM 卸载→创建发票→重新安装 CRM | 对账报告显示悬空引用,用户可修复 |
|
||||
| 数据导入 | 导入 Excel 客户清单 | 解析+校验+批量写入 |
|
||||
| 数据导出 | 导出发票列表为 Excel | 筛选+下载 |
|
||||
| 打印模板 | 打印发票 PDF | HTML→PDF 渲染 |
|
||||
| 插件配置 | 设置税率/发票前缀 | 自动生成的配置页面 |
|
||||
| 编号规则 | 创建发票自动编号 | INV-2026-0001 |
|
||||
| 通知规则 | 发票逾期通知 | 规则引擎触发通知 |
|
||||
| 独立安装 | 不安装 CRM 单独安装财务 | 所有功能正常,客户字段降级 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施优先级
|
||||
|
||||
```
|
||||
P0 (已完成/进行中): P0 平台能力升级 + 插件系统增强
|
||||
|
||||
P1 (跨插件引用): Entity Registry + ref_scope 扩展 + 前端 entity_select 改造
|
||||
这是所有后续能力的基础
|
||||
|
||||
P2 (平台通用服务): 数据导入导出 → 插件配置 UI → 编号规则扩展 → 通知规则
|
||||
|
||||
P3 (质量保障): 上传时安全扫描 → 性能基准 → 运行时监控
|
||||
|
||||
P4 (插件市场): 插件目录 → 一键安装 → 版本管理 → 评分评论
|
||||
|
||||
验证: 财务/应收插件贯穿 P1-P2,每完成一个 P 就用财务插件验证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| Entity Registry 查询性能 | 每次数据操作都要查注册表 | 内存缓存 + DashMap,注册表数据量极小 |
|
||||
| 悬空引用数据量过大 | 对账扫描耗时长 | 异步后台任务 + 分批处理 + 进度条 |
|
||||
| Excel 导入内存占用 | 大文件解析 OOM | 流式解析 + 批量提交 + 文件大小限制 |
|
||||
| 打印模板安全 | 模板注入攻击 | 沙箱渲染 + 变量白名单 |
|
||||
| 插件市场审核成本 | 人工审核效率低 | 自动化扫描 + 人工抽查 + 社区举报 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 讨论溯源
|
||||
|
||||
本文档基于 2026-04-18 的无主题发散式互动探讨产出,完整讨论过程记录在 `plans/skill-cosmic-pancake.md`。
|
||||
|
||||
关键决策历程:
|
||||
- **Round 1:** 发现跨插件数据引用完全不支持(进销存的 customer_id 是裸 UUID)
|
||||
- **Round 2:** 确定声明式引用 + 完全独立(无硬依赖)+ 软警告对账方案
|
||||
- **Round 3:** 确定导入导出/打印/配置/视图/通知应为平台通用服务
|
||||
- **Round 4:** 收敛为统一设计规格,以财务插件为验证载体
|
||||
@@ -1,183 +0,0 @@
|
||||
# 插件系统增强设计规格
|
||||
|
||||
## Context
|
||||
|
||||
插件系统是 ERP 平台的核心差异化能力,当前声明式层面(manifest schema、动态表、前端页面)已达 90% 成熟度。但 WASM 逻辑层存在根本性限制:
|
||||
|
||||
1. **插件无法自主查询数据** — `db_query` 的 filter/pagination 参数被忽略,只能使用预填充结果
|
||||
2. **无读后写一致性** — 延迟刷新模型导致插件在一次调用中无法读取自己刚写入的数据
|
||||
3. **聚合只有 COUNT** — 缺少 SUM/AVG/MAX/MIN,无法支撑财务、统计类场景
|
||||
4. **热更新无原子回滚** — 旧版本先卸载再加载新版本,中间失败无保障
|
||||
5. **Schema 变更只支持新增实体** — 不支持已有实体的字段演进
|
||||
|
||||
这些限制使插件系统只能支撑"数据管理+展示"型轻量场景(CRM、简单进销存),无法支撑需要复杂业务逻辑的行业(财务、制造、电商)。
|
||||
|
||||
本次增强的目标:**让插件逻辑层从 40% 提升到 80%+,使系统能真正承载不同行业的定制化需求。**
|
||||
|
||||
---
|
||||
|
||||
## 改动 1:混合执行模型(解决查询和读后写一致性)
|
||||
|
||||
### 问题
|
||||
|
||||
`host.rs:99-109` — `db_query` 忽略 `_filter` 和 `_pagination` 参数,只从 `query_results` 预填充缓存取数据。插件无法自主构造查询。
|
||||
|
||||
### 方案:读操作走实时 SQL + 写操作保持延迟批量 + 读前自动 flush
|
||||
|
||||
核心流程变更:
|
||||
|
||||
```
|
||||
当前:
|
||||
WASM 调用 db_insert() → 入队 pending_ops
|
||||
WASM 调用 db_query() → 从预填充缓存读(忽略 filter/pagination)
|
||||
WASM 结束 → flush 全部 pending_ops
|
||||
|
||||
改为:
|
||||
WASM 调用 db_insert() → 入队 pending_ops
|
||||
WASM 调用 db_query() → 先 flush pending_ops → 执行真实 SQL 查询 → 返回结果
|
||||
WASM 结束 → flush 剩余 pending_ops
|
||||
```
|
||||
|
||||
### 改动文件
|
||||
|
||||
#### 1. `crates/erp-plugin/src/host.rs`
|
||||
|
||||
HostState 新增字段:
|
||||
|
||||
```rust
|
||||
pub struct HostState {
|
||||
// ... 现有字段保留 ...
|
||||
pub(crate) db: Option<DatabaseConnection>,
|
||||
pub(crate) event_bus: Option<EventBus>,
|
||||
}
|
||||
```
|
||||
|
||||
db_query 实现变更 — 使用 `tokio::runtime::Handle::current()` 在 `spawn_blocking` 内执行异步 DB 操作:
|
||||
|
||||
1. 先 `block_on(flush_ops(...))` 清空 pending writes
|
||||
2. 解析 filter/pagination 参数
|
||||
3. 调用 `DynamicTableManager::build_query_sql()` 构建查询
|
||||
4. `block_on` 执行查询并返回结果
|
||||
|
||||
向后兼容:`db = None` 时走旧的预填充路径。
|
||||
|
||||
#### 2. `crates/erp-plugin/src/dynamic_table.rs`
|
||||
|
||||
新增 `build_query_sql` 方法,复用 `data_service.rs` 中的查询构建逻辑。
|
||||
|
||||
### 向后兼容
|
||||
|
||||
- `HostState::new()` 不传 db → 走旧的预填充路径
|
||||
- `execute_wasm()` 传 db → 走新的实时查询路径
|
||||
- 现有 WASM 插件无需修改
|
||||
|
||||
---
|
||||
|
||||
## 改动 2:扩展聚合查询
|
||||
|
||||
### 问题
|
||||
|
||||
`data_service.rs:655` 的 `aggregate` 方法只支持 `GROUP BY + COUNT(*)`。
|
||||
|
||||
### 方案
|
||||
|
||||
新增 `aggregate_multi` 方法支持 SUM/AVG/MAX/MIN。
|
||||
|
||||
改动文件:
|
||||
|
||||
1. `data_service.rs` — 新增 `AggregateDef`、`AggregateFunc`、`AggregateResult` 类型和 `aggregate_multi` 方法
|
||||
2. `dynamic_table.rs` — 新增 `build_aggregate_multi_sql` 方法
|
||||
3. `data_handler.rs` — 扩展聚合 API 端点
|
||||
4. 前端 Dashboard Widget 适配多聚合返回格式
|
||||
|
||||
SQL 示例:
|
||||
```sql
|
||||
SELECT _f_status as key,
|
||||
COUNT(*) as count,
|
||||
COALESCE(SUM(_f_amount), 0) as sum_amount,
|
||||
COALESCE(AVG(_f_price), 0) as avg_price
|
||||
FROM plugin_erp_crm__order
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
GROUP BY _f_status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 改动 3:热更新原子回滚
|
||||
|
||||
### 问题
|
||||
|
||||
`service.rs:578-585` — 先 `unload(old)` 再 `load(new)`,中间失败无回滚。
|
||||
|
||||
### 方案:先加载新版本到临时 key,成功后原子替换
|
||||
|
||||
改动文件:
|
||||
|
||||
1. `service.rs` — upgrade 方法改用临时 key 加载新版本
|
||||
2. `engine.rs` — 新增 `rename_plugin` 方法
|
||||
|
||||
安全保证:新版本加载失败 → 旧版本仍在运行,零停机。
|
||||
|
||||
---
|
||||
|
||||
## 改动 4:Schema 演进(ALTER TABLE 支持)
|
||||
|
||||
### 问题
|
||||
|
||||
升级时只处理新增实体(CREATE TABLE),不处理已有实体的字段变更。
|
||||
|
||||
### 方案:利用 JSONB 特性实现轻量级 Schema 演进
|
||||
|
||||
大部分字段变更不需要 DDL(JSONB 天然支持),仅新增 filterable/sortable 字段需 ALTER TABLE ADD Generated Column + 索引。
|
||||
|
||||
改动文件:
|
||||
|
||||
1. `service.rs` — upgrade 方法增加 schema diff 逻辑
|
||||
2. `dynamic_table.rs` — 新增 `FieldDiff`、`diff_entity_fields`、`alter_add_generated_columns`
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
| 阶段 | 改动 | 复杂度 | 影响范围 |
|
||||
|------|------|--------|---------|
|
||||
| 1 | 热更新原子回滚 | 低 | engine.rs + service.rs |
|
||||
| 2 | Schema 演进(ALTER TABLE) | 中低 | service.rs + dynamic_table.rs |
|
||||
| 3 | 扩展聚合查询 | 中 | data_service.rs + data_handler.rs + dynamic_table.rs |
|
||||
| 4 | 混合执行模型(查询能力) | 高 | host.rs + engine.rs + dynamic_table.rs |
|
||||
|
||||
---
|
||||
|
||||
## 验证方案
|
||||
|
||||
### 阶段 1:热更新回滚
|
||||
1. 上传损坏的 WASM 二进制 → 验证旧版本仍在运行
|
||||
2. 上传正确的新版本 → 验证成功切换
|
||||
|
||||
### 阶段 2:Schema 演进
|
||||
1. 升级插件增加 filterable 字段 → 验证 ALTER TABLE 正确执行
|
||||
2. 旧数据上新 Generated Column 值正确填充
|
||||
|
||||
### 阶段 3:聚合查询
|
||||
1. 创建测试数据,调用聚合 API → 验证 SUM/AVG 结果正确
|
||||
2. 前端 Dashboard 展示正确
|
||||
|
||||
### 阶段 4:混合执行模型
|
||||
1. 插件 WASM 中 db_insert 后立即 db_query → 读后写一致性
|
||||
2. 带 filter 的 db_query → 过滤结果正确
|
||||
3. 旧插件(预填充模式)仍能正常工作
|
||||
4. 多次连续 db_query 不超过 Fuel 限制
|
||||
|
||||
---
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-plugin/src/host.rs` | 重构 db_query + 新增 db/事件总线字段 |
|
||||
| `crates/erp-plugin/src/engine.rs` | 调整 execute_wasm + 新增 rename_plugin |
|
||||
| `crates/erp-plugin/src/service.rs` | 升级流程回滚安全 + schema diff |
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | 新增 build_query_sql + alter_add_generated_columns + diff_entity_fields |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 新增 aggregate_multi + AggregateDef |
|
||||
| `crates/erp-plugin/src/data_handler.rs` | 扩展聚合 API |
|
||||
| `apps/web/src/pages/PluginDashboardPage.tsx` | 适配多聚合返回格式 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,624 +0,0 @@
|
||||
# freelance + itops 插件增强设计规格
|
||||
|
||||
> 日期: 2026-04-20
|
||||
> 来源: 多专家头脑风暴(UX专家 + 业务顾问 + 运维专家 + 财务专家)
|
||||
> 状态: Draft
|
||||
> 前置: `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
当前插件是「数据录入系统」,不是「赚钱工具」。一人 IT 服务公司的核心痛点:
|
||||
|
||||
1. **钱从哪里来?** — 商机跟进靠人记,没有自动提醒、没有漏斗分析
|
||||
2. **项目做到哪了?** — 任务状态和工时手动填,跟合同金额/应收款脱节
|
||||
3. **钱收回来了吗?** — 报价→合同→开票→收款割裂,没有串联
|
||||
4. **运维服务会不会忘?** — 巡检计划写了没人催,SLA 超时了才知道
|
||||
5. **税和利润算不清?** — 收支分散在不同表里,月底做账要手动汇总
|
||||
|
||||
**问题根因:** 平台已有 trigger_events、settings、templates、cascade_from、visible_when、validation 六大能力,但两个插件完全没有使用。
|
||||
|
||||
**改进目标:** 纯插件层增强,三层递进:
|
||||
- Layer 1: 智能业务引擎 — 让系统主动驱动用户做事
|
||||
- Layer 2: 仪表盘重构 — 一个页面掌控全局
|
||||
- Layer 3: 专业输出 — 一键生成报价单/发票/合同 PDF
|
||||
|
||||
---
|
||||
|
||||
## 2. Layer 1: 智能业务引擎 — freelance 插件
|
||||
|
||||
### 2.1 Settings(插件配置页)
|
||||
|
||||
一次性配置公司信息和业务偏好,后续自动生效:
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
|
||||
# ── 基本信息 ──
|
||||
[[settings.fields]]
|
||||
name = "company_name"
|
||||
display_name = "公司名称"
|
||||
field_type = "text"
|
||||
required = true
|
||||
group = "基本信息"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "currency_symbol"
|
||||
display_name = "货币符号"
|
||||
field_type = "text"
|
||||
default_value = "¥"
|
||||
group = "基本信息"
|
||||
|
||||
# ── 财务 ──
|
||||
[[settings.fields]]
|
||||
name = "default_tax_rate"
|
||||
display_name = "默认税率(%)"
|
||||
field_type = "number"
|
||||
default_value = 6
|
||||
range = [0.0, 100.0]
|
||||
group = "财务"
|
||||
|
||||
# ── 提醒 ──
|
||||
[[settings.fields]]
|
||||
name = "payment_reminder_days"
|
||||
display_name = "收款提前提醒(天)"
|
||||
field_type = "number"
|
||||
default_value = 3
|
||||
range = [1.0, 30.0]
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_contract_expiring"
|
||||
display_name = "合同到期提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_payment_overdue"
|
||||
display_name = "逾期收款提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_opportunity_followup"
|
||||
display_name = "商机跟进提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
```
|
||||
|
||||
### 2.2 Trigger Events(自动事件驱动)
|
||||
|
||||
关键操作时自动发通知,把"人找事"变"事找人":
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "opportunity_stage_changed"
|
||||
display_name = "商机阶段变更"
|
||||
description = "商机阶段发生变化时通知,特别是成交或失败"
|
||||
entity = "opportunity"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "contract_status_changed"
|
||||
display_name = "合同状态变更"
|
||||
description = "合同状态变化时检查到期预警"
|
||||
entity = "contract"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "invoice_status_changed"
|
||||
display_name = "发票状态变更"
|
||||
description = "发票状态变化时检查逾期收款"
|
||||
entity = "invoice"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "task_status_changed"
|
||||
display_name = "任务状态变更"
|
||||
description = "任务完成或取消时通知"
|
||||
entity = "task"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "expense_created"
|
||||
display_name = "新支出记录"
|
||||
description = "记录新支出时通知"
|
||||
entity = "expense"
|
||||
on = "create"
|
||||
```
|
||||
|
||||
### 2.3 Cascade(智能联动下拉)
|
||||
|
||||
选客户后自动过滤其关联数据。以下均为**已有字段追加 cascade 属性**,不是新增字段:
|
||||
|
||||
**contract 实体 — 已有 opportunity_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**contract 实体 — 已有 quote_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**invoice 实体 — 已有 project_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**invoice 实体 — 已有 contract_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**time_entry 实体 — 已有 task_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "project_id"
|
||||
cascade_filter = "project_id"
|
||||
```
|
||||
|
||||
### 2.4 Visible When(条件显示)
|
||||
|
||||
只在有意义时才显示字段。以下为**已有字段追加 visible_when 属性**:
|
||||
|
||||
**invoice 实体 — 已有 payment_date 字段追加:**
|
||||
```toml
|
||||
visible_when = "status == 'paid' || status == 'partial'"
|
||||
```
|
||||
|
||||
**contract 实体 — 已有 paid_amount 字段追加:**
|
||||
```toml
|
||||
visible_when = "status != 'drafting'"
|
||||
```
|
||||
|
||||
**task 实体 — 已有 actual_hours 字段追加:**
|
||||
```toml
|
||||
visible_when = "status != 'todo'"
|
||||
```
|
||||
|
||||
**quote 实体 — 已有 total_amount 字段追加:**
|
||||
```toml
|
||||
visible_when = "status != 'draft'"
|
||||
```
|
||||
|
||||
### 2.5 Validation(字段校验)
|
||||
|
||||
**已有字段追加 validation 属性**,不是新增字段:
|
||||
|
||||
**client 实体 — 已有 email 字段追加:**
|
||||
```toml
|
||||
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
|
||||
```
|
||||
|
||||
**client 实体 — 已有 phone 字段追加:**
|
||||
```toml
|
||||
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Layer 2: 仪表盘重构 — freelance 插件
|
||||
|
||||
将占位符仪表盘升级为真正的指挥中心。通过 `widgets` 声明告诉平台该展示什么。
|
||||
|
||||
> **平台依赖:** 仪表盘 widgets 需要平台层配合:
|
||||
> 1. `manifest.rs` 的 `PluginPageType::Dashboard` 需要新增 `widgets: Option<Vec<PluginWidget>>` 字段
|
||||
> 2. 定义 `PluginWidget` 枚举(stat_cards/action_list/funnel/card_list 类型)
|
||||
> 3. 更新 TOML 解析和验证逻辑
|
||||
> 4. 前端解析 `widgets` 声明并渲染对应组件
|
||||
>
|
||||
> 因此 P5/P6 **不是纯 plugin.toml 改动**,需要平台+前端联合实施。以下 widgets 声明作为设计参考,实施时需先完成平台侧支持。
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "dashboard"
|
||||
label = "工作台"
|
||||
icon = "DashboardOutlined"
|
||||
|
||||
# ── 财务概览卡片 ──
|
||||
[[ui.pages.widgets]]
|
||||
type = "stat_cards"
|
||||
label = "财务概览"
|
||||
cards = [
|
||||
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "type == 'payment' && status != 'overdue'", label = "本月收入", icon = "rise", color = "green" },
|
||||
{ entity = "expense", aggregate = "sum", field = "amount", label = "本月支出", icon = "fall", color = "red" },
|
||||
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "status == 'overdue' || status == 'pending'", label = "应收总额", icon = "dollar", color = "orange" },
|
||||
{ entity = "invoice", aggregate = "count", filter = "status == 'overdue'", label = "逾期笔数", icon = "warning", color = "red" }
|
||||
]
|
||||
|
||||
# ── 紧急待办 ──
|
||||
[[ui.pages.widgets]]
|
||||
type = "action_list"
|
||||
label = "紧急待办"
|
||||
max_items = 5
|
||||
queries = [
|
||||
{ entity = "invoice", filter = "status == 'overdue'", label_field = "invoice_number", subtitle_field = "amount", action = "查看", icon = "warning" },
|
||||
{ entity = "task", filter = "status != 'done' && status != 'cancelled'", sort = "due_date asc", label_field = "title", subtitle_field = "due_date", action = "处理", icon = "clock" },
|
||||
{ entity = "contract", filter = "status == 'active'", sort = "end_date asc", label_field = "title", subtitle_field = "end_date", action = "续约", icon = "file-text" },
|
||||
{ entity = "opportunity", filter = "next_follow_up <= today", label_field = "title", subtitle_field = "next_follow_up", action = "跟进", icon = "phone" }
|
||||
]
|
||||
|
||||
# ── 商机漏斗 ──
|
||||
[[ui.pages.widgets]]
|
||||
type = "funnel"
|
||||
label = "商机漏斗"
|
||||
entity = "opportunity"
|
||||
lane_field = "stage"
|
||||
value_field = "estimated_amount"
|
||||
lane_order = ["visit", "requirement", "quote", "negotiation", "won", "lost"]
|
||||
|
||||
# ── 活跃项目卡片 ──
|
||||
[[ui.pages.widgets]]
|
||||
type = "card_list"
|
||||
label = "活跃项目"
|
||||
entity = "project"
|
||||
filter = "status == 'in_progress'"
|
||||
max_items = 4
|
||||
title_field = "name"
|
||||
subtitle_field = "contract_amount"
|
||||
tags = ["business_type", "status"]
|
||||
```
|
||||
|
||||
**依赖:** 数据源来自平台已有的聚合 API(`/count`、`/aggregate`)。Filter 表达式使用平台过滤 DSL(`==`, `!=`, `||`, `&&`, `<=`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. Layer 3: 专业输出 — freelance 插件
|
||||
|
||||
一键生成专业 PDF,替代手动排 Word。
|
||||
|
||||
> **模板引擎说明:**
|
||||
> - 语法基于 Handlebars(`{{field}}`, `{{#each relation}}...{{/each}}`)
|
||||
> - 当前实体字段直接可用:`{{amount}}`, `{{status}}`
|
||||
> - 关系字段解析:`{{client.name}}` 表示通过 `client_id` 引用的 client 实体的 name 字段,渲染器需自动解析
|
||||
> - `{{#each lines}}` 用于一对多关系(如 quote → quote_line),渲染器查询子实体并遍历
|
||||
> - 平台需要实现 PDF 渲染管道:TOML 模板 → Handlebars 渲染(注入数据)→ HTML → wkhtmltopdf/浏览器打印 → PDF
|
||||
|
||||
### 4.1 报价单模板
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "quote_pdf"
|
||||
display_name = "报价单"
|
||||
entity = "quote"
|
||||
format = "pdf"
|
||||
template_html = """
|
||||
<html>
|
||||
<head><style>
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
|
||||
h1 { text-align: center; border-bottom: 2px solid #333; padding-bottom: 10px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f5f5f5; }
|
||||
.total { text-align: right; font-size: 18px; font-weight: bold; }
|
||||
.footer { margin-top: 40px; color: #666; font-size: 12px; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>报价单 {{quote_number}}</h1>
|
||||
<p>客户:{{client.name}} | 有效期至:{{valid_until}}</p>
|
||||
<table>
|
||||
<tr><th>项目</th><th>描述</th><th>数量</th><th>单价</th><th>金额</th></tr>
|
||||
{{#each lines}}
|
||||
<tr><td>{{item_name}}</td><td>{{description}}</td><td>{{quantity}}</td><td>{{unit_price}}</td><td>{{amount}}</td></tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
<p class="total">小计:{{subtotal}} | 税率:{{tax_rate}}% | 总计:{{total_amount}}</p>
|
||||
<div class="footer">备注:{{notes}}</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
### 4.2 发票模板
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "invoice_pdf"
|
||||
display_name = "发票"
|
||||
entity = "invoice"
|
||||
format = "pdf"
|
||||
template_html = """
|
||||
<html>
|
||||
<head><style>
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
|
||||
h1 { text-align: center; color: #1890ff; border-bottom: 2px solid #1890ff; padding-bottom: 10px; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
|
||||
.info-item { padding: 8px; background: #fafafa; }
|
||||
.amount { font-size: 24px; font-weight: bold; text-align: center; color: #f5222d; margin: 20px 0; }
|
||||
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 4px; background: #f0f0f0; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>发票 {{invoice_number}}</h1>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">客户:{{client.name}}</div>
|
||||
<div class="info-item">类型:{{type}}</div>
|
||||
<div class="info-item">开票日期:{{issue_date}}</div>
|
||||
<div class="info-item">到期日:{{due_date}}</div>
|
||||
</div>
|
||||
<div class="amount">¥{{amount}}</div>
|
||||
<p>状态:<span class="status-badge">{{status}}</span></p>
|
||||
<p>备注:{{notes}}</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
### 4.3 合同模板
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "contract_pdf"
|
||||
display_name = "合同"
|
||||
entity = "contract"
|
||||
format = "pdf"
|
||||
template_html = """
|
||||
<html>
|
||||
<head><style>
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
|
||||
h1 { text-align: center; border-bottom: 3px double #333; padding-bottom: 10px; }
|
||||
.parties { margin: 20px 0; padding: 15px; background: #fafafa; border-left: 4px solid #1890ff; }
|
||||
.signature { margin-top: 60px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
|
||||
.sig-box { border-top: 1px solid #333; padding-top: 10px; text-align: center; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>{{title}}</h1>
|
||||
<p>合同编号:{{contract_number}}</p>
|
||||
<div class="parties">
|
||||
<p>甲方:{{client.name}}</p>
|
||||
<p>合同金额:¥{{amount}} | 已付:¥{{paid_amount}}</p>
|
||||
<p>期限:{{start_date}} 至 {{end_date}}</p>
|
||||
<p>付款条款:{{payment_terms}}</p>
|
||||
</div>
|
||||
<p>备注:{{notes}}</p>
|
||||
<div class="signature">
|
||||
<div class="sig-box">甲方签章</div>
|
||||
<div class="sig-box">乙方签章</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. itops 插件增强
|
||||
|
||||
### 5.1 Settings
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_sla_response"
|
||||
display_name = "默认SLA响应时间(小时)"
|
||||
field_type = "number"
|
||||
default_value = 8
|
||||
range = [1.0, 72.0]
|
||||
group = "SLA"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_sla_resolve"
|
||||
display_name = "默认SLA解决时间(小时)"
|
||||
field_type = "number"
|
||||
default_value = 48
|
||||
range = [1.0, 168.0]
|
||||
group = "SLA"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_sla_breach"
|
||||
display_name = "SLA超标提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_check_due"
|
||||
display_name = "巡检到期提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
```
|
||||
|
||||
### 5.2 Trigger Events
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "ticket_created"
|
||||
display_name = "新工单"
|
||||
description = "创建工单时开始SLA计时并通知"
|
||||
entity = "ticket"
|
||||
on = "create"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "ticket_status_changed"
|
||||
display_name = "工单状态变更"
|
||||
description = "工单状态变化时检查SLA是否达标"
|
||||
entity = "ticket"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "contract_status_changed"
|
||||
display_name = "维保合同状态变更"
|
||||
description = "合同状态变化时检查到期预警"
|
||||
entity = "service_contract"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "check_plan_updated"
|
||||
display_name = "巡检计划更新"
|
||||
description = "巡检计划更新时检查下次巡检日期"
|
||||
entity = "check_plan"
|
||||
on = "update"
|
||||
```
|
||||
|
||||
### 5.3 Cascade
|
||||
|
||||
**已有字段追加 cascade 属性**,不是新增字段:
|
||||
|
||||
**ticket 实体 — 已有 contract_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**check_record 实体 — 已有 contract_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "plan_id"
|
||||
cascade_filter = "contract_id"
|
||||
```
|
||||
|
||||
### 5.4 Visible When
|
||||
|
||||
**已有字段追加 visible_when 属性**:
|
||||
|
||||
**ticket 实体 — 已有 resolution 字段追加:**
|
||||
```toml
|
||||
visible_when = "status == 'resolved' || status == 'closed'"
|
||||
```
|
||||
|
||||
**ticket 实体 — 已有 responded_at 字段追加:**
|
||||
```toml
|
||||
visible_when = "status != 'open'"
|
||||
```
|
||||
|
||||
**ticket 实体 — 已有 resolved_at 字段追加:**
|
||||
```toml
|
||||
visible_when = "status == 'resolved' || status == 'closed'"
|
||||
```
|
||||
|
||||
**ticket 实体 — 已有 closed_at 字段追加:**
|
||||
```toml
|
||||
visible_when = "status == 'closed'"
|
||||
```
|
||||
|
||||
**check_record 实体 — 已有 issues_found 字段追加:**
|
||||
```toml
|
||||
visible_when = "result == 'abnormal'"
|
||||
```
|
||||
|
||||
**check_record 实体 — 已有 actions_taken 字段追加:**
|
||||
```toml
|
||||
visible_when = "result == 'abnormal'"
|
||||
```
|
||||
|
||||
### 5.5 Validation
|
||||
|
||||
**已有字段追加 validation 属性**:
|
||||
|
||||
**service_contract 实体 — 已有 contract_number 字段追加:**
|
||||
```toml
|
||||
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式:SC-YYYY-NNNN" }
|
||||
```
|
||||
|
||||
### 5.6 Dashboard
|
||||
|
||||
> **同 Layer 2 说明:** widgets 需要平台层配合(manifest.rs 扩展 + 前端渲染),非纯 plugin.toml 改动。此仪表盘页面**插入到现有页面列表最前面**,现有 4 个页面保持不变。
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "dashboard"
|
||||
label = "运维概览"
|
||||
icon = "DashboardOutlined"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "stat_cards"
|
||||
label = "运维概览"
|
||||
cards = [
|
||||
{ entity = "service_contract", aggregate = "count", filter = "status == 'active'", label = "活跃合同", icon = "file-text", color = "blue" },
|
||||
{ entity = "ticket", aggregate = "count", filter = "status == 'open' || status == 'in_progress'", label = "待处理工单", icon = "tool", color = "orange" },
|
||||
{ entity = "ticket", aggregate = "count", filter = "status == 'resolved'", label = "已解决工单", icon = "check-circle", color = "green" },
|
||||
{ entity = "check_plan", aggregate = "count", filter = "status == 'active'", label = "活跃巡检", icon = "schedule", color = "blue" }
|
||||
]
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "action_list"
|
||||
label = "紧急待办"
|
||||
max_items = 5
|
||||
queries = [
|
||||
{ entity = "ticket", filter = "status == 'open'", sort = "priority asc", label_field = "title", subtitle_field = "type", action = "处理", icon = "warning" },
|
||||
{ entity = "service_contract", filter = "status == 'active'", sort = "end_date asc", label_field = "name", subtitle_field = "end_date", action = "续约", icon = "file-text" },
|
||||
{ entity = "check_plan", filter = "status == 'active'", sort = "next_check_date asc", label_field = "name", subtitle_field = "next_check_date", action = "巡检", icon = "schedule" }
|
||||
]
|
||||
```
|
||||
|
||||
### 5.7 Template(维保合同 PDF)
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "service_contract_pdf"
|
||||
display_name = "维保合同"
|
||||
entity = "service_contract"
|
||||
format = "pdf"
|
||||
template_html = """
|
||||
<html>
|
||||
<head><style>
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
|
||||
h1 { text-align: center; border-bottom: 3px double #1890ff; padding-bottom: 10px; color: #1890ff; }
|
||||
.sla-box { margin: 20px 0; padding: 15px; background: #e6f7ff; border: 1px solid #91d5ff; border-radius: 4px; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
|
||||
.info-item { padding: 8px; background: #fafafa; }
|
||||
.signature { margin-top: 60px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
|
||||
.sig-box { border-top: 1px solid #333; padding-top: 10px; text-align: center; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>{{name}}</h1>
|
||||
<p>合同编号:{{contract_number}}</p>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">客户:{{client.name}}</div>
|
||||
<div class="info-item">合同金额:¥{{amount}}</div>
|
||||
<div class="info-item">期限:{{start_date}} 至 {{end_date}}</div>
|
||||
<div class="info-item">状态:{{status}}</div>
|
||||
</div>
|
||||
<div class="sla-box">
|
||||
<strong>SLA 承诺:</strong>响应 {{sla_response_hours}} 小时内 / 解决 {{sla_resolve_hours}} 小时内
|
||||
</div>
|
||||
<p>服务范围:{{service_scope}}</p>
|
||||
<p>付款条款:{{payment_terms}}</p>
|
||||
<p>备注:{{notes}}</p>
|
||||
<div class="signature">
|
||||
<div class="sig-box">甲方签章</div>
|
||||
<div class="sig-box">乙方签章</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 改进汇总
|
||||
|
||||
| 层次 | 能力 | freelance | itops |
|
||||
|------|------|-----------|-------|
|
||||
| Layer 1 | settings | 7 个配置项(公司名/税率/提醒偏好) | 4 个配置项(SLA默认值/提醒偏好) |
|
||||
| Layer 1 | trigger_events | 5 个事件(商机/合同/发票/任务/支出) | 4 个事件(工单/合同/巡检) |
|
||||
| Layer 1 | cascade | 4 处联动(合同/发票/工时表单) | 2 处联动(工单/巡检记录) |
|
||||
| Layer 1 | visible_when | 4 个条件字段 | 6 个条件字段 |
|
||||
| Layer 1 | validation | 2 个校验(邮箱/手机) | 1 个校验(合同编号格式) |
|
||||
| Layer 2 | dashboard widgets | 财务卡片+紧急待办+商机漏斗+项目卡片 | 运维卡片+紧急待办 |
|
||||
| Layer 3 | templates | 3 个 PDF(报价单/发票/合同) | 1 个 PDF(维保合同) |
|
||||
|
||||
**总计:** 2 个插件 × 3 层增强,从「数据录入」升级为「赚钱工具」。
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施优先级
|
||||
|
||||
```
|
||||
P1: freelance Layer 1(settings + trigger_events + cascade + visible_when + validation)
|
||||
P2: itops Layer 1(settings + trigger_events + cascade + visible_when + validation)
|
||||
P3: freelance Layer 3(3 个 PDF 模板)
|
||||
P4: itops Layer 3(维保合同 PDF 模板)
|
||||
P5: freelance Layer 2(仪表盘 widgets)
|
||||
P6: itops Layer 2(仪表盘 widgets)
|
||||
```
|
||||
|
||||
P1-P4 是纯 plugin.toml 改动(给已有字段追加 cascade/visible_when/validation 属性,以及新增 settings/trigger_events/templates 段落),可立即实施。P5-P6 的仪表盘 widgets 需要平台层配合:扩展 `manifest.rs` 的 `PluginPageType::Dashboard` 支持 `widgets` 字段 + 前端渲染组件。
|
||||
@@ -1,710 +0,0 @@
|
||||
# 健康管理系统 — erp-health 模块设计规格
|
||||
|
||||
> **文档版本**: 1.0
|
||||
> **日期**: 2026-04-23
|
||||
> **状态**: 已确认
|
||||
> **范围**: V1 — 患者管理 + 健康数据 + 预约排班 + 随访管理 + 咨询管理
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目背景
|
||||
|
||||
### 1.1 产品定位
|
||||
|
||||
构建一个面向体检中心/医疗机构的**综合型健康管理平台**,以体检中心为数据源,汇集不同情况的患者,提供全生命周期的健康管理服务。
|
||||
|
||||
本系统从 ERP 平台底座分叉独立,作为 **Health Management System (HMS)** 产品演进。ERP 底座提供身份权限、工作流、消息通知、系统配置等基础能力,`erp-health` 作为原生 Rust 模块承载所有医疗业务逻辑。
|
||||
|
||||
### 1.2 系统架构
|
||||
|
||||
```
|
||||
📱 患者端(微信小程序) ──┐
|
||||
├──→ 🔀 API 网关 ──→ 🖥️ ERP 后端(HMS)
|
||||
👨⚕️ 医护端(小程序/H5) ──┘ │ │
|
||||
│ ├── erp-auth(用户/角色/权限)
|
||||
│ ├── erp-workflow(工作流引擎)
|
||||
│ ├── erp-message(消息通知)
|
||||
│ ├── erp-config(字典/配置)
|
||||
│ └── erp-health(健康管理)★ 新增
|
||||
│
|
||||
└──→ 💾 PostgreSQL + Redis
|
||||
```
|
||||
|
||||
**关键决策:**
|
||||
- ERP 只负责 **PC 管理后台**功能
|
||||
- 小程序(患者端/医护端)作为**独立系统**开发
|
||||
- 数据共享通过 **API 网关**实现
|
||||
- 健康管理使用**原生 Rust 模块**(非 WASM 插件),获得完整的数据库访问和自定义 API 能力
|
||||
|
||||
### 1.3 为什么不用 WASM 插件
|
||||
|
||||
| 限制 | 影响 |
|
||||
|------|------|
|
||||
| 实体上限 20 个 | 综合健康平台轻松超过 |
|
||||
| JSONB 存储 | 医疗数据需要强类型、索引、关联 |
|
||||
| 无自定义 API | 趋势分析、统计报表需要专用端点 |
|
||||
| 无文件上传 | 化验单、体检报告无法存储 |
|
||||
| WASM 沙箱限制 | 无法引入加密、AI、外部 API |
|
||||
|
||||
原生模块遵循现有模式(如 erp-auth、erp-workflow)。**注意:**`ErpModule` trait 没有 `register_routes` 方法。模块通过固有方法 `public_routes()` 和 `protected_routes()` 暴露路由,在 `erp-server` 的 `main.rs` 中通过 `.nest("/api/v1/health", HealthModule::protected_routes())` 集成。通过 EventBus 通信,未来可平滑拆分为独立微服务。
|
||||
|
||||
---
|
||||
|
||||
## 2. V1 功能范围
|
||||
|
||||
| 模块 | 功能 | 页面数 |
|
||||
|------|------|--------|
|
||||
| ① 患者与医护管理 | 患者档案、家庭成员、医护档案、患者标签 | 3 |
|
||||
| ② 健康数据管理 | 体检记录、日常监测、化验报告、趋势分析 | 3 |
|
||||
| ③ 预约与排班 | 预约管理、医生排班、日历视图 | 2 |
|
||||
| ④ 随访管理 | 随访任务、随访记录台账 | 2 |
|
||||
| ⑤ 咨询管理 | 会话管理、对话记录查看/导出 | 2 |
|
||||
| ⑥ 医护管理 | 医护人员列表 | 1 |
|
||||
| **合计** | | **13** |
|
||||
|
||||
**V2 预留:** 积分商城、数据统计中心、内容管理增强。
|
||||
|
||||
---
|
||||
|
||||
## 3. 实体模型
|
||||
|
||||
### 3.1 设计原则
|
||||
|
||||
- 患者和医护的**账号**走 `erp-auth` 的 `users` 表,`erp-health` 只存医疗业务扩展字段
|
||||
- 通过 `user_id` 外键关联 `users` 表
|
||||
- 所有表含 `tenant_id`(多租户隔离)、`id`(UUIDv7)、`created_at`、`updated_at`、`created_by`、`updated_by`、`deleted_at`、`version`
|
||||
- 多对多关系使用中间表
|
||||
|
||||
### 3.2 实体定义
|
||||
|
||||
#### ① 患者与医护管理
|
||||
|
||||
**patient — 患者档案**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | UUIDv7 |
|
||||
| tenant_id | UUID NOT NULL | 租户 ID |
|
||||
| user_id | UUID FK → users | 关联 erp-auth 账号 |
|
||||
| name | VARCHAR(100) | 姓名 |
|
||||
| gender | VARCHAR(10) | 性别 (male/female/other) |
|
||||
| birth_date | DATE | 出生日期 |
|
||||
| blood_type | VARCHAR(10) | 血型 (A/B/AB/O/RH-/RH+) |
|
||||
| id_number | VARCHAR(20) | 身份证号 |
|
||||
| allergy_history | TEXT | 过敏史 |
|
||||
| medical_history_summary | TEXT | 病史摘要 |
|
||||
| emergency_contact_name | VARCHAR(100) | 紧急联系人姓名 |
|
||||
| emergency_contact_phone | VARCHAR(20) | 紧急联系人电话 |
|
||||
| status | VARCHAR(20) | 状态 (active/inactive/deceased) |
|
||||
| verification_status | VARCHAR(20) | 实名认证 (pending/verified/rejected) |
|
||||
| source | VARCHAR(100) | 来源(体检中心名称) |
|
||||
| notes | TEXT | 备注 |
|
||||
| created_at, updated_at, created_by, updated_by, deleted_at, version | — | 标准字段 |
|
||||
|
||||
索引:`(tenant_id, name)`, `(tenant_id, status)`, `(tenant_id, id_number) UNIQUE WHERE deleted_at IS NULL`
|
||||
|
||||
**patient_family_member — 家庭成员**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | 患者关联 |
|
||||
| name | VARCHAR(100) | 姓名 |
|
||||
| relationship | VARCHAR(50) | 关系(父亲/母亲/配偶/子女等) |
|
||||
| phone | VARCHAR(20) | 电话 |
|
||||
| birth_date | DATE | 出生日期 |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
**doctor_profile — 医护档案**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| user_id | UUID FK → users | 关联 erp-auth 账号 |
|
||||
| department | VARCHAR(100) | 科室 |
|
||||
| title | VARCHAR(50) | 职称(主任医师/副主任医师/主治医师等) |
|
||||
| specialty | VARCHAR(200) | 专长 |
|
||||
| license_number | VARCHAR(50) | 执业证号 |
|
||||
| bio | TEXT | 简介 |
|
||||
| online_status | VARCHAR(20) | 在线状态 (online/offline/busy) |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id)`
|
||||
|
||||
**patient_tag — 患者标签**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| name | VARCHAR(50) | 标签名 |
|
||||
| color | VARCHAR(20) | 颜色值 |
|
||||
| description | TEXT | 描述 |
|
||||
| is_system | BOOLEAN | 系统标签(不可删除) |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`UNIQUE (tenant_id, name) WHERE deleted_at IS NULL`
|
||||
|
||||
**patient_tag_relation — 患者-标签关联**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| tag_id | UUID FK → patient_tag | |
|
||||
| created_at | TIMESTAMPTZ | |
|
||||
| updated_at | TIMESTAMPTZ | |
|
||||
| created_by | UUID | |
|
||||
| updated_by | UUID | |
|
||||
| deleted_at | TIMESTAMPTZ | 软删除 |
|
||||
|
||||
索引:`(tenant_id, patient_id)`, `(tenant_id, tag_id)`
|
||||
|
||||
**patient_doctor_relation — 医患关系**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| relationship_type | VARCHAR(20) | 类型 (primary/consulting) |
|
||||
| created_at | TIMESTAMPTZ | |
|
||||
| updated_at | TIMESTAMPTZ | |
|
||||
| created_by | UUID | |
|
||||
| updated_by | UUID | |
|
||||
| deleted_at | TIMESTAMPTZ | 软删除 |
|
||||
|
||||
索引:`(tenant_id, patient_id)`, `(tenant_id, doctor_id)`
|
||||
|
||||
#### ② 健康数据管理
|
||||
|
||||
**health_record — 体检/就诊记录**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| record_type | VARCHAR(20) | 类型 (checkup/outpatient/inpatient) |
|
||||
| record_date | DATE | 记录日期 |
|
||||
| source | VARCHAR(200) | 来源(体检中心/医院名称) |
|
||||
| overall_assessment | TEXT | 总体评估 |
|
||||
| report_file_url | VARCHAR(500) | 报告文件 URL |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, record_date DESC)`
|
||||
|
||||
**vital_signs — 日常监测数据**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| record_date | DATE | 记录日期 |
|
||||
| systolic_bp_morning | INTEGER | 晨起收缩压 |
|
||||
| diastolic_bp_morning | INTEGER | 晨起舒张压 |
|
||||
| systolic_bp_evening | INTEGER | 晚间收缩压 |
|
||||
| diastolic_bp_evening | INTEGER | 晚间舒张压 |
|
||||
| heart_rate | INTEGER | 心率 |
|
||||
| weight | DECIMAL(5,1) | 体重 (kg) |
|
||||
| blood_sugar | DECIMAL(5,1) | 血糖 (mmol/L) |
|
||||
| water_intake_ml | INTEGER | 饮水量 (ml) |
|
||||
| urine_output_ml | INTEGER | 尿量 (ml) |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, record_date DESC)`
|
||||
|
||||
**lab_report — 化验报告**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| report_date | DATE | 报告日期 |
|
||||
| report_type | VARCHAR(50) | 报告类型(肾功能/血常规/尿常规等) |
|
||||
| indicators | JSONB | 指标数据 [{name, value, unit, ref_range, is_abnormal}] |
|
||||
| image_urls | JSONB | 图片 URLs [url1, url2, ...] |
|
||||
| doctor_interpretation | TEXT | 医生解读 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, report_date DESC)`, GIN on `indicators`, `(tenant_id, report_type)`
|
||||
|
||||
**health_trend — 健康趋势报告**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| period_start | DATE | 周期开始 |
|
||||
| period_end | DATE | 周期结束 |
|
||||
| indicator_summary | JSONB | 指标摘要 |
|
||||
| abnormal_items | JSONB | 异常项 |
|
||||
| generation_type | VARCHAR(20) | 生成方式 (auto/manual) |
|
||||
| report_file_url | VARCHAR(500) | 报告文件 URL |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, period_start DESC)`
|
||||
|
||||
#### ③ 预约排班
|
||||
|
||||
**appointment — 预约记录**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| appointment_type | VARCHAR(20) | 类型 (dialysis/recheck/outpatient) |
|
||||
| appointment_date | DATE | 预约日期 |
|
||||
| start_time | TIME | 开始时间 |
|
||||
| end_time | TIME | 结束时间 |
|
||||
| status | VARCHAR(20) | 状态 (pending/confirmed/cancelled/completed/no_show) |
|
||||
| cancel_reason | TEXT | 取消原因 |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, appointment_date, status)`, `(tenant_id, doctor_id, appointment_date)`
|
||||
|
||||
**doctor_schedule — 医生排班**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| schedule_date | DATE | 排班日期 |
|
||||
| period_type | VARCHAR(20) | 时段 (am/pm/night/full_day) |
|
||||
| start_time | TIME | 开始时间 |
|
||||
| end_time | TIME | 结束时间 |
|
||||
| max_appointments | INTEGER | 最大预约数 |
|
||||
| current_appointments | INTEGER | 已预约数(默认 0) |
|
||||
| status | VARCHAR(20) | 状态 (enabled/disabled) |
|
||||
| 标准 ERP 字段 | — |
|
||||
|
||||
索引:`(tenant_id, doctor_id, schedule_date)`, `UNIQUE (tenant_id, doctor_id, schedule_date, period_type) WHERE deleted_at IS NULL`
|
||||
|
||||
**预约并发控制:** 创建预约时使用原子 CAS 操作 `UPDATE doctor_schedule SET current_appointments = current_appointments + 1 WHERE id = $1 AND current_appointments < max_appointments RETURNING *`,防止超额预约。
|
||||
|
||||
#### ④ 随访管理
|
||||
|
||||
**follow_up_task — 随访任务**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| assigned_to | UUID FK → users | 负责医护 |
|
||||
| follow_up_type | VARCHAR(20) | 类型 (phone/face_to_face/online) |
|
||||
| planned_date | DATE | 计划日期 |
|
||||
| status | VARCHAR(20) | 状态 (pending/in_progress/completed/overdue/cancelled) |
|
||||
| content_template | TEXT | 随访内容模板 |
|
||||
| related_appointment_id | UUID FK → appointment | 关联预约 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, assigned_to, status)`, `(tenant_id, planned_date, status)`
|
||||
|
||||
**follow_up_record — 随访记录**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| task_id | UUID FK → follow_up_task | |
|
||||
| executed_by | UUID FK → users | 执行医护 |
|
||||
| executed_date | DATE | 执行日期 |
|
||||
| result | VARCHAR(20) | 结果 (followed_up/unreachable/refused/other) |
|
||||
| patient_condition | TEXT | 患者状况 |
|
||||
| medical_advice | TEXT | 医嘱建议 |
|
||||
| next_follow_up_date | DATE | 下次随访日期 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, task_id)`, `(tenant_id, executed_date)`
|
||||
|
||||
#### ⑤ 咨询管理
|
||||
|
||||
**consultation_session — 咨询会话**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| type | VARCHAR(20) | 类型 (customer_service/doctor) |
|
||||
| status | VARCHAR(20) | 状态 (waiting/active/closed) |
|
||||
| last_message_at | TIMESTAMPTZ | 最后消息时间 |
|
||||
| unread_count_patient | INTEGER | 患者未读数 |
|
||||
| unread_count_doctor | INTEGER | 医生未读数 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, doctor_id, status)`, `(tenant_id, patient_id, status)`
|
||||
|
||||
**consultation_message — 咨询消息**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| session_id | UUID FK → consultation_session | |
|
||||
| sender_id | UUID | 发送者 ID |
|
||||
| sender_role | VARCHAR(20) | 角色 (patient/doctor/system) |
|
||||
| content_type | VARCHAR(20) | 类型 (text/image/voice/file) |
|
||||
| content | TEXT | 内容 |
|
||||
| is_read | BOOLEAN | 已读状态(默认 false) |
|
||||
| created_at | TIMESTAMPTZ | 发送时间 |
|
||||
| updated_at | TIMESTAMPTZ | |
|
||||
| created_by | UUID | |
|
||||
| updated_by | UUID | |
|
||||
| deleted_at | TIMESTAMPTZ | 软删除(内容审核用) |
|
||||
| version | INT NOT NULL DEFAULT 1 | 乐观锁 |
|
||||
|
||||
索引:`(tenant_id, session_id, created_at)`
|
||||
|
||||
**数据增长策略:** 对 `created_at` 按月分区(PostgreSQL table partitioning),超过 1 年的已关闭会话消息归档到冷存储。
|
||||
|
||||
**说明:**
|
||||
- `patient.user_id` 允许 NULL — 患者可先创建档案(如体检中心导入),后续再绑定 erp-auth 账号
|
||||
- `consultation_message.sender_id` 引用 `users.id` — 统一使用 erp-auth 用户体系标识发送者
|
||||
|
||||
---
|
||||
|
||||
## 3.3 状态机定义
|
||||
|
||||
### appointment.status 转换
|
||||
|
||||
```
|
||||
pending ──→ confirmed ──→ completed
|
||||
│ │
|
||||
│ └──→ no_show(预约时间过后,系统自动或前台手动触发)
|
||||
│
|
||||
└──→ cancelled(任意时刻可取消,需填 cancel_reason)
|
||||
```
|
||||
|
||||
### follow_up_task.status 转换
|
||||
|
||||
```
|
||||
pending ──→ in_progress ──→ completed
|
||||
│ │
|
||||
└──→ cancelled └──→ overdue(系统定时任务:planned_date 已过且仍 pending 自动标记)
|
||||
```
|
||||
|
||||
### consultation_session.status 转换
|
||||
|
||||
```
|
||||
waiting ──→ active(第一条消息发送时自动触发)──→ closed(手动关闭或超时自动关闭)
|
||||
```
|
||||
|
||||
### patient.status 转换
|
||||
|
||||
```
|
||||
active ──→ inactive(手动停用)
|
||||
active ──→ deceased(标记死亡,不可逆)
|
||||
inactive ──→ active(重新激活)
|
||||
```
|
||||
|
||||
### patient.verification_status 转换
|
||||
|
||||
```
|
||||
pending ──→ verified(实名认证通过)
|
||||
pending ──→ rejected(认证被拒)
|
||||
rejected ──→ pending(重新提交认证)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 设计
|
||||
|
||||
所有端点前缀: `/api/v1/health/`
|
||||
|
||||
### 4.1 患者管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/patients` | 患者列表(分页、搜索、标签筛选) |
|
||||
| POST | `/patients` | 创建患者 |
|
||||
| GET | `/patients/:id` | 患者详情 |
|
||||
| PUT | `/patients/:id` | 更新患者 |
|
||||
| DELETE | `/patients/:id` | 软删除 |
|
||||
| POST | `/patients/:id/tags` | 管理标签(批量设置) |
|
||||
| GET | `/patients/:id/health-summary` | 健康摘要 |
|
||||
| GET | `/patients/:id/family-members` | 家庭成员列表 |
|
||||
| POST | `/patients/:id/family-members` | 新增家庭成员 |
|
||||
| PUT | `/patients/:id/family-members/:fid` | 更新家庭成员 |
|
||||
| DELETE | `/patients/:id/family-members/:fid` | 删除家庭成员 |
|
||||
| POST | `/patients/:id/doctors` | 分配主治医生 |
|
||||
| DELETE | `/patients/:id/doctors/:did` | 移除医患关系 |
|
||||
|
||||
### 4.2 健康数据
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/patients/:id/vital-signs` | 日常监测列表 |
|
||||
| POST | `/patients/:id/vital-signs` | 新增监测数据 |
|
||||
| GET | `/patients/:id/lab-reports` | 化验报告列表 |
|
||||
| POST | `/patients/:id/lab-reports` | 新增化验报告 |
|
||||
| GET | `/patients/:id/health-records` | 体检/就诊记录 |
|
||||
| POST | `/patients/:id/health-records` | 新增记录 |
|
||||
| GET | `/patients/:id/trends` | 趋势报告 |
|
||||
| POST | `/patients/:id/trends/generate` | 生成趋势报告 |
|
||||
| GET | `/patients/:id/trends/:indicator` | 单指标时序数据 |
|
||||
| PUT | `/patients/:id/vital-signs/:vid` | 更新监测数据 |
|
||||
| DELETE | `/patients/:id/vital-signs/:vid` | 删除监测数据 |
|
||||
| PUT | `/patients/:id/lab-reports/:rid` | 更新化验报告 |
|
||||
| DELETE | `/patients/:id/lab-reports/:rid` | 删除化验报告 |
|
||||
| PUT | `/patients/:id/health-records/:rid` | 更新体检记录 |
|
||||
| DELETE | `/patients/:id/health-records/:rid` | 删除体检记录 |
|
||||
|
||||
### 4.3 预约排班
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/appointments` | 预约列表 |
|
||||
| POST | `/appointments` | 创建预约 |
|
||||
| PUT | `/appointments/:id/status` | 更新状态 |
|
||||
| GET | `/doctor-schedules` | 排班列表 |
|
||||
| POST | `/doctor-schedules` | 创建排班 |
|
||||
| PUT | `/doctor-schedules/:id` | 更新排班 |
|
||||
| GET | `/doctor-schedules/calendar` | 日历视图 |
|
||||
|
||||
### 4.4 随访管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/follow-up-tasks` | 任务列表 |
|
||||
| POST | `/follow-up-tasks` | 创建任务 |
|
||||
| PUT | `/follow-up-tasks/:id` | 更新任务 |
|
||||
| DELETE | `/follow-up-tasks/:id` | 删除任务 |
|
||||
| POST | `/follow-up-tasks/:id/records` | 填写随访记录 |
|
||||
| GET | `/follow-up-records` | 随访台账 |
|
||||
|
||||
### 4.5 咨询管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/consultation-sessions` | 会话列表 |
|
||||
| GET | `/consultation-sessions/:id/messages` | 消息记录 |
|
||||
| PUT | `/consultation-sessions/:id/close` | 关闭会话 |
|
||||
| POST | `/consultation-messages` | 写入消息(API 网关用) |
|
||||
| GET | `/consultation-sessions/export` | 导出 |
|
||||
|
||||
### 4.6 医护管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/doctors` | 医护列表 |
|
||||
| POST | `/doctors` | 创建医护档案 |
|
||||
| GET | `/doctors/:id` | 医护详情 |
|
||||
| PUT | `/doctors/:id` | 更新医护档案 |
|
||||
| DELETE | `/doctors/:id` | 软删除医护档案 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端页面设计
|
||||
|
||||
文件位置: `apps/web/src/pages/health/`
|
||||
|
||||
### 5.1 页面清单
|
||||
|
||||
| # | 页面 | 文件名 | 类型 |
|
||||
|---|------|--------|------|
|
||||
| 1 | 患者列表 | PatientList.tsx | 表格+搜索+标签筛选+导出 |
|
||||
| 2 | 患者详情 | PatientDetail.tsx | Tab布局:基本信息/健康趋势/化验报告/就诊记录/随访记录 |
|
||||
| 3 | 标签管理 | PatientTagManage.tsx | CRUD+颜色+批量打标 |
|
||||
| 4 | 日常监测 | VitalSignsList.tsx | 按患者+日期+ECharts趋势折线图 |
|
||||
| 5 | 化验报告 | LabReportList.tsx | 列表+图片预览+指标详情+解读 |
|
||||
| 6 | 体检记录 | HealthRecordList.tsx | 类型筛选+报告文件查看/上传 |
|
||||
| 7 | 预约管理 | AppointmentList.tsx | 列表/日历切换+状态流转 |
|
||||
| 8 | 排班管理 | DoctorSchedule.tsx | 周/月日历+排班模板 |
|
||||
| 9 | 随访任务 | FollowUpTaskList.tsx | 任务CRUD+分配+关联工作流 |
|
||||
| 10 | 随访台账 | FollowUpRecordList.tsx | 按患者/医护/日期筛选+导出 |
|
||||
| 11 | 会话管理 | ConsultationList.tsx | 列表+未回复统计 |
|
||||
| 12 | 对话记录 | ConsultationDetail.tsx | 聊天气泡+图片/语音查看+导出 |
|
||||
| 13 | 医护列表 | DoctorList.tsx | 列表+科室筛选+在线状态 |
|
||||
|
||||
### 5.2 技术要点
|
||||
|
||||
- **ECharts 趋势图** — 血压/体重/血糖曲线图,按日期范围展示
|
||||
- **文件上传/预览** — 化验单图片、体检报告 PDF(需新增基础能力)
|
||||
- **日历组件** — Ant Design Calendar 用于排班和预约视图
|
||||
- **聊天 UI** — 消息气泡展示(只读,非实时聊天)
|
||||
- **导出** — 随访台账、咨询记录导出为 Excel
|
||||
|
||||
---
|
||||
|
||||
## 6. 事件集成
|
||||
|
||||
### 6.1 发布事件
|
||||
|
||||
| 事件类型 | 触发时机 | 载荷 |
|
||||
|----------|----------|------|
|
||||
| `patient.created` | 创建患者 | `{patient_id, name, tenant_id}` |
|
||||
| `patient.updated` | 更新患者信息 | `{patient_id, changed_fields}` |
|
||||
| `appointment.created` | 创建预约 | `{appointment_id, patient_id, doctor_id, date}` |
|
||||
| `appointment.confirmed` | 确认预约 | `{appointment_id}` |
|
||||
| `appointment.cancelled` | 取消预约 | `{appointment_id, cancel_reason}` |
|
||||
| `appointment.completed` | 完成就诊 | `{appointment_id}` |
|
||||
| `follow_up.created` | 创建随访任务 | `{task_id, patient_id, assigned_to, planned_date}` |
|
||||
| `follow_up.completed` | 完成随访 | `{task_id, record_id, result}` |
|
||||
| `lab_report.uploaded` | 上传化验报告 | `{report_id, patient_id, report_type, abnormal_count}` |
|
||||
| `consultation.opened` | 开启咨询 | `{session_id, patient_id, doctor_id}` |
|
||||
| `consultation.closed` | 关闭咨询 | `{session_id}` |
|
||||
| `patient.deceased` | 患者死亡标记 | `{patient_id}` |
|
||||
| `patient.verified` | 实名认证通过 | `{patient_id, id_number}` |
|
||||
| `follow_up.overdue` | 随访任务逾期 | `{task_id, patient_id, planned_date}` |
|
||||
| `doctor.online_status_changed` | 医护在线状态变更 | `{doctor_id, old_status, new_status}` |
|
||||
|
||||
**随访记录自动创建后续任务:** 当 `follow_up_record.next_follow_up_date` 不为空时,服务层自动创建新的 `follow_up_task`(planned_date = next_follow_up_date,assigned_to 沿用当前医护)。
|
||||
|
||||
### 6.2 订阅事件
|
||||
|
||||
| 事件类型 | 处理逻辑 |
|
||||
|----------|----------|
|
||||
| `workflow.task.completed` | 工作流任务完成时更新随访任务状态 |
|
||||
| `message.sent` | 消息发送时联动咨询会话的 last_message_at |
|
||||
|
||||
---
|
||||
|
||||
## 7. 模块结构
|
||||
|
||||
```
|
||||
crates/erp-health/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── lib.rs ← ErpModule trait + public_routes() / protected_routes()
|
||||
│ ├── error.rs ← HealthError → AppError
|
||||
│ ├── state.rs ← HealthState (共享状态)
|
||||
│ ├── entity/ ← SeaORM Entity
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient.rs
|
||||
│ │ ├── patient_family_member.rs
|
||||
│ │ ├── patient_tag.rs
|
||||
│ │ ├── patient_tag_relation.rs
|
||||
│ │ ├── patient_doctor_relation.rs
|
||||
│ │ ├── doctor_profile.rs
|
||||
│ │ ├── health_record.rs
|
||||
│ │ ├── vital_signs.rs
|
||||
│ │ ├── lab_report.rs
|
||||
│ │ ├── health_trend.rs
|
||||
│ │ ├── appointment.rs
|
||||
│ │ ├── doctor_schedule.rs
|
||||
│ │ ├── follow_up_task.rs
|
||||
│ │ ├── follow_up_record.rs
|
||||
│ │ ├── consultation_session.rs
|
||||
│ │ └── consultation_message.rs
|
||||
│ ├── service/ ← 业务逻辑
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient_service.rs
|
||||
│ │ ├── health_data_service.rs
|
||||
│ │ ├── appointment_service.rs
|
||||
│ │ ├── follow_up_service.rs
|
||||
│ │ └── consultation_service.rs
|
||||
│ ├── handler/ ← Axum 路由
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient_handler.rs
|
||||
│ │ ├── health_data_handler.rs
|
||||
│ │ ├── appointment_handler.rs
|
||||
│ │ ├── follow_up_handler.rs
|
||||
│ │ └── consultation_handler.rs
|
||||
│ ├── dto/ ← 请求/响应结构体
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient_dto.rs
|
||||
│ │ ├── health_data_dto.rs
|
||||
│ │ ├── appointment_dto.rs
|
||||
│ │ ├── follow_up_dto.rs
|
||||
│ │ └── consultation_dto.rs
|
||||
│ └── event.rs ← 事件定义和处理器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 权限定义
|
||||
|
||||
### 8.1 权限码
|
||||
|
||||
| 权限码 | 名称 | 说明 |
|
||||
|--------|------|------|
|
||||
| `health.patient.list` | 查看患者列表 | 查看和搜索患者列表、详情 |
|
||||
| `health.patient.manage` | 管理患者 | 创建、编辑、删除患者 |
|
||||
| `health.health-data.list` | 查看健康数据 | 查看体检记录、监测数据、化验报告 |
|
||||
| `health.health-data.manage` | 管理健康数据 | 录入、编辑、删除健康数据 |
|
||||
| `health.appointment.list` | 查看预约 | 查看预约列表和排班 |
|
||||
| `health.appointment.manage` | 管理预约 | 创建、确认、取消预约 |
|
||||
| `health.follow-up.list` | 查看随访 | 查看随访任务和记录 |
|
||||
| `health.follow-up.manage` | 管理随访 | 创建、分配、完成随访任务 |
|
||||
| `health.consultation.list` | 查看咨询 | 查看咨询会话和消息记录 |
|
||||
| `health.consultation.manage` | 管理咨询 | 关闭会话、导出记录 |
|
||||
| `health.doctor.list` | 查看医护 | 查看医护列表和详情 |
|
||||
| `health.doctor.manage` | 管理医护 | 创建、编辑医护档案、排班 |
|
||||
|
||||
### 8.2 数据范围
|
||||
|
||||
| 实体 | 支持的数据范围级别 | 说明 |
|
||||
|------|-------------------|------|
|
||||
| patient | self, department, department_tree, all | 医生只能看自己负责的患者或本科室患者 |
|
||||
| follow_up_task | self, department, department_tree, all | 医护只能看分配给自己的随访任务 |
|
||||
| appointment | self, department, department_tree, all | 按科室隔离预约数据 |
|
||||
|
||||
### 8.3 角色模板
|
||||
|
||||
| 角色 | 权限 |
|
||||
|------|------|
|
||||
| health_admin | 全部 health.* 权限 |
|
||||
| doctor | health.patient.list, health.health-data.*, health.appointment.list, health.follow-up.*, health.consultation.list, health.doctor.list |
|
||||
| nurse | health.patient.list, health.health-data.*, health.follow-up.*, health.appointment.list |
|
||||
| receptionist | health.patient.*, health.appointment.*, health.doctor.list |
|
||||
|
||||
---
|
||||
|
||||
## 9. 能力扩展
|
||||
|
||||
V1 需要新增以下基础能力(在 erp-core 或独立模块中):
|
||||
|
||||
1. **文件上传服务** — 文件存储(本地/OSS)、URL 生成、图片缩略图
|
||||
2. **趋势分析** — 时序数据聚合、异常检测逻辑
|
||||
3. **报告批注** — 医生对化验报告的解读/批注能力
|
||||
4. **导出增强** — 健康数据导出为 Excel/PDF
|
||||
|
||||
---
|
||||
|
||||
## 10. 实施步骤
|
||||
|
||||
### Phase 1: 项目初始化
|
||||
- 拷贝 ERP 到 hms
|
||||
- 验证编译和构建
|
||||
|
||||
### Phase 2: erp-health 骨架
|
||||
- 创建 crate 结构
|
||||
- 实现 ErpModule trait + `public_routes()` / `protected_routes()` 固有方法
|
||||
- 注册到 workspace
|
||||
|
||||
### Phase 3: 数据库迁移
|
||||
- 16 张表(14 业务实体 + 2 关联表)的迁移文件
|
||||
- 索引创建、唯一约束
|
||||
|
||||
### Phase 4: 业务逻辑(按域迭代)
|
||||
- ① 患者与医护管理
|
||||
- ② 健康数据管理
|
||||
- ③ 预约排班
|
||||
- ④ 随访管理
|
||||
- ⑤ 咨询管理
|
||||
|
||||
### Phase 5: 前端页面
|
||||
- 13 个自定义 React 页面
|
||||
- 路由注册和侧边栏菜单
|
||||
|
||||
### Phase 6: 集成测试
|
||||
- API 端点测试
|
||||
- 多租户隔离验证
|
||||
- 端到端功能验证
|
||||
@@ -1,509 +0,0 @@
|
||||
# HMS 患者小程序设计规格
|
||||
|
||||
> **版本**: v1.0
|
||||
> **日期**: 2026-04-23
|
||||
> **状态**: 草案
|
||||
> **关联**: 健康模块设计规格 `2026-04-23-health-management-module-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 产品定位
|
||||
|
||||
HMS 患者小程序是**综合健康管理入口**,面向体检中心/医疗机构的患者。覆盖体检预约、报告查询、健康数据长期监测、随访管理、家庭健康管理等场景。
|
||||
|
||||
医护端以 PC 管理后台(`apps/web/`)为主力,小程序聚焦患者体验。医护端小程序可在后续按需补一个轻量版(随访提醒、排班查看),不在本规格范围内。
|
||||
|
||||
### 1.2 核心决策
|
||||
|
||||
| 维度 | 决策 | 原因 |
|
||||
|------|------|------|
|
||||
| 技术选型 | Taro 4 + React 19 | 与 Web 端 React 技能复用,支持多端编译 |
|
||||
| 架构方案 | 直连后端 | MVP 阶段最务实,复用 erp-server API |
|
||||
| 登录方式 | 微信授权 + 手机号补充 | 降低门槛 + 确保身份可靠 |
|
||||
| 代码位置 | `apps/miniprogram/`(Monorepo) | 方便接口同步,共享类型定义 |
|
||||
| 目标平台 | 微信小程序优先 | 覆盖最广泛用户,后续可扩展 |
|
||||
| 数据录入 | 手动 + 蓝牙预留接口 | MVP 快速交付,后续对接设备 |
|
||||
| 视觉风格 | 医疗清新(青色主调) | 专业可靠,沿用现有 HTML 原型风格 |
|
||||
|
||||
### 1.3 MVP 功能范围
|
||||
|
||||
**MVP 包含(7 个功能模块):**
|
||||
1. 登录 + 个人中心
|
||||
2. 健康数据录入 + 趋势图
|
||||
3. 预约挂号
|
||||
4. 报告查询
|
||||
5. 随访管理
|
||||
6. 家庭健康管理(就诊人切换)
|
||||
7. 健康资讯 + 用药提醒
|
||||
|
||||
**后续版本:**
|
||||
- 在线咨询(即时通讯,WebSocket 长连接)
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目结构
|
||||
|
||||
```
|
||||
apps/miniprogram/
|
||||
├── config/ # Taro 编译配置
|
||||
│ ├── index.ts # 通用配置
|
||||
│ ├── dev.ts # 开发环境
|
||||
│ └── prod.ts # 生产环境
|
||||
├── project.config.json # 微信小程序项目配置
|
||||
├── src/
|
||||
│ ├── app.config.ts # Taro 全局配置(TabBar、页面路由)
|
||||
│ ├── app.tsx # 入口组件
|
||||
│ ├── app.scss # 全局样式(医疗清新主题变量)
|
||||
│ ├── components/ # 通用组件
|
||||
│ │ ├── HealthCard/ # 健康指标卡片(血压/血糖/体重)
|
||||
│ │ ├── AppointmentCard/ # 预约卡片
|
||||
│ │ ├── ReportItem/ # 报告列表项
|
||||
│ │ ├── FamilyPicker/ # 就诊人切换器
|
||||
│ │ ├── EmptyState/ # 空状态占位
|
||||
│ │ └── TrendChart/ # 趋势图(echarts-taro3-react)
|
||||
│ ├── pages/
|
||||
│ │ ├── index/ # 首页(今日健康+快捷入口+待办)
|
||||
│ │ ├── health/ # 健康数据(录入+趋势图)
|
||||
│ │ ├── appointment/ # 预约(列表+新建预约)
|
||||
│ │ ├── report/ # 报告(体检报告+化验单)
|
||||
│ │ ├── followup/ # 随访(任务+问卷填写)
|
||||
│ │ ├── article/ # 健康资讯(文章列表+详情)
|
||||
│ │ ├── profile/ # 我的(个人信息+就诊人管理+设置)
|
||||
│ │ └── login/ # 登录(微信授权+手机号)
|
||||
│ ├── services/ # API 调用层
|
||||
│ │ ├── request.ts # 封装 Taro.request(JWT 注入、错误处理)
|
||||
│ │ ├── auth.ts # 登录/刷新 token
|
||||
│ │ ├── health.ts # 健康数据 CRUD
|
||||
│ │ ├── appointment.ts # 预约 CRUD
|
||||
│ │ ├── report.ts # 报告查询
|
||||
│ │ ├── followup.ts # 随访任务/记录
|
||||
│ │ └── article.ts # 资讯/科普
|
||||
│ ├── stores/ # Zustand 状态管理
|
||||
│ │ ├── auth.ts # 登录态、用户信息、就诊人列表
|
||||
│ │ └── health.ts # 健康数据缓存
|
||||
│ ├── utils/
|
||||
│ │ ├── bluetooth.ts # 蓝牙接口预留(MVP 不实现)
|
||||
│ │ ├── format.ts # 日期/数值格式化
|
||||
│ │ └── constants.ts # 常量定义
|
||||
│ └── styles/
|
||||
│ ├── variables.scss # 主题变量(青色主调)
|
||||
│ └── mixins.scss # 常用样式混入
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
**设计原则:**
|
||||
- services 层与 Web 端 `apps/web/src/api/` 职责对齐,用 Taro.request 替代 fetch
|
||||
- stores 复用 Zustand 模式,与 Web 端保持一致的状态管理风格
|
||||
- 组件命名 PascalCase 目录,与 Web 端风格统一
|
||||
- MVP 阶段不强抽取 `packages/shared/`,等两端跑起来后根据重复度决定
|
||||
|
||||
---
|
||||
|
||||
## 3. 认证流程
|
||||
|
||||
### 3.1 整体流程
|
||||
|
||||
```
|
||||
用户打开小程序
|
||||
↓
|
||||
检查本地 storage 有无有效 JWT
|
||||
├── 有且未过期 → 直接进入首页
|
||||
└── 无或已过期 ↓
|
||||
|
||||
Step 1: 微信静默登录
|
||||
wx.login() → code
|
||||
→ POST /api/v1/auth/wechat/login { code }
|
||||
→ 后端用 code 换 openid,查找绑定用户
|
||||
├── 已绑定 → 签发 JWT { token, user }
|
||||
└── 未绑定 → 返回 { need_bind: true, openid }
|
||||
|
||||
Step 2: 手机号绑定(仅新用户)
|
||||
wx.getPhoneNumber 按钮组件 → encryptedData + iv
|
||||
→ POST /api/v1/auth/wechat/bind-phone { openid, encryptedData, iv }
|
||||
→ 后端解密手机号,创建/关联 user + patient 档案
|
||||
→ 签发 JWT { token, user, patient }
|
||||
|
||||
Step 3: 补充档案(首次绑定后)
|
||||
→ 引导填写姓名、性别、出生日期、身份证号(可选)
|
||||
→ PUT /api/v1/health/patients/me { name, gender, birthday }
|
||||
```
|
||||
|
||||
### 3.2 后端新增内容
|
||||
|
||||
**`erp-auth` 新增:**
|
||||
|
||||
| 新增 | 说明 |
|
||||
|------|------|
|
||||
| `wechat_users` 表 | `id, openid, union_id, user_id, phone, created_at, updated_at` |
|
||||
| `POST /api/v1/auth/wechat/login` | code → openid 查询,返回绑定状态 |
|
||||
| `POST /api/v1/auth/wechat/bind-phone` | 绑定手机号,创建 user + patient |
|
||||
| `GET /api/v1/auth/wechat/qrcode` | 生成带参数小程序码(PC 端扫码登录场景) |
|
||||
|
||||
`wechat_users` 表必须包含 `tenant_id`(多租户隔离)和标准审计字段(`created_at`, `updated_at`, `deleted_at`)。
|
||||
|
||||
### 3.3 Token 策略
|
||||
|
||||
| Token | 有效期 | 存储 |
|
||||
|-------|--------|------|
|
||||
| Access Token (JWT) | 15 分钟 | 内存 + Taro.setStorage |
|
||||
| Refresh Token | 7 天 | Taro.setStorage |
|
||||
|
||||
自动刷新机制:`services/request.ts` 拦截 401 → 调用 `POST /auth/refresh` → 重试原请求。刷新失败则跳转登录页。
|
||||
|
||||
### 3.4 多就诊人
|
||||
|
||||
- 一个微信账号可管理多个 patient(本人 + 家人)
|
||||
- 切换就诊人时请求 header 带 `X-Patient-Id`
|
||||
- 后端校验该 patient 属于当前 user
|
||||
|
||||
---
|
||||
|
||||
## 4. 页面结构与导航
|
||||
|
||||
### 4.1 Tab Bar
|
||||
|
||||
底部导航栏 5 个入口:
|
||||
|
||||
| Tab | 图标 | 页面路径 |
|
||||
|-----|------|----------|
|
||||
| 首页 | 🏠 | /pages/index/index |
|
||||
| 健康 | 📊 | /pages/health/index |
|
||||
| 预约 | 📅 | /pages/appointment/index |
|
||||
| 资讯 | 📰 | /pages/article/index |
|
||||
| 我的 | 👤 | /pages/profile/index |
|
||||
|
||||
### 4.2 页面层级
|
||||
|
||||
```
|
||||
Tab: 首页 /pages/index
|
||||
├── /pages/notifications/index # 通知列表
|
||||
└── /pages/followup/detail/index # 随访任务详情
|
||||
|
||||
Tab: 健康 /pages/health
|
||||
├── /pages/health/input/index # 录入数据
|
||||
├── /pages/health/trend/index # 指标趋势
|
||||
└── /pages/health/history/index # 历史记录
|
||||
|
||||
Tab: 预约 /pages/appointment
|
||||
├── /pages/appointment/create/index # 新建预约
|
||||
└── /pages/appointment/detail/index # 预约详情
|
||||
|
||||
Tab: 资讯 /pages/article
|
||||
└── /pages/article/detail/index # 文章详情
|
||||
|
||||
Tab: 我的 /pages/profile
|
||||
├── /pages/profile/family/index # 就诊人管理
|
||||
├── /pages/profile/family-add/index # 添加就诊人
|
||||
├── /pages/profile/reports/index # 我的报告
|
||||
├── /pages/profile/followups/index # 我的随访
|
||||
├── /pages/profile/medication/index # 用药提醒
|
||||
└── /pages/profile/settings/index # 设置
|
||||
|
||||
独立页面(不在 Tab 内):
|
||||
├── /pages/login/index # 登录
|
||||
└── /pages/login/profile/index # 档案补全
|
||||
```
|
||||
|
||||
### 4.3 首页布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ 问候栏(渐变青色背景) │ 用户名 + 日期 + 通知铃铛
|
||||
├─────────────────────────────┤
|
||||
│ 今日健康卡片(上浮 -20px) │ 血压/心率/血糖/体重 2×2 网格
|
||||
├─────────────────────────────┤
|
||||
│ 快捷服务(4 宫格) │ 录数据/预约/报告/随访
|
||||
├─────────────────────────────┤
|
||||
│ 即将到来 │ 最近 1 条预约卡片
|
||||
├─────────────────────────────┤
|
||||
│ 待办随访 │ 最多 2 条待办 + 查看全部
|
||||
├─────────────────────────────┤
|
||||
│ [ 首页 ] [ 健康 ] [ 预约 ] [ 资讯 ] [ 我的 ] │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 核心功能数据流
|
||||
|
||||
### 5.1 健康数据录入
|
||||
|
||||
```
|
||||
选择指标类型 → 输入数值 + 测量时间 → 添加备注(可选)→ POST /vital-signs
|
||||
↓
|
||||
成功 → 更新首页卡片 + 趋势缓存
|
||||
失败 → Toast + 本地暂存
|
||||
```
|
||||
|
||||
**MVP 支持的指标类型:**
|
||||
|
||||
| 指标 | 单位 | 输入控件 |
|
||||
|------|------|---------|
|
||||
| 收缩压 / 舒张压 | mmHg | 两个数字输入框 |
|
||||
| 心率 | bpm | 数字输入框 |
|
||||
| 空腹血糖 | mmol/L | 数字输入框 |
|
||||
| 餐后血糖 | mmol/L | 数字输入框 |
|
||||
| 体重 | kg | 数字输入框(1 位小数) |
|
||||
| 体温 | ℃ | 数字输入框(1 位小数) |
|
||||
|
||||
每次可同时填多项或只填一项。录入时间默认当前,可手动调整为当天任意时间。
|
||||
|
||||
### 5.2 预约挂号
|
||||
|
||||
```
|
||||
选择科室 → 选择医生 → 选择日期(排班日历)→ 选择时段 → 确认预约
|
||||
↓
|
||||
POST /appointments
|
||||
↓
|
||||
成功 → 订阅消息通知 + 日历同步
|
||||
满员 → 提示"该时段已满"
|
||||
```
|
||||
|
||||
**关键交互:**
|
||||
- 排班日历用周视图,有排班的日期标绿点
|
||||
- 点击日期后展示该日可用时段
|
||||
- 时段显示"剩余 X 位"
|
||||
- 预约成功后支持微信订阅消息提醒
|
||||
|
||||
### 5.3 报告查询
|
||||
|
||||
```
|
||||
报告列表(分页,时间倒序)
|
||||
↓ 点击某份报告
|
||||
报告详情 → 基本信息卡 + 指标列表 + PDF/图片附件预览
|
||||
```
|
||||
|
||||
**指标状态标记:**
|
||||
- 异常偏高:红色 + ↑ 箭头
|
||||
- 异常偏低:红色 + ↓ 箭头
|
||||
- 正常范围:灰色
|
||||
|
||||
### 5.4 随访管理
|
||||
|
||||
```
|
||||
待办列表(按截止日期排序)
|
||||
↓ 支持"待完成/已完成/已过期"筛选
|
||||
点击任务 → 动态表单(后端定义字段)→ 提交 → 标记完成
|
||||
```
|
||||
|
||||
问卷由 PC 端医护创建(follow_up_task),小程序负责展示和填写。提交后创建 follow_up_record。
|
||||
|
||||
### 5.5 家庭健康管理
|
||||
|
||||
```
|
||||
就诊人列表(本人 + 已添加家属)
|
||||
↓ 点击头像或下拉切换
|
||||
切换就诊人 → 全局 X-Patient-Id 更新 → 所有页面数据刷新
|
||||
↓ 添加家属
|
||||
填写信息 → 姓名 + 关系 + 身份证号(可选)→ POST /patients
|
||||
```
|
||||
|
||||
切换就诊人通过全局 store 更新,所有 service 请求自动携带新 `X-Patient-Id`。
|
||||
|
||||
### 5.6 健康资讯 + 用药提醒
|
||||
|
||||
**资讯:**
|
||||
- `GET /articles` → 分页列表(缩略图 + 标题 + 摘要 + 时间)
|
||||
- 文章详情使用 Taro `RichText` 组件渲染富文本
|
||||
|
||||
**用药提醒(MVP):**
|
||||
- 小程序本地 storage 存储提醒规则(药品名 + 频率 + 时间)
|
||||
- 每日触发检查
|
||||
- 通过微信订阅消息推送提醒
|
||||
- 不依赖后端新表
|
||||
|
||||
---
|
||||
|
||||
## 6. API 集成与状态管理
|
||||
|
||||
### 6.1 请求层封装
|
||||
|
||||
`services/request.ts` 职责:
|
||||
|
||||
| 拦截点 | 行为 |
|
||||
|--------|------|
|
||||
| 请求拦截 | 自动注入 `Authorization: Bearer {token}` |
|
||||
| 请求拦截 | 自动注入 `X-Patient-Id`(当前选中就诊人) |
|
||||
| 请求拦截 | 自动注入 `X-Tenant-Id`(从登录信息获取) |
|
||||
| 响应拦截 | 401 → 静默刷新 token → 重试原请求 |
|
||||
| 响应拦截 | 刷新失败 → 跳转登录页 |
|
||||
| 错误处理 | 网络错误 / 业务错误 / 超时统一处理 |
|
||||
|
||||
多租户处理:患者只属于一个租户。登录时后端返回 `tenant_id`,前端每次请求带上。不走 `tenant_id` 中间件自动注入。
|
||||
|
||||
### 6.2 Zustand Stores
|
||||
|
||||
**auth store:**
|
||||
|
||||
```typescript
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
refreshToken: string | null
|
||||
user: { id: string; name: string; phone: string; avatar: string } | null
|
||||
currentPatient: Patient | null
|
||||
patients: Patient[]
|
||||
setCurrentPatient: (id: string) => void
|
||||
login: (code: string) => Promise<void>
|
||||
bindPhone: (data: BindPhoneData) => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**health store:**
|
||||
|
||||
```typescript
|
||||
interface HealthState {
|
||||
todaySummary: VitalSigns | null
|
||||
trendData: Record<string, TrendPoint[]>
|
||||
refreshToday: () => Promise<void>
|
||||
getTrend: (type: string, range: '7d' | '30d' | '90d') => Promise<TrendPoint[]>
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 API 端点对应表
|
||||
|
||||
| 小程序 service | 后端端点 | 方法 |
|
||||
|----------------|----------|------|
|
||||
| `auth.login(code)` | `/api/v1/auth/wechat/login` | POST |
|
||||
| `auth.bindPhone(data)` | `/api/v1/auth/wechat/bind-phone` | POST |
|
||||
| `auth.refresh()` | `/api/v1/auth/refresh` | POST |
|
||||
| `health.getToday()` | `/api/v1/health/vital-signs?date=today` | GET |
|
||||
| `health.input(data)` | `/api/v1/health/vital-signs` | POST |
|
||||
| `health.getTrend(type, range)` | `/api/v1/health/vital-signs/trend` | GET |
|
||||
| `appointment.list()` | `/api/v1/health/appointments` | GET |
|
||||
| `appointment.create(data)` | `/api/v1/health/appointments` | POST |
|
||||
| `appointment.cancel(id)` | `/api/v1/health/appointments/:id/cancel` | PUT |
|
||||
| `schedule.getByDoctor(id)` | `/api/v1/health/doctor-schedules` | GET |
|
||||
| `report.list()` | `/api/v1/health/lab-reports` | GET |
|
||||
| `report.detail(id)` | `/api/v1/health/lab-reports/:id` | GET |
|
||||
| `followup.list()` | `/api/v1/health/follow-up-tasks` | GET |
|
||||
| `followup.submit(id, data)` | `/api/v1/health/follow-up-records` | POST |
|
||||
| `patient.list()` | `/api/v1/health/patients` | GET |
|
||||
| `patient.create(data)` | `/api/v1/health/patients` | POST |
|
||||
| `patient.update(id, data)` | `/api/v1/health/patients/:id` | PUT |
|
||||
|
||||
**后端需新增的端点(尚未实现):**
|
||||
|
||||
| 端点 | 说明 |
|
||||
|------|------|
|
||||
| `POST /auth/wechat/login` | 微信登录 |
|
||||
| `POST /auth/wechat/bind-phone` | 手机号绑定 |
|
||||
| `GET /vital-signs/trend` | 趋势聚合查询 |
|
||||
| `GET /doctor-schedules` | 按科室/医生查询排班 |
|
||||
| `GET /articles` | 健康资讯列表 |
|
||||
| `GET /articles/:id` | 资讯详情 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 视觉设计
|
||||
|
||||
### 7.1 主题色
|
||||
|
||||
沿用现有 HTML 原型的医疗清新风格:
|
||||
|
||||
| 用途 | 色值 | 说明 |
|
||||
|------|------|------|
|
||||
| 主色 | `#0891B2` | 青色,按钮、导航、强调 |
|
||||
| 主色浅 | `#E0F7FA` | 背景、卡片高亮 |
|
||||
| 主色深 | `#065A73` | 渐变、按压态 |
|
||||
| 辅助色 | `#059669` | 绿色,成功、正常指标 |
|
||||
| 危险色 | `#DC2626` | 红色,异常指标、删除 |
|
||||
| 警告色 | `#D97706` | 琥珀,待办、提醒 |
|
||||
| 背景色 | `#F0FDFA` | 页面底色 |
|
||||
| 卡片色 | `#FFFFFF` | 卡片背景 |
|
||||
| 主文字 | `#134E4A` | 标题、正文 |
|
||||
| 副文字 | `#6B7280` | 说明、标签 |
|
||||
| 轻文字 | `#94A3B8` | 时间戳、占位符 |
|
||||
|
||||
### 7.2 圆角规范
|
||||
|
||||
| 元素 | 圆角 |
|
||||
|------|------|
|
||||
| 卡片 | 12px |
|
||||
| 按钮 | 8px |
|
||||
| 输入框 | 8px |
|
||||
| 头像 | 50% |
|
||||
| 快捷图标 | 14px |
|
||||
|
||||
### 7.3 阴影规范
|
||||
|
||||
| 层级 | 值 |
|
||||
|------|---|
|
||||
| 轻阴影 | `0 1px 3px rgba(0,0,0,.04)` |
|
||||
| 标准阴影 | `0 2px 8px rgba(0,0,0,.06)` |
|
||||
| 中阴影 | `0 4px 16px rgba(0,0,0,.08)` |
|
||||
| 重阴影 | `0 8px 32px rgba(0,0,0,.12)` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 开发工作流
|
||||
|
||||
### 8.1 开发环境
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
cd apps/miniprogram && pnpm install
|
||||
|
||||
# 开发模式(需配合微信开发者工具)
|
||||
pnpm dev:weapp # Taro 编译 + watch → dist/
|
||||
# 用微信开发者工具打开 dist/ 目录预览
|
||||
|
||||
# 生产构建
|
||||
pnpm build:weapp # 压缩 + tree-shaking
|
||||
|
||||
# 后端联调
|
||||
# 需同时运行 erp-server (port 3000)
|
||||
# 小程序开发设置中关闭域名校验(开发阶段)
|
||||
```
|
||||
|
||||
### 8.2 与 Web 端的代码复用
|
||||
|
||||
| 复用内容 | 方式 | 说明 |
|
||||
|---------|------|------|
|
||||
| TypeScript 类型 | 按需引用 Web 端 DTO 类型 | API 请求/响应结构一致 |
|
||||
| 主题变量值 | Web CSS 变量 → SCSS 变量 | 青色主调色值保持一致 |
|
||||
| Zustand 模式 | 相同 store 设计模式 | 各自独立实现 |
|
||||
| API 接口定义 | service 层函数签名对齐 | Web 用 fetch,小程序用 Taro.request |
|
||||
|
||||
### 8.3 后端需同步开发的内容
|
||||
|
||||
| 优先级 | 内容 | 涉及 crate |
|
||||
|--------|------|-----------|
|
||||
| P0 | `wechat_users` 表 + 微信登录/绑定 API | erp-auth |
|
||||
| P0 | `vital_signs` 趋势查询 API | erp-health |
|
||||
| P0 | `doctor_schedules` 按科室/医生查询 API | erp-health |
|
||||
| P1 | `lab_reports` 指标异常标注字段 | erp-health |
|
||||
| P1 | `follow_up_tasks` 动态问卷字段扩展 | erp-health |
|
||||
| P2 | `articles` 表 + CRUD | erp-health |
|
||||
| P2 | 微信订阅消息模板注册 | erp-server |
|
||||
|
||||
---
|
||||
|
||||
## 9. 分期交付计划
|
||||
|
||||
| 阶段 | 内容 | 目标 |
|
||||
|------|------|------|
|
||||
| Phase 1 | 项目骨架 + 登录流程 + 首页(静态数据) | 基础搭建 |
|
||||
| Phase 2 | 健康数据录入 + 趋势图 | 核心功能 |
|
||||
| Phase 3 | 预约挂号 + 排班日历 | 核心功能 |
|
||||
| Phase 4 | 报告查询 + 家庭管理 | 扩展功能 |
|
||||
| Phase 5 | 随访 + 资讯 + 用药提醒 | 扩展功能 |
|
||||
| Phase 6 | 打磨 + 真机测试 + 提审 | 上线准备 |
|
||||
|
||||
每个 Phase 内部遵循:先对接后端 API → 再实现 UI → 真机验证 → 提交。
|
||||
|
||||
---
|
||||
|
||||
## 10. 约束与风险
|
||||
|
||||
| 约束/风险 | 应对策略 |
|
||||
|-----------|---------|
|
||||
| 小程序包体积限制(2MB 主包) | 按功能分包加载,图表库按需引入 |
|
||||
| 微信审核周期(3-7 天) | Phase 6 预留充足审核时间 |
|
||||
| 后端 API 部分未实现 | 小程序开发与后端同步推进,优先实现 P0 端点 |
|
||||
| 微信订阅消息需用户主动触发 | 在预约成功、随访提交等场景引导用户订阅 |
|
||||
| 蓝牙设备适配复杂 | MVP 预留接口不实现,后续按设备型号逐一对接 |
|
||||
| 多就诊人数据隔离 | 后端严格校验 user-patient 归属关系 |
|
||||
@@ -1,671 +0,0 @@
|
||||
# 健康管理模块全面迭代设计
|
||||
|
||||
> **文档版本**: 1.0
|
||||
> **日期**: 2026-04-24
|
||||
> **状态**: 待评审
|
||||
> **基于**: 5 位专家(后端架构/前端架构/医疗业务/安全质量/产品策略)深度审查
|
||||
|
||||
---
|
||||
|
||||
## 0. 审查发现总览
|
||||
|
||||
### 0.1 V1 发布阻塞项
|
||||
|
||||
| # | 阻塞项 | 来源 | 影响 |
|
||||
|---|--------|------|------|
|
||||
| B1 | Web 健康模块 10 页面未实现 | 前端架构/产品策略 | 无法演示和交付 |
|
||||
| B2 | 医疗数据安全不合规 | 安全质量 | 零 sanitize / 零审计 / 身证明文 / 零测试 |
|
||||
| B3 | 数据一致性缺陷 | 医疗业务/后端架构 | 排班可超额 / 名额释放可能失败 / 随访逾期未实现 |
|
||||
| B4 | 事件处理器空壳 | 后端架构 | 随访状态/咨询消息不联动 |
|
||||
|
||||
### 0.2 当前完成度
|
||||
|
||||
| 层级 | 模块 | 完成度 |
|
||||
|------|------|--------|
|
||||
| 后端 | erp-health(16 实体/8 服务/7 handler/40+ API) | 95% |
|
||||
| 后端 | 事件处理器业务逻辑 | 0%(框架已搭建,需填充 db 操作) |
|
||||
| 后端 | sanitize / 审计 / 加密 | 0% |
|
||||
| 后端 | 测试覆盖 | 0% |
|
||||
| Web 前端 | 健康模块页面 | 0% |
|
||||
| Web 前端 | 健康模块 API 服务层 | 0% |
|
||||
| 小程序 | 初版 21 页面 | 85% |
|
||||
|
||||
---
|
||||
|
||||
## 1. 安全省基(阶段 1,1.5-2 周)
|
||||
|
||||
### 1.1 sanitize 全覆盖
|
||||
|
||||
**问题**: erp-health 模块没有任何对 `strip_html_tags` 的调用,攻击者可在患者姓名、病史等字段注入 XSS payload。
|
||||
|
||||
**参考实现**: `crates/erp-auth/src/dto.rs` 第 96-118 行,`CreateUserReq` 和 `UpdateUserReq` 已实现 `sanitize()` 方法。
|
||||
|
||||
**修复方案**: 为每个 DTO 的字符串输入字段添加 sanitize。
|
||||
|
||||
**覆盖字段清单**:
|
||||
|
||||
| DTO 文件 | 字段 |
|
||||
|----------|------|
|
||||
| `patient_dto.rs` CreatePatientReq / UpdatePatientReq | name, notes, allergy_history, medical_history_summary, emergency_contact_name, source |
|
||||
| `patient_dto.rs` FamilyMemberReq(create + update 共用) | name, notes |
|
||||
| `patient_handler.rs` AssignDoctorReq(位于 handler 非 dto) | — (无字符串字段) |
|
||||
| `health_data_dto.rs` CreateVitalSignsReq | notes |
|
||||
| `health_data_dto.rs` CreateLabReportReq | doctor_interpretation |
|
||||
| `health_data_dto.rs` CreateHealthRecordReq | source, overall_assessment, notes |
|
||||
| `appointment_dto.rs` CreateAppointmentReq | notes, cancel_reason |
|
||||
| `follow_up_dto.rs` CreateFollowUpTaskReq / UpdateFollowUpTaskReq | content_template |
|
||||
| `follow_up_dto.rs` CreateFollowUpRecordReq | patient_condition, medical_advice |
|
||||
| `consultation_dto.rs` CreateMessageReq | content |
|
||||
| `consultation_dto.rs` CreateSessionReq | — (无字符串字段) |
|
||||
| `doctor_dto.rs` CreateDoctorReq / UpdateDoctorReq | department, title, specialty, bio |
|
||||
|
||||
**实现模式**:
|
||||
|
||||
```rust
|
||||
// 封装 sanitize 辅助函数(与 erp-auth 的 sanitize_option 模式一致)
|
||||
fn sanitize_option_string(opt: Option<String>) -> Option<String> {
|
||||
opt.map(|s| strip_html_tags(&s))
|
||||
}
|
||||
|
||||
// 在每个 DTO 的 impl 中添加 sanitize 方法
|
||||
impl CreatePatientReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.name = strip_html_tags(&self.name);
|
||||
self.notes = sanitize_option_string(self.notes.take());
|
||||
self.allergy_history = sanitize_option_string(self.allergy_history.take());
|
||||
self.medical_history_summary = sanitize_option_string(self.medical_history_summary.take());
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// 在 handler 调用 service 前执行
|
||||
async fn create_patient(/* ... */) -> AppResult<Json<ApiResponse<PatientResp>>> {
|
||||
let mut req: CreatePatientReq = Json(req).0;
|
||||
req.sanitize();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**前端安全**: ChatBubble 组件必须使用 React 默认 JSX 转义渲染文本内容(不使用 `dangerouslySetInnerHTML`),图片消息 URL 需做白名单校验。
|
||||
|
||||
### 1.2 审计日志注入
|
||||
|
||||
**问题**: erp-health 整个模块没有任何对 `audit_service::record` 的调用。
|
||||
|
||||
**参考实现**: `crates/erp-auth/src/service/auth_service.rs` 第 168-177 行。
|
||||
|
||||
**修复方案**: 在所有写入操作的 service 层添加审计记录。
|
||||
|
||||
**覆盖操作清单**:
|
||||
|
||||
| Service | 操作 | 审计 action |
|
||||
|---------|------|------------|
|
||||
| patient_service | create_patient | `patient.created` |
|
||||
| patient_service | update_patient | `patient.updated` |
|
||||
| patient_service | delete_patient | `patient.deleted` |
|
||||
| patient_service | manage_patient_tags | `patient.tags_updated` |
|
||||
| health_data_service | create_vital_signs | `vital_signs.created` |
|
||||
| health_data_service | create_lab_report | `lab_report.created` |
|
||||
| health_data_service | create_health_record | `health_record.created` |
|
||||
| appointment_service | create_appointment | `appointment.created` |
|
||||
| appointment_service | update_appointment_status | `appointment.status_changed` |
|
||||
| follow_up_service | create_task | `follow_up_task.created` |
|
||||
| follow_up_service | create_record | `follow_up_record.created` |
|
||||
| consultation_service | create_session | `consultation.opened` |
|
||||
| consultation_service | close_session | `consultation.closed` |
|
||||
| consultation_service | create_message | `consultation.message_sent` |
|
||||
| doctor_service | create/update/delete_doctor | `doctor.*` |
|
||||
|
||||
**审计日志内容**: tenant_id、user_id、action、resource_type、resource_id、变更前后值摘要。
|
||||
|
||||
**注意**: 当前 `audit_service::record` 是 fire-and-forget,审计日志丢失对医疗合规不可接受。修复方案:
|
||||
1. 新增 `record_in_txn(log: AuditLog, txn: &DatabaseTransaction)` 方法,在事务内 await 写入
|
||||
2. 保留原 `record` 方法用于不要求事务保证的场景
|
||||
3. erp-health 的关键写入操作使用 `record_in_txn`,失败时回滚整个事务
|
||||
4. 需要改为事务包裹的 service 方法:create_patient、update_patient、delete_patient、create_appointment、update_appointment_status、create_record(随访)、create_message(咨询)
|
||||
|
||||
### 1.3 身份证号加密存储
|
||||
|
||||
**问题**: `patient.id_number` 明文存储在数据库中,违反《个人信息保护法》。
|
||||
|
||||
**方案**: AES-256-GCM 应用层加密。
|
||||
|
||||
**新增文件**: `crates/erp-health/src/crypto.rs`
|
||||
|
||||
```rust
|
||||
pub struct HealthCrypto { key: [u8; 32] }
|
||||
|
||||
impl HealthCrypto {
|
||||
pub fn from_env() -> Self { /* 从 ERP__HEALTH__ENCRYPTION_KEY 读取 */ }
|
||||
pub fn encrypt(&self, plaintext: &str) -> AppResult<String> { /* AES-256-GCM + Base64 */ }
|
||||
pub fn decrypt(&self, ciphertext: &str) -> AppResult<String> { /* 解密 */ }
|
||||
}
|
||||
```
|
||||
|
||||
**集成点**:
|
||||
- `patient_service::create_patient` — 加密 id_number 后存储
|
||||
- `patient_service::update_patient` — 同上
|
||||
- `patient_service::get_patient` — 解密后返回
|
||||
- `patient_service::list_patients` — 列表不返回 id_number(脱敏)
|
||||
|
||||
**密钥管理**: 环境变量 `ERP__HEALTH__ENCRYPTION_KEY`(32 字节 hex),必须在 `default.toml` 中标记为 `__MUST_SET_VIA_ENV__`。
|
||||
|
||||
**搜索兼容**: `patient.id_number` 的模糊搜索(`contains`)改为精确匹配(`eq`),在加密后使用 HMAC 索引做等值查询。
|
||||
|
||||
**HMAC 索引详情**:
|
||||
- 新增数据库列 `id_number_hash VARCHAR(64)`,存储 HMAC-SHA256 哈希
|
||||
- HMAC 密钥独立于 AES 密钥,从环境变量 `ERP__HEALTH__HMAC_KEY` 读取
|
||||
- 创建/更新患者时同时写入 hash 列,等值查询使用 `WHERE id_number_hash = hmac(输入值)`
|
||||
- 迁移 SQL:新增列 → 批量加密现有明文 → 删除原明文列(可选)
|
||||
|
||||
**数据迁移方案**:
|
||||
1. 停机窗口(预估 1-2 小时,视数据量)
|
||||
2. 迁移脚本:`SELECT id, id_number FROM patients WHERE id_number IS NOT NULL AND deleted_at IS NULL` → 批量加密 → `UPDATE patients SET id_number = $encrypted WHERE id = $id`
|
||||
3. 同步写入 `id_number_hash` 列
|
||||
4. 验证脚本:抽样解密比对原值
|
||||
5. 回滚方案:保留明文备份表 `patients_id_number_backup`,72 小时后确认无误再删除
|
||||
|
||||
**问题**: 列表接口直接返回完整身份证号、病史等敏感字段。
|
||||
|
||||
**修复方案**: 拆分响应 DTO。
|
||||
|
||||
```rust
|
||||
// 列表用 — 不含敏感字段
|
||||
pub struct PatientListResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub gender: Option<String>,
|
||||
pub birth_date: Option<NaiveDate>,
|
||||
pub status: String,
|
||||
pub tags: Vec<TagResp>,
|
||||
// 无 id_number, allergy_history, medical_history_summary, emergency_contact_phone 等
|
||||
}
|
||||
|
||||
// 详情用 — 敏感字段掩码
|
||||
pub struct PatientDetailResp {
|
||||
// ... 全部字段
|
||||
pub id_number: Option<String>, // "320***********1234"
|
||||
pub emergency_contact_phone: Option<String>, // "138****1234"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 后端补完(阶段 2,1.5 周)
|
||||
|
||||
### 2.1 事件处理器实现
|
||||
|
||||
**问题**: `event.rs` 中两个事件处理器只有 `tracing::info`,无实际业务逻辑。且 handler 中没有 `DatabaseConnection`,无法执行数据库操作。
|
||||
|
||||
**方案**: 在 `HealthModule::on_startup` 中创建 `HealthState` 并注册需要数据库访问的事件处理器。将现有 `register_event_handlers` 中的空壳代码迁移到 `on_startup`,`register_event_handlers` 改为空实现。
|
||||
|
||||
**修改 `crates/erp-health/src/module.rs`**:
|
||||
|
||||
```rust
|
||||
// register_event_handlers 改为空实现
|
||||
fn register_event_handlers(&self, _bus: &EventBus) {
|
||||
// 事件处理器迁移到 on_startup,此处不再注册
|
||||
}
|
||||
|
||||
// on_startup 中注册带 db 的事件处理器
|
||||
async fn on_startup(&self, ctx: &ModuleContext) -> AppResult<()> {
|
||||
let state = HealthState {
|
||||
db: ctx.db.clone(),
|
||||
event_bus: ctx.event_bus.clone(),
|
||||
};
|
||||
crate::event::register_handlers_with_state(state);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**修改 `crates/erp-health/src/event.rs`**:
|
||||
|
||||
新增 `register_handlers_with_state(state: HealthState)` 函数替代原有 `register_handlers`。
|
||||
|
||||
**事件处理器业务逻辑**:
|
||||
|
||||
`workflow.task.completed`:
|
||||
1. 从 payload 中提取 `task_id`
|
||||
2. 查询 `follow_up_task WHERE related_appointment_id` 或通过 payload 映射
|
||||
3. 更新随访任务状态为 `completed`
|
||||
|
||||
`message.sent`:
|
||||
1. 从 payload 中提取 `session_id`(或通过 sender/recipient 关联)
|
||||
2. 更新 `consultation_session SET last_message_at = NOW(), unread_count = unread_count + 1`
|
||||
3. 使用 `check_version` 乐观锁
|
||||
|
||||
### 2.2 数据一致性修复
|
||||
|
||||
#### 2.2.1 排班名额保护
|
||||
|
||||
**问题**: `update_schedule` 可以将 `max_appointments` 改为小于 `current_appointments` 的值。
|
||||
|
||||
**修复**: 在 `appointment_service.rs` 的 `update_schedule` 方法中增加校验:
|
||||
|
||||
```rust
|
||||
if req.max_appointments < model.current_appointments {
|
||||
return Err(HealthError::Validation(
|
||||
"max_appointments 不能小于当前已预约数".into()
|
||||
).into());
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2.2 取消预约名额释放
|
||||
|
||||
**问题**: `update_appointment_status` 中取消时名额释放失败只 log error 不回滚。
|
||||
|
||||
**修复**: 将名额释放作为事务的一部分,失败时回滚整个操作(包括状态更新)。
|
||||
|
||||
#### 2.2.3 咨询消息原子性
|
||||
|
||||
**问题**: `create_message` 中消息已插入,但后续 CAS 更新 session 失败时返回错误 — 消息已持久化但 session 元数据未更新。
|
||||
|
||||
**修复**: 将消息 INSERT + session CAS 更新放在同一个事务中。
|
||||
|
||||
### 2.3 随访逾期定时任务
|
||||
|
||||
**问题**: 设计规格定义了 `overdue` 状态和定时任务自动标记,但代码中:
|
||||
- `validation.rs` 不允许转换到 `overdue`
|
||||
- 没有后台定时任务
|
||||
|
||||
**修复**:
|
||||
|
||||
1. 在 `validation.rs` 中添加 `overdue` 转换规则:`pending -> overdue`(仅限系统自动触发)
|
||||
2. 在 `erp-server/src/main.rs` 后台任务区增加逾期检查器,使用与现有 `start_timeout_checker` 一致的 `tokio::spawn` + `loop` + `tokio::time::interval` 模式(每 6 小时执行一次,非 cron 表达式):
|
||||
|
||||
```rust
|
||||
// erp-server/src/main.rs 后台任务区
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(6 * 3600));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
// 调用 health module 的 check_overdue_tasks
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
3. 在 `erp-health` module 中添加一个公开方法 `check_overdue_tasks` 供定时任务调用。
|
||||
|
||||
### 2.4 article 管理 CRUD
|
||||
|
||||
**问题**: 权限声明中有 `health.articles.manage`,但 service/handler 只有 list 和 get。
|
||||
|
||||
**修复**: 在 `article_service.rs` 和 `article_handler.rs` 中补充 create/update/delete 方法。在 `module.rs` 中添加路由。**工时估算**: 0.5 天。
|
||||
|
||||
---
|
||||
|
||||
## 3. Web 前端 10 页面(阶段 3,3.5-4 周)
|
||||
|
||||
### 3.1 页面文件组织
|
||||
|
||||
```
|
||||
apps/web/src/
|
||||
├── api/health/
|
||||
│ ├── patients.ts # 12 端点
|
||||
│ ├── healthData.ts # 13 端点
|
||||
│ ├── appointments.ts # 6 端点
|
||||
│ ├── followUp.ts # 6 端点
|
||||
│ ├── consultations.ts # 6 端点
|
||||
│ └── doctors.ts # 4 端点
|
||||
├── pages/health/
|
||||
│ ├── PatientList.tsx # 患者列表
|
||||
│ ├── PatientDetail.tsx # 患者详情(5 Tab)
|
||||
│ ├── PatientTagManage.tsx # 标签管理
|
||||
│ ├── DoctorList.tsx # 医护列表
|
||||
│ ├── AppointmentList.tsx # 预约管理
|
||||
│ ├── DoctorSchedule.tsx # 排班管理
|
||||
│ ├── FollowUpTaskList.tsx # 随访任务
|
||||
│ ├── FollowUpRecordList.tsx # 随访台账
|
||||
│ ├── ConsultationList.tsx # 会话管理
|
||||
│ ├── ConsultationDetail.tsx # 对话详情
|
||||
│ └── components/
|
||||
│ ├── StatusTag.tsx # 通用状态标签
|
||||
│ ├── PatientSelect.tsx # 患者搜索选择器
|
||||
│ ├── DoctorSelect.tsx # 医护选择器
|
||||
│ ├── VitalSignsChart.tsx # ECharts 趋势图
|
||||
│ ├── CalendarView.tsx # 日历视图
|
||||
│ ├── ChatBubble.tsx # 聊天气泡
|
||||
│ ├── ImagePreview.tsx # 图片预览
|
||||
│ └── ExportButton.tsx # 导出按钮
|
||||
```
|
||||
|
||||
### 3.2 API 服务层设计
|
||||
|
||||
每个 service 文件遵循现有 `api/users.ts` 的解构模式:
|
||||
|
||||
```typescript
|
||||
// api/health/patients.ts
|
||||
import client from '../client';
|
||||
|
||||
export interface Patient {
|
||||
id: string;
|
||||
name: string;
|
||||
gender?: string;
|
||||
birth_date?: string;
|
||||
status: string;
|
||||
tags: Tag[];
|
||||
// ...
|
||||
}
|
||||
|
||||
export interface CreatePatientReq {
|
||||
name: string;
|
||||
gender?: string;
|
||||
// ...
|
||||
}
|
||||
|
||||
export const patientApi = {
|
||||
list: async (params: ListParams) => {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<Patient> }>(
|
||||
'/health/patients', { params }
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
get: async (id: string) => {
|
||||
const { data } = await client.get<{ success: boolean; data: Patient }>(
|
||||
`/health/patients/${id}`
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
create: async (req: CreatePatientReq) => {
|
||||
const { data } = await client.post<{ success: boolean; data: Patient }>(
|
||||
'/health/patients', req
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 3.3 路由注册
|
||||
|
||||
在 `App.tsx` 中新增:
|
||||
|
||||
```typescript
|
||||
// lazy imports
|
||||
const PatientList = lazy(() => import('./pages/health/PatientList'));
|
||||
const PatientDetail = lazy(() => import('./pages/health/PatientDetail'));
|
||||
// ... 共 10 个路由组件
|
||||
|
||||
// Routes 内
|
||||
<Route path="/health/patients" element={<PatientList />} />
|
||||
<Route path="/health/patients/:id" element={<PatientDetail />} />
|
||||
<Route path="/health/tags" element={<PatientTagManage />} />
|
||||
<Route path="/health/doctors" element={<DoctorList />} />
|
||||
<Route path="/health/appointments" element={<AppointmentList />} />
|
||||
<Route path="/health/schedules" element={<DoctorSchedule />} />
|
||||
<Route path="/health/follow-up-tasks" element={<FollowUpTaskList />} />
|
||||
<Route path="/health/follow-up-records" element={<FollowUpRecordList />} />
|
||||
<Route path="/health/consultations" element={<ConsultationList />} />
|
||||
<Route path="/health/consultations/:id" element={<ConsultationDetail />} />
|
||||
```
|
||||
|
||||
### 3.4 侧边栏菜单
|
||||
|
||||
在 `MainLayout.tsx` 中新增 `healthMenuItems` 数组(参照现有 `bizMenuItems` 模式),使用 `@ant-design/icons` 图标(如 `MedicineBoxOutlined`、`HeartOutlined`、`CalendarOutlined`、`PhoneOutlined`、`CommentOutlined`、`TagsOutlined`):
|
||||
|
||||
```
|
||||
侧边栏布局:
|
||||
├── 首页 (HomeOutlined)
|
||||
├── 用户管理 (UserOutlined)
|
||||
├── 权限管理 (SafetyOutlined)
|
||||
├── 工作流 (ApartmentOutlined)
|
||||
├── 消息中心 (BellOutlined)
|
||||
├── ─────────
|
||||
├── 健康管理 (MedicineBoxOutlined) ← 新增组
|
||||
│ ├── 患者管理 (TeamOutlined)
|
||||
│ ├── 医护管理 (HeartOutlined)
|
||||
│ ├── 预约排班 (CalendarOutlined)
|
||||
│ ├── 随访管理 (PhoneOutlined)
|
||||
│ ├── 咨询管理 (CommentOutlined)
|
||||
│ └── 标签管理 (TagsOutlined)
|
||||
├── ─────────
|
||||
├── 插件管理 (AppstoreOutlined)
|
||||
├── 系统设置 (SettingOutlined)
|
||||
```
|
||||
|
||||
### 3.5 前端权限集成
|
||||
|
||||
后端已有完整权限体系(14 个权限码),前端 V1 阶段采用以下策略:
|
||||
|
||||
1. **路由级权限**: 所有健康模块路由在 `PrivateRoute` 内(已实现),后端 `require_permission` 拦截无权限请求返回 403
|
||||
2. **按钮级权限(V1 简化)**: 不做前端按钮级权限控制,依赖后端 403 响应。后续可扩展 `usePermission` hook
|
||||
3. **菜单可见性**: 健康模块菜单组始终显示,但无权限用户点击任何页面会收到 403 提示
|
||||
|
||||
### 3.5 13 页面逐一设计
|
||||
|
||||
#### PatientList.tsx(中复杂度,1.5 天)
|
||||
|
||||
- Ant Design `Table` 组件(与 Users.tsx 模式一致,不使用 ProTable)
|
||||
- 搜索:姓名模糊 + 状态筛选 + 标签多选筛选
|
||||
- 每行显示患者标签为 `Tag` 组件列表
|
||||
- 行点击跳转 `/health/patients/:id`
|
||||
- 批量操作:批量打标
|
||||
- 导出功能
|
||||
|
||||
#### PatientDetail.tsx(高复杂度,3 天)
|
||||
|
||||
- 顶部:患者摘要卡片(姓名/性别/年龄/状态/标签)
|
||||
- Ant Design `Tabs` 5 个 Tab:
|
||||
1. **基本信息** — `Descriptions` 展示 + 编辑 Modal
|
||||
2. **健康趋势** — `VitalSignsChart` 组件 + 时间范围选择器
|
||||
3. **化验报告** — 报告卡片列表 + `ImagePreview` 指标详情
|
||||
4. **就诊记录** — 嵌套列表(体检/门诊/住院)
|
||||
5. **随访记录** — 嵌套列表 + 关联的随访记录
|
||||
|
||||
#### PatientTagManage.tsx(低复杂度,0.5 天)
|
||||
|
||||
- 标准 CRUD 表格
|
||||
- 颜色选择器(Ant Design `ColorPicker`)
|
||||
- 批量打标功能
|
||||
|
||||
#### DoctorList.tsx(低复杂度,0.5 天)
|
||||
|
||||
- 标准 CRUD 表格
|
||||
- 科室筛选 + 在线状态 Badge(online=绿/busy=黄/offline=灰)
|
||||
- 详情 Drawer
|
||||
|
||||
#### AppointmentList.tsx(中复杂度,2 天)
|
||||
|
||||
- `Segmented` 切换列表/日历视图
|
||||
- 列表模式:表格 + 状态筛选 + 日期筛选
|
||||
- 日历模式:`Calendar` + `cellRender` 显示当日预约数
|
||||
- 状态流转 Dropdown(pending → confirmed → completed/no_show/cancelled)
|
||||
- 创建预约 Modal(选择患者 + 医生 + 日期时段 + 检查排班余量)
|
||||
|
||||
#### DoctorSchedule.tsx(高复杂度,2.5 天)
|
||||
|
||||
- 选择医生后展示其排班
|
||||
- 周视图(自定义 7 列网格,每列显示一天的排班时段)
|
||||
- 月视图(Ant Design Calendar)
|
||||
- 批量创建排班(选择日期范围 + 时段模板)
|
||||
- 显示已预约/最大预约数
|
||||
|
||||
#### FollowUpTaskList.tsx(中复杂度,1.5 天)
|
||||
|
||||
- 表格 + 状态筛选(pending/in_progress/completed/overdue/cancelled)
|
||||
- 分配给医护(`DoctorSelect`)
|
||||
- 创建任务 Modal
|
||||
- 快捷"填写随访记录"按钮打开子 Modal
|
||||
|
||||
#### FollowUpRecordList.tsx(低复杂度,0.5 天)
|
||||
|
||||
- 纯只读台账
|
||||
- 筛选:日期范围、患者、任务、结果
|
||||
- 导出功能(`ExportButton`)
|
||||
|
||||
#### ConsultationList.tsx(中复杂度,1 天)
|
||||
|
||||
- 表格 + 状态筛选(waiting/active/closed)
|
||||
- 未读消息数 Badge
|
||||
- 最后消息时间
|
||||
- 关闭会话操作
|
||||
- 点击跳转 `/health/consultations/:id`
|
||||
|
||||
#### ConsultationDetail.tsx(高复杂度,2 天)
|
||||
|
||||
- `ChatBubble` 组件渲染聊天气泡
|
||||
- 根据 `sender_role` 区分左右对齐
|
||||
- 支持内容类型:text / image(`ImagePreview`)/ voice / file
|
||||
- 消息按时间排列,支持滚动加载更多(分页)
|
||||
- 导出按钮
|
||||
|
||||
### 3.6 技术难点方案
|
||||
|
||||
#### ECharts 趋势图
|
||||
|
||||
使用已安装的 `@ant-design/charts` 的 `Line` 组件。
|
||||
|
||||
- 后端 API `/patients/:id/trends/:indicator` 返回时序数据
|
||||
- 前端转换为 `{ date: string, value: number }[]`
|
||||
- 支持多指标叠加(血压收缩压/舒张压双线)
|
||||
- 封装为 `VitalSignsChart`,接收 `patientId` + `indicators` 参数
|
||||
- 时间范围选择器(7天/30天/90天)
|
||||
|
||||
#### 日历视图
|
||||
|
||||
Ant Design `Calendar` + 自定义 `cellRender`:
|
||||
- DoctorSchedule:每个日期格显示排班时段标签
|
||||
- AppointmentList:每个日期格显示预约数量气泡
|
||||
|
||||
#### 聊天 UI
|
||||
|
||||
自定义 `ChatBubble` 组件,基于 Ant Design `Typography.Paragraph` + `Avatar`:
|
||||
- 根据 `sender_role` 区分样式
|
||||
- 只读模式(PC 后台只查看不发送)
|
||||
- 图片消息使用 `Image.PreviewGroup`
|
||||
|
||||
#### 导出
|
||||
|
||||
后端 blob 导出 + 前端触发下载,参照 `PluginCRUDPage` 中已有的 `exportPluginDataAsBlob` 模式。
|
||||
|
||||
#### 文件上传/预览
|
||||
|
||||
- 上传:Ant Design `Upload.Dragger`,上传到后端文件接口
|
||||
- 图片预览:Ant Design `Image.PreviewGroup`
|
||||
- PDF 预览:新窗口打开(V1 简化方案)
|
||||
|
||||
### 3.7 开发顺序
|
||||
|
||||
| Phase | 内容 | 天数 | 依赖 |
|
||||
|-------|------|------|------|
|
||||
| 1 | API 层 6 文件 + 通用组件 + 路由菜单 | 1.5 | 无 |
|
||||
| 2 | PatientList + PatientTagManage + PatientDetail 基本信息Tab | 2 | Phase 1 |
|
||||
| 3 | VitalSignsChart + 健康趋势 Tab + LabReportList + HealthRecordList | 3 | Phase 2 |
|
||||
| 4 | DoctorList + AppointmentList + DoctorSchedule | 3 | Phase 1 |
|
||||
| 5 | FollowUpTaskList + FollowUpRecordList + ConsultationList + ConsultationDetail | 3 | Phase 1 |
|
||||
| 6 | 打磨(暗色主题 + 响应式 + 联调) | 1 | Phase 2-5 |
|
||||
| **合计** | | **13.5 天** | |
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试策略(阶段 2-3 交叉进行)
|
||||
|
||||
### 4.1 优先级排序
|
||||
|
||||
| 优先级 | 测试目标 | 预估用例数 | 工作量 |
|
||||
|--------|---------|-----------|--------|
|
||||
| P0 | `validation.rs` 纯函数 | 20-30 | 1 天 |
|
||||
| P0 | `appointment_service` CAS + 状态流转 | 15-20 | 2 天 |
|
||||
| P0 | `patient_service` CRUD + 状态机 | 15-20 | 2 天 |
|
||||
| P1 | `consultation_service` 消息原子性 | 10-15 | 2 天 |
|
||||
| P1 | `health_data_service` 指标数据 | 10-15 | 1 天 |
|
||||
| P2 | `follow_up_service` 链式任务 | 10 | 1 天 |
|
||||
|
||||
### 4.2 测试基础设施
|
||||
|
||||
在 `erp-health/Cargo.toml` 中添加 `[dev-dependencies]`:
|
||||
- `tokio` 的 `test` 和 `macros` feature
|
||||
- `sea-orm` 的 `mock` feature(用于简单单元测试,如 validation 纯函数)
|
||||
|
||||
对于涉及事务和 CAS 的集成测试(预约并发、消息原子性),使用 testcontainers-postgreSQL 做真实数据库测试,因为 SeaORM 的 `MockDatabaseConnection` 不支持复杂事务模拟。
|
||||
|
||||
创建 `tests/test_helpers.rs` 提供:
|
||||
- `create_test_health_state()` — 带 mock db 的 HealthState(单元测试用)
|
||||
- `create_integration_db()` — testcontainers PostgreSQL 实例(集成测试用)
|
||||
- 共享 fixture 工厂
|
||||
|
||||
### 4.3 关键测试场景
|
||||
|
||||
**预约 CAS 并发**:
|
||||
- 排班已满 → 创建预约失败
|
||||
- 排班有余 → CAS 成功 + 名额减 1
|
||||
- 并发创建 → 只有 max_appointments 个成功
|
||||
|
||||
**状态机转换**:
|
||||
- 合法转换:pending → confirmed → completed
|
||||
- 非法转换:completed → pending → 拒绝
|
||||
- 取消:任意状态 → cancelled(填 cancel_reason)
|
||||
|
||||
**随访链式任务**:
|
||||
- next_follow_up_date 不为空 → 自动创建新任务
|
||||
- 新任务的 assigned_to 沿用当前医护
|
||||
- next_follow_up_date 为空 → 不创建新任务
|
||||
|
||||
---
|
||||
|
||||
## 5. 实施路线图
|
||||
|
||||
### 5.1 总时间线(调整为 7 周)
|
||||
|
||||
```
|
||||
Week 1-2 | 安全地基(1.5-2 周)
|
||||
| ├── sanitize 全覆盖(2 天)
|
||||
| ├── 审计日志注入(2 天)
|
||||
| ├── 身份证号加密 + HMAC 索引 + 数据迁移(3-4 天)
|
||||
| └── 字段级脱敏(1-2 天)
|
||||
|
||||
Week 2-4 | 后端补完 + 测试(1.5-2 周)
|
||||
| ├── 事件处理器实现(2 天)
|
||||
| ├── 数据一致性修复(2 天)
|
||||
| ├── 随访逾期定时任务(1 天)
|
||||
| ├── article CRUD(0.5 天)
|
||||
| └── 核心路径测试(5-6 天)
|
||||
|
||||
Week 4-7 | Web 前端(3.5-4 周)
|
||||
| ├── Phase 1: API 层 + 通用组件 + 路由菜单(1.5 天)
|
||||
| ├── Phase 2: 核心入口页面(2 天)
|
||||
| ├── Phase 3: 健康数据页面(3 天)
|
||||
| ├── Phase 4: 预约排班页面(3 天)
|
||||
| ├── Phase 5: 随访咨询页面(3 天)
|
||||
| └── Phase 6: 打磨联调(1 天)
|
||||
|
||||
Week 7-8 | 端到端验证(1 周)
|
||||
| ├── 小程序联调
|
||||
| ├── 种子数据填充
|
||||
| ├── Docker 演示环境
|
||||
| └── 文档更新
|
||||
```
|
||||
|
||||
### 5.2 里程碑
|
||||
|
||||
| 里程碑 | 交付物 | 验收标准 |
|
||||
|--------|--------|---------|
|
||||
| M1 | 安全省基完成 | sanitize + 审计 + 加密 + 脱敏全部到位,cargo test 通过 |
|
||||
| M2 | 后端功能完整 | 事件处理器 + 数据一致性 + 测试覆盖,cargo test 通过 |
|
||||
| M3 | Web 3 核心页面 | PatientList + AppointmentList + DoctorSchedule 可操作 |
|
||||
| M4 | Web 10 页面完成 | 所有页面功能可用,pnpm build 通过 |
|
||||
| M5 | 端到端验证 | Web + 小程序 + 后端全链路可演示 |
|
||||
|
||||
### 5.3 风险和缓解
|
||||
|
||||
| 风险 | 概率 | 缓解 |
|
||||
|------|------|------|
|
||||
| ECharts 集成复杂度高 | 中 | 使用 @ant-design/charts 已安装,降低自研成本 |
|
||||
| 身份证加密影响现有查询 | 中 | HMAC 索引 + 数据迁移脚本 + 备份表 + 回滚方案 |
|
||||
| 10 页面开发时间超预期 | 高 | 按优先级裁剪,MVP 先做 3 核心页面 |
|
||||
| 文件上传能力未就绪 | 中 | V1 先支持 URL 存储,文件上传推迟到 V1.1 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 不在本设计范围内(推迟到 V2)
|
||||
|
||||
- 积分商城
|
||||
- 数据统计中心 / 运营驾驶舱
|
||||
- AI 辅助诊断/报告解读
|
||||
- 实时 WebSocket 在线咨询
|
||||
- 咨询消息按月分区
|
||||
- 事件幂等性(processed_events 去重表)
|
||||
- Polling Outbox 重试机制
|
||||
- HealthState 扩展 Redis 缓存
|
||||
- 国际化(英文等多语言)
|
||||
- 小程序医护端
|
||||
@@ -1,469 +0,0 @@
|
||||
# HMS 患者小程序迭代设计规格
|
||||
|
||||
> **版本**: v1.0
|
||||
> **日期**: 2026-04-24
|
||||
> **状态**: 草案
|
||||
> **关联**: 小程序初版设计 `2026-04-23-hms-miniprogram-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
小程序初版已完成 21 个页面、7 个 API service 的基础实现,覆盖登录、健康数据、预约挂号、检验报告、随访管理、用药提醒、健康资讯、个人中心。当前处于**开发阶段**,工程质量和用户体验存在明显短板,距测试阶段尚有差距。
|
||||
|
||||
### 1.2 问题全景
|
||||
|
||||
| 优先级 | 问题 | 影响 |
|
||||
|--------|------|------|
|
||||
| P0 | 大量重复代码(profile/reports ≈ report/index, profile/followups ≈ followup/index) | 维护成本翻倍 |
|
||||
| P0 | 预约详情通过 Storage 缓存传递而非 API 获取 | 数据不一致 |
|
||||
| P0 | EmptyState 导入方式不一致导致运行时报错 | 页面崩溃 |
|
||||
| P0 | 手机号绑定后端硬编码 `"13800000000"` | 无法上线 |
|
||||
| P0 | `getTodaySummary()` 调用的后端端点不存在 | 首页/健康页数据无法加载 |
|
||||
| P1 | ErrorState 组件定义但未使用 | 错误处理不统一 |
|
||||
| P1 | mixins.scss 定义但未使用 | 样式重复内联 |
|
||||
| P1 | 无全局错误边界 | 页面崩溃无兜底 |
|
||||
| P1 | tryRefreshToken 静默吞异常 | 调试困难 |
|
||||
| P1 | 趋势图缓存永不过期 | 数据过时 |
|
||||
| P1 | 随访详情获取低效(listTasks().find()) | 性能浪费 |
|
||||
| P1 | 首页/健康页缺少 loading 状态 | 体验空白 |
|
||||
| P1 | 用药提醒纯本地 Storage | 换设备即丢失(后续版本解决) |
|
||||
| P2 | 路径别名 @/* 未使用 | 代码可读性差 |
|
||||
| P2 | 无 schema 验证库 | 表单验证脆弱 |
|
||||
| P2 | 趋势图纯 CSS 柱状图 | 无交互能力 |
|
||||
| P2 | 用药提醒时间选择器未实现 | 功能不完整 |
|
||||
| P2 | 无日志/埋点/上报 | 无法追踪问题 |
|
||||
|
||||
### 1.3 迭代策略:混合策略
|
||||
|
||||
采用**先基建再模块**的混合策略,分 4 个 Sprint 交付:
|
||||
|
||||
```
|
||||
Sprint 0 (2-3天) Sprint 1 (3-4天) Sprint 2 (3-4天) Sprint 3 (4-5天)
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ 工程基础修复 │ → │ 健康数据打磨 │ → │ 预约+通知 │ → │ 报告/随访/ │
|
||||
│ │ │ │ │ │ │ 安全+增长 │
|
||||
│ · 消除重复代码│ │ · ECharts图表│ │ · 步骤指示器 │ │ · 指标卡片 │
|
||||
│ · 统一错误处理│ │ · 缓存TTL │ │ · 周视图日历 │ │ · Token加密 │
|
||||
│ · 修复数据传递│ │ · zod验证 │ │ · 订阅消息 │ │ · 手机号解密 │
|
||||
│ · 统一Loading │ │ · 状态色卡片 │ │ · 时段可视化 │ │ · 埋点+分享 │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
**原则**:Sprint 0 铺路,后续每个 Sprint 都受益于基础设施改善。Sprint 0 只修不建,不引入新依赖。
|
||||
|
||||
---
|
||||
|
||||
## 2. Sprint 0:工程基础修复
|
||||
|
||||
**目标**:消除最痛的工程问题,为后续所有 Sprint 铺路。约束 2-3 天完成。
|
||||
|
||||
### 2.1 修复阻断性 API 端点缺失
|
||||
|
||||
**现状**:前端 `services/health.ts` 的 `getTodaySummary()` 调用 `GET /health/vital-signs?date=today`,但后端路由中**不存在此端点**。后端仅有 `GET /health/patients/{id}/vital-signs`(需 patient_id 路径参数)。这意味着首页"今日健康"卡片和健康页的数据从一开始就**无法加载**。
|
||||
|
||||
**方案**:
|
||||
|
||||
- 后端在 `erp-health` 新增小程序专用端点 `GET /health/vital-signs/today`,通过 JWT `user_id` 自动关联 patient(类似已有的 `GET /health/vital-signs/trend` 模式)
|
||||
- 前端 `services/health.ts` 的 `getTodaySummary()` 调整为调用新端点
|
||||
- 此项为 **Sprint 0 最高优先级**,阻塞首页和健康页基本功能
|
||||
|
||||
**涉及文件**:
|
||||
- 后端新增:`erp-health` handler + 路由注册
|
||||
- 修改:`services/health.ts`
|
||||
|
||||
### 2.2 消除重复页面
|
||||
|
||||
**现状**:`pages/report/index` 与 `pages/profile/reports/index` 几乎完全重复,`pages/followup/index` 与 `pages/profile/followups/index` 同理。且 `report/index` 和 `followup/index` 没有明确的导航入口。
|
||||
|
||||
**方案**:
|
||||
|
||||
1. 删除 `pages/report/index` 和 `pages/followup/index` 及其 SCSS 文件
|
||||
2. 从 `app.config.ts` 移除对应路由注册
|
||||
3. 首页快捷入口和 profile 菜单统一指向 `profile/reports` 和 `profile/followups`
|
||||
4. 如果后续需要独立入口,则抽取共享组件 `components/ReportList` 和 `components/FollowupList`,两个页面只做薄壳路由
|
||||
|
||||
**涉及文件**:
|
||||
- 删除:`pages/report/index.tsx`、`pages/report/index.scss`
|
||||
- 删除:`pages/followup/index.tsx`、`pages/followup/index.scss`
|
||||
- 修改:`app.config.ts`(移除路由)
|
||||
- 修改:`pages/index/index.tsx`(快捷入口路径)
|
||||
|
||||
### 2.2 统一错误处理
|
||||
|
||||
**现状**:`ErrorState` 组件已定义但未被任何页面使用,各页面内联 `showToast` 错误提示。无全局错误边界。
|
||||
|
||||
**方案**:
|
||||
|
||||
1. 所有列表页、详情页统一使用 `ErrorState` 组件,替换内联错误提示
|
||||
2. 在 `app.tsx` 添加 React Error Boundary 组件,兜底页面崩溃
|
||||
3. 新建 `components/ErrorBoundary/index.tsx`
|
||||
4. 修复 `tryRefreshToken` 的 catch 块,添加 `console.error` 日志
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`components/ErrorBoundary/index.tsx`
|
||||
- 修改:`app.tsx`(包裹 ErrorBoundary)
|
||||
- 修改:所有列表页和详情页(替换内联错误处理为 ErrorState)
|
||||
- 修改:`services/request.ts`(tryRefreshToken 日志)
|
||||
|
||||
### 2.3 修复数据传递问题
|
||||
|
||||
**预约详情**:
|
||||
- 移除 `appointment_detail_cache` Storage 传递
|
||||
- 改为进入页面时通过 `GET /health/appointments/:id` 获取数据
|
||||
- **后端需新增此端点**(当前仅有列表 `GET`、创建 `POST`、状态更新 `PUT`,缺少单条查询 `GET`)
|
||||
|
||||
**随访详情**:
|
||||
- 后端**需新增** `GET /health/follow-up-tasks/:id` 单条查询端点(当前 `{id}` 路由仅注册了 `PUT` 和 `DELETE`,缺少 `GET`)
|
||||
- 前端替换 `listTasks().find()` 为直接按 ID 查询
|
||||
|
||||
> **注意**:以上后端新增端点为 Sprint 0 前置阻塞项。如果后端资源有限,前端先做"调用端点"的准备代码,后端并行实现。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/appointment/detail/index.tsx`
|
||||
- 修改:`services/appointment.ts`(新增 getDetail 方法)
|
||||
- 修改:`services/followup.ts`(新增 getTaskDetail 方法)
|
||||
- 后端新增:`erp-health` 预约单条查询 + 随访单条查询端点
|
||||
|
||||
### 2.4 统一 Loading 状态
|
||||
|
||||
**现状**:首页和健康页的 `loading` 状态已在 store 中定义但未在 UI 层消费。详情页使用内联 `<Text>加载中...</Text>`。
|
||||
|
||||
**方案**:
|
||||
|
||||
1. 首页和健康页在数据加载时展示 `Loading` 组件
|
||||
2. 所有详情页统一使用 `Loading` 组件替换内联文字
|
||||
3. 预约创建页三步骤切换时也展示 loading
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/index/index.tsx`(消费 loading 状态)
|
||||
- 修改:`pages/health/index.tsx`(消费 loading 状态)
|
||||
- 修改:所有详情页 tsx(替换内联加载文字)
|
||||
|
||||
### 2.5 杂项修复
|
||||
|
||||
| 项目 | 方案 |
|
||||
|------|------|
|
||||
| EmptyState 导入 bug | 首页 `import { EmptyState }` 改为 `import EmptyState`(默认导入) |
|
||||
| 路径别名启用 | `services/` 和 `stores/` 层的 import 逐步改为 `@/` 别名 |
|
||||
| mixins.scss 复用 | 新写的页面样式使用 `@include card`、`@include flex-center`、`@include safe-bottom` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Sprint 1:健康数据模块打磨
|
||||
|
||||
**目标**:升级健康数据录入、展示和趋势分析体验,从"能用"到"好用"。
|
||||
|
||||
### 3.1 健康卡片状态色
|
||||
|
||||
**现状**:四张健康卡片(血压/心率/血糖/体重)样式统一灰色,无状态区分。
|
||||
|
||||
**方案**:
|
||||
|
||||
每张卡片根据指标状态着色:
|
||||
- **正常**:左侧绿色边条 + 绿色"正常 ─"标签
|
||||
- **偏高**:左侧红色边条 + 红色"偏高 ▲{差值}"标签
|
||||
- **偏低**:左侧红色边条 + 红色"偏低 ▼{差值}"标签
|
||||
- **无数据**:灰色,保持现状
|
||||
|
||||
异常指标数值变红,卡片底部显示参考范围。
|
||||
|
||||
**后端配合**:后端需在新增的 `GET /health/vital-signs/today` 端点中返回 `status`(normal/high/low)和 `reference_range`。前端 `TodaySummary` 类型同步新增 `reference_range` 字段(当前已有 `status` 字段但后端无对应返回)。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/health/index.tsx`(卡片样式逻辑)
|
||||
- 修改:`pages/index/index.tsx`(首页健康卡片同步更新)
|
||||
- 修改:`services/health.ts`(类型定义增加 status 字段)
|
||||
|
||||
### 3.2 ECharts 趋势图
|
||||
|
||||
**现状**:纯 CSS div 柱状图,无交互、无缩放、无 tooltip。
|
||||
|
||||
**方案**:
|
||||
|
||||
引入 `echarts-taro3-react`(设计规格中已规划)。**前置条件**:Sprint 1 开始前需做技术预研(spike),验证 `echarts-taro3-react` 在 Taro 4.2.0 + webpack5 下的兼容性。如果不可用,备选方案为 `echarts-for-weixin` + 手动封装为 React 组件。
|
||||
|
||||
实现:
|
||||
|
||||
- **折线图**:数据点连线,异常点标红放大
|
||||
- **参考范围色带**:正常值区间以半透明绿色背景显示
|
||||
- **Tooltip**:长按/点击显示具体数值和日期
|
||||
- **时间范围切换**:7天/30天/90天 三个 tab
|
||||
- **缓存 TTL**:趋势数据缓存 5 分钟后自动过期,强制重新请求
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`components/TrendChart/index.tsx`、`components/TrendChart/index.scss`
|
||||
- 重写:`pages/health/trend/index.tsx`
|
||||
- 修改:`stores/health.ts`(缓存 TTL 机制)
|
||||
- 新增依赖:`echarts-taro3-react`
|
||||
|
||||
### 3.3 表单验证升级
|
||||
|
||||
**现状**:所有表单验证为手动 if 判断,无 schema 约束。
|
||||
|
||||
**方案**:
|
||||
|
||||
引入 `zod`(~3KB gzip),为每个表单定义验证 schema:
|
||||
|
||||
```typescript
|
||||
// 示例:体征录入验证
|
||||
const vitalSignSchema = z.object({
|
||||
indicator_type: z.enum(['blood_pressure', 'heart_rate', 'blood_sugar', 'weight']),
|
||||
value: z.number().positive(),
|
||||
extra: z.object({ systolic: z.number().min(60).max(250).optional(),
|
||||
diastolic: z.number().min(40).max(150).optional() }).optional(),
|
||||
measured_at: z.string().datetime().optional(),
|
||||
note: z.string().max(200).optional()
|
||||
});
|
||||
```
|
||||
|
||||
异常值即时警告(如收缩压 > 180 显示红色提示"请及时就医")。
|
||||
|
||||
录入成功后自动刷新首页卡片 + 清除趋势缓存。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/health/input/index.tsx`(zod schema 验证)
|
||||
- 修改:`stores/health.ts`(录入成功后清除缓存)
|
||||
- 新增依赖:`zod`
|
||||
|
||||
---
|
||||
|
||||
## 4. Sprint 2:预约挂号 + 通知触达
|
||||
|
||||
**目标**:优化预约三步流程体验,建立微信订阅消息通知机制。
|
||||
|
||||
### 4.1 三步流程升级
|
||||
|
||||
**现状**:三个步骤(选科室 → 选医生 → 选日期时段)无进度指示,排班信息为纯文字列表。
|
||||
|
||||
**方案**:
|
||||
|
||||
**步骤指示器**:
|
||||
- 新增 `components/StepIndicator/index.tsx`
|
||||
- 顶部固定 1→2→3 步骤条,当前步骤高亮,已完成步骤可点击回退
|
||||
- 步骤间切换带过渡动画
|
||||
|
||||
**科室选择**:
|
||||
- 从文字列表改为宫格卡片(图标 + 科室名 + 医生数)
|
||||
- 每个科室卡片可点击,选中后高亮边框
|
||||
|
||||
**排班日历**:
|
||||
- 新增 `components/WeekCalendar/index.tsx` 周视图日历
|
||||
- 有排班的日期标记绿点,无排班的日期灰色
|
||||
- 点击日期展示该日可用时段卡片
|
||||
- 时段卡片按剩余名额着色:>3 绿色、1-3 橙色、0 灰色不可选
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`components/StepIndicator/index.tsx`
|
||||
- 新增:`components/WeekCalendar/index.tsx`
|
||||
- 重写:`pages/appointment/create/index.tsx`
|
||||
|
||||
### 4.2 微信订阅消息
|
||||
|
||||
**现状**:无任何推送通知机制。
|
||||
|
||||
**方案**:
|
||||
|
||||
1. 后端在微信公众平台注册订阅消息模板:
|
||||
- 预约就诊提醒(就诊前 1 天推送)
|
||||
- 随访任务提醒(截止前 1 天推送)
|
||||
- 报告出具通知(新报告发布时推送)
|
||||
|
||||
2. 前端在关键场景引导用户订阅:
|
||||
- 预约成功后弹出订阅授权
|
||||
- 随访提交后引导订阅下次提醒
|
||||
|
||||
3. 后端定时任务检查待推送消息并触发
|
||||
|
||||
4. **降级设计**:用户拒绝订阅时,消息仍写入 `erp-message` 消息中心。小程序"我的"页面顶部显示未读消息数量红点,作为消息触达的备选渠道。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/appointment/detail/index.tsx`(预约成功后订阅引导)
|
||||
- 修改:`pages/followup/detail/index.tsx`(随访提交后订阅引导)
|
||||
- 后端新增:`erp-server` 订阅消息模板注册 + 定时推送任务
|
||||
|
||||
---
|
||||
|
||||
## 5. Sprint 3:报告/随访/个人中心 + 安全 + 增长
|
||||
|
||||
**目标**:打磨剩余模块,完成安全加固和增长基础建设,达到可测试状态。
|
||||
|
||||
### 5.1 报告详情页升级
|
||||
|
||||
**现状**:所有指标卡片样式相同,无法一眼区分正常/异常。
|
||||
|
||||
**方案**:
|
||||
|
||||
指标卡片按状态着色:
|
||||
- **正常**:绿色背景 + 绿色"✓ 正常"标签 + 绿色数值
|
||||
- **偏高**:红色背景 + 红色"↑ 偏高"标签 + 红色数值
|
||||
- **偏低**:红色背景 + 红色"↓ 偏低"标签 + 红色数值
|
||||
|
||||
顶部汇总标签:`2 项异常 · 1 项正常`,一眼掌握整体状况。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/report/detail/index.tsx`
|
||||
- 修改:`pages/profile/reports/index.tsx`(如果仍独立存在)
|
||||
|
||||
### 5.2 随访 UX 细节
|
||||
|
||||
- 任务卡片增加截止日期倒计时("还剩 2 天",红色紧迫)
|
||||
- 过期任务灰色标记
|
||||
- 提交记录后增加"提交成功"确认动画(checkmark 缩放)
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/profile/followups/index.tsx`
|
||||
- 修改:`pages/followup/detail/index.tsx`
|
||||
|
||||
### 5.3 个人中心改进
|
||||
|
||||
**用药提醒**:
|
||||
- 实现时间选择器 Picker(替换当前静态文本)
|
||||
- 增加"提醒开关"(enabled/disabled)
|
||||
- 注:用药提醒数据仍为本地 Storage 存储,**后端同步作为后续版本事项**。MVP 阶段接受"换设备即丢失"的限制。
|
||||
|
||||
**就诊人管理**:
|
||||
- 增加编辑功能(当前只能添加不能编辑)
|
||||
- 复用 `family-add` 页面,传入已有数据进入编辑模式
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/profile/medication/index.tsx`(时间 Picker)
|
||||
- 修改:`pages/profile/family/index.tsx`(编辑入口)
|
||||
- 修改:`pages/profile/family-add/index.tsx`(编辑模式支持)
|
||||
|
||||
### 5.4 安全加固
|
||||
|
||||
#### 5.4.1 Token 安全
|
||||
|
||||
**现状**:Access Token 和 Refresh Token 明文存储在 `Taro.setStorageSync`。
|
||||
|
||||
**方案**:
|
||||
|
||||
MVP 阶段采用简化方案:微信小程序的 Storage 本身有沙箱隔离,明文存储的边际风险有限。做以下最低成本改进:
|
||||
|
||||
- 使用 `wx.getRandomValues()` 生成随机密钥,单独 key 存储
|
||||
- Token 存储时用此密钥做简单混淆(XOR 或 AES-ECB 单块加密)
|
||||
- 目的:防止 Storage 被直接明文读取,非追求密码学安全级别
|
||||
|
||||
> **后续版本**:如果合规要求提高,再升级为完整的 AES-GCM 方案。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`utils/crypto.ts`(轻量混淆工具)
|
||||
- 修改:`stores/auth.ts`(Storage 读写走混淆层)
|
||||
|
||||
#### 5.4.2 手机号真实解密
|
||||
|
||||
**现状**:`wechat_service.rs` 第 82 行硬编码 `"13800000000"`。
|
||||
|
||||
**方案**:
|
||||
- 后端接入微信 `phonenumber.getPhoneNumber` 接口
|
||||
- 使用 `encryptedData` + `iv` + `session_key` 解密真实手机号
|
||||
- 前端无需改动(已传递正确的 encryptedData 和 iv)
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`crates/erp-auth/src/service/wechat_service.rs`
|
||||
|
||||
#### 5.4.3 用户协议与隐私政策
|
||||
|
||||
- 新增 `pages/agreement/index.tsx` 页面
|
||||
- 登录页增加"阅读并同意《用户协议》和《隐私政策》"勾选
|
||||
- 权限使用说明文案(获取手机号用途声明)
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`pages/agreement/index.tsx`
|
||||
- 修改:`pages/login/index.tsx`(协议勾选)
|
||||
- 修改:`app.config.ts`(新增路由)
|
||||
|
||||
### 5.5 增长基础
|
||||
|
||||
#### 5.5.1 数据埋点
|
||||
|
||||
新增 `services/analytics.ts`,轻量事件记录:
|
||||
|
||||
```typescript
|
||||
// 核心事件类型
|
||||
type AnalyticsEvent =
|
||||
| { type: 'page_view'; page: string; duration_ms?: number }
|
||||
| { type: 'feature_use'; feature: string; action: string }
|
||||
| { type: 'error'; message: string; stack?: string }
|
||||
```
|
||||
|
||||
- 页面进入/离开自动记录 `page_view`
|
||||
- 关键操作(录入数据、创建预约、提交随访)记录 `feature_use`
|
||||
- 捕获的错误记录 `error`
|
||||
- MVP 阶段:事件写入本地 `console.info` + Taro Storage 缓存(最近 100 条)
|
||||
- 后续版本:批量上报到后端 `POST /api/v1/analytics/events`
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`services/analytics.ts`
|
||||
- 修改:`app.tsx`(全局页面进入/离开监听)
|
||||
|
||||
#### 5.5.2 分享能力
|
||||
|
||||
- 文章详情页支持分享到微信好友/朋友圈
|
||||
- 自定义分享卡片(标题 + 摘要 + 封面图)通过 `onShareAppMessage` 和 `onShareTimeline` 实现
|
||||
|
||||
> **后续版本**:健康报告 Canvas 分享图片生成、PC 扫码登录。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/article/detail/index.tsx`(onShareAppMessage + onShareTimeline)
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件变更总览
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 | Sprint |
|
||||
|------|------|--------|
|
||||
| `components/ErrorBoundary/index.tsx` | 全局错误边界 | 0 |
|
||||
| `components/TrendChart/index.tsx` | ECharts 趋势图 | 1 |
|
||||
| `components/StepIndicator/index.tsx` | 步骤指示器 | 2 |
|
||||
| `components/WeekCalendar/index.tsx` | 周视图日历 | 2 |
|
||||
| `pages/agreement/index.tsx` | 用户协议/隐私政策 | 3 |
|
||||
| `utils/crypto.ts` | Token 加密工具 | 3 |
|
||||
| `services/analytics.ts` | 数据埋点 | 3 |
|
||||
|
||||
### 删除文件
|
||||
|
||||
| 文件 | 原因 | Sprint |
|
||||
|------|------|--------|
|
||||
| `pages/report/index.tsx` | 与 profile/reports 重复 | 0 |
|
||||
| `pages/report/index.scss` | 同上 | 0 |
|
||||
| `pages/followup/index.tsx` | 与 profile/followups 重复 | 0 |
|
||||
| `pages/followup/index.scss` | 同上 | 0 |
|
||||
|
||||
### 新增依赖
|
||||
|
||||
| 依赖 | 用途 | 体积 | Sprint |
|
||||
|------|------|------|--------|
|
||||
| `echarts-taro3-react` | 交互式图表 | 封装层 ~5KB + echarts 按需 ~100-200KB (gzip) | 1 |
|
||||
| `zod` | 表单 schema 验证(长期投资) | ~3KB (gzip) | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 约束与风险
|
||||
|
||||
| 风险 | 应对策略 |
|
||||
|------|---------|
|
||||
| ECharts 增大包体积 | 按需引入 echarts 模块,不引入全量包;监控主包大小不超过 2MB |
|
||||
| 微信订阅消息需用户主动触发 | 在预约成功、随访提交等高意愿场景引导订阅 |
|
||||
| Token 加密增加启动耗时 | AES-GCM 加解密 < 1ms,可忽略 |
|
||||
| zod 增加包体积 | 3KB gzip,远小于自写验证代码量 |
|
||||
| Sprint 0 范围膨胀 | 严格只修不建,不引入新依赖,不重构架构 |
|
||||
| 后端端点未实现阻塞前端 | Sprint 0/1 的端点基本已实现;Sprint 2 订阅消息、Sprint 3 analytics 需后端配合 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
每个 Sprint 完成时必须满足:
|
||||
|
||||
- [ ] `pnpm build:weapp` 生产构建通过
|
||||
- [ ] 微信开发者工具无编译错误
|
||||
- [ ] 所有涉及页面真机预览功能正常
|
||||
- [ ] 无 console.error 或未捕获异常
|
||||
- [ ] 已修改的页面 loading/error/empty 三态完整
|
||||
- [ ] 所有代码已提交
|
||||
@@ -1,313 +0,0 @@
|
||||
# HMS 功能完善迭代设计规格
|
||||
|
||||
> 日期: 2026-04-25
|
||||
> 状态: 已确认(审查修正版)
|
||||
> 关联: `docs/superpowers/specs/2026-04-25-erp-ai-module-design.md`
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 项目现状
|
||||
|
||||
HMS 健康管理平台已完成核心业务开发(237 次提交、57k 行 Rust + 174 前端文件),但存在以下功能缺口:
|
||||
|
||||
- **按钮级权限控制缺失** — 路由守卫已有,但操作按钮(新增/编辑/删除)未做权限过滤;前端缺少权限数据源(`UserInfo` 接口不含 `permissions` 字段)
|
||||
- **AI 模块管理端空白** — 后端 6 个 API 端点中 4 个 SSE 流式端点可用,但 Prompt CRUD、分析历史查询、用量统计端点均为空壳或缺失
|
||||
- **小程序端 AI 不可见** — 患者无法查看 AI 分析报告
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
通过纵向切片方式逐步交付三个功能域,每个切片从前到后完整打通:
|
||||
|
||||
1. **切片 1:按钮级权限** — 基础设施,后续页面的前置依赖
|
||||
2. **切片 2:AI 管理端** — 3 个 PC 管理页面
|
||||
3. **切片 3:小程序报告** — 患者端只读查看
|
||||
|
||||
---
|
||||
|
||||
## 2. 切片 1:按钮级权限控制
|
||||
|
||||
### 2.1 架构
|
||||
|
||||
```
|
||||
后端新增 /api/v1/auth/me/permissions → 返回当前用户权限码列表
|
||||
↓
|
||||
auth store (permissions: string[]) ← 登录时从新端点加载
|
||||
↓
|
||||
usePermission(code) → { hasPermission: boolean }
|
||||
↓
|
||||
<AuthButton code="health.patient.manage"> ... </AuthButton>
|
||||
↓
|
||||
无权限 → 不渲染(hidden 模式)
|
||||
有权限 → 正常渲染子元素
|
||||
```
|
||||
|
||||
**前置依赖:** 当前 `UserInfo` 接口不含 `permissions` 字段(仅含 `roles`)。需后端新增 `/api/v1/auth/me/permissions` 端点,返回当前用户所有权限码的扁平列表(从角色 → 权限关联表聚合)。超级管理员(`is_system: true`)默认返回全部权限码。
|
||||
|
||||
### 2.2 组件设计
|
||||
|
||||
**usePermission hook**
|
||||
|
||||
位置: `apps/web/src/hooks/usePermission.ts`
|
||||
|
||||
```typescript
|
||||
function usePermission(code: string): { hasPermission: boolean }
|
||||
```
|
||||
|
||||
- 从 auth store 读取当前用户 permissions 数组
|
||||
- 返回 code 是否在权限列表中
|
||||
- 权限数据加载失败时默认无权限(安全降级)
|
||||
|
||||
**AuthButton 组件**
|
||||
|
||||
位置: `apps/web/src/components/AuthButton.tsx`
|
||||
|
||||
Props:
|
||||
- `code: string` — 权限码(如 `health.patient.manage`)
|
||||
- `children: ReactNode` — 受保护的按钮内容
|
||||
|
||||
行为: 无权限时不渲染 children(hidden 模式)。
|
||||
|
||||
**AuthGuard 组件**
|
||||
|
||||
位置: `apps/web/src/components/AuthGuard.tsx`
|
||||
|
||||
Props:
|
||||
- `code: string` — 权限码
|
||||
- `children: ReactNode` — 受保护的内容块
|
||||
|
||||
行为: 同 AuthButton,用于包裹非按钮内容(如整个 Tab、区块)。
|
||||
|
||||
### 2.3 改造范围
|
||||
|
||||
优先改造健康模块 15 个页面中的操作按钮:
|
||||
|
||||
| 页面 | 按钮权限码 |
|
||||
|------|-----------|
|
||||
| PatientList | health.patient.manage |
|
||||
| PatientDetail | health.patient.manage |
|
||||
| AppointmentList | health.appointment.manage |
|
||||
| DoctorList | health.doctor.manage |
|
||||
| DoctorSchedule | health.doctor.manage |
|
||||
| FollowUpTaskList | health.follow-up.manage |
|
||||
| FollowUpRecordList | health.follow-up.manage |
|
||||
| ConsultationList | health.consultation.manage |
|
||||
| ConsultationDetail | health.consultation.manage |
|
||||
| OfflineEventList | health.articles.manage |
|
||||
| PatientTagManage | health.patient.manage |
|
||||
| StatisticsDashboard | health.health-data.list (只读) |
|
||||
| PointsProductList | health.points.manage |
|
||||
| PointsOrderList | health.points.list |
|
||||
| PointsRuleList | health.points.manage |
|
||||
|
||||
扩展到基础模块页面(Users, Roles, Organizations, Workflow 等)。
|
||||
|
||||
### 2.4 验证标准
|
||||
|
||||
- [ ] 无权限用户看不到操作按钮
|
||||
- [ ] 有权限用户操作正常
|
||||
- [ ] 权限变更后界面实时更新(无需刷新)
|
||||
|
||||
---
|
||||
|
||||
## 3. 切片 2:AI 管理端 3 页面
|
||||
|
||||
### 3.1 路由设计
|
||||
|
||||
```
|
||||
/health/ai/prompts → Prompt 管理
|
||||
/health/ai/analysis → 分析历史
|
||||
/health/ai/usage → 用量统计
|
||||
```
|
||||
|
||||
### 3.2 页面 A — Prompt 管理
|
||||
|
||||
位置: `apps/web/src/pages/health/AiPromptList.tsx`
|
||||
|
||||
**功能清单:**
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 列表展示 | 表格:名称/类型(化验单解读、趋势分析、体检方案、报告摘要)/版本号/状态(active/draft)/更新时间 |
|
||||
| 新建 Prompt | Modal 表单:名称、类型(下拉)、系统提示词、用户提示词模板(支持 `{{variable}}` 占位符) |
|
||||
| 编辑 Prompt | 同新建,自动递增版本号 |
|
||||
| 激活/停用 | 切换按钮,激活时停用同类型旧模板 |
|
||||
| 版本历史 | 展开行显示所有历史版本,支持一键回滚 |
|
||||
|
||||
**API 封装:**
|
||||
|
||||
位置: `apps/web/src/api/ai/prompts.ts`
|
||||
|
||||
```typescript
|
||||
// 后端需新增 Prompt CRUD 端点(当前仅有 service 层的 get_active_prompt + create_prompt)
|
||||
getPrompts(params: ListParams): Promise<PaginatedResponse<Prompt>> // GET /api/v1/ai/prompts
|
||||
createPrompt(data: CreatePromptDto): Promise<Prompt> // POST /api/v1/ai/prompts
|
||||
updatePrompt(id: string, data: UpdatePromptDto): Promise<Prompt> // PUT /api/v1/ai/prompts/{id}
|
||||
activatePrompt(id: string): Promise<Prompt> // POST /api/v1/ai/prompts/{id}/activate
|
||||
rollbackPrompt(id: string): Promise<Prompt> // POST /api/v1/ai/prompts/{id}/rollback
|
||||
```
|
||||
|
||||
**版本回滚机制:** 每次编辑 Prompt 创建新记录(递增 version),回滚 = 将目标旧版本 `is_active` 设为 `true` 并将当前激活版本 `is_active` 设为 `false`。不删除任何版本记录。
|
||||
|
||||
**权限码:** `ai.prompt.list`(查看)、`ai.prompt.manage`(编辑/激活/回滚)
|
||||
|
||||
### 3.3 页面 B — 分析历史
|
||||
|
||||
位置: `apps/web/src/pages/health/AiAnalysisList.tsx`
|
||||
|
||||
**功能清单:**
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 列表展示 | 表格:分析类型/患者姓名/状态(streaming/completed/failed)/创建时间/token 用量 |
|
||||
| 详情查看 | 点击行展开/Modal 展示完整分析结果(Markdown 渲染) |
|
||||
| 筛选 | 按类型(4 种)、时间范围(DateRangePicker)、患者(PatientSelect 组件复用) |
|
||||
| 重新分析 | 对 failed 记录支持重新发起分析 |
|
||||
|
||||
**API 封装:**
|
||||
|
||||
位置: `apps/web/src/api/ai/analysis.ts`
|
||||
|
||||
```typescript
|
||||
// 后端当前 list_analysis/get_analysis 为空壳(返回 ApiResponse::ok(())),需实现真实查询
|
||||
getAnalysisHistory(params: AnalysisQueryParams): Promise<PaginatedResponse<Analysis>> // GET /api/v1/ai/analysis/history
|
||||
getAnalysisDetail(id: string): Promise<Analysis> // GET /api/v1/ai/analysis/{id}
|
||||
```
|
||||
|
||||
**权限码:** `ai.analysis.list`(查看)、`ai.analysis.manage`(重新分析)
|
||||
|
||||
### 3.4 页面 C — 用量统计
|
||||
|
||||
位置: `apps/web/src/pages/health/AiUsageDashboard.tsx`
|
||||
|
||||
**功能清单:**
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 概览卡片 | 4 张 StatCard:总用量/本月/今日/平均 token |
|
||||
| 趋势图 | Ant Design Charts 折线图,按日/周/月切换 |
|
||||
| 类型分布 | 饼图展示 4 种分析类型的占比 |
|
||||
| 用户排行 | 表格展示用户维度用量排名 |
|
||||
|
||||
**API 封装:**
|
||||
|
||||
位置: `apps/web/src/api/ai/usage.ts`
|
||||
|
||||
```typescript
|
||||
// 后端需完全新增:路由、handler、聚合 service
|
||||
// ai_usage_logs 表需增加 created_by 列(当前缺失 user_id),或复用 ai_analysis_results.created_by 做用户排行
|
||||
getUsageOverview(): Promise<UsageOverview> // GET /api/v1/ai/usage/overview
|
||||
getUsageTrend(params: TrendParams): Promise<TrendData[]> // GET /api/v1/ai/usage/trend
|
||||
getUsageByType(): Promise<TypeDistribution[]> // GET /api/v1/ai/usage/by-type
|
||||
getUsageByUser(params: UserRankingParams): Promise<PaginatedResponse<UserUsage>> // GET /api/v1/ai/usage/by-user
|
||||
```
|
||||
|
||||
**后端补充:** 需在 erp-ai 中新增用量统计聚合端点。优先方案:复用 `ai_analysis_results` 表的 `created_by` 字段做用户维度排行,避免修改 `ai_usage_logs` 表结构。如需精确 token 统计,后续可加迁移增加 `user_id` 列。
|
||||
|
||||
**权限码:** `ai.usage.list`
|
||||
|
||||
### 3.5 菜单注册
|
||||
|
||||
在 `apps/web/src/layouts/MainLayout.tsx` 健康管理菜单组下新增 AI 分析入口。当前菜单为扁平结构(无子菜单折叠),新增项直接追加为同级菜单项:
|
||||
|
||||
```
|
||||
健康管理
|
||||
├── 患者管理
|
||||
├── 医护管理
|
||||
├── 预约管理
|
||||
├── 随访管理
|
||||
├── 咨询管理
|
||||
├── 积分商城
|
||||
├── 统计看板
|
||||
├── AI Prompt 管理 ← 新增(扁平)
|
||||
├── AI 分析历史 ← 新增(扁平)
|
||||
└── AI 用量统计 ← 新增(扁平)
|
||||
```
|
||||
|
||||
### 3.6 验证标准
|
||||
|
||||
- [ ] Prompt CRUD 全流程可用(创建/编辑/激活/回滚)
|
||||
- [ ] 分析历史可筛选、可查看详情(Markdown 正确渲染)
|
||||
- [ ] 用量统计图表数据正确
|
||||
- [ ] 所有操作按钮受 AuthButton 权限控制
|
||||
- [ ] 页面响应式布局正常
|
||||
|
||||
---
|
||||
|
||||
## 4. 切片 3:小程序 AI 报告查看
|
||||
|
||||
### 4.1 新增页面
|
||||
|
||||
**AI 报告列表页**
|
||||
|
||||
位置: `apps/miniprogram/src/pages/ai-report/list/index.tsx`
|
||||
|
||||
- 调用 `GET /api/v1/ai/analysis/history` (后端需实现,根据 JWT user_id → patient_id 自动过滤)
|
||||
- 列表展示分析记录(类型图标 + 时间 + 状态标签)
|
||||
- 点击进入详情
|
||||
|
||||
**AI 报告详情页**
|
||||
|
||||
位置: `apps/miniprogram/src/pages/ai-report/detail/index.tsx`
|
||||
|
||||
- 调用 `GET /api/v1/ai/analysis/{id}`
|
||||
- 使用 `taro-markdown` 组件渲染 Markdown 格式的分析结果(需先验证兼容性)
|
||||
- 底部展示分析时间和 token 用量信息
|
||||
|
||||
### 4.2 路由集成
|
||||
|
||||
在首页(`pages/index/index.tsx`)健康数据区域增加"AI 报告"入口卡片。
|
||||
|
||||
### 4.3 后端依赖
|
||||
|
||||
后端 `list_analysis` 和 `get_analysis` 当前为空壳(仅验证权限后返回空值),需实现:
|
||||
- 根据 JWT 中的 user_id 查找关联 patient_id
|
||||
- 从 `ai_analysis_results` 表查询该患者的分析记录
|
||||
- 返回不含 PII 的脱敏结果
|
||||
|
||||
### 4.4 验证标准
|
||||
|
||||
- [ ] 患者可查看自己的 AI 分析历史
|
||||
- [ ] 详情页 Markdown 正确渲染
|
||||
- [ ] 无法查看其他患者的报告
|
||||
- [ ] 无报告时显示空状态提示
|
||||
|
||||
---
|
||||
|
||||
## 5. 实施顺序
|
||||
|
||||
| 阶段 | 内容 | 依赖 | 预计工作量 |
|
||||
|------|------|------|-----------|
|
||||
| P0a | 后端:新增 `/api/v1/auth/me/permissions` 端点 | 无 | erp-auth handler + service |
|
||||
| P0b | 后端:实现 Prompt CRUD 端点(list/create/update/activate/rollback) | 无 | erp-ai handler + service |
|
||||
| P0c | 后端:实现分析历史查询(list_analysis/get_analysis 从空壳到真实查询) | 无 | erp-ai handler + service |
|
||||
| P0d | 后端:新增用量统计聚合端点(overview/trend/by-type/by-user) | 无 | erp-ai handler + service + 可能迁移 |
|
||||
| P1 | 前端:usePermission hook + AuthButton/AuthGuard 组件 + auth store 加载 permissions | P0a | 3 文件 |
|
||||
| P2 | 前端:健康模块页面按钮权限改造 | P1 | 15 文件 |
|
||||
| P3 | 前端:AI API 封装(3 个 service 文件) | P0b-d | 3 文件 |
|
||||
| P4 | 前端:AI Prompt 管理页面 | P1, P3 | 1 文件 |
|
||||
| P5 | 前端:AI 分析历史页面 | P1, P3 | 1 文件 |
|
||||
| P6 | 前端:AI 用量统计页面 | P1, P3 | 1 文件 |
|
||||
| P7 | 前端:菜单注册 + 路由配置 | P4-P6 | 2 文件 |
|
||||
| P8 | 小程序:验证 taro-markdown 兼容性 + AI 报告列表/详情页 | P0c | 3 文件 |
|
||||
| P9 | 小程序:首页入口集成 | P8 | 1 文件 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 非目标(明确排除)
|
||||
|
||||
- 不涉及 CI/CD 流水线建设(属于安全与稳定性方向)
|
||||
- 不涉及 erp-plugin unwrap 修复(属于代码质量方向)
|
||||
- 不涉及 TypeScript strict 模式开启(属于质量方向,单独处理)
|
||||
- 不涉及新的 AI 提供商接入(仅使用现有 Claude 提供商)
|
||||
- 不涉及用量配额/计费功能(后续迭代)
|
||||
|
||||
---
|
||||
|
||||
## 7. 技术约束
|
||||
|
||||
- 前端组件使用 Ant Design 6 现有组件
|
||||
- 图表使用 Ant Design Charts(项目已有依赖)
|
||||
- 小程序 Markdown 渲染使用 taro-markdown 组件(P8 开始前验证兼容性)
|
||||
- 后端新增端点遵循现有 handler/service/entity 模式
|
||||
- 所有新页面使用 i18n key(前缀约定:`health.ai.*`),不硬编码中文
|
||||
- 权限数据加载失败时默认无权限(安全降级,宁可少显示按钮也不暴露越权操作)
|
||||
@@ -1,450 +0,0 @@
|
||||
# HMS 健康管理模块业务流程合理性分析
|
||||
|
||||
> 日期: 2026-04-25
|
||||
> 状态: Draft
|
||||
> 分析方法: 三专家组并行深度审查(临床业务 + 运营管理 + 产品架构)
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
HMS 健康管理模块已完成初始实现,包含 27 个数据库实体、14 个权限描述符(7 组 .list/.manage)、16 个 Web 页面、21 个小程序页面。本次分析从**临床业务流程、运营管理有效性、产品架构竞争力**三个维度进行深度审查。
|
||||
|
||||
### 核心结论
|
||||
|
||||
**工程基础设施达到生产级水准。** 多租户隔离、CAS 并发控制、乐观锁、事件驱动架构、数据加密(AES-256 + HMAC)、审计日志等能力一应俱全。
|
||||
|
||||
**业务深度存在三个核心缺口:**
|
||||
|
||||
| 缺口 | 影响 | 紧急度 |
|
||||
|------|------|--------|
|
||||
| 诊断编码缺失 | 随访/趋势分析/统计报表缺乏医学语义锚点,与 HIS/LIS 无法对接 | P0 |
|
||||
| 统计数据造假 | Dashboard 硬编码 0 值,管理者无法做运营决策 | P0 |
|
||||
| 消息推送未集成 | 设计规格定义 11 种事件,代码发布 9 种(缺失 `follow_up.overdue` 等),患者触达手段严重不足 | P0 |
|
||||
|
||||
### 发现总计
|
||||
|
||||
- **P0 关键问题**: 6 个(产品可信度 + 患者安全)
|
||||
- **P1 核心缺失**: 8 个(业务能力补全)
|
||||
- **P2 运营增强**: 7 个(差异化竞争力)
|
||||
- **P3 长期规划**: 7 个(市场准入 + 技术领先)
|
||||
|
||||
### 差异化竞争优势
|
||||
|
||||
1. **Rust 技术性能壁垒** — 高并发预约场景显著优势,单体二进制部署比竞品微服务架构简单一个数量级
|
||||
2. **血透专科深度** — 竞品中少有的专科化设计,补全方案/通路/充分性后更具竞争力
|
||||
3. **患者运营闭环** — 积分商城+签到+活动+资讯,医疗 SaaS 中罕见的互联网运营思维
|
||||
4. **多租户原生设计** — 从第一天内置隔离,竞品多为后期改造
|
||||
|
||||
---
|
||||
|
||||
## 1. 临床业务流程评估
|
||||
|
||||
> 专家角色: 资深临床医疗信息化专家(15 年医院信息系统设计经验)
|
||||
|
||||
### 1.1 患者全生命周期
|
||||
|
||||
**评分: 6/10 — "两头有、中间空"**
|
||||
|
||||
已覆盖的环节:
|
||||
- 建档阶段扎实:加密存储、标签分类、实名认证状态流转、家庭关系、医患关联
|
||||
- 健康摘要 API (`get_health_summary`) 聚合最新体征/化验/预约/随访,提供一站式概览
|
||||
|
||||
缺失的关键环节:
|
||||
|
||||
| 环节 | 说明 | 影响 |
|
||||
|------|------|------|
|
||||
| 分诊 (Triage) | 无主诉、分诊科室、紧急程度的记录 | 预约直接绑定医生,跳过分诊 |
|
||||
| 诊疗记录 (EMR) | `health_record` 仅含 `overall_assessment` + 文件 URL | 缺主诉、现病史、体格检查、诊断、处方 |
|
||||
| 诊断编码 (ICD) | 无 ICD-10/ICD-11 支持 | 随访/趋势分析缺乏医学语义锚点 |
|
||||
| 转诊流程 | 无科室间/院间转诊记录和追踪 | 多学科协作无法支撑 |
|
||||
| 入组/出组管理 | 只有标签这一非结构化方式 | 健康管理项目无法规范化 |
|
||||
|
||||
### 1.2 医疗数据管理
|
||||
|
||||
**评分: 5/10 — 基本可用但临床精细度不足**
|
||||
|
||||
优点:
|
||||
- 体征数据晨/晚血压区分符合慢病管理实践
|
||||
- 化验报告 V2 JSON 结构 `[{name, value, unit, reference_low, reference_high, is_abnormal}]` 灵活实用
|
||||
- 透析记录覆盖干体重、超滤量、血流量、透析类型 (HD/HDF/HF)
|
||||
|
||||
关键缺失:
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| `vital_signs` 与 `daily_monitoring` 字段 91% 重叠 | 数据冗余 + 命名不一致(`systolic_bp_morning` vs `morning_bp_systolic`),趋势分析只查 `vital_signs` 表,`daily_monitoring` 数据完全被忽略 |
|
||||
| 缺乏采集时间精度 | 只有 `record_date`,血压需精确到分钟,血糖需标注空腹/餐后 |
|
||||
| 缺乏体温和 SpO2 | 透析感染监测和呼吸系统疾病管理的必备指标 |
|
||||
| 血糖无类型标记 | 空腹/餐后/随机/OGTT 混在一起,参考范围完全不同 |
|
||||
| 无用药记录 | 小程序有 `profile/medication` 页面但后端无对应实体 |
|
||||
| 化验指标未标准化 | "肌酐"/"CREA"/"Creatinine" 多种写法影响趋势分析 |
|
||||
| 出入量记录过于简单 | 仅有饮水量和尿量,临床需区分口服/静脉/引流/失血 |
|
||||
|
||||
### 1.3 预约排班
|
||||
|
||||
**评分: 7/10 — 核心流程优秀,运营辅助不足**
|
||||
|
||||
优点:
|
||||
- CAS 原子操作防超额预约,取消时自动释放名额,事务保证一致性
|
||||
- 状态机完整: pending → confirmed → completed/no_show/cancelled
|
||||
- 5 种预约类型覆盖主要场景(透析/复诊/门诊/体检/咨询)
|
||||
|
||||
缺失:
|
||||
- 无资源维度绑定(透析机位、检查室)
|
||||
- 无批量排班/排班模板(透析中心需周期性排班)
|
||||
- 无候补机制(号源满后直接拒绝)
|
||||
- no_show 无自动触发(定时任务缺失)
|
||||
- 排班不区分节假日
|
||||
|
||||
### 1.4 随访管理
|
||||
|
||||
**评分: 6/10 — 基础流程完整,临床实用性不足**
|
||||
|
||||
优点:
|
||||
- 逾期自动检查 (`check_overdue_tasks`) + 自动创建后续任务(`next_follow_up_date` 机制)
|
||||
- 乐观锁保护所有更新操作
|
||||
|
||||
缺失:
|
||||
- 无结构化随访模板(`content_template` 是纯文本)
|
||||
- 随访结果无结构化(`result`/`patient_condition`/`medical_advice` 均为自由文本)
|
||||
- 无随访到期提醒通知
|
||||
- 无随访方案模板(一次性生成 1/3/6/12 月任务组)
|
||||
- 无优先级字段
|
||||
- **前后端类型不一致**: 后端 `phone`/`face_to_face`/`online`,前端 `phone`/`outpatient`/`home_visit`/`wechat`
|
||||
|
||||
### 1.5 透析管理
|
||||
|
||||
**评分: 4/10 — "透析记录本"而非"透析管理系统"**
|
||||
|
||||
已实现: 透析记录覆盖核心物理参数(体重变化/血压/超滤/血流量),审阅流程 (draft → reviewed)。
|
||||
|
||||
三大核心支柱缺失:
|
||||
|
||||
| 支柱 | 说明 | 临床影响 |
|
||||
|------|------|---------|
|
||||
| 透析方案 (Prescription) | 无透析器型号、透析液配方、抗凝方案、目标超滤、血管通路类型 | 每次透析需重新输入相同参数 |
|
||||
| 血管通路管理 | 无通路类型/位置/评估/并发症/手术史 | 通路是透析患者的"生命线" |
|
||||
| 充分性评估 | 无 Kt/V 和 URR 计算 | 无法评估透析质量 |
|
||||
|
||||
其他缺失: 透析中动态监测、透析药物管理 (EPO/铁剂/磷结合剂)、症状/并发症未结构化。此外,entity 注释中定义了 `completed` 状态但无代码路径可达,存在"幽灵状态"问题。
|
||||
|
||||
### 1.6 咨询管理
|
||||
|
||||
**评分: 5/10 — 适合"留言板",无法支撑"在线问诊"**
|
||||
|
||||
优点: 消息模型合理(text/image/voice/file 四种类型、CAS 未读计数、会话状态机 waiting → active → closed)。
|
||||
|
||||
缺失:
|
||||
- **无实时推送**: 只有 HTTP API,无 WebSocket/SSE
|
||||
- 消息已读标记 API 缺失
|
||||
- 无会话超时自动关闭
|
||||
- 无满意度评价
|
||||
- 无智能分配机制(轮转/科室匹配)
|
||||
- 无关联健康数据展示
|
||||
|
||||
### 1.7 临床决策支持
|
||||
|
||||
**评分: 3/10 — "概念验证"阶段**
|
||||
|
||||
已实现: 趋势分析框架、异常值基本检测(心率 60-100、血糖 3.9-11.1、血压 90-140/60-90)、化验 `is_abnormal` 标记。
|
||||
|
||||
关键问题:
|
||||
- **阈值硬编码** — 不考虑患者个体差异(老年/糖尿病/透析/儿童不同目标)
|
||||
- **血糖阈值不一致** — 趋势分析用 3.9-11.1,小程序摘要用 3.9-6.1
|
||||
- **无实时预警** — 趋势分析是手动触发,血压 180/110 不会自动报警
|
||||
- **不整合化验数据** — 肌酐/eGFR/血红蛋白/电解质趋势同样重要
|
||||
- **无风险评分** — Framingham/KDOQI/MNA 等临床评分模型完全缺失
|
||||
|
||||
---
|
||||
|
||||
## 2. 运营管理评估
|
||||
|
||||
> 专家角色: 资深健康管理运营专家(10+ 年体检中心/健康管理机构运营经验)
|
||||
|
||||
### 2.1 积分激励体系
|
||||
|
||||
**评分: 6/10 — 架构优秀,激励有效性不足**
|
||||
|
||||
优点:
|
||||
- 事件驱动积分发放,`daily_cap` 每日上限,FIFO 消费模型,12 个月过期
|
||||
- 连续签到阶梯奖励 (7/14/30 天)
|
||||
- 全链路 CAS 并发安全
|
||||
|
||||
关键问题:
|
||||
|
||||
| 问题 | 影响 |
|
||||
|------|------|
|
||||
| 事件类型有限 | 只有 6 种行为获积分,缺少 `vital_signs_input`/`annual_checkup`/`followup_adherence` 等真正驱动健康行为的激励点 |
|
||||
| 积分通胀无控制 | 无发放/消费比率监控、无月度预算上限 |
|
||||
| 积分过期未清理 | `expires_at` 字段写入后无定时检查,`total_expired` 永远为 0 |
|
||||
| 订单过期未取消 | 兑换订单 30 天过期但无定时任务退还积分 |
|
||||
| 无过期提醒 | 患者无法得知积分即将过期 |
|
||||
| 无补签机制 | 中断一天即重置,缺少积分兑换补签卡 |
|
||||
|
||||
### 2.2 患者参与度
|
||||
|
||||
**评分: 4/10 — 触达手段严重不足**
|
||||
|
||||
已有渠道: 每日打卡、积分商城、线下活动、咨询管理、随访任务、体征上报。
|
||||
|
||||
缺失的关键互动:
|
||||
|
||||
| 互动方式 | 状态 | 影响 |
|
||||
|----------|------|------|
|
||||
| 消息推送 (Push) | 未集成 erp-message | 随访/积分/报告/预约提醒全部缺失 |
|
||||
| 健康目标与挑战 | 无 | 无法设定"30 天降压"目标,无社区排行 |
|
||||
| 成就体系 (Badge) | 无 | 无"连续打卡 30 天"等徽章激励 |
|
||||
| 社交互动 | 无 | 家庭成员表仅记录信息,无家庭健康管理联动 |
|
||||
| 内容运营 | 基础 | 文章有 CRUD 但无推荐/阅读积分/分享积分 |
|
||||
|
||||
### 2.3 随访管理运营
|
||||
|
||||
**评分: 5/10 — 单条操作效率低**
|
||||
|
||||
**核心问题: 不支持批量操作。** 实际体检中心一个护士每天要处理 30-50 个随访任务,逐个操作效率极低。
|
||||
|
||||
缺失:
|
||||
- 批量创建(按患者标签/疾病类型)
|
||||
- 批量分配负责人
|
||||
- 批量标记完成
|
||||
- 随访工作量统计(按护士维度的完成率/响应时间)
|
||||
- 随访计划模板(按疾病类型的标准化方案)
|
||||
|
||||
### 2.4 健康干预闭环
|
||||
|
||||
**评分: 4/10 — 闭环完整度约 40%**
|
||||
|
||||
```
|
||||
数据采集 → 异常识别 → 干预建议 → 执行跟踪 → 效果评估
|
||||
80% 20% 0% 30% 0%
|
||||
```
|
||||
|
||||
- 数据采集基本完整(体征/化验/透析)
|
||||
- 异常识别仅有简单阈值判断,无实时预警
|
||||
- 干预建议完全缺失(`medical_advice` 是自由文本,无标准化方案库)
|
||||
- 执行跟踪仅有随访链式创建
|
||||
- 效果评估完全缺失(无 Health Score、无干预前后对比)
|
||||
|
||||
### 2.5 数据分析与报表
|
||||
|
||||
**评分: 2/10 — 不可用于运营决策**
|
||||
|
||||
**严重问题: Dashboard 数据大部分是假的。**
|
||||
|
||||
| 指标 | 实现方式 | 可信度 |
|
||||
|------|---------|--------|
|
||||
| 患者总数 | `list?page_size=1` 取 total | 部分 |
|
||||
| 本月新增 | 硬编码 `0` | 不可信 |
|
||||
| 本周新增 | 硬编码 `0` | 不可信 |
|
||||
| 本月活跃 | 硬编码 `0` | 不可信 |
|
||||
| 随访完成率 | 硬编码 `0` | 不可信 |
|
||||
| 咨询总量 | `list?page_size=1` 取 total | 部分 |
|
||||
| 积分统计 | SQL 聚合 | 可信 |
|
||||
| 积分排行 | SQL 排序 | 可信 |
|
||||
|
||||
缺失的关键运营指标: 患者覆盖率、随访完成率趋势、积分使用率、活动参与率、患者留存率、医生工作量分布。无时间维度分析,无数据导出能力。
|
||||
|
||||
### 2.6 多角色协作
|
||||
|
||||
**评分: 5/10 — 职责边界模糊**
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| 护士/健康管理师无独立 profile | 随访 `assigned_to` 指向医生选择器,但护士才是随访主力 |
|
||||
| 职责边界不清 | `patient_doctor_relation` 只有 `primary`/`consulting`,无"健康管理师"角色 |
|
||||
| 前台核销不流畅 | 需手动输入 UUID,无扫码枪集成 |
|
||||
| 无转诊协作机制 | 无医生间转诊流程,无 MDT 任务分配 |
|
||||
| 无智能工作负载分配 | 随访任务完全手动分配 |
|
||||
|
||||
### 2.7 商业变现能力
|
||||
|
||||
**评分: 4/10 — 基础模型在,变现工具缺失**
|
||||
|
||||
积分商城当前只有获取+兑换的基础闭环。缺失:
|
||||
- 积分充值通道(现金购买)
|
||||
- 会员等级体系 (VIP/SVIP)
|
||||
- 营销工具(优惠券/限时活动/邀请有礼/满减)
|
||||
- 供应商管理(商品采购成本/物流)
|
||||
- 数据分析(商品热度/用户分层 RFM)
|
||||
|
||||
---
|
||||
|
||||
## 3. 产品架构评估
|
||||
|
||||
> 专家角色: 资深医疗 SaaS 产品架构师(10+ 年医疗信息化产品设计经验)
|
||||
|
||||
### 3.1 模块边界与耦合
|
||||
|
||||
**评分: 8/10 — 边界清晰,事件契约有进步空间**
|
||||
|
||||
优点:
|
||||
- 对 erp-core 仅依赖共享类型(AppError/EventBus/PaginatedResponse)
|
||||
- 对 erp-auth 通过 `user_id` 外键松耦合,`Option<Uuid>` 允许患者先建档后绑定
|
||||
- 声明 `dependencies() = vec!["auth"]` 语义明确
|
||||
|
||||
关键问题:
|
||||
- **事件发布不完整**: 设计规格定义 11 种事件,代码实际发布 9 种(含 2 种设计规格外的: `doctor.online_status_changed`/`lab_report.uploaded`)。缺失 `follow_up.overdue` 等关键事件
|
||||
- **message.sent 订阅为空操作**: 只打 `tracing::info`,但 `consultation_session.last_message_at` 已在 `create_message` 方法中通过 CAS 直接更新,此事件订阅是预留扩展点而非功能缺失
|
||||
- 缺少密钥轮换机制
|
||||
|
||||
### 3.2 数据模型完整性
|
||||
|
||||
**评分: 6/10 — 覆盖核心域,存在医疗级缺口**
|
||||
|
||||
已覆盖(27 实体): 患者管理(完整)、医护管理(基本完整)、健康数据(完整)、预约排班(完整)、随访管理(完整)、咨询管理(完整)、专科血透(扩展完整)、患者运营(超预期)。
|
||||
|
||||
缺失的核心实体(按重要性排序):
|
||||
|
||||
| 实体 | 优先级 | 理由 |
|
||||
|------|--------|------|
|
||||
| 诊断记录 (Diagnosis) | P0 | ICD-10 编码,所有临床活动的锚点 |
|
||||
| 用药记录 (Medication) | P1 | 慢病管理核心数据,小程序页面已存在 |
|
||||
| 处方/医嘱 (Prescription) | P1 | 结构化处方是慢病管理核心 |
|
||||
| 健康计划 (Care Plan) | P2 | 从"数据记录"到"主动干预"的关键 |
|
||||
| 过敏详细记录 (Allergy) | P2 | 药物过敏交叉检查需要结构化数据 |
|
||||
|
||||
### 3.3 API 设计质量
|
||||
|
||||
**评分: 7/10 — RESTful 规范,高级特性缺失**
|
||||
|
||||
优点: 统一分页 `PaginatedResponse<T>`、乐观锁 `DeleteWithVersion`、状态机校验、管理端/患者端路由分离。
|
||||
|
||||
不足:
|
||||
- 无批量操作 API
|
||||
- 排序参数未暴露(硬编码 `order_by_desc(CreatedAt)`)
|
||||
- 日期范围过滤缺失(预约只有单日过滤)
|
||||
- 统计 API 不专业(用 `list?page_size=1` 模拟)
|
||||
- 咨询无实时机制
|
||||
- 导出功能单一
|
||||
|
||||
### 3.4 前端架构
|
||||
|
||||
**评分: 7/10 — 组件化程度高,全局状态管理缺失**
|
||||
|
||||
Web 前端优点: API 层按领域拆分(8 个 TS 文件)、12 个共享组件、Tab 化详情页、主题适配。
|
||||
|
||||
不足:
|
||||
- 无 Zustand 全局状态(健康模块共享数据如当前患者、名称缓存无法跨页面)
|
||||
- StatisticsDashboard 数据质量低
|
||||
- 日期处理类型不够安全
|
||||
|
||||
小程序优点: 27 页面覆盖全面、服务层与 Web 端一一对应。
|
||||
|
||||
不足: 无离线缓存策略、无统一错误处理/重试。
|
||||
|
||||
### 3.5 多租户适配
|
||||
|
||||
**评分: 8/10 — 基础隔离完整,差异化配置不足**
|
||||
|
||||
已实现: 全实体 `tenant_id` 过滤、租户生命周期钩子(seed/soft_delete)、AES-256-GCM 加密 + HMAC 索引、数据脱敏、行级数据权限。
|
||||
|
||||
缺失: 不同医疗机构类型(体检中心/社区中心/血透中心/专科诊所)的差异化配置能力。`patient` 模型是"大一统"设计,无法自定义采集项、评估量表、报告模板。
|
||||
|
||||
### 3.6 扩展性与可配置性
|
||||
|
||||
**评分: 5/10 — 架构级良好,业务层不足**
|
||||
|
||||
架构级(良好): ErpModule trait 注册、EventBus 解耦、WASM 插件保留、SeaORM Entity + Migration 扩展。
|
||||
|
||||
业务层(不足): 无自定义表单(EAV 或 JSONB + Schema)、无自定义工作流模板、无报告模板。积分规则和标签系统是系统中少有的可配置业务模块。
|
||||
|
||||
### 3.7 竞品对比
|
||||
|
||||
| 维度 | HMS | 杏树林 | 微医 | 平安好医生 |
|
||||
|------|-----|--------|------|-----------|
|
||||
| 技术性能 | 极高 (Rust) | 中等 (Java) | 中等 | 中等 |
|
||||
| 多租户 | 原生支持 | 后期改造 | 有限 | 有限 |
|
||||
| 患者运营 | 有(积分+商城) | 无 | 无 | 有 |
|
||||
| 血透专科 | 有(待完善) | 无 | 无 | 无 |
|
||||
| AI 能力 | 开发中 | 成熟 | 部分 | 成熟 |
|
||||
| 实时通讯 | 无 | 音视频 | 音视频 | 音视频 |
|
||||
| 医疗标准 | 无 | 有 (ICD) | 有 | 有 |
|
||||
| 影像管理 | 无 | 有 | 有 | 无 |
|
||||
| 合规认证 | 无 | 等保三级 | 等保三级 | 等保三级 |
|
||||
|
||||
**核心差距**: AI 能力、实时通讯、医疗数据标准、影像管理、合规认证。
|
||||
|
||||
---
|
||||
|
||||
## 4. 综合改进路线图
|
||||
|
||||
### Phase 1: 产品可信度修复 (P0, 2-3 人天)
|
||||
|
||||
> 影响: 管理者无法决策、患者安全风险、跨模块联动断裂
|
||||
|
||||
| # | 改进项 | 涉及文件 | 复杂度 |
|
||||
|---|--------|---------|--------|
|
||||
| 1 | 修复 Dashboard 统计数据 | `points.ts` + `points_service.rs` + `StatisticsDashboard.tsx` | 中 |
|
||||
| 2 | 补全事件发布(至少 `follow_up.overdue`) | `event.rs` + `follow_up_service.rs` | 中 |
|
||||
| 3 | 合并 vital_signs 和 daily_monitoring | entity + service + DTO + migration | 中 |
|
||||
| 4 | 增加实时异常预警 | `health_data_service.rs` + `trend_service.rs` | 中 |
|
||||
| 5 | 增加 ICD-10 诊断编码支持 | 新建 entity + migration + service | 中 |
|
||||
| 6 | 实现积分过期清理定时任务 | `points_service.rs` + `module.rs` | 低 |
|
||||
|
||||
### Phase 2: 核心业务能力补全 (P1, 5-7 人天)
|
||||
|
||||
> 影响: 临床实用性不足、患者参与度低、运营效率差
|
||||
|
||||
| # | 改进项 | 复杂度 |
|
||||
|---|--------|--------|
|
||||
| 7 | 结构化随访模板系统 | 高 |
|
||||
| 8 | 用药记录实体 | 中 |
|
||||
| 9 | 透析方案管理 | 中 |
|
||||
| 10 | 体征增加体温/SpO2/血糖类型 | 低 |
|
||||
| 11 | 消息推送集成 | 中 |
|
||||
| 12 | 批量随访操作 | 中 |
|
||||
| 13 | 修复随访类型前后端不一致 | 低 |
|
||||
| 14 | 咨询 WebSocket 实时推送 | 高 |
|
||||
|
||||
### Phase 3: 运营增强 (P2, 5-7 人天)
|
||||
|
||||
> 影响: 差异化竞争力、患者留存、商业变现
|
||||
|
||||
| # | 改进项 | 复杂度 |
|
||||
|---|--------|--------|
|
||||
| 15 | 患者健康评分体系 (Health Score) | 中 |
|
||||
| 16 | 会员等级和营销工具 | 中 |
|
||||
| 17 | 预约资源绑定 | 中 |
|
||||
| 18 | 个性化异常阈值配置 | 中 |
|
||||
| 19 | 化验指标标准化字典 (LOINC) | 中 |
|
||||
| 20 | 批量排班/排班模板 | 中 |
|
||||
| 21 | 小程序分析埋点后端 | 低 |
|
||||
|
||||
### Phase 4: 长期竞争力 (P3, 路线图)
|
||||
|
||||
| # | 改进项 |
|
||||
|---|--------|
|
||||
| 22 | 血管通路管理 |
|
||||
| 23 | 疾病风险评分模型 |
|
||||
| 24 | AI 辅助诊断 (erp-ai 集成) |
|
||||
| 25 | 可配置表单能力 |
|
||||
| 26 | 影像管理集成 (DICOM/PACS) |
|
||||
| 27 | 合规认证 (等保三级) |
|
||||
| 28 | HL7 FHIR R4 数据互操作 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A: 与 QA 审计计划的交叉引用
|
||||
|
||||
本分析与 [QA 审计计划](../../plans/qa-review-brainstorm-floofy-finch.md) 的发现高度重叠,以下是交叉对照:
|
||||
|
||||
| QA 审计编号 | 业务分析对应 | 状态 |
|
||||
|------------|-------------|------|
|
||||
| 1.1 逾期随访检查器未启动 | §1.4 随访管理 — 逾期自动检查已实现但需修复 | 需修复 |
|
||||
| 1.2 积分并发余额损坏 | §2.1 积分激励体系 — CAS 并发安全 | 需修复 |
|
||||
| 2.4 HealthDataProvider 全 stub | §1.7 临床决策支持 — AI 集成依赖 | 需决策 |
|
||||
| 2.6 小程序 DTO 不匹配 | §1.2 医疗数据管理 — 数据模型对齐 | 需修复 |
|
||||
| 3.1 咨询列表显示截断 UUID | §3.4 前端架构 — 名称缓存 | 已修复 |
|
||||
| 3.2 积分订单列表显示 UUID | §3.4 前端架构 — 需名称解析 | 待修复 |
|
||||
| 3.4 预约状态变更无确认 | §1.3 预约排班 — 已在 AppointmentList 中实现 | 已修复 |
|
||||
|
||||
## 附录 B: 工作量估算
|
||||
|
||||
| 阶段 | 人天 | 优先级 |
|
||||
|------|------|--------|
|
||||
| Phase 1: P0 可信度修复 | 2-3 | 立即 |
|
||||
| Phase 2: P1 核心能力 | 5-7 | 本迭代 |
|
||||
| Phase 3: P2 运营增强 | 5-7 | 下迭代 |
|
||||
| Phase 4: P3 长期竞争力 | 路线图 | Q3-Q4 |
|
||||
| **合计** | **12-17 + 路线图** | |
|
||||
@@ -1,670 +0,0 @@
|
||||
# HMS V2 迭代设计 — 血透专科健康管理平台
|
||||
|
||||
> **日期**: 2026-04-25
|
||||
> **状态**: 待评审
|
||||
> **前置文档**: `2026-04-23-health-management-module-design.md`, `2026-04-24-health-module-iteration-design.md`
|
||||
> **需求来源**: 客户功能需求文档 `docs/健康管理/管理系统功能文档(1).xlsx`
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
### 1.1 业务定位变化
|
||||
|
||||
V1 的健康管理模块定位为**通用健康管理平台**,覆盖患者管理、健康数据、预约排班、随访管理、咨询管理五大功能。
|
||||
|
||||
客户反馈后,明确业务定位为**血透(透析)专科健康管理平台**,面向肾病/透析患者和医护群体。这带来三个重大变化:
|
||||
|
||||
1. **数据模型专科化** — 从通用健康指标(血压/心率/血糖/体重/体温)扩展为血透专科指标(透析记录、化验报告、日常监测)
|
||||
2. **新增积分商城** — 替代原计划的完整电商,用积分体系驱动患者活跃度
|
||||
3. **新增医护端小程序** — 独立入口,医护可查看患者数据、填写透析记录、回复咨询
|
||||
|
||||
### 1.2 客户需求来源
|
||||
|
||||
客户通过 Excel 功能文档定义了三端需求:
|
||||
|
||||
| 端 | 核心功能 |
|
||||
|---|---|
|
||||
| 患者端小程序 | 首页、数据上报(透析/化验/日常)、在线咨询、积分商城、预约与随访、个人中心 |
|
||||
| 医护端小程序 | 数据概览、患者管理、咨询回复、随访管理、报告解读 |
|
||||
| PC 管理后台 | 系统管理、患者管理、健康数据中心、咨询管理、商城管理、内容管理、统计报表、系统设置 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 需求-实现差距分析
|
||||
|
||||
### 2.1 已有功能匹配度
|
||||
|
||||
| 客户需求 | 已有实现 | 匹配度 | 需要的工作 |
|
||||
|---------|---------|--------|-----------|
|
||||
| 患者列表/档案 | Patient CRUD + 18 实体 | 90% | 微调 |
|
||||
| 在线咨询(图文) | Consultation 模块 | 70% | 加语音/客服通道 |
|
||||
| 预约管理 | Appointment 模块 | 60% | 加透析专项预约 |
|
||||
| 随访任务/台账 | Follow-up 模块 | 80% | 微调 |
|
||||
| 科普文章 | Article 模块 | 90% | 微调 |
|
||||
| 医护管理 | Doctor 模块 | 80% | 微调 |
|
||||
| 账号权限/操作日志 | erp-auth RBAC | 95% | 基本不变 |
|
||||
| 患者标签 | Tag 模块 | 90% | 微调 |
|
||||
| 小程序健康数据 | 6 种指标录入 + ECharts 趋势图 | 80% | 扩展指标 + 透析数据 |
|
||||
|
||||
### 2.2 全新模块
|
||||
|
||||
| 模块 | 说明 | 复杂度 |
|
||||
|------|------|--------|
|
||||
| 血透透析记录 | 透析日期/时间、干体重、透前/后血压、心率、超滤量、症状 | 中 |
|
||||
| 日常监测扩展 | 饮水量、尿量 + 打卡模式 | 低 |
|
||||
| 化验报告上传 | 拍照上传 + 指标录入 | 中 |
|
||||
| 积分商城 | 积分获取、商品兑换、二维码核销 | 高 |
|
||||
| 线下活动 | 活动管理、报名、扫码签到 | 中 |
|
||||
| 在线咨询(IM) | 客服通道、医生通道、图文/语音消息 | 高 |
|
||||
| 医护端小程序 | 独立小程序,医护专属功能 | 高 |
|
||||
| 统计报表中心 | 患者增长、咨询量、随访完成率、商城销售 | 中-高 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 积分商城设计
|
||||
|
||||
### 3.1 核心链路
|
||||
|
||||
**赚积分 → 攒积分 → 花积分 → 核销**
|
||||
|
||||
### 3.2 实现方式
|
||||
|
||||
放在 `erp-health` 原生模块内,直接复用现有患者体系和事件机制。
|
||||
|
||||
### 3.3 积分获取渠道(数据库可配置)
|
||||
|
||||
| 渠道 | 积分 | 说明 |
|
||||
|------|------|------|
|
||||
| 每日健康打卡 | 可配置/天 | 完成日常监测数据填写触发 |
|
||||
| 数据上报 | 可配置/次 | 上传化验单、填透析记录 |
|
||||
| 线下活动签到 | 活动配置 | 到院参加讲座/义诊等 |
|
||||
| 连续打卡奖励 | 阶梯配置 | 连续 7/14/30 天额外加分 |
|
||||
| 医生互动 | 可配置/次 | 完成一次咨询、回复随访问卷 |
|
||||
|
||||
积分获取规则通过 `points_rule` 表配置,管理员可在 PC 端调整积分值、每日上限、连续奖励等参数。
|
||||
|
||||
### 3.4 兑换品类
|
||||
|
||||
| 类型 | 兑换物 | 履约方式 |
|
||||
|------|--------|---------|
|
||||
| 实物 | 血透护理用品、肾病食品、慢病器械 | 到院自提(二维码核销) |
|
||||
| 服务券 | 免费抽血、肾功能检查、营养咨询 | 生成预约券 → 线下二维码核销 |
|
||||
| 权益 | 免费停车券、优先预约权 | 虚拟权益即时生效 |
|
||||
|
||||
### 3.5 核销方式
|
||||
|
||||
用户兑换后生成二维码(UUID),到院后工作人员扫码核销。
|
||||
|
||||
服务券类型核销后可自动关联预约系统创建预约。
|
||||
|
||||
### 3.6 积分过期机制
|
||||
|
||||
**滚动 12 个月过期,FIFO 先进先出结算。**
|
||||
|
||||
- 每笔积分(earn)有独立 `expires_at`(创建时间 + 12 个月)
|
||||
- 每日后台任务扫描并标记过期积分
|
||||
- 消费时从最老的未过期积分开始扣减
|
||||
- 每笔积分支持部分消费(`remaining_amount` 字段)
|
||||
|
||||
### 3.7 数据模型(8 张新表)
|
||||
|
||||
#### points_account(积分账户)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient,唯一约束 |
|
||||
| balance | i32 | 当前可用积分 |
|
||||
| total_earned | i32 | 累计获得 |
|
||||
| total_spent | i32 | 累计消耗 |
|
||||
| total_expired | i32 | 累计过期 |
|
||||
| version | i32 | 乐观锁 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| created_at / updated_at / created_by / updated_by / deleted_at | — | 标准字段 |
|
||||
|
||||
#### points_rule(积分规则,数据库可配置)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| event_type | String | 触发事件:daily_checkin / data_report / lab_upload / event_checkin / consultation_complete / followup_complete |
|
||||
| name | String | 规则名称(展示用) |
|
||||
| description | String | 规则描述 |
|
||||
| points_value | i32 | 单次获得积分 |
|
||||
| daily_cap | i32 | 每日上限(0 = 无限制) |
|
||||
| streak_7d_bonus | i32 | 连续 7 天额外奖励 |
|
||||
| streak_14d_bonus | i32 | 连续 14 天额外奖励 |
|
||||
| streak_30d_bonus | i32 | 连续 30 天额外奖励 |
|
||||
| is_active | bool | 是否启用 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### points_transaction(积分流水,FIFO 桶模型)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| account_id | UUID | FK → points_account |
|
||||
| type | Enum | earn / spend / expired / refund |
|
||||
| amount | i32 | 正数=获得,负数=消耗 |
|
||||
| remaining_amount | i32 | 该笔积分剩余可用量(earn 类型) |
|
||||
| status | Enum | active / expired / consumed |
|
||||
| expires_at | DateTime | 过期时间(earn 类型:创建 + 12 个月) |
|
||||
| balance_after | i32 | 操作后账户余额快照 |
|
||||
| rule_id | UUID | FK → points_rule(earn 类型) |
|
||||
| order_id | UUID | FK → points_order(spend 类型,可空) |
|
||||
| description | String | 流水描述 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### points_product(兑换商品)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| name | String | 商品名称 |
|
||||
| type | Enum | physical / service / privilege |
|
||||
| points_cost | i32 | 兑换所需积分 |
|
||||
| stock | i32 | 库存数量(-1 = 无限) |
|
||||
| image_url | String | 商品图片 |
|
||||
| description | Text | 商品描述 |
|
||||
| service_config | JSON | 服务类型配置(service 类型:关联检查项目、有效期等) |
|
||||
| is_active | bool | 是否上架 |
|
||||
| sort_order | i32 | 排序权重 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### points_order(兑换订单)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| product_id | UUID | FK → points_product |
|
||||
| points_cost | i32 | 消耗积分(冗余,防商品价格变化) |
|
||||
| status | Enum | pending / verified / cancelled / expired |
|
||||
| qr_code | UUID | 核销二维码(UUID v4) |
|
||||
| verified_by | UUID | 核销人 FK → user |
|
||||
| verified_at | DateTime | 核销时间 |
|
||||
| expires_at | DateTime | 订单过期时间(实物/服务券有效期) |
|
||||
| notes | String | 备注 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### points_checkin(每日打卡)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| checkin_date | Date | 打卡日期 |
|
||||
| consecutive_days | i32 | 连续打卡天数(计算值) |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| created_at | DateTime | — |
|
||||
|
||||
唯一约束:(patient_id, checkin_date)
|
||||
|
||||
#### offline_event(线下活动)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| title | String | 活动标题 |
|
||||
| description | Text | 活动描述 |
|
||||
| event_date | Date | 活动日期 |
|
||||
| start_time / end_time | Time | 活动时间 |
|
||||
| location | String | 活动地点 |
|
||||
| points_reward | i32 | 参与奖励积分 |
|
||||
| max_participants | i32 | 最大参与人数(0 = 无限制) |
|
||||
| current_participants | i32 | 已报名人数 |
|
||||
| status | Enum | draft / published / ongoing / completed / cancelled |
|
||||
| image_url | String | 活动封面图 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### offline_event_registration(活动报名 + 签到)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| event_id | UUID | FK → offline_event |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| status | Enum | registered / checked_in / cancelled |
|
||||
| checked_in_at | DateTime | 签到时间 |
|
||||
| checked_in_by | UUID | 签到确认人 FK → user |
|
||||
| points_granted | bool | 是否已发放积分 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
唯一约束:(event_id, patient_id)
|
||||
|
||||
### 3.8 积分获取链路
|
||||
|
||||
```
|
||||
健康打卡 → 触发事件 → points_rule 匹配规则 → 写入 points_transaction → 更新 points_account.balance
|
||||
数据上报 → 同上 ↓
|
||||
咨询完成 → 同上 连续打卡检查
|
||||
线下签到 → 同上 → 达到 7/14/30 天?
|
||||
→ 额外 bonus transaction
|
||||
```
|
||||
|
||||
### 3.9 兑换核销链路
|
||||
|
||||
```
|
||||
用户浏览商品 → 兑换(扣积分)→ 生成 points_order + QR(UUID v4)
|
||||
↓
|
||||
用户到院出示二维码
|
||||
↓
|
||||
工作人员扫码(小程序/PC)→ 状态变 verified
|
||||
↓
|
||||
type=service → 自动创建预约券
|
||||
type=physical → 库存扣减
|
||||
```
|
||||
|
||||
### 3.10 后台任务
|
||||
|
||||
| 任务 | 频率 | 说明 |
|
||||
|------|------|------|
|
||||
| 积分过期扫描 | 每日 | 扫描 expires_at < now 的 earn 记录,标记 expired,扣减 balance |
|
||||
| 订单过期扫描 | 每日 | 扫描未核销且过期的订单,标记 expired,退还积分 |
|
||||
| 连续打卡计算 | 每日 | 计算各患者连续打卡天数,触发阶梯奖励 |
|
||||
|
||||
### 3.11 API 端点
|
||||
|
||||
**患者端:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/points/account | 查看我的积分账户 |
|
||||
| POST | /api/v1/points/checkin | 每日打卡 |
|
||||
| GET | /api/v1/points/checkin/status | 打卡状态(连续天数等) |
|
||||
| GET | /api/v1/points/transactions | 积分流水(分页) |
|
||||
| GET | /api/v1/points/products | 商品列表(分页、按类型筛选) |
|
||||
| GET | /api/v1/points/products/{id} | 商品详情 |
|
||||
| POST | /api/v1/points/exchange | 兑换商品 |
|
||||
| GET | /api/v1/points/orders | 我的兑换订单 |
|
||||
| GET | /api/v1/points/orders/{id} | 订单详情(含二维码) |
|
||||
| GET | /api/v1/offline-events | 线下活动列表 |
|
||||
| GET | /api/v1/offline-events/{id} | 活动详情 |
|
||||
| POST | /api/v1/offline-events/{id}/register | 报名活动 |
|
||||
|
||||
**医护/管理员端:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/v1/points/verify | 扫码核销 |
|
||||
| CRUD | /api/v1/admin/points/rules | 积分规则管理 |
|
||||
| CRUD | /api/v1/admin/points/products | 商品管理 |
|
||||
| GET | /api/v1/admin/points/orders | 订单管理(导出) |
|
||||
| GET | /api/v1/admin/points/statistics | 积分统计 |
|
||||
| CRUD | /api/v1/admin/offline-events | 线下活动管理 |
|
||||
| POST | /api/v1/admin/offline-events/{id}/checkin | 活动扫码签到 |
|
||||
|
||||
### 3.12 PC 管理后台新增页面
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| 积分规则管理 | 增删改规则、启用/禁用、调整积分值和上限 |
|
||||
| 商品管理 | 增删改兑换品、设置库存、上传图片、设置积分价格 |
|
||||
| 订单管理 | 查看兑换记录、手动核销、导出 |
|
||||
| 线下活动管理 | 创建活动、设置积分奖励、查看报名/签到名单 |
|
||||
| 积分统计 | 总发放/总消耗/活跃用户排行、积分流水查询 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 血透专科数据模型
|
||||
|
||||
### 4.1 设计决策
|
||||
|
||||
- **独立实体表**:不复用现有 health_data 键值对结构,每类数据一张独立表
|
||||
- **医护+患者协同上报**:透析记录由医护填写,化验报告由患者上传/医护审阅,日常监测由患者填写
|
||||
|
||||
### 4.2 权限矩阵
|
||||
|
||||
| 数据 | 患者端 | 医护端 | PC 管理后台 |
|
||||
|------|--------|--------|------------|
|
||||
| 透析记录 | 查看(只读) | 创建/编辑 | 查看/导出/统计 |
|
||||
| 化验报告 | 上传照片/查看 | 审阅/标注/解读 | 查看/导出 |
|
||||
| 日常监测 | 创建/查看 | 查看/预警 | 查看/统计 |
|
||||
| AI 健康报告 | 查看 | 查看/编辑 | 查看/导出 |
|
||||
|
||||
### 4.3 新增实体
|
||||
|
||||
#### dialysis_record(透析记录)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| dialysis_date | Date | 透析日期 |
|
||||
| start_time | Time | 开始时间 |
|
||||
| end_time | Time | 结束时间 |
|
||||
| dry_weight | Decimal(5,1) | 干体重 (kg) |
|
||||
| pre_weight | Decimal(5,1) | 透前体重 (kg) |
|
||||
| post_weight | Decimal(5,1) | 透后体重 (kg) |
|
||||
| pre_bp_systolic | i32 | 透前收缩压 |
|
||||
| pre_bp_diastolic | i32 | 透前舒张压 |
|
||||
| post_bp_systolic | i32 | 透后收缩压 |
|
||||
| post_bp_diastolic | i32 | 透后舒张压 |
|
||||
| pre_heart_rate | i32 | 透前心率 |
|
||||
| post_heart_rate | i32 | 透后心率 |
|
||||
| ultrafiltration_volume | i32 | 超滤量 (ml) |
|
||||
| dialysis_duration | i32 | 透析时长 (min) |
|
||||
| blood_flow_rate | i32 | 血流量 (ml/min) |
|
||||
| dialysis_type | Enum | HD / HDF / HF |
|
||||
| symptoms | JSON | 不适症状数组 ["低血压","恶心","抽筋"] |
|
||||
| complication_notes | Text | 并发症备注 |
|
||||
| status | Enum | draft / completed / reviewed |
|
||||
| reviewed_by | UUID | FK → user,审阅医生 |
|
||||
| reviewed_at | DateTime | 审阅时间 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | created_at / updated_at / created_by / updated_by / deleted_at / version |
|
||||
|
||||
#### lab_report(化验报告)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| report_date | Date | 化验日期 |
|
||||
| report_type | Enum | kidney_function / blood_routine / electrolyte / liver_function / other |
|
||||
| source | Enum | manual_input / photo_upload |
|
||||
| image_urls | JSON | 化验单照片 URL 数组 |
|
||||
| items | JSON | 指标数据数组(见下方结构) |
|
||||
| doctor_notes | Text | 医生解读/批注 |
|
||||
| reviewed_by | UUID | FK → user,审阅医生 |
|
||||
| reviewed_at | DateTime | 审阅时间 |
|
||||
| status | Enum | pending / reviewed |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
**items JSON 结构:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "肌酐",
|
||||
"value": "856",
|
||||
"unit": "μmol/L",
|
||||
"reference_low": 44,
|
||||
"reference_high": 133,
|
||||
"is_abnormal": true
|
||||
},
|
||||
{
|
||||
"name": "血钾",
|
||||
"value": "6.2",
|
||||
"unit": "mmol/L",
|
||||
"reference_low": 3.5,
|
||||
"reference_high": 5.3,
|
||||
"is_abnormal": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### daily_monitoring(日常监测)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| record_date | Date | 记录日期 |
|
||||
| morning_bp_systolic | i32 | 晨起收缩压 |
|
||||
| morning_bp_diastolic | i32 | 晨起舒张压 |
|
||||
| evening_bp_systolic | i32 | 晚间收缩压 |
|
||||
| evening_bp_diastolic | i32 | 晚间舒张压 |
|
||||
| weight | Decimal(5,1) | 体重 (kg) |
|
||||
| blood_sugar | Decimal(4,1) | 血糖 (mmol/L) |
|
||||
| fluid_intake | i32 | 饮水量 (ml) |
|
||||
| urine_output | i32 | 尿量 (ml) |
|
||||
| notes | Text | 备注 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
唯一约束:(patient_id, record_date)
|
||||
|
||||
### 4.4 与现有模块的关系
|
||||
|
||||
```
|
||||
patient(已有)
|
||||
├── dialysis_record(新增) 1:N 每次透析一条
|
||||
├── lab_report(新增) 1:N 每次化验一份
|
||||
├── daily_monitoring(新增) 1:N 每天一条
|
||||
├── health_data(已有,保留) 1:N 通用指标仍可用
|
||||
├── appointment(已有) 1:N 透析预约走这里
|
||||
└── follow_up(已有) 1:N 随访任务
|
||||
```
|
||||
|
||||
### 4.5 化验报告 items 用 JSON 的原因
|
||||
|
||||
- 化验项目数量和类型因报告而异(肾功能 8 项 vs 血常规 20+ 项)
|
||||
- 不需要按单个指标做复杂查询(都是按患者+日期范围查整份报告)
|
||||
- JSON 内含 `is_abnormal` 标记,前端直接渲染异常标红
|
||||
- PostgreSQL JSONB 支持按单个指标查询趋势(未来可加物化视图展平)
|
||||
|
||||
### 4.6 数据上报协同流程
|
||||
|
||||
**透析记录**:医护在医护端小程序/PC 填写 → 患者端只读查看
|
||||
|
||||
**化验报告**:患者拍照上传照片 + 手动填写指标 → 医护审阅/标注/解读 → 患者查看异常标红+医生解读
|
||||
|
||||
**日常监测**:患者每日填写(血压/体重/血糖/饮水量/尿量)→ 触发积分获取 → 医护端查看趋势+异常预警
|
||||
|
||||
### 4.7 新增 API 端点
|
||||
|
||||
**透析记录:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/health/patients/{id}/dialysis-records | 患者透析记录列表 |
|
||||
| GET | /api/v1/health/dialysis-records/{id} | 透析记录详情 |
|
||||
| POST | /api/v1/health/dialysis-records | 创建透析记录(医护) |
|
||||
| PUT | /api/v1/health/dialysis-records/{id} | 更新透析记录(医护) |
|
||||
|
||||
**化验报告:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/health/patients/{id}/lab-reports | 患者化验报告列表 |
|
||||
| GET | /api/v1/health/lab-reports/{id} | 报告详情 |
|
||||
| POST | /api/v1/health/lab-reports | 上传化验报告(患者/医护) |
|
||||
| PUT | /api/v1/health/lab-reports/{id}/review | 医生审阅(标注+解读) |
|
||||
|
||||
**日常监测:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/health/patients/{id}/daily-monitoring | 患者日常监测列表 |
|
||||
| POST | /api/v1/health/daily-monitoring | 上报日常监测(患者) |
|
||||
| GET | /api/v1/health/daily-monitoring/trend | 趋势数据查询 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 小程序迭代设计
|
||||
|
||||
### 5.1 患者端小程序
|
||||
|
||||
#### 新 TabBar 结构
|
||||
|
||||
```
|
||||
首页 | 上报 | 咨询 | 商城 | 我的
|
||||
```
|
||||
|
||||
替换现有:~~首页 | 健康 | 预约 | 资讯 | 我的~~
|
||||
|
||||
#### 首页(改造)
|
||||
|
||||
- 公告轮播(Banner 组件)
|
||||
- 功能入口 Grid:数据上报、我的医生、在线咨询、血透预约、积分商城
|
||||
- 今日健康打卡入口(未打卡时醒目提示)
|
||||
- 健康概览卡片:血压、体重、最近透析记录
|
||||
- 今日提醒列表:透析预约、血压测量、用药提醒
|
||||
|
||||
#### 上报 Tab(改造自"健康")
|
||||
|
||||
- 打卡区:血压/体重/血糖/饮水/尿量 快捷填写入口
|
||||
- 数据类型切换:日常监测 / 透析记录(只读)/ 化验报告
|
||||
- 趋势图(ECharts):血压/体重/血糖 折线图
|
||||
- 子页面:
|
||||
- 日常监测填写表单
|
||||
- 化验报告上传(拍照 + 指标填写)
|
||||
- 透析记录详情(只读)
|
||||
- 化验报告详情(含医生批注)
|
||||
- 趋势分析详情(大图 + AI 解读)
|
||||
|
||||
#### 咨询 Tab(全新)
|
||||
|
||||
- 咨询类型切换:医生 / 客服
|
||||
- 最近对话列表(未读红点)
|
||||
- 子页面:
|
||||
- 选择医生
|
||||
- 聊天界面(图文/语音 + 上传报告)
|
||||
- 客服对话
|
||||
|
||||
#### 商城 Tab(全新)
|
||||
|
||||
- 我的积分 + 签到按钮
|
||||
- 商品分类:全部 / 实物 / 检查 / 权益
|
||||
- 商品列表(网格)
|
||||
- 线下活动入口
|
||||
- 子页面:
|
||||
- 商品详情 + 兑换
|
||||
- 兑换确认(生成二维码)
|
||||
- 我的订单(待核销/已核销/已过期)
|
||||
- 线下活动详情 + 报名
|
||||
- 积分明细
|
||||
|
||||
#### 我的 Tab(改造)
|
||||
|
||||
- 个人信息 + 积分展示 + 连续打卡天数
|
||||
- 就诊人管理(已有)
|
||||
- 健康档案
|
||||
- 我的医生
|
||||
- 我的预约(从 TabBar 降级为菜单入口)
|
||||
- 我的报告(已有)
|
||||
- 我的随访(已有)
|
||||
- 我的订单(积分商城)
|
||||
- 消息通知(新增)
|
||||
- 用药提醒(已有,需接入后端)
|
||||
- 设置(已有)
|
||||
|
||||
### 5.2 医护端小程序(V2)
|
||||
|
||||
医护端为独立小程序(独立 AppID),V2 实现。V1 阶段医护功能通过 PC 管理后台覆盖。
|
||||
|
||||
#### V2 页面结构(约 18 页)
|
||||
|
||||
TabBar:概览 | 患者 | 咨询 | 随访 | 我的
|
||||
|
||||
- **概览**:今日待回复咨询数、异常预警列表、今日透析患者数、随访任务数
|
||||
- **患者**:患者列表(按透析/高危/标签筛选)、患者详情(档案+透析记录+化验+趋势图+标签)、填写透析记录、报告解读
|
||||
- **咨询**:未读消息列表、图文/语音回复、发送科普文章
|
||||
- **随访**:随访任务列表、填写记录、台账导出
|
||||
- **我的**:医生信息、我的患者、排班日历、科普文章管理
|
||||
|
||||
### 5.3 页面工作量统计
|
||||
|
||||
| 端 | 改造页 | 新增页 | 总计 |
|
||||
|----|--------|--------|------|
|
||||
| 患者端 | 5(首页/健康/我的/预约/资讯改造) | ~15(咨询3+商城6+上报子页4+通知2) | ~20 页 |
|
||||
| 医护端(V2) | 0 | 18 | 18 页 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 在线咨询设计(V2 详情待补充)
|
||||
|
||||
### 6.1 核心需求
|
||||
|
||||
- 客服通道:订单/物流/使用问题
|
||||
- 医生通道:选择医生、发送问题、上传报告、语音/文字沟通
|
||||
- 留言功能:医生离线时留存问题
|
||||
|
||||
### 6.2 技术方向
|
||||
|
||||
- V1 可先实现基于轮询的图文消息(复用现有 Consultation 模块)
|
||||
- V2 升级为 WebSocket 实时通信 + 语音消息
|
||||
- 客服通道可对接第三方客服系统(如美洽、智齿)
|
||||
|
||||
---
|
||||
|
||||
## 7. PC 管理后台新增页面
|
||||
|
||||
### 7.1 健康数据中心(新增)
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| 透析数据统计 | 透析次数趋势、干体重变化、超滤量统计 |
|
||||
| 异常指标排行 | 血钾/血磷/肌酐异常患者排行 |
|
||||
| 上报率统计 | 患者数据上报活跃度、打卡率 |
|
||||
|
||||
### 7.2 商城管理(新增)
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| 积分规则管理 | 增删改积分获取规则 |
|
||||
| 商品管理 | 增删改兑换商品、库存管理 |
|
||||
| 订单管理 | 兑换记录查看、手动核销、导出 |
|
||||
| 线下活动管理 | 活动创建、报名/签到管理 |
|
||||
| 积分统计 | 发放/消耗/活跃排行 |
|
||||
|
||||
### 7.3 统计报表(新增)
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| 患者增长 | 新增患者趋势、活跃度 |
|
||||
| 咨询量 | 咨询次数/回复率/满意度 |
|
||||
| 随访完成率 | 随访任务执行统计 |
|
||||
| 商城数据 | 兑换排行、库存周转 |
|
||||
|
||||
---
|
||||
|
||||
## 8. AI 分析能力(V2)
|
||||
|
||||
### 8.1 V1 预留
|
||||
|
||||
- 趋势图已通过 ECharts 实现(现有 TrendChart 组件)
|
||||
- 异常指标通过 `is_abnormal` 标记和阈值校验实现
|
||||
- 健康报告可生成基础版(数据汇总 + 异常标注)
|
||||
|
||||
### 8.2 V2 增强
|
||||
|
||||
- LLM 集成:自然语言健康报告生成
|
||||
- AI 辅助:化验单 OCR 自动识别
|
||||
- 智能预警:基于历史数据的异常趋势预测
|
||||
|
||||
---
|
||||
|
||||
## 9. 分期建议
|
||||
|
||||
### V1.1(建议优先)
|
||||
|
||||
1. 血透专科数据模型(3 张新表 + API)
|
||||
2. 患者端小程序改造(TabBar + 首页 + 上报扩展)
|
||||
3. 积分商城后端 + 小程序前端
|
||||
4. 线下活动管理
|
||||
|
||||
### V1.2
|
||||
|
||||
1. 在线咨询(轮询版)
|
||||
2. 化验报告上传 + 审阅流程
|
||||
3. PC 管理后台新增页面
|
||||
4. 统计报表
|
||||
|
||||
### V2
|
||||
|
||||
1. 医护端小程序
|
||||
2. 在线咨询升级(WebSocket + 语音)
|
||||
3. AI 分析增强(LLM + OCR)
|
||||
4. Redis 缓存层
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 积分 FIFO 结算复杂度 | 并发消费时积分桶冲突 | 使用数据库事务 + 乐观锁(account.version) |
|
||||
| 在线咨询工作量超预期 | V1.2 延期 | V1 先做轮询图文,WebSocket 推迟到 V2 |
|
||||
| 医护端小程序工作量大 | V2 延期 | V1 阶段用 PC 后台替代医护端功能 |
|
||||
| 化验单 OCR 准确率 | 用户体验差 | V1 先做手动填写,OCR 作为 V2 AI 增量 |
|
||||
| 积分通胀 | 积分价值稀释 | 可配置每日上限 + 过期机制 + 运营调整积分价格 |
|
||||
@@ -1,406 +0,0 @@
|
||||
# HMS 平台基座回顾与演进设计
|
||||
|
||||
> 日期: 2026-04-26 | 状态: Draft | 方法: 三专家多视角评审
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 回顾目的
|
||||
|
||||
HMS 健康管理平台经过 17 天密集开发(2026-04-10 ~ 2026-04-26),从 ERP 底座演进到包含 16 个 Rust crate、62 个前端页面、27 个小程序页面的综合医疗 SaaS 平台。本次回顾旨在:
|
||||
|
||||
- **验证基座设计** — 星形依赖拓扑、ErpModule trait、事件总线、多租户策略是否经得起实践检验
|
||||
- **评估演进路径** — 从插件开发模式到原生模块开发的决策是否正确
|
||||
- **识别缺口与风险** — 通过多专家视角发现盲点
|
||||
- **制定演进路线** — 基于 P0/P1/P2 优先级指导后续迭代
|
||||
|
||||
### 1.2 评审方法
|
||||
|
||||
采用三专家独立评审,每个专家从不同视角分析相同的诊断和建议:
|
||||
|
||||
| 专家 | 视角 | 关注点 |
|
||||
|------|------|--------|
|
||||
| 高级系统架构师 | 架构可持续性 | 模块边界、事件可靠性、技术债 |
|
||||
| 医疗信息化专家 | 临床安全与合规 | 患者安全、PIPL 合规、领域模型 |
|
||||
| 产品策略专家 | ROI 与开发节奏 | 优先级、技术债量化、路线图现实性 |
|
||||
|
||||
### 1.3 核心结论
|
||||
|
||||
**基座设计方向正确,但深度不足。** 星形依赖、trait 抽象、事件总线等基础架构经受住了实践检验。但在临床安全(危急值告警未闭环)、合规(知情同意缺失)、事件可靠性(无重放机制)方面存在需立即修复的缺口。插件系统已验证可行性但对 HMS 核心业务贡献有限,建议有条件冻结。
|
||||
|
||||
---
|
||||
|
||||
## 2. 基座设计验证
|
||||
|
||||
### 2.1 评分总览
|
||||
|
||||
| 维度 | 评分 | 说明 |
|
||||
|------|------|------|
|
||||
| 模块边界 | ★★★★ | 星形拓扑零循环依赖,trait 契约清晰 |
|
||||
| ErpModule trait | ★★★★ | 生命周期/权限/事件/健康检查统一接口 |
|
||||
| 事件总线 | ★★★☆ | 基础设施扎实(broadcast+outbox),但无重放机制,消费侧不完整 |
|
||||
| 多租户 | ★★★☆ | JWT→TenantContext 全链路贯通,但缺 RLS 兜底和集成测试 |
|
||||
| 权限体系 | ★★★★ | RBAC + 行级数据权限 + 按钮级控制 |
|
||||
| 插件系统 | ★★★☆ | CRUD 场景验证通过,医疗场景天花板明显 |
|
||||
| API 一致性 | ★★★★ | 统一 envelope、分页、OpenAPI 自动文档 |
|
||||
| 数据库迁移 | ★★★★ | 59 个迁移,幂等、可回滚、fixup 模式健康 |
|
||||
| 测试覆盖 | ★☆☆☆ | 36 后端 + 3 前端,覆盖率 < 5% |
|
||||
| 合规性 | ★☆☆☆ | 知情同意缺失,审计不完整,PIE 加密范围不足 |
|
||||
|
||||
### 2.2 星形依赖拓扑
|
||||
|
||||
```
|
||||
erp-core (L1)
|
||||
/ | \ \ \ \
|
||||
erp-auth workflow message config erp-health erp-plugin erp-ai
|
||||
\ | / / / / /
|
||||
erp-server (L3, 组装入口)
|
||||
```
|
||||
|
||||
- `erp-core`:零业务依赖,纯净基础层
|
||||
- 7 个业务 crate:各只依赖 `erp-core`,兄弟间无横向依赖
|
||||
- `erp-server`:唯一组装点,负责路由合并和模块初始化
|
||||
- **无循环依赖** — 架构师验证通过
|
||||
|
||||
### 2.3 ErpModule trait
|
||||
|
||||
当前 trait 提供统一的模块接口:
|
||||
|
||||
- **身份**:`name()` / `id()` / `version()`
|
||||
- **依赖声明**:`dependencies()` — 用于拓扑排序启动顺序
|
||||
- **生命周期**:`on_startup()` / `on_shutdown()` / `health_check()`
|
||||
- **多租户**:`on_tenant_created()` / `on_tenant_deleted()`
|
||||
- **权限自描述**:`permissions()` — 模块声明自己需要的权限码
|
||||
- **事件订阅**:`register_event_handlers()` / `as_any()`
|
||||
|
||||
**已知张力**:路由注册不在 trait 中,而是通过各模块的 inherent method (`public_routes()` / `protected_routes()`) 手动在 `main.rs` 中合并。原因是 Axum 的 `Router<S>` 泛型约束不适合 trait object。这是务实的妥协,但在添加新模块时有 boilerplate 成本。
|
||||
|
||||
### 2.4 事件总线
|
||||
|
||||
**实现机制**:`tokio::sync::broadcast` (容量 1024) + `domain_events` 表持久化(best-effort)+ Outbox relay (5秒轮询,3次重试)
|
||||
|
||||
**发布侧**(已识别的事件类型):
|
||||
|
||||
| 模块 | 事件类型数 | 示例 |
|
||||
|------|-----------|------|
|
||||
| erp-auth | 10 | `user.login`, `user.created`, `role.created` |
|
||||
| erp-workflow | 4 | `process_instance.started`, `task.completed` |
|
||||
| erp-message | 1 | `message.sent` |
|
||||
| erp-health | 13 | `patient.created`, `health_data.critical_alert`, `follow_up.overdue` |
|
||||
| erp-plugin | 2+ | `plugin.config.updated`, `plugin.trigger.*` |
|
||||
|
||||
**消费侧**(已识别的订阅者):
|
||||
|
||||
| 订阅者 | 订阅方式 | 处理的事件 |
|
||||
|--------|---------|-----------|
|
||||
| erp-message | `subscribe()` 全量 | `appointment.*`, `process_instance.*`, `task.*` |
|
||||
| erp-health | `register_handlers_with_state` | `workflow.task.completed` |
|
||||
| erp-plugin 通知 | `subscribe_filtered("plugin.trigger.*")` | 插件触发通知 |
|
||||
| outbox relay | 轮询 DB | 重发 pending 事件 |
|
||||
|
||||
**已识别缺陷**:
|
||||
1. **无重放机制** — 内存 broadcast,服务重启后未消费的事件丢失
|
||||
2. **无幂等保护** — `follow_up.overdue` 每 6 小时检查会重复发布同一条逾期事件
|
||||
3. **全量订阅** — erp-message 使用 `subscribe()` 而非 `subscribe_filtered()`,所有事件都经过消息模块
|
||||
|
||||
### 2.5 多租户
|
||||
|
||||
**已实现**:
|
||||
- JWT claims 提取 `tenant_id` → `TenantContext` 注入请求扩展
|
||||
- 所有 Entity 含 `tenant_id` 字段,BaseFields 统一
|
||||
- 所有 DomainEvent 携带 `tenant_id`
|
||||
- `on_tenant_created()` / `on_tenant_deleted()` 钩子(auth 和 health 已实现)
|
||||
- 部门级数据范围(`department_ids` 在 TenantContext 中)
|
||||
|
||||
**缺失**:
|
||||
- 无 PostgreSQL RLS policy 作为兜底层
|
||||
- 无强制 tenant_id 过滤的查询层机制 — 依赖每个 service 手动 `.filter()`
|
||||
- 当前实际只有 default_tenant,微信登录硬编码使用 `default_tenant_id`
|
||||
- 无多租户管理 API(创建/配置/迁移)
|
||||
|
||||
---
|
||||
|
||||
## 3. 演进路径回顾
|
||||
|
||||
### 3.1 时间线
|
||||
|
||||
```
|
||||
4/10-4/16 基座搭建 (Phase 1-6)
|
||||
→ core → auth → config → workflow → message
|
||||
→ 全部原生 Rust 模块,30+ 数据库表
|
||||
|
||||
4/13-4/18 WASM 插件实验
|
||||
→ 插件系统设计与实现 (Wasmtime + WIT bindgen)
|
||||
→ CRM (5实体) → Inventory (6实体) → Freelance → ITOps
|
||||
→ 证明:CRUD 密集型领域可行,沙盒隔离有效
|
||||
→ 跨插件数据引用未解决
|
||||
|
||||
4/23-4/26 HMS 分叉 — 健康模块原生开发
|
||||
→ 18+ 强类型实体 (患者/家属/医生/预约/排班/随访/咨询/体征/化验/透析/诊断/积分...)
|
||||
→ PII 加密 (AES-256-GCM)、脱敏管道
|
||||
→ AI 模块 (4 SSE 流式端点 + 3 REST 端点)
|
||||
→ 微信小程序 (27 页面)
|
||||
→ 按钮级权限控制
|
||||
```
|
||||
|
||||
### 3.2 从插件到原生的决策链
|
||||
|
||||
**原始插件愿景**(设计规格 2026-04-13):
|
||||
|
||||
- 平台模块原生,行业模块 WASM 插件
|
||||
- 插件通过 9 个 Host API 函数通信(db_insert/query/update/delete、event_publish、config_get 等)
|
||||
- 数据存 JSONB 动态表,路由自动生成
|
||||
- UI 配置驱动,通用 PluginCRUDPage 组件
|
||||
|
||||
**健康模块原生的 5 个硬限制**(设计规格 2026-04-23 §1.3):
|
||||
|
||||
| 限制 | 影响 | 不可妥协原因 |
|
||||
|------|------|-------------|
|
||||
| 20 实体上限 | 健康平台轻松超过 | 18+ 实体已是最低合理粒度 |
|
||||
| JSONB 存储 | 无强类型、无外键约束 | 医疗数据需要引用完整性和精确索引 |
|
||||
| 无自定义 API | 只有自动 CRUD | 趋势分析/统计报表/日历视图无法实现 |
|
||||
| 无文件上传 | 沙盒阻止文件系统访问 | 化验单/体检报告需要文件存储 |
|
||||
| WASM 沙盒限制 | 无 native crypto/外部 API/后台任务 | PII 加密、微信集成、定时任务全部需要 |
|
||||
|
||||
### 3.3 得失评估
|
||||
|
||||
**得 — 正确的决策:**
|
||||
|
||||
| 决策 | 收益 |
|
||||
|------|------|
|
||||
| 星形依赖拓扑 | 模块独立性强,可独立测试和替换 |
|
||||
| ErpModule 统一接口 | 新模块注册流程标准化 |
|
||||
| 事件总线 | 跨模块解耦通信的基础设施已就绪 |
|
||||
| JWT→TenantContext | 多租户全链路贯通 |
|
||||
| 健康模块原生 | 不受沙盒限制,加密/文件/后台任务全部可用 |
|
||||
| 插件实验 | 验证了平台灵活性,CRM/库存可正常使用 |
|
||||
|
||||
**失 — 需要修正的问题:**
|
||||
|
||||
| 决策 | 代价 |
|
||||
|------|------|
|
||||
| 插件系统投入过大 | 22,000 行代码(41% Rust 总量),对 HMS 核心业务贡献接近零 |
|
||||
| 积分系统混入 health | 8 实体/12+ 路由,增加合规复杂度和数据泄露面 |
|
||||
| 事件消费侧忽视 | 13 个事件只有 3 个被消费,危急告警和逾期通知空转 |
|
||||
| 测试覆盖极薄 | 36 后端 + 3 前端测试,覆盖率 < 5% |
|
||||
| 合规意识不足 | 知情同意缺失、审计不完整、PIE 加密范围不足 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 三专家评审摘要
|
||||
|
||||
### 4.1 高级系统架构师
|
||||
|
||||
**诊断准确度:7/10** — 四个张力都真实存在,但优先级和细节有偏差。
|
||||
|
||||
关键补充:
|
||||
|
||||
| 发现 | 严重程度 |
|
||||
|------|---------|
|
||||
| WIT 接口是同步调用(阻塞),WASM 运行时嵌入主进程(故障隔离不足) | 架构隐患 |
|
||||
| EventBus 内存 broadcast 无重放机制,服务重启丢事件 | P1 |
|
||||
| `follow_up.overdue` 无幂等保护,每 6h 检查重复发布 | P0 |
|
||||
| erp-message 用 `subscribe()` 全量订阅,性能隐患 | P1 |
|
||||
| RLS 不是 P0,多租户集成测试才是 | 观点 |
|
||||
| 积分系统(8 独立实体、12+ 路由)不应在 erp-health 内 | 共识 |
|
||||
| 缺监控/可观测性、数据备份策略、API 版本升级路线图 | 盲点 |
|
||||
|
||||
核心原则:**先补测试再重构,先修事件再上功能,先验证再加固。**
|
||||
|
||||
### 4.2 医疗信息化专家
|
||||
|
||||
**发现了比原始诊断更深层的临床安全风险。**
|
||||
|
||||
| 新发现 | 严重程度 |
|
||||
|--------|---------|
|
||||
| 危急值阈值全部硬编码(收缩压 180/80、心率 150/40),不可配置 | P0 |
|
||||
| `daily_monitoring` 表体征数据不经过危急值检测(合并前遗留) | P0 |
|
||||
| 过敏史更新直接覆盖,无变更历史 | P0 |
|
||||
| 知情同意完全缺失(搜索 consent/同意/授权/隐私 零结果) | P0 — PIPL 违规 |
|
||||
| 只有身份证号存储加密,姓名/过敏史/诊断/咨询内容明文 | P1 |
|
||||
| 审计日志不完整 — 只有预约状态变更记录前后值 | P1 |
|
||||
| `ip_address` 和 `user_agent` 从未被填充 | P1 |
|
||||
| 读操作(查看患者详情/化验报告)完全没有审计记录 | P1 |
|
||||
| 诊断记录 `icd_code` 只做字符串约束,无格式校验,无同行审核 | P1 |
|
||||
|
||||
合规评估:PIPL 第 29 条要求处理敏感个人信息须取得单独同意。医疗数据属于敏感个人信息。知情同意缺失是法律红线。
|
||||
|
||||
领域模型建议:积分系统(6 实体 + 2 线下活动实体)应拆分为独立 `erp-points` 或 `erp-engagement` 模块,与健康数据分离以降低合规复杂度。
|
||||
|
||||
### 4.3 产品策略专家
|
||||
|
||||
**开发节奏不可持续但不必恐慌。**
|
||||
|
||||
| 分析 | 结论 |
|
||||
|------|------|
|
||||
| 峰值 68 提交/天,fix 提交占 21.6% | 短期冲刺可以,长期人会耗竭 |
|
||||
| 41% Rust 代码在插件系统,对核心业务贡献接近零 | 最大 ROI 失衡 |
|
||||
| 单人 + AI 的"速度幻觉" | 68 提交/天 = 审查不足,积分混入 health 就是例证 |
|
||||
| 测试覆盖 < 5% | 正确水位不是 80%,而是关键路径不回退(目标 50-80 用例,3-4 天) |
|
||||
|
||||
关键风险缓解建议:
|
||||
- ADR(架构决策记录)强制化
|
||||
- 医疗安全代码双人外部 review
|
||||
- 每日提交上限 15 次
|
||||
- 每月需求裁剪
|
||||
|
||||
V2 血透路线图评估:技术储备已够(`dialysis_service` 286 行骨架在),但缺市场验证。建议先做 3-5 家目标客户调研,确认需求后再做 2 周 MVP 试运行。
|
||||
|
||||
---
|
||||
|
||||
## 5. 共识优先级
|
||||
|
||||
### 5.1 三专家加权共识矩阵
|
||||
|
||||
| 议题 | 架构师 | 医疗专家 | 产品策略 | 共识等级 |
|
||||
|------|--------|---------|---------|---------|
|
||||
| 危急值告警闭环 | P0 | P0 + 硬编码 | P0 | 三方一致 |
|
||||
| 知情同意 (PIPL) | 未涉及 | P0 | P0 | 两方一致 |
|
||||
| 审计日志补全 | 未涉及 | P1 | P0 | P0-P1 |
|
||||
| EventBus 可靠性 | P1 | 未涉及 | P0 | P0-P1 |
|
||||
| 随访逾期通知 | P0 | P0 | P0 | 三方一致 |
|
||||
| 积分系统拆分 | 应拆 | 应拆(合规) | 占 19.5% | 三方一致 |
|
||||
| RLS | 不是 P0 | P1 | P0 | 有分歧 |
|
||||
| 插件系统 | 有条件冻结 | 未涉及 | 冻结 | 两方一致 |
|
||||
| 测试覆盖 | 先补测试 | 上线前必修 | 50-80 用例 | 三方一致 |
|
||||
| V2 血透 | 未涉及 | 缺标准流程 | 先调研 | 两方一致 |
|
||||
|
||||
### 5.2 P0 — 上线前必修(估计 2-3 周)
|
||||
|
||||
| 序号 | 项 | 工作量 | 负责 crate | 说明 |
|
||||
|------|---|--------|-----------|------|
|
||||
| 1 | 危急值告警消费者 | 1 天 | erp-health + erp-message | `health_data.critical_alert` → 推送通知给责任医护 |
|
||||
| 2 | 危急值阈值可配置化 | 2 天 | erp-health | 硬编码阈值改为数据库配置,支持科室/年龄差异化 |
|
||||
| 3 | daily_monitoring 合并后告警验证 | 1 天 | erp-health | 确认合并到 vital_signs 后所有体征数据都经过告警检测 |
|
||||
| 4 | 随访逾期通知 | 1 天 | erp-health + erp-message | `follow_up.overdue` → 催办通知 + 幂等保护 |
|
||||
| 5 | 知情同意记录 | 3 天 | erp-health | 患者数据处理同意获取和记录机制 |
|
||||
| 6 | 审计日志补全 | 3 天 | erp-core + erp-health | 临床数据变更记录前后值、读操作审计、IP/UA 填充 |
|
||||
| 7 | EventBus 持久化增强 | 2 天 | erp-core | 服务重启不丢事件 + overdue 事件幂等 |
|
||||
|
||||
### 5.3 P1 — 治理(2-4 周)
|
||||
|
||||
| 序号 | 项 | 工作量 | 说明 |
|
||||
|------|---|--------|------|
|
||||
| 8 | 积分系统剥离 | 5 天 | 从 erp-health 拆分为独立 erp-engagement crate |
|
||||
| 9 | 关键路径测试 | 4 天 | 多租户隔离、患者安全路径、预约并发(50-80 用例) |
|
||||
| 10 | 插件系统冻结声明 | 0.5 天 | 保留代码,README 声明实验性,不再投入 |
|
||||
| 11 | erp-message 改用 `subscribe_filtered` | 1 天 | 减少无效事件传递 |
|
||||
| 12 | 统一事件消费模式 | 2 天 | 消除 `register_event_handlers` vs `on_startup` 双路径 |
|
||||
| 13 | 过敏史变更历史 | 1 天 | 更新时记录旧值 |
|
||||
|
||||
### 5.4 P2 — 扩展(后续迭代)
|
||||
|
||||
| 序号 | 项 | 前置条件 |
|
||||
|------|---|---------|
|
||||
| 14 | PostgreSQL RLS | P1 测试覆盖完成 |
|
||||
| 15 | 血透专科 | 3-5 家客户调研完成 |
|
||||
| 16 | OCR 化验单提取 | 血透验证后 |
|
||||
| 17 | IM 咨询 | 血透验证后 |
|
||||
| 18 | health 模块按子域重组目录 | P1 测试覆盖完成 |
|
||||
| 19 | 前端测试覆盖提升 | P1 后端测试完成 |
|
||||
| 20 | 动态菜单系统 | 现有计划可用 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险与缓解
|
||||
|
||||
### 6.1 开发模式风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 单人认知单点 | 一人理解 16 个 crate,bus factor = 1 | ADR 强制化,关键决策留文档 |
|
||||
| AI 生成"编译对但逻辑错" | 危急值阈值硬编码、积分混入 health 就是例证 | 医疗安全代码双人外部 review |
|
||||
| 速度幻觉 | 68 提交/天 = 审查不足 | 每日提交上限 15 次 |
|
||||
| AI 回音壁 | AI 不质疑需求合理性 | 每月需求裁剪,引入真实用户反馈 |
|
||||
|
||||
### 6.2 临床安全风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 危急值告警未闭环 | 危急体征值无人响应,可致患者安全事故 | P0-1:实现消费者 + 阈值可配置 |
|
||||
| 逾期随访无催办 | 患者失访,影响医疗质量指标 | P0-4:实现通知 + 幂等保护 |
|
||||
| 过敏史无变更记录 | 无法追溯过敏史变更,用药风险 | P1-13:添加变更历史 |
|
||||
| 告警阈值硬编码 | 无法适应儿科/老年科/血透科不同范围 | P0-2:数据库配置 |
|
||||
|
||||
### 6.3 合规风险
|
||||
|
||||
| 风险 | 法规依据 | 缓解措施 |
|
||||
|------|---------|---------|
|
||||
| 知情同意缺失 | PIPL 第 29 条 | P0-5:实现同意记录机制 |
|
||||
| 审计不完整 | 医疗机构信息化建设要求 | P0-6:补全审计日志 |
|
||||
| PIE 加密范围不足 | PIPL 第 51 条 | P1:扩展加密到姓名/过敏史/诊断 |
|
||||
| 数据删除权缺失 | PIPL 第 47 条 | P2:实现患者数据导出/删除 |
|
||||
|
||||
### 6.4 架构风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| EventBus 无重放 | 服务重启丢事件 | P0-7:增强持久化 |
|
||||
| 全量订阅 | 性能隐患,所有事件经消息模块 | P1-11:改用过滤订阅 |
|
||||
| 路由手动合并 | 新模块 boilerplate 成本 | 长期:ErpModule trait v2 |
|
||||
| erp-health 过大 | 18+ 实体,维护复杂度上升 | P2-18:按子域重组 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 附录
|
||||
|
||||
### 7.1 关键文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `crates/erp-core/src/module.rs` | ErpModule trait + ModuleRegistry (拓扑排序) |
|
||||
| `crates/erp-core/src/events.rs` | EventBus 实现 (broadcast + outbox) |
|
||||
| `crates/erp-core/src/types.rs` | TenantContext, BaseFields, Pagination |
|
||||
| `crates/erp-core/src/rbac.rs` | 权限/角色检查 |
|
||||
| `crates/erp-server/src/main.rs` | 服务组装和手动路由合并 |
|
||||
| `crates/erp-server/src/state.rs` | AppState + FromRef 桥接 |
|
||||
| `crates/erp-server/src/outbox.rs` | Outbox relay (5s 轮询, 3 次重试) |
|
||||
| `crates/erp-auth/src/middleware/jwt_auth.rs` | JWT 认证 + TenantContext 注入 |
|
||||
| `crates/erp-health/src/module.rs` | HealthModule (ErpModule 实现 + 后台任务) |
|
||||
| `crates/erp-health/src/event.rs` | 健康模块事件订阅 |
|
||||
| `crates/erp-health/src/crypto.rs` | AES-256-GCM 加密 |
|
||||
| `crates/erp-health/src/service/masking.rs` | PII 脱敏管道 |
|
||||
| `crates/erp-plugin/src/engine.rs` | WASM 插件引擎 |
|
||||
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | 插件系统设计规格 |
|
||||
| `docs/superpowers/specs/2026-04-23-health-management-module-design.md` | 健康模块设计规格 |
|
||||
| `docs/discussions/2026-04-18-plugin-platform-brainstorm.md` | 插件平台演进讨论 |
|
||||
|
||||
### 7.2 迁移历史时间线
|
||||
|
||||
| 日期 | 迁移范围 | 说明 |
|
||||
|------|---------|------|
|
||||
| 4/10-11 | 核心平台 | 租户、用户、凭证、角色、权限、组织、部门、岗位 |
|
||||
| 4/12 | 配置 + 工作流 | 字典、菜单、设置、编号规则 + 流程定义/实例/令牌/任务 |
|
||||
| 4/13 | 消息 + 审计 | 模板、消息、订阅 + 审计日志 |
|
||||
| 4/14 | 修复 | 唯一索引与软删除冲突、标准字段补全 |
|
||||
| 4/16 | 领域事件 | domain_events 表 |
|
||||
| 4/17 | 插件系统 | 插件表、动态表 |
|
||||
| 4/18 | 搜索 + 权限 | pg_trgm、实体注册表、数据范围 |
|
||||
| 4/19 | 关联修复 | 用户部门、CRM 修复、插件市场 |
|
||||
| 4/23 | 健康表 | 患者、微信用户、文章 |
|
||||
| 4/24 | 索引修复 | 3 个 fixup 迁移 |
|
||||
| 4/25 | 健康扩展 | 患者ID哈希、医生名、透析/化验增强、AI 表、积分 |
|
||||
| 4/26 | 业务改进 | 诊断、列重命名、daily_monitoring 合并、菜单种子 |
|
||||
|
||||
**总计:59 个迁移,17 天内。** fixup 迁移模式健康(不编辑旧迁移,单独修复)。
|
||||
|
||||
### 7.3 项目统计快照 (2026-04-26)
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| Rust crate 数 | 16 |
|
||||
| Rust 代码行 | ~57,000 |
|
||||
| 前端文件数 | 174 (TSX/TS) |
|
||||
| 前端页面 | 62 |
|
||||
| 小程序页面 | 27 |
|
||||
| 数据库迁移 | 59 |
|
||||
| 数据库表 | 30 基础 + 18 健康 + 3 AI |
|
||||
| 后端测试 | 36 |
|
||||
| 前端单元测试 | 3 |
|
||||
| Git 提交 | 237 |
|
||||
| 开发周期 | 17 天 |
|
||||
|
||||
---
|
||||
|
||||
*本文档由三专家多视角评审生成,作为 HMS 平台基座演进的参考基准。后续实施计划将基于本文档的优先级排序展开。*
|
||||
@@ -1,346 +0,0 @@
|
||||
# V1 客户演示方案设计规格
|
||||
|
||||
> 日期: 2026-05-09 | 状态: Draft v2 | 类型: 演示方案
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 为什么做这次演示
|
||||
|
||||
HMS 健康管理平台已完成核心功能开发(700+ 次提交,V2 审计 85% 完成度),进入 V1 发布阶段。需要面向潜在客户(体检中心/血透中心)进行产品演示,目标是:
|
||||
|
||||
- **打动决策者签约** — 展示业务价值,而非功能清单
|
||||
- **收集真实反馈** — 了解客户实际工作流中的痛点,指导 V2 迭代
|
||||
- **验证产品定位** — 确认「AI 驱动主动关怀引擎」的定位是否与客户需求匹配
|
||||
|
||||
### 1.2 当前系统状态
|
||||
|
||||
| 指标 | 状态 |
|
||||
|------|------|
|
||||
| 核心链路 | 11 条端到端链路已验证通过 |
|
||||
| 已知 CRITICAL | 1 个未修复(Token 刷新竞态);其余 CRITICAL(告警权限码拼写、晚间血压丢失、仪表盘 500)均已修复 |
|
||||
| 角色测试通过率 | 84.6%(R01-R05) |
|
||||
| Web 前端 | 55 路由,283 文件,最完整的端 |
|
||||
| 小程序 | 59 页面,118 文件,代码完整 |
|
||||
| AI 模块 | 已对接 Ollama qwen3:4b,SSE 分析可用 |
|
||||
|
||||
### 1.3 演示策略
|
||||
|
||||
- **质量优先** — 修完所有已知问题再发布
|
||||
- **故事线驱动** — 用一个患者的 30 天管理历程展示完整闭环
|
||||
- **单患者深度** — 而非多角色广度,降低演示事故风险
|
||||
|
||||
## 2. 演示信息
|
||||
|
||||
| 项 | 值 |
|
||||
|------|------|
|
||||
| 受众 | 机构决策层 + 医疗团队 |
|
||||
| 时长 | 30-40 分钟 |
|
||||
| 视角 | 患者旅程(张大爷的 30 天) |
|
||||
| 涉及端 | Web 管理端(主力)+ 微信小程序(辅助) |
|
||||
| 涉及角色 | 护士、AI、医生、患者、健康管理师、管理员 |
|
||||
|
||||
## 3. 准备清单
|
||||
|
||||
### 3.1 测试账号
|
||||
|
||||
| 账号 | 角色 | 密码 | 用途 |
|
||||
|------|------|------|------|
|
||||
| `admin` | 管理员 | `Admin@2026` | 场景 7 仪表盘 |
|
||||
| `doctor1` | 医生 | `Admin@2026` | 场景 3 医生审批 |
|
||||
| `nurse1` | 护士 | `Admin@2026` | 场景 1 建档 + 场景 5 告警处理 |
|
||||
| `health_mgr` | 健康管理师 | `Admin@2026` | 场景 6 随访执行 |
|
||||
| `zhang_daye` | 患者(小程序) | 微信登录 | 场景 4/5 患者端操作 |
|
||||
|
||||
### 3.2 预置测试数据
|
||||
|
||||
| 数据 | 说明 | 目的 |
|
||||
|------|------|------|
|
||||
| 患者档案(张大爷) | 张建国,65岁,男,CKD 3期 | 主角 |
|
||||
| 历史化验单 ×2 | 肌酐 88→102 μmol/L 的趋势 | AI 分析需要历史对比发现趋势 |
|
||||
| 随访模板 | "慢性肾病定期随访"模板 | 场景 3 医生一键生成随访 |
|
||||
| 告警规则 | 肌酐>120 或 收缩压>160 | 场景 5 触发告警 |
|
||||
| 健康科普文章 ×3 | CKD 饮食/运动/用药 | 场景 4 小程序内容展示 |
|
||||
|
||||
### 3.3 环境检查
|
||||
|
||||
| 检查项 | 方法 | 通过标准 |
|
||||
|--------|------|----------|
|
||||
| 后端服务 | `cargo run` | 无 panic,Swagger 可访问 |
|
||||
| Web 前端 | `pnpm dev` | 登录页正常加载 |
|
||||
| 小程序 | 微信开发者工具 | 真机预览可扫码 |
|
||||
| 数据库 | 迁移已执行 | 预置数据查询无空结果 |
|
||||
| AI 模块 | Ollama 运行中 | SSE 分析端点可返回结果 |
|
||||
| 浏览器 | Chrome 无痕模式 | 干净环境,无缓存干扰 |
|
||||
|
||||
### 3.4 风险预案
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|----------|
|
||||
| AI 分析响应慢/失败 | 预先跑一次分析,截图备用;口头说明"云端大模型更快" |
|
||||
| 小程序真机扫码失败 | 准备 15 秒录屏视频展示关键页面 |
|
||||
| 后端服务崩溃 | 演示前重启一次确保干净状态 |
|
||||
| 数据库连接断开 | 提前验证 Docker PostgreSQL 健康状态 |
|
||||
| 告警权限码 bug | 演示前验证 AlertDashboard.tsx 权限码已修复(`health.alerts.manage`) |
|
||||
| SSE 长连接断开 | 录制 30 秒 AI 分析过程视频备用 |
|
||||
| Ollama 模型未加载 | 环境检查清单加入 `ollama list` 确认 qwen3:4b 已就绪 |
|
||||
| 多角色登录冲突 | 使用多个 Chrome Profile,每个角色一个独立 Profile |
|
||||
| 演示超时 | 标注可跳过场景(场景 6 可一句话带过) |
|
||||
|
||||
### 3.5 硬件与网络要求
|
||||
|
||||
| 项 | 要求 |
|
||||
|------|------|
|
||||
| 投影仪/大屏 | 分辨率 ≥ 1920x1080 |
|
||||
| 网络 | 演示机器与服务器在同一局域网,延迟 < 10ms |
|
||||
| 浏览器 | Chrome ×2 个 Profile(Web 端两个角色并行),或双屏方案 |
|
||||
| 手机 | 安装微信,可扫小程序码(备用:开发者工具投屏) |
|
||||
| 服务器 | 后端 + PostgreSQL + Ollama 运行在同一台机器,避免网络依赖 |
|
||||
|
||||
### 3.6 角色切换指引
|
||||
|
||||
| 切换点 | 操作 | 预计耗时 |
|
||||
|--------|------|----------|
|
||||
| 场景 1→2 | nurse1 退出 → admin 登录 | 15 秒 |
|
||||
| 场景 2→3 | admin 退出 → doctor1 登录 | 15 秒 |
|
||||
| 场景 3→4 | Web → 微信开发者工具/手机 | 10 秒 |
|
||||
| 场景 4→5 | 小程序录入 → Web nurse1 告警 | 10 秒 |
|
||||
| 场景 5→6 | nurse1 退出 → health_mgr 登录 | 15 秒 |
|
||||
| 场景 6→7 | health_mgr 退出 → admin 登录 | 15 秒 |
|
||||
|
||||
**建议:** 准备 2 个 Chrome Profile(Profile A: nurse1/admin,Profile B: doctor1/health_mgr),减少登录切换。场景 4/5 用独立手机或开发者工具。总切换时间约 1-1.5 分钟。
|
||||
|
||||
## 4. 演示脚本
|
||||
|
||||
### 开场(2 分钟)
|
||||
|
||||
**话术:**
|
||||
> "体检中心最大的痛点是什么?患者体检完,就走了。没有后续管理,没有随访跟进,体检数据躺在系统里没人看。今天给大家演示 HMS 健康管理平台如何解决这个问题——用一个真实场景:张大爷来体检后,系统如何帮他做 30 天的持续健康管理。"
|
||||
|
||||
---
|
||||
|
||||
### 场景 1:张大爷来体检(Day 1 上午)— 护士视角
|
||||
|
||||
**登录:** Web 端 `nurse1` | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 登录后展示护士工作台首页 — 一眼看到今日待办
|
||||
2. 点击「患者管理」→「新建患者」
|
||||
3. 填入:张建国 / 男 / 65岁 / 手机号 / 慢性肾病3期(诊断标签)
|
||||
4. 保存 → 跳转患者详情页
|
||||
5. 在患者详情页点击「体征录入」→ 录入血压 142/88、心率 72、空腹血糖 5.8
|
||||
6. 点击「化验报告」→ 上传预置的化验单图片,显示肌酐值 102 μmol/L
|
||||
|
||||
**话术:**
|
||||
> "张大爷第一次来体检中心。以前护士拿纸质表格登记,现在 30 秒建档。体征数据和化验报告立刻进入系统。"
|
||||
|
||||
**突出能力:** 快速建档、结构化体征录入、化验单数字化
|
||||
|
||||
---
|
||||
|
||||
### 场景 2:AI 自动分析(Day 1 下午)— 系统自动触发
|
||||
|
||||
**登录:** `admin` 或任意管理端账号 | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 展示「AI 分析」页面 → 显示张大爷的分析结果
|
||||
2. 点击分析详情 → 展示 AI 输出:
|
||||
- "肌酐值 88→102 μmol/L,3 个月持续上升趋势"
|
||||
- "建议:加做肾功能全套检查,排除 CKD 进展"
|
||||
- 风险等级:中风险(黄色标签)
|
||||
3. 切到 AI 建议列表 → 展示系统自动生成的「建议加做肾功能检查」行动项
|
||||
|
||||
**话术:**
|
||||
> "护士录入完数据,系统后台自动跑 AI 分析。不需要医生手动触发。AI 发现张大爷肌酐 3 个月在涨,主动建议进一步检查。这就是我们说的「主动关怀」——不是等患者出问题才看,是系统帮你盯着。"
|
||||
|
||||
**突出能力:** AI 自动分析、趋势发现、主动建议生成
|
||||
|
||||
**重要说明:** 当前 Web 端 AI 分析触发入口有限(审计报告指出"仅历史查看有 UI,分析触发无入口")。演示前**必须**执行以下操作之一:
|
||||
- 方案 A:演示前通过 API 手动触发一次分析(`POST /api/v1/ai/analysis/...`),演示时展示已生成的结果
|
||||
- 方案 B:为演示临时添加一个「触发分析」按钮到患者详情页
|
||||
- 推荐方案 A,配合话术调整:"这是系统刚才自动生成的分析结果"
|
||||
|
||||
**预案:** AI 分析慢或失败 → 展示预置截图,口头说明"接入云端大模型后速度更快"
|
||||
|
||||
---
|
||||
|
||||
### 场景 3:医生一秒决策(Day 3)— 医生视角
|
||||
|
||||
**登录:** Web 端 `doctor1` | **时长:** ~5 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 展示医生工作台 → 待办区域显示"1 条 AI 建议待审批"
|
||||
2. 点击进入 → 查看 AI 分析详情 + 患者历史数据
|
||||
3. 点击「同意建议」→ 系统自动:
|
||||
- 生成随访任务("肾功能复查随访",2 周后到期)
|
||||
- 推送小程序消息给患者
|
||||
4. 展示随访任务列表 → 新任务已创建
|
||||
5. 点击「预约管理」→ 演示为张大爷预约复查(选医生、选时间段、确认)
|
||||
|
||||
**话术:**
|
||||
> "李医生早上打开系统,看到 AI 昨天的分析建议。以前要翻纸质报告、手动比对数据,现在 AI 已经帮你分析好了,医生只需要做一个决策:同意还是不同意。点一下,系统自动安排随访、自动通知患者。"
|
||||
|
||||
**突出能力:** AI 辅助决策、一键生成随访、自动通知患者
|
||||
|
||||
---
|
||||
|
||||
### 场景 4:张大爷在家收到提醒(Day 7)— 小程序视角
|
||||
|
||||
**操作:** 微信开发者工具或真机预览 | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 打开小程序首页 → 展示今日摘要:1 条随访待办 + 1 篇健康科普
|
||||
2. 点击「消息」Tab → 显示"您有一条新的随访任务"
|
||||
3. 点击进入随访详情 → 显示随访问卷(饮食情况、用药依从性、症状变化)
|
||||
4. 快速填写 2-3 项 → 提交
|
||||
5. 切回「健康」Tab → 展示张大爷的体征趋势图(血压曲线、肌酐趋势)
|
||||
6. 展示 AI 建议卡片:"您的血压近一周有上升趋势,建议减少盐分摄入"
|
||||
|
||||
**话术:**
|
||||
> "张大爷在家打开手机,不用打电话、不用跑医院,系统自动提醒他有随访要完成。填个问卷 2 分钟,医生那边就能看到。趋势图也让他自己看到身体变化,比口头解释直观得多。"
|
||||
|
||||
**突出能力:** 小程序主动提醒、随访问卷、趋势可视化、AI 健康建议触达
|
||||
|
||||
**预案:** 真机失败 → 播放 15 秒小程序录屏,重点展示随访提醒和趋势图
|
||||
|
||||
---
|
||||
|
||||
### 场景 5:危急值告警(Day 14)— 护士 + 系统联动
|
||||
|
||||
**操作:** 先小程序,再切 Web 端 | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. **小程序端**(快速操作):张大爷录入血压 168/95 → 提交
|
||||
2. **切到 Web 端**(`nurse1` 登录):
|
||||
- 顶部弹出告警通知 "危急值告警:张建国 收缩压 168mmHg"
|
||||
- 点击进入告警列表 → 红色高亮显示
|
||||
- 点击告警详情 → 展示:触发规则(收缩压>160)、当前值、历史趋势
|
||||
- 点击「确认」→ 状态变为"已确认"
|
||||
- 点击「处理」→ 录入处理备注:"已电话通知患者,建议立即到门诊"
|
||||
- 状态变为"已处理"
|
||||
3. **回到小程序端**:张大爷收到消息"您的血压偏高,李医生建议您尽快来院检查"
|
||||
|
||||
**话术:**
|
||||
> "张大爷在家量了个血压,168。以前这种情况没人知道,可能拖到下次复诊才发现。现在数据一传上来,护士工作站立刻弹告警。护士确认后打电话给患者,15 分钟内完成从发现到处理。这才是真正的「主动关怀」。"
|
||||
|
||||
**突出能力:** 实时告警、分级处理、跨端联动(小程序录入→Web 告警→小程序反馈)
|
||||
|
||||
---
|
||||
|
||||
### 场景 6:随访闭环(Day 21)— 健康管理师视角
|
||||
|
||||
**登录:** Web 端 `health_mgr` | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 展示健康管理师工作台 → 随访任务列表显示"张建国 - 肾功能复查随访 - 即将到期"
|
||||
2. 点击执行随访 → 选择"电话随访"
|
||||
3. 录入随访记录:
|
||||
- 患者状态:"已完成肾功能检查,肌酐降至 98"
|
||||
- 遵医行为:"按时服药,控制饮食"
|
||||
- 下一步:"继续观察,3 个月后复查"
|
||||
4. 提交 → 随访状态变为"已完成"
|
||||
5. 展示随访历史时间线 → Day 3 创建 → Day 7 问卷 → Day 21 电话随访,完整记录
|
||||
|
||||
**话术:**
|
||||
> "30 天的管理周期里,每一步都有记录。从 AI 发现问题、医生决策、患者问卷、到健康管理师电话回访,全部可追溯。卫健委来检查,一导出就是完整的健康管理档案。"
|
||||
|
||||
**突出能力:** 随访全流程记录、可追溯、健康管理闭环
|
||||
|
||||
---
|
||||
|
||||
### 场景 7:数据说话(Day 30)— 管理员视角
|
||||
|
||||
**登录:** Web 端 `admin` | **时长:** ~3 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 展示运营仪表盘:
|
||||
- 本月管理患者数
|
||||
- 随访完成率
|
||||
- AI 分析覆盖率
|
||||
- 告警响应平均时间
|
||||
2. 展示趋势图:患者增长曲线、随访完成率趋势
|
||||
3. 切到「内容管理」→ 展示已发布的健康科普文章(阅读量、转发量)
|
||||
4. 切到「积分商城」→ 展示患者积分排行、兑换记录
|
||||
|
||||
**话术:**
|
||||
> "张大爷的故事不是个例。系统帮你管每一个患者,而且每一步都有数据。随访完成率从手工追踪的 40% 提升到系统化管理后能做到 80% 以上。这些数据就是你们向卫健委、向患者证明管理质量的最好证据。"
|
||||
|
||||
**突出能力:** 运营数据可视化、管理质量量化、内容运营
|
||||
|
||||
**重要说明:** 单个患者(张大爷)的数据不足以支撑仪表盘的说服力。演示前**必须**预置 20-30 个背景患者数据 + 若干随访/告警记录,让仪表盘显示有意义的统计数字。在数据预置脚本中一并处理。
|
||||
|
||||
---
|
||||
|
||||
### 收尾(5 分钟)
|
||||
|
||||
**总结话术:**
|
||||
> "总结一下 HMS 带来的三个核心变化:
|
||||
> 1. **从被动到主动** — AI 帮你看数据,系统帮你盯着患者
|
||||
> 2. **从纸质到数字** — 每一步可追溯,检查随时可导出
|
||||
> 3. **从单点到闭环** — 体检不是终点,30 天持续管理才是"
|
||||
|
||||
**收集反馈(3 个问题):**
|
||||
1. "您刚才看到的流程中,哪些环节对您机构最有价值?"
|
||||
2. "有没有我们没覆盖到、但您实际工作中很重要的场景?"
|
||||
3. "您更关心 Web 管理端还是患者小程序端的能力?"
|
||||
|
||||
---
|
||||
|
||||
## 5. V1 发布前必修项
|
||||
|
||||
### 5.1 必修(阻塞发布)
|
||||
|
||||
| # | 问题 | 修复方案 | 工作量估计 |
|
||||
|---|------|----------|-----------|
|
||||
| 1 | Token 刷新并发竞态 | refresh 流程加事务 + SELECT FOR UPDATE | 0.5 天 |
|
||||
|
||||
### 5.2 建议修(提升演示体验)
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | AI 分析预置截图 | 演示前手动跑一次分析,截图备用 |
|
||||
| 2 | 小程序录屏视频 | 15 秒展示随访提醒 + 趋势图 |
|
||||
| 3 | 测试数据脚本 | 一键预置张大爷的完整数据 |
|
||||
| 4 | 演示前全链路冒烟 | 跑一遍 7 个场景确认无阻塞 |
|
||||
|
||||
## 6. 下一步演化方向(演示后收集)
|
||||
|
||||
| 方向 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| HIS 系统集成 | 场景 1 | 演示后可能被问"能不能对接我们现有 HIS" |
|
||||
| 报告导出 | 场景 6 | 卫健委检查需要标准格式报告 |
|
||||
| 多科室支持 | 客户反馈 | 当前以肾病/体检为主,其他科室扩展 |
|
||||
| 微信服务号推送 | 场景 4 | 小程序消息触达有限,服务号更灵活 |
|
||||
| 设备直连 | 场景 5 | 血压计/血糖仪 BLE 直连小程序 |
|
||||
|
||||
## 7. Q&A 异议处理
|
||||
|
||||
### 客户可能提出的问题及建议回答
|
||||
|
||||
**Q: 能不能对接我们现有的 HIS/EMR 系统?**
|
||||
> HMS 提供标准 FHIR R4 接口和 RESTful API,支持 HL7 标准数据交换。具体集成方案需要了解贵院 HIS 的品牌和版本,我们可以安排技术团队做接口评估。通常 2-4 周可以完成基础对接。
|
||||
|
||||
**Q: 患者数据安全如何保障?**
|
||||
> 数据存储采用 PII 加密(姓名/身份证/手机号等敏感字段加密存储),多租户隔离确保不同机构数据完全独立。系统支持私有化部署,数据不出院。后端使用 Rust 语言开发,天然免疫内存安全漏洞。
|
||||
|
||||
**Q: AI 分析的准确率如何?**
|
||||
> 当前 AI 模块定位是「辅助筛查」,发现异常趋势后由医生做最终决策。不是替代医生诊断,而是帮医生从海量数据中找到需要关注的患者。所有 AI 建议都需要医生审批才生效。
|
||||
|
||||
**Q: 部署方式有哪些?**
|
||||
> 支持 SaaS(按年付费,我们运维)和私有化部署(一次性 + 年维护费,部署在客户服务器)。SaaS 适合快速上线,私有化适合数据合规要求高的机构。
|
||||
|
||||
**Q: 价格怎么算?**
|
||||
> 根据机构规模(管理患者数、医护账号数)定制方案。演示后我们可以根据贵院的具体需求出一份详细报价。
|
||||
|
||||
**Q: 医护人员需要培训多久?**
|
||||
> 系统设计遵循「零培训」理念——医生工作台只展示待办,护士录入界面跟纸质表单一样直观。通常 30 分钟上手,1 天熟练。我们提供远程培训和操作手册。
|
||||
|
||||
## 8. DRY RUN 计划
|
||||
|
||||
| 阶段 | 时间 | 内容 |
|
||||
|------|------|------|
|
||||
| D-7 | 演示前 7 天 | 修完 P0 问题(Token 刷新、AI 触发入口验证) |
|
||||
| D-5 | 演示前 5 天 | 编写数据预置脚本,预置张大爷完整数据 |
|
||||
| D-3 | 演示前 3 天 | 第一次 DRY RUN:完整走 7 个场景,记录阻塞点 |
|
||||
| D-2 | 演示前 2 天 | 修复 DRY RUN 发现的问题,预置 20-30 个背景患者数据 |
|
||||
| D-1 | 演示前 1 天 | 第二次 DRY RUN(带投影/网络),确认全链路无阻塞 |
|
||||
| D-Day | 演示当天 | 提前 1 小时启动环境,30 分钟前最终冒烟 |
|
||||
Reference in New Issue
Block a user