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:
iven
2026-05-15 09:29:04 +08:00
parent dc983945ff
commit 18fa6ce6d4
92 changed files with 53 additions and 10253 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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 # 宿主 APIdb/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 两个 entity3 个 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/currencycustom 页面后续迭代 |

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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.rsGuest 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(&current.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 描述与代码实际状态一致。

View File

@@ -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自由职业者工作台和 itopsIT 运维服务台)两个 WASM 插件,覆盖其全部 12 条经营范围。
**Architecture:** 两个独立的 WASM 插件 crate每个包含 Cargo.tomlcdylib、src/lib.rsGuest 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.tomlfreelance
**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.310 个实体)+ 2.4(编号规则)+ 2.5(页面声明)的所有 TOML 内容,合并为完整的 `plugin.toml` 文件。
文件结构:
1. `[metadata]`
2. `[[permissions]]` × 20
3. `[[schema.entities]]` × 10client, opportunity, quote, quote_line, contract, project, task, time_entry, invoice, expense每个实体包含 fields 和 relations
4. `[[numbering]]` × 3quote_number, contract_number, invoice_number
5. `[[ui.pages]]` × 7dashboard, 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: < 100KBCRM 约 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]]` × 4service_contract, ticket, check_plan, check_record每个实体包含 fields 和 relations
4. `[[numbering]]` × 1contract_number
5. `[[ui.pages]]` × 4crud+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: 测试跨插件引用**
场景 Afreelance 已安装):创建工单时 client_id 字段显示为下拉选择器,可搜索 freelance.client
场景 Bfreelance 未安装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` | 插件制作完整流程 |

View File

@@ -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"按钮
- [ ] 点击生成 → 下载 PDFSLA 承诺正确
- [ ] `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

View File

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

View File

@@ -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 (只读,无操作按钮) |

View File

@@ -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 / 分析历史查询 / 用量统计端点为空壳或缺失。先补全后端 APIhandler + 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) / 模型 / 创建时间
- 状态 Tagcompleted=绿色, failed=红色, streaming=蓝色
- 详情查看:点击行展开,显示 result_contentMarkdown 渲染)
- 筛选:分析类型下拉 + 时间范围
- 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
```

View File

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

View File

@@ -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`,计算变更 diffchanged_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 添加断线自动重连。
**验收**: 事件延迟 < 100msDB 轮询频率从 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。逐个 service14 个模块)替换手动构建为调用辅助函数,统一信封格式。
**验收**: 所有事件 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 失败仅 warnOutbox relay 兜底
4. **统一信封格式** — 使用 `build_event_payload` 保证一致性
5. **LISTEN/NOTIFY 保留兜底轮询** — 30s 轮询防 NOTIFY 丢失

View File

@@ -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 体积 < 400KBgzip 前约 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. **渐进迁移** — 重复模式统一采用渐进策略,不一次性全量迁移

View File

@@ -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 + 生产 DockerDay 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) 分离,避免暴露内部指标

View File

@@ -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/WASMwit-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 crate2 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事件消费者应在技术债批次 BEventBus dead-letter之后执行
- Chunk 2透析拆分应在技术债批次 A安全修复之后执行避免合并冲突
- Chunk 1WASM 插件)完全独立,随时可执行

View File

@@ -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先修 CRITICALToken 竞态再验证关键链路告警、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 刷新并发竞态条件
使用原子 CASUPDATE 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 个月前 肌酐 881 个月前 肌酐 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;
-- 化验单 13 个月前 肌酐 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;
-- 化验单 21 个月前 肌酐 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 RUN7 个场景)
**前置条件:** 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 结果报告"
```