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:
273
docs/archive/DESIGN.md
Normal file
273
docs/archive/DESIGN.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Design System — Enterprise ERP Platform
|
||||
|
||||
> Generated by UI UX Pro Max | Style: Soft UI Evolution | Target: Cross-industry business users
|
||||
|
||||
## 1. Visual Theme & Atmosphere
|
||||
|
||||
A warm, professional, and approachable design that feels trustworthy for business users across all industries — manufacturing, retail, finance, services, and more. The design uses a clean white canvas with soft blue as the primary brand color, conveying reliability and clarity without feeling cold or corporate.
|
||||
|
||||
The Soft UI Evolution style provides subtle depth through improved shadows — softer than flat design but clearer than neumorphism. This creates a sense of modern polish that invites interaction while maintaining excellent readability and accessibility (WCAG AA+).
|
||||
|
||||
**Key Characteristics:**
|
||||
- Clean white canvas with soft blue accent — trustworthy, professional, warm
|
||||
- Subtle depth through soft shadows — modern but not flat, not skeuomorphic
|
||||
- Moderate border-radius (10-12px) — friendly but not playful
|
||||
- Chinese-first typography with Noto Sans SC — readable at all sizes
|
||||
- Two-tier token system: CSS Variables (`--erp-*`) + Ant Design ConfigProvider
|
||||
- Dual theme support: light (default) + dark mode
|
||||
|
||||
## 2. Color Palette & Roles
|
||||
|
||||
### Primary Brand
|
||||
- **Primary Blue** (`#2563EB`): Primary CTA, active states, links, brand accent
|
||||
- **Primary Hover** (`#1D4ED8`): Pressed/active primary state
|
||||
- **Primary Light** (`#EFF6FF`): Primary background tint, subtle highlights
|
||||
- **Primary Subtle** (`#DBEAFE`): Light blue backgrounds for badges, chips
|
||||
|
||||
### Semantic Colors
|
||||
- **Success Green** (`#059669`): Success states, positive indicators, confirmations
|
||||
- **Success Light** (`#ECFDF5`): Success background tint
|
||||
- **Warning Amber** (`#D97706`): Warnings, pending states, attention needed
|
||||
- **Warning Light** (`#FFFBEB`): Warning background tint
|
||||
- **Error Red** (`#DC2626`): Errors, destructive actions, required fields
|
||||
- **Error Light** (`#FEF2F2`): Error background tint
|
||||
- **Info Blue** (`#0284C7`): Informational elements, tooltips, help text
|
||||
|
||||
### Text
|
||||
- **Primary Text** (`#0F172A`): Headings, primary body text — deep navy, professional
|
||||
- **Secondary Text** (`#475569`): Descriptions, labels, helper text
|
||||
- **Tertiary Text** (`#94A3B8`): Placeholders, disabled text, timestamps
|
||||
- **Inverse Text** (`#FFFFFF`): Text on colored/dark surfaces
|
||||
|
||||
### Surface & Border
|
||||
- **Page Background** (`#F8FAFC`): App background — cool off-white
|
||||
- **Container Background** (`#FFFFFF`): Cards, panels, modals
|
||||
- **Elevated Background** (`#FFFFFF`): Dropdowns, popovers, tooltips
|
||||
- **Border** (`#E2E8F0`): Default borders, dividers
|
||||
- **Border Strong** (`#CBD5E1`): Emphasized borders, active card outlines
|
||||
- **Hover Background** (`#F1F5F9`): Row hover, item hover backgrounds
|
||||
- **Muted Background** (`#F1F5F9`): Subtle section backgrounds, table headers
|
||||
|
||||
### Dark Mode
|
||||
- **Dark Page** (`#0F172A`): Dark app background
|
||||
- **Dark Container** (`#1E293B`): Dark cards, panels
|
||||
- **Dark Elevated** (`#334155`): Dark dropdowns, popovers
|
||||
- **Dark Border** (`#334155`): Dark borders
|
||||
- **Dark Hover** (`#1E293B`): Dark row hover
|
||||
- **Dark Text Primary** (`#F8FAFC`): Dark mode primary text
|
||||
- **Dark Text Secondary** (`#94A3B8`): Dark mode secondary text
|
||||
|
||||
## 3. Typography Rules
|
||||
|
||||
### Font Family
|
||||
- **Primary**: `Noto Sans SC`, fallbacks: `-apple-system, system-ui, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif`
|
||||
- **Monospace (optional)**: `'JetBrains Mono', 'Fira Code', Consolas, monospace`
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Role | Size | Weight | Line Height | Usage |
|
||||
|------|------|--------|-------------|-------|
|
||||
| Page Title | 24px (1.5rem) | 700 | 1.3 | Page headings, dialog titles |
|
||||
| Section Title | 20px (1.25rem) | 600 | 1.4 | Section headings, card titles |
|
||||
| Subsection | 16px (1rem) | 600 | 1.5 | Subsection labels, form group titles |
|
||||
| Body | 14px (0.875rem) | 400 | 1.6 | Standard body text, table cells, descriptions |
|
||||
| Caption | 12px (0.75rem) | 400 | 1.5 | Timestamps, badges, helper text, metadata |
|
||||
|
||||
### Principles
|
||||
- **Chinese-first**: Noto Sans SC ensures excellent CJK rendering
|
||||
- **Readable weights**: 400 for body, 600-700 for headings — always substantial, never thin
|
||||
- **Generous line-height**: 1.5-1.6 for body text ensures comfortable reading
|
||||
- **Moderate scale**: 12-24px range creates a compact, professional information hierarchy
|
||||
|
||||
## 4. Component Stylings
|
||||
|
||||
### Buttons
|
||||
- **Primary**: Blue (#2563EB) background, white text, 10px radius, soft shadow
|
||||
- **Secondary**: White background, slate border (#CBD5E1), dark text, 10px radius
|
||||
- **Ghost**: Transparent background, primary blue text, no border
|
||||
- **Danger**: Red (#DC2626) background, white text, for destructive actions
|
||||
- **All buttons**: Min height 36px, 40px preferred; smooth hover transition 200ms
|
||||
|
||||
### Cards & Containers
|
||||
- White background, 12px radius, soft shadow: `0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)`
|
||||
- Hover: Slightly elevated shadow: `0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.05)`
|
||||
- No thick borders — shadows and subtle background differences create depth
|
||||
|
||||
### Inputs
|
||||
- White background, 1px solid #CBD5E1 border, 10px radius
|
||||
- Focus: Blue ring (2px solid #2563EB) with outer glow
|
||||
- Error: Red border + error message below field
|
||||
- Height: 40px standard, 32px small
|
||||
|
||||
### Tables
|
||||
- Header: #F1F5F9 background, #475569 text, 14px weight 600
|
||||
- Row hover: #F1F5F9 background
|
||||
- Cell padding: 16px vertical, 12px horizontal
|
||||
- Border: Bottom-only using #E2E8F0
|
||||
|
||||
### Navigation / Sidebar
|
||||
- Background: White (#FFFFFF) with right border #E2E8F0
|
||||
- Active item: #EFF6FF background, #2563EB text, left accent bar (3px)
|
||||
- Hover item: #F1F5F9 background
|
||||
- Item height: 40px, 12px radius
|
||||
- Icons: 20px, inline with label text
|
||||
|
||||
### Tags / Badges
|
||||
- Small radius (6px), medium padding (4px 8px)
|
||||
- Color-coded backgrounds: blue tint, green tint, amber tint, red tint
|
||||
- Text matches semantic color (darker shade than background)
|
||||
|
||||
### Modals / Dialogs
|
||||
- 16px radius, generous padding (24px)
|
||||
- Soft elevated shadow: `0 8px 30px rgba(0,0,0,0.12)`
|
||||
- Header with title, footer with action buttons
|
||||
|
||||
## 5. Layout Principles
|
||||
|
||||
### Spacing System
|
||||
- Base unit: 4px
|
||||
- Scale: 4px, 8px, 12px, 16px, 20px, 24px, 32px, 40px, 48px, 64px
|
||||
- Content padding: 24px standard, 16px compact
|
||||
|
||||
### Layout
|
||||
- Fixed sidebar: 240px wide, collapsible to 72px
|
||||
- Sticky header: 56px
|
||||
- Content area: Fluid width with max comfortable reading width
|
||||
- Standard CRUD table/list views for data management
|
||||
|
||||
### Border Radius Scale
|
||||
- Small (6px): Tags, badges, small elements
|
||||
- Standard (10px): Buttons, inputs, cards
|
||||
- Large (12px): Panels, modals, large containers
|
||||
- Full (50%): Circular avatars, icon buttons
|
||||
|
||||
## 6. Depth & Elevation
|
||||
|
||||
| Level | Shadow | Use |
|
||||
|-------|--------|-----|
|
||||
| Level 0 (Flat) | None | Page background, sidebar |
|
||||
| Level 1 (Subtle) | `0 1px 2px rgba(0,0,0,0.05)` | Cards, form sections |
|
||||
| Level 2 (Default) | `0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)` | Elevated cards, dropdowns |
|
||||
| Level 3 (Elevated) | `0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.05)` | Hover states, active cards |
|
||||
| Level 4 (Modal) | `0 8px 30px rgba(0,0,0,0.12)` | Modals, overlays |
|
||||
|
||||
## 7. Interactive States
|
||||
|
||||
### Hover
|
||||
- Background color shift: `#F1F5F9` for neutral, semantic tint for colored elements
|
||||
- Transition: 200ms ease
|
||||
- Cursor: `pointer` on clickable elements
|
||||
|
||||
### Focus
|
||||
- 2px solid ring using `#2563EB`
|
||||
- 2px offset for visibility
|
||||
- Always visible for keyboard navigation
|
||||
|
||||
### Active / Pressed
|
||||
- Slightly darker shade of the element's color
|
||||
- Brief scale or shadow change for tactile feedback
|
||||
|
||||
### Disabled
|
||||
- Reduced opacity (0.5)
|
||||
- No hover effects
|
||||
- Cursor: `not-allowed`
|
||||
|
||||
### Loading
|
||||
- Spinner or skeleton placeholder
|
||||
- Disabled interactions during async operations
|
||||
|
||||
## 8. Do's and Don'ts
|
||||
|
||||
### Do
|
||||
- Use soft shadows for depth — the Soft UI Evolution identity
|
||||
- Apply Primary Blue (#2563EB) only for primary actions and active states
|
||||
- Use Noto Sans SC for consistent Chinese rendering
|
||||
- Apply 10-12px radius — friendly but professional
|
||||
- Use semantic color tints for status backgrounds
|
||||
- Keep tables clean with subtle header differentiation
|
||||
- Ensure 4.5:1 contrast ratio for all text
|
||||
|
||||
### Don't
|
||||
- Don't use heavy or dramatic shadows — keep depth subtle
|
||||
- Don't use pure black (#000000) for text — use #0F172A instead
|
||||
- Don't use pill-shaped buttons — 10px radius is rounded but not pill
|
||||
- Don't mix warm and cool neutrals — stay in the slate family
|
||||
- Don't use emojis as icons — use SVG icons (Lucide/Heroicons)
|
||||
- Don't use decorative-only animations — every animation must serve a purpose
|
||||
- Don't use colors as the sole means of conveying information
|
||||
|
||||
## 9. Responsive Behavior
|
||||
|
||||
### Breakpoints
|
||||
| Name | Width | Key Changes |
|
||||
|------|-------|-------------|
|
||||
| Mobile | <576px | Sidebar collapsed, single column |
|
||||
| Tablet | 576-768px | Sidebar collapsed, 2-column grid |
|
||||
| Desktop Small | 768-1024px | Sidebar expanded, responsive grid |
|
||||
| Desktop | 1024-1440px | Full layout |
|
||||
| Large Desktop | >1440px | Maximum content width |
|
||||
|
||||
### Collapsing Strategy
|
||||
- Sidebar: 240px → 72px (icon only) on mobile/tablet
|
||||
- Tables: Horizontal scroll on narrow screens
|
||||
- Forms: Single column on mobile, multi-column on desktop
|
||||
- Cards: Full-width stack → grid layout
|
||||
|
||||
## 10. ERP Platform Adaptations
|
||||
|
||||
### Layout
|
||||
- Fixed sidebar navigation (240px wide, collapsible to 72px)
|
||||
- Sticky header (56px) with search, notifications, user menu
|
||||
- Content area uses CSS Grid for responsive multi-column dashboards
|
||||
- Standard CRUD table/list views replace masonry grid for data management
|
||||
|
||||
### Color Adaptations
|
||||
- Primary Blue (#2563EB) for primary actions (Save, Create, Submit)
|
||||
- Success Green (#059669) for positive states (Approved, Completed, Paid)
|
||||
- Warning Amber (#D97706) for pending states (Pending Review, Awaiting)
|
||||
- Error Red (#DC2626) for destructive/error states (Rejected, Failed, Overdue)
|
||||
- Info Blue (#0284C7) for informational elements (Tips, Help, Documentation)
|
||||
|
||||
### Component Adaptations
|
||||
- **Tables**: Slate header bg (#F1F5F9), generous cell padding, subtle hover (#F1F5F9)
|
||||
- **Forms**: 10px radius inputs, slate borders (#CBD5E1), generous spacing
|
||||
- **Cards**: 12px radius, soft shadow, white background
|
||||
- **Sidebar**: Blue active states (#EFF6FF bg), slate hover, 12px radius items
|
||||
- **Tags/Badges**: 6px radius, semantic color tints (blue/green/amber/red)
|
||||
- **Modals**: 16px radius, elevated shadow, generous padding
|
||||
|
||||
### Dark Mode
|
||||
- Background: #0F172A (deep navy)
|
||||
- Container: #1E293B (dark slate)
|
||||
- Elevated: #334155 (medium slate)
|
||||
- Border: #334155
|
||||
- Active accent: #3B82F6 (lighter blue for dark backgrounds)
|
||||
- Text: #F8FAFC primary, #94A3B8 secondary
|
||||
|
||||
## 11. Agent Prompt Guide
|
||||
|
||||
### Quick Color Reference
|
||||
- Brand: Primary Blue (#2563EB)
|
||||
- Background: Cool Off-White (#F8FAFC)
|
||||
- Text: Deep Navy (#0F172A)
|
||||
- Secondary text: Slate (#475569)
|
||||
- Border: Light Slate (#E2E8F0)
|
||||
- Success: Green (#059669)
|
||||
- Warning: Amber (#D97706)
|
||||
- Error: Red (#DC2626)
|
||||
- Focus: Blue (#2563EB)
|
||||
|
||||
### Example Component Prompts
|
||||
- "Create a card: white background, 12px radius, soft shadow (0 1px 3px rgba(0,0,0,0.06)). Title in 16px weight 600 #0F172A. Body in 14px #475569."
|
||||
- "Design a primary button: #2563EB background, white text, 10px radius, 8px 16px padding. Hover: #1D4ED8. Focus: 2px solid #2563EB ring."
|
||||
- "Build a table: header #F1F5F9 background, #475569 text 14px weight 600. Row hover #F1F5F9. Cell padding 16px 12px. Border bottom #E2E8F0."
|
||||
- "Create a sidebar: white background, right border #E2E8F0. Active item: #EFF6FF background, #2563EB text, 3px left accent bar. Hover: #F1F5F9."
|
||||
- "Design a modal: 16px radius, shadow 0 8px 30px rgba(0,0,0,0.12). Title 20px weight 600. Footer with primary blue CTA."
|
||||
|
||||
### Iteration Guide
|
||||
1. Soft shadows everywhere — subtle depth is the identity
|
||||
2. Primary Blue for CTAs and active states — trustworthy, not overwhelming
|
||||
3. 10px radius on buttons/inputs, 12px on cards — friendly but professional
|
||||
4. Noto Sans SC is the primary font — Chinese-first, readable at all sizes
|
||||
5. Slate neutrals — never pure black or pure gray, always with blue undertone
|
||||
6. Semantic tints for status — green/amber/red backgrounds with matching text
|
||||
109
docs/archive/audits-v1/00-baseline-snapshot.md
Normal file
109
docs/archive/audits-v1/00-baseline-snapshot.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# HMS 功能审计 — Phase 0: 基线快照
|
||||
|
||||
> 日期: 2026-04-30 | 审计范围: 全系统
|
||||
|
||||
## 环境信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| Git HEAD | `84fafb0bc5bf3d742f4136c78288a3d06678fc4d` |
|
||||
| 最新提交 | `fix(web+health): 修复咨询轮询 temp ID 400 + 健康数据统计 500` |
|
||||
| 总提交数 | 409 |
|
||||
|
||||
## 编译状态
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| `cargo check --workspace` | **通过**(有警告) |
|
||||
| 编译警告总数 | 40 个 |
|
||||
| 受影响 crate | erp-core(1), erp-plugin(11), erp-health(11), erp-ai(6), erp-dialysis(1), erp-workflow(2), erp-server(1) |
|
||||
|
||||
### 警告分类
|
||||
|
||||
**未使用字段(9 处)**:
|
||||
| 文件 | 字段 | 状态 |
|
||||
|------|------|------|
|
||||
| erp-plugin `host.rs` | `chk`(×2), `tenant_id`, `user_id` | #[allow(dead_code)] 已抑制 |
|
||||
| erp-health 多处 | `message`, `usage`, `input_tokens`, `output_tokens`, `check_result`, `total` | 编译器警告 |
|
||||
| erp-server `analytics.rs` | `timestamp` | 编译器警告 |
|
||||
| erp-health | `RefRow` struct 从未构造 | 编译器警告 |
|
||||
|
||||
**未使用导入(18 处)**:分布在 erp-health(7)、erp-plugin(3)、erp-ai(2)、erp-workflow(2)、erp-dialysis(1)、erp-core(1)。
|
||||
|
||||
**未使用变量(7 处)**:`user_id`, `total`, `tenant_id`, `plugin_resp`, `plugin_id`, `final_nodes`, `entity`。
|
||||
|
||||
## 测试状态
|
||||
|
||||
### 总览
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 测试总数 | **772 个函数** |
|
||||
| 通过 | **753(97.5%)** |
|
||||
| 失败 | **9(1.2%)** |
|
||||
| 跳过 | 0 |
|
||||
|
||||
### 失败测试(全部因 `blind_indexes` 表缺失)
|
||||
|
||||
| 测试 | 文件 |
|
||||
|------|------|
|
||||
| `test_create_patient` | `health_patient_tests.rs` |
|
||||
| `test_patient_pii_encrypted` | `health_patient_tests.rs` |
|
||||
| `test_dialysis_create_without_patient_returns_error` | `health_dialysis_tests.rs` |
|
||||
| `test_cross_tenant_data_integrity` | `health_pii_encryption_tests.rs` |
|
||||
| `test_patient_detail_returns_decrypted_fields` | `health_pii_encryption_tests.rs` |
|
||||
| `test_patient_hmac_search_by_phone` | `health_pii_encryption_tests.rs` |
|
||||
| `test_patient_list_hides_tier1_fields` | `health_pii_encryption_tests.rs` |
|
||||
| `test_patient_tier1_fields_encrypted_in_db` | `health_pii_encryption_tests.rs` |
|
||||
| `test_tenant_isolation_encrypted_patient` | `health_pii_encryption_tests.rs` |
|
||||
|
||||
**根因**:测试数据库缺少 `blind_indexes` 表(迁移未执行),非代码逻辑错误。
|
||||
|
||||
### 模块测试分布
|
||||
|
||||
| Crate | 单元测试 | 集成测试 | 总计 | 通过率 |
|
||||
|-------|---------|---------|------|--------|
|
||||
| erp-core | 74 | — | 74 | 100% |
|
||||
| erp-auth | 41 | 3 | 44 | 100% |
|
||||
| erp-config | 78 | — | 78 | 100% |
|
||||
| erp-workflow | 63 | 4 | 67 | 100% |
|
||||
| erp-message | 72 | — | 72 | 100% |
|
||||
| erp-health | 159 | 144 | 303 | 97% |
|
||||
| erp-ai | 36 | — | 36 | 100% |
|
||||
| erp-dialysis | 10 | 15 | 25 | 93% |
|
||||
| erp-plugin | 78 | 2 | 80 | 100% |
|
||||
| erp-server | — | 153 | 153 | 94% |
|
||||
| **合计** | **611** | **153** | **772** | **97.5%** |
|
||||
|
||||
## 路由统计
|
||||
|
||||
| 模块 | 路由数 |
|
||||
|------|--------|
|
||||
| erp-health | 169 |
|
||||
| erp-plugin | 38 |
|
||||
| erp-config | 26 |
|
||||
| erp-auth | 33(含 4 个公开路由) |
|
||||
| erp-workflow | 17 |
|
||||
| erp-ai | 12 |
|
||||
| erp-dialysis | 12 |
|
||||
| erp-message | 13 |
|
||||
| erp-server | 8(含 4 个公开路由) |
|
||||
| **合计** | **328**(8 个公开 + 320 个受保护) |
|
||||
|
||||
## 代码规模
|
||||
|
||||
| 维度 | 值 |
|
||||
|------|-----|
|
||||
| Rust 源文件 | 462 个 |
|
||||
| Rust 代码行数 | ~77,000 行 |
|
||||
| Web 前端文件 | 163 个 |
|
||||
| 小程序文件 | 125 个 |
|
||||
| 数据库迁移 | 96 个 |
|
||||
| Rust crate | 18 个 |
|
||||
|
||||
## 基线结论
|
||||
|
||||
1. **系统整体健康**:编译通过,97.5% 测试通过
|
||||
2. **单一阻塞问题**:9 个测试因 `blind_indexes` 表缺失失败,需执行迁移
|
||||
3. **技术债务低**:仅 40 个编译警告(多为未使用导入/变量),无 `unimplemented!` 或 `todo!` 宏
|
||||
4. **规模庞大**:233 个后端路由、40 个前端页面,审计工作量较大
|
||||
252
docs/archive/audits-v1/01-feature-inventory.md
Normal file
252
docs/archive/audits-v1/01-feature-inventory.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# HMS 功能审计 — Phase 1: 功能清单与路由映射
|
||||
|
||||
> 日期: 2026-04-30 | 审计范围: 后端 + Web + 小程序三端
|
||||
|
||||
## 总览
|
||||
|
||||
| 维度 | 数量 |
|
||||
|------|------|
|
||||
| 后端路由 | 328 个(8 公开 + 320 受保护) |
|
||||
| Web 前端 API 调用 | 235 个 |
|
||||
| 小程序 API 调用 | 76 个 |
|
||||
| Web 页面路由 | 38 个 |
|
||||
| 小程序页面 | 40 个(31 患者 + 9 医护) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块路由分布
|
||||
|
||||
| 模块 | 后端路由 | Web API | 小程序 API | 状态 |
|
||||
|------|---------|---------|-----------|------|
|
||||
| erp-auth | 33 | 32 | 3 | 分叉正常(Web=管理端,MP=微信登录) |
|
||||
| erp-health | 169 | 124 | 57 | 覆盖广泛 |
|
||||
| erp-ai | 12 | 8 | 2 | Web 为主 |
|
||||
| erp-dialysis | 12 | 6 | 0 | **MP 缺失** |
|
||||
| erp-config | 26 | 26 | 0 | Web 专属(管理功能) |
|
||||
| erp-workflow | 17 | 14 | 0 | Web 专属(管理功能) |
|
||||
| erp-message | 13 | 8 | 0 | Web 专属 |
|
||||
| erp-plugin | 38 | 35 | 0 | Web 专属 |
|
||||
| erp-server | 8 | 1 | 1 | SSE + 健康检查 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 三端对齐矩阵 — 健康模块(核心业务)
|
||||
|
||||
### 2.1 患者管理
|
||||
|
||||
| 后端路由 | Web | 小程序 | 状态 |
|
||||
|----------|-----|--------|------|
|
||||
| `GET /health/patients` | ✓ list | ✓ listPatients + getPatients | ALIGNED |
|
||||
| `POST /health/patients` | ✓ create | ✓ createPatient | ALIGNED |
|
||||
| `GET /health/patients/{id}` | ✓ get | ✓ getPatient | ALIGNED |
|
||||
| `PUT /health/patients/{id}` | ✓ update | ✓ updatePatient | ALIGNED |
|
||||
| `DELETE /health/patients/{id}` | ✓ delete | — | WEB-ONLY(预期) |
|
||||
| `POST /health/patients/{id}/tags` | ✓ manageTags | — | WEB-ONLY(预期) |
|
||||
| `GET /health/patient-tags` | ✓ listTags | ✓ listPatientTags | ALIGNED |
|
||||
| `POST/PUT/DELETE /health/patient-tags` | ✓ CRUD | — | WEB-ONLY(预期) |
|
||||
| `GET /health/patients/{id}/health-summary` | — | ✓ getHealthSummary | **MP-ONLY** |
|
||||
| `GET /health/patients/{id}/family-members` | ✓ list | — | WEB-ONLY |
|
||||
| `POST/PUT/DELETE .../family-members` | ✓ CRUD | ✓ (pages) | MP 有独立页面 |
|
||||
| `POST .../doctors` (assign) | ✓ | — | WEB-ONLY |
|
||||
| `DELETE .../doctors/{did}` | ✓ | — | WEB-ONLY |
|
||||
|
||||
### 2.2 健康数据
|
||||
|
||||
| 后端路由 | Web | 小程序 | 状态 |
|
||||
|----------|-----|--------|------|
|
||||
| `GET/POST/PUT/DELETE .../vital-signs` | ✓ 全 CRUD | ✓ 仅 create(inputVitalSign) | 差异正常 |
|
||||
| `GET /health/vital-signs/today` | — | ✓ getTodaySummary | **MP-ONLY** |
|
||||
| `GET /health/vital-signs/trend` | — | ✓ getTrend | **MP-ONLY** |
|
||||
| `GET .../trends` | ✓ listTrends | — | WEB-ONLY |
|
||||
| `GET .../trends/{indicator}` | ✓ timeseries | — | WEB-ONLY |
|
||||
| `POST .../trends/generate` | ✓ | — | **ORPHAN**(未见前端调用) |
|
||||
| `GET/POST .../lab-reports` | ✓ 全 CRUD | ✓ list + get | MP 只读 |
|
||||
| `PUT .../lab-reports/{id}/review` | ✓ review | ✓ review(医护端) | ALIGNED |
|
||||
| `GET/POST/PUT/DELETE .../health-records` | ✓ 全 CRUD | — | **WEB-ONLY** |
|
||||
| `GET/POST/PUT/DELETE .../diagnoses` | ✓ 全 CRUD | — | **WEB-ONLY** |
|
||||
| `GET/POST/PUT/DELETE .../daily-monitoring` | ✓ 全 CRUD | ✓ create + list | MP 仅创建+列表 |
|
||||
| `GET/POST/PUT/DELETE .../medications` | ✓ 全 CRUD | — | **WEB-ONLY** |
|
||||
| `GET/POST/PUT/DELETE .../medication-reminders` | ✓ 全 CRUD | — | **WEB-ONLY** |
|
||||
|
||||
### 2.3 预约管理
|
||||
|
||||
| 后端路由 | Web | 小程序 | 状态 |
|
||||
|----------|-----|--------|------|
|
||||
| `GET /health/appointments` | ✓ list | ✓ list + doctor list | ALIGNED |
|
||||
| `POST /health/appointments` | ✓ create | ✓ create | ALIGNED |
|
||||
| `GET /health/appointments/{id}` | ✓ get | ✓ get | ALIGNED |
|
||||
| `PUT .../status` | ✓ updateStatus | ✓ cancelAppointment | ALIGNED |
|
||||
| `GET/POST/PUT /health/doctor-schedules` | ✓ | ✓ get + calendar | Web 有管理 CRUD |
|
||||
| `GET .../calendar` | ✓ calendar | ✓ calendarView | ALIGNED |
|
||||
| `GET /health/doctors` | ✓ list | ✓ list | ALIGNED |
|
||||
| `POST/PUT/DELETE /health/doctors` | ✓ CRUD | — | WEB-ONLY(预期) |
|
||||
|
||||
### 2.4 随访管理
|
||||
|
||||
| 后端路由 | Web | 小程序 | 状态 |
|
||||
|----------|-----|--------|------|
|
||||
| `GET/POST/PUT/DELETE .../follow-up-tasks` | ✓ 全 CRUD | ✓ list + get | Web 有管理,MP 只读 |
|
||||
| `POST .../batch-create/assign/complete` | ✓ 批量操作 | — | WEB-ONLY |
|
||||
| `POST .../records` (create) | ✓ | ✓ submit + doctor create | ALIGNED |
|
||||
| `GET .../follow-up-records` | ✓ list | ✓ list | ALIGNED |
|
||||
| `GET/POST/PUT/DELETE .../follow-up-templates` | ✓ 全 CRUD | — | WEB-ONLY |
|
||||
|
||||
### 2.5 咨询管理
|
||||
|
||||
| 后端路由 | Web | 小程序 | 状态 |
|
||||
|----------|-----|--------|------|
|
||||
| `GET/POST .../consultation-sessions` | ✓ list + create | ✓ list + doctor list | ALIGNED |
|
||||
| `GET .../{id}` | ✓ get | ✓ get | ALIGNED |
|
||||
| `GET .../{id}/messages` | ✓ list | ✓ list + doctor list | ALIGNED |
|
||||
| `POST .../consultation-messages` | ✓ create | ✓ send + doctor send | ALIGNED |
|
||||
| `PUT .../{id}/close` | ✓ close | ✓ doctor close | ALIGNED |
|
||||
| `PUT .../{id}/read` | ✓ markRead | ✓ markRead | ALIGNED |
|
||||
| `GET .../export` | ✓ export | — | WEB-ONLY(预期) |
|
||||
| `GET .../doctor/dashboard` | — | ✓ doctor dashboard | **MP-ONLY** |
|
||||
|
||||
### 2.6 内容管理(文章)
|
||||
|
||||
| 后端路由 | Web | 小程序 | 状态 |
|
||||
|----------|-----|--------|------|
|
||||
| `GET /health/articles` | ✓ list(全状态) | ✓ list(仅 published) | 角色分叉(正常) |
|
||||
| `GET /health/articles/{id}` | ✓ get | ✓ getDetail | ALIGNED |
|
||||
| `POST/PUT/DELETE /health/articles` | ✓ 全 CRUD | — | WEB-ONLY(预期) |
|
||||
| `POST .../submit/approve/reject/unpublish` | ✓ 审核流程 | — | WEB-ONLY(预期) |
|
||||
| `POST .../view` | ✓ view | — | WEB-ONLY |
|
||||
| `GET .../revisions` | ✓ revisions | — | WEB-ONLY |
|
||||
| `GET/POST/PUT/DELETE .../article-categories` | ✓ CRUD | ✓ list | MP 只读 |
|
||||
| `GET/POST/PUT/DELETE .../article-tags` | ✓ CRUD | — | WEB-ONLY |
|
||||
|
||||
### 2.7 积分商城
|
||||
|
||||
| 后端路由 | Web | 小程序 | 状态 |
|
||||
|----------|-----|--------|------|
|
||||
| `GET /health/points/account` | — | ✓ getAccount | **MP-ONLY** |
|
||||
| `POST /health/points/checkin` | — | ✓ dailyCheckin | **MP-ONLY** |
|
||||
| `GET /health/points/checkin/status` | — | ✓ getCheckinStatus | **MP-ONLY** |
|
||||
| `GET .../products` | ✓ admin + patient | ✓ listProducts | ALIGNED |
|
||||
| `POST .../exchange` | — | ✓ exchangeProduct | **MP-ONLY** |
|
||||
| `GET .../orders` | ✓ admin list | ✓ listMyOrders | 角色分叉(正常) |
|
||||
| `GET .../transactions` | — | ✓ listMyTransactions | **MP-ONLY** |
|
||||
| `POST .../verify` | ✓ verifyOrder | — | WEB-ONLY |
|
||||
| `GET .../offline-events` | ✓ admin + patient | ✓ list | ALIGNED |
|
||||
| `POST .../offline-events/{id}/register` | — | ✓ registerEvent | **MP-ONLY** |
|
||||
| `GET/POST/PUT/DELETE .../admin/points/rules` | ✓ CRUD | — | WEB-ONLY(预期) |
|
||||
| `GET/POST/PUT/DELETE .../admin/points/products` | ✓ CRUD | — | WEB-ONLY(预期) |
|
||||
| `GET .../admin/points/orders` | ✓ list | — | WEB-ONLY(预期) |
|
||||
| `GET .../admin/points/statistics` | ✓ | — | WEB-ONLY(预期) |
|
||||
| `GET .../admin/offline-events` | ✓ admin CRUD | — | WEB-ONLY(预期) |
|
||||
|
||||
### 2.8 告警系统
|
||||
|
||||
| 后端路由 | Web | 小程序 | 状态 |
|
||||
|----------|-----|--------|------|
|
||||
| `GET /health/alerts` | ✓ list | ✓ list + doctor list | ALIGNED |
|
||||
| `PUT .../acknowledge/dismiss/resolve` | ✓ | ✓ doctor acknowledge/dismiss/resolve | ALIGNED |
|
||||
| `GET/POST/PUT .../alert-rules` | ✓ 全 CRUD | — | WEB-ONLY(预期) |
|
||||
| `GET/POST .../critical-alerts` | ✓ list/get/acknowledge | — | **WEB-ONLY** |
|
||||
| `GET/POST/PUT/DELETE .../critical-value-thresholds` | ✓ 全 CRUD | — | **WEB-ONLY** |
|
||||
|
||||
### 2.9 设备与数据采集
|
||||
|
||||
| 后端路由 | Web | 小程序 | 状态 |
|
||||
|----------|-----|--------|------|
|
||||
| `GET .../devices` | ✓ list | — | WEB-ONLY |
|
||||
| `DELETE .../devices/{id}` | ✓ unbind | — | WEB-ONLY |
|
||||
| `POST .../device-readings/batch` | ✓ batchCreate | ✓ uploadReadings | ALIGNED |
|
||||
| `GET .../device-readings` | ✓ query | ✓ query | ALIGNED |
|
||||
| `GET .../device-readings/hourly` | ✓ hourly | ✓ queryHourly | ALIGNED |
|
||||
|
||||
### 2.10 AI 分析
|
||||
|
||||
| 后端路由 | Web | 小程序 | 状态 |
|
||||
|----------|-----|--------|------|
|
||||
| `POST /ai/analyze/*` (4 个 SSE) | — | — | **ORPHAN**(管理端未接入) |
|
||||
| `GET /ai/analysis/history` | ✓ list | ✓ list | ALIGNED |
|
||||
| `GET /ai/analysis/{id}` | ✓ get | ✓ getDetail | ALIGNED |
|
||||
| `GET/POST .../prompts` | ✓ CRUD | — | WEB-ONLY(预期) |
|
||||
| `POST .../prompts/{id}/activate/rollback` | ✓ | — | WEB-ONLY |
|
||||
| `GET .../usage/overview + by-type` | ✓ | — | WEB-ONLY |
|
||||
|
||||
### 2.11 透析管理
|
||||
|
||||
| 后端路由 | Web | 小程序 | 状态 |
|
||||
|----------|-----|--------|------|
|
||||
| `GET .../dialysis-records` | ✓ list | — | **WEB-ONLY** |
|
||||
| `POST/PUT/DELETE .../dialysis-records` | ✓ 全 CRUD | — | **WEB-ONLY** |
|
||||
| `PUT .../dialysis-records/{id}/review` | ✓ review | — | **WEB-ONLY** |
|
||||
| `GET/POST/PUT/DELETE .../dialysis-prescriptions` | ✓ 全 CRUD | — | **WEB-ONLY** |
|
||||
| `GET .../admin/statistics/dialysis` | ✓ | — | **WEB-ONLY** |
|
||||
|
||||
### 2.12 知情同意
|
||||
|
||||
| 后端路由 | Web | 小程序 | 状态 |
|
||||
|----------|-----|--------|------|
|
||||
| `GET .../consents` | ✓ list | — | **WEB-ONLY** |
|
||||
| `POST .../consents` (grant) | ✓ | — | **WEB-ONLY** |
|
||||
| `PUT .../consents/{id}/revoke` | ✓ | — | **WEB-ONLY** |
|
||||
|
||||
### 2.13 统计仪表盘
|
||||
|
||||
| 后端路由 | Web | 小程序 | 状态 |
|
||||
|----------|-----|--------|------|
|
||||
| `GET .../admin/statistics/*` (9 个) | ✓ 全部 | ✓ 3 个(doctor 端) | Web 完整,MP 部分覆盖 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 差距模式摘要
|
||||
|
||||
### 3.1 预期分叉(管理员 vs 患者角色)
|
||||
|
||||
以下差异是**正常的角色分叉**,Web 面向管理员,小程序面向患者/医护:
|
||||
|
||||
- 用户/角色/组织管理 → 仅 Web
|
||||
- 系统配置(字典/菜单/设置/主题/语言/编号) → 仅 Web
|
||||
- 工作流引擎 → 仅 Web
|
||||
- 消息管理 → 仅 Web
|
||||
- 插件系统 → 仅 Web
|
||||
- 微信登录 → 仅小程序
|
||||
- 积分签到/兑换 → 仅小程序
|
||||
- BLE 设备同步 → 仅小程序
|
||||
- 每日摘要/趋势 → 仅小程序
|
||||
- 医生仪表盘 → 仅小程序(医护端)
|
||||
|
||||
### 3.2 需关注的差距
|
||||
|
||||
| 差距 | 影响范围 | 优先级 |
|
||||
|------|---------|--------|
|
||||
| 透析管理 — 小程序完全无入口 | 患者无法在移动端查看透析记录 | P1 |
|
||||
| 知情同意 — 小程序无入口 | 患者无法在移动端管理同意书 | P1 |
|
||||
| AI 分析 SSE 端点 — 两端都无管理 UI 调用 | AI 功能可能仅通过直接 API 测试使用 | P2 |
|
||||
| 药物管理 — 小程序有页面但无 API 调用对应 | MP 药物页面可能使用其他 API 或硬编码 | P2 |
|
||||
| 趋势生成 `POST .../trends/generate` — 无前端调用 | 后台功能可能仅通过定时任务触发 | P3 |
|
||||
| 危急值告警/阈值管理 — 小程序无入口 | 仅 Web 管理端可操作 | P3 |
|
||||
| 健康记录 CRUD — 小程序无入口 | 患者移动端无法查看健康档案 | P2 |
|
||||
| 诊断记录 CRUD — 小程序无入口 | 患者移动端无法查看诊断 | P2 |
|
||||
|
||||
### 3.3 后端孤立路由(无任何前端调用者)
|
||||
|
||||
| 路由 | 说明 |
|
||||
|------|------|
|
||||
| `POST /health/patients/{id}/trends/generate` | 趋势报告生成,可能为内部任务 |
|
||||
| `POST /ai/analyze/*` (4 个 SSE) | AI 分析接口,可能通过 API 工具直接调用 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 功能完成度评估
|
||||
|
||||
| 功能域 | 后端 | Web | 小程序 | 整体完成度 |
|
||||
|--------|------|-----|--------|-----------|
|
||||
| 患者管理 | 100% | 100% | 85%(无删除) | 95% |
|
||||
| 医生/排班 | 100% | 100% | 40%(只读) | 80% |
|
||||
| 健康数据 | 100% | 100% | 60%(体征+化验+监测) | 87% |
|
||||
| 预约管理 | 100% | 100% | 90% | 97% |
|
||||
| 随访管理 | 100% | 100% | 70%(医护端完善) | 90% |
|
||||
| 咨询管理 | 100% | 100% | 95% | 98% |
|
||||
| 内容管理 | 100% | 100% | 50%(只读列表) | 83% |
|
||||
| 积分商城 | 100% | 80%(管理端) | 100%(患者端) | 93% |
|
||||
| 告警系统 | 100% | 100% | 60%(仅查看/处理) | 87% |
|
||||
| AI 分析 | 100% | 70%(无 SSE 调用) | 30%(仅历史查看) | 67% |
|
||||
| 透析管理 | 100% | 100% | 0% | 67% |
|
||||
| 知情同意 | 100% | 100% | 0% | 67% |
|
||||
| 统计仪表盘 | 100% | 100% | 30%(医护端部分) | 77% |
|
||||
191
docs/archive/audits-v1/02-backend-integrity.md
Normal file
191
docs/archive/audits-v1/02-backend-integrity.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# HMS 功能审计 — Phase 2: 后端完整性审计
|
||||
|
||||
> 日期: 2026-04-30 | 审计范围: 后端 Rust 代码
|
||||
|
||||
## 总览
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| Handler 函数 | 169 个(23 个文件) |
|
||||
| Service 函数 | 180 个(26 个文件) |
|
||||
| Entity | 45 个(46 个文件含 mod.rs) |
|
||||
| 路由 | 121 个(erp-health) |
|
||||
| 编译警告 | 40 个 |
|
||||
| 死代码抑制 | 4 处 |
|
||||
| TODO 注释 | 4 处 |
|
||||
| 生产代码 unwrap() | 10 处 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 死代码分析
|
||||
|
||||
### 1.1 `#[allow(dead_code)]` 抑制(4 处)
|
||||
|
||||
| 文件 | 行号 | 抑制内容 | 是否有调用者 | 判定 |
|
||||
|------|------|---------|------------|------|
|
||||
| `erp-auth/src/service/wechat_service.rs` | 43 | `unionid` 字段 | 微信 API 返回但不使用 | 保留合理(未来可能用于 UnionID 登录) |
|
||||
| `erp-server/src/middleware/rate_limit.rs` | 27 | `RateLimitConfig` 整个 struct | 当前使用环境变量配置 | 保留合理(预留结构化配置) |
|
||||
| `erp-plugin/src/host.rs` | 42 | `tenant_id` 字段 | HostState 中的 tenant_id | **建议清理**:已在 data_service 中改为函数参数传递 |
|
||||
| `erp-plugin/src/host.rs` | 44 | `user_id` 字段 | HostState 中的 user_id | **建议清理**:同上 |
|
||||
|
||||
### 1.2 编译器死代码警告(9 处未抑制)
|
||||
|
||||
| 文件 | 警告内容 | 严重性 |
|
||||
|------|---------|--------|
|
||||
| erp-health 多处 | `message`, `usage`, `input_tokens`, `output_tokens` 字段 | LOW — DTO 响应字段,前端可能使用 |
|
||||
| erp-health | `RefRow` struct 从未构造 | MEDIUM — 可能是重构残留 |
|
||||
| erp-health | `check_result`, `total` 字段 | LOW — 可能用于序列化 |
|
||||
| erp-server | `AnalyticsEvent.timestamp` 字段 | LOW — 预留字段 |
|
||||
|
||||
### 1.3 未使用导入(18 处)
|
||||
|
||||
分布在 6 个 crate 中,建议运行 `cargo fix` 自动清理:
|
||||
```bash
|
||||
cargo fix --lib -p erp-health --allow-dirty
|
||||
cargo fix --lib -p erp-plugin --allow-dirty
|
||||
cargo fix --lib -p erp-ai --allow-dirty
|
||||
```
|
||||
|
||||
### 1.4 TODO 注释(4 处)
|
||||
|
||||
| 文件 | 行号 | 内容 | 优先级 |
|
||||
|------|------|------|--------|
|
||||
| `erp-health/src/event.rs` | 50 | PATIENT_VERIFIED/PATIENT_DECEASED 未实现 | KNOWN |
|
||||
| `erp-auth/src/handler/wechat_handler.rs` | 45 | 多租户微信登录租户解析策略 | P2 |
|
||||
| `erp-auth/src/handler/wechat_handler.rs` | 76 | 同上 | P2 |
|
||||
| `erp-plugin/src/data_service.rs` | 1073 | 未来版本添加 Redis 缓存层 | P3 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 调用链完整性
|
||||
|
||||
### 2.1 Handler → Service 覆盖率
|
||||
|
||||
| Handler 文件 | Handler 函数数 | 对应 Service | 覆盖率 |
|
||||
|-------------|--------------|-------------|--------|
|
||||
| patient_handler | 17 | patient_service | 100% |
|
||||
| health_data_handler | 18 | health_data_service + trend_service | 100% |
|
||||
| points_handler | 28 | points_service + stats_service | 100% |
|
||||
| stats_handler | 9 | stats_service | 100% |
|
||||
| follow_up_handler | 10 | follow_up_service | 100% |
|
||||
| article_handler | 11 | article_service | 100% |
|
||||
| consultation_handler | 9 | consultation_service | 100% |
|
||||
| doctor_handler | 5 | doctor_service | 100% |
|
||||
| appointment_handler | 8 | appointment_service | 100% |
|
||||
| follow_up_template_handler | 5 | follow_up_template_service | 100% |
|
||||
| medication_record_handler | 5 | medication_record_service | 100% |
|
||||
| medication_reminder_handler | 4 | medication_reminder_service | 100% |
|
||||
| daily_monitoring_handler | 5 | daily_monitoring_service | 100% |
|
||||
| diagnosis_handler | 4 | diagnosis_service | 100% |
|
||||
| device_reading_handler | 3 | device_reading_service | 100% |
|
||||
| device_handler | 2 | device_service | 100% |
|
||||
| consent_handler | 3 | consent_service | 100% |
|
||||
| alert_handler | 4 | alert_service | 100% |
|
||||
| alert_rule_handler | 4 | alert_rule_service | 100% |
|
||||
| critical_alert_handler | 3 | critical_alert_service | 100% |
|
||||
| critical_value_threshold_handler | 4 | critical_value_threshold_service | 100% |
|
||||
| article_category_handler | 4 | article_category_service | 100% |
|
||||
| article_tag_handler | 4 | article_tag_service | 100% |
|
||||
|
||||
**结论:Handler → Service 覆盖率 100%。每个 handler 都有对应的 service 实现。**
|
||||
|
||||
### 2.2 Service → Entity 覆盖率
|
||||
|
||||
| Entity | 对应 Service | 状态 |
|
||||
|--------|-------------|------|
|
||||
| patient | patient_service | ✓ |
|
||||
| patient_family_member | patient_service | ✓ |
|
||||
| patient_tag | patient_service | ✓ |
|
||||
| patient_tag_relation | patient_service | ✓ |
|
||||
| patient_doctor_relation | patient_service | ✓ |
|
||||
| patient_devices | device_service | ✓ |
|
||||
| blind_index | patient_service(PII 加密) | ✓ |
|
||||
| consent | consent_service | ✓ |
|
||||
| doctor_profile | doctor_service | ✓ |
|
||||
| doctor_schedule | doctor_service | ✓ |
|
||||
| health_record | health_data_service | ✓ |
|
||||
| vital_signs | health_data_service | ✓ |
|
||||
| vital_signs_hourly | health_data_service | ✓ |
|
||||
| lab_report | health_data_service | ✓ |
|
||||
| health_trend | trend_service | ✓ |
|
||||
| diagnosis | diagnosis_service | ✓ |
|
||||
| medication_record | medication_record_service | ✓ |
|
||||
| medication_reminder | medication_reminder_service | ✓ |
|
||||
| device_readings | device_reading_service | ✓ |
|
||||
| appointment | appointment_service | ✓ |
|
||||
| follow_up_task | follow_up_service | ✓ |
|
||||
| follow_up_record | follow_up_service | ✓ |
|
||||
| follow_up_template | follow_up_template_service | ✓ |
|
||||
| follow_up_template_field | follow_up_template_service | ✓ |
|
||||
| consultation_session | consultation_service | ✓ |
|
||||
| consultation_message | consultation_service | ✓ |
|
||||
| article | article_service | ✓ |
|
||||
| article_category | article_category_service | ✓ |
|
||||
| article_tag | article_tag_service | ✓ |
|
||||
| article_article_tag | article_service | ✓ |
|
||||
| article_revision | article_service | ✓ |
|
||||
| alerts | alert_service | ✓ |
|
||||
| alert_rules | alert_rule_service | ✓ |
|
||||
| critical_alert | critical_alert_service | ✓ |
|
||||
| critical_alert_response | critical_alert_service | ✓ |
|
||||
| critical_value_threshold | critical_value_threshold_service | ✓ |
|
||||
| points_account | points_service | ✓ |
|
||||
| points_rule | points_service | ✓ |
|
||||
| points_product | points_service | ✓ |
|
||||
| points_order | points_service | ✓ |
|
||||
| points_transaction | points_service | ✓ |
|
||||
| points_checkin | points_service | ✓ |
|
||||
| offline_event | points_service | ✓ |
|
||||
| offline_event_registration | points_service | ✓ |
|
||||
| daily_monitoring | daily_monitoring_service | ✓ |
|
||||
|
||||
**结论:Entity → Service 覆盖率 100%。45 个实体全部有对应的 service 操作。**
|
||||
|
||||
---
|
||||
|
||||
## 3. unwrap() 调用审计
|
||||
|
||||
### 生产代码中的 unwrap()(10 处)
|
||||
|
||||
| 模式 | 次数 | 安全性 |
|
||||
|------|------|--------|
|
||||
| `active.version.unwrap() + 1` | 9 处 | **安全** — version 从 DB 查询获取,SeaORM ActiveModel 保证非 None |
|
||||
| `existing.unwrap().into()` | 1 处 | **需审查** — device_reading_service.rs:192 |
|
||||
|
||||
device_reading_service.rs:192 上下文:在 `update()` 函数中,`existing` 来自 `find_by_id` 查询。如果调用 `update()` 时记录不存在,会 panic。**建议**:改用 `ok_or(AppError::NotFound)` 模式。
|
||||
|
||||
### 测试代码中的 unwrap()(20+ 处)
|
||||
|
||||
全部在 `#[cfg(test)]` 块中,仅用于测试断言,无安全风险。
|
||||
|
||||
---
|
||||
|
||||
## 4. ErpModule Trait 覆盖率
|
||||
|
||||
| 模块 | on_startup | on_tenant_created | on_tenant_deleted | permissions | health_check | 等级 |
|
||||
|------|-----------|-------------------|-------------------|-------------|-------------|------|
|
||||
| erp-health | **5 个后台任务 + 事件监听** | **种子数据** | **软删除** | **56 个** | 默认 | **FULL** |
|
||||
| erp-ai | 默认 | 默认 | 默认 | 6 个 | 默认 | PARTIAL |
|
||||
| erp-dialysis | 默认 | 默认 | 默认 | 5 个 | 默认 | PARTIAL |
|
||||
| erp-auth | 默认 | 默认 | 默认 | 默认 | 默认 | MINIMAL |
|
||||
| erp-config | 默认 | 默认 | 默认 | 默认 | 默认 | MINIMAL |
|
||||
| erp-workflow | 默认 | 默认 | 默认 | 默认 | 默认 | MINIMAL |
|
||||
| erp-message | 默认 | 默认 | 默认 | 默认 | 默认 | MINIMAL |
|
||||
| erp-plugin | 默认 | 默认 | 默认 | 默认 | 默认 | MINIMAL |
|
||||
|
||||
**说明**:
|
||||
- MINIMAL 不一定代表缺陷 — auth/config/workflow/message 的权限通过中间件和 JWT claims 管控,不需要声明 PermissionDescriptor
|
||||
- 只有业务模块(health/ai/dialysis)需要声明细粒度权限码,因为它们的路由使用 `require_permission` 中间件
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端完整性评分
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 代码存在性 | 100% | 所有实体/服务/处理器 完整 |
|
||||
| 调用链连通性 | 100% | 处理器→服务→实体 全部连通 |
|
||||
| 死代码率 | 2% | 4 处抑制 + 9 处警告 / 462 文件 |
|
||||
| unwrap() 风险 | 98% | 仅 1 处 device_reading_service 可能 panic |
|
||||
| Trait 实现完整度 | 37.5% | 3/8 模块有实质实现(其余使用默认值) |
|
||||
| TODO 债务 | LOW | 4 处,均为已知的 P2/P3 预留项 |
|
||||
214
docs/archive/audits-v1/03-event-system.md
Normal file
214
docs/archive/audits-v1/03-event-system.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# HMS 功能审计 — Phase 3: 事件系统审计
|
||||
|
||||
> 日期: 2026-04-30 | 审计范围: 全系统事件总线
|
||||
|
||||
## 总览
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 事件类型常量定义 | 25 个(event.rs) |
|
||||
| 事件发布调用 | 44 处 |
|
||||
| 事件消费者 | 14 个(11 个 tokio::spawn 任务) |
|
||||
| 已知未实现事件 | 2 个(PATIENT_VERIFIED / PATIENT_DECEASED,标记 KNOWN) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 事件发布方清单
|
||||
|
||||
### erp-health 模块(30 处发布)
|
||||
|
||||
| 事件类型 | 发布者 Service | 函数 | Payload 字段 |
|
||||
|----------|---------------|------|-------------|
|
||||
| `patient.created` | patient_service.rs | create_patient | patient_id, name, phone |
|
||||
| `patient.updated` | patient_service.rs | update_patient | patient_id, updated_fields |
|
||||
| `appointment.created` | appointment_service.rs | create_appointment | appointment_id, patient_id, doctor_id, scheduled_at |
|
||||
| `appointment.{status}` | appointment_service.rs | update_appointment_status | appointment_id, patient_id, doctor_id, status |
|
||||
| `appointment.reminder` | appointment_service.rs | send_reminders | appointment_id, patient_id, doctor_id, scheduled_at |
|
||||
| `follow_up.created` | follow_up_service.rs | create_task | task_id, patient_id, assigned_to |
|
||||
| `follow_up.batch_created` | follow_up_service.rs | batch_create_tasks | count, task_ids |
|
||||
| `follow_up.completed` | follow_up_service.rs | batch_complete_tasks | task_ids, completed_count |
|
||||
| `follow_up.assigned` | follow_up_service.rs | batch_assign_tasks | task_ids, assigned_to |
|
||||
| `follow_up.overdue` | follow_up_service.rs | check_overdue_tasks | task_id, assigned_to, patient_id |
|
||||
| `consultation.opened` | consultation_service.rs | create_session | session_id, patient_id, doctor_id |
|
||||
| `consultation.closed` | consultation_service.rs | close_session | session_id, patient_id, doctor_id |
|
||||
| `consultation.new_message` | consultation_service.rs | create_message | session_id, sender_id, sender_type |
|
||||
| `device.readings.synced` | device_reading_service.rs | sync_readings | patient_id, device_type, reading_count |
|
||||
| `vital_signs.created` | health_data_service.rs | create_vital_signs | patient_id, record_id, indicators |
|
||||
| `lab_report.uploaded` | health_data_service.rs | create_lab_report | patient_id, report_id, indicator_count |
|
||||
| `lab_report.reviewed` | health_data_service.rs | review_lab_report | report_id, reviewer_id, status |
|
||||
| `health_data.critical_alert` | health_data_service.rs | create_vital_signs | patient_id, alert_type, metric_name, metric_value, threshold_value |
|
||||
| `daily_monitoring.created` | daily_monitoring_service.rs | create_daily_monitoring | patient_id, monitoring_id, record_date |
|
||||
| `alert.triggered` | alert_engine.rs | evaluate_rules | patient_id, severity, rule_name, alert_id |
|
||||
| `article.published` | article_service.rs | publish_article | article_id, title, author_id |
|
||||
| `article.rejected` | article_service.rs | reject_article | article_id, reviewer_id, reason |
|
||||
| `consent.granted` | consent_service.rs | grant_consent | patient_id, consent_type |
|
||||
| `consent.revoked` | consent_service.rs | revoke_consent | patient_id, consent_type, reason |
|
||||
| `points.earned` | points_service.rs | daily_checkin | patient_id, points, balance |
|
||||
| `points.exchanged` | points_service.rs | exchange_product | patient_id, product_id, order_id, points |
|
||||
| `points.expired` | points_service.rs | expire_points | count, expired_points_total |
|
||||
| `doctor.online_status_changed` | doctor_service.rs | update_online_status | doctor_id, old_status, new_status |
|
||||
|
||||
### erp-ai 模块(2 处发布)
|
||||
|
||||
| 事件类型 | 发布者 | 函数 |
|
||||
|----------|--------|------|
|
||||
| `ai.analysis.failed` | handler/mod.rs | stream_lab_report 等 |
|
||||
| `ai.analysis.completed` | handler/mod.rs | stream_lab_report 等 |
|
||||
|
||||
### erp-dialysis 模块(1 处发布)
|
||||
|
||||
| 事件类型 | 发布者 | 函数 |
|
||||
|----------|--------|------|
|
||||
| `dialysis.record.created` | dialysis_service.rs | create_record |
|
||||
|
||||
### erp-auth 模块(1 处发布)
|
||||
|
||||
| 事件类型 | 发布者 | 函数 |
|
||||
|----------|--------|------|
|
||||
| `user.login` | auth_service.rs | login |
|
||||
|
||||
### erp-workflow 模块(2+ 处发布)
|
||||
|
||||
| 事件类型 | 发布者 | 函数 |
|
||||
|----------|--------|------|
|
||||
| `workflow.instance.*` | instance_service.rs | start_instance 等 |
|
||||
| `workflow.task.timeout` | module.rs | 后台超时检测 |
|
||||
|
||||
### erp-plugin 模块(3 处发布)
|
||||
|
||||
| 事件类型 | 发布者 | 函数 |
|
||||
|----------|--------|------|
|
||||
| `plugin.data.*` | data_service.rs | CRUD 操作 |
|
||||
| `plugin.trigger.*` | data_service.rs | 通知触发 |
|
||||
| 动态事件 | engine.rs | 插件自定义事件 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 事件消费方清单
|
||||
|
||||
### erp-health 消费者(11 个 tokio::spawn 任务)
|
||||
|
||||
| # | 消费者名称 | 订阅前缀 | 处理事件 | 业务动作 |
|
||||
|---|-----------|---------|---------|---------|
|
||||
| 1 | workflow_task_consumer | `workflow.task.` | `workflow.task.completed` | 更新随访任务状态为 completed |
|
||||
| 2 | message_consumer | `message.` | `message.sent` | 日志记录(预留扩展) |
|
||||
| 3 | device_reading_consumer | `device.readings.` | `device.readings.synced` | 触发告警引擎评估 |
|
||||
| 4 | alert_notifier | `alert.` | `alert.triggered` | 发送应用内告警通知 |
|
||||
| 5 | patient_welcome | `patient.` | `patient.created` | 发送欢迎消息 |
|
||||
| 6 | appointment_notifier | `appointment.` | `appointment.confirmed` | 发送预约确认通知 |
|
||||
| 7 | appointment_cancel_handler | `appointment.` | `appointment.cancelled` | 日志记录(号源释放) |
|
||||
| 8 | follow_up_escalator | `follow_up.` | `follow_up.overdue` | 发送逾期升级通知 |
|
||||
| 9 | critical_alert_consumer | `health_data.` | `health_data.critical_alert` | 创建危急值告警记录 |
|
||||
| 10 | ai_analysis_notifier | `ai.` | `ai.analysis.completed` | 通知关联医生 |
|
||||
| 11 | dialysis_notifier | `ai.` | `dialysis.record.created` | 日志记录 |
|
||||
| 12 | consent_notifier | `consent.` | `consent.granted` | 发送授予通知 |
|
||||
| 13 | consent_revoked_notifier | `consent.` | `consent.revoked` | 发送撤回通知给医护 |
|
||||
|
||||
### 其他模块消费者
|
||||
|
||||
| 模块 | 消费者 | 订阅范围 |
|
||||
|------|--------|---------|
|
||||
| erp-message | SSE handler | 全部事件(转发给前端) |
|
||||
| erp-plugin | notification handler | `plugin.trigger.` |
|
||||
| erp-plugin | engine | 按插件 manifest 的 pattern |
|
||||
|
||||
---
|
||||
|
||||
## 3. 事件常量 vs 发布方 vs 消费方矩阵
|
||||
|
||||
| 事件常量 | 事件类型 | 发布方 | 消费方 | 状态 |
|
||||
|----------|---------|--------|--------|------|
|
||||
| APPOINTMENT_CREATED | `appointment.created` | ✓ appointment_service | ✓ appointment_notifier(前缀匹配) | **ALIVE** |
|
||||
| ALERT_TRIGGERED | `alert.triggered` | ✓ alert_engine | ✓ alert_notifier | **ALIVE** |
|
||||
| CONSENT_GRANTED | `consent.granted` | ✓ consent_service | ✓ consent_notifier | **ALIVE** |
|
||||
| CONSENT_REVOKED | `consent.revoked` | ✓ consent_service | ✓ consent_revoked_notifier | **ALIVE** |
|
||||
| ARTICLE_PUBLISHED | `article.published` | ✓ article_service | — | **NO CONSUMER** |
|
||||
| ARTICLE_REJECTED | `article.rejected` | ✓ article_service | — | **NO CONSUMER** |
|
||||
| CONSULTATION_OPENED | `consultation.opened` | ✓ consultation_service | — | **NO CONSUMER** |
|
||||
| CONSULTATION_CLOSED | `consultation.closed` | ✓ consultation_service | — | **NO CONSUMER** |
|
||||
| CONSULTATION_NEW_MESSAGE | `consultation.new_message` | ✓ consultation_service | — | **NO CONSUMER** |
|
||||
| DEVICE_READINGS_SYNCED | `device.readings.synced` | ✓ device_reading_service | ✓ device_reading_consumer | **ALIVE** |
|
||||
| DOCTOR_ONLINE_STATUS_CHANGED | `doctor.online_status_changed` | ✓ doctor_service | — | **NO CONSUMER** |
|
||||
| FOLLOW_UP_CREATED | `follow_up.created` | ✓ follow_up_service | — | **NO CONSUMER** |
|
||||
| FOLLOW_UP_COMPLETED | `follow_up.completed` | ✓ follow_up_service | — | **NO CONSUMER** |
|
||||
| FOLLOW_UP_OVERDUE | `follow_up.overdue` | ✓ follow_up_service | ✓ follow_up_escalator | **ALIVE** |
|
||||
| DAILY_MONITORING_CREATED | `daily_monitoring.created` | ✓ daily_monitoring_service | — | **NO CONSUMER** |
|
||||
| LAB_REPORT_UPLOADED | `lab_report.uploaded` | ✓ health_data_service | — | **NO CONSUMER** |
|
||||
| LAB_REPORT_REVIEWED | `lab_report.reviewed` | ✓ health_data_service | — | **NO CONSUMER** |
|
||||
| HEALTH_DATA_CRITICAL_ALERT | `health_data.critical_alert` | ✓ health_data_service | ✓ critical_alert_consumer | **ALIVE** |
|
||||
| PATIENT_CREATED | `patient.created` | ✓ patient_service | ✓ patient_welcome | **ALIVE** |
|
||||
| PATIENT_UPDATED | `patient.updated` | ✓ patient_service | — | **NO CONSUMER** |
|
||||
| PATIENT_VERIFIED | `patient.verified` | — | — | **KNOWN** 未实现 |
|
||||
| PATIENT_DECEASED | `patient.deceased` | — | — | **KNOWN** 未实现 |
|
||||
| POINTS_EXPIRED | `points.expired` | ✓ points_service | — | **NO CONSUMER** |
|
||||
| POINTS_EARNED | `points.earned` | ✓ points_service | — | **NO CONSUMER** |
|
||||
| POINTS_EXCHANGED | `points.exchanged` | ✓ points_service | — | **NO CONSUMER** |
|
||||
|
||||
---
|
||||
|
||||
## 4. 分析结论
|
||||
|
||||
### 4.1 活跃事件(有发布+有消费):11 个
|
||||
这是系统核心业务链路,全部正常:
|
||||
- `workflow.task.completed` → 随访自动完成
|
||||
- `device.readings.synced` → 告警评估
|
||||
- `alert.triggered` → 告警通知
|
||||
- `patient.created` → 欢迎消息
|
||||
- `appointment.confirmed/cancelled` → 预约通知
|
||||
- `follow_up.overdue` → 逾期升级
|
||||
- `health_data.critical_alert` → 危急值告警
|
||||
- `ai.analysis.completed` → 医生通知
|
||||
- `dialysis.record.created` → 日志
|
||||
- `consent.granted/revoked` → 知情同意通知
|
||||
|
||||
### 4.2 有发布无消费事件:14 个
|
||||
这些事件被发布到 EventBus 并持久化到 domain_events 表,但没有业务消费者。它们可能仅用于审计追踪或 SSE 前端推送。
|
||||
|
||||
**风险评估**:
|
||||
| 事件 | 风险 | 说明 |
|
||||
|------|------|------|
|
||||
| `patient.updated` | LOW | 信息性事件,更新操作已直接处理 |
|
||||
| `vital_signs.created` | LOW | 数据已写入 DB,无需后续触发 |
|
||||
| `lab_report.uploaded` | LOW | 同上 |
|
||||
| `lab_report.reviewed` | LOW | 同上 |
|
||||
| `follow_up.created` | LOW | 任务已创建,无需后续触发 |
|
||||
| `follow_up.completed` | LOW | 状态已更新,无需后续触发 |
|
||||
| `consultation.*` | LOW | 会话管理已在 service 内直接处理 |
|
||||
| `article.published/rejected` | LOW | 状态已更新,无需后续触发 |
|
||||
| `points.earned/exchanged/expired` | LOW | 积分操作已在 service 内完成 |
|
||||
| `daily_monitoring.created` | LOW | 数据已写入 |
|
||||
| `doctor.online_status_changed` | LOW | 状态已更新 |
|
||||
|
||||
### 4.3 已知未实现事件(KNOWN):2 个
|
||||
- `PATIENT_VERIFIED` — 患者认证流程未实现
|
||||
- `PATIENT_DECEASED` — 死亡记录流程未实现
|
||||
|
||||
### 4.4 潜在问题
|
||||
1. **message.sent 消费者仅为日志记录** — event.rs:119 的消费者仅做 tracing::info,实际业务逻辑(如更新 consultation last_message_at)已在 service 层直接处理,此消费者预留扩展但当前无实质作用。
|
||||
2. **SSE 全事件转发** — erp-message 的 SSE handler 监听所有事件,意味着所有事件(包括 points.earned 等低优先级事件)都会被推送到前端,可能导致通知噪音。
|
||||
|
||||
---
|
||||
|
||||
## 5. Payload Schema 一致性验证(抽样 5 个)
|
||||
|
||||
| 事件 | 发布方字段 | 消费方解析 | 一致性 |
|
||||
|------|-----------|-----------|--------|
|
||||
| `workflow.task.completed` | `task_id` | `task_id` → Uuid | ✅ |
|
||||
| `device.readings.synced` | `patient_id` | `patient_id` → Uuid | ✅ |
|
||||
| `health_data.critical_alert` | `patient_id, alert_type, metric_name, metric_value, threshold_value` | 全部解析 | ✅ |
|
||||
| `follow_up.overdue` | `task_id, assigned_to` | 全部解析 | ✅ |
|
||||
| `appointment.confirmed` | `doctor_id, patient_id` | 全部解析 | ✅ |
|
||||
|
||||
**结论**:所有抽样的关键事件 payload schema 发布方与消费方完全一致。所有消费者都使用幂等检查(`is_event_processed`),防止重复处理。
|
||||
|
||||
---
|
||||
|
||||
## 6. 事件系统评分
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 事件定义完整性 | 95% | 25/27 常量有发布者(2 个 KNOWN 未实现) |
|
||||
| 消费者覆盖率 | 44% | 11/25 事件有活跃消费者 |
|
||||
| Payload 一致性 | 100% | 抽样 5 个全部一致 |
|
||||
| 幂等性保证 | 100% | 所有消费者使用 `is_event_processed` 检查 |
|
||||
| 死信处理 | 100% | 消费失败自动进入 dead_letter_event 表 |
|
||||
334
docs/archive/audits-v1/04-parameter-config.md
Normal file
334
docs/archive/audits-v1/04-parameter-config.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# HMS 功能审计 — Phase 4: 参数传递与配置审计
|
||||
|
||||
> 日期: 2026-04-30 | 审计范围: DTO 覆盖率、配置参数、权限码、数据模型映射
|
||||
|
||||
## 总览
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| DTO 结构体 | 105 个(17 文件) |
|
||||
| 配置参数 | 30 个字段(12 配置节) |
|
||||
| 未使用配置字段 | 6 个(均在 AiConfig) |
|
||||
| 声明权限码 | 50 个(3 模块) |
|
||||
| Handler 实际使用权限码 | ~106 个 |
|
||||
| 前端 AuthButton 引用 | 13 个唯一码 |
|
||||
| 小程序未映射字段 | 6 个(vital_signs) |
|
||||
|
||||
---
|
||||
|
||||
## 1. DTO 覆盖率检查
|
||||
|
||||
### 1.1 DTO 分布统计
|
||||
|
||||
| 分类 | 数量 | 说明 |
|
||||
|------|------|------|
|
||||
| Create(Create*Req) | 27 | 创建请求 |
|
||||
| Update(Update*Req) | 19 | 更新/审核/撤销/核销请求 |
|
||||
| Query(*Query/*Params) | 8 | 列表查询参数 |
|
||||
| Response(*Resp/*Response) | 38 | 响应结构体 |
|
||||
| Other(批量操作/辅助) | 13 | BatchReq、ExchangeReq、数据点等 |
|
||||
| **总计** | **105** | 17 个文件 |
|
||||
|
||||
### 1.2 按 Handler 的 DTO 覆盖矩阵
|
||||
|
||||
| Handler | Create DTO | Update DTO | Query DTO | Response DTO | 状态 |
|
||||
|---------|-----------|-----------|----------|-------------|------|
|
||||
| patient_handler | CreatePatientReq | UpdatePatientReq | PatientListQuery | PatientResp | ✓ |
|
||||
| doctor_handler | CreateDoctorReq | UpdateDoctorReq | DoctorListQuery | DoctorResp | ✓ |
|
||||
| appointment_handler | CreateAppointmentReq | UpdateAppointmentStatusReq | AppointmentListQuery/CalendarQuery | AppointmentResp/ScheduleResp | ✓ |
|
||||
| consultation_handler | CreateSessionReq/CreateMessageReq | — | SessionQuery | SessionResp/MessageResp | ✓ |
|
||||
| follow_up_handler | CreateFollowUpTaskReq/BatchCreateTasksReq | UpdateFollowUpTaskReq | FollowUpTaskListQuery/FollowUpRecordListQuery | FollowUpTaskResp/FollowUpRecordResp | ✓ |
|
||||
| follow_up_template_handler | CreateFollowUpTemplateReq | UpdateFollowUpTemplateReq | FollowUpTemplateListQuery | FollowUpTemplateResp | ✓ |
|
||||
| health_data_handler | CreateVitalSignsReq/CreateLabReportReq/CreateHealthRecordReq | UpdateVitalSignsReq/UpdateLabReportReq/UpdateHealthRecordReq/ReviewLabReportReq | MiniTrendQueryParams | VitalSignsResp/LabReportResp/HealthRecordResp/TrendResp | ✓ |
|
||||
| daily_monitoring_handler | CreateDailyMonitoringReq | UpdateDailyMonitoringReq | — | DailyMonitoringResp | ✓ |
|
||||
| diagnosis_handler | CreateDiagnosisReq | UpdateDiagnosisReq | — | DiagnosisResp | ✓ |
|
||||
| medication_record_handler | CreateMedicationRecordReq | UpdateMedicationRecordReq | — | MedicationRecordResp | ✓ |
|
||||
| medication_reminder_handler | CreateMedicationReminderReq | UpdateMedicationReminderReq | — | MedicationReminderResp | ✓ |
|
||||
| alert_handler | CreateAlertRuleRequest | UpdateAlertRuleRequest/AcknowledgeAlertRequest | — | AlertRuleResponse/AlertResponse | ✓ |
|
||||
| consent_handler | CreateConsentReq | RevokeConsentReq | — | ConsentResp | ✓ |
|
||||
| points_handler | CreatePointsRuleReq/CreatePointsProductReq/CreateOfflineEventReq | UpdatePointsRuleReq/UpdatePointsProductReq/UpdateOfflineEventWithVersion/VerifyOrderReq | — | PointsRuleResp/PointsProductResp/PointsOrderResp/PointsAccountResp | ✓ |
|
||||
| article_handler | CreateArticleReq/CreateCategoryReq/CreateTagReq | UpdateArticleReq/UpdateCategoryReq/UpdateTagReq/ReviewArticleReq | ArticleListParams | ArticleResp/CategoryResp/TagResp/ArticleRevisionResp | ✓ |
|
||||
| stats_handler | — | — | — | DashboardStatsResp/PatientStatisticsResp/...(9 个) | ✓ |
|
||||
|
||||
**结论:所有 23 个 handler 都有完整的 DTO 覆盖。每个写入端点有 Create/Update DTO,列表端点有 Query DTO,所有端点有 Response DTO。**
|
||||
|
||||
### 1.3 DTO 传递链完整性(抽样验证 5 个)
|
||||
|
||||
| 端点 | Handler 输入 DTO | Service 函数签名 | 一致性 |
|
||||
|------|-----------------|-----------------|--------|
|
||||
| `POST /patients` | CreatePatientReq | `create_patient(req: CreatePatientReq)` | ✅ |
|
||||
| `POST /appointments` | CreateAppointmentReq | `create_appointment(req: CreateAppointmentReq)` | ✅ |
|
||||
| `POST /vital-signs` | CreateVitalSignsReq | `create_vital_signs(patient_id, req: CreateVitalSignsReq)` | ✅ |
|
||||
| `POST /follow-up/tasks` | CreateFollowUpTaskReq | `create_task(req: CreateFollowUpTaskReq)` | ✅ |
|
||||
| `POST /consultation-sessions` | CreateSessionReq | `create_session(req: CreateSessionReq)` | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 2. 配置参数使用率
|
||||
|
||||
### 2.1 配置结构总览
|
||||
|
||||
AppConfig 包含 12 个子配置节、30 个字段:
|
||||
|
||||
| 配置节 | 字段数 | 用途 |
|
||||
|--------|-------|------|
|
||||
| ServerConfig | 3 | 服务端口、指标端口 |
|
||||
| DatabaseConfig | 3 | 连接字符串、连接池大小 |
|
||||
| RedisConfig | 1 | 连接字符串 |
|
||||
| JwtConfig | 3 | 密钥、Token TTL |
|
||||
| AuthConfig | 1 | 超级管理员密码 |
|
||||
| LogConfig | 1 | 日志级别 |
|
||||
| CorsConfig | 1 | 允许的 Origin |
|
||||
| WechatConfig | 3 | 微信小程序 AppID/Secret/开发模式 |
|
||||
| HealthConfig | 2 | AES/HMAC 密钥(PII 加密) |
|
||||
| CryptoConfig | 1 | KEK 主密钥 |
|
||||
| AiConfig | 8 | AI 提供商配置 |
|
||||
| StorageConfig | 2 | 文件上传目录/大小限制 |
|
||||
|
||||
### 2.2 字段使用情况
|
||||
|
||||
| 配置节 | 字段 | 使用次数 | 状态 |
|
||||
|--------|------|---------|------|
|
||||
| ServerConfig | `host` | 1 | ✓ 正常 |
|
||||
| ServerConfig | `port` | 1 | ✓ 正常 |
|
||||
| ServerConfig | `metrics_port` | 2 | ✓ 正常 |
|
||||
| DatabaseConfig | `url` | 5 | ✓ 正常 |
|
||||
| DatabaseConfig | `max_connections` | 1 | ✓ 正常 |
|
||||
| DatabaseConfig | `min_connections` | 1 | ✓ 正常 |
|
||||
| RedisConfig | `url` | 2 | ✓ 正常 |
|
||||
| JwtConfig | `secret` | 5 | ✓ 正常 |
|
||||
| JwtConfig | `access_token_ttl` | 1 | ✓ 正常 |
|
||||
| JwtConfig | `refresh_token_ttl` | 1 | ✓ 正常 |
|
||||
| AuthConfig | `super_admin_password` | 1 | ✓ 正常 |
|
||||
| LogConfig | `level` | 1 | ✓ 正常 |
|
||||
| CorsConfig | `allowed_origins` | 1 | ✓ 正常 |
|
||||
| WechatConfig | `appid` | 2 | ✓ 正常 |
|
||||
| WechatConfig | `secret` | 3 | ✓ 正常 |
|
||||
| WechatConfig | `dev_mode` | 2 | ✓ 正常 |
|
||||
| HealthConfig | `aes_key` | 2 | ✓ 正常(启动校验 + CryptoService 初始化) |
|
||||
| HealthConfig | `hmac_key` | 2 | ✓ 正常(启动校验 + HMAC 盲索引) |
|
||||
| CryptoConfig | `kek` | 2 | ✓ 正常(KEK/DEK 密钥体系) |
|
||||
| AiConfig | `api_key` | 1 | ✓ 正常 |
|
||||
| AiConfig | `base_url` | 1 | ✓ 正常 |
|
||||
| **AiConfig** | **`default_provider`** | **0** | **未使用** |
|
||||
| **AiConfig** | **`model`** | **0** | **未使用** |
|
||||
| **AiConfig** | **`max_tokens`** | **0** | **未使用** |
|
||||
| **AiConfig** | **`temperature`** | **0** | **未使用** |
|
||||
| **AiConfig** | **`cache_ttl_seconds`** | **0** | **未使用** |
|
||||
| **AiConfig** | **`rate_limit_patient_daily`** | **0** | **未使用** |
|
||||
| StorageConfig | `upload_dir` | 3 | ✓ 正常 |
|
||||
| StorageConfig | `max_file_size` | 1 | ✓ 正常 |
|
||||
|
||||
### 2.3 未使用字段分析
|
||||
|
||||
6 个未使用字段全部集中在 `AiConfig`:
|
||||
|
||||
| 字段 | 原设计意图 | 当前替代机制 |
|
||||
|------|-----------|------------|
|
||||
| `default_provider` | 全局默认 AI 提供商 | 硬编码使用 Claude |
|
||||
| `model` | 全局默认模型 | 每个 Prompt 模板通过 `model_config` JSONB 字段配置 |
|
||||
| `max_tokens` | 全局默认最大 Token 数 | 同上,从 `model_config.max_tokens` 读取,默认 2048 |
|
||||
| `temperature` | 全局默认温度 | 同上,从 `model_config.temperature` 读取,默认 0.3 |
|
||||
| `cache_ttl_seconds` | AI 结果缓存时间 | 未实现缓存层(每次为独立 SSE 流) |
|
||||
| `rate_limit_patient_daily` | 每患者每日 AI 调用限制 | 未实现限流 |
|
||||
|
||||
**影响评估**:LOW。这些字段是预留的全局默认值,实际业务已通过更灵活的 per-prompt `model_config` 实现相同能力。但 `cache_ttl_seconds` 和 `rate_limit_patient_daily` 代表缺失的功能(缓存和限流),属于 P3 待实现。
|
||||
|
||||
---
|
||||
|
||||
## 3. 权限码审计
|
||||
|
||||
### 3.1 权限码声明总览
|
||||
|
||||
| 模块 | 声明数 | 权限码前缀 |
|
||||
|------|--------|-----------|
|
||||
| erp-health | 39 | `health.*` |
|
||||
| erp-ai | 6 | `ai.*` |
|
||||
| erp-dialysis | 5 | `health.dialysis*` |
|
||||
| **合计** | **50** | |
|
||||
|
||||
其余 5 个模块(auth/config/workflow/message/plugin)**未声明任何 PermissionDescriptor**,但 handler 中使用了约 56 个权限码。
|
||||
|
||||
### 3.2 声明 vs 使用覆盖矩阵
|
||||
|
||||
#### erp-health(39 声明 → 全部使用)
|
||||
|
||||
| 权限码 | Handler 使用 | 前端 AuthButton |
|
||||
|--------|-------------|----------------|
|
||||
| health.patient.list | ✓ | — |
|
||||
| health.patient.manage | ✓ | ✓ PatientList/PatientDetail/PatientTagManage |
|
||||
| health.health-data.list | ✓ | — |
|
||||
| health.health-data.manage | ✓ | ✓ VitalSignsTab/LabReportsTab/HealthRecordsTab/DailyMonitoringTab |
|
||||
| health.appointment.list | ✓ | — |
|
||||
| health.appointment.manage | ✓ | ✓ AppointmentList |
|
||||
| health.follow-up.list | ✓ | — |
|
||||
| health.follow-up.manage | ✓ | ✓ FollowUpTaskList |
|
||||
| health.consultation.list | ✓ | — |
|
||||
| health.consultation.manage | ✓ | ✓ ConsultationList/ConsultationDetail |
|
||||
| health.doctor.list | ✓ | — |
|
||||
| health.doctor.manage | ✓ | ✓ DoctorList/DoctorSchedule |
|
||||
| health.articles.list | ✓ | — |
|
||||
| health.articles.manage | ✓ | ✓ ArticleManageList/ArticleEditor/ArticleCategoryManage/ArticleTagManage |
|
||||
| health.articles.review | ✓ | ✓ ArticleManageList |
|
||||
| health.points.list | ✓ | — |
|
||||
| health.points.manage | ✓ | ✓ PointsRuleList/PointsProductList/PointsOrderList/OfflineEventList |
|
||||
| health.device-readings.list | ✓ | — |
|
||||
| health.device-readings.manage | ✓ | — |
|
||||
| health.devices.list | ✓ | — |
|
||||
| health.devices.manage | ✓ | — |
|
||||
| health.alerts.list | ✓ | — |
|
||||
| health.alerts.manage | ✓ | ⚠️ 前端拼写错误(见 §3.4) |
|
||||
| health.alert-rules.list | ✓ | — |
|
||||
| health.alert-rules.manage | ✓ | — |
|
||||
| health.critical-alerts.list | ✓ | — |
|
||||
| health.critical-alerts.manage | ✓ | — |
|
||||
| health.critical-value-thresholds.list | ✓ | — |
|
||||
| health.critical-value-thresholds.manage | ✓ | — |
|
||||
| health.follow-up-templates.list | ✓ | — |
|
||||
| health.follow-up-templates.manage | ✓ | — |
|
||||
| health.daily-monitoring.list | ✓ | — |
|
||||
| health.daily-monitoring.manage | ✓ | — |
|
||||
| health.consent.list | ✓ | — |
|
||||
| health.consent.manage | ✓ | — |
|
||||
| health.medication-records.list | ✓ | — |
|
||||
| health.medication-records.manage | ✓ | — |
|
||||
| health.medication-reminders.list | ✓ | — |
|
||||
| health.medication-reminders.manage | ✓ | — |
|
||||
|
||||
#### erp-dialysis(5 声明 → 全部使用)
|
||||
|
||||
| 权限码 | Handler 使用 | 前端 AuthButton |
|
||||
|--------|-------------|----------------|
|
||||
| health.dialysis.list | ✓ | — |
|
||||
| health.dialysis.manage | ✓ | ✓ DialysisManageList |
|
||||
| health.dialysis-prescription.list | ✓ | — |
|
||||
| health.dialysis-prescription.manage | ✓ | — |
|
||||
| health.dialysis.stats | ✓ | — |
|
||||
|
||||
#### erp-ai(6 声明 → 5 使用)
|
||||
|
||||
| 权限码 | Handler 使用 | 前端 AuthButton |
|
||||
|--------|-------------|----------------|
|
||||
| ai.analysis.list | ✓ | — |
|
||||
| ai.analysis.manage | ✓ | — |
|
||||
| ai.prompt.list | ✓ | ✓ AiPromptList |
|
||||
| ai.prompt.manage | ✓ | ✓ AiPromptList |
|
||||
| ai.usage.list | ✓ | — |
|
||||
| **ai.provider.manage** | **—** | **已声明但无 Handler 调用** |
|
||||
|
||||
### 3.3 未声明权限码(基础模块)
|
||||
|
||||
以下 5 个模块的 handler 使用了 `require_permission` 但未实现 `permissions()` 方法,导致权限码不会通过 `sync_module_permissions` 自动注册到数据库:
|
||||
|
||||
| 模块 | 未声明权限数 | 权限码示例 |
|
||||
|------|------------|-----------|
|
||||
| erp-auth | 23 | user.list, role.create, organization.update, department.delete, position.list |
|
||||
| erp-config | 18 | dictionary.list, menu.update, setting.read, numbering.generate, theme.update |
|
||||
| erp-workflow | 8 | workflow.list, workflow.start, workflow.approve, workflow.delegate |
|
||||
| erp-message | 5 | message.list, message.send, message.template.create |
|
||||
| erp-plugin | 2 | plugin.admin, plugin.list |
|
||||
| **合计** | **56** | |
|
||||
|
||||
**影响评估**:这些权限码通过种子数据(super_admin 角色)手动注册到数据库,而非通过 `ErpModule::permissions()` 自动注册。功能上可以正常工作,但:
|
||||
- 新增权限需要手动 SQL 插入,容易遗漏
|
||||
- 与 health/ai/dialysis 模块的自动注册机制不一致
|
||||
- 建议在后续迭代中为这 5 个模块补充 `permissions()` 实现
|
||||
|
||||
### 3.4 前端权限码拼写错误(BUG)
|
||||
|
||||
| 文件 | 前端代码 | 后端声明 | 差异 |
|
||||
|------|---------|---------|------|
|
||||
| [AlertList.tsx:240](apps/web/src/pages/health/AlertList.tsx#L240) | `health.alert.manage` | `health.alerts.manage` | 缺少 `s` |
|
||||
|
||||
**影响**:AlertList 页面的"管理"按钮(确认/处置告警)永远不显示,因为 `health.alert.manage` 权限码不存在于系统。用户虽然可以通过 API 直接操作,但 UI 层面无法触发管理动作。
|
||||
|
||||
**修复**:将 `health.alert.manage` 改为 `health.alerts.manage`(复数形式)。
|
||||
|
||||
### 3.5 前端路由级权限控制
|
||||
|
||||
Web 前端通过 `PrivateRoute` 组件做路由守卫,当前仅检查:
|
||||
1. 是否已认证(`isAuthenticated`)
|
||||
2. `/users`、`/roles`、`/organizations` 路径需要 `auth.*` 前缀权限
|
||||
|
||||
健康模块的路由**没有前端路由级权限守卫**,依赖后端 API 返回 403 拒绝未授权请求。这在 SPA 架构中是可接受的做法,但用户可能看到空白页面而非友好的"无权限"提示。
|
||||
|
||||
---
|
||||
|
||||
## 4. 小程序数据模型映射验证
|
||||
|
||||
### 4.1 后端 vital_signs Entity 字段(24 个,含标准字段)
|
||||
|
||||
| # | 字段名 | 类型 | 小程序映射 |
|
||||
|---|--------|------|-----------|
|
||||
| 1 | id | Uuid | — |
|
||||
| 2 | tenant_id | Uuid | — |
|
||||
| 3 | patient_id | Uuid | — |
|
||||
| 4 | record_date | NaiveDate | ✓ |
|
||||
| 5 | systolic_bp_morning | Option\<i32\> | ✓ blood_pressure → extra.systolic |
|
||||
| 6 | diastolic_bp_morning | Option\<i32\> | ✓ blood_pressure → extra.diastolic |
|
||||
| 7 | **systolic_bp_evening** | Option\<i32\> | **未映射** |
|
||||
| 8 | **diastolic_bp_evening** | Option\<i32\> | **未映射** |
|
||||
| 9 | heart_rate | Option\<i32\> | ✓ heart_rate |
|
||||
| 10 | weight | Option\<Decimal\> | ✓ weight |
|
||||
| 11 | blood_sugar | Option\<Decimal\> | ✓ blood_sugar |
|
||||
| 12 | **body_temperature** | Option\<Decimal\> | **未映射** |
|
||||
| 13 | **spo2** | Option\<i32\> | **未映射** |
|
||||
| 14 | **blood_sugar_type** | Option\<String\> | **未映射** |
|
||||
| 15 | water_intake_ml | Option\<i32\> | ✓ water_intake |
|
||||
| 16 | urine_output_ml | Option\<i32\> | ✓ urine_output |
|
||||
| 17 | notes | Option\<String\> | ✓ |
|
||||
| 18 | source | String | —(后端默认 "manual") |
|
||||
| 19-24 | 标准字段 | — | — |
|
||||
|
||||
### 4.2 小程序 indicator_type 映射表
|
||||
|
||||
来源:[health.ts:30-59](apps/miniprogram/src/services/health.ts#L30-L59)
|
||||
|
||||
| indicator_type | 映射字段 | 转换逻辑 |
|
||||
|---------------|---------|---------|
|
||||
| `blood_pressure` | systolic_bp_morning + diastolic_bp_morning | extra.systolic / extra.diastolic |
|
||||
| `heart_rate` | heart_rate | Math.round(value) |
|
||||
| `weight` | weight | value |
|
||||
| `blood_sugar` | blood_sugar | value |
|
||||
| `water_intake` | water_intake_ml | Math.round(value) |
|
||||
| `urine_output` | urine_output_ml | Math.round(value) |
|
||||
| *(default)* | — | 丢弃(不发送) |
|
||||
|
||||
### 4.3 差异清单
|
||||
|
||||
#### A. 后端有但小程序未映射(6 个业务字段)
|
||||
|
||||
| 字段 | 严重性 | 说明 |
|
||||
|------|--------|------|
|
||||
| **systolic_bp_evening** | **HIGH** | 后端趋势服务和危急值检测均支持晚间血压,但小程序所有血压数据一律写入 `*_morning` 字段,晚间数据丢失 |
|
||||
| **diastolic_bp_evening** | **HIGH** | 同上 |
|
||||
| **body_temperature** | MEDIUM | 后端 entity 和 DTO 均支持体温录入,但小程序无 `body_temperature` indicator_type |
|
||||
| **spo2** | MEDIUM | 后端支持血氧饱和度,小程序无对应类型 |
|
||||
| **blood_sugar_type** | LOW | 后端支持区分空腹/餐后/随机/OGTT 血糖类型,小程序仅传数值不传类型 |
|
||||
| **source** | LOW | 后端默认 "manual",小程序未区分来源标识 |
|
||||
|
||||
#### B. 小程序映射了但后端不存在的字段
|
||||
|
||||
**无此类差异。** 所有小程序映射的目标字段在后端 CreateVitalSignsReq 中均存在。
|
||||
|
||||
### 4.4 根因分析
|
||||
|
||||
小程序 `inputVitalSign()` 的 `indicator_type` 模型设计为"每种体征一个类型",但血压实际上需要区分时段(晨间/晚间)。当前 `blood_pressure` 类型固定映射到 `*_morning` 字段,没有逻辑可以写入 `*_evening` 字段。
|
||||
|
||||
后端设计是完善的——趋势服务([trend_service.rs](crates/erp-health/src/service/trend_service.rs))支持 `systolic_bp_evening`/`diastolic_bp_evening` 指标查询,危急值检测([health_data_service.rs](crates/erp-health/src/service/health_data_service.rs))使用 `morning.or(evening)` 做回退检查。
|
||||
|
||||
**修复建议**:新增 `blood_pressure_evening` indicator_type,或修改 UI 让用户选择时段。
|
||||
|
||||
---
|
||||
|
||||
## 5. 评分
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| DTO 覆盖率 | 100% | 105 个 DTO 完整覆盖所有 handler,传递链一致 |
|
||||
| 配置参数使用率 | 80% | 24/30 字段活跃使用,6 个 AiConfig 字段预留未接入 |
|
||||
| 权限码声明覆盖率 | 47% | 仅 50/106 个使用的权限码通过 PermissionDescriptor 声明 |
|
||||
| 权限码一致性 | 98% | 1 处前端拼写错误(alert vs alerts) |
|
||||
| 前端权限按钮覆盖 | 26% | 13/50 声明码有 AuthButton,其余依赖 API 403 |
|
||||
| 数据模型映射完整性 | 63% | 6/16 业务字段未映射(2 个 HIGH + 2 个 MEDIUM) |
|
||||
| **综合评分** | **69%** | DTO 和配置健壮,权限码和数据模型映射有差距 |
|
||||
243
docs/archive/audits-v1/05-gap-patterns.md
Normal file
243
docs/archive/audits-v1/05-gap-patterns.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# HMS 功能审计 — Phase 5: 五种差距模式识别
|
||||
|
||||
> 日期: 2026-04-30 | 审计范围: 全系统差距模式分析
|
||||
|
||||
## 总览
|
||||
|
||||
基于 Phase 0-4 的审计数据,系统性检测五种常见差距模式。
|
||||
|
||||
---
|
||||
|
||||
## 1. 模式 1:"写了没接" — 后端路由无前端调用者
|
||||
|
||||
### 检测方法
|
||||
|
||||
集合差运算:`backend_routes - web_api_calls - mp_api_calls`
|
||||
|
||||
### 发现
|
||||
|
||||
| 路由 | 模块 | 说明 | 严重性 |
|
||||
|------|------|------|--------|
|
||||
| `POST /health/patients/{id}/trends/generate` | erp-health | 趋势报告生成,无前端调用。可能为后台定时任务触发 | LOW |
|
||||
| `POST /ai/analyze/vital-signs` | erp-ai | SSE 端点,Web 和小程序均无 UI 调用 | MEDIUM |
|
||||
| `POST /ai/analyze/lab-report` | erp-ai | 同上 | MEDIUM |
|
||||
| `POST /ai/analyze/health-trend` | erp-ai | 同上 | MEDIUM |
|
||||
| `POST /ai/analyze/health-summary` | erp-ai | 同上 | MEDIUM |
|
||||
|
||||
**AI 分析 SSE 端点说明**:4 个 AI 分析端点通过 SSE 流式返回结果。前端未调用是因为 AI 分析功能可能仅通过 API 工具(如 Swagger UI)直接测试。这些端点的后端实现完整(含权限检查 `ai.analysis.manage`),但缺少前端管理界面的触发入口。
|
||||
|
||||
**趋势生成说明**:`POST .../trends/generate` 可能设计为后台任务调用(如定时生成趋势报告),而非前端直接触发。
|
||||
|
||||
### 统计
|
||||
|
||||
| 类别 | 数量 |
|
||||
|------|------|
|
||||
| 孤立后端路由(无任何前端调用) | 5 |
|
||||
| 其中 AI SSE 端点 | 4 |
|
||||
| 其中后台任务路由 | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 模式 2:"接了没传" — 前端调用但参数传递不完整
|
||||
|
||||
### 检测方法
|
||||
|
||||
搜索前端 API 调用中空参数传递、缺失 header、字段遗漏。
|
||||
|
||||
### 发现
|
||||
|
||||
#### 2.1 小程序 vital_signs 字段遗漏(6 个)
|
||||
|
||||
已在 Phase 4 §4.3 详细分析。核心问题:`indicator_type` 模型无法传递晚间血压、体温、血氧等字段。
|
||||
|
||||
#### 2.2 小程序 X-Patient-Id / X-Tenant-Id Header
|
||||
|
||||
小程序通过 `request.ts` 拦截器统一注入 `X-Patient-Id` 和 `X-Tenant-Id` header。经检查:
|
||||
- 所有患者端 API 调用均通过 `request.ts` 封装,header 注入正常
|
||||
- 医护端 API 调用使用独立的 `doctorRequest` 实例,header 注入机制一致
|
||||
|
||||
#### 2.3 后端 DTO 字段前端未传递(低风险)
|
||||
|
||||
| 端点 | 后端 DTO 字段 | 前端行为 | 风险 |
|
||||
|------|-------------|---------|------|
|
||||
| `POST /vital-signs` | blood_sugar_type | 小程序不传,后端默认 NULL | LOW |
|
||||
| `POST /vital-signs` | source | 小程序不传,后端默认 "manual" | LOW |
|
||||
| `POST /device-readings/batch` | raw_data | 前端传递完整 JSON | ✓ |
|
||||
|
||||
### 统计
|
||||
|
||||
| 类别 | 数量 |
|
||||
|------|------|
|
||||
| 前端字段遗漏(HIGH) | 2(晚间血压) |
|
||||
| 前端字段遗漏(MEDIUM) | 2(体温、血氧) |
|
||||
| 前端字段遗漏(LOW) | 2(血糖类型、来源) |
|
||||
| Header 注入问题 | 0 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 模式 3:"传了没存" — 数据传递但未完整持久化
|
||||
|
||||
### 检测方法
|
||||
|
||||
追踪 4 条关键写入路径:小程序体征录入、Web 创建预约、BLE 设备同步、每日签到。
|
||||
|
||||
### 路径 1:小程序体征录入
|
||||
|
||||
```
|
||||
inputVitalSign() → POST /health/patients/{id}/vital-signs
|
||||
→ health_data_handler::create_vital_signs()
|
||||
→ health_data_service::create_vital_signs()
|
||||
→ INSERT vital_signs
|
||||
```
|
||||
|
||||
**结果**:✅ 晨间血压/心率/体重/血糖/饮水量/尿量正常写入。❌ 晚间血压/体温/血氧未写入(前端未传)。
|
||||
|
||||
### 路径 2:Web 创建预约
|
||||
|
||||
```
|
||||
AppointmentList → POST /health/appointments
|
||||
→ appointment_handler::create_appointment()
|
||||
→ appointment_service::create_appointment()
|
||||
→ CAS 原子检查 slot_available → INSERT appointment
|
||||
```
|
||||
|
||||
**结果**:✅ 完整。含 CAS 并发控制、乐观锁、事务保证。
|
||||
|
||||
### 路径 3:BLE 设备同步
|
||||
|
||||
```
|
||||
BLE scan → uploadReadings() → POST /health/patients/{id}/device-readings/batch
|
||||
→ device_reading_handler::batch_create()
|
||||
→ device_reading_service::sync_readings()
|
||||
→ INSERT device_readings + UPSERT vital_signs_hourly
|
||||
```
|
||||
|
||||
**结果**:✅ 完整。批量插入 + 小时聚合 + 事件发布。
|
||||
|
||||
### 路径 4:每日签到
|
||||
|
||||
```
|
||||
checkin() → POST /health/points/checkin
|
||||
→ points_handler::daily_checkin()
|
||||
→ points_service::daily_checkin()
|
||||
→ INSERT/UPDATE points_checkin + INSERT points_transaction
|
||||
```
|
||||
|
||||
**结果**:✅ 完整。幂等检查(同日不可重复签到)+ 事务保证 + 事件发布。
|
||||
|
||||
### 统计
|
||||
|
||||
| 路径 | 结果 |
|
||||
|------|------|
|
||||
| 体征录入 | 部分数据丢失(晚间血压等) |
|
||||
| 创建预约 | ✅ 完整 |
|
||||
| BLE 同步 | ✅ 完整 |
|
||||
| 每日签到 | ✅ 完整 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 模式 4:"存了没用" — 数据持久化但未被消费
|
||||
|
||||
### 检测方法
|
||||
|
||||
检查写入了但从未被读取或展示的数据。
|
||||
|
||||
### 发现
|
||||
|
||||
#### 4.1 事件无消费者(14 个)
|
||||
|
||||
已在 Phase 3 §4.2 详细分析。14 个事件被发布到 EventBus 但没有业务消费者:
|
||||
- `patient.updated` — 信息性事件,更新已直接处理
|
||||
- `consultation.*`(3 个) — 会话管理已在 service 内直接处理
|
||||
- `article.published/rejected` — 状态已更新
|
||||
- `points.earned/exchanged/expired`(3 个) — 积分操作已完成
|
||||
- 其余 5 个同类型
|
||||
|
||||
**评估**:这些事件通过 SSE 推送到前端,用于实时通知,不完全是"没用"。但没有专门的后端消费者处理后续业务逻辑。
|
||||
|
||||
#### 4.2 数据库字段未读取
|
||||
|
||||
| 字段 | 表 | 写入场景 | 读取场景 | 状态 |
|
||||
|------|-----|---------|---------|------|
|
||||
| raw_data | device_readings | BLE 同步时写入 | 无专门查询 | MEDIUM |
|
||||
| revoke_reason | consent | 撤回知情同意时写入 | 列表查询返回 | ✓ 已用 |
|
||||
| model_config | ai_prompt | 创建/更新 Prompt 时写入 | 分析时读取 model/temperature/max_tokens | ✓ 已用 |
|
||||
| article_revision | article_revision | 文章编辑时写入 | GET .../revisions 端点返回 | ✓ 已用 |
|
||||
|
||||
**raw_data 字段说明**:`device_readings.raw_data` 存储 BLE 设备原始 JSONB 数据。当前无专门的读取/分析页面。价值在于数据溯源和异常排查,但在 UI 层面无入口。
|
||||
|
||||
#### 4.3 entity 字段从未构造
|
||||
|
||||
Phase 2 发现 `RefRow` struct 从未构造(编译器警告),属于重构残留。
|
||||
|
||||
### 统计
|
||||
|
||||
| 类别 | 数量 | 影响 |
|
||||
|------|------|------|
|
||||
| 无消费者事件 | 14 | LOW(SSE 推送 + 审计追踪仍有价值) |
|
||||
| 未读取数据库字段 | 1(raw_data) | MEDIUM(数据溯源需要但无 UI 入口) |
|
||||
| 死代码 struct | 1(RefRow) | LOW(编译器警告,重构残留) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 模式 5:"双系统不同步" — Web 与小程序实现差异
|
||||
|
||||
### 检测方法
|
||||
|
||||
对比共享功能在 Web 和小程序的实现差异,区分"角色分叉(正常)"和"实现缺陷"。
|
||||
|
||||
### 5.1 角色分叉(正常差异)
|
||||
|
||||
| 功能 | Web(管理端) | 小程序(患者/医护端) | 差异类型 |
|
||||
|------|-------------|-------------------|---------|
|
||||
| 积分管理 | 规则 CRUD + 商品管理 + 订单核销 | 签到 + 兑换 + 查看订单 | 角色分叉 ✓ |
|
||||
| 文章管理 | CMS 全流程(创建/编辑/审核/发布) | 只读列表(仅 published) | 角色分叉 ✓ |
|
||||
| 告警管理 | 规则配置 + 仪表盘 + 处置 | 查看自身告警 + 处理 | 角色分叉 ✓ |
|
||||
| 设备管理 | 列表 + 解绑 | BLE 扫描 + 同步 | 平台差异 ✓ |
|
||||
| 预约管理 | 全 CRUD + 排班配置 | 创建/查看/取消预约 | 角色分叉 ✓ |
|
||||
| 医生管理 | 档案 CRUD + 排班管理 | 查看排班日历 | 角色分叉 ✓ |
|
||||
|
||||
### 5.2 实现缺陷(需修复)
|
||||
|
||||
| 功能 | Web | 小程序 | 差异类型 | 优先级 |
|
||||
|------|-----|--------|---------|--------|
|
||||
| 体征录入 | 结构化表单(所有字段独立输入) | indicator_type 映射(丢失晚间血压/体温/血氧) | **数据模型差异** | P1 |
|
||||
| 咨询消息 | 列表 + 详情 + 导出 | 列表 + 详情(无导出) | 功能缺失 | P3 |
|
||||
| 透析管理 | 完整 CRUD + 审阅 | **无任何入口** | **功能缺失** | P1 |
|
||||
| 知情同意 | 完整 CRUD | **无任何入口** | **功能缺失** | P1 |
|
||||
| 健康记录 | 完整 CRUD | **无任何入口** | 功能缺失 | P2 |
|
||||
| 诊断记录 | 完整 CRUD | **无任何入口** | 功能缺失 | P2 |
|
||||
| 药物管理 | 完整 CRUD | 有页面但 API 调用不明确 | 待验证 | P2 |
|
||||
| 权限码拼写 | `health.alerts.manage`(正确) | —(前端用 `health.alert.manage`) | **拼写错误** | P1 |
|
||||
|
||||
### 5.3 透析管理 — 最大差距
|
||||
|
||||
透析是唯一一个**后端完整实现但小程序完全空白**的功能域:
|
||||
- 后端:12 个路由(记录 CRUD + 处方 CRUD + 审阅 + 统计)
|
||||
- Web:完整管理界面(DialysisManageList)
|
||||
- 小程序:0 个 API 调用、0 个页面
|
||||
|
||||
**影响**:透析患者无法在移动端查看自己的透析记录和处方,医护无法在小程序端录入透析数据。
|
||||
|
||||
### 统计
|
||||
|
||||
| 差异类型 | 数量 |
|
||||
|---------|------|
|
||||
| 角色分叉(正常) | 6 |
|
||||
| 数据模型差异 | 1 |
|
||||
| 功能缺失(P1) | 3(透析/知情同意/体征映射) |
|
||||
| 功能缺失(P2) | 3(健康记录/诊断/药物) |
|
||||
| 功能缺失(P3) | 1(咨询导出) |
|
||||
| 拼写错误 | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 差距模式汇总评分
|
||||
|
||||
| 模式 | 严重程度 | 发现数 | 评分 |
|
||||
|------|---------|--------|------|
|
||||
| 模式 1: 写了没接 | LOW-MEDIUM | 5 | 95%(仅 AI SSE 和趋势生成孤立) |
|
||||
| 模式 2: 接了没传 | HIGH-LOW | 6 | 75%(晚间血压数据丢失是关键缺陷) |
|
||||
| 模式 3: 传了没存 | LOW | 1 | 95%(仅体征部分字段未传导致未存) |
|
||||
| 模式 4: 存了没用 | LOW | 16 | 85%(大部分事件有 SSE 消费者,raw_data 待利用) |
|
||||
| 模式 5: 双系统不同步 | HIGH-LOW | 9 | 65%(透析/知情同意完全缺失,体征映射丢失) |
|
||||
227
docs/archive/audits-v1/06-error-handling.md
Normal file
227
docs/archive/audits-v1/06-error-handling.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# HMS 功能审计 — Phase 6: 错误处理与降级审计
|
||||
|
||||
> 日期: 2026-04-30 | 审计范围: 错误处理、降级策略、日志完整性
|
||||
|
||||
## 总览
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| AppError 变体 | 8 个 |
|
||||
| 领域错误变体 | HealthError 26 + AiError 11 |
|
||||
| Handler 错误映射机制 | 统一 IntoResponse trait |
|
||||
| 生产 unwrap() | 18 处(13 低风险 + 2 中风险 + 3 无害) |
|
||||
| 审计日志 | 140 处(33 文件)+ SHA256 哈希链 |
|
||||
| 运行时 tracing 日志 | 11 处(health service 层) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 错误处理覆盖率
|
||||
|
||||
### 1.1 AppError 枚举(8 变体)
|
||||
|
||||
| 变体 | HTTP 状态码 | 触发场景 |
|
||||
|------|-----------|---------|
|
||||
| `NotFound(String)` | 404 | 资源不存在 |
|
||||
| `Validation(String)` | 400 | 输入验证失败 |
|
||||
| `Unauthorized` | 401 | 未认证 |
|
||||
| `Forbidden(String)` | 403 | 无权限 |
|
||||
| `Conflict(String)` | 409 | 唯一约束冲突 |
|
||||
| `VersionMismatch` | 409 | 乐观锁版本不匹配 |
|
||||
| `TooManyRequests` | 429 | 速率限制 |
|
||||
| `Internal(String)` | 500 | 内部错误(消息对外隐藏) |
|
||||
|
||||
### 1.2 领域错误映射
|
||||
|
||||
**HealthError → AppError**(26 变体):
|
||||
|
||||
| 映射规则 | 变体示例 | 目标 AppError |
|
||||
|---------|---------|-------------|
|
||||
| 资源不存在 | PatientNotFound, DoctorNotFound, ScheduleNotFound | NotFound |
|
||||
| 状态转换无效 | InvalidStatusTransition, AppointmentAlreadyCancelled | Validation |
|
||||
| 容量限制 | ScheduleFull | Validation |
|
||||
| 乐观锁 | VersionMismatch | VersionMismatch (409) |
|
||||
| 数据库错误 | DbError | Internal |
|
||||
| 加密错误 | EncryptionError, DecryptionError | Internal |
|
||||
|
||||
**AiError → AppError**(11 变体):
|
||||
|
||||
| 映射规则 | 变体示例 | 目标 AppError |
|
||||
|---------|---------|-------------|
|
||||
| 资源不存在 | AnalysisNotFound, PromptNotFound | NotFound |
|
||||
| 验证失败 | Validation, SanitizationError, TemplateError | Validation |
|
||||
| 提供商不可用 | ProviderUnavailable | Internal |
|
||||
| 提供商错误 | ProviderError | Internal |
|
||||
| 速率限制 | RateLimitExceeded | TooManyRequests (429) |
|
||||
|
||||
### 1.3 Handler 错误传播模式
|
||||
|
||||
所有 handler 函数统一使用 `?` 运算符自动传播错误:
|
||||
|
||||
```
|
||||
Handler → require_permission()? → service.do_work()? → Ok(Json(ApiResponse::ok(result)))
|
||||
```
|
||||
|
||||
无需手动 match/map,`From<DomainError> for AppError` 自动转换。SeaORM 的 `DbErr` 也有智能映射(RecordNotFound → 404, duplicate key → 409)。
|
||||
|
||||
### 1.4 PII 加密错误处理
|
||||
|
||||
所有加密/解密失败通过 `AppError::Internal` 返回。关键设计:
|
||||
- 错误细节仅通过 `tracing::error!` 记录到日志
|
||||
- HTTP 响应体统一替换为 `"内部错误"`,不泄露加密实现细节
|
||||
- 防止通过错误消息推断加密方案
|
||||
|
||||
### 1.5 SSE 端点错误处理
|
||||
|
||||
**预流阶段**(连接建立前):
|
||||
- 权限/验证/数据获取全部通过 `?` 传播
|
||||
- AI Provider 不可用时返回标准 JSON 错误响应(500),**不会挂起连接**
|
||||
|
||||
**流中阶段**(连接建立后):
|
||||
- Provider 断连:发送 SSE `error` 事件 → 标记分析记录为 `failed` → 发布 `ai.analysis.failed` 事件 → 优雅终止流
|
||||
- 序列化错误:使用 `unwrap_or_default()` 避免 panic
|
||||
|
||||
**结论:SSE 端点不会挂起。** Provider 不可用时有完整的错误传播和清理机制。
|
||||
|
||||
### 1.6 生产代码 unwrap() 风险
|
||||
|
||||
| 类别 | 数量 | 风险 | 建议 |
|
||||
|------|------|------|------|
|
||||
| `active.version.unwrap() + 1` | 13 | LOW | 改用 `expect("version from DB must be set")` |
|
||||
| `PluginHost::db.unwrap()` | 1 | **MEDIUM** | 改用 `ok_or(AppError::Internal(...))` |
|
||||
| 信号量 `acquire().unwrap()` | 1 | **MEDIUM** | 改用 `map_err` 处理关闭场景 |
|
||||
| Response builder `.unwrap()` | 3 | LOW | 可接受,硬编码值不会失败 |
|
||||
| `unwrap_or`/`unwrap_or_default` | ~20 | NONE | 安全模式 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 降级策略评估
|
||||
|
||||
### 2.1 Redis 不可用
|
||||
|
||||
| 场景 | 降级策略 | 评估 |
|
||||
|------|---------|------|
|
||||
| IP 限流 | **Fail-Open**:Redis 不可达时限流被旁路,请求正常放行 | ✓ 安全(宁可放过不可误杀) |
|
||||
| 账户锁定 | **可配置**:`ERP__RATE_LIMIT__FAIL_CLOSE` 环境变量控制,默认 Fail-Open,生产可切 Fail-Close | ✓ 灵活 |
|
||||
| 微信会话存储 | **优雅降级**:`AuthState.redis` 为 `Option<redis::Client>`,缺失时降级 | ✓ 安全 |
|
||||
| 断路器 | 30 秒冷却期,避免重复连接失败阻塞请求 | ✓ 成熟 |
|
||||
|
||||
**风险点**:`AppState.redis` 是 `redis::Client`(非 `Option`),仅 `AuthState` 用 `Option` 包裹。但没有全局 Redis 健康检查端点。
|
||||
|
||||
### 2.2 AI Provider (Claude) 不可用
|
||||
|
||||
| 阶段 | 行为 | 评估 |
|
||||
|------|------|------|
|
||||
| 预流(连接前) | 返回 500 JSON 错误,不挂起 | ✓ |
|
||||
| 流中(连接后) | 发送 SSE error 事件 → 标记失败 → 发布事件 → 终止流 | ✓ |
|
||||
| 分析记录 | 状态更新为 `failed`,保留错误信息 | ✓ |
|
||||
|
||||
**结论:AI Provider 不可用不会导致连接挂起。** 有完整的错误传播和清理机制。
|
||||
|
||||
### 2.3 EventBus 满载
|
||||
|
||||
| 层级 | 机制 | 评估 |
|
||||
|------|------|------|
|
||||
| 内存层 | broadcast channel 容量 1024,慢消费者收到 `Lagged` 错误 | ✓ |
|
||||
| 持久化层 | `domain_events` 表 outbox 模式,事件不丢失 | ✓ |
|
||||
| 兜底轮询 | 30 秒间隔扫描 `pending` 事件 | ✓ |
|
||||
| 最大重试 | 5 次,超限进入 `dead_letter_events` 表 | ✓ |
|
||||
|
||||
**结论:事件不会丢失。** broadcast channel 满载时实时性受影响(最多 30 秒延迟),但 outbox relay 保证最终交付。
|
||||
|
||||
### 2.4 前端 SSE 重连
|
||||
|
||||
| 维度 | 实现 | 评估 |
|
||||
|------|------|------|
|
||||
| 重连机制 | 浏览器原生 EventSource 自动重连 | 基本可用 |
|
||||
| 指数退避 | 无(浏览器默认 ~3 秒固定间隔) | ⚠️ 缺失 |
|
||||
| 最大重连次数 | 无(原生无限重试) | ⚠️ 缺失 |
|
||||
| 连接状态 UI | `useAlertSSE` 暴露 `connected` 状态但未在界面显示 | ⚠️ 缺失 |
|
||||
| Keep-alive | 后端 `Sse::new().keep_alive(KeepAlive::default())` | ✓ |
|
||||
|
||||
---
|
||||
|
||||
## 3. 日志完整性
|
||||
|
||||
### 3.1 运行时 tracing 日志密度
|
||||
|
||||
**erp-health/src/service/ 目录**(核心业务层):
|
||||
|
||||
| 日志级别 | 调用次数 | 分布文件 |
|
||||
|---------|---------|---------|
|
||||
| `tracing::info!` | 4 | points_service(1), seed(3) |
|
||||
| `tracing::warn!` | 6 | appointment(1), device_reading(1), health_data(3), points(1), seed(1) |
|
||||
| `tracing::error!` | 0 | — |
|
||||
| `tracing::debug!` | 1 | points(1) |
|
||||
| **总计** | **11** | **5/26 文件** |
|
||||
|
||||
**关键缺口**:
|
||||
|
||||
| 文件 | 行数 | tracing 调用 | 问题 |
|
||||
|------|------|-------------|------|
|
||||
| patient_service.rs | 949 | 0 | 患者创建/更新/删除无运行时日志 |
|
||||
| appointment_service.rs | 590 | 1 | 仅取消预约边缘场景有日志 |
|
||||
| consultation_service.rs | ~400 | 0 | 咨询会话/消息操作无运行时日志 |
|
||||
| follow_up_service.rs | ~500 | 0 | 随访任务操作无运行时日志 |
|
||||
|
||||
**对比**:基础设施层日志丰富:
|
||||
- `erp-core/events.rs`:EventBus 有完整的生命周期日志
|
||||
- `erp-server/rate_limit.rs`:限流有 warn/error 日志
|
||||
- `erp-server/outbox.rs`:Outbox relay 有完整的处理日志
|
||||
|
||||
### 3.2 审计日志覆盖
|
||||
|
||||
审计日志通过 `audit_service::record` 实现,全局 140 处调用分布在 33 个文件中。
|
||||
|
||||
**Health 模块审计覆盖**:
|
||||
|
||||
| 文件 | 审计调用数 | 覆盖操作 |
|
||||
|------|----------|---------|
|
||||
| patient_service.rs | 12 | 创建/更新/删除患者、标签管理、家庭成员 CRUD、医生分配 |
|
||||
| points_service.rs | 12 | 积分发放/兑换/过期 |
|
||||
| health_data_service.rs | 10 | 体征/化验/体检记录 CRUD + 审核 |
|
||||
| article_service.rs | 7 | 文章 CRUD |
|
||||
| follow_up_service.rs | 7 | 随访任务 CRUD |
|
||||
| appointment_service.rs | 4 | 预约创建/状态变更/排班管理 |
|
||||
| 其余 7 个文件 | 各 2-3 | 咨询/诊断/同意/医生/透析/设备/告警 |
|
||||
|
||||
**审计日志特性**:
|
||||
|
||||
| 特性 | 实现 |
|
||||
|------|------|
|
||||
| 结构化字段 | tenant_id, user_id, action, resource_type, resource_id, ip_address, user_agent |
|
||||
| 变更追踪 | 更新操作记录 old_value/new_value 快照 |
|
||||
| 哈希链 | SHA256 链式签名,可验证完整性 |
|
||||
| 请求来源 | task_local 自动注入 IP + User-Agent |
|
||||
| 写入模式 | Fire-and-forget(失败仅 warn,不影响业务) |
|
||||
|
||||
**审计日志风险点**:
|
||||
- Fire-and-forget 意味着写入失败是静默的,无重试或告警
|
||||
- 哈希链在并发写入时可能出现短暂链断裂(查询和写入非原子操作)
|
||||
|
||||
### 3.3 日志分层评估
|
||||
|
||||
| 层级 | tracing 日志 | 审计日志 | 评估 |
|
||||
|------|-------------|---------|------|
|
||||
| Handler 层 | — | — | 依赖 service 层 |
|
||||
| Service 层(health) | 11 处 | 70+ 处 | 运行时日志不足,审计日志完善 |
|
||||
| Service 层(infrastructure) | 丰富 | — | 日志密度高 |
|
||||
| EventBus | 完整 | — | 发布/消费全链路可追踪 |
|
||||
| 加密层 | — | — | 仅错误时 tracing::error |
|
||||
|
||||
---
|
||||
|
||||
## 4. 评分
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 错误变体覆盖 | 95% | 8 个 AppError + 37 个领域错误,覆盖全面 |
|
||||
| Handler 错误传播 | 100% | 统一 `?` + IntoResponse,无手动 match |
|
||||
| PII 错误安全 | 100% | Internal 错误消息对外隐藏,仅日志记录 |
|
||||
| SSE 挂起风险 | 100% | Provider 不可用不会挂起,有完整清理 |
|
||||
| unwrap() 安全性 | 95% | 2 处中等风险(PluginHost::db + 信号量) |
|
||||
| Redis 降级 | 90% | Fail-Open 断路器 + 可配置,缺健康检查端点 |
|
||||
| EventBus 降级 | 95% | Outbox 兜底 + 死信队列,事件不丢失 |
|
||||
| 前端重连 | 60% | 依赖原生 EventSource,缺指数退避和 UI 反馈 |
|
||||
| 运行时日志 | 30% | health service 仅 11 处 tracing,运维盲区大 |
|
||||
| 审计日志 | 95% | 140 处覆盖所有写操作 + 哈希链验证 |
|
||||
| **综合评分** | **76%** | 错误处理架构优秀,日志和前端重连有差距 |
|
||||
115
docs/archive/audits-v1/07-test-coverage.md
Normal file
115
docs/archive/audits-v1/07-test-coverage.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# HMS 功能审计 — Phase 7: 测试覆盖率审计
|
||||
|
||||
> 日期: 2026-04-30 | 审计范围: 后端 + 前端测试分布与缺口
|
||||
|
||||
## 总览
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 测试总数 | 772 个函数 |
|
||||
| 通过 | 753(97.5%) |
|
||||
| 失败 | 9(全部因 blind_indexes 表缺失) |
|
||||
| 跳过 | 0 |
|
||||
| 后端测试 | 767(611 单元 + 153 集成 + 3 多模块集成) |
|
||||
| Web 前端测试 | 5 单元(vitest)+ 5 E2E(playwright) |
|
||||
| 小程序测试 | 0 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块测试分布
|
||||
|
||||
### 1.1 后端 Rust 测试
|
||||
|
||||
| Crate | 单元测试 | 集成测试 | 总计 | 通过率 | 评估 |
|
||||
|-------|---------|---------|------|--------|------|
|
||||
| erp-core | 74 | — | 74 | 100% | 良好 |
|
||||
| erp-auth | 41 | 3 | 44 | 100% | 良好 |
|
||||
| erp-config | 78 | — | 78 | 100% | 良好 |
|
||||
| erp-workflow | 63 | 4 | 67 | 100% | 良好 |
|
||||
| erp-message | 72 | — | 72 | 100% | 中等(缺集成测试) |
|
||||
| erp-health | 159 | 144 | 303 | 97% | 良好 |
|
||||
| erp-ai | 36 | — | 36 | 100% | 中等(缺集成测试) |
|
||||
| erp-dialysis | 10 | 15 | 25 | 93% | 中等 |
|
||||
| erp-plugin | 78 | 2 | 80 | 100% | 良好 |
|
||||
| erp-server | — | 153 | 153 | 94% | 良好(API 集成测试) |
|
||||
| **合计** | **611** | **153** | **767** | **97.5%** | |
|
||||
|
||||
### 1.2 前端测试
|
||||
|
||||
| 层级 | 框架 | 数量 | 评估 |
|
||||
|------|------|------|------|
|
||||
| Web 单元测试 | vitest | 5 | **极低** |
|
||||
| Web E2E 测试 | playwright | 5 spec | **低** |
|
||||
| 小程序测试 | — | 0 | **无** |
|
||||
|
||||
### 1.3 测试 vs 代码规模比
|
||||
|
||||
| 维度 | 代码量 | 测试数 | 比率 |
|
||||
|------|--------|--------|------|
|
||||
| Rust 后端 | ~77k 行 | 767 | 1:100 |
|
||||
| Web 前端 | ~20k 行(163 文件) | 10 | 1:2000 |
|
||||
| 小程序 | ~15k 行(125 文件) | 0 | N/A |
|
||||
|
||||
---
|
||||
|
||||
## 2. 失败测试根因
|
||||
|
||||
9 个失败测试全部集中在 erp-health,原因一致:
|
||||
|
||||
| 测试文件 | 失败数 | 根因 |
|
||||
|---------|--------|------|
|
||||
| health_patient_tests.rs | 2 | `blind_indexes` 表不存在 |
|
||||
| health_dialysis_tests.rs | 1 | 同上 |
|
||||
| health_pii_encryption_tests.rs | 6 | 同上 |
|
||||
|
||||
**修复方案**:执行数据库迁移创建 `blind_indexes` 表。这是测试环境配置问题,非代码逻辑错误。
|
||||
|
||||
---
|
||||
|
||||
## 3. 测试缺口分析
|
||||
|
||||
### 3.1 关键缺口(按风险排序)
|
||||
|
||||
| 优先级 | 模块 | 缺口 | 影响 |
|
||||
|--------|------|------|------|
|
||||
| **P0** | erp-ai | 无集成测试(SSE 流 + 外部 API) | AI 功能仅通过手动测试验证,无法回归 |
|
||||
| **P1** | 小程序 | 完全无测试 | 40 个页面全靠手工验证 |
|
||||
| **P1** | erp-message | 无集成测试(SSE 推送) | SSE 连接/重连行为未测试 |
|
||||
| **P2** | Web 前端 | 仅 10 个测试 | 163 个文件的 API 调用/组件/路由无覆盖 |
|
||||
| **P2** | erp-dialysis | 93% 通过率 | 2 个测试失败待修复 |
|
||||
| **P3** | erp-config | 无集成测试 | 简单 CRUD,风险较低 |
|
||||
|
||||
### 3.2 测试类型覆盖矩阵
|
||||
|
||||
| 测试类型 | 后端 | Web 前端 | 小程序 |
|
||||
|---------|------|---------|--------|
|
||||
| 单元测试 | ✓ 丰富 | ⚠️ 极少 | ✗ 无 |
|
||||
| 集成测试 | ✓ health+plugin+server | ✗ 无 | ✗ 无 |
|
||||
| E2E 测试 | — | ⚠️ 5 spec | ✗ 无 |
|
||||
| 多租户隔离 | ✓ PII 加密测试 | ✗ 无 | ✗ 无 |
|
||||
| 并发测试 | ✓ 预约 CAS 测试 | ✗ 无 | ✗ 无 |
|
||||
| 安全测试 | ⚠️ 部分(权限/注入) | ✗ 无 | ✗ 无 |
|
||||
|
||||
### 3.3 测试覆盖良好的领域
|
||||
|
||||
| 领域 | 测试特点 |
|
||||
|------|---------|
|
||||
| 患者 CRUD | 完整的集成测试覆盖创建/更新/删除/列表 |
|
||||
| PII 加密 | 独立测试文件验证加密/解密/盲索引/跨租户隔离 |
|
||||
| 预约并发 | CAS 原子操作测试,验证乐观锁和排班满额 |
|
||||
| 工作流引擎 | BPMN 解析 + Token 驱动 + 任务分配测试 |
|
||||
| 权限 RBAC | 角色/权限/菜单关联测试 |
|
||||
| 插件系统 | WASM 运行时 + 动态表 CRUD + 租户隔离 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试覆盖率评分
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 后端单元测试 | 85% | 611 个,覆盖核心 service 逻辑 |
|
||||
| 后端集成测试 | 70% | 153 个 API 测试,但 AI/Message 模块缺失 |
|
||||
| 前端测试 | 5% | 仅 10 个测试覆盖 163 个文件 |
|
||||
| 小程序测试 | 0% | 完全空白 |
|
||||
| 测试稳定性 | 98% | 9 个失败因环境配置,非代码缺陷 |
|
||||
| **综合评分** | **65%** | 后端测试基础扎实,前端和 AI 是主要缺口 |
|
||||
363
docs/archive/audits-v1/08-audit-report-2026-04-30.md
Normal file
363
docs/archive/audits-v1/08-audit-report-2026-04-30.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# HMS 功能审计报告
|
||||
|
||||
> 日期: 2026-04-30 | Git: `84fafb0` | 审计范围: 全系统(后端 + Web + 小程序)
|
||||
|
||||
## 执行摘要
|
||||
|
||||
| 维度 | 结果 |
|
||||
|------|------|
|
||||
| 总体完成度 | **83%** |
|
||||
| CRITICAL 发现 | **2 项**(小程序晚间血压丢失、告警权限码拼写错误) |
|
||||
| HIGH 发现 | **3 项**(透析/知情同意小程序缺失、前端日志严重不足) |
|
||||
| MEDIUM 发现 | **8 项** |
|
||||
| LOW 发现 | **12 项** |
|
||||
| 后端健康度 | 优秀(97.5% 测试通过,100% 调用链连通) |
|
||||
| 前端覆盖度 | 中等(Web 管理端完善,小程序关键功能缺失) |
|
||||
|
||||
### 审计范围
|
||||
|
||||
- 328 个后端路由(8 公开 + 320 受保护)
|
||||
- 235 个 Web 前端 API 调用
|
||||
- 76 个小程序 API 调用
|
||||
- 38 个 Web 页面路由
|
||||
- 40 个小程序页面
|
||||
- 45 个数据库 Entity
|
||||
- 25 个事件类型
|
||||
- 772 个测试函数
|
||||
|
||||
---
|
||||
|
||||
## 功能域评分
|
||||
|
||||
### 评分方法
|
||||
|
||||
每个功能域按 10 项审计清单打分(0-100%),加权求和:
|
||||
|
||||
| 检查项 | 权重 | 100% 标准 |
|
||||
|--------|------|----------|
|
||||
| 代码存在性 | 15% | Handler + Service + Entity 全部存在 |
|
||||
| 调用链连通性 | 15% | Handler→Service→DB 完整连通 |
|
||||
| 配置参数传递 | 5% | 所有配置字段被正确读取和使用 |
|
||||
| 降级策略 | 5% | 外部依赖不可用时有优雅降级 |
|
||||
| 错误处理 | 10% | 所有错误路径返回适当 HTTP 响应 |
|
||||
| 性能 | 5% | 列表端点有分页,查询有索引 |
|
||||
| 安全合规 | 15% | tenant_id 过滤 + 权限检查 + PII 保护 |
|
||||
| 兼容性 | 5% | Web 和小程序 API 契约一致 |
|
||||
| 日志完整性 | 10% | 关键操作有日志和审计记录 |
|
||||
| UX 一致性 | 15% | 两端展示数据一致,交互逻辑统一 |
|
||||
|
||||
### 1. 患者管理: 93%
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 代码存在性 | 100% | 17 handler + 完整 service + 6 entity |
|
||||
| 调用链 | 100% | Handler→Service→Entity 全连通 |
|
||||
| 配置参数 | 100% | PII 加密密钥正确使用 |
|
||||
| 降级策略 | 90% | 加密失败有错误处理 |
|
||||
| 错误处理 | 100% | 26 种领域错误完整映射 |
|
||||
| 性能 | 90% | 分页查询 + HMAC 盲索引搜索 |
|
||||
| 安全合规 | 100% | tenant_id + RLS + PII AES-256-GCM + HMAC |
|
||||
| 兼容性 | 90% | Web/MP 基本对齐,MP 无删除(预期) |
|
||||
| 日志 | 95% | 12 处审计日志(含变更快照),运行时日志不足 |
|
||||
| UX 一致性 | 70% | 健康摘要仅 MP,家庭医生管理仅 Web |
|
||||
|
||||
### 2. 医生/排班: 88%
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 代码存在性 | 100% | 5 handler + service + 2 entity |
|
||||
| 调用链 | 100% | 全连通 |
|
||||
| 配置参数 | 100% | 正确 |
|
||||
| 降级策略 | 90% | 标准 |
|
||||
| 错误处理 | 100% | 领域错误映射 |
|
||||
| 性能 | 90% | 排班 CAS 并发控制 |
|
||||
| 安全合规 | 100% | tenant_id + 权限检查 |
|
||||
| 兼容性 | 60% | MP 仅只读列表,无管理操作 |
|
||||
| 日志 | 90% | 3 处审计日志 |
|
||||
| UX 一致性 | 60% | Web 有排班 CRUD,MP 仅查看日历 |
|
||||
|
||||
### 3. 健康数据: 85%
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 代码存在性 | 100% | 18 handler + service + 7 entity |
|
||||
| 调用链 | 100% | 全连通 |
|
||||
| 配置参数 | 100% | AES/HMAC 密钥正确使用 |
|
||||
| 降级策略 | 90% | 加密失败安全处理 |
|
||||
| 错误处理 | 100% | 完整错误映射 |
|
||||
| 性能 | 85% | 分页 + 趋势缓存,但趋势生成可能慢 |
|
||||
| 安全合规 | 100% | PII 加密 + 权限 + 危急值检测 |
|
||||
| 兼容性 | 70% | MP 丢失晚间血压/体温/血氧字段 |
|
||||
| 日志 | 80% | 10 处审计日志,运行时 tracing 不足 |
|
||||
| UX 一致性 | 60% | 小程序 indicator_type 模型限制数据录入 |
|
||||
|
||||
### 4. 预约管理: 95%
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 代码存在性 | 100% | 8 handler + 完整 CRUD |
|
||||
| 调用链 | 100% | 全连通 |
|
||||
| 配置参数 | 100% | 正确 |
|
||||
| 降级策略 | 95% | CAS 原子操作保证 |
|
||||
| 错误处理 | 100% | 排班满额/状态转换错误 |
|
||||
| 性能 | 100% | CAS 并发控制 + 分页 |
|
||||
| 安全合规 | 100% | tenant_id + 权限 |
|
||||
| 兼容性 | 90% | Web/MP 基本对齐 |
|
||||
| 日志 | 90% | 4 处审计 + 1 处 tracing |
|
||||
| UX 一致性 | 85% | Web 排班管理,MP 预约创建/查看 |
|
||||
|
||||
### 5. 随访管理: 88%
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 代码存在性 | 100% | 10+5 handler + 模板管理 |
|
||||
| 调用链 | 100% | 全连通 |
|
||||
| 配置参数 | 100% | 正确 |
|
||||
| 降级策略 | 90% | 事件驱动逾期检测 |
|
||||
| 错误处理 | 100% | 完整 |
|
||||
| 性能 | 90% | 批量操作支持 |
|
||||
| 安全合规 | 100% | 权限检查 |
|
||||
| 兼容性 | 80% | MP 仅列表+创建,无管理 |
|
||||
| 日志 | 90% | 7 处审计 |
|
||||
| UX 一致性 | 70% | Web 批量操作,MP 基础功能 |
|
||||
|
||||
### 6. 咨询管理: 94%
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 代码存在性 | 100% | 9 handler |
|
||||
| 调用链 | 100% | 全连通 |
|
||||
| 配置参数 | 100% | 正确 |
|
||||
| 降级策略 | 95% | 消息持久化保证 |
|
||||
| 错误处理 | 100% | 完整 |
|
||||
| 性能 | 90% | 分页消息列表 |
|
||||
| 安全合规 | 100% | 权限 + 数据隔离 |
|
||||
| 兼容性 | 95% | Web/MP 高度对齐 |
|
||||
| 日志 | 85% | 3 处审计 |
|
||||
| UX 一致性 | 90% | Web 有导出,MP 无(预期) |
|
||||
|
||||
### 7. 内容管理: 86%
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 代码存在性 | 100% | 11+4+4 handler(文章+分类+标签) |
|
||||
| 调用链 | 100% | 全连通 |
|
||||
| 配置参数 | 100% | 文件上传配置 |
|
||||
| 降级策略 | 90% | 标准 |
|
||||
| 错误处理 | 100% | 审核流程错误 |
|
||||
| 性能 | 90% | 分页 |
|
||||
| 安全合规 | 100% | 审核权限分离 |
|
||||
| 兼容性 | 70% | MP 仅只读 published 文章 |
|
||||
| 日志 | 90% | 7 处审计 |
|
||||
| UX 一致性 | 65% | Web 完整 CMS,MP 仅文章列表 |
|
||||
|
||||
### 8. 积分商城: 90%
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 代码存在性 | 100% | 28 handler(最复杂的 handler) |
|
||||
| 调用链 | 100% | 全连通 |
|
||||
| 配置参数 | 100% | 正确 |
|
||||
| 降级策略 | 90% | 幂等签到检查 |
|
||||
| 错误处理 | 100% | 完整 |
|
||||
| 性能 | 90% | 分页 + 统计缓存 |
|
||||
| 安全合规 | 95% | 权限检查,但积分操作无事务日志 |
|
||||
| 兼容性 | 90% | Web 管理端 + MP 患者端对齐 |
|
||||
| 日志 | 85% | 12 处审计 |
|
||||
| UX 一致性 | 85% | 角色分叉正常 |
|
||||
|
||||
### 9. 告警系统: 87%
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 代码存在性 | 100% | 4+4+3+4 handler |
|
||||
| 调用链 | 100% | 全连通 |
|
||||
| 配置参数 | 100% | 正确 |
|
||||
| 降级策略 | 90% | 事件驱动评估 |
|
||||
| 错误处理 | 100% | 完整 |
|
||||
| 性能 | 90% | 分页 |
|
||||
| 安全合规 | 100% | 权限检查 |
|
||||
| 兼容性 | 70% | MP 仅查看+处理,无规则管理 |
|
||||
| 日志 | 85% | 审计日志 |
|
||||
| UX 一致性 | 60% | ⚠️ 前端 `health.alert.manage` 拼写错误导致按钮不显示 |
|
||||
|
||||
### 10. AI 分析: 70%
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 代码存在性 | 100% | 12 handler + 3 entity |
|
||||
| 调用链 | 100% | 全连通 |
|
||||
| 配置参数 | 40% | 6/8 AiConfig 字段未使用 |
|
||||
| 降级策略 | 95% | SSE 不挂起,Provider 不可用优雅终止 |
|
||||
| 错误处理 | 100% | 预流/流中完整错误处理 |
|
||||
| 性能 | 80% | SSE 流式返回,但无缓存层 |
|
||||
| 安全合规 | 100% | 权限检查 + 输入消毒 |
|
||||
| 兼容性 | 50% | SSE 端点无前端 UI 调用 |
|
||||
| 日志 | 70% | 分析成功/失败有事件发布 |
|
||||
| UX 一致性 | 30% | 仅历史查看有 UI,分析触发无入口 |
|
||||
|
||||
### 11. 透析管理: 67%
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 代码存在性 | 100% | 12 handler |
|
||||
| 调用链 | 100% | 全连通 |
|
||||
| 配置参数 | 100% | 正确 |
|
||||
| 降级策略 | 90% | 标准 |
|
||||
| 错误处理 | 100% | 完整 |
|
||||
| 性能 | 90% | 分页 |
|
||||
| 安全合规 | 100% | 5 个权限码 |
|
||||
| 兼容性 | 0% | 小程序完全无入口 |
|
||||
| 日志 | 90% | 审计 + 事件 |
|
||||
| UX 一致性 | 0% | 小程序无透析功能 |
|
||||
|
||||
### 12. 统计仪表盘: 85%
|
||||
|
||||
| 检查项 | 评分 | 说明 |
|
||||
|--------|------|------|
|
||||
| 代码存在性 | 100% | 9 handler |
|
||||
| 调用链 | 100% | 全连通 |
|
||||
| 配置参数 | 100% | 正确 |
|
||||
| 降级策略 | 85% | 统计查询失败返回 500 |
|
||||
| 错误处理 | 90% | 标准 |
|
||||
| 性能 | 80% | 聚合查询可能慢 |
|
||||
| 安全合规 | 100% | 权限检查 |
|
||||
| 兼容性 | 60% | MP 仅 3 个医护端统计 |
|
||||
| 日志 | 80% | 标准 |
|
||||
| UX 一致性 | 70% | Web 完整仪表盘,MP 部分 |
|
||||
|
||||
---
|
||||
|
||||
## 发现清单(按严重程度排序)
|
||||
|
||||
### CRITICAL(2 项)
|
||||
|
||||
| # | 发现 | 模块 | 来源 |
|
||||
|---|------|------|------|
|
||||
| C1 | **小程序晚间血压数据永久丢失** — `inputVitalSign()` 的 `blood_pressure` 类型固定映射到 `*_morning` 字段,`systolic_bp_evening`/`diastolic_bp_evening` 从未写入。后端趋势服务和危急值检测支持晚间血压,但数据源缺失 | Phase 4 §4.3 | |
|
||||
| C2 | **告警管理按钮永远不显示** — 前端 AlertList.tsx 使用 `health.alert.manage`(单数),后端声明 `health.alerts.manage`(复数),权限码不匹配导致 AuthButton 永远隐藏 | Phase 4 §3.4 | |
|
||||
|
||||
### HIGH(3 项)
|
||||
|
||||
| # | 发现 | 模块 | 来源 |
|
||||
|---|------|------|------|
|
||||
| H1 | **透析管理小程序完全无入口** — 后端 12 个路由完整实现,Web 有管理界面,但小程序 0 个 API 调用、0 个页面。透析患者无法在移动端查看记录和处方 | Phase 1 §2.11, Phase 5 §5.2 | |
|
||||
| H2 | **知情同意小程序完全无入口** — 同上,后端完整但小程序无任何入口 | Phase 1 §2.12 | |
|
||||
| H3 | **Health service 层运行时日志极缺** — 26 个 service 文件仅 11 处 tracing 日志,patient_service(949 行)0 处日志,运维排查困难 | Phase 6 §3.1 | |
|
||||
|
||||
### MEDIUM(8 项)
|
||||
|
||||
| # | 发现 | 说明 | 来源 |
|
||||
|---|------|------|------|
|
||||
| M1 | 56 个基础模块权限码未通过 PermissionDescriptor 声明 | auth/config/workflow/message/plugin 的权限通过种子数据手动注册 | Phase 4 §3.3 |
|
||||
| M2 | 4 个 AI 分析 SSE 端点无前端 UI 调用 | 可能仅通过 API 工具直接测试 | Phase 5 §1 |
|
||||
| M3 | 小程序丢失体温/血氧数据 | body_temperature/spo2 无 indicator_type | Phase 4 §4.3 |
|
||||
| M4 | 前端 SSE 重连无指数退避 | 依赖浏览器原生 EventSource,固定 3 秒间隔 | Phase 6 §2.4 |
|
||||
| M5 | erp-ai 无集成测试 | SSE 流 + 外部 API 调用无法回归测试 | Phase 7 §3.1 |
|
||||
| M6 | Web 前端测试仅 10 个 | 163 个文件仅 5 单元 + 5 E2E | Phase 7 §1.2 |
|
||||
| M7 | 小程序完全无测试 | 40 个页面全靠手工验证 | Phase 7 §1.2 |
|
||||
| M8 | 健康记录/诊断记录小程序无入口 | 患者移动端无法查看 | Phase 1 §2.2 |
|
||||
|
||||
### LOW(12 项)
|
||||
|
||||
| # | 发现 | 说明 | 来源 |
|
||||
|---|------|------|------|
|
||||
| L1 | 14 个事件无业务消费者 | 通过 SSE 推送仍有价值,但无后端消费者 | Phase 3 §4.2 |
|
||||
| L2 | AiConfig 6 字段声明未使用 | 预留全局默认值,per-prompt 已覆盖 | Phase 4 §2.3 |
|
||||
| L3 | device_readings.raw_data 无读取入口 | 数据溯源需要但无 UI | Phase 5 §4.2 |
|
||||
| L4 | RefRow struct 从未构造 | 重构残留,编译器警告 | Phase 2 §1.2 |
|
||||
| L5 | 2 处 unwrap() 中等风险 | PluginHost::db + 信号量 acquire | Phase 6 §1.6 |
|
||||
| L6 | 审计日志 Fire-and-forget | 写入失败静默,无重试 | Phase 6 §3.2 |
|
||||
| L7 | health.dialysis.stats 权限无前端引用 | 透析统计页面可能无按钮 | Phase 4 §3.2 |
|
||||
| L8 | ai.provider.manage 声明但无 Handler | 预留提供商管理功能 | Phase 4 §3.2 |
|
||||
| L9 | 咨询消息导出仅 Web | 小程序无导出功能(预期) | Phase 1 §2.5 |
|
||||
| L10 | 趋势生成路由无前端调用 | 可能仅后台任务触发 | Phase 5 §1 |
|
||||
| L11 | 9 个测试因 blind_indexes 表失败 | 环境配置问题,非代码缺陷 | Phase 0 |
|
||||
| L12 | 40 个编译警告 | 多为未使用导入/变量 | Phase 0 |
|
||||
|
||||
---
|
||||
|
||||
## 差距模式分析
|
||||
|
||||
基于 Phase 5 的系统性检测,五种差距模式的发生频率和影响:
|
||||
|
||||
| 模式 | 发现数 | 最大影响 | 整体评估 |
|
||||
|------|--------|---------|---------|
|
||||
| 写了没接 | 5 | AI SSE 端点无 UI 入口 | 低风险(功能完整,缺触发入口) |
|
||||
| 接了没传 | 6 | 晚间血压数据丢失 | 高风险(数据永久丢失) |
|
||||
| 传了没存 | 0 | — | 无问题 |
|
||||
| 存了没用 | 16 | 14 事件无消费者 + raw_data 无入口 | 低风险(审计追踪仍有价值) |
|
||||
| 双系统不同步 | 9 | 透析/知情同意小程序完全缺失 | 高风险(核心功能移动端空白) |
|
||||
|
||||
**根因模式**:
|
||||
1. **后端先行,前端跟进不足** — 后端功能完整度高(100% 调用链),但小程序覆盖仅 60%
|
||||
2. **角色分叉设计正确但执行不彻底** — 透析/知情同意应覆盖小程序医护端但未实现
|
||||
3. **数据模型差异** — 小程序 indicator_type 简化模型与后端完整字段不匹配
|
||||
|
||||
---
|
||||
|
||||
## 测试覆盖率总结
|
||||
|
||||
| 层级 | 测试数 | 通过率 | 评估 |
|
||||
|------|--------|--------|------|
|
||||
| Rust 单元测试 | 611 | 100% | 良好 |
|
||||
| Rust 集成测试 | 153 | 94% | 良好 |
|
||||
| Web 前端 | 10 | N/A | **极低** |
|
||||
| 小程序 | 0 | N/A | **无测试** |
|
||||
| **合计** | **772** | **97.5%** | |
|
||||
|
||||
关键缺口:AI 模块无集成测试(SSE + 外部 API),小程序完全无测试。
|
||||
|
||||
---
|
||||
|
||||
## 建议与优先级排序
|
||||
|
||||
### P0 — 立即修复(1-2 天)
|
||||
|
||||
| # | 建议 | 影响 | 工作量 |
|
||||
|---|------|------|--------|
|
||||
| C2 | 修复前端权限码拼写:`health.alert.manage` → `health.alerts.manage` | 告警管理按钮恢复可用 | 5 分钟 |
|
||||
| C1-fix | 小程序新增 `blood_pressure_evening` indicator_type | 晚间血压数据可正常录入 | 2 小时 |
|
||||
|
||||
### P1 — 短期优化(1-2 周)
|
||||
|
||||
| # | 建议 | 影响 | 工作量 |
|
||||
|---|------|------|--------|
|
||||
| H1 | 实现小程序透析模块(患者查看记录 + 医护端录入) | 透析患者移动端可用 | 1 周 |
|
||||
| H2 | 实现小程序知情同意页面 | 患者可管理同意书 | 3 天 |
|
||||
| H3 | 补充 health service 层 tracing 日志 | 运维可追踪关键操作 | 2 天 |
|
||||
| M5 | 补充 erp-ai 集成测试 | AI SSE 流可回归测试 | 3 天 |
|
||||
|
||||
### P2 — 中期改进(1-2 月)
|
||||
|
||||
| # | 建议 | 影响 | 工作量 |
|
||||
|---|------|------|--------|
|
||||
| M1 | 为 auth/config/workflow/message/plugin 模块补充 PermissionDescriptor 声明 | 权限码自动注册,一致性提升 | 1 周 |
|
||||
| M3 | 小程序新增 body_temperature/spo2 indicator_type | 体温/血氧可录入 | 1 天 |
|
||||
| M8 | 实现小程序健康记录/诊断记录查看页面 | 患者可查看完整健康档案 | 1 周 |
|
||||
| M4/M6/M7 | 补充前端 SSE 指数退避 + 前端/小程序测试 | 系统健壮性提升 | 持续 |
|
||||
|
||||
### P3 — 长期规划
|
||||
|
||||
| # | 建议 | 影响 | 工作量 |
|
||||
|---|------|------|--------|
|
||||
| L1 | 为高价值事件添加消费者(如 consultation.opened 发送通知) | 事件驱动更完善 | 按需 |
|
||||
| L2 | 清理或接入 AiConfig 未使用字段 | 配置一致性 | 1 天 |
|
||||
| L5 | 替换 2 处中风险 unwrap() | 消除潜在 panic | 1 小时 |
|
||||
| L11 | 修复 blind_indexes 迁移使 9 个测试通过 | 测试通过率 100% | 1 小时 |
|
||||
|
||||
---
|
||||
|
||||
## 审计产出文件索引
|
||||
|
||||
| Phase | 文件 | 内容 |
|
||||
|-------|------|------|
|
||||
| 0 | [00-baseline-snapshot.md](docs/audits/00-baseline-snapshot.md) | 基线快照 |
|
||||
| 1 | [01-feature-inventory.md](docs/audits/01-feature-inventory.md) | 功能清单 + 三端映射矩阵 |
|
||||
| 2 | [02-backend-integrity.md](docs/audits/02-backend-integrity.md) | 死代码 + 调用链 + trait 覆盖率 |
|
||||
| 3 | [03-event-system.md](docs/audits/03-event-system.md) | 事件发布-消费矩阵 |
|
||||
| 4 | [04-parameter-config.md](docs/audits/04-parameter-config.md) | DTO + 配置 + 权限码 + 数据映射 |
|
||||
| 5 | [05-gap-patterns.md](docs/audits/05-gap-patterns.md) | 五种差距模式 |
|
||||
| 6 | [06-error-handling.md](docs/audits/06-error-handling.md) | 错误处理 + 降级 + 日志 |
|
||||
| 7 | [07-test-coverage.md](docs/audits/07-test-coverage.md) | 测试分布与缺口 |
|
||||
| 8 | [08-audit-report-2026-04-30.md](docs/audits/08-audit-report-2026-04-30.md) | 最终评分报告(本文档) |
|
||||
382
docs/archive/audits-v1/audit-2026-04-18-full.md
Normal file
382
docs/archive/audits-v1/audit-2026-04-18-full.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# ERP 平台全面深度审计报告
|
||||
|
||||
> 审计日期: 2026-04-18
|
||||
> 审计方式: 前端功能链路 + API 接口测试 + 代码静态分析 + 安全渗透测试
|
||||
> 审计范围: 全部 5 个业务模块 + 2 个插件 + 前端 SPA + 安全与代码质量
|
||||
|
||||
---
|
||||
|
||||
## 一、审计总结
|
||||
|
||||
| 维度 | CRITICAL | HIGH | MEDIUM | LOW | 合计 |
|
||||
|------|----------|------|--------|-----|------|
|
||||
| 安全 | 2 | 4 | 3 | 2 | 11 |
|
||||
| 功能 | 1 | 3 | 3 | 1 | 8 |
|
||||
| 代码质量 | 0 | 1 | 3 | 6 | 10 |
|
||||
| **合计** | **3** | **8** | **9** | **9** | **29** |
|
||||
|
||||
---
|
||||
|
||||
## 二、CRITICAL — 必须立即修复
|
||||
|
||||
### C-01 Redis 凭据硬编码在配置文件中(泄露到 Git)
|
||||
|
||||
- **文件**: `crates/erp-server/config/default.toml` (line 11)
|
||||
- **现象**: `url = "redis://:redis_KBCYJk@129.204.154.246:6379"` 硬编码了远程 Redis 密码和 IP
|
||||
- **影响**: 凭据已提交到 Git 仓库,任何有代码访问权限的人都能获取 Redis 密码和服务器 IP
|
||||
- **修复**:
|
||||
1. 立即轮换 Redis 密码
|
||||
2. 将 `url` 改回 `__MUST_SET_VIA_ENV__` 占位符
|
||||
3. 使用环境变量 `ERP__REDIS__URL` 传递
|
||||
|
||||
### C-02 存储型 XSS — 用户输入未做 HTML 清理
|
||||
|
||||
- **文件**: `crates/erp-auth/src/service/user_service.rs` (创建/更新用户)
|
||||
- **现象**: 通过 `POST /api/v1/users` 可将 `<script>alert('xss')</script>` 和 `<img src=x onerror=alert(1)>` 直接存入数据库的 `display_name`、`email` 等字段
|
||||
- **影响**:
|
||||
- React JSX 自动转义避免了前端直接触发(当前安全)
|
||||
- 但原始 HTML 已存储在数据库中,在以下场景可触发:
|
||||
- 邮件模板渲染
|
||||
- PDF 导出
|
||||
- OpenAPI 文档中的 schema 示例
|
||||
- 未来使用非 React 渲染的任何场景
|
||||
- **验证**:
|
||||
```
|
||||
POST /api/v1/users {"display_name":"<img src=x onerror=alert(document.cookie)>"}
|
||||
→ 201 Created, 原始 HTML 直接入库
|
||||
```
|
||||
- **修复**: 后端入库前对所有用户可编辑字段 strip HTML tags 或 escape HTML entities
|
||||
|
||||
### C-03 首页工作台统计卡片永久 Loading
|
||||
|
||||
- **文件**: `apps/web/src/pages/Home.tsx`
|
||||
- **现象**: 4 个统计卡片(用户总数、角色数量、流程实例、未读消息)始终显示 loading 动画
|
||||
- **根因**: `useCountUp` 动画依赖数据加载,API 返回格式与前端预期不匹配
|
||||
- **影响**: 工作台页面无法展示核心统计数据,用户体验极差
|
||||
- **修复**: 修正统计 API 的数据格式,确保与 `StatisticCard` 组件预期一致
|
||||
|
||||
---
|
||||
|
||||
## 三、HIGH — 高优先级问题
|
||||
|
||||
### H-01 用户名唯一性约束未生效
|
||||
|
||||
- **文件**: `crates/erp-auth/src/service/user_service.rs` (创建用户)
|
||||
- **现象**: 用相同 `username` 创建两次用户均返回 `201 Created`
|
||||
- **影响**: 可能导致身份混淆、审计日志混乱
|
||||
- **修复**: 在创建用户前先查询 `username` 是否已存在(同 tenant_id + 未删除),或添加数据库唯一索引
|
||||
|
||||
### H-02 消息模板 API 返回空 body
|
||||
|
||||
- **文件**: `GET /api/v1/messages/templates`
|
||||
- **现象**: 返回空 HTTP body(非 JSON 格式),前端无法解析
|
||||
- **影响**: 消息中心"模板"tab 无法展示数据
|
||||
- **修复**: 修复空列表的序列化处理,确保返回 `{"success":true,"data":{"data":[],"total":0}}`
|
||||
|
||||
### H-03 主题 API 返回空 body
|
||||
|
||||
- **文件**: `GET /api/v1/config/theme`
|
||||
- **现象**: 返回空 body 而非 JSON
|
||||
- **影响**: 主题设置页面无法加载当前配置
|
||||
- **修复**: 为新租户初始化默认主题配置,或 API 返回默认值
|
||||
|
||||
### H-04 JWT Token 体积过大
|
||||
|
||||
- **文件**: `crates/erp-auth/src/service/token_service.rs`
|
||||
- **现象**: Access Token 包含 64 个权限字符串,JWT payload 约 2.5KB
|
||||
- **影响**:
|
||||
- 每次 HTTP 请求都要携带 2.5KB+ 的 Authorization header
|
||||
- 影响带宽和性能,尤其在高频 API 调用场景
|
||||
- 权限变更需要等 Token 过期才生效(最长 15 分钟)
|
||||
- **修复**:
|
||||
1. 方案 A:JWT 只存角色,权限在服务端 Redis 缓存实时查询
|
||||
2. 方案 B:使用权限位图/bitmask 压缩
|
||||
3. 方案 C:减少 JWT 中的权限列表,改为中间件实时校验
|
||||
|
||||
### H-05 字段长度无限制
|
||||
|
||||
- **文件**: `crates/erp-auth/src/dto.rs`
|
||||
- **现象**: `display_name` 可接受 500+ 字符(测试通过 500 个 'A'),无 max length 验证
|
||||
- **影响**: UI 布局破坏、潜在数据库性能问题
|
||||
- **修复**: 添加 `#[validate(length(max = 100))]` 等长度约束
|
||||
|
||||
---
|
||||
|
||||
## 四、MEDIUM — 中优先级问题
|
||||
|
||||
### M-01 菜单配置前端硬编码,未使用后端 API
|
||||
|
||||
- **文件**: `apps/web/src/components/AppLayout.tsx` 或路由配置
|
||||
- **现象**: 后端 `GET /api/v1/config/menus` 返回空数组,侧边栏菜单完全前端硬编码
|
||||
- **影响**: 菜单无法通过管理后台动态配置,插件菜单需要在代码中手动添加
|
||||
- **修复**: 实现前端从 API 动态加载菜单配置,或在后端初始化默认菜单数据
|
||||
|
||||
### M-02 时间戳未本地化显示
|
||||
|
||||
- **文件**: 消息中心、审计日志等列表页面
|
||||
- **现象**: 时间显示为原始 ISO 格式 `2026-04-14T13:10:59.516776Z`,用户不友好
|
||||
- **影响**: 用户体验差
|
||||
- **修复**: 使用 dayjs 格式化为本地时间,如 `2026-04-14 21:10:59`
|
||||
|
||||
### M-03 前端路由仅做认证守卫,无权限守卫
|
||||
|
||||
- **文件**: `apps/web/src/App.tsx`
|
||||
- **现象**: 路由只检查是否已登录(token 存在),不检查用户是否有权限访问特定页面
|
||||
- **影响**: 无权限用户可以通过直接输入 URL 访问任何页面(虽然 API 层会返回 403)
|
||||
- **修复**: 在路由守卫中增加权限校验,根据 JWT 中的 permissions 控制页面可见性
|
||||
|
||||
### M-04 消息响应包含内部 tenant_id
|
||||
|
||||
- **文件**: `crates/erp-message/src/handler/message_handler.rs`
|
||||
- **现象**: `GET /api/v1/messages` 返回每条消息的 `tenant_id` 字段
|
||||
- **影响**: 泄露内部多租户架构信息
|
||||
- **修复**: 在 DTO 层排除 `tenant_id` 字段
|
||||
|
||||
### M-05 搜索缺少防抖(Debounce)
|
||||
|
||||
- **文件**: `apps/web/src/pages/Users.tsx`, `apps/web/src/components/EntitySelect.tsx`
|
||||
- **现象**: 用户搜索输入框每次按键都触发 API 请求
|
||||
- **影响**: 高频请求冲击服务器,用户体验差
|
||||
- **修复**: 添加 300ms debounce(已有 `useDebouncedValue` Hook 但未使用)
|
||||
|
||||
### M-06 Organizations 页面 useCallback/useEffect 循环依赖
|
||||
|
||||
- **文件**: `apps/web/src/pages/Organizations.tsx`
|
||||
- **现象**: useCallback 依赖项导致 useEffect 无限循环渲染
|
||||
- **影响**: 性能问题、可能导致浏览器卡顿
|
||||
- **修复**: 重构 useCallback 依赖项,消除循环
|
||||
|
||||
### M-07 测试数据残留在生产数据库
|
||||
|
||||
- **现象**: 数据库中存在以下测试用户和数据:
|
||||
- `xss_user` — display_name 为 `<script>alert('xss')</script>`
|
||||
- `test_role_api` — 测试角色
|
||||
- `audit_test_user` — 审计测试用户
|
||||
- `testuser01` — 测试用户
|
||||
- `test_user_api` — API 测试用户
|
||||
- `Perf test` 消息 — 性能测试消息
|
||||
- `business_key: PERF-TEST-26311` 的待办任务
|
||||
- **影响**: 数据污染、潜在安全风险
|
||||
- **修复**: 清理所有测试数据,确保数据库只包含有意义的业务数据
|
||||
|
||||
### M-08 系统设置多个 tab 数据为空
|
||||
|
||||
- **现象**: 数据字典、编号规则、系统参数、语言管理等 tab 均无种子数据
|
||||
- **影响**: 系统看起来像空的,用户需要手动配置所有基础数据
|
||||
- **修复**: 在 `on_tenant_created` 中初始化默认字典(客户类型、行业、地区等)
|
||||
|
||||
### M-09 中文 API 响应编码异常
|
||||
|
||||
- **现象**: 部分中文内容在 API JSON 响应中显示为乱码(如 `\u7eef\u8364\u7cba` 而非"系统管理员")
|
||||
- **影响**: 可能是 curl 的显示问题,也可能是后端序列化配置问题
|
||||
- **修复**: 确认后端 JSON 序列化使用 `ensure_ascii: false` 等效配置
|
||||
|
||||
---
|
||||
|
||||
## 五、LOW — 低优先级 / 代码质量
|
||||
|
||||
### L-01 死代码 — graph 目录
|
||||
|
||||
- **文件**: `apps/web/src/pages/plugins/graph/` (6 个文件)
|
||||
- **现象**: 完全未使用的代码
|
||||
- **修复**: 删除或标记为实验性
|
||||
|
||||
### L-02 死代码 — 未使用的 Hooks
|
||||
|
||||
- **文件**: `apps/web/src/hooks/`
|
||||
- **现象**: `useDarkMode`, `useDebouncedValue`, `usePaginatedData`, `useApiRequest` 4 个 Hook 未被引用
|
||||
- **修复**: 清理或接入这些 Hook(特别是 useDebouncedValue 在搜索场景很有用)
|
||||
|
||||
### L-03 重复代码 — useCountUp
|
||||
|
||||
- **现象**: `useCountUp` 在 3 处重复定义
|
||||
- **修复**: 提取为共享 Hook
|
||||
|
||||
### L-04 暗色模式检测逻辑重复
|
||||
|
||||
- **现象**: `const isDark = token.colorBgContainer === '#111827'` 在 20+ 组件中重复
|
||||
- **修复**: 用已有的 `useDarkMode` Hook 替换
|
||||
|
||||
### L-05 i18n 已配置但未使用
|
||||
|
||||
- **现象**: i18next 已初始化且有 30 个翻译 key,但所有页面组件硬编码中文
|
||||
- **修复**: 逐步替换硬编码中文为 i18n key
|
||||
|
||||
### L-06 antd 废弃 API 警告
|
||||
|
||||
- **现象**: `Drawer` 的 `width` 属性、`Modal` 的 `destroyOnClose` 已废弃
|
||||
- **修复**: 升级到 antd 6 的新 API 用法
|
||||
|
||||
### L-07 ErrorBoundary 错误信息泄露
|
||||
|
||||
- **文件**: `apps/web/src/components/ErrorBoundary.tsx`
|
||||
- **现象**: 错误边界展示完整的错误堆栈给用户
|
||||
- **修复**: 生产环境只显示友好错误消息,堆栈信息仅记录到控制台
|
||||
|
||||
### L-08 Home 页面使用 dangerouslySetInnerHTML
|
||||
|
||||
- **文件**: `apps/web/src/pages/Home.tsx`
|
||||
- **现象**: 工作台页面使用 `dangerouslySetInnerHTML` 渲染内容
|
||||
- **影响**: 如果内容包含用户输入,可能导致 XSS
|
||||
- **修复**: 改用 React 组件渲染
|
||||
|
||||
### L-09 插件恢复计数不准确
|
||||
|
||||
- **现象**: `Plugin recovered: 0` 但实际 WASM 已加载
|
||||
- **修复**: 修正 recovery 计数逻辑
|
||||
|
||||
---
|
||||
|
||||
## 六、安全测试矩阵
|
||||
|
||||
| 测试项 | 结果 | 备注 |
|
||||
|--------|------|------|
|
||||
| 无 Token 访问受保护端点 | ✅ 401 | 正确拦截 |
|
||||
| 无效 Token | ✅ 401 | 正确拦截 |
|
||||
| 篡改 Token payload | ✅ 401 | HMAC 签名校验有效 |
|
||||
| 错误密码登录 | ✅ 401 | 正确拒绝 |
|
||||
| 短密码创建用户 | ✅ 400 | 验证 min=6 生效 |
|
||||
| 空 Token 刷新 | ✅ 401 | 正确拒绝 |
|
||||
| 旧 Refresh Token 重用 | ✅ 401 | 轮换机制生效 |
|
||||
| SQL 注入(搜索参数) | ✅ 安全 | SeaORM 参数化查询 |
|
||||
| SQL 注入(UUID 路径) | ✅ 安全 | UUID 解析拒绝非法字符 |
|
||||
| 存储型 XSS | ❌ 入库 | 后端未清理 HTML,React 前端安全 |
|
||||
| 无权限用户访问 API | ✅ 403 | 权限校验正确 |
|
||||
| 无权限用户提权 | ✅ 403 | 角色分配受权限保护 |
|
||||
| 限流 | ✅ 生效 | 5 次失败后触发 429 |
|
||||
| CORS 配置 | ✅ 白名单 | 仅允许 localhost 端口 |
|
||||
| 凭据泄露 | ❌ Redis 密码硬编码 | 已提交到 Git |
|
||||
|
||||
---
|
||||
|
||||
## 七、功能链路审计结果
|
||||
|
||||
### 7.1 认证链路 ✅ 基本正常
|
||||
|
||||
| 环节 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| 登录 → JWT 签发 | ✅ | access (15min) + refresh (7d) |
|
||||
| Token 刷新轮换 | ✅ | 旧 Token 使用后立即失效 |
|
||||
| 密码修改 → Token 吊销 | ✅ | 所有 refresh token 失效 |
|
||||
| 登出 → Token 吊销 | ✅ | |
|
||||
| 限流保护 | ✅ | 5 次失败后 429 |
|
||||
| 审计日志记录 | ✅ | 登录成功/失败均有记录 |
|
||||
|
||||
### 7.2 用户管理 ✅ 基本正常,有缺陷
|
||||
|
||||
| 环节 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| CRUD 操作 | ✅ | |
|
||||
| 角色分配 | ✅ | |
|
||||
| 用户名唯一性 | ❌ | 重复用户名可创建 |
|
||||
| 输入验证 | ⚠️ | 密码有验证,其他字段长度/XSS 无验证 |
|
||||
| 软删除 | ✅ | |
|
||||
|
||||
### 7.3 权限管理 ✅ 正常
|
||||
|
||||
| 环节 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| 角色列表 | ✅ | 3 个角色(admin/viewer/test) |
|
||||
| 权限分配 | ✅ | 54 个权限可精确分配 |
|
||||
| 系统角色保护 | ⚠️ | admin 角色权限可被修改 |
|
||||
| data_scope 配置 | ❌ | 权限对话框中无 data_scope 配置入口 |
|
||||
|
||||
### 7.4 工作流引擎 ✅ 正常
|
||||
|
||||
| 环节 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| 流程定义 CRUD | ✅ | 3 个定义(draft/published) |
|
||||
| 流程发起 | ✅ | |
|
||||
| 任务审批 | ✅ | approve/reject |
|
||||
| 任务委派 | ✅ | |
|
||||
| 实例监控 | ✅ | running/terminated/suspended |
|
||||
| 超时检测 | ✅ | 60s 间隔扫描 |
|
||||
|
||||
### 7.5 消息中心 ⚠️ 部分异常
|
||||
|
||||
| 环节 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| 消息列表 | ✅ | 10 条消息 |
|
||||
| 未读计数 | ✅ | bell 图标显示未读数 |
|
||||
| 标记已读 | ✅ | |
|
||||
| 全部已读 | ✅ | |
|
||||
| 消息模板 | ❌ | API 返回空 body |
|
||||
| 通知设置 | ⚠️ | 未验证 |
|
||||
| 工作流事件 → 消息 | ✅ | "流程已启动" 消息自动生成 |
|
||||
|
||||
### 7.6 系统配置 ⚠️ 数据缺失
|
||||
|
||||
| 环节 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| 数据字典 | ❌ | 空数据,无种子 |
|
||||
| 菜单配置 | ❌ | 后端空,前端硬编码 |
|
||||
| 编号规则 | ❌ | 空 |
|
||||
| 系统参数 | ⚠️ | 未验证 |
|
||||
| 主题设置 | ❌ | API 返回空 |
|
||||
| 语言管理 | ⚠️ | 未验证 |
|
||||
| 修改密码 | ✅ | 功能正常 |
|
||||
| 审计日志 | ✅ | |
|
||||
|
||||
### 7.7 插件系统 ✅ 基本正常
|
||||
|
||||
| 环节 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| CRM 插件运行 | ✅ | 状态:运行中 |
|
||||
| 客户 CRUD | ✅ | 6 条客户数据 |
|
||||
| 联系人 | ✅ | |
|
||||
| 沟通记录 | ✅ | |
|
||||
| 标签管理 | ✅ | |
|
||||
| 客户关系 | ✅ | |
|
||||
| 统计概览 | ⚠️ | |
|
||||
| 销售漏斗 | ⚠️ | |
|
||||
|
||||
---
|
||||
|
||||
## 八、修复优先级建议
|
||||
|
||||
### 🔴 立即修复(本周内)
|
||||
|
||||
| 编号 | 问题 | 预计工作量 |
|
||||
|------|------|-----------|
|
||||
| C-01 | Redis 凭据从 config 移除,改用环境变量 | 0.5h |
|
||||
| C-02 | 后端添加 HTML sanitize 中间件 | 2h |
|
||||
| C-03 | 修复首页统计卡片数据格式 | 1h |
|
||||
| H-01 | 添加用户名唯一性校验 | 1h |
|
||||
|
||||
### 🟡 本迭代修复(2 周内)
|
||||
|
||||
| 编号 | 问题 | 预计工作量 |
|
||||
|------|------|-----------|
|
||||
| H-02 | 修复消息模板空返回 | 0.5h |
|
||||
| H-03 | 修复主题 API 空返回 | 0.5h |
|
||||
| H-04 | JWT 权限压缩或改为服务端查询 | 4h |
|
||||
| H-05 | 添加字段长度验证 | 1h |
|
||||
| M-03 | 添加前端路由权限守卫 | 2h |
|
||||
| M-05 | 搜索添加防抖 | 0.5h |
|
||||
| M-07 | 清理测试数据 | 1h |
|
||||
| M-08 | 初始化默认系统配置数据 | 2h |
|
||||
|
||||
### 🟢 迭代中逐步修复
|
||||
|
||||
| 编号 | 问题 |
|
||||
|------|------|
|
||||
| M-01 | 菜单动态加载 |
|
||||
| M-02 | 时间戳本地化 |
|
||||
| M-04 | API 响应排除 tenant_id |
|
||||
| M-06 | Organizations 性能修复 |
|
||||
| L-01~L-09 | 代码质量清理 |
|
||||
|
||||
---
|
||||
|
||||
## 九、系统亮点(做得好的地方)
|
||||
|
||||
1. **Token 刷新轮换机制** — 旧 Refresh Token 重用被正确拒绝
|
||||
2. **限流保护** — 登录失败 5 次后触发 429 Too Many Requests
|
||||
3. **SeaORM 参数化查询** — SQL 注入测试全部被拦截
|
||||
4. **权限校验完整性** — 无权限用户所有操作返回 403
|
||||
5. **多租户架构** — JWT 注入 tenant_id,中间件自动过滤
|
||||
6. **审计日志** — 登录/登出/密码修改等关键操作有完整记录
|
||||
7. **WASM 插件沙箱** — CRM 插件运行稳定,6 个实体全部可用
|
||||
8. **工作流引擎** — BPMN 解析、Token 驱动、任务分配完整实现
|
||||
9. **错误处理链** — thiserror → AppError → HTTP 响应的统一错误体系
|
||||
10. **优雅关闭** — CTRL+C 信号处理、模块按拓扑逆序关闭
|
||||
155
docs/archive/audits-v1/audit-2026-04-18.md
Normal file
155
docs/archive/audits-v1/audit-2026-04-18.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# 系统全面审计报告 — 2026-04-18
|
||||
|
||||
## 审计环境
|
||||
|
||||
| 项目 | 值 |
|
||||
|---|---|
|
||||
| PostgreSQL | 18 (原生安装 D:\postgreSQL), 端口 5432 |
|
||||
| Redis | 未安装/未运行 (限流改为 fail-open 降级) |
|
||||
| 后端 | Axum 0.8, 端口 3000 |
|
||||
| 前端 | Vite 8, 端口 5174 |
|
||||
| 操作系统 | Windows 11 Pro |
|
||||
|
||||
## P0 — 严重问题(必须立即修复)
|
||||
|
||||
### 1. CRM 插件数据 403 Forbidden — ✅ 已修复
|
||||
|
||||
**现象**: 所有 CRM 数据页面(客户、联系人、沟通记录等)返回 403 错误,页面显示"加载数据失败"。
|
||||
|
||||
**根因**: CRM 插件在安装时正确注册了 9 条权限到 `permissions` 表(`erp-crm.customer.list` 等),但 **没有自动将这些权限分配给 admin 角色**。导致 JWT 中只有 `plugin.admin` 和 `plugin.list`,缺少 `erp-crm.*` 权限。
|
||||
|
||||
**修复**: 在 `erp-plugin/src/service.rs` 中新增 `grant_permissions_to_admin()` 函数,在 `install()` 和 `enable()` 中自动调用。修复后 CRM 客户列表 API 正常返回数据。
|
||||
|
||||
### 2. CRM 插件启动恢复失败 — ✅ 已修复
|
||||
|
||||
**现象**: 后端日志 `Failed to recover plugin (initialize): 数据库错误: 关系 "plugin_erp_crm_inventory_item" 不存在`
|
||||
|
||||
**根因**: CRM 插件的 `on_init` 回调尝试创建 `inventory_item` 实体的种子数据,但该表不存在。可能是 CRM 插件 WASM 代码中的实体定义与数据库迁移不匹配。
|
||||
|
||||
**影响**: 服务器重启后 CRM 插件恢复失败,`Plugins recovered: 0`。
|
||||
|
||||
**修复**: 通过升级 API 重新上传正确的 CRM WASM 二进制(22KB 替换错误的 110KB 测试插件)。修复后插件正常恢复并运行。
|
||||
|
||||
### 3. 首页统计数据卡片永久 Loading
|
||||
|
||||
**现象**: 工作台首页 4 个统计卡片(用户总数、角色数量、流程实例、未读消息)显示 loading 状态(`busy` 属性),数字不显示。
|
||||
|
||||
**根因**: 首页统计卡片使用 `useCountUp` 动画但依赖数据加载,数据加载可能失败或 API 返回格式不匹配。
|
||||
|
||||
### 4. 插件 API 路由不支持字符串 ID
|
||||
|
||||
**现象**: `/api/v1/plugins/erp-crm/customer` 返回 `UUID parsing failed`。
|
||||
|
||||
**根因**: 后端路由定义 `Path<(Uuid, String)>`,要求 `plugin_id` 必须是 UUID 格式。但插件的 manifest ID 是字符串(如 `erp-crm`)。
|
||||
|
||||
**影响**: 直接用 manifest ID 调用 API 不行,必须先查 UUID。前端已绕过此问题(使用 UUID),但 API 设计不够友好。
|
||||
|
||||
## P1 — 高优先级问题
|
||||
|
||||
### 5. XSS: 显示名未转义存储
|
||||
|
||||
**现象**: `POST /api/v1/users` 时 `display_name` 字段可以存储 `<script>alert(1)</script>`,API 返回原样值。
|
||||
|
||||
**评估**: React 框架自动转义防止了前端 XSS。但数据库中存储了原始 HTML,如果有其他客户端(如邮件、导出 PDF 等)不转义渲染,仍存在风险。
|
||||
|
||||
**建议**: 后端入库时 strip HTML tags 或 escape。
|
||||
|
||||
### 6. 重复用户名检测缺失
|
||||
|
||||
**现象**: `POST /api/v1/users` 用 `audit_test_user` 创建两次,第二次也返回 `success: true`,没有报重复错误。
|
||||
|
||||
**评估**: 第二次创建返回的 `id` 不同但 `username` 相同,说明用户名唯一性约束可能没生效。
|
||||
|
||||
### 7. 消息模板 API 返回空
|
||||
|
||||
**现象**: `GET /api/v1/messages/templates` 返回空 body(非 JSON)。
|
||||
|
||||
**根因**: 可能数据库无模板数据,且空列表情况下序列化异常。
|
||||
|
||||
### 8. 主题 API 返回空
|
||||
|
||||
**现象**: `GET /api/v1/config/theme` 返回空 body。
|
||||
|
||||
### 9. `roles/permissions` 路由冲突 — ✅ 已修复
|
||||
|
||||
**现象**: `GET /api/v1/roles/permissions` 返回 UUID 解析错误。
|
||||
|
||||
**根因**: 路由 `GET /roles/{id}` 把 `permissions` 当成 UUID 解析了。
|
||||
|
||||
**修复**: 在 `erp-auth/src/module.rs` 中,在 `/roles/{id}` 之前注册 `/roles/permissions` 精确匹配路由。修复后返回 64 条权限数据。
|
||||
|
||||
## P2 — 中优先级问题
|
||||
|
||||
### 10. CRM 插件恢复后 Plugin recovered: 0
|
||||
|
||||
后端日志显示插件加载成功但 recovery 报 0。on_init 失败导致插件状态变为 error,但实际插件 WASM 已加载到内存。
|
||||
|
||||
### 11. 创建用户时中文 display_name 解析失败
|
||||
|
||||
`POST /api/v1/users` 带 `display_name` 含中文字符时,返回 `invalid unicode code point`。可能与 curl 的编码有关而非后端 bug,需要进一步验证。
|
||||
|
||||
### 12. 菜单数据为空
|
||||
|
||||
`GET /api/v1/config/menus` 返回空数组。系统侧边栏菜单是前端硬编码的,后端菜单配置未使用。
|
||||
|
||||
### 13. 数据字典为空
|
||||
|
||||
`GET /api/v1/config/dictionaries` 返回空。这是正常的(未创建字典数据)。
|
||||
|
||||
## P3 — 低优先级 / 代码质量
|
||||
|
||||
### 14. 前端死代码
|
||||
|
||||
- `src/pages/plugins/graph/` 6 个文件完全未使用
|
||||
- `src/hooks/` 下 4 个 Hook 未被任何组件引用(useDarkMode, useDebouncedValue, usePaginatedData, useApiRequest)
|
||||
- `useCountUp` 在 3 处重复定义
|
||||
|
||||
### 15. i18n 已配置但完全未使用
|
||||
|
||||
i18next 已初始化,翻译文件有 30 个 key,但所有页面组件硬编码中文。
|
||||
|
||||
### 16. 暗色模式检测逻辑重复 20+ 次
|
||||
|
||||
`const isDark = token.colorBgContainer === '#111827'` 在 20+ 组件中重复,已有 `useDarkMode` Hook 但未使用。
|
||||
|
||||
### 17. antd 废弃 API 警告
|
||||
|
||||
- `Drawer` 的 `width` 属性已废弃
|
||||
- `Modal` 的 `destroyOnClose` 已废弃
|
||||
- `message` 静态方法无法消费 context
|
||||
|
||||
## 安全测试结果
|
||||
|
||||
| 测试项 | 结果 |
|
||||
|---|---|
|
||||
| 无 token 访问 | 401 Unauthorized |
|
||||
| 错误 token | 401 Unauthorized |
|
||||
| 错误密码登录 | 401 Unauthorized |
|
||||
| 空请求体登录 | 反序列化错误(非 500) |
|
||||
| 短密码验证 | 400 Bad Request + 详细验证信息 |
|
||||
| SQL 注入(用户名) | JSON 解析失败(被拦截) |
|
||||
| XSS(显示名) | 存储了原始 HTML(需后端过滤) |
|
||||
| 权限不足操作 | 403 Forbidden |
|
||||
|
||||
## 正常工作的功能
|
||||
|
||||
- 登录/登出/Token 刷新
|
||||
- 用户 CRUD(创建/列表/删除)
|
||||
- 角色 CRUD + 权限查看
|
||||
- 组织架构三栏管理
|
||||
- 工作流定义列表/待办任务
|
||||
- 消息列表/已读/未读计数
|
||||
- 审计日志记录
|
||||
- 插件管理(上传/启用/停用)
|
||||
- 系统设置 Tab 页(字典/语言/菜单/编号/主题/参数/审计/密码)
|
||||
- OpenAPI 文档端点
|
||||
|
||||
## 下一步工作建议
|
||||
|
||||
1. **P0-1**: 修复插件权限自动分配给 admin 角色
|
||||
2. **P0-2**: 修复 CRM 插件 on_init 中 inventory_item 表不存在的问题
|
||||
3. **P0-3**: 修复首页统计卡片数据加载
|
||||
4. **P1-5**: 后端 display_name HTML 过滤
|
||||
5. **P1-6**: 用户名唯一性约束
|
||||
6. **P1-9**: 修复 roles/permissions 路由冲突
|
||||
7. 更新所有相关文档(wiki/插件系统文档)
|
||||
269
docs/archive/audits-v1/e2e-consistency-report.md
Normal file
269
docs/archive/audits-v1/e2e-consistency-report.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# HMS 三端一致性检查报告
|
||||
|
||||
> 日期: 2026-05-08 | 审查范围: 后端 API / Web 前端 / 微信小程序
|
||||
|
||||
## 一、审查概要
|
||||
|
||||
| 维度 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 功能设计一致性 | ⚠️ 基本一致 | 三端定位不同(管理端/患者端/医护端),功能差异多为设计意图 |
|
||||
| 数据接口一致性 | ✅ 高度一致 | 小程序 91 个端点 / Web 270+ 端点,路径/参数/响应格式统一 |
|
||||
| 业务流程链路一致性 | ⚠️ 存在差异 | 透析管理、积分商城、AI 分析存在端间覆盖不完整 |
|
||||
|
||||
**总体评分**: **一致性 82%** — 不一致项多为设计意图(端定位不同),少量为遗漏需修复。
|
||||
|
||||
---
|
||||
|
||||
## 二、三端功能覆盖矩阵
|
||||
|
||||
### 2.1 完整覆盖(三端一致)✅
|
||||
|
||||
| 业务模块 | 后端 | Web | 小程序 | 说明 |
|
||||
|----------|------|-----|--------|------|
|
||||
| 患者管理 CRUD | ✅ | ✅ | ✅(患者端) | Web 管理端 + MP 患者端 |
|
||||
| 预约管理 | ✅ | ✅ | ✅ | 完整覆盖 |
|
||||
| 咨询管理 | ✅ | ✅ | ✅ | 含医生端会话处理 |
|
||||
| 随访管理 | ✅ | ✅ | ✅ | Web 管理 + MP 医生端 + 患者端 |
|
||||
| 化验报告 | ✅ | ✅ | ✅ | 含医生端审阅 |
|
||||
| 告警管理 | ✅ | ✅ | ✅ | 确认/忽略/解除三端一致 |
|
||||
| 健康记录 | ✅ | ✅ | ✅ | CRUD 完整 |
|
||||
| 知情同意 | ✅ | ✅ | ✅ | 授权/撤回 |
|
||||
| 诊断记录 | ✅ | ✅ | ✅ | CRUD 完整 |
|
||||
| 消息通知 | ✅ | ✅ | ✅ | 列表/已读/未读(MP 不支持 SSE) |
|
||||
| 日常监测 | ✅ | ✅ | ✅ | 创建/查看 |
|
||||
| 设备读数 | ✅ | ✅ | ✅ | BLE 上传 + 查询 |
|
||||
|
||||
### 2.2 部分覆盖(存在差异)⚠️
|
||||
|
||||
| 业务模块 | 后端 | Web | 小程序 | 差异说明 |
|
||||
|----------|------|-----|--------|----------|
|
||||
| 透析管理 | ✅ 46 端点 | ⚠️ 冻结 | ✅ 完整 | **Web 端路由标记 frozen**,小程序医生端完整可用 |
|
||||
| 透析处方 | ✅ | ❌ | ✅ | **Web 端无处方管理页面**,小程序医生端有 |
|
||||
| 积分商城(患者) | ✅ | ❌ | ✅ | 签到/兑换/商品浏览仅小程序 |
|
||||
| 积分商城(管理) | ✅ | ✅ | ❌ | 规则/商品/订单管理仅 Web |
|
||||
| AI 分析(SSE) | ✅ | ✅ | ❌ | 小程序不支持 SSE 流式,仅查看历史 |
|
||||
| AI 建议审批 | ✅ | ✅ | ❌ | 仅 Web 端可审批 |
|
||||
| 文章审核流程 | ✅ | ✅ | ❌ | submit/approve/reject 仅 Web |
|
||||
| 班次管理 | ✅ | ✅ | ❌ | 管理功能仅 Web |
|
||||
| 护理计划 | ✅ | ⚠️ 冻结 | ❌ | Web 冻结,小程序无 |
|
||||
| 排班管理 | ✅ | ✅ | ❌ | 创建/管理仅 Web,小程序仅查看 |
|
||||
| 设备管理 | ✅ | ✅ | ❌ | 解绑/管理仅 Web,小程序仅 BLE 同步 |
|
||||
| BLE 网关管理 | ✅ | ✅ | ❌ | 注册/绑定/管理仅 Web |
|
||||
| 危急值阈值 | ✅ | ✅ | ⚠️ | Web 可管理,MP 仅查看 public 端点 |
|
||||
| OAuth 客户端 | ✅ | ✅ | ❌ | FHIR 合作方管理仅 Web |
|
||||
| 用药提醒 | ✅ | ❌ | ✅ | **Web 端无用药提醒页面**,小程序有 CRUD |
|
||||
|
||||
### 2.3 单端独有(设计意图,非遗漏)
|
||||
|
||||
| 独有功能 | 端 | 说明 |
|
||||
|----------|-----|------|
|
||||
| 用户/角色/权限管理 | Web | 管理后台职责 |
|
||||
| 组织/部门/岗位 | Web | 管理后台职责 |
|
||||
| 工作流引擎 | Web | 管理后台职责 |
|
||||
| 插件系统 | Web | 管理后台职责 |
|
||||
| 系统设置/字典/编号规则 | Web | 管理后台职责 |
|
||||
| 微信登录+手机号绑定 | MP | 小程序专属 |
|
||||
| 每日签到 | MP | 小程序用户粘性功能 |
|
||||
| 线下活动报名 | MP | 患者端功能 |
|
||||
| 法律文件(用户协议/隐私) | MP | 小程序合规要求 |
|
||||
| BLE 设备蓝牙连接 | MP | 小程序蓝牙能力 |
|
||||
| 埋点数据上报 | MP | 小程序分析功能 |
|
||||
| FHIR R4 接口 | 后端 | 标准互操作,无前端页面 |
|
||||
|
||||
---
|
||||
|
||||
## 三、API 接口一致性分析
|
||||
|
||||
### 3.1 请求格式一致性 ✅
|
||||
|
||||
| 维度 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| URL 路径前缀 | ✅ 一致 | 三端统一 `/api/v1/` |
|
||||
| 分页参数 | ✅ 一致 | `page`, `page_size`, 响应 `PaginatedResponse<T>` |
|
||||
| 乐观锁参数 | ✅ 一致 | 更新/删除均带 `version` 字段 |
|
||||
| 认证方式 | ✅ 一致 | Bearer JWT Token |
|
||||
| 多租户 | ✅ 一致 | 中间件自动注入 `tenant_id` |
|
||||
|
||||
### 3.2 接口覆盖统计
|
||||
|
||||
| 指标 | 后端 | Web 前端 | 小程序 |
|
||||
|------|------|----------|--------|
|
||||
| API 端点总数 | ~300+ | ~270 | ~91 |
|
||||
| Health 端点 | ~200 | ~140 | ~70 |
|
||||
| AI 端点 | ~18 | ~18 | ~3 |
|
||||
| Auth 端点 | ~8 | ~4 | ~4 |
|
||||
| Config/基础端点 | ~74 | ~108 | ~4 |
|
||||
| 消息端点 | ~7 | ~9 | ~4 |
|
||||
|
||||
### 3.3 发现的接口不一致
|
||||
|
||||
| # | 不一致项 | 后端 | Web | 小程序 | 严重度 |
|
||||
|---|----------|------|-----|--------|--------|
|
||||
| 1 | **透析处方 CRUD** | ✅ 完整端点 | ❌ 无 API 调用 | ✅ 完整调用 | **HIGH** |
|
||||
| 2 | **用药提醒 CRUD** | ✅ 完整端点 | ❌ 无 API 调用 | ✅ 完整调用 | **MEDIUM** |
|
||||
| 3 | **小程序趋势查询** `GET /health/vital-signs/trend` | ✅ 专属端点 | ❌ 使用患者级趋势 | ✅ 专属调用 | LOW(设计意图) |
|
||||
| 4 | **小程序今日体征** `GET /health/vital-signs/today` | ✅ 专属端点 | ❌ 不需要 | ✅ 专属调用 | LOW(设计意图) |
|
||||
| 5 | **公开阈值** `GET /health/critical-value-thresholds/public` | ✅ 专属端点 | ❌ 使用管理端点 | ✅ 专属调用 | LOW(设计意图) |
|
||||
| 6 | **小程序未调用透析审阅** `PUT /health/dialysis-records/:id/review` | ✅ | ❌ 冻结 | ✅ 医生端调用 | LOW |
|
||||
| 7 | **AI SSE 端点** | ✅ 4 个 SSE | ✅ 调用 | ❌ 不支持 SSE | LOW(平台限制) |
|
||||
|
||||
---
|
||||
|
||||
## 四、业务流程链路一致性
|
||||
|
||||
### 4.1 用户认证流程
|
||||
|
||||
| 步骤 | Web | 小程序 | 一致性 |
|
||||
|------|-----|--------|--------|
|
||||
| 登录方式 | 账号密码 `POST /auth/login` | 微信授权 `POST /auth/wechat/login` | ⚠️ 设计意图不同 |
|
||||
| Token 管理 | 自动刷新(过期前 30s) | 自动刷新(401 触发) | ✅ 机制一致 |
|
||||
| 登出 | `POST /auth/logout` | 清除本地 token | ✅ |
|
||||
| 手机号绑定 | N/A | `POST /auth/wechat/bind-phone` | ⚠️ MP 独有 |
|
||||
|
||||
**结论**: 认证流程符合各端定位,设计合理。
|
||||
|
||||
### 4.2 预约流程
|
||||
|
||||
| 步骤 | Web | 小程序 | 一致性 |
|
||||
|------|-----|--------|--------|
|
||||
| 选择医生 | ✅ 医生列表 | ✅ 医生列表 | ✅ |
|
||||
| 查看排班 | ✅ 日历视图 | ✅ 日历视图 | ✅ |
|
||||
| 创建预约 | ✅ `POST /health/appointments` | ✅ 相同 | ✅ |
|
||||
| 查看预约 | ✅ 列表+详情 | ✅ 列表+详情 | ✅ |
|
||||
| 取消预约 | ✅ `PUT /appointments/:id/status` | ✅ 相同 | ✅ |
|
||||
|
||||
**结论**: 预约流程三端完全一致。
|
||||
|
||||
### 4.3 健康数据录入流程
|
||||
|
||||
| 步骤 | Web | 小程序 | 一致性 |
|
||||
|------|-----|--------|--------|
|
||||
| 体征录入 | ✅ `POST /patients/:id/vital-signs` | ✅ 相同 | ✅ |
|
||||
| 查看趋势 | ✅ `GET /patients/:id/trends` | ✅ `GET /vital-signs/trend` | ⚠️ 路径不同 |
|
||||
| 今日概览 | ❌ 无此功能 | ✅ `GET /vital-signs/today` | ⚠️ MP 独有 |
|
||||
| 日常监测 | ✅ | ✅ | ✅ |
|
||||
| 化验报告上传 | ✅ 含文件上传 | ✅ 仅查看 | ⚠️ MP 无上传 |
|
||||
|
||||
**结论**: 核心录入一致,查看路径有差异(患者自服务 vs 管理端视角)。
|
||||
|
||||
### 4.4 咨询流程
|
||||
|
||||
| 步骤 | Web | 小程序 | 一致性 |
|
||||
|------|-----|--------|--------|
|
||||
| 创建会话 | ✅ | ✅ | ✅ |
|
||||
| 发送消息 | ✅ `POST /consultation-messages` | ✅ 相同 | ✅ |
|
||||
| 接收消息 | ✅ SSE 实时 | ⚠️ 8s 轮询 | ⚠️ 实时性差异 |
|
||||
| 标记已读 | ✅ | ✅ | ✅ |
|
||||
| 关闭会话 | ✅ | ✅(仅医生端) | ✅ |
|
||||
|
||||
**结论**: 核心流程一致,消息接收机制因平台限制不同。
|
||||
|
||||
### 4.5 透析管理流程 ⚠️
|
||||
|
||||
| 步骤 | Web | 小程序 | 一致性 |
|
||||
|------|-----|--------|--------|
|
||||
| 透析记录列表 | ⚠️ 冻结 | ✅ | ❌ |
|
||||
| 创建透析记录 | ⚠️ 冻结 | ✅(医生端) | ❌ |
|
||||
| 审阅透析记录 | ⚠️ 冻结 | ✅(医生端) | ❌ |
|
||||
| 透析处方管理 | ❌ 无页面 | ✅(医生端) | ❌ |
|
||||
| 透析统计 | ✅ | ✅(医生端) | ✅ |
|
||||
|
||||
**结论**: Web 端透析模块冻结,小程序端完整可用。这是最大的不一致项。
|
||||
|
||||
### 4.6 积分商城流程
|
||||
|
||||
| 步骤 | Web(管理) | 小程序(患者) | 一致性 |
|
||||
|------|------------|----------------|--------|
|
||||
| 每日签到 | ❌ | ✅ | ⚠️ MP 独有 |
|
||||
| 积分查询 | ✅ | ✅ | ✅ |
|
||||
| 商品浏览 | ✅(管理) | ✅(浏览) | ✅ |
|
||||
| 积分兑换 | ❌ | ✅ | ⚠️ MP 独有 |
|
||||
| 订单核销 | ✅ | ❌ | ⚠️ Web 独有 |
|
||||
|
||||
**结论**: 管理端与患者端分工明确,无遗漏。
|
||||
|
||||
---
|
||||
|
||||
## 五、权限码一致性
|
||||
|
||||
### 5.1 权限覆盖
|
||||
|
||||
| 模块 | 后端权限码 | Web 路由守卫 | 小程序角色检查 |
|
||||
|------|-----------|-------------|---------------|
|
||||
| health.patient | .list / .manage | ✅ 路由守卫 | ✅ isMedicalStaff |
|
||||
| health.health-data | .list / .manage | ✅ | ✅ |
|
||||
| health.appointment | .list / .manage | ✅ | ✅ |
|
||||
| health.follow-up | .list / .manage | ✅ | ✅ |
|
||||
| health.consultation | .list / .manage | ✅ | ✅ |
|
||||
| health.alerts | .list / .manage | ✅ | ✅ |
|
||||
| health.dialysis | .list / .manage | ⚠️ 冻结路由 | ✅ 医生角色 |
|
||||
| health.points | .list / .manage | ✅ | ✅ |
|
||||
| ai.analysis | .list / .manage | ✅ | ✅(仅查看) |
|
||||
| ai.suggestion | .list / .manage | ✅ | ⚠️ 仅 list |
|
||||
|
||||
**结论**: 权限码体系完整,Web 路由守卫与后端权限一一对应。
|
||||
|
||||
---
|
||||
|
||||
## 六、需要修复的不一致项
|
||||
|
||||
### CRITICAL — 无
|
||||
|
||||
### HIGH — 1 项
|
||||
|
||||
| # | 问题 | 影响 | 状态 |
|
||||
|---|------|------|------|
|
||||
| H1 | **小程序咨询消息为 8s 轮询,Web 为 SSE 实时** | 小程序消息延迟,体验不一致 | 🔧 待实现 |
|
||||
|
||||
### 已关闭(产品决策冻结)
|
||||
|
||||
| # | 问题 | 决策 |
|
||||
|---|------|------|
|
||||
| ~~H1~~ | Web 端透析管理路由冻结 | ✅ 保持冻结,当前版本不涉及医疗业务 |
|
||||
| ~~H2~~ | Web 端无透析处方管理页面 | ✅ 冻结,与透析管理同步 |
|
||||
| ~~M1~~ | Web 端无用药提醒功能 | ✅ 三端冻结 |
|
||||
| ~~M2~~ | 小程序 AI 分析仅查看历史 | ✅ 设计意图,小程序仅展示结果 |
|
||||
|
||||
### LOW — 5 项(多为设计意图)
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| L1 | 小程序趋势查询使用专属端点 | 患者自服务视角 vs 管理端视角,设计意图 |
|
||||
| L2 | 小程序今日体征为独有功能 | 患者端需求,管理端不需要 |
|
||||
| L3 | 小程序不支持 SSE 流式分析 | 平台限制,非遗漏 |
|
||||
| L4 | 积分签到仅小程序 | 用户粘性功能,管理端不需要 |
|
||||
| L5 | 法律文件仅小程序 | 小程序上架合规要求 |
|
||||
|
||||
---
|
||||
|
||||
## 七、统计数据
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 后端 API 端点 | ~300+ |
|
||||
| Web 前端 API 调用 | ~270 |
|
||||
| 小程序 API 调用 | ~91 |
|
||||
| 三端完全一致的业务流程 | 8/11 (73%) |
|
||||
| 需要修复的不一致项 | HIGH ×2 + MEDIUM ×3 + LOW ×5 |
|
||||
| 设计意图导致的差异 | 13 项(非遗漏) |
|
||||
| 总体一致性评分 | **82%** |
|
||||
|
||||
---
|
||||
|
||||
## 八、结论与建议
|
||||
|
||||
### 8.1 总体评价
|
||||
|
||||
HMS 三端在 API 接口层面保持了高度一致性(统一前缀、统一响应格式、统一分页、统一乐观锁),差异主要集中在:
|
||||
|
||||
1. **端定位不同导致的功能差异** — 这是设计意图,不需要修复
|
||||
2. **Web 端透析模块冻结** — 这是最大的不一致项,需要产品决策
|
||||
3. **个别功能仅在单端实现** — 用药提醒、透析处方等需评估是否补齐
|
||||
|
||||
### 8.2 优先行动建议
|
||||
|
||||
1. **产品决策**: 确认透析管理模块是否在 Web 端解冻。如果血透中心是首发场景,Web 管理端的透析能力不应缺失
|
||||
2. **功能补齐**: Web 端补充透析处方管理页面(后端 API 已就绪)
|
||||
3. **功能补齐**: Web 端患者详情增加用药提醒管理(后端 API 已就绪)
|
||||
4. **体验优化**: 评估小程序咨询消息是否需要更实时的方案
|
||||
5. **能力对齐**: 评估小程序是否需要 AI 分析触发入口
|
||||
@@ -0,0 +1,615 @@
|
||||
# ERP 平台发散式探讨记录
|
||||
|
||||
> 日期: 2026-04-18 | 形式: 无主题发散式互动讨论
|
||||
|
||||
---
|
||||
|
||||
## 项目当前状态快照
|
||||
|
||||
**已完成:**
|
||||
- Phase 1-6 核心平台 (core/auth/config/workflow/message/plugin)
|
||||
- WASM 插件系统 (Wasmtime + WIT + 动态表 + 热更新)
|
||||
- 2 个行业插件 (CRM 5实体 + 进销存 6实体)
|
||||
- Q2-Q4 成熟度路线图 (安全/架构/测试/插件生态)
|
||||
- 13 个 Rust crate, 37 个迁移, 15+ 前端页面
|
||||
|
||||
**进行中 (29 个未提交文件):**
|
||||
- P0 平台能力升级 (实体关系增强/字段校验/前端去硬编码)
|
||||
- 插件系统增强 (混合执行模型/聚合查询扩展/热更新原子回滚/Schema演进)
|
||||
|
||||
**代码中的 TODO:**
|
||||
- Workflow 超时自动完成/升级逻辑
|
||||
- Redis 缓存层 (data_service)
|
||||
|
||||
---
|
||||
|
||||
## 发散探讨方向
|
||||
|
||||
### 方向 A: 技术纵深 — 平台能力的下一个突破点
|
||||
|
||||
**插件系统能力边界在哪里?**
|
||||
- 混合执行模型 (WASM + Host Query) 的安全边界如何界定?
|
||||
- 插件能否拥有自己的定时任务?事件订阅后的异步处理链?
|
||||
- WASM 组件之间的通信机制 — 插件 A 能否调用插件 B 的能力?
|
||||
- 插件市场/分发机制 — 如何做到"一键安装"?
|
||||
|
||||
**性能与规模化的隐藏挑战:**
|
||||
- 动态表在海量数据下的查询性能 — 索引策略?
|
||||
- 多租户隔离在大规模场景下的瓶颈 — schema-per-tenant 何时比 row-level 更优?
|
||||
- WASM 执行的 Fuel 限制如何平衡安全与灵活性?
|
||||
- 热更新期间的请求如何处理 — 连接排空?
|
||||
|
||||
### 方向 B: 业务纵深 — ERP 领域的深度探索
|
||||
|
||||
**CRM 插件的完整度缺口:**
|
||||
- 商机/销售漏斗 — 从线索到成单的全链路
|
||||
- 合同管理 — 模板、电子签章、履约跟踪
|
||||
- 报价单 — 产品目录、价格策略、审批流
|
||||
- 客户画像 — 标签体系、行为追踪、智能推荐
|
||||
|
||||
**下一个行业插件应该是什么?**
|
||||
- 财务 (总账/应收/应付/固定资产)
|
||||
- 采购 (供应商/询价/采购订单/入库)
|
||||
- 制造 (BOM/工单/排产/质检)
|
||||
- 人力 (员工/考勤/薪资/绩效)
|
||||
- 电商 (商品/订单/物流/售后)
|
||||
|
||||
**跨模块业务流程:**
|
||||
- 从销售订单 → 采购 → 入库 → 付款 的端到端流程
|
||||
- 插件间的数据如何流转?订单确认触发采购申请?
|
||||
- 工作流引擎如何编排跨插件流程?
|
||||
|
||||
### 方向 C: 体验纵深 — 前端与用户交互
|
||||
|
||||
**低代码/零代码的可能性:**
|
||||
- 插件的前端页面能否完全由 schema 驱动生成?
|
||||
- 可视化表单设计器 — 拖拽生成插件页面
|
||||
- 自定义 Dashboard — 用户拼装自己的工作台
|
||||
- 报表引擎 — 从数据到图表的可视化配置
|
||||
|
||||
**移动端/多端体验:**
|
||||
- PWA 方案 — 离线能力 + 推送通知
|
||||
- Tauri 桌面端何时启动?哪些场景需要桌面端?
|
||||
- 小程序/企业微信集成 — 中国市场的刚需?
|
||||
|
||||
**AI 增强交互:**
|
||||
- 自然语言查询 — "帮我查上个月销售额最高的 10 个客户"
|
||||
- 智能推荐 — 基于操作习惯的快捷入口
|
||||
- 数据洞察 — 自动发现异常趋势并提醒
|
||||
- AI 辅助填单 — 自动补全/智能校验
|
||||
|
||||
### 方向 D: 商业纵深 — SaaS 化与商业化
|
||||
|
||||
**多租户高级能力:**
|
||||
- 租户级别的功能开关 — 不同套餐解锁不同插件
|
||||
- 计量计费 — 按用户数/存储/API调用量计费
|
||||
- 租户数据导出/迁移 — 保障数据主权
|
||||
- 白标/品牌定制 — 租户自定义 Logo/主题
|
||||
|
||||
**开放平台战略:**
|
||||
- API Gateway + 开发者门户
|
||||
- Webhook 系统 — 外部系统集成
|
||||
- 第三方插件审核/上架流程
|
||||
- 合作伙伴生态 — ISV 开发行业插件
|
||||
|
||||
### 方向 E: 团队与工程效率
|
||||
|
||||
**开发体验提升:**
|
||||
- 插件开发脚手架 CLI — `erp-plugin create crm`
|
||||
- 本地开发热重载 — 改 WASM 代码即时生效
|
||||
- 插件调试工具 — 断点/日志/性能分析
|
||||
- 一键生成插件 CRUD — 从 schema 到完整页面
|
||||
|
||||
**DevOps 与运维:**
|
||||
- 蓝绿部署 / 金丝雀发布策略
|
||||
- 数据库迁移的零停机方案
|
||||
- 多环境管理 (dev/staging/prod)
|
||||
- 监控告警体系 (APM + 日志聚合)
|
||||
|
||||
---
|
||||
|
||||
## 讨论记录
|
||||
|
||||
> 以下是互动讨论的要点,按时间顺序记录
|
||||
|
||||
### Round 1: "造一个财务插件来验证平台" — 立刻暴露了跨插件数据引用的缺失
|
||||
|
||||
**用户意图:** 希望通过搭建第二个行业插件(财务/应收),验证基座和插件系统,特别是与 CRM 插件的数据交互。
|
||||
|
||||
**已发现的系统缺陷 — 跨插件数据引用完全不支持:**
|
||||
|
||||
| 能力 | 现状 | 影响 |
|
||||
|------|------|------|
|
||||
| `ref_entity` 跨插件引用 | 仅限当前插件表空间 | 财务插件的 `customer_id` 无法声明指向 CRM 的 customer |
|
||||
| Host API 跨插件查询 | `db-query` 无 plugin_id 参数 | WASM 插件无法查询其他插件数据 |
|
||||
| PluginRelation 跨插件 | `entity` 字段无插件限定 | 无法声明跨插件的关联关系 |
|
||||
| 前端 entity_select | 仅加载当前插件数据源 | 下拉框无法显示其他插件的实体列表 |
|
||||
| 引用完整性校验 | 仅校验当前插件表空间 | 跨插件的外键约束无法生效 |
|
||||
|
||||
**进销存插件已有的"绕路":** `customer_id` 作为裸 UUID 存在,没有 `ref_entity` 声明 — 证明这是一个已知的痛点。
|
||||
|
||||
**唯一现有机制:** EventBus 事件广播(松耦合通知),但无法支持同步查询或声明式引用。
|
||||
|
||||
**财务插件与 CRM 的理想交互场景:**
|
||||
```
|
||||
CRM.customer ──引用──→ Finance.invoice.customer_id (外键 + 下拉选择)
|
||||
CRM.opportunity ──引用──→ Finance.sales_order.opportunity_id
|
||||
CRM.contact ──引用──→ Finance.quote.contact_id
|
||||
```
|
||||
|
||||
**要实现这些,需要改造:**
|
||||
1. `manifest.rs` — PluginField/PluginRelation 增加 `ref_plugin` 字段
|
||||
2. `data_service.rs` — validate_ref_entities 支持跨插件表名解析
|
||||
3. `plugin.wit` + `host.rs` — 新增跨插件查询 API
|
||||
4. `dynamic_table.rs` — 表名解析支持目标 plugin_id
|
||||
5. 前端 entity_select — 支持加载其他插件数据源
|
||||
6. 权限模型 — 跨插件数据访问控制
|
||||
|
||||
### Round 2: 方案收敛 — 软引用 + 实体注册表 + 优雅降级
|
||||
|
||||
**决策记录:**
|
||||
|
||||
| 问题 | 决策 | 理由 |
|
||||
|------|------|------|
|
||||
| 引用模式 | **声明式** (plugin.toml) | 与现有 schema-driven 模式一致,插件作者零代码 |
|
||||
| 依赖严格度 | **完全独立,无硬依赖** | SaaS 用户必须能自由组合/卸载插件 |
|
||||
| 实体归属 | **插件自拥有,平台注册表发现** | 不改变现有模型,通过注册表实现运行时发现 |
|
||||
| 悬空引用 | **软警告 + 后台对账** | 永不阻塞用户操作,对账工具引导修复 |
|
||||
|
||||
**架构设计:**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ Layer 3: Plugin (财务/采购/制造...) │
|
||||
│ - optional_dependencies 声明 │
|
||||
│ - ref_scope = "external" 跨插件引用字段 │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ Layer 2: Entity Registry (平台实体注册表) │
|
||||
│ - 插件安装时注册实体、卸载时标记 inactive │
|
||||
│ - 查询时动态发现源插件 │
|
||||
│ - 悬空引用检测 + 对账报告 │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ Layer 1: Plugin System (现有基础设施) │
|
||||
│ - 动态表、Host API、EventBus 不变 │
|
||||
│ - 新增 Entity Registry 接入点 │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**plugin.toml 声明示例:**
|
||||
```toml
|
||||
[dependencies.crm]
|
||||
optional = true
|
||||
description = "客户管理 — 自动关联客户数据"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
ref_entity = "customer"
|
||||
ref_scope = "external"
|
||||
ref_display_field = "name"
|
||||
ref_fallback_label = "外部客户"
|
||||
```
|
||||
|
||||
**运行时行为:**
|
||||
|
||||
| 源插件状态 | 写入 | 读取 | 展示 |
|
||||
|-----------|------|------|------|
|
||||
| 已安装 | 强校验 | JOIN 富化 | ✅ 绿色链接 "张三" |
|
||||
| 未安装 | 无校验 | 原始 UUID | ⬜ 灰色 "外部客户" |
|
||||
| 刚重新启用 | 新写入强校验 | 后台对账 | ⚠️ 黄色警告 (悬空) |
|
||||
|
||||
**悬空引用处理 (CRM 重新启用时):**
|
||||
1. 后台扫描所有 `ref_scope=external` 的字段
|
||||
2. 生成引用对账报告(有效/悬空分类)
|
||||
3. 前端提示用户逐条处理(映射/清空/忽略)
|
||||
4. 永不硬阻塞用户操作
|
||||
|
||||
**需改造的 6 个点:**
|
||||
1. `manifest.rs` — 新增 `ref_scope`, `ref_display_field`, `ref_fallback_label`, `dependencies` 段
|
||||
2. `entity_registry` (新模块) — 实体注册/发现/inactive 标记
|
||||
3. `data_service.rs` — validate_ref_entities 支持运行时发现
|
||||
4. `host.rs` + `plugin.wit` — 新增 resolve-ref-entity API
|
||||
5. 前端 `entity_select` — 检测注册表,有源插件加载下拉,无则降级
|
||||
6. 对账工具 — 后台扫描 + 前端对账 UI
|
||||
|
||||
### Round 3: 插件生态与商业化 — 技术优先路径
|
||||
|
||||
**用户选择:** 技术优先 → 市场,先做好平台能力再考虑商业模式。
|
||||
|
||||
**发现的三大技术缺口:**
|
||||
|
||||
1. **插件质量保障** — 安全扫描、性能基准、兼容性检测、运行时监控全部缺失
|
||||
2. **插件配置与数据管理** — 导入导出、打印模板、配置 UI、自定义视图全部缺失
|
||||
3. **插件市场/商店** — 浏览、发现、一键安装、评分全部缺失
|
||||
|
||||
**决策: 这些能力应该是平台级通用服务,不是插件各自实现。**
|
||||
|
||||
新增架构层:
|
||||
```
|
||||
插件 → Plugin Platform Services → Plugin System → ERP Core
|
||||
↑
|
||||
导入导出 / 打印 / 配置 / 视图 / 通知 / 编号
|
||||
```
|
||||
|
||||
**平台 P1 通用服务清单:**
|
||||
|
||||
| 服务 | 接入方式 | 财务插件示例 |
|
||||
|------|---------|-------------|
|
||||
| 数据导入导出 | plugin.toml 声明 importable/exportable | 导入客户清单、导出发票明细 |
|
||||
| 打印模板 | 模板文件 + schema 映射 | 发票 PDF、收款凭证 |
|
||||
| 插件配置 UI | plugin.toml 声明 settings | 税率表、付款条件、发票前缀 |
|
||||
| 自定义视图 | 用户保存列/筛选配置 | 财务看不同列、销售看不同列 |
|
||||
| 通知规则 | 插件定义触发事件 | 发票逾期 → 通知负责人 |
|
||||
| 编号规则 | 复用 erp-config 的编号服务 | INV-2026-0001 |
|
||||
|
||||
### Round 4: 收敛 — 全部整合为一份设计规格
|
||||
|
||||
用户确认将所有讨论成果写入一份"插件平台演进设计规格"文档。
|
||||
|
||||
---
|
||||
|
||||
## 设计规格: ERP 插件平台演进路线图
|
||||
|
||||
> 基于 2026-04-18 发散式探讨的成果,涵盖跨插件引用、平台通用服务、质量保障、插件市场四个维度。
|
||||
|
||||
### 1. 背景与动机
|
||||
|
||||
ERP 平台已完成 Phase 1-6 核心开发和 Q2-Q4 成熟度路线图。当前有两个行业插件(CRM + 进销存)运行在 WASM 插件系统上。但通过分析发现:
|
||||
|
||||
- **跨插件数据引用完全不支持** — 进销存的 `customer_id` 只能存裸 UUID
|
||||
- **插件无通用业务能力** — 导入导出/打印/配置/视图每个插件都要自己实现
|
||||
- **无质量保障机制** — 第三方插件的安全性和性能无法保证
|
||||
- **无发现和分发渠道** — 用户无法自助发现和安装插件
|
||||
|
||||
目标:通过搭建财务/应收插件来验证和推动这些平台能力的实现。
|
||||
|
||||
### 2. 跨插件数据引用系统
|
||||
|
||||
#### 2.1 设计原则
|
||||
|
||||
- **插件完全独立** — 任何插件可独立安装/卸载,不受其他插件影响
|
||||
- **声明式配置** — 跨插件引用通过 plugin.toml 声明,插件作者零代码
|
||||
- **优雅降级** — 源插件不存在时功能降级,不阻塞用户操作
|
||||
- **软警告** — 外部引用问题永远是警告,不是错误
|
||||
|
||||
#### 2.2 实体注册表 (Entity Registry)
|
||||
|
||||
**数据结构:**
|
||||
```
|
||||
entity_registry:
|
||||
- entity_name: string # 实体名 (如 "customer")
|
||||
- plugin_id: string # 注册该实体的插件 ID
|
||||
- display_fields: string[] # 用于下拉显示的字段列表
|
||||
- search_fields: string[] # 用于搜索的字段列表
|
||||
- status: active | inactive # 插件卸载时标记 inactive
|
||||
- registered_at: timestamp
|
||||
- tenant_id: uuid # 多租户隔离
|
||||
```
|
||||
|
||||
**生命周期:**
|
||||
- 插件安装 → 注册所有 entities 到 registry
|
||||
- 插件启用 → status = active
|
||||
- 插件禁用 → status = inactive(数据保留)
|
||||
- 插件卸载 → status = inactive + 标记为 orphaned
|
||||
|
||||
#### 2.3 plugin.toml 扩展
|
||||
|
||||
```toml
|
||||
# 可选依赖声明
|
||||
[dependencies.crm]
|
||||
optional = true
|
||||
description = "客户管理 — 自动关联客户数据,未安装时客户字段为手动输入"
|
||||
|
||||
[dependencies.inventory]
|
||||
optional = true
|
||||
description = "进销存 — 自动关联商品数据"
|
||||
|
||||
# 跨插件引用字段
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
display_name = "客户"
|
||||
ref_entity = "customer" # 目标实体名
|
||||
ref_scope = "external" # "internal" (默认) | "external"
|
||||
ref_display_field = "name" # 下拉框显示字段
|
||||
ref_search_fields = ["name", "phone"] # 搜索字段
|
||||
ref_fallback_label = "外部客户" # 降级时显示文本
|
||||
```
|
||||
|
||||
#### 2.4 运行时行为
|
||||
|
||||
**写入时校验:**
|
||||
```
|
||||
IF ref_scope == "external":
|
||||
registry = EntityRegistry.find("customer")
|
||||
IF registry.status == "active":
|
||||
强校验: customer_id 必须存在于 registry.plugin_id 的对应表中
|
||||
ELSE:
|
||||
无校验: 接受任意 UUID
|
||||
```
|
||||
|
||||
**读取时富化:**
|
||||
```
|
||||
IF ref_scope == "external" AND registry.status == "active":
|
||||
JOIN plugin_{registry.plugin_id}_{ref_entity} 获取 display_field
|
||||
前端显示: "张三 (CRM)" (绿色可点击链接)
|
||||
ELIF ref_scope == "external" AND registry.status == "inactive":
|
||||
前端显示: "外部客户 ({uuid})" (灰色)
|
||||
```
|
||||
|
||||
**悬空引用处理:**
|
||||
```
|
||||
ON plugin.activate:
|
||||
1. 后台扫描所有 ref_scope="external" 且指向本插件实体的字段
|
||||
2. 验证每个 UUID 是否存在于本插件表中
|
||||
3. 生成对账报告: { valid: N, dangling: M, details: [...] }
|
||||
4. 前端展示对账结果,用户逐条处理
|
||||
```
|
||||
|
||||
#### 2.5 需要改造的文件
|
||||
|
||||
| 文件 | 改动 | 复杂度 |
|
||||
|------|------|--------|
|
||||
| `crates/erp-plugin/src/manifest.rs` | 新增 `ref_scope`, `ref_display_field`, `ref_search_fields`, `ref_fallback_label`; 新增 `DependenciesSection` | 低 |
|
||||
| `crates/erp-plugin/src/entity_registry.rs` (新) | 实体注册/发现/inactive 标记/对账 | 中 |
|
||||
| `crates/erp-plugin/src/data_service.rs` | `validate_ref_entities` 支持运行时发现外部引用 | 中 |
|
||||
| `crates/erp-plugin/src/host.rs` | 新增 `resolve_ref_entity` Host API | 中 |
|
||||
| `crates/erp-plugin/wit/plugin.wit` | 新增 `resolve-ref-entity` 接口 | 低 |
|
||||
| `crates/erp-plugin/src/service.rs` | 插件安装/卸载时维护 Entity Registry | 中 |
|
||||
| `apps/web/src/` 前端 | entity_select 组件支持跨插件数据源 + 降级显示 + 对账 UI | 高 |
|
||||
|
||||
### 3. 插件平台通用服务层 (P1)
|
||||
|
||||
#### 3.1 数据导入导出服务
|
||||
|
||||
**设计思路:** 插件在 plugin.toml 中声明哪些实体支持导入导出,平台提供统一的导入导出 UI 和引擎。
|
||||
|
||||
```toml
|
||||
# plugin.toml 中的声明
|
||||
[[schema.entities]]
|
||||
name = "invoice"
|
||||
display_name = "发票"
|
||||
importable = true
|
||||
exportable = true
|
||||
import_template = "invoice_import_template.xlsx" # 可选: 自定义导入模板
|
||||
|
||||
[[schema.entities]]
|
||||
name = "payment"
|
||||
display_name = "收款"
|
||||
importable = true
|
||||
exportable = true
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- 自动生成导入模板(基于 schema entities fields)
|
||||
- Excel/CSV 解析 + schema 字段校验
|
||||
- 批量写入(支持事务 + 错误行级报告)
|
||||
- 导出为 Excel/CSV(支持筛选条件)
|
||||
- 导入历史记录 + 回滚
|
||||
|
||||
**实现位置:** 新增 `crates/erp-plugin/src/import_export.rs`,前端新增 `ImportExportModal` 通用组件。
|
||||
|
||||
#### 3.2 打印模板引擎
|
||||
|
||||
**设计思路:** 平台提供 HTML → PDF 的模板渲染能力,插件定义模板和字段映射。
|
||||
|
||||
```toml
|
||||
# plugin.toml 中的声明
|
||||
[[templates]]
|
||||
name = "invoice_pdf"
|
||||
display_name = "发票"
|
||||
entity = "invoice"
|
||||
format = "pdf"
|
||||
template_file = "templates/invoice.html" # HTML 模板
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- HTML 模板渲染 → PDF 下载
|
||||
- 模板变量替换(基于实体字段)
|
||||
- 租户级模板自定义(覆盖默认模板)
|
||||
- 打印预览
|
||||
|
||||
**实现位置:** 后端使用 `wkhtmltopdf` 或 `headless-chrome` 渲染,前端新增 `PrintPreviewModal` 组件。
|
||||
|
||||
#### 3.3 插件配置 UI
|
||||
|
||||
**设计思路:** 插件在 plugin.toml 中声明配置项,平台自动生成配置页面。
|
||||
|
||||
```toml
|
||||
# plugin.toml 中的声明
|
||||
[settings]
|
||||
[[settings.fields]]
|
||||
name = "default_tax_rate"
|
||||
display_name = "默认税率"
|
||||
field_type = "number"
|
||||
default_value = 0.13
|
||||
|
||||
[[settings.fields]]
|
||||
name = "invoice_prefix"
|
||||
display_name = "发票前缀"
|
||||
field_type = "text"
|
||||
default_value = "INV"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "payment_terms"
|
||||
display_name = "默认付款条件"
|
||||
field_type = "select"
|
||||
options = ["net_15", "net_30", "net_60", "cod"]
|
||||
default_value = "net_30"
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- 根据 settings 声明自动生成配置表单
|
||||
- 配置数据存储在 `plugin_settings` 表(tenant_id + plugin_id + key/value)
|
||||
- 配置变更时通知插件(通过事件)
|
||||
- 支持配置权限控制(仅管理员可改)
|
||||
|
||||
#### 3.4 自定义视图
|
||||
|
||||
**设计思路:** 用户可以保存列表页的列配置和筛选条件。
|
||||
|
||||
```
|
||||
user_views:
|
||||
- id: uuid
|
||||
- user_id: uuid
|
||||
- plugin_id: string
|
||||
- entity_name: string
|
||||
- view_name: string
|
||||
- columns: string[] # 显示的列
|
||||
- filters: json # 筛选条件
|
||||
- sort: json # 排序条件
|
||||
- is_default: boolean
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- 列表页支持列拖拽排序、显示/隐藏
|
||||
- 筛选条件保存/加载
|
||||
- 每个用户可以有多个视图
|
||||
- 支持共享视图给同角色用户
|
||||
|
||||
#### 3.5 通知规则
|
||||
|
||||
**设计思路:** 插件在 plugin.toml 中声明可触发的事件,平台提供通知规则配置 UI。
|
||||
|
||||
```toml
|
||||
# plugin.toml 中的声明
|
||||
[[trigger_events]]
|
||||
name = "invoice.overdue"
|
||||
display_name = "发票逾期"
|
||||
description = "发票超过付款期限未收款"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "payment.received"
|
||||
display_name = "收款确认"
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- 规则引擎: WHEN event THEN notify [user/role/department]
|
||||
- 复用 erp-message 的通知渠道
|
||||
- 租户级规则配置
|
||||
- 通知模板自定义
|
||||
|
||||
#### 3.6 编号规则 (已有基础扩展)
|
||||
|
||||
**设计思路:** 复用 erp-config 的编号规则服务,扩展为插件可接入。
|
||||
|
||||
```toml
|
||||
# plugin.toml 中的声明
|
||||
[[numbering]]
|
||||
entity = "invoice"
|
||||
prefix = "INV"
|
||||
format = "{PREFIX}-{YEAR}-{SEQ:4}"
|
||||
reset_rule = "yearly" # daily/monthly/yearly/never
|
||||
```
|
||||
|
||||
### 4. 插件质量保障
|
||||
|
||||
#### 4.1 上传时校验
|
||||
|
||||
```
|
||||
插件上传 → Schema 校验 → WASM 二进制验证 → 安全扫描 → 性能基准 → 发布/拒绝
|
||||
```
|
||||
|
||||
| 阶段 | 校验内容 | 现状 |
|
||||
|------|---------|------|
|
||||
| Schema 校验 | plugin.toml 格式、字段类型、权限码一致性 | ✅ 已有部分 |
|
||||
| WASM 验证 | 二进制格式、WIT 兼容性、导出函数检查 | ✅ 已有 |
|
||||
| 安全扫描 | 动态表 SQL 注入风险、Fuel 耗尽、内存泄漏 | ❌ 缺失 |
|
||||
| 性能基准 | 标准 CRUD 操作在 N 条数据下的响应时间 | ❌ 缺失 |
|
||||
| 兼容性 | 平台版本匹配、依赖插件版本兼容 | ❌ 缺失 |
|
||||
|
||||
#### 4.2 运行时监控
|
||||
|
||||
```
|
||||
plugin_runtime_metrics:
|
||||
- plugin_id: string
|
||||
- error_rate: float # 24h 错误率
|
||||
- avg_response_ms: float # 平均响应时间
|
||||
- fuel_consumption: float # 平均 Fuel 消耗
|
||||
- memory_peak_mb: float # 内存峰值
|
||||
- active_instances: int # 活跃实例数
|
||||
```
|
||||
|
||||
**告警规则:**
|
||||
- 错误率 > 5% → 警告
|
||||
- 平均响应 > 2s → 警告
|
||||
- Fuel 消耗异常 → 警告
|
||||
- 内存持续增长 → 疑似泄漏
|
||||
|
||||
### 5. 插件市场/商店
|
||||
|
||||
#### 5.1 功能范围
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 插件目录 | 按行业/功能分类浏览 |
|
||||
| 搜索 | 按名称/标签/行业搜索 |
|
||||
| 详情页 | 截图、演示、功能描述、权限说明 |
|
||||
| 一键安装 | 上传 → 自动安装 → 配置 → 启用 |
|
||||
| 评分/评论 | 用户评分和使用反馈 |
|
||||
| 版本管理 | 版本列表、更新日志、回滚 |
|
||||
| 依赖提示 | 安装时提示可选依赖("推荐配合 CRM 使用") |
|
||||
|
||||
#### 5.2 技术实现
|
||||
|
||||
- 后端: 新增 `plugin_store` 表 + API
|
||||
- 前端: 新增 `PluginStore` 页面
|
||||
- 管理端: 管理员审核/上架/下架
|
||||
|
||||
### 6. 验证计划 — 财务/应收插件
|
||||
|
||||
#### 6.1 实体设计
|
||||
|
||||
| 实体 | 字段概要 | 跨插件引用 |
|
||||
|------|---------|-----------|
|
||||
| invoice (发票) | 编号/客户/金额/税额/状态/到期日 | customer_id → CRM.customer |
|
||||
| invoice_line (发票行) | 发票/商品/数量/单价/税额 | product_id → Inventory.product |
|
||||
| payment (收款) | 发票/金额/方式/日期/状态 | invoice_id → 本插件内部 |
|
||||
| quote (报价单) | 编号/客户/有效期/状态 | customer_id → CRM.customer |
|
||||
| quote_line (报价行) | 报价单/商品/数量/单价 | product_id → Inventory.product |
|
||||
|
||||
#### 6.2 验证矩阵
|
||||
|
||||
| 能力 | 验证方式 | 预期结果 |
|
||||
|------|---------|---------|
|
||||
| 跨插件引用 (CRM 安装) | 创建发票时选择客户 | entity_select 下拉显示 CRM 客户列表 |
|
||||
| 跨插件引用 (CRM 卸载) | 创建发票时输入客户 | 降级为文本输入,不阻塞 |
|
||||
| 悬空引用对账 | CRM 卸载→创建发票→重新安装 CRM | 对账报告显示悬空引用,用户可修复 |
|
||||
| 数据导入 | 导入 Excel 客户清单 | 解析+校验+批量写入 |
|
||||
| 数据导出 | 导出发票列表为 Excel | 筛选+下载 |
|
||||
| 打印模板 | 打印发票 PDF | HTML→PDF 渲染 |
|
||||
| 插件配置 | 设置税率/发票前缀 | 自动生成的配置页面 |
|
||||
| 编号规则 | 创建发票自动编号 | INV-2026-0001 |
|
||||
| 通知规则 | 发票逾期通知 | 规则引擎触发通知 |
|
||||
| 独立安装 | 不安装 CRM 单独安装财务 | 所有功能正常,客户字段降级 |
|
||||
|
||||
### 7. 实施优先级
|
||||
|
||||
```
|
||||
P0 (已完成/进行中): P0 平台能力升级 (实体关系增强/字段校验/前端去硬编码)
|
||||
插件系统增强 (混合执行模型/聚合查询/热更新回滚/Schema演进)
|
||||
|
||||
P1 (跨插件引用): Entity Registry + ref_scope 扩展 + 前端 entity_select 改造
|
||||
这是所有后续能力的基础
|
||||
|
||||
P2 (平台通用服务): 数据导入导出 → 插件配置 UI → 编号规则扩展 → 通知规则
|
||||
按业务迫切程度排序
|
||||
|
||||
P3 (质量保障): 上传时安全扫描 → 性能基准 → 运行时监控
|
||||
逐步建立信任体系
|
||||
|
||||
P4 (插件市场): 插件目录 → 一键安装 → 版本管理 → 评分评论
|
||||
商业化的最后一块拼图
|
||||
|
||||
验证: 财务/应收插件贯穿 P1-P2,每完成一个 P 就用财务插件验证
|
||||
```
|
||||
|
||||
### 8. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| Entity Registry 查询性能 | 每次数据操作都要查注册表 | 内存缓存 + DashMap,注册表数据量极小 |
|
||||
| 悬空引用数据量过大 | 对账扫描耗时长 | 异步后台任务 + 分批处理 + 进度条 |
|
||||
| Excel 导入内存占用 | 大文件解析 OOM | 流式解析 + 批量提交 + 文件大小限制 |
|
||||
| 打印模板安全 | 模板注入攻击 | 沙箱渲染 + 变量白名单 |
|
||||
| 插件市场审核成本 | 人工审核效率低 | 自动化扫描 + 人工抽查 + 社区举报 |
|
||||
@@ -0,0 +1,618 @@
|
||||
# ERP Platform Base - Design Specification
|
||||
|
||||
**Date:** 2026-04-10
|
||||
**Status:** Draft (Review Round 2)
|
||||
**Author:** Claude + User
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Build a commercial SaaS ERP product from scratch using a "platform base + industry plugins" architecture. The base provides core infrastructure (auth, workflow, messaging, configuration), enabling rapid deployment of industry-specific modules (inventory, manufacturing, finance, HR, etc.) on top.
|
||||
|
||||
The system targets progressive scaling: start with small businesses, expand to mid and large enterprises. Multi-tenant SaaS deployment is the default, with private deployment as an option.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Overall: Modular Monolith (Progressive)
|
||||
|
||||
Start as a single Rust backend service with well-defined module boundaries. Modules communicate through an internal event bus and shared traits. When a module needs independent scaling, it can be extracted into a standalone service without changing interfaces.
|
||||
|
||||
### System Layers
|
||||
|
||||
```
|
||||
Web Frontend (Vite + React 18 + Ant Design 5)
|
||||
├── Shell / Layout / Navigation
|
||||
└── Module UI (dynamically loaded per tenant config)
|
||||
│
|
||||
API Layer (REST + WebSocket)
|
||||
│
|
||||
Rust Backend Service (Axum + Tokio)
|
||||
├── Auth Module (identity, roles, permissions, tenants)
|
||||
├── Workflow Engine (BPMN processes, tasks, approvals)
|
||||
├── Message Center (notifications, templates, channels)
|
||||
└── Config Module (menus, dictionaries, settings, numbering)
|
||||
│
|
||||
Core Shared Layer (tenant context, audit, events, caching)
|
||||
│
|
||||
PostgreSQL (primary) + Redis (cache + session + pub/sub)
|
||||
```
|
||||
|
||||
> **Note:** Tauri 桌面端为可选方案,未来行业模块(如工厂仓库)需要硬件集成时启用。主力前端为 Web SPA。
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Module isolation**: Each business module is an independent Rust crate, interfaces defined via traits
|
||||
2. **Multi-tenant built-in**: All data tables include `tenant_id`, middleware auto-injects tenant context
|
||||
3. **Event-driven**: Modules communicate via event bus, no direct coupling
|
||||
4. **Plugin extensibility**: Industry modules register through standard interfaces, support dynamic enable/disable
|
||||
|
||||
### Error Handling Strategy
|
||||
|
||||
```rust
|
||||
// erp-core defines the unified error hierarchy
|
||||
// Uses thiserror for typed errors across crate boundaries
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
// Axum IntoResponse impl maps to HTTP status codes
|
||||
// Validation errors include field-level detail for UI rendering
|
||||
```
|
||||
|
||||
**Decision**: `thiserror` for crate boundaries (typed, catchable), `anyhow` never crosses crate boundaries (internal use only for prototyping). Each module defines its own error variants that wrap `AppError`.
|
||||
|
||||
### Event Bus Specification
|
||||
|
||||
```
|
||||
EventBus (tokio::sync::broadcast based, in-process)
|
||||
|
||||
Event {
|
||||
id: UUID v7
|
||||
event_type: String (e.g., "user.created", "workflow.task.completed")
|
||||
tenant_id: UUID
|
||||
payload: serde_json::Value
|
||||
timestamp: DateTime<Utc>
|
||||
correlation_id: UUID (for tracing)
|
||||
}
|
||||
|
||||
Delivery Guarantees:
|
||||
- At-least-once delivery within the process
|
||||
- Events persisted to `domain_events` table before dispatch (outbox pattern)
|
||||
- Failed handlers log to dead-letter storage, trigger alert
|
||||
- No cross-process delivery in Phase 1 (single binary)
|
||||
```
|
||||
|
||||
### Plugin / Module Registration Interface
|
||||
|
||||
```rust
|
||||
// erp-core defines the plugin trait
|
||||
pub trait ErpModule: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> &str;
|
||||
fn dependencies(&self) -> Vec<&str>; // required modules
|
||||
|
||||
fn register_routes(&self, router: Router) -> Router;
|
||||
fn register_event_handlers(&self, bus: &EventBus);
|
||||
fn on_tenant_created(&self, tenant_id: Uuid) -> Result<()>;
|
||||
fn on_tenant_deleted(&self, tenant_id: Uuid) -> Result<()>;
|
||||
}
|
||||
|
||||
// erp-server assembles modules at startup
|
||||
fn build_app(modules: Vec<Box<dyn ErpModule>>) -> Router { ... }
|
||||
```
|
||||
|
||||
Industry modules implement `ErpModule` and are discovered via configuration, not compile-time.
|
||||
|
||||
### API Versioning & Contract Governance
|
||||
|
||||
- Code-first with utoipa: derive OpenAPI from Rust types
|
||||
- Auto-generated Swagger UI at `/docs` in development
|
||||
- `/api/v1/` prefix for all endpoints; v2 only when breaking changes needed
|
||||
- Client sends `X-API-Version: 1` header; server rejects unsupported versions
|
||||
- Tauri client version and server version must be compatible (checked on connect)
|
||||
|
||||
### Concurrency & Transaction Strategy
|
||||
|
||||
- **Optimistic locking**: All mutable entities carry `version` column; updates fail on mismatch
|
||||
- **Idempotency**: Write endpoints accept optional `Idempotency-Key` header
|
||||
- **Cross-module transactions**: Avoided by design; event bus + saga pattern for consistency
|
||||
- **Numbering sequences**: PostgreSQL sequences with `advisory_lock` per tenant per rule
|
||||
|
||||
### Audit Logging
|
||||
|
||||
```
|
||||
AuditLog {
|
||||
id: UUID v7
|
||||
tenant_id: UUID
|
||||
user_id: UUID
|
||||
action: String (e.g., "user.update", "role.create")
|
||||
resource_type: String
|
||||
resource_id: UUID
|
||||
changes: JSONB { before: {}, after: {} }
|
||||
ip_address: String
|
||||
user_agent: String
|
||||
timestamp: DateTime<Utc>
|
||||
}
|
||||
|
||||
Retention: 90 days hot, archive to cold storage after
|
||||
Query API: GET /api/v1/audit-logs?resource_type=user&from=...
|
||||
```
|
||||
|
||||
### Frontend-Backend Communication
|
||||
|
||||
| Aspect | Decision |
|
||||
|--------|----------|
|
||||
| Auth flow | Login via REST → JWT stored in httpOnly cookie (web) → sent as Bearer header |
|
||||
| REST calls | Standard fetch/axios from browser to backend |
|
||||
| WebSocket | Connect on page load, auth via first message with JWT, auto-reconnect with exponential backoff |
|
||||
| File upload/download | Standard HTTP multipart + blob download |
|
||||
| CORS | Whitelist per tenant, deny by default |
|
||||
|
||||
### Security Measures
|
||||
|
||||
- CORS: Whitelist per tenant, deny by default
|
||||
- Rate limiting: Per-IP + per-user via Redis token bucket
|
||||
- Secret management: Environment variables + vault (HashiCorp Vault for production)
|
||||
- Data encryption: TLS in transit, AES-256 at rest for PII fields (optional per tenant config)
|
||||
- Input validation: Schema-based (JSON Schema for complex inputs, types for simple)
|
||||
- SQL injection: Prevented by SeaORM parameterized queries
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend (Rust)
|
||||
|
||||
| Component | Choice | Rationale |
|
||||
|-----------|--------|-----------|
|
||||
| Web framework | Axum 0.8 | Tokio-team maintained, best ecosystem |
|
||||
| Async runtime | Tokio | Rust async standard |
|
||||
| ORM | SeaORM | Async, type-safe, migration support |
|
||||
| DB migration | SeaORM Migration | Versioned schema management |
|
||||
| Cache | redis-rs | Official Redis client |
|
||||
| JWT | jsonwebtoken | Lightweight, reliable |
|
||||
| Serialization | serde + serde_json | Rust standard |
|
||||
| Logging | tracing + tracing-subscriber | Structured logging |
|
||||
| Config | config-rs | Multi-format support |
|
||||
| API docs | utoipa (OpenAPI 3) | Auto-generate Swagger |
|
||||
| Testing | Built-in + tokio-test | Unit + integration |
|
||||
|
||||
### Web Frontend
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Build tool | Vite 6 |
|
||||
| UI framework | React 18 + TypeScript |
|
||||
| Component library | Ant Design 5 |
|
||||
| State management | Zustand |
|
||||
| Routing | React Router 7 |
|
||||
| Styling | TailwindCSS + CSS Variables |
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| Primary database | PostgreSQL 16+ |
|
||||
| Cache / Session / PubSub | Redis 7+ |
|
||||
| Containerization | Docker + Docker Compose (dev) |
|
||||
|
||||
---
|
||||
|
||||
## Crate Structure
|
||||
|
||||
```
|
||||
erp/
|
||||
├── crates/
|
||||
│ ├── erp-core/ # Shared: error handling, types, traits, events
|
||||
│ ├── erp-auth/ # Identity & permissions module
|
||||
│ ├── erp-workflow/ # Workflow engine module
|
||||
│ ├── erp-message/ # Message center module
|
||||
│ ├── erp-config/ # System configuration module
|
||||
│ ├── erp-server/ # Axum server entry, assembles all modules
|
||||
│ └── erp-common/ # Shared utilities, macros
|
||||
├── apps/
|
||||
│ └── web/ # Vite + React SPA (primary frontend)
|
||||
├── desktop/ # (Optional) Tauri desktop, enabled per industry need
|
||||
├── packages/
|
||||
│ └── ui-components/ # React shared component library
|
||||
├── migrations/ # Database migrations
|
||||
├── docs/ # Documentation
|
||||
└── docker/ # Docker configurations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module 1: Identity & Permissions (Auth)
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
Tenant
|
||||
├── Organization
|
||||
│ └── Department
|
||||
│ └── Position
|
||||
├── User
|
||||
│ ├── UserCredential (password / OAuth / SSO)
|
||||
│ ├── UserProfile
|
||||
│ └── UserToken (session)
|
||||
├── Role
|
||||
│ └── Permission
|
||||
└── Policy (ABAC rules)
|
||||
```
|
||||
|
||||
### Permission Model: RBAC + ABAC Hybrid
|
||||
|
||||
- **RBAC**: User -> Role -> Permission, for standard scenarios
|
||||
- **ABAC**: Attribute-based rules (e.g., "department manager can only approve own department's requests")
|
||||
- **Data-level**: Row filtering (e.g., "only see own department's data")
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|------------|
|
||||
| Username/Password | Basic auth, Argon2 hash |
|
||||
| OAuth 2.0 | Third-party login (WeChat, DingTalk, WeCom) |
|
||||
| SSO (SAML/OIDC) | Enterprise SSO, required for private deployment |
|
||||
| TOTP | Two-factor authentication |
|
||||
|
||||
### Key APIs
|
||||
|
||||
```
|
||||
POST /api/v1/auth/login
|
||||
POST /api/v1/auth/logout
|
||||
POST /api/v1/auth/refresh
|
||||
POST /api/v1/auth/revoke # Revoke a specific token
|
||||
GET /api/v1/users
|
||||
POST /api/v1/users
|
||||
PUT /api/v1/users/:id
|
||||
DELETE /api/v1/users/:id (soft delete)
|
||||
GET /api/v1/roles
|
||||
POST /api/v1/roles
|
||||
PUT /api/v1/roles/:id
|
||||
DELETE /api/v1/roles/:id
|
||||
POST /api/v1/roles/:id/permissions
|
||||
GET /api/v1/permissions # List all available permissions
|
||||
GET /api/v1/tenants/:id/users
|
||||
GET /api/v1/organizations
|
||||
POST /api/v1/organizations
|
||||
PUT /api/v1/organizations/:id
|
||||
DELETE /api/v1/organizations/:id
|
||||
GET /api/v1/organizations/:id/departments
|
||||
POST /api/v1/organizations/:id/departments
|
||||
GET /api/v1/positions
|
||||
POST /api/v1/positions
|
||||
GET /api/v1/policies
|
||||
POST /api/v1/policies
|
||||
PUT /api/v1/policies/:id
|
||||
DELETE /api/v1/policies/:id
|
||||
```
|
||||
|
||||
### Multi-tenant Isolation
|
||||
|
||||
- **Default**: Shared database + `tenant_id` column isolation (cost-optimal)
|
||||
- **Switchable**: Independent schema per tenant (for private deployment)
|
||||
- Middleware auto-injects `tenant_id`, application code is tenant-agnostic
|
||||
|
||||
### Multi-tenant Migration Strategy
|
||||
|
||||
- Schema migrations run once globally, affect all tenants' rows
|
||||
- New tenant provisioning: seed data script (default roles, admin user, org structure, menus)
|
||||
- Migrations are versioned and idempotent; failed migrations halt startup
|
||||
- Per-tenant data migrations (e.g., adding default config) trigger on `on_tenant_created` hook
|
||||
|
||||
---
|
||||
|
||||
## Module 2: Workflow Engine
|
||||
|
||||
### Design Goals
|
||||
|
||||
- BPMN 2.0 **subset** compatible visual process designer
|
||||
- Low latency, high throughput (Rust advantage)
|
||||
- Support conditional branches, parallel gateways, sub-processes
|
||||
- Embeddable into any business module
|
||||
|
||||
### BPMN Subset Scope (Phase 4)
|
||||
|
||||
**Included in Phase 4:**
|
||||
- Start/End events
|
||||
- User Tasks (with assignee, candidate groups)
|
||||
- Service Tasks (HTTP call, script execution)
|
||||
- Exclusive Gateways (conditional branching)
|
||||
- Parallel Gateways (fork/join)
|
||||
- Sequence Flows with conditions
|
||||
- Process variables (basic types: string, number, boolean, date)
|
||||
|
||||
**Deferred to later phases:**
|
||||
- Inclusive Gateways
|
||||
- Sub-Processes (call activity)
|
||||
- Timer events (intermediate, boundary)
|
||||
- Signal/Message events
|
||||
- Error boundary events
|
||||
- Multi-instance (loop) activities
|
||||
- Data objects and stores
|
||||
|
||||
### Core Concepts
|
||||
|
||||
```
|
||||
ProcessDefinition
|
||||
├── Node Types
|
||||
│ ├── StartNode
|
||||
│ ├── EndNode
|
||||
│ ├── UserTask (human task)
|
||||
│ ├── ServiceTask (system task)
|
||||
│ ├── Gateway (exclusive / parallel / inclusive)
|
||||
│ └── SubProcess
|
||||
├── Flow (connections)
|
||||
│ └── Condition (expressions)
|
||||
└── ProcessInstance
|
||||
├── Token (tracks execution position)
|
||||
├── Task (pending tasks)
|
||||
└── Variable (process variables)
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|------------|
|
||||
| Visual designer | React flowchart editor, drag-and-drop |
|
||||
| Condition expressions | EL expressions: `amount > 10000 && dept == "finance"` |
|
||||
| Countersign / Or-sign | Multi-person approval: all approve / any approve |
|
||||
| Delegate / Transfer | Tasks can be delegated to others |
|
||||
| Reminder / Timeout | Auto-remind, auto-handle on timeout |
|
||||
| Version management | Process definitions versioned, running instances use old version |
|
||||
|
||||
### Key APIs
|
||||
|
||||
```
|
||||
POST /api/v1/workflow/definitions
|
||||
GET /api/v1/workflow/definitions/:id
|
||||
PUT /api/v1/workflow/definitions/:id
|
||||
POST /api/v1/workflow/instances
|
||||
GET /api/v1/workflow/instances/:id
|
||||
GET /api/v1/workflow/tasks (my pending)
|
||||
POST /api/v1/workflow/tasks/:id/approve
|
||||
POST /api/v1/workflow/tasks/:id/reject
|
||||
POST /api/v1/workflow/tasks/:id/delegate
|
||||
GET /api/v1/workflow/instances/:id/diagram (highlighted)
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
- **Auth**: Task assignment based on roles/org structure
|
||||
- **Message**: Pending task notifications, reminders, approval results
|
||||
- **Config**: Process categories, numbering rules
|
||||
|
||||
---
|
||||
|
||||
## Module 3: Message Center
|
||||
|
||||
### Message Channels
|
||||
|
||||
| Channel | Use Case |
|
||||
|---------|----------|
|
||||
| In-app notifications | Foundation for all messages |
|
||||
| WebSocket | Real-time push, instant desktop alerts |
|
||||
| Email | Important approvals, scheduled reports |
|
||||
| SMS | Verification codes, urgent alerts |
|
||||
| WeCom / DingTalk | Enterprise messaging integration |
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
MessageTemplate
|
||||
├── Channel type
|
||||
├── Template content (variable interpolation: {{user_name}})
|
||||
└── Multi-language versions
|
||||
|
||||
Message
|
||||
├── Sender (system / user)
|
||||
├── Recipient (user / role / department / all)
|
||||
├── Priority (normal / important / urgent)
|
||||
├── Read status
|
||||
└── Business reference (deep link to specific page)
|
||||
|
||||
MessageSubscription
|
||||
├── User notification preferences
|
||||
├── Do-not-disturb periods
|
||||
└── Channel preferences (e.g., approvals via in-app + WeCom, reports via email)
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Message aggregation**: Group similar messages (e.g., "You have 5 pending approvals")
|
||||
- **Read/unread**: Read receipts, unread count query
|
||||
- **Message recall**: Sender can recall unread messages
|
||||
- **Scheduled sending**: Set delivery time
|
||||
- **Message archive**: Auto-archive history, searchable
|
||||
|
||||
### Key APIs
|
||||
|
||||
```
|
||||
GET /api/v1/messages (list with pagination)
|
||||
GET /api/v1/messages/unread-count
|
||||
PUT /api/v1/messages/:id/read
|
||||
PUT /api/v1/messages/read-all
|
||||
DELETE /api/v1/messages/:id
|
||||
POST /api/v1/messages/send
|
||||
GET /api/v1/message-templates
|
||||
POST /api/v1/message-templates
|
||||
PUT /api/v1/message-subscriptions (update preferences)
|
||||
WS /ws/v1/messages (real-time push)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module 4: System Configuration
|
||||
|
||||
### Configuration Hierarchy
|
||||
|
||||
```
|
||||
Platform (global)
|
||||
└── Tenant
|
||||
└── Organization
|
||||
└── User
|
||||
```
|
||||
|
||||
Lower-level overrides higher-level. Priority: User > Organization > Tenant > Platform.
|
||||
|
||||
### Capabilities
|
||||
|
||||
| Capability | Description |
|
||||
|-----------|------------|
|
||||
| Dynamic menus | Tenants customize menu structure, display by role |
|
||||
| Data dictionaries | System-level and tenant-level enum management |
|
||||
| Numbering rules | Document number generation with concurrency-safe sequences |
|
||||
| Multi-language | i18n resource management, runtime switching |
|
||||
| System parameters | Key-value general configuration |
|
||||
| Theme customization | Tenant-level UI theme (colors, logo) |
|
||||
|
||||
### Key APIs
|
||||
|
||||
```
|
||||
GET /api/v1/config/menus # Tenant from middleware
|
||||
PUT /api/v1/config/menus
|
||||
GET /api/v1/config/dictionaries
|
||||
POST /api/v1/config/dictionaries
|
||||
PUT /api/v1/config/dictionaries/:id
|
||||
GET /api/v1/config/settings/:key
|
||||
PUT /api/v1/config/settings/:key
|
||||
GET /api/v1/config/numbering-rules
|
||||
POST /api/v1/config/numbering-rules
|
||||
PUT /api/v1/config/numbering-rules/:id
|
||||
GET /api/v1/config/languages
|
||||
PUT /api/v1/config/languages/:code
|
||||
GET /api/v1/config/themes # Tenant theme
|
||||
PUT /api/v1/config/themes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Design Principles
|
||||
|
||||
- All tables include: `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`
|
||||
- Soft delete via `deleted_at` (no hard deletes)
|
||||
- UUID v7 as primary keys (time-sortable + unique)
|
||||
- JSONB columns for flexible extension data
|
||||
- Indexes on `tenant_id` + business keys for multi-tenant queries
|
||||
|
||||
---
|
||||
|
||||
## Web UI Design
|
||||
|
||||
### Layout
|
||||
|
||||
Classic SaaS admin panel layout (responsive, mobile-friendly):
|
||||
|
||||
```
|
||||
+----------------------------------------------+
|
||||
| LOGO Search... 🔔 5 👤 Admin ▾ | ← Top nav bar
|
||||
+--------+-------------------------------------+
|
||||
| Home | |
|
||||
| Users | Main Content Area |
|
||||
| Roles | (Dynamic per menu selection) |
|
||||
| Flows | Multi-tab support |
|
||||
| Messages| |
|
||||
| Settings| |
|
||||
|--------| |
|
||||
| Inv. | |
|
||||
| Mfg. | |
|
||||
| Finance| |
|
||||
|--------| |
|
||||
| More > | |
|
||||
+--------+-------------------------------------+
|
||||
```
|
||||
|
||||
### Key UI Features
|
||||
|
||||
- **Collapsible sidebar**: Multi-level menus, grouped (base modules / industry modules)
|
||||
- **Multi-tab content**: Switch between open pages like browser tabs
|
||||
- **Global search**: Search menus, users, documents
|
||||
- **Notification panel**: Click bell icon to expand message list
|
||||
- **Dark/Light theme**: Toggle support, follow system preference
|
||||
- **Responsive**: Mobile/tablet adaptive layout
|
||||
- **Browser notifications**: Web Notification API for real-time alerts
|
||||
|
||||
---
|
||||
|
||||
## Development Roadmap
|
||||
|
||||
### Phase 1 - Foundation (2-3 weeks)
|
||||
- Rust workspace scaffolding + Vite + React setup
|
||||
- erp-core: error types, shared types, trait definitions, event bus
|
||||
- ErpModule trait + module registration system
|
||||
- Database migration framework (SeaORM) with tenant provisioning
|
||||
- Docker dev environment (PostgreSQL + Redis)
|
||||
- CI/CD pipeline setup
|
||||
|
||||
### Phase 2 - Identity & Permissions (2-3 weeks)
|
||||
- User, Role, Organization, Department, Position CRUD
|
||||
- RBAC + ABAC permission model
|
||||
- JWT auth (access + refresh tokens, token revocation)
|
||||
- httpOnly cookie for web JWT storage
|
||||
- Multi-tenant middleware
|
||||
- Login page UI + user management pages
|
||||
|
||||
### Phase 3 - System Configuration (1-2 weeks)
|
||||
- Data dictionaries
|
||||
- Dynamic menus
|
||||
- System parameters (hierarchical override)
|
||||
- Numbering rules (concurrency-safe PostgreSQL sequences)
|
||||
- i18n framework
|
||||
- Settings pages UI
|
||||
|
||||
### Phase 4 - Workflow Engine (4-6 weeks)
|
||||
- Process definition storage and versioning
|
||||
- BPMN subset parser (start/end, user/service tasks, exclusive/parallel gateways)
|
||||
- Execution engine with token tracking
|
||||
- Task assignment, countersign, delegation
|
||||
- Condition expression evaluator
|
||||
- React visual flowchart designer
|
||||
- Process diagram viewer (highlighted current node)
|
||||
- Reminder and timeout handling
|
||||
|
||||
### Phase 5 - Message Center (2 weeks)
|
||||
- Message templates with variable interpolation
|
||||
- In-app notification CRUD
|
||||
- WebSocket real-time push (auth, reconnect)
|
||||
- Notification panel UI
|
||||
- Message aggregation and read tracking
|
||||
|
||||
### Phase 6 - Integration & Polish (2-3 weeks)
|
||||
- Cross-module integration testing
|
||||
- Audit logging verification
|
||||
- Web app deployment and optimization
|
||||
- Performance optimization
|
||||
- Documentation
|
||||
|
||||
---
|
||||
|
||||
## Verification Plan
|
||||
|
||||
1. **Unit tests**: Each module has comprehensive unit tests (80%+ coverage target)
|
||||
2. **Integration tests**: API endpoint tests against real PostgreSQL/Redis
|
||||
3. **E2E tests**: Desktop client test automation via Tauri WebDriver
|
||||
4. **Multi-tenant tests**: Verify data isolation between tenants
|
||||
5. **Workflow tests**: Full process lifecycle (define -> start -> approve -> complete)
|
||||
6. **Performance benchmarks**: API response time < 100ms (p99), WebSocket push < 50ms
|
||||
7. **Security audit**: OWASP top 10 check before release
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,871 @@
|
||||
# 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
@@ -0,0 +1,706 @@
|
||||
# Q4 测试覆盖 + 插件生态 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 建立 Testcontainers 集成测试框架覆盖核心模块;Playwright E2E 覆盖关键用户旅程;开发进销存插件验证插件系统扩展性;实现插件热更新能力。
|
||||
|
||||
**Architecture:** Testcontainers 启动真实 PostgreSQL 容器运行迁移后执行集成测试;Playwright 驱动浏览器完成端到端验证;进销存插件复用 CRM 插件的 manifest + dynamic_table 模式;热更新通过版本对比 + 增量 DDL + 两阶段提交实现。
|
||||
|
||||
**Tech Stack:** Rust (testcontainers, testcontainers-modules), Playwright, WASM (wit-bindgen), SeaORM Migration
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-17-platform-maturity-roadmap-design.md` §4
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Create | `crates/erp-server/tests/integration/mod.rs` | 集成测试入口 |
|
||||
| Create | `crates/erp-server/tests/integration/test_db.rs` | Testcontainers 测试基座 |
|
||||
| Create | `crates/erp-server/tests/integration/auth_tests.rs` | Auth 模块集成测试 |
|
||||
| Create | `crates/erp-server/tests/integration/plugin_tests.rs` | Plugin 模块集成测试 |
|
||||
| Create | `crates/erp-server/tests/integration/workflow_tests.rs` | Workflow 模块集成测试 |
|
||||
| Create | `crates/erp-server/tests/integration/event_tests.rs` | EventBus 端到端测试 |
|
||||
| Create | `apps/web/e2e/login.spec.ts` | 登录流程 E2E |
|
||||
| Create | `apps/web/e2e/users.spec.ts` | 用户管理 E2E |
|
||||
| Create | `apps/web/e2e/plugins.spec.ts` | 插件安装 E2E |
|
||||
| Create | `apps/web/e2e/tenant-isolation.spec.ts` | 多租户隔离 E2E |
|
||||
| Create | `apps/web/playwright.config.ts` | Playwright 配置 |
|
||||
| Create | `crates/erp-plugin-inventory/` | 进销存插件 crate |
|
||||
| Modify | `Cargo.toml` | workspace 添加新 crate |
|
||||
| Modify | `crates/erp-plugin/src/engine.rs` | 热更新 upgrade 端点支持 |
|
||||
| Modify | `crates/erp-plugin/src/service.rs` | upgrade 生命周期 |
|
||||
| Modify | `crates/erp-plugin/src/handler/plugin_handler.rs` | upgrade 路由 |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: 集成测试框架
|
||||
|
||||
### Task 1: 添加 Testcontainers 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-server/Cargo.toml`(dev-dependencies)
|
||||
|
||||
- [ ] **Step 1: 在 `erp-server` 的 `[dev-dependencies]` 中添加**
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
testcontainers = "0.23"
|
||||
testcontainers-modules = { version = "0.11", features = ["postgres"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
```
|
||||
|
||||
注意:版本号需与 workspace 已有依赖兼容。如果 workspace 已有 `testcontainers`,使用 workspace 引用。
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-server
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/Cargo.toml Cargo.lock
|
||||
git commit -m "chore(server): 添加 testcontainers 开发依赖"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 创建测试基座
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/tests/integration/mod.rs`
|
||||
- Create: `crates/erp-server/tests/integration/test_db.rs`
|
||||
|
||||
- [ ] **Step 1: 创建测试模块入口**
|
||||
|
||||
```rust
|
||||
// crates/erp-server/tests/integration/mod.rs
|
||||
mod test_db;
|
||||
mod auth_tests;
|
||||
mod plugin_tests;
|
||||
```
|
||||
|
||||
注意:需要确保 `erp-server` 的 `Cargo.toml` 中有 `[[test]]` 配置或集成测试自动发现。
|
||||
|
||||
- [ ] **Step 2: 创建 Testcontainers 测试基座**
|
||||
|
||||
```rust
|
||||
// crates/erp-server/tests/integration/test_db.rs
|
||||
use testcontainers_modules::postgres::Postgres;
|
||||
use testcontainers::runners::AsyncRunner;
|
||||
use sea_orm::{Database, DatabaseConnection};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 测试数据库容器 — 使用 once_cell 确保每进程一个容器
|
||||
pub struct TestDb {
|
||||
pub db: DatabaseConnection,
|
||||
pub container: testcontainers::ContainerAsync<Postgres>,
|
||||
}
|
||||
|
||||
impl TestDb {
|
||||
pub async fn new() -> Self {
|
||||
let postgres = Postgres::default()
|
||||
.with_db_name("erp_test")
|
||||
.with_user("test")
|
||||
.with_password("test");
|
||||
|
||||
let container = postgres.start().await
|
||||
.expect("Failed to start PostgreSQL container");
|
||||
|
||||
let host_port = container.get_host_port_ipv4(5432).await
|
||||
.expect("Failed to get port");
|
||||
|
||||
let url = format!("postgres://test:test@localhost:{}/erp_test", host_port);
|
||||
let db = Database::connect(&url).await
|
||||
.expect("Failed to connect to test database");
|
||||
|
||||
// 运行所有迁移
|
||||
run_migrations(&db).await;
|
||||
|
||||
Self { db, container }
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_migrations(db: &DatabaseConnection) {
|
||||
use migration::{Migrator, MigratorTrait};
|
||||
Migrator::up(db, None).await.expect("Failed to run migrations");
|
||||
}
|
||||
```
|
||||
|
||||
注意:需要确保 `migration` crate 可被测试引用。可能需要调整 `Cargo.toml` 的依赖。
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-server
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/tests/
|
||||
git commit -m "test(server): 创建 Testcontainers 集成测试基座"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Auth 模块集成测试
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/tests/integration/auth_tests.rs`
|
||||
|
||||
- [ ] **Step 1: 编写用户 CRUD 测试**
|
||||
|
||||
```rust
|
||||
// crates/erp-server/tests/integration/auth_tests.rs
|
||||
use super::test_db::TestDb;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_crud() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = &test_db.db;
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
|
||||
// 创建用户
|
||||
let user = erp_auth::service::UserService::create(
|
||||
tenant_id,
|
||||
uuid::Uuid::new_v4(),
|
||||
erp_auth::dto::CreateUserReq {
|
||||
username: "testuser".to_string(),
|
||||
password: "TestPass123".to_string(),
|
||||
email: Some("test@example.com".to_string()),
|
||||
phone: None,
|
||||
display_name: Some("测试用户".to_string()),
|
||||
},
|
||||
db,
|
||||
&erp_core::events::EventBus::new(100),
|
||||
).await.expect("Failed to create user");
|
||||
|
||||
assert_eq!(user.username, "testuser");
|
||||
|
||||
// 查询用户
|
||||
let found = erp_auth::service::UserService::get_by_id(user.id, tenant_id, db)
|
||||
.await.expect("Failed to get user");
|
||||
assert_eq!(found.username, "testuser");
|
||||
|
||||
// 列表查询
|
||||
let (users, total) = erp_auth::service::UserService::list(
|
||||
tenant_id, erp_core::types::Pagination { page: 1, page_size: 10 }, None, db,
|
||||
).await.expect("Failed to list users");
|
||||
assert_eq!(total, 1);
|
||||
assert_eq!(users[0].username, "testuser");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写多租户隔离测试**
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_tenant_isolation() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = &test_db.db;
|
||||
let tenant_a = uuid::Uuid::new_v4();
|
||||
let tenant_b = uuid::Uuid::new_v4();
|
||||
|
||||
// 租户 A 创建用户
|
||||
let user_a = erp_auth::service::UserService::create(
|
||||
tenant_a,
|
||||
uuid::Uuid::new_v4(),
|
||||
erp_auth::dto::CreateUserReq {
|
||||
username: "user_a".to_string(),
|
||||
password: "Pass123!".to_string(),
|
||||
email: None, phone: None, display_name: None,
|
||||
},
|
||||
db,
|
||||
&erp_core::events::EventBus::new(100),
|
||||
).await.unwrap();
|
||||
|
||||
// 租户 B 查询不应看到租户 A 的用户
|
||||
let (users_b, total_b) = erp_auth::service::UserService::list(
|
||||
tenant_b, erp_core::types::Pagination { page: 1, page_size: 10 }, None, db,
|
||||
).await.unwrap();
|
||||
|
||||
assert_eq!(total_b, 0);
|
||||
assert!(users_b.is_empty());
|
||||
|
||||
// 租户 B 通过 ID 查询租户 A 的用户应返回 NotFound
|
||||
let result = erp_auth::service::UserService::get_by_id(user_a.id, tenant_b, db).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试验证**
|
||||
|
||||
```bash
|
||||
cargo test -p erp-server --test integration auth_tests
|
||||
```
|
||||
|
||||
注意:需要 Docker 运行。Windows 上可能需要 WSL2。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/tests/integration/auth_tests.rs
|
||||
git commit -m "test(auth): 添加用户 CRUD 和多租户隔离集成测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Plugin 模块集成测试
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/tests/integration/plugin_tests.rs`
|
||||
|
||||
- [ ] **Step 1: 编写插件生命周期测试**
|
||||
|
||||
测试 install → enable → data CRUD → disable → uninstall 完整流程。
|
||||
|
||||
- [ ] **Step 2: 编写 JSONB 查询测试**
|
||||
|
||||
验证 dynamic_table 的 generated column、pg_trgm 索引是否正确创建。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/tests/integration/plugin_tests.rs
|
||||
git commit -m "test(plugin): 添加插件生命周期和 JSONB 集成测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Workflow + EventBus 集成测试
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/tests/integration/workflow_tests.rs`
|
||||
- Create: `crates/erp-server/tests/integration/event_tests.rs`
|
||||
|
||||
- [ ] **Step 1: Workflow 测试 — 流程实例启动和任务完成**
|
||||
|
||||
- [ ] **Step 2: EventBus 测试 — 发布/订阅端到端 + outbox relay**
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/tests/integration/
|
||||
git commit -m "test: 添加 workflow 和 EventBus 集成测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: E2E 测试
|
||||
|
||||
### Task 6: Playwright 环境搭建
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/playwright.config.ts`
|
||||
- Modify: `apps/web/package.json`
|
||||
|
||||
- [ ] **Step 1: 安装 Playwright**
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm add -D @playwright/test && pnpm exec playwright install chromium
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 Playwright 配置**
|
||||
|
||||
```ts
|
||||
// apps/web/playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30000,
|
||||
retries: 1,
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
headless: true,
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/playwright.config.ts apps/web/package.json
|
||||
git commit -m "test(web): 搭建 Playwright E2E 测试环境"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 登录流程 E2E 测试
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/e2e/login.spec.ts`
|
||||
|
||||
- [ ] **Step 1: 编写登录 E2E 测试**
|
||||
|
||||
```ts
|
||||
// apps/web/e2e/login.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('完整登录流程', async ({ page }) => {
|
||||
await page.goto('/#/login');
|
||||
await expect(page.locator('h2, .ant-card-head-title')).toContainText('登录');
|
||||
|
||||
// 输入凭据
|
||||
await page.fill('input[placeholder*="用户名"]', 'admin');
|
||||
await page.fill('input[placeholder*="密码"]', 'Admin@2026');
|
||||
await page.click('button:has-text("登录")');
|
||||
|
||||
// 验证跳转到首页
|
||||
await page.waitForURL('**/'),
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
});
|
||||
```
|
||||
|
||||
注意:此测试需要后端服务运行。可在 CI 中使用 service container 或手动启动。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/e2e/login.spec.ts
|
||||
git commit -m "test(web): 添加登录流程 E2E 测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 用户管理 E2E 测试
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/e2e/users.spec.ts`
|
||||
|
||||
- [ ] **Step 1: 编写用户管理闭环测试**
|
||||
|
||||
创建 → 搜索 → 编辑 → 软删除 → 验证列表不显示。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/e2e/users.spec.ts
|
||||
git commit -m "test(web): 添加用户管理 E2E 测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 插件安装 + 多租户 E2E 测试
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/e2e/plugins.spec.ts`
|
||||
- Create: `apps/web/e2e/tenant-isolation.spec.ts`
|
||||
|
||||
- [ ] **Step 1: 插件安装 E2E 测试**
|
||||
|
||||
上传 → 安装 → 验证菜单 → 数据 CRUD → 卸载。
|
||||
|
||||
- [ ] **Step 2: 多租户隔离 E2E 测试**
|
||||
|
||||
租户 A 创建数据 → 切换租户 B → 验证不可见。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/e2e/
|
||||
git commit -m "test(web): 添加插件安装和多租户 E2E 测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 进销存插件
|
||||
|
||||
### Task 10: 创建插件 crate 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-inventory/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-inventory/src/lib.rs`
|
||||
- Create: `crates/erp-plugin-inventory/manifest.toml`
|
||||
- Modify: `Cargo.toml`(workspace members)
|
||||
|
||||
- [ ] **Step 1: 创建 Cargo.toml**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-inventory"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.38"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 manifest.toml**
|
||||
|
||||
定义 6 个实体(product, warehouse, stock, supplier, purchase_order, sales_order)的完整 schema,包括字段、关系、页面、权限声明。参考 CRM 插件的 `crates/erp-plugin-crm/manifest.toml`。
|
||||
|
||||
- [ ] **Step 3: 创建 lib.rs(Guest trait 实现)**
|
||||
|
||||
```rust
|
||||
// crates/erp-plugin-inventory/src/lib.rs
|
||||
wit_bindgen::generate!({
|
||||
path: "../erp-plugin-prototype/wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
struct InventoryPlugin;
|
||||
|
||||
impl Guest for InventoryPlugin {
|
||||
fn init() -> Result<(), String> { Ok(()) }
|
||||
fn on_tenant_created(_tenant_id: String) -> Result<(), String> { Ok(()) }
|
||||
fn handle_event(_event_type: String, _event_data: String) -> Result<(), String> { Ok(()) }
|
||||
}
|
||||
|
||||
export_plugin!(InventoryPlugin);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 添加到 workspace**
|
||||
|
||||
在根 `Cargo.toml` 的 `members` 中添加 `"crates/erp-plugin-inventory"`。
|
||||
|
||||
- [ ] **Step 5: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-inventory
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-inventory/ Cargo.toml
|
||||
git commit -m "feat(inventory): 创建进销存插件 crate 骨架"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 定义实体 Schema
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-plugin-inventory/manifest.toml`
|
||||
|
||||
- [ ] **Step 1: 定义 6 个实体**
|
||||
|
||||
参考 CRM 插件 manifest 格式,定义:
|
||||
|
||||
| 实体 | 关键字段 | 关联 | 页面类型 |
|
||||
|------|---------|------|---------|
|
||||
| product | code, name, spec, unit, category, price, cost | — | CRUD |
|
||||
| warehouse | code, name, address, manager, status | — | CRUD |
|
||||
| stock | product_id, warehouse_id, qty, cost, alert_line | → product, warehouse | CRUD |
|
||||
| supplier | code, name, contact, phone, address | — | CRUD |
|
||||
| purchase_order | supplier_id, total_amount, status, date | → supplier, stock | CRUD + Dashboard |
|
||||
| sales_order | customer_id, total_amount, status, date | → customer(CRM), stock | CRUD + Kanban |
|
||||
|
||||
- [ ] **Step 2: 定义 6 个页面**(4 CRUD + 1 Dashboard 库存汇总 + 1 Kanban 销售看板)
|
||||
|
||||
- [ ] **Step 3: 定义 9 个权限**(每个实体 list/create/update/delete + 全局 manage)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-inventory/manifest.toml
|
||||
git commit -m "feat(inventory): 定义 6 实体/6 页面/9 权限 manifest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: 编译 WASM 并测试安装
|
||||
|
||||
**Files:**
|
||||
- Build output: `apps/web/public/inventory.wasm`
|
||||
|
||||
- [ ] **Step 1: 编译为 WASM Component**
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-inventory --target wasm32-unknown-unknown --release
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm -o target/erp_plugin_inventory.component.wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 复制到前端 public 目录**
|
||||
|
||||
```bash
|
||||
cp target/erp_plugin_inventory.component.wasm apps/web/public/inventory.wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 通过 API 安装插件并验证**
|
||||
|
||||
使用 curl 或前端插件管理页面上传 `inventory.wasm`,验证动态表创建成功,CRUD 页面正常工作。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/public/inventory.wasm
|
||||
git commit -m "feat(inventory): 编译并部署进销存插件 WASM"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: 插件热更新
|
||||
|
||||
### Task 13: 添加 upgrade 端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-plugin/src/handler/plugin_handler.rs`
|
||||
- Modify: `crates/erp-plugin/src/service.rs`
|
||||
|
||||
- [ ] **Step 1: 在 plugin_handler 中添加 upgrade 路由**
|
||||
|
||||
```rust
|
||||
pub fn protected_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
// ... 现有路由 ...
|
||||
.route("/admin/plugins/:plugin_id/upgrade", post(upgrade_plugin))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 upgrade handler**
|
||||
|
||||
接收新 WASM 文件,调用 service 层执行升级。
|
||||
|
||||
- [ ] **Step 3: 在 service 中实现升级逻辑**
|
||||
|
||||
```rust
|
||||
pub async fn upgrade_plugin(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
new_wasm_bytes: Vec<u8>,
|
||||
db: &DatabaseConnection,
|
||||
) -> PluginResult<()> {
|
||||
// 1. 解析新 manifest
|
||||
let new_manifest = parse_manifest_from_wasm(&new_wasm_bytes)?;
|
||||
|
||||
// 2. 获取当前插件信息
|
||||
let current = find_by_id(plugin_id, tenant_id, db).await?;
|
||||
|
||||
// 3. 对比 schema 变更,生成增量 DDL
|
||||
let schema_diff = compare_schemas(¤t.manifest, &new_manifest)?;
|
||||
|
||||
// 4. 暂存新 WASM,尝试验证初始化
|
||||
// 5. 初始化成功后,在事务中执行 DDL + 状态更新
|
||||
// 6. 失败时保持旧 WASM 继续运行
|
||||
// 详见 spec §4.4 回滚策略
|
||||
todo!()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin/src/
|
||||
git commit -m "feat(plugin): 添加插件热更新 upgrade 端点"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5: 文档更新与清理
|
||||
|
||||
### Task 14: 更新 Wiki 文档
|
||||
|
||||
**Files:**
|
||||
- Modify: `wiki/frontend.md`
|
||||
- Modify: `wiki/database.md`
|
||||
- Modify: `wiki/testing.md`
|
||||
- Modify: `wiki/index.md`
|
||||
|
||||
- [ ] **Step 1: 更新 `wiki/frontend.md`**
|
||||
|
||||
更新为反映当前 16 条路由、6 种插件页面类型、Zustand stores 等实际状态。
|
||||
|
||||
- [ ] **Step 2: 更新 `wiki/testing.md`**
|
||||
|
||||
更新测试数量、添加 Testcontainers 集成测试和 Playwright E2E 描述。
|
||||
|
||||
- [ ] **Step 3: 更新 `wiki/index.md`**
|
||||
|
||||
添加进销存插件到模块导航树,更新开发进度表。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add wiki/
|
||||
git commit -m "docs: 更新 Wiki 文档到当前状态"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 15: CLAUDE.md 版本号修正 + 根目录清理
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
- Cleanup: 根目录未跟踪文件
|
||||
|
||||
- [ ] **Step 1: 修正 CLAUDE.md 版本号**
|
||||
|
||||
将 `React 18 + Ant Design 5` 改为 `React 19 + Ant Design 6`。
|
||||
|
||||
- [ ] **Step 2: 清理根目录未跟踪文件**
|
||||
|
||||
删除开发临时文件:截图、heap dump、perf trace、agent plan 文件。
|
||||
|
||||
```bash
|
||||
rm -f current-page.png home-full.png home-improved.png docs/debug-*.png
|
||||
rm -f docs/memory-snapshot-*.heapsnapshot docs/perf-trace-*.json
|
||||
rm -f test_api_auth.py test_users.py
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 处理 integration-tests/ 目录**
|
||||
|
||||
验证 `integration-tests/` 中的测试是否能编译。若已失效则删除(新的集成测试在 `crates/erp-server/tests/integration/`)。若仍有效则添加到 workspace。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: 修正 CLAUDE.md 版本号 (React 19 / AD 6) 并清理临时文件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
- [ ] **V1: 全 workspace 编译和测试**
|
||||
```bash
|
||||
cargo check && cargo test --workspace
|
||||
```
|
||||
|
||||
- [ ] **V2: 集成测试通过**
|
||||
```bash
|
||||
cargo test -p erp-server --test integration
|
||||
```
|
||||
注意:需要 Docker 运行
|
||||
|
||||
- [ ] **V3: 前端构建**
|
||||
```bash
|
||||
cd apps/web && pnpm build
|
||||
```
|
||||
|
||||
- [ ] **V4: E2E 测试**
|
||||
```bash
|
||||
cd apps/web && pnpm exec playwright test
|
||||
```
|
||||
|
||||
- [ ] **V5: 进销存插件安装验证**
|
||||
|
||||
通过 API 安装 inventory.wasm,验证动态表和 CRUD 页面正常。
|
||||
|
||||
- [ ] **V6: Wiki 文档同步**
|
||||
|
||||
确认 Wiki 描述与代码实际状态一致。
|
||||
@@ -0,0 +1,456 @@
|
||||
# ERP 平台底座 — 全面成熟度提升路线图
|
||||
|
||||
> 创建日期:2026-04-17
|
||||
> 状态:审查修订完成
|
||||
> 范围:安全、架构、测试、前端体验、插件生态 — 3 季度分层推进
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 项目现状
|
||||
|
||||
ERP 平台底座已完成 Phase 1-6 基础设施建设 + WASM 插件系统集成 + CRM 客户管理插件。当前具备:
|
||||
|
||||
- 6 个业务模块(auth, config, workflow, message, plugin, server)
|
||||
- 36 个数据库迁移
|
||||
- 完整的 WASM 插件运行时
|
||||
- Schema 驱动的动态前端(6 种页面类型)
|
||||
- React 19 + Ant Design 6 + Zustand 5 前端 SPA
|
||||
|
||||
### 1.2 分析发现摘要
|
||||
|
||||
| 维度 | 评分 | 关键问题 |
|
||||
|------|------|---------|
|
||||
| 架构健壮性 | 8/10 | ErpModule trait 死代码、路由注册未自动化 |
|
||||
| 代码质量 | 7/10 | N+1 查询、错误映射过宽、 oversized 组件 |
|
||||
| 安全性 | 5/10 | 3 个 CRITICAL(硬编码密钥/密码)、4 个 HIGH |
|
||||
| 测试覆盖 | 4/10 | 零数据库集成测试、关键流程未覆盖 |
|
||||
| 前端体验 | 7/10 | 无 i18n、无 Error Boundary、无虚拟滚动 |
|
||||
| 基础设施 | 4/10 | 无 CI/CD、Wiki 过时、大量未跟踪文件 |
|
||||
|
||||
### 1.3 目标
|
||||
|
||||
通过 3 个季度的分层改进,将平台从"功能完整"推进到"生产就绪":
|
||||
|
||||
- **Q2(4-5月)**:消除安全风险,建立自动化质量门
|
||||
- **Q3(6-8月)**:强化架构,提升前端工程化水平
|
||||
- **Q4(9-11月)**:补齐测试覆盖,扩展插件生态
|
||||
|
||||
### 1.4 约束
|
||||
|
||||
- **独立开发者** + Claude 辅助 — 每季度聚焦单一维度
|
||||
- **SaaS 优先**部署 — 多租户安全是硬性要求
|
||||
- **不破坏现有功能** — 所有改进必须向后兼容
|
||||
|
||||
---
|
||||
|
||||
## 2. Q2:安全地基 + CI/CD(4-5月)
|
||||
|
||||
### 2.1 密钥外部化与启动强制检查
|
||||
|
||||
**问题:**
|
||||
- JWT 密钥 `"change-me-in-production"` 硬编码在 `crates/erp-server/config/default.toml`
|
||||
- 管理员密码 `"Admin@2026"` 硬编码 + fallback
|
||||
- 数据库凭据 `postgres://erp:erp_dev_2024@...` 硬编码
|
||||
- `.test_token` 含有效 admin JWT 提交到仓库
|
||||
|
||||
**方案:**
|
||||
|
||||
1. **配置强制化**:`default.toml` 只保留开发环境默认值。生产敏感值通过环境变量 `ERP__` 前缀注入(已有机制)
|
||||
2. **启动检查**:服务启动时检测 JWT 密钥是否为默认值,若是则 **拒绝启动**(返回错误退出码,不只是警告)
|
||||
3. **密码初始化**:`seed_tenant_auth` 从环境变量 `ERP__SUPER_ADMIN_PASSWORD` 读取初始密码(与现有 `module.rs:149` 中的变量名一致),未设置则拒绝初始化(移除 fallback 到硬编码值的逻辑)
|
||||
4. **清理 `.test_token`**:立即加入 `.gitignore`。验证该文件是否曾被提交到 git 历史 — 如果曾提交,需使用 BFG Repo-Cleaner 清理历史(因包含用硬编码密钥签名的 admin JWT,等同于密钥泄露)
|
||||
5. **`default.toml` 占位符**:敏感字段改为 `"__MUST_SET_VIA_ENV__"` 之类的明显占位值
|
||||
|
||||
**验证标准:**
|
||||
- 默认配置启动时服务拒绝运行
|
||||
- 环境变量设置后正常启动
|
||||
- `.test_token` 不再出现在仓库中
|
||||
|
||||
### 2.2 Gitea Actions CI/CD
|
||||
|
||||
**流水线设计:**
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
rust-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo fmt --check --all
|
||||
- run: cargo clippy -- -D warnings
|
||||
|
||||
rust-test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env: { POSTGRES_DB: erp_test, POSTGRES_USER: test, POSTGRES_PASSWORD: test }
|
||||
ports: ["5432:5432"]
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --workspace
|
||||
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- run: cd apps/web && pnpm install && pnpm build
|
||||
|
||||
security-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo audit
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- run: cd apps/web && pnpm audit
|
||||
```
|
||||
|
||||
**关键决策:**
|
||||
- 使用 Gitea Actions(与 GitHub Actions 语法兼容)
|
||||
- 每个 job 包含 `actions/checkout@v4` + 对应语言 toolchain setup
|
||||
- Rust 使用 `Swatinem/rust-cache@v2` 缓存编译产物,避免每次全量编译
|
||||
- PostgreSQL 通过 service 容器提供
|
||||
- 四个 job 并行运行,互不依赖
|
||||
- 后续可扩展:Redis service、Playwright E2E、Docker 镜像构建推送
|
||||
|
||||
### 2.3 审计日志补全
|
||||
|
||||
**当前缺口与改进:**
|
||||
|
||||
| 缺口 | 改进方案 |
|
||||
|------|---------|
|
||||
| 登录/登出只发 DomainEvent,不写审计日志 | 在 `auth_service` 的 login/logout/change_password 中调用 `audit_service::record()` |
|
||||
| 审计日志缺少 `old_value`/`new_value` | 关键实体(user/role/permission/org)的 update 操作添加 `.with_changes(old, new)`。序列化完整的旧模型和新模型为 JSON,由审计日志消费者计算 diff — 比应用层计算细粒度 diff 更简单健壮 |
|
||||
| 缺少 IP 地址和 User-Agent | `AuditLogBuilder::with_request_info()` 在 handler 层传入请求上下文 |
|
||||
| 插件 CRUD 无审计 | `data_service` 的 create/update/delete 操作添加审计日志记录 |
|
||||
| 登录失败无记录 | 添加失败登录审计(含尝试的用户名/IP),用于入侵检测 |
|
||||
|
||||
**验证标准:**
|
||||
- 登录成功/失败均写入审计日志
|
||||
- 用户更新操作记录变更前后值
|
||||
- 审计日志包含 IP 和 User-Agent
|
||||
|
||||
### 2.4 Docker 生产化
|
||||
|
||||
| 改进项 | 当前 | 目标 |
|
||||
|--------|------|------|
|
||||
| PostgreSQL 端口 | `ports: "5432:5432"` 暴露到宿主机 | 移除 `ports:`,使用 Docker 网络内部通信 |
|
||||
| Redis 端口 | `ports: "6379:6379"` 无认证 | 移除 `ports:`,添加 `--requirepass` |
|
||||
| 容器资源限制 | 无 | CPU 1核 / 内存 512MB |
|
||||
| 应用镜像 | 无 Dockerfile | 多阶段构建:Rust build → 精简 runtime 镜像 |
|
||||
| Redis 宕机时限流 | fail-open(无限流) | fail-closed(拒绝请求) |
|
||||
|
||||
**限流 fail-closed 改动:**
|
||||
`crates/erp-server/src/middleware/rate_limit.rs` 中 Redis 不可用时,返回 `429 Too Many Requests` 而非放行。
|
||||
|
||||
### 2.5 多租户安全加固
|
||||
|
||||
| 问题 | 改进方案 |
|
||||
|------|---------|
|
||||
| 登录使用硬编码 `default_tenant_id` | 登录接口增加租户解析(从子域名/请求头 `X-Tenant-ID`) |
|
||||
| `auth_service::refresh()` 用户查询缺少 tenant_id(`auth_service.rs:177`) | `find_by_id` 添加 `.filter(user::Column::TenantId.eq(claims.tenant_id))` |
|
||||
| 内存级 tenant_id 过滤(`user_service.rs` 的 `get_by_id`/`update`/`delete`) | 改为数据库级 `.filter(Column::TenantId.eq(tenant_id))` 查询。注意:`login`/`list`/`assign_roles` 已正确使用数据库级过滤,无需修改 |
|
||||
|
||||
**涉及文件:**
|
||||
- `crates/erp-auth/src/handler/auth_handler.rs`
|
||||
- `crates/erp-auth/src/service/auth_service.rs`
|
||||
- `crates/erp-auth/src/service/user_service.rs`
|
||||
- `crates/erp-auth/src/middleware/jwt_auth.rs`
|
||||
|
||||
---
|
||||
|
||||
## 3. Q3:架构强化 + 前端体验(6-8月)
|
||||
|
||||
### 3.1 ErpModule Trait 重构
|
||||
|
||||
**当前问题:**
|
||||
- `register_event_handlers` 是死代码 — 所有模块实现为空操作
|
||||
- 路由注册需在 `main.rs` 手动编辑两处
|
||||
- 事件订阅在 `main.rs` 中手动调用,绕过 trait
|
||||
|
||||
**改进方案:**
|
||||
|
||||
基于当前 trait 签名(`erp-core/src/module.rs`),新增双路由注册和权限声明。保持与现有 `ModuleContext` 参数一致,不引入 `AppState` 依赖(避免 `erp-core` → `erp-server` 反向依赖):
|
||||
|
||||
```rust
|
||||
pub trait ErpModule: Send + Sync + 'static {
|
||||
// 保留已有方法
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> &str;
|
||||
fn module_type(&self) -> &str { "business" }
|
||||
fn dependencies(&self) -> Vec<&str> { vec![] }
|
||||
fn id(&self) -> Uuid { /* 默认实现 */ }
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
|
||||
// 新增:双路由注册(匹配现有 public/protected 分离模式)
|
||||
fn register_public_routes(&self, router: Router) -> Router { router }
|
||||
fn register_protected_routes(&self, router: Router) -> Router { router }
|
||||
|
||||
// 重构:事件订阅真正生效(当前所有模块实现为空操作)
|
||||
fn register_event_handlers(&self, bus: &EventBus) {}
|
||||
|
||||
// 新增:模块权限声明
|
||||
fn permissions(&self) -> Vec<PermissionDef> { vec![] }
|
||||
|
||||
// 保留已有生命周期钩子(保持 ModuleContext 参数签名)
|
||||
async fn on_startup(&self, _ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
async fn on_shutdown(&self) -> AppResult<()> { Ok(()) }
|
||||
async fn on_tenant_created(&self, _tenant_id: Uuid, _db: &DatabaseConnection, _bus: &EventBus) -> AppResult<()> { Ok(()) }
|
||||
async fn on_tenant_deleted(&self, _tenant_id: Uuid, _db: &DatabaseConnection, _bus: &EventBus) -> AppResult<()> { Ok(()) }
|
||||
async fn health_check(&self) -> AppResult<serde_json::Value> { Ok(serde_json::json!({})) }
|
||||
}
|
||||
```
|
||||
|
||||
`ModuleRegistry::build()` 自动收集路由、事件处理器和权限,`main.rs` 简化为:
|
||||
|
||||
```rust
|
||||
let (registry, public_routes, protected_routes) = ModuleRegistry::new()
|
||||
.register(auth_module)
|
||||
.register(config_module)
|
||||
.register(workflow_module)
|
||||
.register(message_module)
|
||||
.register(plugin_module)
|
||||
.build();
|
||||
|
||||
// 自动组合:public_routes 直接挂载,protected_routes 包裹 JWT 中间件
|
||||
let app = Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(protected_routes.layer(jwt_middleware));
|
||||
```
|
||||
|
||||
**迁移策略:** 逐模块迁移 — 每个模块从静态 `public_routes()`/`protected_routes()` 函数改为 trait 方法实现,`main.rs` 逐步简化。
|
||||
|
||||
**已知例外:** PluginModule 的两阶段初始化(先注册再启动事件监听器)在初期保持独立处理,不强行纳入自动化。`MessageModule::start_event_listener`、`WorkflowModule::start_timeout_checker`、`outbox::start_outbox_relay` 等独立生命周期钩子作为范围排除项,后续迭代再统一。
|
||||
|
||||
**迁移策略:** 逐模块迁移 — 每个模块从静态函数改为 trait 方法实现,`main.rs` 逐步简化。
|
||||
|
||||
### 3.2 错误映射修正 + N+1 查询优化
|
||||
|
||||
**错误映射修正:**
|
||||
|
||||
当前 `erp-auth` 服务中直接 `.map_err(|e| AuthError::Validation(e.to_string()))` 将所有 `DbErr` 映射为 `Validation`,绕过了 `erp-core` 中已有的 `From<DbErr> for AppError` 语义映射(该映射已正确处理 `RecordNotFound` → `NotFound`、重复键 → `Conflict`)。
|
||||
|
||||
**修复策略:** `erp-auth` 服务层停止手动包装 `DbErr`,改为通过 `?` 操作符依赖 `DbErr → AppError` 的核心映射,通过现有的 `From<AuthError> for AppError` 转换传播。这样数据库连接错误会正确显示为 `Internal`,唯一约束冲突会正确显示为 `Conflict`。
|
||||
|
||||
移除 `From<AppError> for AuthError` 的反向映射(当前是 lossy wrapping — `AppError::NotFound` 变为 `AuthError::Validation`,丢失语义信息)。
|
||||
|
||||
**N+1 查询优化:**
|
||||
|
||||
`user_service.rs` 的 `list()` 方法改为批量查询:
|
||||
1. 先查询当前页用户列表
|
||||
2. 收集所有 `user_id`
|
||||
3. 一次 `WHERE user_id IN (...)` 查询 `user_role` + `role`
|
||||
4. 内存中按 `user_id` 分组组装
|
||||
|
||||
从 N+1 查询降为 3 次固定查询(用户列表 + 角色关联 + 角色详情)。
|
||||
|
||||
### 3.3 前端 Error Boundary + hooks 提取
|
||||
|
||||
**Error Boundary:**
|
||||
- `App.tsx` 根组件包裹全局 Error Boundary(捕获未预期崩溃)
|
||||
- 每个懒加载页面外包裹页面级 Error Boundary(隔离单页面崩溃)
|
||||
- 失败时展示友好错误页面 + 重试按钮
|
||||
|
||||
**hooks 提取:**
|
||||
|
||||
| Hook | 提取来源 | 用途 |
|
||||
|------|---------|------|
|
||||
| `usePaginatedData<T>` | 6+ 页面的分页加载逻辑 | 统一分页/搜索/加载状态 |
|
||||
| `useDarkMode` | 8+ 文件的 `token.colorBgContainer` 字符串比较 | 提供可靠的 boolean 暗色模式判断 |
|
||||
| `useCountUp` | Home.tsx + DashboardWidgets 重复实现 | 计数动画复用 |
|
||||
| `useDebouncedValue` | Users.tsx 等搜索输入 | 防抖搜索,避免每次按键触发 API |
|
||||
| `useApiRequest` | 所有页面的 try/catch + message.error | 统一 API 错误处理和消息提示 |
|
||||
|
||||
### 3.4 i18n 基础设施搭建
|
||||
|
||||
**方案:react-i18next**
|
||||
|
||||
- 安装 `react-i18next` + `i18next`
|
||||
- 创建 `locales/zh-CN.json`,提取所有硬编码中文为 key
|
||||
- 配置 i18next 初始化,默认 `zh-CN`
|
||||
- 用 `useTranslation()` hook 替换硬编码字符串
|
||||
|
||||
**实施策略:** 增量式 — 新页面强制使用 i18n,旧页面按模块逐步迁移。不强求一次性替换。
|
||||
|
||||
**命名规范:**
|
||||
- 页面文案:`{module}.{page}.{element}` 如 `auth.login.username`
|
||||
- 通用文案:`common.{action}` 如 `common.save`, `common.cancel`
|
||||
- 错误消息:`error.{type}` 如 `error.network`, `error.unauthorized`
|
||||
|
||||
### 3.5 行级数据权限接线
|
||||
|
||||
**当前状态:** 数据库列、SQL 条件构建器、manifest 声明已就绪,handler 层有 TODO 未实现。
|
||||
|
||||
**完成步骤:**
|
||||
1. JWT 中间件注入 `department_ids`(完成 `jwt_auth.rs:50` 的 TODO)
|
||||
2. `data_handler` 查询接口注入 data scope 条件
|
||||
3. 前端角色权限编辑页添加 `data_scope` 选择控件
|
||||
4. 端到端验证:创建测试角色 → 设置数据范围 → 验证查询过滤
|
||||
|
||||
### 3.6 前端共享类型统一
|
||||
|
||||
- `PaginatedResponse<T>` 从 `users.ts` 提取到 `api/types.ts`
|
||||
- 错误提取工具函数 `extractErrorMessage(err: unknown): string` → `api/errors.ts`
|
||||
- 插件 Schema 类型定义集中到 `types/plugin.ts`
|
||||
- 移除 `api/client.ts` 中已废弃的 `CancelToken`,改用 `AbortController`
|
||||
|
||||
---
|
||||
|
||||
## 4. Q4:测试覆盖 + 插件生态(9-11月)
|
||||
|
||||
### 4.1 Q4 范围调整说明
|
||||
|
||||
Q4 原始范围较大(Testcontainers + Playwright + 进销存插件 + 热更新 + 文档清理)。调整为两个子阶段:
|
||||
|
||||
- **Q4a(9-10月)**:测试基础设施 — Testcontainers 集成测试框架 + Playwright E2E + 文档清理
|
||||
- **Q4b(11月+)**:插件生态 — 进销存插件 + 热更新
|
||||
|
||||
热更新功能可视 Q4a 进度推迟到 Q1 2027,避免在单季度内承载过多工作。
|
||||
|
||||
### 4.2 数据库集成测试框架
|
||||
|
||||
**方案:Testcontainers + PostgreSQL**
|
||||
|
||||
创建 `crates/erp-server/tests/integration/` 目录,使用 `testcontainers` crate 启动真实 PostgreSQL 容器。
|
||||
|
||||
**测试基座:**
|
||||
- 每个测试套件共享一个 PostgreSQL 容器
|
||||
- 自动运行所有迁移
|
||||
- 提供 `setup_test_db()` 辅助函数返回连接池
|
||||
- 测试结束自动清理
|
||||
|
||||
**覆盖优先级:**
|
||||
|
||||
| 优先级 | 模块 | 测试场景 |
|
||||
|--------|------|---------|
|
||||
| P0 | erp-auth | 用户 CRUD、角色权限分配、登录/JWT 完整流程 |
|
||||
| P0 | erp-auth | 多租户隔离 — 租户 A 数据对租户 B 不可见 |
|
||||
| P0 | erp-plugin | 插件生命周期(install→enable→disable→uninstall) |
|
||||
| P1 | erp-auth | 乐观锁并发冲突、软删除恢复 |
|
||||
| P1 | erp-plugin | 行级数据权限过滤、JSONB 查询/索引 |
|
||||
| P1 | erp-plugin | 动态表 DDL 正确性(generated column、pg_trgm 索引) |
|
||||
| P1 | erp-workflow | 流程实例启动、任务完成、网关分支 |
|
||||
| P1 | erp-core | 事件总线发布/订阅端到端、outbox relay 补偿 |
|
||||
|
||||
### 4.2 核心流程 E2E 测试
|
||||
|
||||
**方案:Playwright**
|
||||
|
||||
放在 `apps/web/e2e/` 目录,CI 中作为独立 job 运行。
|
||||
|
||||
**覆盖场景(4 个关键旅程):**
|
||||
|
||||
| 场景 | 步骤 |
|
||||
|------|------|
|
||||
| 完整登录流程 | 打开登录页 → 输入密码 → 验证 token → 刷新 token → 登出 → 验证跳转 |
|
||||
| 用户管理闭环 | 创建用户 → 分配角色 → 搜索用户 → 编辑 → 软删除 → 验证列表不显示 |
|
||||
| 插件安装流程 | 上传 WASM → 安装 → 验证菜单出现 → 数据 CRUD → 卸载 → 验证菜单消失 |
|
||||
| 多租户隔离 | 租户 A 创建用户 → 切换租户 B → 验证查询结果为空 |
|
||||
|
||||
### 4.3 第二个行业插件 — 进销存(Inventory)
|
||||
|
||||
**选择理由:**
|
||||
- 与 CRM 有天然关联(客户 → 订单 → 出库)
|
||||
- 实体数量适中(5-8 个),复杂度可控
|
||||
- 能验证插件系统的复用性和跨实体关联能力
|
||||
- 为后续财务模块铺垫
|
||||
|
||||
**实体设计:**
|
||||
|
||||
| 实体 | 字段 | 关联 |
|
||||
|------|------|------|
|
||||
| product 商品 | 名称/编码/规格/单位/分类/售价/成本价 | — |
|
||||
| warehouse 仓库 | 名称/地址/负责人/状态 | — |
|
||||
| stock 库存 | 商品/仓库/数量/成本/预警线 | → product, warehouse |
|
||||
| purchase_order 采购单 | 供应商/总金额/状态/日期 | → supplier(CRM), stock |
|
||||
| sales_order 销售单 | 客户/总金额/状态/日期 | → customer(CRM), stock |
|
||||
| supplier 供应商 | 名称/编码/联系方式/地址 | — |
|
||||
|
||||
**需要验证的插件能力:**
|
||||
- 跨实体关联(订单 → 商品 → 库存联动)
|
||||
- 事务性事件(库存扣减在订单确认时原子执行)
|
||||
- 页面间导航(从订单跳转客户详情)
|
||||
- 报表/统计页面(库存汇总、进销存明细)
|
||||
|
||||
### 4.4 插件热更新能力
|
||||
|
||||
**当前限制:** 更新插件需要完整 uninstall/reinstall。
|
||||
|
||||
**改进方案:**
|
||||
- 新增 `POST /api/v1/admin/plugins/{id}/upgrade` 端点
|
||||
- 升级流程:上传新 WASM → 对比 manifest schema → 增量 DDL(ADD COLUMN 等) → 热替换 WASM 模块
|
||||
- 数据安全:`tenant_id` 数据不丢失
|
||||
- 版本兼容性检查:新版本必须向后兼容或提供迁移脚本
|
||||
|
||||
**回滚策略:** 升级前创建 schema 备份点。升级流程分两步执行:
|
||||
1. 先暂存新 WASM 并尝试验证初始化(不应用 DDL)
|
||||
2. 初始化成功后,在单事务中执行 DDL 变更 + 状态转换
|
||||
3. 如果新 WASM 初始化失败,保持旧 WASM 继续运行,回滚暂存状态
|
||||
4. DDL 已应用但 WASM 运行异常时,保留旧 WASM 可加载作为 fallback
|
||||
|
||||
### 4.5 文档更新与清理
|
||||
|
||||
| 项目 | 改进 |
|
||||
|------|------|
|
||||
| Wiki 文档 | 全面更新到当前状态(前端路由、测试数量、模块能力、插件系统) |
|
||||
| CLAUDE.md | 版本号修正(React 19 / Ant Design 6) |
|
||||
| 根目录清理 | 删除未跟踪的开发临时文件(截图、heap dump、perf trace、agent plan 文件) |
|
||||
| integration-tests/ | 验证现有测试是否能编译。若已失效则删除,用新的 Testcontainers 框架替代;若仍有效则纳入 Cargo workspace |
|
||||
| N+1 查询(plugin) | `plugin_service.rs` 的列表查询也存在 N+1 问题(每条插件单独查询 entities),需一并优化 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险与缓解
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| 安全修复引入新 bug | 中 | 高 | 每个修复配有对应的测试用例 |
|
||||
| ErpModule trait 重构影响所有模块 | 高 | 中 | 逐模块迁移,每步验证 `cargo test` |
|
||||
| i18n 迁移工作量大 | 中 | 低 | 增量式,不追求一次性完成 |
|
||||
| Testcontainers 在 CI 环境不稳定 | 低 | 中 | 本地开发可跳过集成测试,CI 用 service container 兜底 |
|
||||
| Testcontainers 在 Windows (WSL2) 上兼容性 | 中 | 中 | 主开发环境为 Windows 11,Testcontainers 对 Windows 支持有限。本地开发可依赖 CI service container 运行集成测试,或使用 WSL2 环境 |
|
||||
| 进销存插件实体设计变更 | 中 | 低 | 先完成最小实体集,后续迭代扩展 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 成功标准
|
||||
|
||||
**Q2 完成标准:**
|
||||
- [ ] 3 个 CRITICAL 安全问题全部修复
|
||||
- [ ] Gitea Actions CI/CD 流水线运行通过
|
||||
- [ ] 默认配置启动被拒绝
|
||||
- [ ] 登录/登出写入审计日志
|
||||
- [ ] Docker 生产化配置就绪
|
||||
|
||||
**Q3 完成标准:**
|
||||
- [ ] ErpModule trait 路由注册自动化
|
||||
- [ ] N+1 查询优化,用户列表查询次数固定为 3
|
||||
- [ ] 前端 Error Boundary 覆盖全局 + 页面级
|
||||
- [ ] 5 个自定义 hooks 提取完成
|
||||
- [ ] i18n 基础设施可用,至少 1 个页面完成迁移
|
||||
- [ ] 行级数据权限端到端验证通过
|
||||
|
||||
**Q4 完成标准:**
|
||||
- [ ] 集成测试覆盖 auth + plugin 核心流程
|
||||
- [ ] 4 个 E2E 测试场景通过
|
||||
- [ ] 进销存插件 6 个实体可用
|
||||
- [ ] 插件热更新功能可用
|
||||
- [ ] Wiki 文档与代码同步
|
||||
@@ -0,0 +1,710 @@
|
||||
# 健康管理系统 — erp-health 模块设计规格
|
||||
|
||||
> **文档版本**: 1.0
|
||||
> **日期**: 2026-04-23
|
||||
> **状态**: 已确认
|
||||
> **范围**: V1 — 患者管理 + 健康数据 + 预约排班 + 随访管理 + 咨询管理
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目背景
|
||||
|
||||
### 1.1 产品定位
|
||||
|
||||
构建一个面向体检中心/医疗机构的**综合型健康管理平台**,以体检中心为数据源,汇集不同情况的患者,提供全生命周期的健康管理服务。
|
||||
|
||||
本系统从 ERP 平台底座分叉独立,作为 **Health Management System (HMS)** 产品演进。ERP 底座提供身份权限、工作流、消息通知、系统配置等基础能力,`erp-health` 作为原生 Rust 模块承载所有医疗业务逻辑。
|
||||
|
||||
### 1.2 系统架构
|
||||
|
||||
```
|
||||
📱 患者端(微信小程序) ──┐
|
||||
├──→ 🔀 API 网关 ──→ 🖥️ ERP 后端(HMS)
|
||||
👨⚕️ 医护端(小程序/H5) ──┘ │ │
|
||||
│ ├── erp-auth(用户/角色/权限)
|
||||
│ ├── erp-workflow(工作流引擎)
|
||||
│ ├── erp-message(消息通知)
|
||||
│ ├── erp-config(字典/配置)
|
||||
│ └── erp-health(健康管理)★ 新增
|
||||
│
|
||||
└──→ 💾 PostgreSQL + Redis
|
||||
```
|
||||
|
||||
**关键决策:**
|
||||
- ERP 只负责 **PC 管理后台**功能
|
||||
- 小程序(患者端/医护端)作为**独立系统**开发
|
||||
- 数据共享通过 **API 网关**实现
|
||||
- 健康管理使用**原生 Rust 模块**(非 WASM 插件),获得完整的数据库访问和自定义 API 能力
|
||||
|
||||
### 1.3 为什么不用 WASM 插件
|
||||
|
||||
| 限制 | 影响 |
|
||||
|------|------|
|
||||
| 实体上限 20 个 | 综合健康平台轻松超过 |
|
||||
| JSONB 存储 | 医疗数据需要强类型、索引、关联 |
|
||||
| 无自定义 API | 趋势分析、统计报表需要专用端点 |
|
||||
| 无文件上传 | 化验单、体检报告无法存储 |
|
||||
| WASM 沙箱限制 | 无法引入加密、AI、外部 API |
|
||||
|
||||
原生模块遵循现有模式(如 erp-auth、erp-workflow)。**注意:**`ErpModule` trait 没有 `register_routes` 方法。模块通过固有方法 `public_routes()` 和 `protected_routes()` 暴露路由,在 `erp-server` 的 `main.rs` 中通过 `.nest("/api/v1/health", HealthModule::protected_routes())` 集成。通过 EventBus 通信,未来可平滑拆分为独立微服务。
|
||||
|
||||
---
|
||||
|
||||
## 2. V1 功能范围
|
||||
|
||||
| 模块 | 功能 | 页面数 |
|
||||
|------|------|--------|
|
||||
| ① 患者与医护管理 | 患者档案、家庭成员、医护档案、患者标签 | 3 |
|
||||
| ② 健康数据管理 | 体检记录、日常监测、化验报告、趋势分析 | 3 |
|
||||
| ③ 预约与排班 | 预约管理、医生排班、日历视图 | 2 |
|
||||
| ④ 随访管理 | 随访任务、随访记录台账 | 2 |
|
||||
| ⑤ 咨询管理 | 会话管理、对话记录查看/导出 | 2 |
|
||||
| ⑥ 医护管理 | 医护人员列表 | 1 |
|
||||
| **合计** | | **13** |
|
||||
|
||||
**V2 预留:** 积分商城、数据统计中心、内容管理增强。
|
||||
|
||||
---
|
||||
|
||||
## 3. 实体模型
|
||||
|
||||
### 3.1 设计原则
|
||||
|
||||
- 患者和医护的**账号**走 `erp-auth` 的 `users` 表,`erp-health` 只存医疗业务扩展字段
|
||||
- 通过 `user_id` 外键关联 `users` 表
|
||||
- 所有表含 `tenant_id`(多租户隔离)、`id`(UUIDv7)、`created_at`、`updated_at`、`created_by`、`updated_by`、`deleted_at`、`version`
|
||||
- 多对多关系使用中间表
|
||||
|
||||
### 3.2 实体定义
|
||||
|
||||
#### ① 患者与医护管理
|
||||
|
||||
**patient — 患者档案**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | UUIDv7 |
|
||||
| tenant_id | UUID NOT NULL | 租户 ID |
|
||||
| user_id | UUID FK → users | 关联 erp-auth 账号 |
|
||||
| name | VARCHAR(100) | 姓名 |
|
||||
| gender | VARCHAR(10) | 性别 (male/female/other) |
|
||||
| birth_date | DATE | 出生日期 |
|
||||
| blood_type | VARCHAR(10) | 血型 (A/B/AB/O/RH-/RH+) |
|
||||
| id_number | VARCHAR(20) | 身份证号 |
|
||||
| allergy_history | TEXT | 过敏史 |
|
||||
| medical_history_summary | TEXT | 病史摘要 |
|
||||
| emergency_contact_name | VARCHAR(100) | 紧急联系人姓名 |
|
||||
| emergency_contact_phone | VARCHAR(20) | 紧急联系人电话 |
|
||||
| status | VARCHAR(20) | 状态 (active/inactive/deceased) |
|
||||
| verification_status | VARCHAR(20) | 实名认证 (pending/verified/rejected) |
|
||||
| source | VARCHAR(100) | 来源(体检中心名称) |
|
||||
| notes | TEXT | 备注 |
|
||||
| created_at, updated_at, created_by, updated_by, deleted_at, version | — | 标准字段 |
|
||||
|
||||
索引:`(tenant_id, name)`, `(tenant_id, status)`, `(tenant_id, id_number) UNIQUE WHERE deleted_at IS NULL`
|
||||
|
||||
**patient_family_member — 家庭成员**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | 患者关联 |
|
||||
| name | VARCHAR(100) | 姓名 |
|
||||
| relationship | VARCHAR(50) | 关系(父亲/母亲/配偶/子女等) |
|
||||
| phone | VARCHAR(20) | 电话 |
|
||||
| birth_date | DATE | 出生日期 |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
**doctor_profile — 医护档案**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| user_id | UUID FK → users | 关联 erp-auth 账号 |
|
||||
| department | VARCHAR(100) | 科室 |
|
||||
| title | VARCHAR(50) | 职称(主任医师/副主任医师/主治医师等) |
|
||||
| specialty | VARCHAR(200) | 专长 |
|
||||
| license_number | VARCHAR(50) | 执业证号 |
|
||||
| bio | TEXT | 简介 |
|
||||
| online_status | VARCHAR(20) | 在线状态 (online/offline/busy) |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id)`
|
||||
|
||||
**patient_tag — 患者标签**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| name | VARCHAR(50) | 标签名 |
|
||||
| color | VARCHAR(20) | 颜色值 |
|
||||
| description | TEXT | 描述 |
|
||||
| is_system | BOOLEAN | 系统标签(不可删除) |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`UNIQUE (tenant_id, name) WHERE deleted_at IS NULL`
|
||||
|
||||
**patient_tag_relation — 患者-标签关联**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| tag_id | UUID FK → patient_tag | |
|
||||
| created_at | TIMESTAMPTZ | |
|
||||
| updated_at | TIMESTAMPTZ | |
|
||||
| created_by | UUID | |
|
||||
| updated_by | UUID | |
|
||||
| deleted_at | TIMESTAMPTZ | 软删除 |
|
||||
|
||||
索引:`(tenant_id, patient_id)`, `(tenant_id, tag_id)`
|
||||
|
||||
**patient_doctor_relation — 医患关系**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| relationship_type | VARCHAR(20) | 类型 (primary/consulting) |
|
||||
| created_at | TIMESTAMPTZ | |
|
||||
| updated_at | TIMESTAMPTZ | |
|
||||
| created_by | UUID | |
|
||||
| updated_by | UUID | |
|
||||
| deleted_at | TIMESTAMPTZ | 软删除 |
|
||||
|
||||
索引:`(tenant_id, patient_id)`, `(tenant_id, doctor_id)`
|
||||
|
||||
#### ② 健康数据管理
|
||||
|
||||
**health_record — 体检/就诊记录**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| record_type | VARCHAR(20) | 类型 (checkup/outpatient/inpatient) |
|
||||
| record_date | DATE | 记录日期 |
|
||||
| source | VARCHAR(200) | 来源(体检中心/医院名称) |
|
||||
| overall_assessment | TEXT | 总体评估 |
|
||||
| report_file_url | VARCHAR(500) | 报告文件 URL |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, record_date DESC)`
|
||||
|
||||
**vital_signs — 日常监测数据**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| record_date | DATE | 记录日期 |
|
||||
| systolic_bp_morning | INTEGER | 晨起收缩压 |
|
||||
| diastolic_bp_morning | INTEGER | 晨起舒张压 |
|
||||
| systolic_bp_evening | INTEGER | 晚间收缩压 |
|
||||
| diastolic_bp_evening | INTEGER | 晚间舒张压 |
|
||||
| heart_rate | INTEGER | 心率 |
|
||||
| weight | DECIMAL(5,1) | 体重 (kg) |
|
||||
| blood_sugar | DECIMAL(5,1) | 血糖 (mmol/L) |
|
||||
| water_intake_ml | INTEGER | 饮水量 (ml) |
|
||||
| urine_output_ml | INTEGER | 尿量 (ml) |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, record_date DESC)`
|
||||
|
||||
**lab_report — 化验报告**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| report_date | DATE | 报告日期 |
|
||||
| report_type | VARCHAR(50) | 报告类型(肾功能/血常规/尿常规等) |
|
||||
| indicators | JSONB | 指标数据 [{name, value, unit, ref_range, is_abnormal}] |
|
||||
| image_urls | JSONB | 图片 URLs [url1, url2, ...] |
|
||||
| doctor_interpretation | TEXT | 医生解读 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, report_date DESC)`, GIN on `indicators`, `(tenant_id, report_type)`
|
||||
|
||||
**health_trend — 健康趋势报告**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| period_start | DATE | 周期开始 |
|
||||
| period_end | DATE | 周期结束 |
|
||||
| indicator_summary | JSONB | 指标摘要 |
|
||||
| abnormal_items | JSONB | 异常项 |
|
||||
| generation_type | VARCHAR(20) | 生成方式 (auto/manual) |
|
||||
| report_file_url | VARCHAR(500) | 报告文件 URL |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, period_start DESC)`
|
||||
|
||||
#### ③ 预约排班
|
||||
|
||||
**appointment — 预约记录**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| appointment_type | VARCHAR(20) | 类型 (dialysis/recheck/outpatient) |
|
||||
| appointment_date | DATE | 预约日期 |
|
||||
| start_time | TIME | 开始时间 |
|
||||
| end_time | TIME | 结束时间 |
|
||||
| status | VARCHAR(20) | 状态 (pending/confirmed/cancelled/completed/no_show) |
|
||||
| cancel_reason | TEXT | 取消原因 |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, appointment_date, status)`, `(tenant_id, doctor_id, appointment_date)`
|
||||
|
||||
**doctor_schedule — 医生排班**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| schedule_date | DATE | 排班日期 |
|
||||
| period_type | VARCHAR(20) | 时段 (am/pm/night/full_day) |
|
||||
| start_time | TIME | 开始时间 |
|
||||
| end_time | TIME | 结束时间 |
|
||||
| max_appointments | INTEGER | 最大预约数 |
|
||||
| current_appointments | INTEGER | 已预约数(默认 0) |
|
||||
| status | VARCHAR(20) | 状态 (enabled/disabled) |
|
||||
| 标准 ERP 字段 | — |
|
||||
|
||||
索引:`(tenant_id, doctor_id, schedule_date)`, `UNIQUE (tenant_id, doctor_id, schedule_date, period_type) WHERE deleted_at IS NULL`
|
||||
|
||||
**预约并发控制:** 创建预约时使用原子 CAS 操作 `UPDATE doctor_schedule SET current_appointments = current_appointments + 1 WHERE id = $1 AND current_appointments < max_appointments RETURNING *`,防止超额预约。
|
||||
|
||||
#### ④ 随访管理
|
||||
|
||||
**follow_up_task — 随访任务**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| assigned_to | UUID FK → users | 负责医护 |
|
||||
| follow_up_type | VARCHAR(20) | 类型 (phone/face_to_face/online) |
|
||||
| planned_date | DATE | 计划日期 |
|
||||
| status | VARCHAR(20) | 状态 (pending/in_progress/completed/overdue/cancelled) |
|
||||
| content_template | TEXT | 随访内容模板 |
|
||||
| related_appointment_id | UUID FK → appointment | 关联预约 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, assigned_to, status)`, `(tenant_id, planned_date, status)`
|
||||
|
||||
**follow_up_record — 随访记录**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| task_id | UUID FK → follow_up_task | |
|
||||
| executed_by | UUID FK → users | 执行医护 |
|
||||
| executed_date | DATE | 执行日期 |
|
||||
| result | VARCHAR(20) | 结果 (followed_up/unreachable/refused/other) |
|
||||
| patient_condition | TEXT | 患者状况 |
|
||||
| medical_advice | TEXT | 医嘱建议 |
|
||||
| next_follow_up_date | DATE | 下次随访日期 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, task_id)`, `(tenant_id, executed_date)`
|
||||
|
||||
#### ⑤ 咨询管理
|
||||
|
||||
**consultation_session — 咨询会话**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| type | VARCHAR(20) | 类型 (customer_service/doctor) |
|
||||
| status | VARCHAR(20) | 状态 (waiting/active/closed) |
|
||||
| last_message_at | TIMESTAMPTZ | 最后消息时间 |
|
||||
| unread_count_patient | INTEGER | 患者未读数 |
|
||||
| unread_count_doctor | INTEGER | 医生未读数 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, doctor_id, status)`, `(tenant_id, patient_id, status)`
|
||||
|
||||
**consultation_message — 咨询消息**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| session_id | UUID FK → consultation_session | |
|
||||
| sender_id | UUID | 发送者 ID |
|
||||
| sender_role | VARCHAR(20) | 角色 (patient/doctor/system) |
|
||||
| content_type | VARCHAR(20) | 类型 (text/image/voice/file) |
|
||||
| content | TEXT | 内容 |
|
||||
| is_read | BOOLEAN | 已读状态(默认 false) |
|
||||
| created_at | TIMESTAMPTZ | 发送时间 |
|
||||
| updated_at | TIMESTAMPTZ | |
|
||||
| created_by | UUID | |
|
||||
| updated_by | UUID | |
|
||||
| deleted_at | TIMESTAMPTZ | 软删除(内容审核用) |
|
||||
| version | INT NOT NULL DEFAULT 1 | 乐观锁 |
|
||||
|
||||
索引:`(tenant_id, session_id, created_at)`
|
||||
|
||||
**数据增长策略:** 对 `created_at` 按月分区(PostgreSQL table partitioning),超过 1 年的已关闭会话消息归档到冷存储。
|
||||
|
||||
**说明:**
|
||||
- `patient.user_id` 允许 NULL — 患者可先创建档案(如体检中心导入),后续再绑定 erp-auth 账号
|
||||
- `consultation_message.sender_id` 引用 `users.id` — 统一使用 erp-auth 用户体系标识发送者
|
||||
|
||||
---
|
||||
|
||||
## 3.3 状态机定义
|
||||
|
||||
### appointment.status 转换
|
||||
|
||||
```
|
||||
pending ──→ confirmed ──→ completed
|
||||
│ │
|
||||
│ └──→ no_show(预约时间过后,系统自动或前台手动触发)
|
||||
│
|
||||
└──→ cancelled(任意时刻可取消,需填 cancel_reason)
|
||||
```
|
||||
|
||||
### follow_up_task.status 转换
|
||||
|
||||
```
|
||||
pending ──→ in_progress ──→ completed
|
||||
│ │
|
||||
└──→ cancelled └──→ overdue(系统定时任务:planned_date 已过且仍 pending 自动标记)
|
||||
```
|
||||
|
||||
### consultation_session.status 转换
|
||||
|
||||
```
|
||||
waiting ──→ active(第一条消息发送时自动触发)──→ closed(手动关闭或超时自动关闭)
|
||||
```
|
||||
|
||||
### patient.status 转换
|
||||
|
||||
```
|
||||
active ──→ inactive(手动停用)
|
||||
active ──→ deceased(标记死亡,不可逆)
|
||||
inactive ──→ active(重新激活)
|
||||
```
|
||||
|
||||
### patient.verification_status 转换
|
||||
|
||||
```
|
||||
pending ──→ verified(实名认证通过)
|
||||
pending ──→ rejected(认证被拒)
|
||||
rejected ──→ pending(重新提交认证)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 设计
|
||||
|
||||
所有端点前缀: `/api/v1/health/`
|
||||
|
||||
### 4.1 患者管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/patients` | 患者列表(分页、搜索、标签筛选) |
|
||||
| POST | `/patients` | 创建患者 |
|
||||
| GET | `/patients/:id` | 患者详情 |
|
||||
| PUT | `/patients/:id` | 更新患者 |
|
||||
| DELETE | `/patients/:id` | 软删除 |
|
||||
| POST | `/patients/:id/tags` | 管理标签(批量设置) |
|
||||
| GET | `/patients/:id/health-summary` | 健康摘要 |
|
||||
| GET | `/patients/:id/family-members` | 家庭成员列表 |
|
||||
| POST | `/patients/:id/family-members` | 新增家庭成员 |
|
||||
| PUT | `/patients/:id/family-members/:fid` | 更新家庭成员 |
|
||||
| DELETE | `/patients/:id/family-members/:fid` | 删除家庭成员 |
|
||||
| POST | `/patients/:id/doctors` | 分配主治医生 |
|
||||
| DELETE | `/patients/:id/doctors/:did` | 移除医患关系 |
|
||||
|
||||
### 4.2 健康数据
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/patients/:id/vital-signs` | 日常监测列表 |
|
||||
| POST | `/patients/:id/vital-signs` | 新增监测数据 |
|
||||
| GET | `/patients/:id/lab-reports` | 化验报告列表 |
|
||||
| POST | `/patients/:id/lab-reports` | 新增化验报告 |
|
||||
| GET | `/patients/:id/health-records` | 体检/就诊记录 |
|
||||
| POST | `/patients/:id/health-records` | 新增记录 |
|
||||
| GET | `/patients/:id/trends` | 趋势报告 |
|
||||
| POST | `/patients/:id/trends/generate` | 生成趋势报告 |
|
||||
| GET | `/patients/:id/trends/:indicator` | 单指标时序数据 |
|
||||
| PUT | `/patients/:id/vital-signs/:vid` | 更新监测数据 |
|
||||
| DELETE | `/patients/:id/vital-signs/:vid` | 删除监测数据 |
|
||||
| PUT | `/patients/:id/lab-reports/:rid` | 更新化验报告 |
|
||||
| DELETE | `/patients/:id/lab-reports/:rid` | 删除化验报告 |
|
||||
| PUT | `/patients/:id/health-records/:rid` | 更新体检记录 |
|
||||
| DELETE | `/patients/:id/health-records/:rid` | 删除体检记录 |
|
||||
|
||||
### 4.3 预约排班
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/appointments` | 预约列表 |
|
||||
| POST | `/appointments` | 创建预约 |
|
||||
| PUT | `/appointments/:id/status` | 更新状态 |
|
||||
| GET | `/doctor-schedules` | 排班列表 |
|
||||
| POST | `/doctor-schedules` | 创建排班 |
|
||||
| PUT | `/doctor-schedules/:id` | 更新排班 |
|
||||
| GET | `/doctor-schedules/calendar` | 日历视图 |
|
||||
|
||||
### 4.4 随访管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/follow-up-tasks` | 任务列表 |
|
||||
| POST | `/follow-up-tasks` | 创建任务 |
|
||||
| PUT | `/follow-up-tasks/:id` | 更新任务 |
|
||||
| DELETE | `/follow-up-tasks/:id` | 删除任务 |
|
||||
| POST | `/follow-up-tasks/:id/records` | 填写随访记录 |
|
||||
| GET | `/follow-up-records` | 随访台账 |
|
||||
|
||||
### 4.5 咨询管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/consultation-sessions` | 会话列表 |
|
||||
| GET | `/consultation-sessions/:id/messages` | 消息记录 |
|
||||
| PUT | `/consultation-sessions/:id/close` | 关闭会话 |
|
||||
| POST | `/consultation-messages` | 写入消息(API 网关用) |
|
||||
| GET | `/consultation-sessions/export` | 导出 |
|
||||
|
||||
### 4.6 医护管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/doctors` | 医护列表 |
|
||||
| POST | `/doctors` | 创建医护档案 |
|
||||
| GET | `/doctors/:id` | 医护详情 |
|
||||
| PUT | `/doctors/:id` | 更新医护档案 |
|
||||
| DELETE | `/doctors/:id` | 软删除医护档案 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端页面设计
|
||||
|
||||
文件位置: `apps/web/src/pages/health/`
|
||||
|
||||
### 5.1 页面清单
|
||||
|
||||
| # | 页面 | 文件名 | 类型 |
|
||||
|---|------|--------|------|
|
||||
| 1 | 患者列表 | PatientList.tsx | 表格+搜索+标签筛选+导出 |
|
||||
| 2 | 患者详情 | PatientDetail.tsx | Tab布局:基本信息/健康趋势/化验报告/就诊记录/随访记录 |
|
||||
| 3 | 标签管理 | PatientTagManage.tsx | CRUD+颜色+批量打标 |
|
||||
| 4 | 日常监测 | VitalSignsList.tsx | 按患者+日期+ECharts趋势折线图 |
|
||||
| 5 | 化验报告 | LabReportList.tsx | 列表+图片预览+指标详情+解读 |
|
||||
| 6 | 体检记录 | HealthRecordList.tsx | 类型筛选+报告文件查看/上传 |
|
||||
| 7 | 预约管理 | AppointmentList.tsx | 列表/日历切换+状态流转 |
|
||||
| 8 | 排班管理 | DoctorSchedule.tsx | 周/月日历+排班模板 |
|
||||
| 9 | 随访任务 | FollowUpTaskList.tsx | 任务CRUD+分配+关联工作流 |
|
||||
| 10 | 随访台账 | FollowUpRecordList.tsx | 按患者/医护/日期筛选+导出 |
|
||||
| 11 | 会话管理 | ConsultationList.tsx | 列表+未回复统计 |
|
||||
| 12 | 对话记录 | ConsultationDetail.tsx | 聊天气泡+图片/语音查看+导出 |
|
||||
| 13 | 医护列表 | DoctorList.tsx | 列表+科室筛选+在线状态 |
|
||||
|
||||
### 5.2 技术要点
|
||||
|
||||
- **ECharts 趋势图** — 血压/体重/血糖曲线图,按日期范围展示
|
||||
- **文件上传/预览** — 化验单图片、体检报告 PDF(需新增基础能力)
|
||||
- **日历组件** — Ant Design Calendar 用于排班和预约视图
|
||||
- **聊天 UI** — 消息气泡展示(只读,非实时聊天)
|
||||
- **导出** — 随访台账、咨询记录导出为 Excel
|
||||
|
||||
---
|
||||
|
||||
## 6. 事件集成
|
||||
|
||||
### 6.1 发布事件
|
||||
|
||||
| 事件类型 | 触发时机 | 载荷 |
|
||||
|----------|----------|------|
|
||||
| `patient.created` | 创建患者 | `{patient_id, name, tenant_id}` |
|
||||
| `patient.updated` | 更新患者信息 | `{patient_id, changed_fields}` |
|
||||
| `appointment.created` | 创建预约 | `{appointment_id, patient_id, doctor_id, date}` |
|
||||
| `appointment.confirmed` | 确认预约 | `{appointment_id}` |
|
||||
| `appointment.cancelled` | 取消预约 | `{appointment_id, cancel_reason}` |
|
||||
| `appointment.completed` | 完成就诊 | `{appointment_id}` |
|
||||
| `follow_up.created` | 创建随访任务 | `{task_id, patient_id, assigned_to, planned_date}` |
|
||||
| `follow_up.completed` | 完成随访 | `{task_id, record_id, result}` |
|
||||
| `lab_report.uploaded` | 上传化验报告 | `{report_id, patient_id, report_type, abnormal_count}` |
|
||||
| `consultation.opened` | 开启咨询 | `{session_id, patient_id, doctor_id}` |
|
||||
| `consultation.closed` | 关闭咨询 | `{session_id}` |
|
||||
| `patient.deceased` | 患者死亡标记 | `{patient_id}` |
|
||||
| `patient.verified` | 实名认证通过 | `{patient_id, id_number}` |
|
||||
| `follow_up.overdue` | 随访任务逾期 | `{task_id, patient_id, planned_date}` |
|
||||
| `doctor.online_status_changed` | 医护在线状态变更 | `{doctor_id, old_status, new_status}` |
|
||||
|
||||
**随访记录自动创建后续任务:** 当 `follow_up_record.next_follow_up_date` 不为空时,服务层自动创建新的 `follow_up_task`(planned_date = next_follow_up_date,assigned_to 沿用当前医护)。
|
||||
|
||||
### 6.2 订阅事件
|
||||
|
||||
| 事件类型 | 处理逻辑 |
|
||||
|----------|----------|
|
||||
| `workflow.task.completed` | 工作流任务完成时更新随访任务状态 |
|
||||
| `message.sent` | 消息发送时联动咨询会话的 last_message_at |
|
||||
|
||||
---
|
||||
|
||||
## 7. 模块结构
|
||||
|
||||
```
|
||||
crates/erp-health/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── lib.rs ← ErpModule trait + public_routes() / protected_routes()
|
||||
│ ├── error.rs ← HealthError → AppError
|
||||
│ ├── state.rs ← HealthState (共享状态)
|
||||
│ ├── entity/ ← SeaORM Entity
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient.rs
|
||||
│ │ ├── patient_family_member.rs
|
||||
│ │ ├── patient_tag.rs
|
||||
│ │ ├── patient_tag_relation.rs
|
||||
│ │ ├── patient_doctor_relation.rs
|
||||
│ │ ├── doctor_profile.rs
|
||||
│ │ ├── health_record.rs
|
||||
│ │ ├── vital_signs.rs
|
||||
│ │ ├── lab_report.rs
|
||||
│ │ ├── health_trend.rs
|
||||
│ │ ├── appointment.rs
|
||||
│ │ ├── doctor_schedule.rs
|
||||
│ │ ├── follow_up_task.rs
|
||||
│ │ ├── follow_up_record.rs
|
||||
│ │ ├── consultation_session.rs
|
||||
│ │ └── consultation_message.rs
|
||||
│ ├── service/ ← 业务逻辑
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient_service.rs
|
||||
│ │ ├── health_data_service.rs
|
||||
│ │ ├── appointment_service.rs
|
||||
│ │ ├── follow_up_service.rs
|
||||
│ │ └── consultation_service.rs
|
||||
│ ├── handler/ ← Axum 路由
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient_handler.rs
|
||||
│ │ ├── health_data_handler.rs
|
||||
│ │ ├── appointment_handler.rs
|
||||
│ │ ├── follow_up_handler.rs
|
||||
│ │ └── consultation_handler.rs
|
||||
│ ├── dto/ ← 请求/响应结构体
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient_dto.rs
|
||||
│ │ ├── health_data_dto.rs
|
||||
│ │ ├── appointment_dto.rs
|
||||
│ │ ├── follow_up_dto.rs
|
||||
│ │ └── consultation_dto.rs
|
||||
│ └── event.rs ← 事件定义和处理器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 权限定义
|
||||
|
||||
### 8.1 权限码
|
||||
|
||||
| 权限码 | 名称 | 说明 |
|
||||
|--------|------|------|
|
||||
| `health.patient.list` | 查看患者列表 | 查看和搜索患者列表、详情 |
|
||||
| `health.patient.manage` | 管理患者 | 创建、编辑、删除患者 |
|
||||
| `health.health-data.list` | 查看健康数据 | 查看体检记录、监测数据、化验报告 |
|
||||
| `health.health-data.manage` | 管理健康数据 | 录入、编辑、删除健康数据 |
|
||||
| `health.appointment.list` | 查看预约 | 查看预约列表和排班 |
|
||||
| `health.appointment.manage` | 管理预约 | 创建、确认、取消预约 |
|
||||
| `health.follow-up.list` | 查看随访 | 查看随访任务和记录 |
|
||||
| `health.follow-up.manage` | 管理随访 | 创建、分配、完成随访任务 |
|
||||
| `health.consultation.list` | 查看咨询 | 查看咨询会话和消息记录 |
|
||||
| `health.consultation.manage` | 管理咨询 | 关闭会话、导出记录 |
|
||||
| `health.doctor.list` | 查看医护 | 查看医护列表和详情 |
|
||||
| `health.doctor.manage` | 管理医护 | 创建、编辑医护档案、排班 |
|
||||
|
||||
### 8.2 数据范围
|
||||
|
||||
| 实体 | 支持的数据范围级别 | 说明 |
|
||||
|------|-------------------|------|
|
||||
| patient | self, department, department_tree, all | 医生只能看自己负责的患者或本科室患者 |
|
||||
| follow_up_task | self, department, department_tree, all | 医护只能看分配给自己的随访任务 |
|
||||
| appointment | self, department, department_tree, all | 按科室隔离预约数据 |
|
||||
|
||||
### 8.3 角色模板
|
||||
|
||||
| 角色 | 权限 |
|
||||
|------|------|
|
||||
| health_admin | 全部 health.* 权限 |
|
||||
| doctor | health.patient.list, health.health-data.*, health.appointment.list, health.follow-up.*, health.consultation.list, health.doctor.list |
|
||||
| nurse | health.patient.list, health.health-data.*, health.follow-up.*, health.appointment.list |
|
||||
| receptionist | health.patient.*, health.appointment.*, health.doctor.list |
|
||||
|
||||
---
|
||||
|
||||
## 9. 能力扩展
|
||||
|
||||
V1 需要新增以下基础能力(在 erp-core 或独立模块中):
|
||||
|
||||
1. **文件上传服务** — 文件存储(本地/OSS)、URL 生成、图片缩略图
|
||||
2. **趋势分析** — 时序数据聚合、异常检测逻辑
|
||||
3. **报告批注** — 医生对化验报告的解读/批注能力
|
||||
4. **导出增强** — 健康数据导出为 Excel/PDF
|
||||
|
||||
---
|
||||
|
||||
## 10. 实施步骤
|
||||
|
||||
### Phase 1: 项目初始化
|
||||
- 拷贝 ERP 到 hms
|
||||
- 验证编译和构建
|
||||
|
||||
### Phase 2: erp-health 骨架
|
||||
- 创建 crate 结构
|
||||
- 实现 ErpModule trait + `public_routes()` / `protected_routes()` 固有方法
|
||||
- 注册到 workspace
|
||||
|
||||
### Phase 3: 数据库迁移
|
||||
- 16 张表(14 业务实体 + 2 关联表)的迁移文件
|
||||
- 索引创建、唯一约束
|
||||
|
||||
### Phase 4: 业务逻辑(按域迭代)
|
||||
- ① 患者与医护管理
|
||||
- ② 健康数据管理
|
||||
- ③ 预约排班
|
||||
- ④ 随访管理
|
||||
- ⑤ 咨询管理
|
||||
|
||||
### Phase 5: 前端页面
|
||||
- 13 个自定义 React 页面
|
||||
- 路由注册和侧边栏菜单
|
||||
|
||||
### Phase 6: 集成测试
|
||||
- API 端点测试
|
||||
- 多租户隔离验证
|
||||
- 端到端功能验证
|
||||
@@ -0,0 +1,509 @@
|
||||
# HMS 患者小程序设计规格
|
||||
|
||||
> **版本**: v1.0
|
||||
> **日期**: 2026-04-23
|
||||
> **状态**: 草案
|
||||
> **关联**: 健康模块设计规格 `2026-04-23-health-management-module-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 产品定位
|
||||
|
||||
HMS 患者小程序是**综合健康管理入口**,面向体检中心/医疗机构的患者。覆盖体检预约、报告查询、健康数据长期监测、随访管理、家庭健康管理等场景。
|
||||
|
||||
医护端以 PC 管理后台(`apps/web/`)为主力,小程序聚焦患者体验。医护端小程序可在后续按需补一个轻量版(随访提醒、排班查看),不在本规格范围内。
|
||||
|
||||
### 1.2 核心决策
|
||||
|
||||
| 维度 | 决策 | 原因 |
|
||||
|------|------|------|
|
||||
| 技术选型 | Taro 4 + React 19 | 与 Web 端 React 技能复用,支持多端编译 |
|
||||
| 架构方案 | 直连后端 | MVP 阶段最务实,复用 erp-server API |
|
||||
| 登录方式 | 微信授权 + 手机号补充 | 降低门槛 + 确保身份可靠 |
|
||||
| 代码位置 | `apps/miniprogram/`(Monorepo) | 方便接口同步,共享类型定义 |
|
||||
| 目标平台 | 微信小程序优先 | 覆盖最广泛用户,后续可扩展 |
|
||||
| 数据录入 | 手动 + 蓝牙预留接口 | MVP 快速交付,后续对接设备 |
|
||||
| 视觉风格 | 医疗清新(青色主调) | 专业可靠,沿用现有 HTML 原型风格 |
|
||||
|
||||
### 1.3 MVP 功能范围
|
||||
|
||||
**MVP 包含(7 个功能模块):**
|
||||
1. 登录 + 个人中心
|
||||
2. 健康数据录入 + 趋势图
|
||||
3. 预约挂号
|
||||
4. 报告查询
|
||||
5. 随访管理
|
||||
6. 家庭健康管理(就诊人切换)
|
||||
7. 健康资讯 + 用药提醒
|
||||
|
||||
**后续版本:**
|
||||
- 在线咨询(即时通讯,WebSocket 长连接)
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目结构
|
||||
|
||||
```
|
||||
apps/miniprogram/
|
||||
├── config/ # Taro 编译配置
|
||||
│ ├── index.ts # 通用配置
|
||||
│ ├── dev.ts # 开发环境
|
||||
│ └── prod.ts # 生产环境
|
||||
├── project.config.json # 微信小程序项目配置
|
||||
├── src/
|
||||
│ ├── app.config.ts # Taro 全局配置(TabBar、页面路由)
|
||||
│ ├── app.tsx # 入口组件
|
||||
│ ├── app.scss # 全局样式(医疗清新主题变量)
|
||||
│ ├── components/ # 通用组件
|
||||
│ │ ├── HealthCard/ # 健康指标卡片(血压/血糖/体重)
|
||||
│ │ ├── AppointmentCard/ # 预约卡片
|
||||
│ │ ├── ReportItem/ # 报告列表项
|
||||
│ │ ├── FamilyPicker/ # 就诊人切换器
|
||||
│ │ ├── EmptyState/ # 空状态占位
|
||||
│ │ └── TrendChart/ # 趋势图(echarts-taro3-react)
|
||||
│ ├── pages/
|
||||
│ │ ├── index/ # 首页(今日健康+快捷入口+待办)
|
||||
│ │ ├── health/ # 健康数据(录入+趋势图)
|
||||
│ │ ├── appointment/ # 预约(列表+新建预约)
|
||||
│ │ ├── report/ # 报告(体检报告+化验单)
|
||||
│ │ ├── followup/ # 随访(任务+问卷填写)
|
||||
│ │ ├── article/ # 健康资讯(文章列表+详情)
|
||||
│ │ ├── profile/ # 我的(个人信息+就诊人管理+设置)
|
||||
│ │ └── login/ # 登录(微信授权+手机号)
|
||||
│ ├── services/ # API 调用层
|
||||
│ │ ├── request.ts # 封装 Taro.request(JWT 注入、错误处理)
|
||||
│ │ ├── auth.ts # 登录/刷新 token
|
||||
│ │ ├── health.ts # 健康数据 CRUD
|
||||
│ │ ├── appointment.ts # 预约 CRUD
|
||||
│ │ ├── report.ts # 报告查询
|
||||
│ │ ├── followup.ts # 随访任务/记录
|
||||
│ │ └── article.ts # 资讯/科普
|
||||
│ ├── stores/ # Zustand 状态管理
|
||||
│ │ ├── auth.ts # 登录态、用户信息、就诊人列表
|
||||
│ │ └── health.ts # 健康数据缓存
|
||||
│ ├── utils/
|
||||
│ │ ├── bluetooth.ts # 蓝牙接口预留(MVP 不实现)
|
||||
│ │ ├── format.ts # 日期/数值格式化
|
||||
│ │ └── constants.ts # 常量定义
|
||||
│ └── styles/
|
||||
│ ├── variables.scss # 主题变量(青色主调)
|
||||
│ └── mixins.scss # 常用样式混入
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
**设计原则:**
|
||||
- services 层与 Web 端 `apps/web/src/api/` 职责对齐,用 Taro.request 替代 fetch
|
||||
- stores 复用 Zustand 模式,与 Web 端保持一致的状态管理风格
|
||||
- 组件命名 PascalCase 目录,与 Web 端风格统一
|
||||
- MVP 阶段不强抽取 `packages/shared/`,等两端跑起来后根据重复度决定
|
||||
|
||||
---
|
||||
|
||||
## 3. 认证流程
|
||||
|
||||
### 3.1 整体流程
|
||||
|
||||
```
|
||||
用户打开小程序
|
||||
↓
|
||||
检查本地 storage 有无有效 JWT
|
||||
├── 有且未过期 → 直接进入首页
|
||||
└── 无或已过期 ↓
|
||||
|
||||
Step 1: 微信静默登录
|
||||
wx.login() → code
|
||||
→ POST /api/v1/auth/wechat/login { code }
|
||||
→ 后端用 code 换 openid,查找绑定用户
|
||||
├── 已绑定 → 签发 JWT { token, user }
|
||||
└── 未绑定 → 返回 { need_bind: true, openid }
|
||||
|
||||
Step 2: 手机号绑定(仅新用户)
|
||||
wx.getPhoneNumber 按钮组件 → encryptedData + iv
|
||||
→ POST /api/v1/auth/wechat/bind-phone { openid, encryptedData, iv }
|
||||
→ 后端解密手机号,创建/关联 user + patient 档案
|
||||
→ 签发 JWT { token, user, patient }
|
||||
|
||||
Step 3: 补充档案(首次绑定后)
|
||||
→ 引导填写姓名、性别、出生日期、身份证号(可选)
|
||||
→ PUT /api/v1/health/patients/me { name, gender, birthday }
|
||||
```
|
||||
|
||||
### 3.2 后端新增内容
|
||||
|
||||
**`erp-auth` 新增:**
|
||||
|
||||
| 新增 | 说明 |
|
||||
|------|------|
|
||||
| `wechat_users` 表 | `id, openid, union_id, user_id, phone, created_at, updated_at` |
|
||||
| `POST /api/v1/auth/wechat/login` | code → openid 查询,返回绑定状态 |
|
||||
| `POST /api/v1/auth/wechat/bind-phone` | 绑定手机号,创建 user + patient |
|
||||
| `GET /api/v1/auth/wechat/qrcode` | 生成带参数小程序码(PC 端扫码登录场景) |
|
||||
|
||||
`wechat_users` 表必须包含 `tenant_id`(多租户隔离)和标准审计字段(`created_at`, `updated_at`, `deleted_at`)。
|
||||
|
||||
### 3.3 Token 策略
|
||||
|
||||
| Token | 有效期 | 存储 |
|
||||
|-------|--------|------|
|
||||
| Access Token (JWT) | 15 分钟 | 内存 + Taro.setStorage |
|
||||
| Refresh Token | 7 天 | Taro.setStorage |
|
||||
|
||||
自动刷新机制:`services/request.ts` 拦截 401 → 调用 `POST /auth/refresh` → 重试原请求。刷新失败则跳转登录页。
|
||||
|
||||
### 3.4 多就诊人
|
||||
|
||||
- 一个微信账号可管理多个 patient(本人 + 家人)
|
||||
- 切换就诊人时请求 header 带 `X-Patient-Id`
|
||||
- 后端校验该 patient 属于当前 user
|
||||
|
||||
---
|
||||
|
||||
## 4. 页面结构与导航
|
||||
|
||||
### 4.1 Tab Bar
|
||||
|
||||
底部导航栏 5 个入口:
|
||||
|
||||
| Tab | 图标 | 页面路径 |
|
||||
|-----|------|----------|
|
||||
| 首页 | 🏠 | /pages/index/index |
|
||||
| 健康 | 📊 | /pages/health/index |
|
||||
| 预约 | 📅 | /pages/appointment/index |
|
||||
| 资讯 | 📰 | /pages/article/index |
|
||||
| 我的 | 👤 | /pages/profile/index |
|
||||
|
||||
### 4.2 页面层级
|
||||
|
||||
```
|
||||
Tab: 首页 /pages/index
|
||||
├── /pages/notifications/index # 通知列表
|
||||
└── /pages/followup/detail/index # 随访任务详情
|
||||
|
||||
Tab: 健康 /pages/health
|
||||
├── /pages/health/input/index # 录入数据
|
||||
├── /pages/health/trend/index # 指标趋势
|
||||
└── /pages/health/history/index # 历史记录
|
||||
|
||||
Tab: 预约 /pages/appointment
|
||||
├── /pages/appointment/create/index # 新建预约
|
||||
└── /pages/appointment/detail/index # 预约详情
|
||||
|
||||
Tab: 资讯 /pages/article
|
||||
└── /pages/article/detail/index # 文章详情
|
||||
|
||||
Tab: 我的 /pages/profile
|
||||
├── /pages/profile/family/index # 就诊人管理
|
||||
├── /pages/profile/family-add/index # 添加就诊人
|
||||
├── /pages/profile/reports/index # 我的报告
|
||||
├── /pages/profile/followups/index # 我的随访
|
||||
├── /pages/profile/medication/index # 用药提醒
|
||||
└── /pages/profile/settings/index # 设置
|
||||
|
||||
独立页面(不在 Tab 内):
|
||||
├── /pages/login/index # 登录
|
||||
└── /pages/login/profile/index # 档案补全
|
||||
```
|
||||
|
||||
### 4.3 首页布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ 问候栏(渐变青色背景) │ 用户名 + 日期 + 通知铃铛
|
||||
├─────────────────────────────┤
|
||||
│ 今日健康卡片(上浮 -20px) │ 血压/心率/血糖/体重 2×2 网格
|
||||
├─────────────────────────────┤
|
||||
│ 快捷服务(4 宫格) │ 录数据/预约/报告/随访
|
||||
├─────────────────────────────┤
|
||||
│ 即将到来 │ 最近 1 条预约卡片
|
||||
├─────────────────────────────┤
|
||||
│ 待办随访 │ 最多 2 条待办 + 查看全部
|
||||
├─────────────────────────────┤
|
||||
│ [ 首页 ] [ 健康 ] [ 预约 ] [ 资讯 ] [ 我的 ] │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 核心功能数据流
|
||||
|
||||
### 5.1 健康数据录入
|
||||
|
||||
```
|
||||
选择指标类型 → 输入数值 + 测量时间 → 添加备注(可选)→ POST /vital-signs
|
||||
↓
|
||||
成功 → 更新首页卡片 + 趋势缓存
|
||||
失败 → Toast + 本地暂存
|
||||
```
|
||||
|
||||
**MVP 支持的指标类型:**
|
||||
|
||||
| 指标 | 单位 | 输入控件 |
|
||||
|------|------|---------|
|
||||
| 收缩压 / 舒张压 | mmHg | 两个数字输入框 |
|
||||
| 心率 | bpm | 数字输入框 |
|
||||
| 空腹血糖 | mmol/L | 数字输入框 |
|
||||
| 餐后血糖 | mmol/L | 数字输入框 |
|
||||
| 体重 | kg | 数字输入框(1 位小数) |
|
||||
| 体温 | ℃ | 数字输入框(1 位小数) |
|
||||
|
||||
每次可同时填多项或只填一项。录入时间默认当前,可手动调整为当天任意时间。
|
||||
|
||||
### 5.2 预约挂号
|
||||
|
||||
```
|
||||
选择科室 → 选择医生 → 选择日期(排班日历)→ 选择时段 → 确认预约
|
||||
↓
|
||||
POST /appointments
|
||||
↓
|
||||
成功 → 订阅消息通知 + 日历同步
|
||||
满员 → 提示"该时段已满"
|
||||
```
|
||||
|
||||
**关键交互:**
|
||||
- 排班日历用周视图,有排班的日期标绿点
|
||||
- 点击日期后展示该日可用时段
|
||||
- 时段显示"剩余 X 位"
|
||||
- 预约成功后支持微信订阅消息提醒
|
||||
|
||||
### 5.3 报告查询
|
||||
|
||||
```
|
||||
报告列表(分页,时间倒序)
|
||||
↓ 点击某份报告
|
||||
报告详情 → 基本信息卡 + 指标列表 + PDF/图片附件预览
|
||||
```
|
||||
|
||||
**指标状态标记:**
|
||||
- 异常偏高:红色 + ↑ 箭头
|
||||
- 异常偏低:红色 + ↓ 箭头
|
||||
- 正常范围:灰色
|
||||
|
||||
### 5.4 随访管理
|
||||
|
||||
```
|
||||
待办列表(按截止日期排序)
|
||||
↓ 支持"待完成/已完成/已过期"筛选
|
||||
点击任务 → 动态表单(后端定义字段)→ 提交 → 标记完成
|
||||
```
|
||||
|
||||
问卷由 PC 端医护创建(follow_up_task),小程序负责展示和填写。提交后创建 follow_up_record。
|
||||
|
||||
### 5.5 家庭健康管理
|
||||
|
||||
```
|
||||
就诊人列表(本人 + 已添加家属)
|
||||
↓ 点击头像或下拉切换
|
||||
切换就诊人 → 全局 X-Patient-Id 更新 → 所有页面数据刷新
|
||||
↓ 添加家属
|
||||
填写信息 → 姓名 + 关系 + 身份证号(可选)→ POST /patients
|
||||
```
|
||||
|
||||
切换就诊人通过全局 store 更新,所有 service 请求自动携带新 `X-Patient-Id`。
|
||||
|
||||
### 5.6 健康资讯 + 用药提醒
|
||||
|
||||
**资讯:**
|
||||
- `GET /articles` → 分页列表(缩略图 + 标题 + 摘要 + 时间)
|
||||
- 文章详情使用 Taro `RichText` 组件渲染富文本
|
||||
|
||||
**用药提醒(MVP):**
|
||||
- 小程序本地 storage 存储提醒规则(药品名 + 频率 + 时间)
|
||||
- 每日触发检查
|
||||
- 通过微信订阅消息推送提醒
|
||||
- 不依赖后端新表
|
||||
|
||||
---
|
||||
|
||||
## 6. API 集成与状态管理
|
||||
|
||||
### 6.1 请求层封装
|
||||
|
||||
`services/request.ts` 职责:
|
||||
|
||||
| 拦截点 | 行为 |
|
||||
|--------|------|
|
||||
| 请求拦截 | 自动注入 `Authorization: Bearer {token}` |
|
||||
| 请求拦截 | 自动注入 `X-Patient-Id`(当前选中就诊人) |
|
||||
| 请求拦截 | 自动注入 `X-Tenant-Id`(从登录信息获取) |
|
||||
| 响应拦截 | 401 → 静默刷新 token → 重试原请求 |
|
||||
| 响应拦截 | 刷新失败 → 跳转登录页 |
|
||||
| 错误处理 | 网络错误 / 业务错误 / 超时统一处理 |
|
||||
|
||||
多租户处理:患者只属于一个租户。登录时后端返回 `tenant_id`,前端每次请求带上。不走 `tenant_id` 中间件自动注入。
|
||||
|
||||
### 6.2 Zustand Stores
|
||||
|
||||
**auth store:**
|
||||
|
||||
```typescript
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
refreshToken: string | null
|
||||
user: { id: string; name: string; phone: string; avatar: string } | null
|
||||
currentPatient: Patient | null
|
||||
patients: Patient[]
|
||||
setCurrentPatient: (id: string) => void
|
||||
login: (code: string) => Promise<void>
|
||||
bindPhone: (data: BindPhoneData) => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**health store:**
|
||||
|
||||
```typescript
|
||||
interface HealthState {
|
||||
todaySummary: VitalSigns | null
|
||||
trendData: Record<string, TrendPoint[]>
|
||||
refreshToday: () => Promise<void>
|
||||
getTrend: (type: string, range: '7d' | '30d' | '90d') => Promise<TrendPoint[]>
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 API 端点对应表
|
||||
|
||||
| 小程序 service | 后端端点 | 方法 |
|
||||
|----------------|----------|------|
|
||||
| `auth.login(code)` | `/api/v1/auth/wechat/login` | POST |
|
||||
| `auth.bindPhone(data)` | `/api/v1/auth/wechat/bind-phone` | POST |
|
||||
| `auth.refresh()` | `/api/v1/auth/refresh` | POST |
|
||||
| `health.getToday()` | `/api/v1/health/vital-signs?date=today` | GET |
|
||||
| `health.input(data)` | `/api/v1/health/vital-signs` | POST |
|
||||
| `health.getTrend(type, range)` | `/api/v1/health/vital-signs/trend` | GET |
|
||||
| `appointment.list()` | `/api/v1/health/appointments` | GET |
|
||||
| `appointment.create(data)` | `/api/v1/health/appointments` | POST |
|
||||
| `appointment.cancel(id)` | `/api/v1/health/appointments/:id/cancel` | PUT |
|
||||
| `schedule.getByDoctor(id)` | `/api/v1/health/doctor-schedules` | GET |
|
||||
| `report.list()` | `/api/v1/health/lab-reports` | GET |
|
||||
| `report.detail(id)` | `/api/v1/health/lab-reports/:id` | GET |
|
||||
| `followup.list()` | `/api/v1/health/follow-up-tasks` | GET |
|
||||
| `followup.submit(id, data)` | `/api/v1/health/follow-up-records` | POST |
|
||||
| `patient.list()` | `/api/v1/health/patients` | GET |
|
||||
| `patient.create(data)` | `/api/v1/health/patients` | POST |
|
||||
| `patient.update(id, data)` | `/api/v1/health/patients/:id` | PUT |
|
||||
|
||||
**后端需新增的端点(尚未实现):**
|
||||
|
||||
| 端点 | 说明 |
|
||||
|------|------|
|
||||
| `POST /auth/wechat/login` | 微信登录 |
|
||||
| `POST /auth/wechat/bind-phone` | 手机号绑定 |
|
||||
| `GET /vital-signs/trend` | 趋势聚合查询 |
|
||||
| `GET /doctor-schedules` | 按科室/医生查询排班 |
|
||||
| `GET /articles` | 健康资讯列表 |
|
||||
| `GET /articles/:id` | 资讯详情 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 视觉设计
|
||||
|
||||
### 7.1 主题色
|
||||
|
||||
沿用现有 HTML 原型的医疗清新风格:
|
||||
|
||||
| 用途 | 色值 | 说明 |
|
||||
|------|------|------|
|
||||
| 主色 | `#0891B2` | 青色,按钮、导航、强调 |
|
||||
| 主色浅 | `#E0F7FA` | 背景、卡片高亮 |
|
||||
| 主色深 | `#065A73` | 渐变、按压态 |
|
||||
| 辅助色 | `#059669` | 绿色,成功、正常指标 |
|
||||
| 危险色 | `#DC2626` | 红色,异常指标、删除 |
|
||||
| 警告色 | `#D97706` | 琥珀,待办、提醒 |
|
||||
| 背景色 | `#F0FDFA` | 页面底色 |
|
||||
| 卡片色 | `#FFFFFF` | 卡片背景 |
|
||||
| 主文字 | `#134E4A` | 标题、正文 |
|
||||
| 副文字 | `#6B7280` | 说明、标签 |
|
||||
| 轻文字 | `#94A3B8` | 时间戳、占位符 |
|
||||
|
||||
### 7.2 圆角规范
|
||||
|
||||
| 元素 | 圆角 |
|
||||
|------|------|
|
||||
| 卡片 | 12px |
|
||||
| 按钮 | 8px |
|
||||
| 输入框 | 8px |
|
||||
| 头像 | 50% |
|
||||
| 快捷图标 | 14px |
|
||||
|
||||
### 7.3 阴影规范
|
||||
|
||||
| 层级 | 值 |
|
||||
|------|---|
|
||||
| 轻阴影 | `0 1px 3px rgba(0,0,0,.04)` |
|
||||
| 标准阴影 | `0 2px 8px rgba(0,0,0,.06)` |
|
||||
| 中阴影 | `0 4px 16px rgba(0,0,0,.08)` |
|
||||
| 重阴影 | `0 8px 32px rgba(0,0,0,.12)` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 开发工作流
|
||||
|
||||
### 8.1 开发环境
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
cd apps/miniprogram && pnpm install
|
||||
|
||||
# 开发模式(需配合微信开发者工具)
|
||||
pnpm dev:weapp # Taro 编译 + watch → dist/
|
||||
# 用微信开发者工具打开 dist/ 目录预览
|
||||
|
||||
# 生产构建
|
||||
pnpm build:weapp # 压缩 + tree-shaking
|
||||
|
||||
# 后端联调
|
||||
# 需同时运行 erp-server (port 3000)
|
||||
# 小程序开发设置中关闭域名校验(开发阶段)
|
||||
```
|
||||
|
||||
### 8.2 与 Web 端的代码复用
|
||||
|
||||
| 复用内容 | 方式 | 说明 |
|
||||
|---------|------|------|
|
||||
| TypeScript 类型 | 按需引用 Web 端 DTO 类型 | API 请求/响应结构一致 |
|
||||
| 主题变量值 | Web CSS 变量 → SCSS 变量 | 青色主调色值保持一致 |
|
||||
| Zustand 模式 | 相同 store 设计模式 | 各自独立实现 |
|
||||
| API 接口定义 | service 层函数签名对齐 | Web 用 fetch,小程序用 Taro.request |
|
||||
|
||||
### 8.3 后端需同步开发的内容
|
||||
|
||||
| 优先级 | 内容 | 涉及 crate |
|
||||
|--------|------|-----------|
|
||||
| P0 | `wechat_users` 表 + 微信登录/绑定 API | erp-auth |
|
||||
| P0 | `vital_signs` 趋势查询 API | erp-health |
|
||||
| P0 | `doctor_schedules` 按科室/医生查询 API | erp-health |
|
||||
| P1 | `lab_reports` 指标异常标注字段 | erp-health |
|
||||
| P1 | `follow_up_tasks` 动态问卷字段扩展 | erp-health |
|
||||
| P2 | `articles` 表 + CRUD | erp-health |
|
||||
| P2 | 微信订阅消息模板注册 | erp-server |
|
||||
|
||||
---
|
||||
|
||||
## 9. 分期交付计划
|
||||
|
||||
| 阶段 | 内容 | 目标 |
|
||||
|------|------|------|
|
||||
| Phase 1 | 项目骨架 + 登录流程 + 首页(静态数据) | 基础搭建 |
|
||||
| Phase 2 | 健康数据录入 + 趋势图 | 核心功能 |
|
||||
| Phase 3 | 预约挂号 + 排班日历 | 核心功能 |
|
||||
| Phase 4 | 报告查询 + 家庭管理 | 扩展功能 |
|
||||
| Phase 5 | 随访 + 资讯 + 用药提醒 | 扩展功能 |
|
||||
| Phase 6 | 打磨 + 真机测试 + 提审 | 上线准备 |
|
||||
|
||||
每个 Phase 内部遵循:先对接后端 API → 再实现 UI → 真机验证 → 提交。
|
||||
|
||||
---
|
||||
|
||||
## 10. 约束与风险
|
||||
|
||||
| 约束/风险 | 应对策略 |
|
||||
|-----------|---------|
|
||||
| 小程序包体积限制(2MB 主包) | 按功能分包加载,图表库按需引入 |
|
||||
| 微信审核周期(3-7 天) | Phase 6 预留充足审核时间 |
|
||||
| 后端 API 部分未实现 | 小程序开发与后端同步推进,优先实现 P0 端点 |
|
||||
| 微信订阅消息需用户主动触发 | 在预约成功、随访提交等场景引导用户订阅 |
|
||||
| 蓝牙设备适配复杂 | MVP 预留接口不实现,后续按设备型号逐一对接 |
|
||||
| 多就诊人数据隔离 | 后端严格校验 user-patient 归属关系 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,671 @@
|
||||
# 健康管理模块全面迭代设计
|
||||
|
||||
> **文档版本**: 1.0
|
||||
> **日期**: 2026-04-24
|
||||
> **状态**: 待评审
|
||||
> **基于**: 5 位专家(后端架构/前端架构/医疗业务/安全质量/产品策略)深度审查
|
||||
|
||||
---
|
||||
|
||||
## 0. 审查发现总览
|
||||
|
||||
### 0.1 V1 发布阻塞项
|
||||
|
||||
| # | 阻塞项 | 来源 | 影响 |
|
||||
|---|--------|------|------|
|
||||
| B1 | Web 健康模块 10 页面未实现 | 前端架构/产品策略 | 无法演示和交付 |
|
||||
| B2 | 医疗数据安全不合规 | 安全质量 | 零 sanitize / 零审计 / 身证明文 / 零测试 |
|
||||
| B3 | 数据一致性缺陷 | 医疗业务/后端架构 | 排班可超额 / 名额释放可能失败 / 随访逾期未实现 |
|
||||
| B4 | 事件处理器空壳 | 后端架构 | 随访状态/咨询消息不联动 |
|
||||
|
||||
### 0.2 当前完成度
|
||||
|
||||
| 层级 | 模块 | 完成度 |
|
||||
|------|------|--------|
|
||||
| 后端 | erp-health(16 实体/8 服务/7 handler/40+ API) | 95% |
|
||||
| 后端 | 事件处理器业务逻辑 | 0%(框架已搭建,需填充 db 操作) |
|
||||
| 后端 | sanitize / 审计 / 加密 | 0% |
|
||||
| 后端 | 测试覆盖 | 0% |
|
||||
| Web 前端 | 健康模块页面 | 0% |
|
||||
| Web 前端 | 健康模块 API 服务层 | 0% |
|
||||
| 小程序 | 初版 21 页面 | 85% |
|
||||
|
||||
---
|
||||
|
||||
## 1. 安全省基(阶段 1,1.5-2 周)
|
||||
|
||||
### 1.1 sanitize 全覆盖
|
||||
|
||||
**问题**: erp-health 模块没有任何对 `strip_html_tags` 的调用,攻击者可在患者姓名、病史等字段注入 XSS payload。
|
||||
|
||||
**参考实现**: `crates/erp-auth/src/dto.rs` 第 96-118 行,`CreateUserReq` 和 `UpdateUserReq` 已实现 `sanitize()` 方法。
|
||||
|
||||
**修复方案**: 为每个 DTO 的字符串输入字段添加 sanitize。
|
||||
|
||||
**覆盖字段清单**:
|
||||
|
||||
| DTO 文件 | 字段 |
|
||||
|----------|------|
|
||||
| `patient_dto.rs` CreatePatientReq / UpdatePatientReq | name, notes, allergy_history, medical_history_summary, emergency_contact_name, source |
|
||||
| `patient_dto.rs` FamilyMemberReq(create + update 共用) | name, notes |
|
||||
| `patient_handler.rs` AssignDoctorReq(位于 handler 非 dto) | — (无字符串字段) |
|
||||
| `health_data_dto.rs` CreateVitalSignsReq | notes |
|
||||
| `health_data_dto.rs` CreateLabReportReq | doctor_interpretation |
|
||||
| `health_data_dto.rs` CreateHealthRecordReq | source, overall_assessment, notes |
|
||||
| `appointment_dto.rs` CreateAppointmentReq | notes, cancel_reason |
|
||||
| `follow_up_dto.rs` CreateFollowUpTaskReq / UpdateFollowUpTaskReq | content_template |
|
||||
| `follow_up_dto.rs` CreateFollowUpRecordReq | patient_condition, medical_advice |
|
||||
| `consultation_dto.rs` CreateMessageReq | content |
|
||||
| `consultation_dto.rs` CreateSessionReq | — (无字符串字段) |
|
||||
| `doctor_dto.rs` CreateDoctorReq / UpdateDoctorReq | department, title, specialty, bio |
|
||||
|
||||
**实现模式**:
|
||||
|
||||
```rust
|
||||
// 封装 sanitize 辅助函数(与 erp-auth 的 sanitize_option 模式一致)
|
||||
fn sanitize_option_string(opt: Option<String>) -> Option<String> {
|
||||
opt.map(|s| strip_html_tags(&s))
|
||||
}
|
||||
|
||||
// 在每个 DTO 的 impl 中添加 sanitize 方法
|
||||
impl CreatePatientReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.name = strip_html_tags(&self.name);
|
||||
self.notes = sanitize_option_string(self.notes.take());
|
||||
self.allergy_history = sanitize_option_string(self.allergy_history.take());
|
||||
self.medical_history_summary = sanitize_option_string(self.medical_history_summary.take());
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// 在 handler 调用 service 前执行
|
||||
async fn create_patient(/* ... */) -> AppResult<Json<ApiResponse<PatientResp>>> {
|
||||
let mut req: CreatePatientReq = Json(req).0;
|
||||
req.sanitize();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**前端安全**: ChatBubble 组件必须使用 React 默认 JSX 转义渲染文本内容(不使用 `dangerouslySetInnerHTML`),图片消息 URL 需做白名单校验。
|
||||
|
||||
### 1.2 审计日志注入
|
||||
|
||||
**问题**: erp-health 整个模块没有任何对 `audit_service::record` 的调用。
|
||||
|
||||
**参考实现**: `crates/erp-auth/src/service/auth_service.rs` 第 168-177 行。
|
||||
|
||||
**修复方案**: 在所有写入操作的 service 层添加审计记录。
|
||||
|
||||
**覆盖操作清单**:
|
||||
|
||||
| Service | 操作 | 审计 action |
|
||||
|---------|------|------------|
|
||||
| patient_service | create_patient | `patient.created` |
|
||||
| patient_service | update_patient | `patient.updated` |
|
||||
| patient_service | delete_patient | `patient.deleted` |
|
||||
| patient_service | manage_patient_tags | `patient.tags_updated` |
|
||||
| health_data_service | create_vital_signs | `vital_signs.created` |
|
||||
| health_data_service | create_lab_report | `lab_report.created` |
|
||||
| health_data_service | create_health_record | `health_record.created` |
|
||||
| appointment_service | create_appointment | `appointment.created` |
|
||||
| appointment_service | update_appointment_status | `appointment.status_changed` |
|
||||
| follow_up_service | create_task | `follow_up_task.created` |
|
||||
| follow_up_service | create_record | `follow_up_record.created` |
|
||||
| consultation_service | create_session | `consultation.opened` |
|
||||
| consultation_service | close_session | `consultation.closed` |
|
||||
| consultation_service | create_message | `consultation.message_sent` |
|
||||
| doctor_service | create/update/delete_doctor | `doctor.*` |
|
||||
|
||||
**审计日志内容**: tenant_id、user_id、action、resource_type、resource_id、变更前后值摘要。
|
||||
|
||||
**注意**: 当前 `audit_service::record` 是 fire-and-forget,审计日志丢失对医疗合规不可接受。修复方案:
|
||||
1. 新增 `record_in_txn(log: AuditLog, txn: &DatabaseTransaction)` 方法,在事务内 await 写入
|
||||
2. 保留原 `record` 方法用于不要求事务保证的场景
|
||||
3. erp-health 的关键写入操作使用 `record_in_txn`,失败时回滚整个事务
|
||||
4. 需要改为事务包裹的 service 方法:create_patient、update_patient、delete_patient、create_appointment、update_appointment_status、create_record(随访)、create_message(咨询)
|
||||
|
||||
### 1.3 身份证号加密存储
|
||||
|
||||
**问题**: `patient.id_number` 明文存储在数据库中,违反《个人信息保护法》。
|
||||
|
||||
**方案**: AES-256-GCM 应用层加密。
|
||||
|
||||
**新增文件**: `crates/erp-health/src/crypto.rs`
|
||||
|
||||
```rust
|
||||
pub struct HealthCrypto { key: [u8; 32] }
|
||||
|
||||
impl HealthCrypto {
|
||||
pub fn from_env() -> Self { /* 从 ERP__HEALTH__ENCRYPTION_KEY 读取 */ }
|
||||
pub fn encrypt(&self, plaintext: &str) -> AppResult<String> { /* AES-256-GCM + Base64 */ }
|
||||
pub fn decrypt(&self, ciphertext: &str) -> AppResult<String> { /* 解密 */ }
|
||||
}
|
||||
```
|
||||
|
||||
**集成点**:
|
||||
- `patient_service::create_patient` — 加密 id_number 后存储
|
||||
- `patient_service::update_patient` — 同上
|
||||
- `patient_service::get_patient` — 解密后返回
|
||||
- `patient_service::list_patients` — 列表不返回 id_number(脱敏)
|
||||
|
||||
**密钥管理**: 环境变量 `ERP__HEALTH__ENCRYPTION_KEY`(32 字节 hex),必须在 `default.toml` 中标记为 `__MUST_SET_VIA_ENV__`。
|
||||
|
||||
**搜索兼容**: `patient.id_number` 的模糊搜索(`contains`)改为精确匹配(`eq`),在加密后使用 HMAC 索引做等值查询。
|
||||
|
||||
**HMAC 索引详情**:
|
||||
- 新增数据库列 `id_number_hash VARCHAR(64)`,存储 HMAC-SHA256 哈希
|
||||
- HMAC 密钥独立于 AES 密钥,从环境变量 `ERP__HEALTH__HMAC_KEY` 读取
|
||||
- 创建/更新患者时同时写入 hash 列,等值查询使用 `WHERE id_number_hash = hmac(输入值)`
|
||||
- 迁移 SQL:新增列 → 批量加密现有明文 → 删除原明文列(可选)
|
||||
|
||||
**数据迁移方案**:
|
||||
1. 停机窗口(预估 1-2 小时,视数据量)
|
||||
2. 迁移脚本:`SELECT id, id_number FROM patients WHERE id_number IS NOT NULL AND deleted_at IS NULL` → 批量加密 → `UPDATE patients SET id_number = $encrypted WHERE id = $id`
|
||||
3. 同步写入 `id_number_hash` 列
|
||||
4. 验证脚本:抽样解密比对原值
|
||||
5. 回滚方案:保留明文备份表 `patients_id_number_backup`,72 小时后确认无误再删除
|
||||
|
||||
**问题**: 列表接口直接返回完整身份证号、病史等敏感字段。
|
||||
|
||||
**修复方案**: 拆分响应 DTO。
|
||||
|
||||
```rust
|
||||
// 列表用 — 不含敏感字段
|
||||
pub struct PatientListResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub gender: Option<String>,
|
||||
pub birth_date: Option<NaiveDate>,
|
||||
pub status: String,
|
||||
pub tags: Vec<TagResp>,
|
||||
// 无 id_number, allergy_history, medical_history_summary, emergency_contact_phone 等
|
||||
}
|
||||
|
||||
// 详情用 — 敏感字段掩码
|
||||
pub struct PatientDetailResp {
|
||||
// ... 全部字段
|
||||
pub id_number: Option<String>, // "320***********1234"
|
||||
pub emergency_contact_phone: Option<String>, // "138****1234"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 后端补完(阶段 2,1.5 周)
|
||||
|
||||
### 2.1 事件处理器实现
|
||||
|
||||
**问题**: `event.rs` 中两个事件处理器只有 `tracing::info`,无实际业务逻辑。且 handler 中没有 `DatabaseConnection`,无法执行数据库操作。
|
||||
|
||||
**方案**: 在 `HealthModule::on_startup` 中创建 `HealthState` 并注册需要数据库访问的事件处理器。将现有 `register_event_handlers` 中的空壳代码迁移到 `on_startup`,`register_event_handlers` 改为空实现。
|
||||
|
||||
**修改 `crates/erp-health/src/module.rs`**:
|
||||
|
||||
```rust
|
||||
// register_event_handlers 改为空实现
|
||||
fn register_event_handlers(&self, _bus: &EventBus) {
|
||||
// 事件处理器迁移到 on_startup,此处不再注册
|
||||
}
|
||||
|
||||
// on_startup 中注册带 db 的事件处理器
|
||||
async fn on_startup(&self, ctx: &ModuleContext) -> AppResult<()> {
|
||||
let state = HealthState {
|
||||
db: ctx.db.clone(),
|
||||
event_bus: ctx.event_bus.clone(),
|
||||
};
|
||||
crate::event::register_handlers_with_state(state);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**修改 `crates/erp-health/src/event.rs`**:
|
||||
|
||||
新增 `register_handlers_with_state(state: HealthState)` 函数替代原有 `register_handlers`。
|
||||
|
||||
**事件处理器业务逻辑**:
|
||||
|
||||
`workflow.task.completed`:
|
||||
1. 从 payload 中提取 `task_id`
|
||||
2. 查询 `follow_up_task WHERE related_appointment_id` 或通过 payload 映射
|
||||
3. 更新随访任务状态为 `completed`
|
||||
|
||||
`message.sent`:
|
||||
1. 从 payload 中提取 `session_id`(或通过 sender/recipient 关联)
|
||||
2. 更新 `consultation_session SET last_message_at = NOW(), unread_count = unread_count + 1`
|
||||
3. 使用 `check_version` 乐观锁
|
||||
|
||||
### 2.2 数据一致性修复
|
||||
|
||||
#### 2.2.1 排班名额保护
|
||||
|
||||
**问题**: `update_schedule` 可以将 `max_appointments` 改为小于 `current_appointments` 的值。
|
||||
|
||||
**修复**: 在 `appointment_service.rs` 的 `update_schedule` 方法中增加校验:
|
||||
|
||||
```rust
|
||||
if req.max_appointments < model.current_appointments {
|
||||
return Err(HealthError::Validation(
|
||||
"max_appointments 不能小于当前已预约数".into()
|
||||
).into());
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2.2 取消预约名额释放
|
||||
|
||||
**问题**: `update_appointment_status` 中取消时名额释放失败只 log error 不回滚。
|
||||
|
||||
**修复**: 将名额释放作为事务的一部分,失败时回滚整个操作(包括状态更新)。
|
||||
|
||||
#### 2.2.3 咨询消息原子性
|
||||
|
||||
**问题**: `create_message` 中消息已插入,但后续 CAS 更新 session 失败时返回错误 — 消息已持久化但 session 元数据未更新。
|
||||
|
||||
**修复**: 将消息 INSERT + session CAS 更新放在同一个事务中。
|
||||
|
||||
### 2.3 随访逾期定时任务
|
||||
|
||||
**问题**: 设计规格定义了 `overdue` 状态和定时任务自动标记,但代码中:
|
||||
- `validation.rs` 不允许转换到 `overdue`
|
||||
- 没有后台定时任务
|
||||
|
||||
**修复**:
|
||||
|
||||
1. 在 `validation.rs` 中添加 `overdue` 转换规则:`pending -> overdue`(仅限系统自动触发)
|
||||
2. 在 `erp-server/src/main.rs` 后台任务区增加逾期检查器,使用与现有 `start_timeout_checker` 一致的 `tokio::spawn` + `loop` + `tokio::time::interval` 模式(每 6 小时执行一次,非 cron 表达式):
|
||||
|
||||
```rust
|
||||
// erp-server/src/main.rs 后台任务区
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(6 * 3600));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
// 调用 health module 的 check_overdue_tasks
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
3. 在 `erp-health` module 中添加一个公开方法 `check_overdue_tasks` 供定时任务调用。
|
||||
|
||||
### 2.4 article 管理 CRUD
|
||||
|
||||
**问题**: 权限声明中有 `health.articles.manage`,但 service/handler 只有 list 和 get。
|
||||
|
||||
**修复**: 在 `article_service.rs` 和 `article_handler.rs` 中补充 create/update/delete 方法。在 `module.rs` 中添加路由。**工时估算**: 0.5 天。
|
||||
|
||||
---
|
||||
|
||||
## 3. Web 前端 10 页面(阶段 3,3.5-4 周)
|
||||
|
||||
### 3.1 页面文件组织
|
||||
|
||||
```
|
||||
apps/web/src/
|
||||
├── api/health/
|
||||
│ ├── patients.ts # 12 端点
|
||||
│ ├── healthData.ts # 13 端点
|
||||
│ ├── appointments.ts # 6 端点
|
||||
│ ├── followUp.ts # 6 端点
|
||||
│ ├── consultations.ts # 6 端点
|
||||
│ └── doctors.ts # 4 端点
|
||||
├── pages/health/
|
||||
│ ├── PatientList.tsx # 患者列表
|
||||
│ ├── PatientDetail.tsx # 患者详情(5 Tab)
|
||||
│ ├── PatientTagManage.tsx # 标签管理
|
||||
│ ├── DoctorList.tsx # 医护列表
|
||||
│ ├── AppointmentList.tsx # 预约管理
|
||||
│ ├── DoctorSchedule.tsx # 排班管理
|
||||
│ ├── FollowUpTaskList.tsx # 随访任务
|
||||
│ ├── FollowUpRecordList.tsx # 随访台账
|
||||
│ ├── ConsultationList.tsx # 会话管理
|
||||
│ ├── ConsultationDetail.tsx # 对话详情
|
||||
│ └── components/
|
||||
│ ├── StatusTag.tsx # 通用状态标签
|
||||
│ ├── PatientSelect.tsx # 患者搜索选择器
|
||||
│ ├── DoctorSelect.tsx # 医护选择器
|
||||
│ ├── VitalSignsChart.tsx # ECharts 趋势图
|
||||
│ ├── CalendarView.tsx # 日历视图
|
||||
│ ├── ChatBubble.tsx # 聊天气泡
|
||||
│ ├── ImagePreview.tsx # 图片预览
|
||||
│ └── ExportButton.tsx # 导出按钮
|
||||
```
|
||||
|
||||
### 3.2 API 服务层设计
|
||||
|
||||
每个 service 文件遵循现有 `api/users.ts` 的解构模式:
|
||||
|
||||
```typescript
|
||||
// api/health/patients.ts
|
||||
import client from '../client';
|
||||
|
||||
export interface Patient {
|
||||
id: string;
|
||||
name: string;
|
||||
gender?: string;
|
||||
birth_date?: string;
|
||||
status: string;
|
||||
tags: Tag[];
|
||||
// ...
|
||||
}
|
||||
|
||||
export interface CreatePatientReq {
|
||||
name: string;
|
||||
gender?: string;
|
||||
// ...
|
||||
}
|
||||
|
||||
export const patientApi = {
|
||||
list: async (params: ListParams) => {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<Patient> }>(
|
||||
'/health/patients', { params }
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
get: async (id: string) => {
|
||||
const { data } = await client.get<{ success: boolean; data: Patient }>(
|
||||
`/health/patients/${id}`
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
create: async (req: CreatePatientReq) => {
|
||||
const { data } = await client.post<{ success: boolean; data: Patient }>(
|
||||
'/health/patients', req
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 3.3 路由注册
|
||||
|
||||
在 `App.tsx` 中新增:
|
||||
|
||||
```typescript
|
||||
// lazy imports
|
||||
const PatientList = lazy(() => import('./pages/health/PatientList'));
|
||||
const PatientDetail = lazy(() => import('./pages/health/PatientDetail'));
|
||||
// ... 共 10 个路由组件
|
||||
|
||||
// Routes 内
|
||||
<Route path="/health/patients" element={<PatientList />} />
|
||||
<Route path="/health/patients/:id" element={<PatientDetail />} />
|
||||
<Route path="/health/tags" element={<PatientTagManage />} />
|
||||
<Route path="/health/doctors" element={<DoctorList />} />
|
||||
<Route path="/health/appointments" element={<AppointmentList />} />
|
||||
<Route path="/health/schedules" element={<DoctorSchedule />} />
|
||||
<Route path="/health/follow-up-tasks" element={<FollowUpTaskList />} />
|
||||
<Route path="/health/follow-up-records" element={<FollowUpRecordList />} />
|
||||
<Route path="/health/consultations" element={<ConsultationList />} />
|
||||
<Route path="/health/consultations/:id" element={<ConsultationDetail />} />
|
||||
```
|
||||
|
||||
### 3.4 侧边栏菜单
|
||||
|
||||
在 `MainLayout.tsx` 中新增 `healthMenuItems` 数组(参照现有 `bizMenuItems` 模式),使用 `@ant-design/icons` 图标(如 `MedicineBoxOutlined`、`HeartOutlined`、`CalendarOutlined`、`PhoneOutlined`、`CommentOutlined`、`TagsOutlined`):
|
||||
|
||||
```
|
||||
侧边栏布局:
|
||||
├── 首页 (HomeOutlined)
|
||||
├── 用户管理 (UserOutlined)
|
||||
├── 权限管理 (SafetyOutlined)
|
||||
├── 工作流 (ApartmentOutlined)
|
||||
├── 消息中心 (BellOutlined)
|
||||
├── ─────────
|
||||
├── 健康管理 (MedicineBoxOutlined) ← 新增组
|
||||
│ ├── 患者管理 (TeamOutlined)
|
||||
│ ├── 医护管理 (HeartOutlined)
|
||||
│ ├── 预约排班 (CalendarOutlined)
|
||||
│ ├── 随访管理 (PhoneOutlined)
|
||||
│ ├── 咨询管理 (CommentOutlined)
|
||||
│ └── 标签管理 (TagsOutlined)
|
||||
├── ─────────
|
||||
├── 插件管理 (AppstoreOutlined)
|
||||
├── 系统设置 (SettingOutlined)
|
||||
```
|
||||
|
||||
### 3.5 前端权限集成
|
||||
|
||||
后端已有完整权限体系(14 个权限码),前端 V1 阶段采用以下策略:
|
||||
|
||||
1. **路由级权限**: 所有健康模块路由在 `PrivateRoute` 内(已实现),后端 `require_permission` 拦截无权限请求返回 403
|
||||
2. **按钮级权限(V1 简化)**: 不做前端按钮级权限控制,依赖后端 403 响应。后续可扩展 `usePermission` hook
|
||||
3. **菜单可见性**: 健康模块菜单组始终显示,但无权限用户点击任何页面会收到 403 提示
|
||||
|
||||
### 3.5 13 页面逐一设计
|
||||
|
||||
#### PatientList.tsx(中复杂度,1.5 天)
|
||||
|
||||
- Ant Design `Table` 组件(与 Users.tsx 模式一致,不使用 ProTable)
|
||||
- 搜索:姓名模糊 + 状态筛选 + 标签多选筛选
|
||||
- 每行显示患者标签为 `Tag` 组件列表
|
||||
- 行点击跳转 `/health/patients/:id`
|
||||
- 批量操作:批量打标
|
||||
- 导出功能
|
||||
|
||||
#### PatientDetail.tsx(高复杂度,3 天)
|
||||
|
||||
- 顶部:患者摘要卡片(姓名/性别/年龄/状态/标签)
|
||||
- Ant Design `Tabs` 5 个 Tab:
|
||||
1. **基本信息** — `Descriptions` 展示 + 编辑 Modal
|
||||
2. **健康趋势** — `VitalSignsChart` 组件 + 时间范围选择器
|
||||
3. **化验报告** — 报告卡片列表 + `ImagePreview` 指标详情
|
||||
4. **就诊记录** — 嵌套列表(体检/门诊/住院)
|
||||
5. **随访记录** — 嵌套列表 + 关联的随访记录
|
||||
|
||||
#### PatientTagManage.tsx(低复杂度,0.5 天)
|
||||
|
||||
- 标准 CRUD 表格
|
||||
- 颜色选择器(Ant Design `ColorPicker`)
|
||||
- 批量打标功能
|
||||
|
||||
#### DoctorList.tsx(低复杂度,0.5 天)
|
||||
|
||||
- 标准 CRUD 表格
|
||||
- 科室筛选 + 在线状态 Badge(online=绿/busy=黄/offline=灰)
|
||||
- 详情 Drawer
|
||||
|
||||
#### AppointmentList.tsx(中复杂度,2 天)
|
||||
|
||||
- `Segmented` 切换列表/日历视图
|
||||
- 列表模式:表格 + 状态筛选 + 日期筛选
|
||||
- 日历模式:`Calendar` + `cellRender` 显示当日预约数
|
||||
- 状态流转 Dropdown(pending → confirmed → completed/no_show/cancelled)
|
||||
- 创建预约 Modal(选择患者 + 医生 + 日期时段 + 检查排班余量)
|
||||
|
||||
#### DoctorSchedule.tsx(高复杂度,2.5 天)
|
||||
|
||||
- 选择医生后展示其排班
|
||||
- 周视图(自定义 7 列网格,每列显示一天的排班时段)
|
||||
- 月视图(Ant Design Calendar)
|
||||
- 批量创建排班(选择日期范围 + 时段模板)
|
||||
- 显示已预约/最大预约数
|
||||
|
||||
#### FollowUpTaskList.tsx(中复杂度,1.5 天)
|
||||
|
||||
- 表格 + 状态筛选(pending/in_progress/completed/overdue/cancelled)
|
||||
- 分配给医护(`DoctorSelect`)
|
||||
- 创建任务 Modal
|
||||
- 快捷"填写随访记录"按钮打开子 Modal
|
||||
|
||||
#### FollowUpRecordList.tsx(低复杂度,0.5 天)
|
||||
|
||||
- 纯只读台账
|
||||
- 筛选:日期范围、患者、任务、结果
|
||||
- 导出功能(`ExportButton`)
|
||||
|
||||
#### ConsultationList.tsx(中复杂度,1 天)
|
||||
|
||||
- 表格 + 状态筛选(waiting/active/closed)
|
||||
- 未读消息数 Badge
|
||||
- 最后消息时间
|
||||
- 关闭会话操作
|
||||
- 点击跳转 `/health/consultations/:id`
|
||||
|
||||
#### ConsultationDetail.tsx(高复杂度,2 天)
|
||||
|
||||
- `ChatBubble` 组件渲染聊天气泡
|
||||
- 根据 `sender_role` 区分左右对齐
|
||||
- 支持内容类型:text / image(`ImagePreview`)/ voice / file
|
||||
- 消息按时间排列,支持滚动加载更多(分页)
|
||||
- 导出按钮
|
||||
|
||||
### 3.6 技术难点方案
|
||||
|
||||
#### ECharts 趋势图
|
||||
|
||||
使用已安装的 `@ant-design/charts` 的 `Line` 组件。
|
||||
|
||||
- 后端 API `/patients/:id/trends/:indicator` 返回时序数据
|
||||
- 前端转换为 `{ date: string, value: number }[]`
|
||||
- 支持多指标叠加(血压收缩压/舒张压双线)
|
||||
- 封装为 `VitalSignsChart`,接收 `patientId` + `indicators` 参数
|
||||
- 时间范围选择器(7天/30天/90天)
|
||||
|
||||
#### 日历视图
|
||||
|
||||
Ant Design `Calendar` + 自定义 `cellRender`:
|
||||
- DoctorSchedule:每个日期格显示排班时段标签
|
||||
- AppointmentList:每个日期格显示预约数量气泡
|
||||
|
||||
#### 聊天 UI
|
||||
|
||||
自定义 `ChatBubble` 组件,基于 Ant Design `Typography.Paragraph` + `Avatar`:
|
||||
- 根据 `sender_role` 区分样式
|
||||
- 只读模式(PC 后台只查看不发送)
|
||||
- 图片消息使用 `Image.PreviewGroup`
|
||||
|
||||
#### 导出
|
||||
|
||||
后端 blob 导出 + 前端触发下载,参照 `PluginCRUDPage` 中已有的 `exportPluginDataAsBlob` 模式。
|
||||
|
||||
#### 文件上传/预览
|
||||
|
||||
- 上传:Ant Design `Upload.Dragger`,上传到后端文件接口
|
||||
- 图片预览:Ant Design `Image.PreviewGroup`
|
||||
- PDF 预览:新窗口打开(V1 简化方案)
|
||||
|
||||
### 3.7 开发顺序
|
||||
|
||||
| Phase | 内容 | 天数 | 依赖 |
|
||||
|-------|------|------|------|
|
||||
| 1 | API 层 6 文件 + 通用组件 + 路由菜单 | 1.5 | 无 |
|
||||
| 2 | PatientList + PatientTagManage + PatientDetail 基本信息Tab | 2 | Phase 1 |
|
||||
| 3 | VitalSignsChart + 健康趋势 Tab + LabReportList + HealthRecordList | 3 | Phase 2 |
|
||||
| 4 | DoctorList + AppointmentList + DoctorSchedule | 3 | Phase 1 |
|
||||
| 5 | FollowUpTaskList + FollowUpRecordList + ConsultationList + ConsultationDetail | 3 | Phase 1 |
|
||||
| 6 | 打磨(暗色主题 + 响应式 + 联调) | 1 | Phase 2-5 |
|
||||
| **合计** | | **13.5 天** | |
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试策略(阶段 2-3 交叉进行)
|
||||
|
||||
### 4.1 优先级排序
|
||||
|
||||
| 优先级 | 测试目标 | 预估用例数 | 工作量 |
|
||||
|--------|---------|-----------|--------|
|
||||
| P0 | `validation.rs` 纯函数 | 20-30 | 1 天 |
|
||||
| P0 | `appointment_service` CAS + 状态流转 | 15-20 | 2 天 |
|
||||
| P0 | `patient_service` CRUD + 状态机 | 15-20 | 2 天 |
|
||||
| P1 | `consultation_service` 消息原子性 | 10-15 | 2 天 |
|
||||
| P1 | `health_data_service` 指标数据 | 10-15 | 1 天 |
|
||||
| P2 | `follow_up_service` 链式任务 | 10 | 1 天 |
|
||||
|
||||
### 4.2 测试基础设施
|
||||
|
||||
在 `erp-health/Cargo.toml` 中添加 `[dev-dependencies]`:
|
||||
- `tokio` 的 `test` 和 `macros` feature
|
||||
- `sea-orm` 的 `mock` feature(用于简单单元测试,如 validation 纯函数)
|
||||
|
||||
对于涉及事务和 CAS 的集成测试(预约并发、消息原子性),使用 testcontainers-postgreSQL 做真实数据库测试,因为 SeaORM 的 `MockDatabaseConnection` 不支持复杂事务模拟。
|
||||
|
||||
创建 `tests/test_helpers.rs` 提供:
|
||||
- `create_test_health_state()` — 带 mock db 的 HealthState(单元测试用)
|
||||
- `create_integration_db()` — testcontainers PostgreSQL 实例(集成测试用)
|
||||
- 共享 fixture 工厂
|
||||
|
||||
### 4.3 关键测试场景
|
||||
|
||||
**预约 CAS 并发**:
|
||||
- 排班已满 → 创建预约失败
|
||||
- 排班有余 → CAS 成功 + 名额减 1
|
||||
- 并发创建 → 只有 max_appointments 个成功
|
||||
|
||||
**状态机转换**:
|
||||
- 合法转换:pending → confirmed → completed
|
||||
- 非法转换:completed → pending → 拒绝
|
||||
- 取消:任意状态 → cancelled(填 cancel_reason)
|
||||
|
||||
**随访链式任务**:
|
||||
- next_follow_up_date 不为空 → 自动创建新任务
|
||||
- 新任务的 assigned_to 沿用当前医护
|
||||
- next_follow_up_date 为空 → 不创建新任务
|
||||
|
||||
---
|
||||
|
||||
## 5. 实施路线图
|
||||
|
||||
### 5.1 总时间线(调整为 7 周)
|
||||
|
||||
```
|
||||
Week 1-2 | 安全地基(1.5-2 周)
|
||||
| ├── sanitize 全覆盖(2 天)
|
||||
| ├── 审计日志注入(2 天)
|
||||
| ├── 身份证号加密 + HMAC 索引 + 数据迁移(3-4 天)
|
||||
| └── 字段级脱敏(1-2 天)
|
||||
|
||||
Week 2-4 | 后端补完 + 测试(1.5-2 周)
|
||||
| ├── 事件处理器实现(2 天)
|
||||
| ├── 数据一致性修复(2 天)
|
||||
| ├── 随访逾期定时任务(1 天)
|
||||
| ├── article CRUD(0.5 天)
|
||||
| └── 核心路径测试(5-6 天)
|
||||
|
||||
Week 4-7 | Web 前端(3.5-4 周)
|
||||
| ├── Phase 1: API 层 + 通用组件 + 路由菜单(1.5 天)
|
||||
| ├── Phase 2: 核心入口页面(2 天)
|
||||
| ├── Phase 3: 健康数据页面(3 天)
|
||||
| ├── Phase 4: 预约排班页面(3 天)
|
||||
| ├── Phase 5: 随访咨询页面(3 天)
|
||||
| └── Phase 6: 打磨联调(1 天)
|
||||
|
||||
Week 7-8 | 端到端验证(1 周)
|
||||
| ├── 小程序联调
|
||||
| ├── 种子数据填充
|
||||
| ├── Docker 演示环境
|
||||
| └── 文档更新
|
||||
```
|
||||
|
||||
### 5.2 里程碑
|
||||
|
||||
| 里程碑 | 交付物 | 验收标准 |
|
||||
|--------|--------|---------|
|
||||
| M1 | 安全省基完成 | sanitize + 审计 + 加密 + 脱敏全部到位,cargo test 通过 |
|
||||
| M2 | 后端功能完整 | 事件处理器 + 数据一致性 + 测试覆盖,cargo test 通过 |
|
||||
| M3 | Web 3 核心页面 | PatientList + AppointmentList + DoctorSchedule 可操作 |
|
||||
| M4 | Web 10 页面完成 | 所有页面功能可用,pnpm build 通过 |
|
||||
| M5 | 端到端验证 | Web + 小程序 + 后端全链路可演示 |
|
||||
|
||||
### 5.3 风险和缓解
|
||||
|
||||
| 风险 | 概率 | 缓解 |
|
||||
|------|------|------|
|
||||
| ECharts 集成复杂度高 | 中 | 使用 @ant-design/charts 已安装,降低自研成本 |
|
||||
| 身份证加密影响现有查询 | 中 | HMAC 索引 + 数据迁移脚本 + 备份表 + 回滚方案 |
|
||||
| 10 页面开发时间超预期 | 高 | 按优先级裁剪,MVP 先做 3 核心页面 |
|
||||
| 文件上传能力未就绪 | 中 | V1 先支持 URL 存储,文件上传推迟到 V1.1 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 不在本设计范围内(推迟到 V2)
|
||||
|
||||
- 积分商城
|
||||
- 数据统计中心 / 运营驾驶舱
|
||||
- AI 辅助诊断/报告解读
|
||||
- 实时 WebSocket 在线咨询
|
||||
- 咨询消息按月分区
|
||||
- 事件幂等性(processed_events 去重表)
|
||||
- Polling Outbox 重试机制
|
||||
- HealthState 扩展 Redis 缓存
|
||||
- 国际化(英文等多语言)
|
||||
- 小程序医护端
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,469 @@
|
||||
# HMS 患者小程序迭代设计规格
|
||||
|
||||
> **版本**: v1.0
|
||||
> **日期**: 2026-04-24
|
||||
> **状态**: 草案
|
||||
> **关联**: 小程序初版设计 `2026-04-23-hms-miniprogram-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
小程序初版已完成 21 个页面、7 个 API service 的基础实现,覆盖登录、健康数据、预约挂号、检验报告、随访管理、用药提醒、健康资讯、个人中心。当前处于**开发阶段**,工程质量和用户体验存在明显短板,距测试阶段尚有差距。
|
||||
|
||||
### 1.2 问题全景
|
||||
|
||||
| 优先级 | 问题 | 影响 |
|
||||
|--------|------|------|
|
||||
| P0 | 大量重复代码(profile/reports ≈ report/index, profile/followups ≈ followup/index) | 维护成本翻倍 |
|
||||
| P0 | 预约详情通过 Storage 缓存传递而非 API 获取 | 数据不一致 |
|
||||
| P0 | EmptyState 导入方式不一致导致运行时报错 | 页面崩溃 |
|
||||
| P0 | 手机号绑定后端硬编码 `"13800000000"` | 无法上线 |
|
||||
| P0 | `getTodaySummary()` 调用的后端端点不存在 | 首页/健康页数据无法加载 |
|
||||
| P1 | ErrorState 组件定义但未使用 | 错误处理不统一 |
|
||||
| P1 | mixins.scss 定义但未使用 | 样式重复内联 |
|
||||
| P1 | 无全局错误边界 | 页面崩溃无兜底 |
|
||||
| P1 | tryRefreshToken 静默吞异常 | 调试困难 |
|
||||
| P1 | 趋势图缓存永不过期 | 数据过时 |
|
||||
| P1 | 随访详情获取低效(listTasks().find()) | 性能浪费 |
|
||||
| P1 | 首页/健康页缺少 loading 状态 | 体验空白 |
|
||||
| P1 | 用药提醒纯本地 Storage | 换设备即丢失(后续版本解决) |
|
||||
| P2 | 路径别名 @/* 未使用 | 代码可读性差 |
|
||||
| P2 | 无 schema 验证库 | 表单验证脆弱 |
|
||||
| P2 | 趋势图纯 CSS 柱状图 | 无交互能力 |
|
||||
| P2 | 用药提醒时间选择器未实现 | 功能不完整 |
|
||||
| P2 | 无日志/埋点/上报 | 无法追踪问题 |
|
||||
|
||||
### 1.3 迭代策略:混合策略
|
||||
|
||||
采用**先基建再模块**的混合策略,分 4 个 Sprint 交付:
|
||||
|
||||
```
|
||||
Sprint 0 (2-3天) Sprint 1 (3-4天) Sprint 2 (3-4天) Sprint 3 (4-5天)
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ 工程基础修复 │ → │ 健康数据打磨 │ → │ 预约+通知 │ → │ 报告/随访/ │
|
||||
│ │ │ │ │ │ │ 安全+增长 │
|
||||
│ · 消除重复代码│ │ · ECharts图表│ │ · 步骤指示器 │ │ · 指标卡片 │
|
||||
│ · 统一错误处理│ │ · 缓存TTL │ │ · 周视图日历 │ │ · Token加密 │
|
||||
│ · 修复数据传递│ │ · zod验证 │ │ · 订阅消息 │ │ · 手机号解密 │
|
||||
│ · 统一Loading │ │ · 状态色卡片 │ │ · 时段可视化 │ │ · 埋点+分享 │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
**原则**:Sprint 0 铺路,后续每个 Sprint 都受益于基础设施改善。Sprint 0 只修不建,不引入新依赖。
|
||||
|
||||
---
|
||||
|
||||
## 2. Sprint 0:工程基础修复
|
||||
|
||||
**目标**:消除最痛的工程问题,为后续所有 Sprint 铺路。约束 2-3 天完成。
|
||||
|
||||
### 2.1 修复阻断性 API 端点缺失
|
||||
|
||||
**现状**:前端 `services/health.ts` 的 `getTodaySummary()` 调用 `GET /health/vital-signs?date=today`,但后端路由中**不存在此端点**。后端仅有 `GET /health/patients/{id}/vital-signs`(需 patient_id 路径参数)。这意味着首页"今日健康"卡片和健康页的数据从一开始就**无法加载**。
|
||||
|
||||
**方案**:
|
||||
|
||||
- 后端在 `erp-health` 新增小程序专用端点 `GET /health/vital-signs/today`,通过 JWT `user_id` 自动关联 patient(类似已有的 `GET /health/vital-signs/trend` 模式)
|
||||
- 前端 `services/health.ts` 的 `getTodaySummary()` 调整为调用新端点
|
||||
- 此项为 **Sprint 0 最高优先级**,阻塞首页和健康页基本功能
|
||||
|
||||
**涉及文件**:
|
||||
- 后端新增:`erp-health` handler + 路由注册
|
||||
- 修改:`services/health.ts`
|
||||
|
||||
### 2.2 消除重复页面
|
||||
|
||||
**现状**:`pages/report/index` 与 `pages/profile/reports/index` 几乎完全重复,`pages/followup/index` 与 `pages/profile/followups/index` 同理。且 `report/index` 和 `followup/index` 没有明确的导航入口。
|
||||
|
||||
**方案**:
|
||||
|
||||
1. 删除 `pages/report/index` 和 `pages/followup/index` 及其 SCSS 文件
|
||||
2. 从 `app.config.ts` 移除对应路由注册
|
||||
3. 首页快捷入口和 profile 菜单统一指向 `profile/reports` 和 `profile/followups`
|
||||
4. 如果后续需要独立入口,则抽取共享组件 `components/ReportList` 和 `components/FollowupList`,两个页面只做薄壳路由
|
||||
|
||||
**涉及文件**:
|
||||
- 删除:`pages/report/index.tsx`、`pages/report/index.scss`
|
||||
- 删除:`pages/followup/index.tsx`、`pages/followup/index.scss`
|
||||
- 修改:`app.config.ts`(移除路由)
|
||||
- 修改:`pages/index/index.tsx`(快捷入口路径)
|
||||
|
||||
### 2.2 统一错误处理
|
||||
|
||||
**现状**:`ErrorState` 组件已定义但未被任何页面使用,各页面内联 `showToast` 错误提示。无全局错误边界。
|
||||
|
||||
**方案**:
|
||||
|
||||
1. 所有列表页、详情页统一使用 `ErrorState` 组件,替换内联错误提示
|
||||
2. 在 `app.tsx` 添加 React Error Boundary 组件,兜底页面崩溃
|
||||
3. 新建 `components/ErrorBoundary/index.tsx`
|
||||
4. 修复 `tryRefreshToken` 的 catch 块,添加 `console.error` 日志
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`components/ErrorBoundary/index.tsx`
|
||||
- 修改:`app.tsx`(包裹 ErrorBoundary)
|
||||
- 修改:所有列表页和详情页(替换内联错误处理为 ErrorState)
|
||||
- 修改:`services/request.ts`(tryRefreshToken 日志)
|
||||
|
||||
### 2.3 修复数据传递问题
|
||||
|
||||
**预约详情**:
|
||||
- 移除 `appointment_detail_cache` Storage 传递
|
||||
- 改为进入页面时通过 `GET /health/appointments/:id` 获取数据
|
||||
- **后端需新增此端点**(当前仅有列表 `GET`、创建 `POST`、状态更新 `PUT`,缺少单条查询 `GET`)
|
||||
|
||||
**随访详情**:
|
||||
- 后端**需新增** `GET /health/follow-up-tasks/:id` 单条查询端点(当前 `{id}` 路由仅注册了 `PUT` 和 `DELETE`,缺少 `GET`)
|
||||
- 前端替换 `listTasks().find()` 为直接按 ID 查询
|
||||
|
||||
> **注意**:以上后端新增端点为 Sprint 0 前置阻塞项。如果后端资源有限,前端先做"调用端点"的准备代码,后端并行实现。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/appointment/detail/index.tsx`
|
||||
- 修改:`services/appointment.ts`(新增 getDetail 方法)
|
||||
- 修改:`services/followup.ts`(新增 getTaskDetail 方法)
|
||||
- 后端新增:`erp-health` 预约单条查询 + 随访单条查询端点
|
||||
|
||||
### 2.4 统一 Loading 状态
|
||||
|
||||
**现状**:首页和健康页的 `loading` 状态已在 store 中定义但未在 UI 层消费。详情页使用内联 `<Text>加载中...</Text>`。
|
||||
|
||||
**方案**:
|
||||
|
||||
1. 首页和健康页在数据加载时展示 `Loading` 组件
|
||||
2. 所有详情页统一使用 `Loading` 组件替换内联文字
|
||||
3. 预约创建页三步骤切换时也展示 loading
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/index/index.tsx`(消费 loading 状态)
|
||||
- 修改:`pages/health/index.tsx`(消费 loading 状态)
|
||||
- 修改:所有详情页 tsx(替换内联加载文字)
|
||||
|
||||
### 2.5 杂项修复
|
||||
|
||||
| 项目 | 方案 |
|
||||
|------|------|
|
||||
| EmptyState 导入 bug | 首页 `import { EmptyState }` 改为 `import EmptyState`(默认导入) |
|
||||
| 路径别名启用 | `services/` 和 `stores/` 层的 import 逐步改为 `@/` 别名 |
|
||||
| mixins.scss 复用 | 新写的页面样式使用 `@include card`、`@include flex-center`、`@include safe-bottom` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Sprint 1:健康数据模块打磨
|
||||
|
||||
**目标**:升级健康数据录入、展示和趋势分析体验,从"能用"到"好用"。
|
||||
|
||||
### 3.1 健康卡片状态色
|
||||
|
||||
**现状**:四张健康卡片(血压/心率/血糖/体重)样式统一灰色,无状态区分。
|
||||
|
||||
**方案**:
|
||||
|
||||
每张卡片根据指标状态着色:
|
||||
- **正常**:左侧绿色边条 + 绿色"正常 ─"标签
|
||||
- **偏高**:左侧红色边条 + 红色"偏高 ▲{差值}"标签
|
||||
- **偏低**:左侧红色边条 + 红色"偏低 ▼{差值}"标签
|
||||
- **无数据**:灰色,保持现状
|
||||
|
||||
异常指标数值变红,卡片底部显示参考范围。
|
||||
|
||||
**后端配合**:后端需在新增的 `GET /health/vital-signs/today` 端点中返回 `status`(normal/high/low)和 `reference_range`。前端 `TodaySummary` 类型同步新增 `reference_range` 字段(当前已有 `status` 字段但后端无对应返回)。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/health/index.tsx`(卡片样式逻辑)
|
||||
- 修改:`pages/index/index.tsx`(首页健康卡片同步更新)
|
||||
- 修改:`services/health.ts`(类型定义增加 status 字段)
|
||||
|
||||
### 3.2 ECharts 趋势图
|
||||
|
||||
**现状**:纯 CSS div 柱状图,无交互、无缩放、无 tooltip。
|
||||
|
||||
**方案**:
|
||||
|
||||
引入 `echarts-taro3-react`(设计规格中已规划)。**前置条件**:Sprint 1 开始前需做技术预研(spike),验证 `echarts-taro3-react` 在 Taro 4.2.0 + webpack5 下的兼容性。如果不可用,备选方案为 `echarts-for-weixin` + 手动封装为 React 组件。
|
||||
|
||||
实现:
|
||||
|
||||
- **折线图**:数据点连线,异常点标红放大
|
||||
- **参考范围色带**:正常值区间以半透明绿色背景显示
|
||||
- **Tooltip**:长按/点击显示具体数值和日期
|
||||
- **时间范围切换**:7天/30天/90天 三个 tab
|
||||
- **缓存 TTL**:趋势数据缓存 5 分钟后自动过期,强制重新请求
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`components/TrendChart/index.tsx`、`components/TrendChart/index.scss`
|
||||
- 重写:`pages/health/trend/index.tsx`
|
||||
- 修改:`stores/health.ts`(缓存 TTL 机制)
|
||||
- 新增依赖:`echarts-taro3-react`
|
||||
|
||||
### 3.3 表单验证升级
|
||||
|
||||
**现状**:所有表单验证为手动 if 判断,无 schema 约束。
|
||||
|
||||
**方案**:
|
||||
|
||||
引入 `zod`(~3KB gzip),为每个表单定义验证 schema:
|
||||
|
||||
```typescript
|
||||
// 示例:体征录入验证
|
||||
const vitalSignSchema = z.object({
|
||||
indicator_type: z.enum(['blood_pressure', 'heart_rate', 'blood_sugar', 'weight']),
|
||||
value: z.number().positive(),
|
||||
extra: z.object({ systolic: z.number().min(60).max(250).optional(),
|
||||
diastolic: z.number().min(40).max(150).optional() }).optional(),
|
||||
measured_at: z.string().datetime().optional(),
|
||||
note: z.string().max(200).optional()
|
||||
});
|
||||
```
|
||||
|
||||
异常值即时警告(如收缩压 > 180 显示红色提示"请及时就医")。
|
||||
|
||||
录入成功后自动刷新首页卡片 + 清除趋势缓存。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/health/input/index.tsx`(zod schema 验证)
|
||||
- 修改:`stores/health.ts`(录入成功后清除缓存)
|
||||
- 新增依赖:`zod`
|
||||
|
||||
---
|
||||
|
||||
## 4. Sprint 2:预约挂号 + 通知触达
|
||||
|
||||
**目标**:优化预约三步流程体验,建立微信订阅消息通知机制。
|
||||
|
||||
### 4.1 三步流程升级
|
||||
|
||||
**现状**:三个步骤(选科室 → 选医生 → 选日期时段)无进度指示,排班信息为纯文字列表。
|
||||
|
||||
**方案**:
|
||||
|
||||
**步骤指示器**:
|
||||
- 新增 `components/StepIndicator/index.tsx`
|
||||
- 顶部固定 1→2→3 步骤条,当前步骤高亮,已完成步骤可点击回退
|
||||
- 步骤间切换带过渡动画
|
||||
|
||||
**科室选择**:
|
||||
- 从文字列表改为宫格卡片(图标 + 科室名 + 医生数)
|
||||
- 每个科室卡片可点击,选中后高亮边框
|
||||
|
||||
**排班日历**:
|
||||
- 新增 `components/WeekCalendar/index.tsx` 周视图日历
|
||||
- 有排班的日期标记绿点,无排班的日期灰色
|
||||
- 点击日期展示该日可用时段卡片
|
||||
- 时段卡片按剩余名额着色:>3 绿色、1-3 橙色、0 灰色不可选
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`components/StepIndicator/index.tsx`
|
||||
- 新增:`components/WeekCalendar/index.tsx`
|
||||
- 重写:`pages/appointment/create/index.tsx`
|
||||
|
||||
### 4.2 微信订阅消息
|
||||
|
||||
**现状**:无任何推送通知机制。
|
||||
|
||||
**方案**:
|
||||
|
||||
1. 后端在微信公众平台注册订阅消息模板:
|
||||
- 预约就诊提醒(就诊前 1 天推送)
|
||||
- 随访任务提醒(截止前 1 天推送)
|
||||
- 报告出具通知(新报告发布时推送)
|
||||
|
||||
2. 前端在关键场景引导用户订阅:
|
||||
- 预约成功后弹出订阅授权
|
||||
- 随访提交后引导订阅下次提醒
|
||||
|
||||
3. 后端定时任务检查待推送消息并触发
|
||||
|
||||
4. **降级设计**:用户拒绝订阅时,消息仍写入 `erp-message` 消息中心。小程序"我的"页面顶部显示未读消息数量红点,作为消息触达的备选渠道。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/appointment/detail/index.tsx`(预约成功后订阅引导)
|
||||
- 修改:`pages/followup/detail/index.tsx`(随访提交后订阅引导)
|
||||
- 后端新增:`erp-server` 订阅消息模板注册 + 定时推送任务
|
||||
|
||||
---
|
||||
|
||||
## 5. Sprint 3:报告/随访/个人中心 + 安全 + 增长
|
||||
|
||||
**目标**:打磨剩余模块,完成安全加固和增长基础建设,达到可测试状态。
|
||||
|
||||
### 5.1 报告详情页升级
|
||||
|
||||
**现状**:所有指标卡片样式相同,无法一眼区分正常/异常。
|
||||
|
||||
**方案**:
|
||||
|
||||
指标卡片按状态着色:
|
||||
- **正常**:绿色背景 + 绿色"✓ 正常"标签 + 绿色数值
|
||||
- **偏高**:红色背景 + 红色"↑ 偏高"标签 + 红色数值
|
||||
- **偏低**:红色背景 + 红色"↓ 偏低"标签 + 红色数值
|
||||
|
||||
顶部汇总标签:`2 项异常 · 1 项正常`,一眼掌握整体状况。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/report/detail/index.tsx`
|
||||
- 修改:`pages/profile/reports/index.tsx`(如果仍独立存在)
|
||||
|
||||
### 5.2 随访 UX 细节
|
||||
|
||||
- 任务卡片增加截止日期倒计时("还剩 2 天",红色紧迫)
|
||||
- 过期任务灰色标记
|
||||
- 提交记录后增加"提交成功"确认动画(checkmark 缩放)
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/profile/followups/index.tsx`
|
||||
- 修改:`pages/followup/detail/index.tsx`
|
||||
|
||||
### 5.3 个人中心改进
|
||||
|
||||
**用药提醒**:
|
||||
- 实现时间选择器 Picker(替换当前静态文本)
|
||||
- 增加"提醒开关"(enabled/disabled)
|
||||
- 注:用药提醒数据仍为本地 Storage 存储,**后端同步作为后续版本事项**。MVP 阶段接受"换设备即丢失"的限制。
|
||||
|
||||
**就诊人管理**:
|
||||
- 增加编辑功能(当前只能添加不能编辑)
|
||||
- 复用 `family-add` 页面,传入已有数据进入编辑模式
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/profile/medication/index.tsx`(时间 Picker)
|
||||
- 修改:`pages/profile/family/index.tsx`(编辑入口)
|
||||
- 修改:`pages/profile/family-add/index.tsx`(编辑模式支持)
|
||||
|
||||
### 5.4 安全加固
|
||||
|
||||
#### 5.4.1 Token 安全
|
||||
|
||||
**现状**:Access Token 和 Refresh Token 明文存储在 `Taro.setStorageSync`。
|
||||
|
||||
**方案**:
|
||||
|
||||
MVP 阶段采用简化方案:微信小程序的 Storage 本身有沙箱隔离,明文存储的边际风险有限。做以下最低成本改进:
|
||||
|
||||
- 使用 `wx.getRandomValues()` 生成随机密钥,单独 key 存储
|
||||
- Token 存储时用此密钥做简单混淆(XOR 或 AES-ECB 单块加密)
|
||||
- 目的:防止 Storage 被直接明文读取,非追求密码学安全级别
|
||||
|
||||
> **后续版本**:如果合规要求提高,再升级为完整的 AES-GCM 方案。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`utils/crypto.ts`(轻量混淆工具)
|
||||
- 修改:`stores/auth.ts`(Storage 读写走混淆层)
|
||||
|
||||
#### 5.4.2 手机号真实解密
|
||||
|
||||
**现状**:`wechat_service.rs` 第 82 行硬编码 `"13800000000"`。
|
||||
|
||||
**方案**:
|
||||
- 后端接入微信 `phonenumber.getPhoneNumber` 接口
|
||||
- 使用 `encryptedData` + `iv` + `session_key` 解密真实手机号
|
||||
- 前端无需改动(已传递正确的 encryptedData 和 iv)
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`crates/erp-auth/src/service/wechat_service.rs`
|
||||
|
||||
#### 5.4.3 用户协议与隐私政策
|
||||
|
||||
- 新增 `pages/agreement/index.tsx` 页面
|
||||
- 登录页增加"阅读并同意《用户协议》和《隐私政策》"勾选
|
||||
- 权限使用说明文案(获取手机号用途声明)
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`pages/agreement/index.tsx`
|
||||
- 修改:`pages/login/index.tsx`(协议勾选)
|
||||
- 修改:`app.config.ts`(新增路由)
|
||||
|
||||
### 5.5 增长基础
|
||||
|
||||
#### 5.5.1 数据埋点
|
||||
|
||||
新增 `services/analytics.ts`,轻量事件记录:
|
||||
|
||||
```typescript
|
||||
// 核心事件类型
|
||||
type AnalyticsEvent =
|
||||
| { type: 'page_view'; page: string; duration_ms?: number }
|
||||
| { type: 'feature_use'; feature: string; action: string }
|
||||
| { type: 'error'; message: string; stack?: string }
|
||||
```
|
||||
|
||||
- 页面进入/离开自动记录 `page_view`
|
||||
- 关键操作(录入数据、创建预约、提交随访)记录 `feature_use`
|
||||
- 捕获的错误记录 `error`
|
||||
- MVP 阶段:事件写入本地 `console.info` + Taro Storage 缓存(最近 100 条)
|
||||
- 后续版本:批量上报到后端 `POST /api/v1/analytics/events`
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`services/analytics.ts`
|
||||
- 修改:`app.tsx`(全局页面进入/离开监听)
|
||||
|
||||
#### 5.5.2 分享能力
|
||||
|
||||
- 文章详情页支持分享到微信好友/朋友圈
|
||||
- 自定义分享卡片(标题 + 摘要 + 封面图)通过 `onShareAppMessage` 和 `onShareTimeline` 实现
|
||||
|
||||
> **后续版本**:健康报告 Canvas 分享图片生成、PC 扫码登录。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/article/detail/index.tsx`(onShareAppMessage + onShareTimeline)
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件变更总览
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 | Sprint |
|
||||
|------|------|--------|
|
||||
| `components/ErrorBoundary/index.tsx` | 全局错误边界 | 0 |
|
||||
| `components/TrendChart/index.tsx` | ECharts 趋势图 | 1 |
|
||||
| `components/StepIndicator/index.tsx` | 步骤指示器 | 2 |
|
||||
| `components/WeekCalendar/index.tsx` | 周视图日历 | 2 |
|
||||
| `pages/agreement/index.tsx` | 用户协议/隐私政策 | 3 |
|
||||
| `utils/crypto.ts` | Token 加密工具 | 3 |
|
||||
| `services/analytics.ts` | 数据埋点 | 3 |
|
||||
|
||||
### 删除文件
|
||||
|
||||
| 文件 | 原因 | Sprint |
|
||||
|------|------|--------|
|
||||
| `pages/report/index.tsx` | 与 profile/reports 重复 | 0 |
|
||||
| `pages/report/index.scss` | 同上 | 0 |
|
||||
| `pages/followup/index.tsx` | 与 profile/followups 重复 | 0 |
|
||||
| `pages/followup/index.scss` | 同上 | 0 |
|
||||
|
||||
### 新增依赖
|
||||
|
||||
| 依赖 | 用途 | 体积 | Sprint |
|
||||
|------|------|------|--------|
|
||||
| `echarts-taro3-react` | 交互式图表 | 封装层 ~5KB + echarts 按需 ~100-200KB (gzip) | 1 |
|
||||
| `zod` | 表单 schema 验证(长期投资) | ~3KB (gzip) | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 约束与风险
|
||||
|
||||
| 风险 | 应对策略 |
|
||||
|------|---------|
|
||||
| ECharts 增大包体积 | 按需引入 echarts 模块,不引入全量包;监控主包大小不超过 2MB |
|
||||
| 微信订阅消息需用户主动触发 | 在预约成功、随访提交等高意愿场景引导订阅 |
|
||||
| Token 加密增加启动耗时 | AES-GCM 加解密 < 1ms,可忽略 |
|
||||
| zod 增加包体积 | 3KB gzip,远小于自写验证代码量 |
|
||||
| Sprint 0 范围膨胀 | 严格只修不建,不引入新依赖,不重构架构 |
|
||||
| 后端端点未实现阻塞前端 | Sprint 0/1 的端点基本已实现;Sprint 2 订阅消息、Sprint 3 analytics 需后端配合 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
每个 Sprint 完成时必须满足:
|
||||
|
||||
- [ ] `pnpm build:weapp` 生产构建通过
|
||||
- [ ] 微信开发者工具无编译错误
|
||||
- [ ] 所有涉及页面真机预览功能正常
|
||||
- [ ] 无 console.error 或未捕获异常
|
||||
- [ ] 已修改的页面 loading/error/empty 三态完整
|
||||
- [ ] 所有代码已提交
|
||||
File diff suppressed because it is too large
Load Diff
2217
docs/archive/superpowers-completed/2026-04-25-erp-ai-phase1-mvp.md
Normal file
2217
docs/archive/superpowers-completed/2026-04-25-erp-ai-phase1-mvp.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
# HMS 功能完善迭代设计规格
|
||||
|
||||
> 日期: 2026-04-25
|
||||
> 状态: 已确认(审查修正版)
|
||||
> 关联: `docs/superpowers/specs/2026-04-25-erp-ai-module-design.md`
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 项目现状
|
||||
|
||||
HMS 健康管理平台已完成核心业务开发(237 次提交、57k 行 Rust + 174 前端文件),但存在以下功能缺口:
|
||||
|
||||
- **按钮级权限控制缺失** — 路由守卫已有,但操作按钮(新增/编辑/删除)未做权限过滤;前端缺少权限数据源(`UserInfo` 接口不含 `permissions` 字段)
|
||||
- **AI 模块管理端空白** — 后端 6 个 API 端点中 4 个 SSE 流式端点可用,但 Prompt CRUD、分析历史查询、用量统计端点均为空壳或缺失
|
||||
- **小程序端 AI 不可见** — 患者无法查看 AI 分析报告
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
通过纵向切片方式逐步交付三个功能域,每个切片从前到后完整打通:
|
||||
|
||||
1. **切片 1:按钮级权限** — 基础设施,后续页面的前置依赖
|
||||
2. **切片 2:AI 管理端** — 3 个 PC 管理页面
|
||||
3. **切片 3:小程序报告** — 患者端只读查看
|
||||
|
||||
---
|
||||
|
||||
## 2. 切片 1:按钮级权限控制
|
||||
|
||||
### 2.1 架构
|
||||
|
||||
```
|
||||
后端新增 /api/v1/auth/me/permissions → 返回当前用户权限码列表
|
||||
↓
|
||||
auth store (permissions: string[]) ← 登录时从新端点加载
|
||||
↓
|
||||
usePermission(code) → { hasPermission: boolean }
|
||||
↓
|
||||
<AuthButton code="health.patient.manage"> ... </AuthButton>
|
||||
↓
|
||||
无权限 → 不渲染(hidden 模式)
|
||||
有权限 → 正常渲染子元素
|
||||
```
|
||||
|
||||
**前置依赖:** 当前 `UserInfo` 接口不含 `permissions` 字段(仅含 `roles`)。需后端新增 `/api/v1/auth/me/permissions` 端点,返回当前用户所有权限码的扁平列表(从角色 → 权限关联表聚合)。超级管理员(`is_system: true`)默认返回全部权限码。
|
||||
|
||||
### 2.2 组件设计
|
||||
|
||||
**usePermission hook**
|
||||
|
||||
位置: `apps/web/src/hooks/usePermission.ts`
|
||||
|
||||
```typescript
|
||||
function usePermission(code: string): { hasPermission: boolean }
|
||||
```
|
||||
|
||||
- 从 auth store 读取当前用户 permissions 数组
|
||||
- 返回 code 是否在权限列表中
|
||||
- 权限数据加载失败时默认无权限(安全降级)
|
||||
|
||||
**AuthButton 组件**
|
||||
|
||||
位置: `apps/web/src/components/AuthButton.tsx`
|
||||
|
||||
Props:
|
||||
- `code: string` — 权限码(如 `health.patient.manage`)
|
||||
- `children: ReactNode` — 受保护的按钮内容
|
||||
|
||||
行为: 无权限时不渲染 children(hidden 模式)。
|
||||
|
||||
**AuthGuard 组件**
|
||||
|
||||
位置: `apps/web/src/components/AuthGuard.tsx`
|
||||
|
||||
Props:
|
||||
- `code: string` — 权限码
|
||||
- `children: ReactNode` — 受保护的内容块
|
||||
|
||||
行为: 同 AuthButton,用于包裹非按钮内容(如整个 Tab、区块)。
|
||||
|
||||
### 2.3 改造范围
|
||||
|
||||
优先改造健康模块 15 个页面中的操作按钮:
|
||||
|
||||
| 页面 | 按钮权限码 |
|
||||
|------|-----------|
|
||||
| PatientList | health.patient.manage |
|
||||
| PatientDetail | health.patient.manage |
|
||||
| AppointmentList | health.appointment.manage |
|
||||
| DoctorList | health.doctor.manage |
|
||||
| DoctorSchedule | health.doctor.manage |
|
||||
| FollowUpTaskList | health.follow-up.manage |
|
||||
| FollowUpRecordList | health.follow-up.manage |
|
||||
| ConsultationList | health.consultation.manage |
|
||||
| ConsultationDetail | health.consultation.manage |
|
||||
| OfflineEventList | health.articles.manage |
|
||||
| PatientTagManage | health.patient.manage |
|
||||
| StatisticsDashboard | health.health-data.list (只读) |
|
||||
| PointsProductList | health.points.manage |
|
||||
| PointsOrderList | health.points.list |
|
||||
| PointsRuleList | health.points.manage |
|
||||
|
||||
扩展到基础模块页面(Users, Roles, Organizations, Workflow 等)。
|
||||
|
||||
### 2.4 验证标准
|
||||
|
||||
- [ ] 无权限用户看不到操作按钮
|
||||
- [ ] 有权限用户操作正常
|
||||
- [ ] 权限变更后界面实时更新(无需刷新)
|
||||
|
||||
---
|
||||
|
||||
## 3. 切片 2:AI 管理端 3 页面
|
||||
|
||||
### 3.1 路由设计
|
||||
|
||||
```
|
||||
/health/ai/prompts → Prompt 管理
|
||||
/health/ai/analysis → 分析历史
|
||||
/health/ai/usage → 用量统计
|
||||
```
|
||||
|
||||
### 3.2 页面 A — Prompt 管理
|
||||
|
||||
位置: `apps/web/src/pages/health/AiPromptList.tsx`
|
||||
|
||||
**功能清单:**
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 列表展示 | 表格:名称/类型(化验单解读、趋势分析、体检方案、报告摘要)/版本号/状态(active/draft)/更新时间 |
|
||||
| 新建 Prompt | Modal 表单:名称、类型(下拉)、系统提示词、用户提示词模板(支持 `{{variable}}` 占位符) |
|
||||
| 编辑 Prompt | 同新建,自动递增版本号 |
|
||||
| 激活/停用 | 切换按钮,激活时停用同类型旧模板 |
|
||||
| 版本历史 | 展开行显示所有历史版本,支持一键回滚 |
|
||||
|
||||
**API 封装:**
|
||||
|
||||
位置: `apps/web/src/api/ai/prompts.ts`
|
||||
|
||||
```typescript
|
||||
// 后端需新增 Prompt CRUD 端点(当前仅有 service 层的 get_active_prompt + create_prompt)
|
||||
getPrompts(params: ListParams): Promise<PaginatedResponse<Prompt>> // GET /api/v1/ai/prompts
|
||||
createPrompt(data: CreatePromptDto): Promise<Prompt> // POST /api/v1/ai/prompts
|
||||
updatePrompt(id: string, data: UpdatePromptDto): Promise<Prompt> // PUT /api/v1/ai/prompts/{id}
|
||||
activatePrompt(id: string): Promise<Prompt> // POST /api/v1/ai/prompts/{id}/activate
|
||||
rollbackPrompt(id: string): Promise<Prompt> // POST /api/v1/ai/prompts/{id}/rollback
|
||||
```
|
||||
|
||||
**版本回滚机制:** 每次编辑 Prompt 创建新记录(递增 version),回滚 = 将目标旧版本 `is_active` 设为 `true` 并将当前激活版本 `is_active` 设为 `false`。不删除任何版本记录。
|
||||
|
||||
**权限码:** `ai.prompt.list`(查看)、`ai.prompt.manage`(编辑/激活/回滚)
|
||||
|
||||
### 3.3 页面 B — 分析历史
|
||||
|
||||
位置: `apps/web/src/pages/health/AiAnalysisList.tsx`
|
||||
|
||||
**功能清单:**
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 列表展示 | 表格:分析类型/患者姓名/状态(streaming/completed/failed)/创建时间/token 用量 |
|
||||
| 详情查看 | 点击行展开/Modal 展示完整分析结果(Markdown 渲染) |
|
||||
| 筛选 | 按类型(4 种)、时间范围(DateRangePicker)、患者(PatientSelect 组件复用) |
|
||||
| 重新分析 | 对 failed 记录支持重新发起分析 |
|
||||
|
||||
**API 封装:**
|
||||
|
||||
位置: `apps/web/src/api/ai/analysis.ts`
|
||||
|
||||
```typescript
|
||||
// 后端当前 list_analysis/get_analysis 为空壳(返回 ApiResponse::ok(())),需实现真实查询
|
||||
getAnalysisHistory(params: AnalysisQueryParams): Promise<PaginatedResponse<Analysis>> // GET /api/v1/ai/analysis/history
|
||||
getAnalysisDetail(id: string): Promise<Analysis> // GET /api/v1/ai/analysis/{id}
|
||||
```
|
||||
|
||||
**权限码:** `ai.analysis.list`(查看)、`ai.analysis.manage`(重新分析)
|
||||
|
||||
### 3.4 页面 C — 用量统计
|
||||
|
||||
位置: `apps/web/src/pages/health/AiUsageDashboard.tsx`
|
||||
|
||||
**功能清单:**
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 概览卡片 | 4 张 StatCard:总用量/本月/今日/平均 token |
|
||||
| 趋势图 | Ant Design Charts 折线图,按日/周/月切换 |
|
||||
| 类型分布 | 饼图展示 4 种分析类型的占比 |
|
||||
| 用户排行 | 表格展示用户维度用量排名 |
|
||||
|
||||
**API 封装:**
|
||||
|
||||
位置: `apps/web/src/api/ai/usage.ts`
|
||||
|
||||
```typescript
|
||||
// 后端需完全新增:路由、handler、聚合 service
|
||||
// ai_usage_logs 表需增加 created_by 列(当前缺失 user_id),或复用 ai_analysis_results.created_by 做用户排行
|
||||
getUsageOverview(): Promise<UsageOverview> // GET /api/v1/ai/usage/overview
|
||||
getUsageTrend(params: TrendParams): Promise<TrendData[]> // GET /api/v1/ai/usage/trend
|
||||
getUsageByType(): Promise<TypeDistribution[]> // GET /api/v1/ai/usage/by-type
|
||||
getUsageByUser(params: UserRankingParams): Promise<PaginatedResponse<UserUsage>> // GET /api/v1/ai/usage/by-user
|
||||
```
|
||||
|
||||
**后端补充:** 需在 erp-ai 中新增用量统计聚合端点。优先方案:复用 `ai_analysis_results` 表的 `created_by` 字段做用户维度排行,避免修改 `ai_usage_logs` 表结构。如需精确 token 统计,后续可加迁移增加 `user_id` 列。
|
||||
|
||||
**权限码:** `ai.usage.list`
|
||||
|
||||
### 3.5 菜单注册
|
||||
|
||||
在 `apps/web/src/layouts/MainLayout.tsx` 健康管理菜单组下新增 AI 分析入口。当前菜单为扁平结构(无子菜单折叠),新增项直接追加为同级菜单项:
|
||||
|
||||
```
|
||||
健康管理
|
||||
├── 患者管理
|
||||
├── 医护管理
|
||||
├── 预约管理
|
||||
├── 随访管理
|
||||
├── 咨询管理
|
||||
├── 积分商城
|
||||
├── 统计看板
|
||||
├── AI Prompt 管理 ← 新增(扁平)
|
||||
├── AI 分析历史 ← 新增(扁平)
|
||||
└── AI 用量统计 ← 新增(扁平)
|
||||
```
|
||||
|
||||
### 3.6 验证标准
|
||||
|
||||
- [ ] Prompt CRUD 全流程可用(创建/编辑/激活/回滚)
|
||||
- [ ] 分析历史可筛选、可查看详情(Markdown 正确渲染)
|
||||
- [ ] 用量统计图表数据正确
|
||||
- [ ] 所有操作按钮受 AuthButton 权限控制
|
||||
- [ ] 页面响应式布局正常
|
||||
|
||||
---
|
||||
|
||||
## 4. 切片 3:小程序 AI 报告查看
|
||||
|
||||
### 4.1 新增页面
|
||||
|
||||
**AI 报告列表页**
|
||||
|
||||
位置: `apps/miniprogram/src/pages/ai-report/list/index.tsx`
|
||||
|
||||
- 调用 `GET /api/v1/ai/analysis/history` (后端需实现,根据 JWT user_id → patient_id 自动过滤)
|
||||
- 列表展示分析记录(类型图标 + 时间 + 状态标签)
|
||||
- 点击进入详情
|
||||
|
||||
**AI 报告详情页**
|
||||
|
||||
位置: `apps/miniprogram/src/pages/ai-report/detail/index.tsx`
|
||||
|
||||
- 调用 `GET /api/v1/ai/analysis/{id}`
|
||||
- 使用 `taro-markdown` 组件渲染 Markdown 格式的分析结果(需先验证兼容性)
|
||||
- 底部展示分析时间和 token 用量信息
|
||||
|
||||
### 4.2 路由集成
|
||||
|
||||
在首页(`pages/index/index.tsx`)健康数据区域增加"AI 报告"入口卡片。
|
||||
|
||||
### 4.3 后端依赖
|
||||
|
||||
后端 `list_analysis` 和 `get_analysis` 当前为空壳(仅验证权限后返回空值),需实现:
|
||||
- 根据 JWT 中的 user_id 查找关联 patient_id
|
||||
- 从 `ai_analysis_results` 表查询该患者的分析记录
|
||||
- 返回不含 PII 的脱敏结果
|
||||
|
||||
### 4.4 验证标准
|
||||
|
||||
- [ ] 患者可查看自己的 AI 分析历史
|
||||
- [ ] 详情页 Markdown 正确渲染
|
||||
- [ ] 无法查看其他患者的报告
|
||||
- [ ] 无报告时显示空状态提示
|
||||
|
||||
---
|
||||
|
||||
## 5. 实施顺序
|
||||
|
||||
| 阶段 | 内容 | 依赖 | 预计工作量 |
|
||||
|------|------|------|-----------|
|
||||
| P0a | 后端:新增 `/api/v1/auth/me/permissions` 端点 | 无 | erp-auth handler + service |
|
||||
| P0b | 后端:实现 Prompt CRUD 端点(list/create/update/activate/rollback) | 无 | erp-ai handler + service |
|
||||
| P0c | 后端:实现分析历史查询(list_analysis/get_analysis 从空壳到真实查询) | 无 | erp-ai handler + service |
|
||||
| P0d | 后端:新增用量统计聚合端点(overview/trend/by-type/by-user) | 无 | erp-ai handler + service + 可能迁移 |
|
||||
| P1 | 前端:usePermission hook + AuthButton/AuthGuard 组件 + auth store 加载 permissions | P0a | 3 文件 |
|
||||
| P2 | 前端:健康模块页面按钮权限改造 | P1 | 15 文件 |
|
||||
| P3 | 前端:AI API 封装(3 个 service 文件) | P0b-d | 3 文件 |
|
||||
| P4 | 前端:AI Prompt 管理页面 | P1, P3 | 1 文件 |
|
||||
| P5 | 前端:AI 分析历史页面 | P1, P3 | 1 文件 |
|
||||
| P6 | 前端:AI 用量统计页面 | P1, P3 | 1 文件 |
|
||||
| P7 | 前端:菜单注册 + 路由配置 | P4-P6 | 2 文件 |
|
||||
| P8 | 小程序:验证 taro-markdown 兼容性 + AI 报告列表/详情页 | P0c | 3 文件 |
|
||||
| P9 | 小程序:首页入口集成 | P8 | 1 文件 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 非目标(明确排除)
|
||||
|
||||
- 不涉及 CI/CD 流水线建设(属于安全与稳定性方向)
|
||||
- 不涉及 erp-plugin unwrap 修复(属于代码质量方向)
|
||||
- 不涉及 TypeScript strict 模式开启(属于质量方向,单独处理)
|
||||
- 不涉及新的 AI 提供商接入(仅使用现有 Claude 提供商)
|
||||
- 不涉及用量配额/计费功能(后续迭代)
|
||||
|
||||
---
|
||||
|
||||
## 7. 技术约束
|
||||
|
||||
- 前端组件使用 Ant Design 6 现有组件
|
||||
- 图表使用 Ant Design Charts(项目已有依赖)
|
||||
- 小程序 Markdown 渲染使用 taro-markdown 组件(P8 开始前验证兼容性)
|
||||
- 后端新增端点遵循现有 handler/service/entity 模式
|
||||
- 所有新页面使用 i18n key(前缀约定:`health.ai.*`),不硬编码中文
|
||||
- 权限数据加载失败时默认无权限(安全降级,宁可少显示按钮也不暴露越权操作)
|
||||
@@ -0,0 +1,806 @@
|
||||
# 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+** | |
|
||||
@@ -0,0 +1,450 @@
|
||||
# HMS 健康管理模块业务流程合理性分析
|
||||
|
||||
> 日期: 2026-04-25
|
||||
> 状态: Draft
|
||||
> 分析方法: 三专家组并行深度审查(临床业务 + 运营管理 + 产品架构)
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
HMS 健康管理模块已完成初始实现,包含 27 个数据库实体、14 个权限描述符(7 组 .list/.manage)、16 个 Web 页面、21 个小程序页面。本次分析从**临床业务流程、运营管理有效性、产品架构竞争力**三个维度进行深度审查。
|
||||
|
||||
### 核心结论
|
||||
|
||||
**工程基础设施达到生产级水准。** 多租户隔离、CAS 并发控制、乐观锁、事件驱动架构、数据加密(AES-256 + HMAC)、审计日志等能力一应俱全。
|
||||
|
||||
**业务深度存在三个核心缺口:**
|
||||
|
||||
| 缺口 | 影响 | 紧急度 |
|
||||
|------|------|--------|
|
||||
| 诊断编码缺失 | 随访/趋势分析/统计报表缺乏医学语义锚点,与 HIS/LIS 无法对接 | P0 |
|
||||
| 统计数据造假 | Dashboard 硬编码 0 值,管理者无法做运营决策 | P0 |
|
||||
| 消息推送未集成 | 设计规格定义 11 种事件,代码发布 9 种(缺失 `follow_up.overdue` 等),患者触达手段严重不足 | P0 |
|
||||
|
||||
### 发现总计
|
||||
|
||||
- **P0 关键问题**: 6 个(产品可信度 + 患者安全)
|
||||
- **P1 核心缺失**: 8 个(业务能力补全)
|
||||
- **P2 运营增强**: 7 个(差异化竞争力)
|
||||
- **P3 长期规划**: 7 个(市场准入 + 技术领先)
|
||||
|
||||
### 差异化竞争优势
|
||||
|
||||
1. **Rust 技术性能壁垒** — 高并发预约场景显著优势,单体二进制部署比竞品微服务架构简单一个数量级
|
||||
2. **血透专科深度** — 竞品中少有的专科化设计,补全方案/通路/充分性后更具竞争力
|
||||
3. **患者运营闭环** — 积分商城+签到+活动+资讯,医疗 SaaS 中罕见的互联网运营思维
|
||||
4. **多租户原生设计** — 从第一天内置隔离,竞品多为后期改造
|
||||
|
||||
---
|
||||
|
||||
## 1. 临床业务流程评估
|
||||
|
||||
> 专家角色: 资深临床医疗信息化专家(15 年医院信息系统设计经验)
|
||||
|
||||
### 1.1 患者全生命周期
|
||||
|
||||
**评分: 6/10 — "两头有、中间空"**
|
||||
|
||||
已覆盖的环节:
|
||||
- 建档阶段扎实:加密存储、标签分类、实名认证状态流转、家庭关系、医患关联
|
||||
- 健康摘要 API (`get_health_summary`) 聚合最新体征/化验/预约/随访,提供一站式概览
|
||||
|
||||
缺失的关键环节:
|
||||
|
||||
| 环节 | 说明 | 影响 |
|
||||
|------|------|------|
|
||||
| 分诊 (Triage) | 无主诉、分诊科室、紧急程度的记录 | 预约直接绑定医生,跳过分诊 |
|
||||
| 诊疗记录 (EMR) | `health_record` 仅含 `overall_assessment` + 文件 URL | 缺主诉、现病史、体格检查、诊断、处方 |
|
||||
| 诊断编码 (ICD) | 无 ICD-10/ICD-11 支持 | 随访/趋势分析缺乏医学语义锚点 |
|
||||
| 转诊流程 | 无科室间/院间转诊记录和追踪 | 多学科协作无法支撑 |
|
||||
| 入组/出组管理 | 只有标签这一非结构化方式 | 健康管理项目无法规范化 |
|
||||
|
||||
### 1.2 医疗数据管理
|
||||
|
||||
**评分: 5/10 — 基本可用但临床精细度不足**
|
||||
|
||||
优点:
|
||||
- 体征数据晨/晚血压区分符合慢病管理实践
|
||||
- 化验报告 V2 JSON 结构 `[{name, value, unit, reference_low, reference_high, is_abnormal}]` 灵活实用
|
||||
- 透析记录覆盖干体重、超滤量、血流量、透析类型 (HD/HDF/HF)
|
||||
|
||||
关键缺失:
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| `vital_signs` 与 `daily_monitoring` 字段 91% 重叠 | 数据冗余 + 命名不一致(`systolic_bp_morning` vs `morning_bp_systolic`),趋势分析只查 `vital_signs` 表,`daily_monitoring` 数据完全被忽略 |
|
||||
| 缺乏采集时间精度 | 只有 `record_date`,血压需精确到分钟,血糖需标注空腹/餐后 |
|
||||
| 缺乏体温和 SpO2 | 透析感染监测和呼吸系统疾病管理的必备指标 |
|
||||
| 血糖无类型标记 | 空腹/餐后/随机/OGTT 混在一起,参考范围完全不同 |
|
||||
| 无用药记录 | 小程序有 `profile/medication` 页面但后端无对应实体 |
|
||||
| 化验指标未标准化 | "肌酐"/"CREA"/"Creatinine" 多种写法影响趋势分析 |
|
||||
| 出入量记录过于简单 | 仅有饮水量和尿量,临床需区分口服/静脉/引流/失血 |
|
||||
|
||||
### 1.3 预约排班
|
||||
|
||||
**评分: 7/10 — 核心流程优秀,运营辅助不足**
|
||||
|
||||
优点:
|
||||
- CAS 原子操作防超额预约,取消时自动释放名额,事务保证一致性
|
||||
- 状态机完整: pending → confirmed → completed/no_show/cancelled
|
||||
- 5 种预约类型覆盖主要场景(透析/复诊/门诊/体检/咨询)
|
||||
|
||||
缺失:
|
||||
- 无资源维度绑定(透析机位、检查室)
|
||||
- 无批量排班/排班模板(透析中心需周期性排班)
|
||||
- 无候补机制(号源满后直接拒绝)
|
||||
- no_show 无自动触发(定时任务缺失)
|
||||
- 排班不区分节假日
|
||||
|
||||
### 1.4 随访管理
|
||||
|
||||
**评分: 6/10 — 基础流程完整,临床实用性不足**
|
||||
|
||||
优点:
|
||||
- 逾期自动检查 (`check_overdue_tasks`) + 自动创建后续任务(`next_follow_up_date` 机制)
|
||||
- 乐观锁保护所有更新操作
|
||||
|
||||
缺失:
|
||||
- 无结构化随访模板(`content_template` 是纯文本)
|
||||
- 随访结果无结构化(`result`/`patient_condition`/`medical_advice` 均为自由文本)
|
||||
- 无随访到期提醒通知
|
||||
- 无随访方案模板(一次性生成 1/3/6/12 月任务组)
|
||||
- 无优先级字段
|
||||
- **前后端类型不一致**: 后端 `phone`/`face_to_face`/`online`,前端 `phone`/`outpatient`/`home_visit`/`wechat`
|
||||
|
||||
### 1.5 透析管理
|
||||
|
||||
**评分: 4/10 — "透析记录本"而非"透析管理系统"**
|
||||
|
||||
已实现: 透析记录覆盖核心物理参数(体重变化/血压/超滤/血流量),审阅流程 (draft → reviewed)。
|
||||
|
||||
三大核心支柱缺失:
|
||||
|
||||
| 支柱 | 说明 | 临床影响 |
|
||||
|------|------|---------|
|
||||
| 透析方案 (Prescription) | 无透析器型号、透析液配方、抗凝方案、目标超滤、血管通路类型 | 每次透析需重新输入相同参数 |
|
||||
| 血管通路管理 | 无通路类型/位置/评估/并发症/手术史 | 通路是透析患者的"生命线" |
|
||||
| 充分性评估 | 无 Kt/V 和 URR 计算 | 无法评估透析质量 |
|
||||
|
||||
其他缺失: 透析中动态监测、透析药物管理 (EPO/铁剂/磷结合剂)、症状/并发症未结构化。此外,entity 注释中定义了 `completed` 状态但无代码路径可达,存在"幽灵状态"问题。
|
||||
|
||||
### 1.6 咨询管理
|
||||
|
||||
**评分: 5/10 — 适合"留言板",无法支撑"在线问诊"**
|
||||
|
||||
优点: 消息模型合理(text/image/voice/file 四种类型、CAS 未读计数、会话状态机 waiting → active → closed)。
|
||||
|
||||
缺失:
|
||||
- **无实时推送**: 只有 HTTP API,无 WebSocket/SSE
|
||||
- 消息已读标记 API 缺失
|
||||
- 无会话超时自动关闭
|
||||
- 无满意度评价
|
||||
- 无智能分配机制(轮转/科室匹配)
|
||||
- 无关联健康数据展示
|
||||
|
||||
### 1.7 临床决策支持
|
||||
|
||||
**评分: 3/10 — "概念验证"阶段**
|
||||
|
||||
已实现: 趋势分析框架、异常值基本检测(心率 60-100、血糖 3.9-11.1、血压 90-140/60-90)、化验 `is_abnormal` 标记。
|
||||
|
||||
关键问题:
|
||||
- **阈值硬编码** — 不考虑患者个体差异(老年/糖尿病/透析/儿童不同目标)
|
||||
- **血糖阈值不一致** — 趋势分析用 3.9-11.1,小程序摘要用 3.9-6.1
|
||||
- **无实时预警** — 趋势分析是手动触发,血压 180/110 不会自动报警
|
||||
- **不整合化验数据** — 肌酐/eGFR/血红蛋白/电解质趋势同样重要
|
||||
- **无风险评分** — Framingham/KDOQI/MNA 等临床评分模型完全缺失
|
||||
|
||||
---
|
||||
|
||||
## 2. 运营管理评估
|
||||
|
||||
> 专家角色: 资深健康管理运营专家(10+ 年体检中心/健康管理机构运营经验)
|
||||
|
||||
### 2.1 积分激励体系
|
||||
|
||||
**评分: 6/10 — 架构优秀,激励有效性不足**
|
||||
|
||||
优点:
|
||||
- 事件驱动积分发放,`daily_cap` 每日上限,FIFO 消费模型,12 个月过期
|
||||
- 连续签到阶梯奖励 (7/14/30 天)
|
||||
- 全链路 CAS 并发安全
|
||||
|
||||
关键问题:
|
||||
|
||||
| 问题 | 影响 |
|
||||
|------|------|
|
||||
| 事件类型有限 | 只有 6 种行为获积分,缺少 `vital_signs_input`/`annual_checkup`/`followup_adherence` 等真正驱动健康行为的激励点 |
|
||||
| 积分通胀无控制 | 无发放/消费比率监控、无月度预算上限 |
|
||||
| 积分过期未清理 | `expires_at` 字段写入后无定时检查,`total_expired` 永远为 0 |
|
||||
| 订单过期未取消 | 兑换订单 30 天过期但无定时任务退还积分 |
|
||||
| 无过期提醒 | 患者无法得知积分即将过期 |
|
||||
| 无补签机制 | 中断一天即重置,缺少积分兑换补签卡 |
|
||||
|
||||
### 2.2 患者参与度
|
||||
|
||||
**评分: 4/10 — 触达手段严重不足**
|
||||
|
||||
已有渠道: 每日打卡、积分商城、线下活动、咨询管理、随访任务、体征上报。
|
||||
|
||||
缺失的关键互动:
|
||||
|
||||
| 互动方式 | 状态 | 影响 |
|
||||
|----------|------|------|
|
||||
| 消息推送 (Push) | 未集成 erp-message | 随访/积分/报告/预约提醒全部缺失 |
|
||||
| 健康目标与挑战 | 无 | 无法设定"30 天降压"目标,无社区排行 |
|
||||
| 成就体系 (Badge) | 无 | 无"连续打卡 30 天"等徽章激励 |
|
||||
| 社交互动 | 无 | 家庭成员表仅记录信息,无家庭健康管理联动 |
|
||||
| 内容运营 | 基础 | 文章有 CRUD 但无推荐/阅读积分/分享积分 |
|
||||
|
||||
### 2.3 随访管理运营
|
||||
|
||||
**评分: 5/10 — 单条操作效率低**
|
||||
|
||||
**核心问题: 不支持批量操作。** 实际体检中心一个护士每天要处理 30-50 个随访任务,逐个操作效率极低。
|
||||
|
||||
缺失:
|
||||
- 批量创建(按患者标签/疾病类型)
|
||||
- 批量分配负责人
|
||||
- 批量标记完成
|
||||
- 随访工作量统计(按护士维度的完成率/响应时间)
|
||||
- 随访计划模板(按疾病类型的标准化方案)
|
||||
|
||||
### 2.4 健康干预闭环
|
||||
|
||||
**评分: 4/10 — 闭环完整度约 40%**
|
||||
|
||||
```
|
||||
数据采集 → 异常识别 → 干预建议 → 执行跟踪 → 效果评估
|
||||
80% 20% 0% 30% 0%
|
||||
```
|
||||
|
||||
- 数据采集基本完整(体征/化验/透析)
|
||||
- 异常识别仅有简单阈值判断,无实时预警
|
||||
- 干预建议完全缺失(`medical_advice` 是自由文本,无标准化方案库)
|
||||
- 执行跟踪仅有随访链式创建
|
||||
- 效果评估完全缺失(无 Health Score、无干预前后对比)
|
||||
|
||||
### 2.5 数据分析与报表
|
||||
|
||||
**评分: 2/10 — 不可用于运营决策**
|
||||
|
||||
**严重问题: Dashboard 数据大部分是假的。**
|
||||
|
||||
| 指标 | 实现方式 | 可信度 |
|
||||
|------|---------|--------|
|
||||
| 患者总数 | `list?page_size=1` 取 total | 部分 |
|
||||
| 本月新增 | 硬编码 `0` | 不可信 |
|
||||
| 本周新增 | 硬编码 `0` | 不可信 |
|
||||
| 本月活跃 | 硬编码 `0` | 不可信 |
|
||||
| 随访完成率 | 硬编码 `0` | 不可信 |
|
||||
| 咨询总量 | `list?page_size=1` 取 total | 部分 |
|
||||
| 积分统计 | SQL 聚合 | 可信 |
|
||||
| 积分排行 | SQL 排序 | 可信 |
|
||||
|
||||
缺失的关键运营指标: 患者覆盖率、随访完成率趋势、积分使用率、活动参与率、患者留存率、医生工作量分布。无时间维度分析,无数据导出能力。
|
||||
|
||||
### 2.6 多角色协作
|
||||
|
||||
**评分: 5/10 — 职责边界模糊**
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| 护士/健康管理师无独立 profile | 随访 `assigned_to` 指向医生选择器,但护士才是随访主力 |
|
||||
| 职责边界不清 | `patient_doctor_relation` 只有 `primary`/`consulting`,无"健康管理师"角色 |
|
||||
| 前台核销不流畅 | 需手动输入 UUID,无扫码枪集成 |
|
||||
| 无转诊协作机制 | 无医生间转诊流程,无 MDT 任务分配 |
|
||||
| 无智能工作负载分配 | 随访任务完全手动分配 |
|
||||
|
||||
### 2.7 商业变现能力
|
||||
|
||||
**评分: 4/10 — 基础模型在,变现工具缺失**
|
||||
|
||||
积分商城当前只有获取+兑换的基础闭环。缺失:
|
||||
- 积分充值通道(现金购买)
|
||||
- 会员等级体系 (VIP/SVIP)
|
||||
- 营销工具(优惠券/限时活动/邀请有礼/满减)
|
||||
- 供应商管理(商品采购成本/物流)
|
||||
- 数据分析(商品热度/用户分层 RFM)
|
||||
|
||||
---
|
||||
|
||||
## 3. 产品架构评估
|
||||
|
||||
> 专家角色: 资深医疗 SaaS 产品架构师(10+ 年医疗信息化产品设计经验)
|
||||
|
||||
### 3.1 模块边界与耦合
|
||||
|
||||
**评分: 8/10 — 边界清晰,事件契约有进步空间**
|
||||
|
||||
优点:
|
||||
- 对 erp-core 仅依赖共享类型(AppError/EventBus/PaginatedResponse)
|
||||
- 对 erp-auth 通过 `user_id` 外键松耦合,`Option<Uuid>` 允许患者先建档后绑定
|
||||
- 声明 `dependencies() = vec!["auth"]` 语义明确
|
||||
|
||||
关键问题:
|
||||
- **事件发布不完整**: 设计规格定义 11 种事件,代码实际发布 9 种(含 2 种设计规格外的: `doctor.online_status_changed`/`lab_report.uploaded`)。缺失 `follow_up.overdue` 等关键事件
|
||||
- **message.sent 订阅为空操作**: 只打 `tracing::info`,但 `consultation_session.last_message_at` 已在 `create_message` 方法中通过 CAS 直接更新,此事件订阅是预留扩展点而非功能缺失
|
||||
- 缺少密钥轮换机制
|
||||
|
||||
### 3.2 数据模型完整性
|
||||
|
||||
**评分: 6/10 — 覆盖核心域,存在医疗级缺口**
|
||||
|
||||
已覆盖(27 实体): 患者管理(完整)、医护管理(基本完整)、健康数据(完整)、预约排班(完整)、随访管理(完整)、咨询管理(完整)、专科血透(扩展完整)、患者运营(超预期)。
|
||||
|
||||
缺失的核心实体(按重要性排序):
|
||||
|
||||
| 实体 | 优先级 | 理由 |
|
||||
|------|--------|------|
|
||||
| 诊断记录 (Diagnosis) | P0 | ICD-10 编码,所有临床活动的锚点 |
|
||||
| 用药记录 (Medication) | P1 | 慢病管理核心数据,小程序页面已存在 |
|
||||
| 处方/医嘱 (Prescription) | P1 | 结构化处方是慢病管理核心 |
|
||||
| 健康计划 (Care Plan) | P2 | 从"数据记录"到"主动干预"的关键 |
|
||||
| 过敏详细记录 (Allergy) | P2 | 药物过敏交叉检查需要结构化数据 |
|
||||
|
||||
### 3.3 API 设计质量
|
||||
|
||||
**评分: 7/10 — RESTful 规范,高级特性缺失**
|
||||
|
||||
优点: 统一分页 `PaginatedResponse<T>`、乐观锁 `DeleteWithVersion`、状态机校验、管理端/患者端路由分离。
|
||||
|
||||
不足:
|
||||
- 无批量操作 API
|
||||
- 排序参数未暴露(硬编码 `order_by_desc(CreatedAt)`)
|
||||
- 日期范围过滤缺失(预约只有单日过滤)
|
||||
- 统计 API 不专业(用 `list?page_size=1` 模拟)
|
||||
- 咨询无实时机制
|
||||
- 导出功能单一
|
||||
|
||||
### 3.4 前端架构
|
||||
|
||||
**评分: 7/10 — 组件化程度高,全局状态管理缺失**
|
||||
|
||||
Web 前端优点: API 层按领域拆分(8 个 TS 文件)、12 个共享组件、Tab 化详情页、主题适配。
|
||||
|
||||
不足:
|
||||
- 无 Zustand 全局状态(健康模块共享数据如当前患者、名称缓存无法跨页面)
|
||||
- StatisticsDashboard 数据质量低
|
||||
- 日期处理类型不够安全
|
||||
|
||||
小程序优点: 27 页面覆盖全面、服务层与 Web 端一一对应。
|
||||
|
||||
不足: 无离线缓存策略、无统一错误处理/重试。
|
||||
|
||||
### 3.5 多租户适配
|
||||
|
||||
**评分: 8/10 — 基础隔离完整,差异化配置不足**
|
||||
|
||||
已实现: 全实体 `tenant_id` 过滤、租户生命周期钩子(seed/soft_delete)、AES-256-GCM 加密 + HMAC 索引、数据脱敏、行级数据权限。
|
||||
|
||||
缺失: 不同医疗机构类型(体检中心/社区中心/血透中心/专科诊所)的差异化配置能力。`patient` 模型是"大一统"设计,无法自定义采集项、评估量表、报告模板。
|
||||
|
||||
### 3.6 扩展性与可配置性
|
||||
|
||||
**评分: 5/10 — 架构级良好,业务层不足**
|
||||
|
||||
架构级(良好): ErpModule trait 注册、EventBus 解耦、WASM 插件保留、SeaORM Entity + Migration 扩展。
|
||||
|
||||
业务层(不足): 无自定义表单(EAV 或 JSONB + Schema)、无自定义工作流模板、无报告模板。积分规则和标签系统是系统中少有的可配置业务模块。
|
||||
|
||||
### 3.7 竞品对比
|
||||
|
||||
| 维度 | HMS | 杏树林 | 微医 | 平安好医生 |
|
||||
|------|-----|--------|------|-----------|
|
||||
| 技术性能 | 极高 (Rust) | 中等 (Java) | 中等 | 中等 |
|
||||
| 多租户 | 原生支持 | 后期改造 | 有限 | 有限 |
|
||||
| 患者运营 | 有(积分+商城) | 无 | 无 | 有 |
|
||||
| 血透专科 | 有(待完善) | 无 | 无 | 无 |
|
||||
| AI 能力 | 开发中 | 成熟 | 部分 | 成熟 |
|
||||
| 实时通讯 | 无 | 音视频 | 音视频 | 音视频 |
|
||||
| 医疗标准 | 无 | 有 (ICD) | 有 | 有 |
|
||||
| 影像管理 | 无 | 有 | 有 | 无 |
|
||||
| 合规认证 | 无 | 等保三级 | 等保三级 | 等保三级 |
|
||||
|
||||
**核心差距**: AI 能力、实时通讯、医疗数据标准、影像管理、合规认证。
|
||||
|
||||
---
|
||||
|
||||
## 4. 综合改进路线图
|
||||
|
||||
### Phase 1: 产品可信度修复 (P0, 2-3 人天)
|
||||
|
||||
> 影响: 管理者无法决策、患者安全风险、跨模块联动断裂
|
||||
|
||||
| # | 改进项 | 涉及文件 | 复杂度 |
|
||||
|---|--------|---------|--------|
|
||||
| 1 | 修复 Dashboard 统计数据 | `points.ts` + `points_service.rs` + `StatisticsDashboard.tsx` | 中 |
|
||||
| 2 | 补全事件发布(至少 `follow_up.overdue`) | `event.rs` + `follow_up_service.rs` | 中 |
|
||||
| 3 | 合并 vital_signs 和 daily_monitoring | entity + service + DTO + migration | 中 |
|
||||
| 4 | 增加实时异常预警 | `health_data_service.rs` + `trend_service.rs` | 中 |
|
||||
| 5 | 增加 ICD-10 诊断编码支持 | 新建 entity + migration + service | 中 |
|
||||
| 6 | 实现积分过期清理定时任务 | `points_service.rs` + `module.rs` | 低 |
|
||||
|
||||
### Phase 2: 核心业务能力补全 (P1, 5-7 人天)
|
||||
|
||||
> 影响: 临床实用性不足、患者参与度低、运营效率差
|
||||
|
||||
| # | 改进项 | 复杂度 |
|
||||
|---|--------|--------|
|
||||
| 7 | 结构化随访模板系统 | 高 |
|
||||
| 8 | 用药记录实体 | 中 |
|
||||
| 9 | 透析方案管理 | 中 |
|
||||
| 10 | 体征增加体温/SpO2/血糖类型 | 低 |
|
||||
| 11 | 消息推送集成 | 中 |
|
||||
| 12 | 批量随访操作 | 中 |
|
||||
| 13 | 修复随访类型前后端不一致 | 低 |
|
||||
| 14 | 咨询 WebSocket 实时推送 | 高 |
|
||||
|
||||
### Phase 3: 运营增强 (P2, 5-7 人天)
|
||||
|
||||
> 影响: 差异化竞争力、患者留存、商业变现
|
||||
|
||||
| # | 改进项 | 复杂度 |
|
||||
|---|--------|--------|
|
||||
| 15 | 患者健康评分体系 (Health Score) | 中 |
|
||||
| 16 | 会员等级和营销工具 | 中 |
|
||||
| 17 | 预约资源绑定 | 中 |
|
||||
| 18 | 个性化异常阈值配置 | 中 |
|
||||
| 19 | 化验指标标准化字典 (LOINC) | 中 |
|
||||
| 20 | 批量排班/排班模板 | 中 |
|
||||
| 21 | 小程序分析埋点后端 | 低 |
|
||||
|
||||
### Phase 4: 长期竞争力 (P3, 路线图)
|
||||
|
||||
| # | 改进项 |
|
||||
|---|--------|
|
||||
| 22 | 血管通路管理 |
|
||||
| 23 | 疾病风险评分模型 |
|
||||
| 24 | AI 辅助诊断 (erp-ai 集成) |
|
||||
| 25 | 可配置表单能力 |
|
||||
| 26 | 影像管理集成 (DICOM/PACS) |
|
||||
| 27 | 合规认证 (等保三级) |
|
||||
| 28 | HL7 FHIR R4 数据互操作 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A: 与 QA 审计计划的交叉引用
|
||||
|
||||
本分析与 [QA 审计计划](../../plans/qa-review-brainstorm-floofy-finch.md) 的发现高度重叠,以下是交叉对照:
|
||||
|
||||
| QA 审计编号 | 业务分析对应 | 状态 |
|
||||
|------------|-------------|------|
|
||||
| 1.1 逾期随访检查器未启动 | §1.4 随访管理 — 逾期自动检查已实现但需修复 | 需修复 |
|
||||
| 1.2 积分并发余额损坏 | §2.1 积分激励体系 — CAS 并发安全 | 需修复 |
|
||||
| 2.4 HealthDataProvider 全 stub | §1.7 临床决策支持 — AI 集成依赖 | 需决策 |
|
||||
| 2.6 小程序 DTO 不匹配 | §1.2 医疗数据管理 — 数据模型对齐 | 需修复 |
|
||||
| 3.1 咨询列表显示截断 UUID | §3.4 前端架构 — 名称缓存 | 已修复 |
|
||||
| 3.2 积分订单列表显示 UUID | §3.4 前端架构 — 需名称解析 | 待修复 |
|
||||
| 3.4 预约状态变更无确认 | §1.3 预约排班 — 已在 AppointmentList 中实现 | 已修复 |
|
||||
|
||||
## 附录 B: 工作量估算
|
||||
|
||||
| 阶段 | 人天 | 优先级 |
|
||||
|------|------|--------|
|
||||
| Phase 1: P0 可信度修复 | 2-3 | 立即 |
|
||||
| Phase 2: P1 核心能力 | 5-7 | 本迭代 |
|
||||
| Phase 3: P2 运营增强 | 5-7 | 下迭代 |
|
||||
| Phase 4: P3 长期竞争力 | 路线图 | Q3-Q4 |
|
||||
| **合计** | **12-17 + 路线图** | |
|
||||
@@ -0,0 +1,670 @@
|
||||
# HMS V2 迭代设计 — 血透专科健康管理平台
|
||||
|
||||
> **日期**: 2026-04-25
|
||||
> **状态**: 待评审
|
||||
> **前置文档**: `2026-04-23-health-management-module-design.md`, `2026-04-24-health-module-iteration-design.md`
|
||||
> **需求来源**: 客户功能需求文档 `docs/健康管理/管理系统功能文档(1).xlsx`
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
### 1.1 业务定位变化
|
||||
|
||||
V1 的健康管理模块定位为**通用健康管理平台**,覆盖患者管理、健康数据、预约排班、随访管理、咨询管理五大功能。
|
||||
|
||||
客户反馈后,明确业务定位为**血透(透析)专科健康管理平台**,面向肾病/透析患者和医护群体。这带来三个重大变化:
|
||||
|
||||
1. **数据模型专科化** — 从通用健康指标(血压/心率/血糖/体重/体温)扩展为血透专科指标(透析记录、化验报告、日常监测)
|
||||
2. **新增积分商城** — 替代原计划的完整电商,用积分体系驱动患者活跃度
|
||||
3. **新增医护端小程序** — 独立入口,医护可查看患者数据、填写透析记录、回复咨询
|
||||
|
||||
### 1.2 客户需求来源
|
||||
|
||||
客户通过 Excel 功能文档定义了三端需求:
|
||||
|
||||
| 端 | 核心功能 |
|
||||
|---|---|
|
||||
| 患者端小程序 | 首页、数据上报(透析/化验/日常)、在线咨询、积分商城、预约与随访、个人中心 |
|
||||
| 医护端小程序 | 数据概览、患者管理、咨询回复、随访管理、报告解读 |
|
||||
| PC 管理后台 | 系统管理、患者管理、健康数据中心、咨询管理、商城管理、内容管理、统计报表、系统设置 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 需求-实现差距分析
|
||||
|
||||
### 2.1 已有功能匹配度
|
||||
|
||||
| 客户需求 | 已有实现 | 匹配度 | 需要的工作 |
|
||||
|---------|---------|--------|-----------|
|
||||
| 患者列表/档案 | Patient CRUD + 18 实体 | 90% | 微调 |
|
||||
| 在线咨询(图文) | Consultation 模块 | 70% | 加语音/客服通道 |
|
||||
| 预约管理 | Appointment 模块 | 60% | 加透析专项预约 |
|
||||
| 随访任务/台账 | Follow-up 模块 | 80% | 微调 |
|
||||
| 科普文章 | Article 模块 | 90% | 微调 |
|
||||
| 医护管理 | Doctor 模块 | 80% | 微调 |
|
||||
| 账号权限/操作日志 | erp-auth RBAC | 95% | 基本不变 |
|
||||
| 患者标签 | Tag 模块 | 90% | 微调 |
|
||||
| 小程序健康数据 | 6 种指标录入 + ECharts 趋势图 | 80% | 扩展指标 + 透析数据 |
|
||||
|
||||
### 2.2 全新模块
|
||||
|
||||
| 模块 | 说明 | 复杂度 |
|
||||
|------|------|--------|
|
||||
| 血透透析记录 | 透析日期/时间、干体重、透前/后血压、心率、超滤量、症状 | 中 |
|
||||
| 日常监测扩展 | 饮水量、尿量 + 打卡模式 | 低 |
|
||||
| 化验报告上传 | 拍照上传 + 指标录入 | 中 |
|
||||
| 积分商城 | 积分获取、商品兑换、二维码核销 | 高 |
|
||||
| 线下活动 | 活动管理、报名、扫码签到 | 中 |
|
||||
| 在线咨询(IM) | 客服通道、医生通道、图文/语音消息 | 高 |
|
||||
| 医护端小程序 | 独立小程序,医护专属功能 | 高 |
|
||||
| 统计报表中心 | 患者增长、咨询量、随访完成率、商城销售 | 中-高 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 积分商城设计
|
||||
|
||||
### 3.1 核心链路
|
||||
|
||||
**赚积分 → 攒积分 → 花积分 → 核销**
|
||||
|
||||
### 3.2 实现方式
|
||||
|
||||
放在 `erp-health` 原生模块内,直接复用现有患者体系和事件机制。
|
||||
|
||||
### 3.3 积分获取渠道(数据库可配置)
|
||||
|
||||
| 渠道 | 积分 | 说明 |
|
||||
|------|------|------|
|
||||
| 每日健康打卡 | 可配置/天 | 完成日常监测数据填写触发 |
|
||||
| 数据上报 | 可配置/次 | 上传化验单、填透析记录 |
|
||||
| 线下活动签到 | 活动配置 | 到院参加讲座/义诊等 |
|
||||
| 连续打卡奖励 | 阶梯配置 | 连续 7/14/30 天额外加分 |
|
||||
| 医生互动 | 可配置/次 | 完成一次咨询、回复随访问卷 |
|
||||
|
||||
积分获取规则通过 `points_rule` 表配置,管理员可在 PC 端调整积分值、每日上限、连续奖励等参数。
|
||||
|
||||
### 3.4 兑换品类
|
||||
|
||||
| 类型 | 兑换物 | 履约方式 |
|
||||
|------|--------|---------|
|
||||
| 实物 | 血透护理用品、肾病食品、慢病器械 | 到院自提(二维码核销) |
|
||||
| 服务券 | 免费抽血、肾功能检查、营养咨询 | 生成预约券 → 线下二维码核销 |
|
||||
| 权益 | 免费停车券、优先预约权 | 虚拟权益即时生效 |
|
||||
|
||||
### 3.5 核销方式
|
||||
|
||||
用户兑换后生成二维码(UUID),到院后工作人员扫码核销。
|
||||
|
||||
服务券类型核销后可自动关联预约系统创建预约。
|
||||
|
||||
### 3.6 积分过期机制
|
||||
|
||||
**滚动 12 个月过期,FIFO 先进先出结算。**
|
||||
|
||||
- 每笔积分(earn)有独立 `expires_at`(创建时间 + 12 个月)
|
||||
- 每日后台任务扫描并标记过期积分
|
||||
- 消费时从最老的未过期积分开始扣减
|
||||
- 每笔积分支持部分消费(`remaining_amount` 字段)
|
||||
|
||||
### 3.7 数据模型(8 张新表)
|
||||
|
||||
#### points_account(积分账户)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient,唯一约束 |
|
||||
| balance | i32 | 当前可用积分 |
|
||||
| total_earned | i32 | 累计获得 |
|
||||
| total_spent | i32 | 累计消耗 |
|
||||
| total_expired | i32 | 累计过期 |
|
||||
| version | i32 | 乐观锁 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| created_at / updated_at / created_by / updated_by / deleted_at | — | 标准字段 |
|
||||
|
||||
#### points_rule(积分规则,数据库可配置)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| event_type | String | 触发事件:daily_checkin / data_report / lab_upload / event_checkin / consultation_complete / followup_complete |
|
||||
| name | String | 规则名称(展示用) |
|
||||
| description | String | 规则描述 |
|
||||
| points_value | i32 | 单次获得积分 |
|
||||
| daily_cap | i32 | 每日上限(0 = 无限制) |
|
||||
| streak_7d_bonus | i32 | 连续 7 天额外奖励 |
|
||||
| streak_14d_bonus | i32 | 连续 14 天额外奖励 |
|
||||
| streak_30d_bonus | i32 | 连续 30 天额外奖励 |
|
||||
| is_active | bool | 是否启用 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### points_transaction(积分流水,FIFO 桶模型)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| account_id | UUID | FK → points_account |
|
||||
| type | Enum | earn / spend / expired / refund |
|
||||
| amount | i32 | 正数=获得,负数=消耗 |
|
||||
| remaining_amount | i32 | 该笔积分剩余可用量(earn 类型) |
|
||||
| status | Enum | active / expired / consumed |
|
||||
| expires_at | DateTime | 过期时间(earn 类型:创建 + 12 个月) |
|
||||
| balance_after | i32 | 操作后账户余额快照 |
|
||||
| rule_id | UUID | FK → points_rule(earn 类型) |
|
||||
| order_id | UUID | FK → points_order(spend 类型,可空) |
|
||||
| description | String | 流水描述 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### points_product(兑换商品)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| name | String | 商品名称 |
|
||||
| type | Enum | physical / service / privilege |
|
||||
| points_cost | i32 | 兑换所需积分 |
|
||||
| stock | i32 | 库存数量(-1 = 无限) |
|
||||
| image_url | String | 商品图片 |
|
||||
| description | Text | 商品描述 |
|
||||
| service_config | JSON | 服务类型配置(service 类型:关联检查项目、有效期等) |
|
||||
| is_active | bool | 是否上架 |
|
||||
| sort_order | i32 | 排序权重 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### points_order(兑换订单)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| product_id | UUID | FK → points_product |
|
||||
| points_cost | i32 | 消耗积分(冗余,防商品价格变化) |
|
||||
| status | Enum | pending / verified / cancelled / expired |
|
||||
| qr_code | UUID | 核销二维码(UUID v4) |
|
||||
| verified_by | UUID | 核销人 FK → user |
|
||||
| verified_at | DateTime | 核销时间 |
|
||||
| expires_at | DateTime | 订单过期时间(实物/服务券有效期) |
|
||||
| notes | String | 备注 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### points_checkin(每日打卡)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| checkin_date | Date | 打卡日期 |
|
||||
| consecutive_days | i32 | 连续打卡天数(计算值) |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| created_at | DateTime | — |
|
||||
|
||||
唯一约束:(patient_id, checkin_date)
|
||||
|
||||
#### offline_event(线下活动)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| title | String | 活动标题 |
|
||||
| description | Text | 活动描述 |
|
||||
| event_date | Date | 活动日期 |
|
||||
| start_time / end_time | Time | 活动时间 |
|
||||
| location | String | 活动地点 |
|
||||
| points_reward | i32 | 参与奖励积分 |
|
||||
| max_participants | i32 | 最大参与人数(0 = 无限制) |
|
||||
| current_participants | i32 | 已报名人数 |
|
||||
| status | Enum | draft / published / ongoing / completed / cancelled |
|
||||
| image_url | String | 活动封面图 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### offline_event_registration(活动报名 + 签到)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| event_id | UUID | FK → offline_event |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| status | Enum | registered / checked_in / cancelled |
|
||||
| checked_in_at | DateTime | 签到时间 |
|
||||
| checked_in_by | UUID | 签到确认人 FK → user |
|
||||
| points_granted | bool | 是否已发放积分 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
唯一约束:(event_id, patient_id)
|
||||
|
||||
### 3.8 积分获取链路
|
||||
|
||||
```
|
||||
健康打卡 → 触发事件 → points_rule 匹配规则 → 写入 points_transaction → 更新 points_account.balance
|
||||
数据上报 → 同上 ↓
|
||||
咨询完成 → 同上 连续打卡检查
|
||||
线下签到 → 同上 → 达到 7/14/30 天?
|
||||
→ 额外 bonus transaction
|
||||
```
|
||||
|
||||
### 3.9 兑换核销链路
|
||||
|
||||
```
|
||||
用户浏览商品 → 兑换(扣积分)→ 生成 points_order + QR(UUID v4)
|
||||
↓
|
||||
用户到院出示二维码
|
||||
↓
|
||||
工作人员扫码(小程序/PC)→ 状态变 verified
|
||||
↓
|
||||
type=service → 自动创建预约券
|
||||
type=physical → 库存扣减
|
||||
```
|
||||
|
||||
### 3.10 后台任务
|
||||
|
||||
| 任务 | 频率 | 说明 |
|
||||
|------|------|------|
|
||||
| 积分过期扫描 | 每日 | 扫描 expires_at < now 的 earn 记录,标记 expired,扣减 balance |
|
||||
| 订单过期扫描 | 每日 | 扫描未核销且过期的订单,标记 expired,退还积分 |
|
||||
| 连续打卡计算 | 每日 | 计算各患者连续打卡天数,触发阶梯奖励 |
|
||||
|
||||
### 3.11 API 端点
|
||||
|
||||
**患者端:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/points/account | 查看我的积分账户 |
|
||||
| POST | /api/v1/points/checkin | 每日打卡 |
|
||||
| GET | /api/v1/points/checkin/status | 打卡状态(连续天数等) |
|
||||
| GET | /api/v1/points/transactions | 积分流水(分页) |
|
||||
| GET | /api/v1/points/products | 商品列表(分页、按类型筛选) |
|
||||
| GET | /api/v1/points/products/{id} | 商品详情 |
|
||||
| POST | /api/v1/points/exchange | 兑换商品 |
|
||||
| GET | /api/v1/points/orders | 我的兑换订单 |
|
||||
| GET | /api/v1/points/orders/{id} | 订单详情(含二维码) |
|
||||
| GET | /api/v1/offline-events | 线下活动列表 |
|
||||
| GET | /api/v1/offline-events/{id} | 活动详情 |
|
||||
| POST | /api/v1/offline-events/{id}/register | 报名活动 |
|
||||
|
||||
**医护/管理员端:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/v1/points/verify | 扫码核销 |
|
||||
| CRUD | /api/v1/admin/points/rules | 积分规则管理 |
|
||||
| CRUD | /api/v1/admin/points/products | 商品管理 |
|
||||
| GET | /api/v1/admin/points/orders | 订单管理(导出) |
|
||||
| GET | /api/v1/admin/points/statistics | 积分统计 |
|
||||
| CRUD | /api/v1/admin/offline-events | 线下活动管理 |
|
||||
| POST | /api/v1/admin/offline-events/{id}/checkin | 活动扫码签到 |
|
||||
|
||||
### 3.12 PC 管理后台新增页面
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| 积分规则管理 | 增删改规则、启用/禁用、调整积分值和上限 |
|
||||
| 商品管理 | 增删改兑换品、设置库存、上传图片、设置积分价格 |
|
||||
| 订单管理 | 查看兑换记录、手动核销、导出 |
|
||||
| 线下活动管理 | 创建活动、设置积分奖励、查看报名/签到名单 |
|
||||
| 积分统计 | 总发放/总消耗/活跃用户排行、积分流水查询 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 血透专科数据模型
|
||||
|
||||
### 4.1 设计决策
|
||||
|
||||
- **独立实体表**:不复用现有 health_data 键值对结构,每类数据一张独立表
|
||||
- **医护+患者协同上报**:透析记录由医护填写,化验报告由患者上传/医护审阅,日常监测由患者填写
|
||||
|
||||
### 4.2 权限矩阵
|
||||
|
||||
| 数据 | 患者端 | 医护端 | PC 管理后台 |
|
||||
|------|--------|--------|------------|
|
||||
| 透析记录 | 查看(只读) | 创建/编辑 | 查看/导出/统计 |
|
||||
| 化验报告 | 上传照片/查看 | 审阅/标注/解读 | 查看/导出 |
|
||||
| 日常监测 | 创建/查看 | 查看/预警 | 查看/统计 |
|
||||
| AI 健康报告 | 查看 | 查看/编辑 | 查看/导出 |
|
||||
|
||||
### 4.3 新增实体
|
||||
|
||||
#### dialysis_record(透析记录)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| dialysis_date | Date | 透析日期 |
|
||||
| start_time | Time | 开始时间 |
|
||||
| end_time | Time | 结束时间 |
|
||||
| dry_weight | Decimal(5,1) | 干体重 (kg) |
|
||||
| pre_weight | Decimal(5,1) | 透前体重 (kg) |
|
||||
| post_weight | Decimal(5,1) | 透后体重 (kg) |
|
||||
| pre_bp_systolic | i32 | 透前收缩压 |
|
||||
| pre_bp_diastolic | i32 | 透前舒张压 |
|
||||
| post_bp_systolic | i32 | 透后收缩压 |
|
||||
| post_bp_diastolic | i32 | 透后舒张压 |
|
||||
| pre_heart_rate | i32 | 透前心率 |
|
||||
| post_heart_rate | i32 | 透后心率 |
|
||||
| ultrafiltration_volume | i32 | 超滤量 (ml) |
|
||||
| dialysis_duration | i32 | 透析时长 (min) |
|
||||
| blood_flow_rate | i32 | 血流量 (ml/min) |
|
||||
| dialysis_type | Enum | HD / HDF / HF |
|
||||
| symptoms | JSON | 不适症状数组 ["低血压","恶心","抽筋"] |
|
||||
| complication_notes | Text | 并发症备注 |
|
||||
| status | Enum | draft / completed / reviewed |
|
||||
| reviewed_by | UUID | FK → user,审阅医生 |
|
||||
| reviewed_at | DateTime | 审阅时间 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | created_at / updated_at / created_by / updated_by / deleted_at / version |
|
||||
|
||||
#### lab_report(化验报告)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| report_date | Date | 化验日期 |
|
||||
| report_type | Enum | kidney_function / blood_routine / electrolyte / liver_function / other |
|
||||
| source | Enum | manual_input / photo_upload |
|
||||
| image_urls | JSON | 化验单照片 URL 数组 |
|
||||
| items | JSON | 指标数据数组(见下方结构) |
|
||||
| doctor_notes | Text | 医生解读/批注 |
|
||||
| reviewed_by | UUID | FK → user,审阅医生 |
|
||||
| reviewed_at | DateTime | 审阅时间 |
|
||||
| status | Enum | pending / reviewed |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
**items JSON 结构:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "肌酐",
|
||||
"value": "856",
|
||||
"unit": "μmol/L",
|
||||
"reference_low": 44,
|
||||
"reference_high": 133,
|
||||
"is_abnormal": true
|
||||
},
|
||||
{
|
||||
"name": "血钾",
|
||||
"value": "6.2",
|
||||
"unit": "mmol/L",
|
||||
"reference_low": 3.5,
|
||||
"reference_high": 5.3,
|
||||
"is_abnormal": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### daily_monitoring(日常监测)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| record_date | Date | 记录日期 |
|
||||
| morning_bp_systolic | i32 | 晨起收缩压 |
|
||||
| morning_bp_diastolic | i32 | 晨起舒张压 |
|
||||
| evening_bp_systolic | i32 | 晚间收缩压 |
|
||||
| evening_bp_diastolic | i32 | 晚间舒张压 |
|
||||
| weight | Decimal(5,1) | 体重 (kg) |
|
||||
| blood_sugar | Decimal(4,1) | 血糖 (mmol/L) |
|
||||
| fluid_intake | i32 | 饮水量 (ml) |
|
||||
| urine_output | i32 | 尿量 (ml) |
|
||||
| notes | Text | 备注 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
唯一约束:(patient_id, record_date)
|
||||
|
||||
### 4.4 与现有模块的关系
|
||||
|
||||
```
|
||||
patient(已有)
|
||||
├── dialysis_record(新增) 1:N 每次透析一条
|
||||
├── lab_report(新增) 1:N 每次化验一份
|
||||
├── daily_monitoring(新增) 1:N 每天一条
|
||||
├── health_data(已有,保留) 1:N 通用指标仍可用
|
||||
├── appointment(已有) 1:N 透析预约走这里
|
||||
└── follow_up(已有) 1:N 随访任务
|
||||
```
|
||||
|
||||
### 4.5 化验报告 items 用 JSON 的原因
|
||||
|
||||
- 化验项目数量和类型因报告而异(肾功能 8 项 vs 血常规 20+ 项)
|
||||
- 不需要按单个指标做复杂查询(都是按患者+日期范围查整份报告)
|
||||
- JSON 内含 `is_abnormal` 标记,前端直接渲染异常标红
|
||||
- PostgreSQL JSONB 支持按单个指标查询趋势(未来可加物化视图展平)
|
||||
|
||||
### 4.6 数据上报协同流程
|
||||
|
||||
**透析记录**:医护在医护端小程序/PC 填写 → 患者端只读查看
|
||||
|
||||
**化验报告**:患者拍照上传照片 + 手动填写指标 → 医护审阅/标注/解读 → 患者查看异常标红+医生解读
|
||||
|
||||
**日常监测**:患者每日填写(血压/体重/血糖/饮水量/尿量)→ 触发积分获取 → 医护端查看趋势+异常预警
|
||||
|
||||
### 4.7 新增 API 端点
|
||||
|
||||
**透析记录:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/health/patients/{id}/dialysis-records | 患者透析记录列表 |
|
||||
| GET | /api/v1/health/dialysis-records/{id} | 透析记录详情 |
|
||||
| POST | /api/v1/health/dialysis-records | 创建透析记录(医护) |
|
||||
| PUT | /api/v1/health/dialysis-records/{id} | 更新透析记录(医护) |
|
||||
|
||||
**化验报告:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/health/patients/{id}/lab-reports | 患者化验报告列表 |
|
||||
| GET | /api/v1/health/lab-reports/{id} | 报告详情 |
|
||||
| POST | /api/v1/health/lab-reports | 上传化验报告(患者/医护) |
|
||||
| PUT | /api/v1/health/lab-reports/{id}/review | 医生审阅(标注+解读) |
|
||||
|
||||
**日常监测:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/health/patients/{id}/daily-monitoring | 患者日常监测列表 |
|
||||
| POST | /api/v1/health/daily-monitoring | 上报日常监测(患者) |
|
||||
| GET | /api/v1/health/daily-monitoring/trend | 趋势数据查询 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 小程序迭代设计
|
||||
|
||||
### 5.1 患者端小程序
|
||||
|
||||
#### 新 TabBar 结构
|
||||
|
||||
```
|
||||
首页 | 上报 | 咨询 | 商城 | 我的
|
||||
```
|
||||
|
||||
替换现有:~~首页 | 健康 | 预约 | 资讯 | 我的~~
|
||||
|
||||
#### 首页(改造)
|
||||
|
||||
- 公告轮播(Banner 组件)
|
||||
- 功能入口 Grid:数据上报、我的医生、在线咨询、血透预约、积分商城
|
||||
- 今日健康打卡入口(未打卡时醒目提示)
|
||||
- 健康概览卡片:血压、体重、最近透析记录
|
||||
- 今日提醒列表:透析预约、血压测量、用药提醒
|
||||
|
||||
#### 上报 Tab(改造自"健康")
|
||||
|
||||
- 打卡区:血压/体重/血糖/饮水/尿量 快捷填写入口
|
||||
- 数据类型切换:日常监测 / 透析记录(只读)/ 化验报告
|
||||
- 趋势图(ECharts):血压/体重/血糖 折线图
|
||||
- 子页面:
|
||||
- 日常监测填写表单
|
||||
- 化验报告上传(拍照 + 指标填写)
|
||||
- 透析记录详情(只读)
|
||||
- 化验报告详情(含医生批注)
|
||||
- 趋势分析详情(大图 + AI 解读)
|
||||
|
||||
#### 咨询 Tab(全新)
|
||||
|
||||
- 咨询类型切换:医生 / 客服
|
||||
- 最近对话列表(未读红点)
|
||||
- 子页面:
|
||||
- 选择医生
|
||||
- 聊天界面(图文/语音 + 上传报告)
|
||||
- 客服对话
|
||||
|
||||
#### 商城 Tab(全新)
|
||||
|
||||
- 我的积分 + 签到按钮
|
||||
- 商品分类:全部 / 实物 / 检查 / 权益
|
||||
- 商品列表(网格)
|
||||
- 线下活动入口
|
||||
- 子页面:
|
||||
- 商品详情 + 兑换
|
||||
- 兑换确认(生成二维码)
|
||||
- 我的订单(待核销/已核销/已过期)
|
||||
- 线下活动详情 + 报名
|
||||
- 积分明细
|
||||
|
||||
#### 我的 Tab(改造)
|
||||
|
||||
- 个人信息 + 积分展示 + 连续打卡天数
|
||||
- 就诊人管理(已有)
|
||||
- 健康档案
|
||||
- 我的医生
|
||||
- 我的预约(从 TabBar 降级为菜单入口)
|
||||
- 我的报告(已有)
|
||||
- 我的随访(已有)
|
||||
- 我的订单(积分商城)
|
||||
- 消息通知(新增)
|
||||
- 用药提醒(已有,需接入后端)
|
||||
- 设置(已有)
|
||||
|
||||
### 5.2 医护端小程序(V2)
|
||||
|
||||
医护端为独立小程序(独立 AppID),V2 实现。V1 阶段医护功能通过 PC 管理后台覆盖。
|
||||
|
||||
#### V2 页面结构(约 18 页)
|
||||
|
||||
TabBar:概览 | 患者 | 咨询 | 随访 | 我的
|
||||
|
||||
- **概览**:今日待回复咨询数、异常预警列表、今日透析患者数、随访任务数
|
||||
- **患者**:患者列表(按透析/高危/标签筛选)、患者详情(档案+透析记录+化验+趋势图+标签)、填写透析记录、报告解读
|
||||
- **咨询**:未读消息列表、图文/语音回复、发送科普文章
|
||||
- **随访**:随访任务列表、填写记录、台账导出
|
||||
- **我的**:医生信息、我的患者、排班日历、科普文章管理
|
||||
|
||||
### 5.3 页面工作量统计
|
||||
|
||||
| 端 | 改造页 | 新增页 | 总计 |
|
||||
|----|--------|--------|------|
|
||||
| 患者端 | 5(首页/健康/我的/预约/资讯改造) | ~15(咨询3+商城6+上报子页4+通知2) | ~20 页 |
|
||||
| 医护端(V2) | 0 | 18 | 18 页 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 在线咨询设计(V2 详情待补充)
|
||||
|
||||
### 6.1 核心需求
|
||||
|
||||
- 客服通道:订单/物流/使用问题
|
||||
- 医生通道:选择医生、发送问题、上传报告、语音/文字沟通
|
||||
- 留言功能:医生离线时留存问题
|
||||
|
||||
### 6.2 技术方向
|
||||
|
||||
- V1 可先实现基于轮询的图文消息(复用现有 Consultation 模块)
|
||||
- V2 升级为 WebSocket 实时通信 + 语音消息
|
||||
- 客服通道可对接第三方客服系统(如美洽、智齿)
|
||||
|
||||
---
|
||||
|
||||
## 7. PC 管理后台新增页面
|
||||
|
||||
### 7.1 健康数据中心(新增)
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| 透析数据统计 | 透析次数趋势、干体重变化、超滤量统计 |
|
||||
| 异常指标排行 | 血钾/血磷/肌酐异常患者排行 |
|
||||
| 上报率统计 | 患者数据上报活跃度、打卡率 |
|
||||
|
||||
### 7.2 商城管理(新增)
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| 积分规则管理 | 增删改积分获取规则 |
|
||||
| 商品管理 | 增删改兑换商品、库存管理 |
|
||||
| 订单管理 | 兑换记录查看、手动核销、导出 |
|
||||
| 线下活动管理 | 活动创建、报名/签到管理 |
|
||||
| 积分统计 | 发放/消耗/活跃排行 |
|
||||
|
||||
### 7.3 统计报表(新增)
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| 患者增长 | 新增患者趋势、活跃度 |
|
||||
| 咨询量 | 咨询次数/回复率/满意度 |
|
||||
| 随访完成率 | 随访任务执行统计 |
|
||||
| 商城数据 | 兑换排行、库存周转 |
|
||||
|
||||
---
|
||||
|
||||
## 8. AI 分析能力(V2)
|
||||
|
||||
### 8.1 V1 预留
|
||||
|
||||
- 趋势图已通过 ECharts 实现(现有 TrendChart 组件)
|
||||
- 异常指标通过 `is_abnormal` 标记和阈值校验实现
|
||||
- 健康报告可生成基础版(数据汇总 + 异常标注)
|
||||
|
||||
### 8.2 V2 增强
|
||||
|
||||
- LLM 集成:自然语言健康报告生成
|
||||
- AI 辅助:化验单 OCR 自动识别
|
||||
- 智能预警:基于历史数据的异常趋势预测
|
||||
|
||||
---
|
||||
|
||||
## 9. 分期建议
|
||||
|
||||
### V1.1(建议优先)
|
||||
|
||||
1. 血透专科数据模型(3 张新表 + API)
|
||||
2. 患者端小程序改造(TabBar + 首页 + 上报扩展)
|
||||
3. 积分商城后端 + 小程序前端
|
||||
4. 线下活动管理
|
||||
|
||||
### V1.2
|
||||
|
||||
1. 在线咨询(轮询版)
|
||||
2. 化验报告上传 + 审阅流程
|
||||
3. PC 管理后台新增页面
|
||||
4. 统计报表
|
||||
|
||||
### V2
|
||||
|
||||
1. 医护端小程序
|
||||
2. 在线咨询升级(WebSocket + 语音)
|
||||
3. AI 分析增强(LLM + OCR)
|
||||
4. Redis 缓存层
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 积分 FIFO 结算复杂度 | 并发消费时积分桶冲突 | 使用数据库事务 + 乐观锁(account.version) |
|
||||
| 在线咨询工作量超预期 | V1.2 延期 | V1 先做轮询图文,WebSocket 推迟到 V2 |
|
||||
| 医护端小程序工作量大 | V2 延期 | V1 阶段用 PC 后台替代医护端功能 |
|
||||
| 化验单 OCR 准确率 | 用户体验差 | V1 先做手动填写,OCR 作为 V2 AI 增量 |
|
||||
| 积分通胀 | 积分价值稀释 | 可配置每日上限 + 过期机制 + 运营调整积分价格 |
|
||||
@@ -0,0 +1,657 @@
|
||||
# 切片 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 (只读,无操作按钮) |
|
||||
@@ -0,0 +1,884 @@
|
||||
# 切片 2: AI 管理端 3 页面 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现 AI 模块的 PC 管理端 — Prompt 管理、分析历史、用量统计 3 个页面,以及对应的后端 API 补全。
|
||||
|
||||
**Architecture:** 后端 4 个 SSE 端点已可用,但 Prompt CRUD / 分析历史查询 / 用量统计端点为空壳或缺失。先补全后端 API(handler + service 方法),再实现前端 API 封装和 3 个管理页面,最后注册菜单和路由。
|
||||
|
||||
**Tech Stack:** Rust/Axum (后端) + React 19/TypeScript/Ant Design 6 (前端)
|
||||
|
||||
**设计规格:** `docs/superpowers/specs/2026-04-25-feature-completion-design.md` §3
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: 后端 API 补全
|
||||
|
||||
### Task 1: PromptService — 补全 CRUD 方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-ai/src/service/prompt.rs`
|
||||
|
||||
**现状:** 仅有 `get_active_prompt` + `create_prompt`。需新增 `list_prompts`、`update_prompt`、`activate_prompt`、`rollback_prompt`。
|
||||
|
||||
- [ ] **Step 1: 添加 list_prompts 方法**
|
||||
|
||||
在 `PromptService` impl 中追加:
|
||||
|
||||
```rust
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set};
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
pub async fn list_prompts(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
category: Option<String>,
|
||||
pagination: Pagination,
|
||||
) -> AiResult<(Vec<ai_prompt::Model>, u64)> {
|
||||
let mut query = ai_prompt::Entity::find()
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(cat) = category {
|
||||
query = query.filter(ai_prompt::Column::Category.eq(cat));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let items = query
|
||||
.order_by_desc(ai_prompt::Column::UpdatedAt)
|
||||
.offset(pagination.offset())
|
||||
.limit(pagination.limit())
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
Ok((items, total))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 update_prompt 方法**
|
||||
|
||||
```rust
|
||||
pub async fn update_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
system_prompt: Option<String>,
|
||||
user_prompt_template: Option<String>,
|
||||
model_config: Option<serde_json::Value>,
|
||||
description: Option<String>,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
|
||||
|
||||
if entity.tenant_id != tenant_id {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
// 创建新版本
|
||||
let new_id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
let active = ai_prompt::ActiveModel {
|
||||
id: Set(new_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(entity.name.clone()),
|
||||
description: Set(description.unwrap_or(entity.description.clone())),
|
||||
system_prompt: Set(system_prompt.unwrap_or(entity.system_prompt.clone())),
|
||||
user_prompt_template: Set(user_prompt_template.unwrap_or(entity.user_prompt_template.clone())),
|
||||
variables_schema: Set(entity.variables_schema.clone()),
|
||||
model_config: Set(model_config.unwrap_or(entity.model_config.clone())),
|
||||
version: Set(entity.version + 1),
|
||||
is_active: Set(entity.is_active),
|
||||
category: Set(entity.category.clone()),
|
||||
tags: Set(entity.tags.clone()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Some(user_id)),
|
||||
updated_by: Set(Some(user_id)),
|
||||
deleted_at: Set(None),
|
||||
version_lock: Set(1),
|
||||
};
|
||||
Ok(active.insert(&self.db).await?)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 添加 activate_prompt 方法**
|
||||
|
||||
```rust
|
||||
pub async fn activate_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
|
||||
|
||||
if entity.tenant_id != tenant_id {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
// 停用同 name + category 的其他版本
|
||||
let siblings = ai_prompt::Entity::find()
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::Name.eq(&entity.name))
|
||||
.filter(ai_prompt::Column::Category.eq(&entity.category))
|
||||
.filter(ai_prompt::Column::IsActive.eq(true))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null())
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
for sibling in siblings {
|
||||
let mut active: ai_prompt::ActiveModel = sibling.into();
|
||||
active.is_active = Set(false);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.update(&self.db).await?;
|
||||
}
|
||||
|
||||
// 激活目标
|
||||
let mut active: ai_prompt::ActiveModel = entity.into();
|
||||
active.is_active = Set(true);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
Ok(active.update(&self.db).await?)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 添加 rollback_prompt(激活指定旧版本)**
|
||||
|
||||
```rust
|
||||
pub async fn rollback_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
// 回滚 = 激活指定版本
|
||||
self.activate_prompt(id, tenant_id).await
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 编译验证**
|
||||
|
||||
Run: `cargo check -p erp-ai`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-ai/src/service/prompt.rs
|
||||
git commit -m "feat(ai): PromptService 补全 list/update/activate/rollback 方法"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: AnalysisService — 补全查询方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-ai/src/service/analysis.rs`
|
||||
|
||||
**现状:** 有 `stream_analyze`、`complete_analysis`、`fail_analysis`、`find_cached`。需新增 `list_analysis`、`get_analysis`。
|
||||
|
||||
- [ ] **Step 1: 添加 list_analysis 方法**
|
||||
|
||||
在 `AnalysisService` impl 中追加:
|
||||
|
||||
```rust
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
pub async fn list_analysis(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Option<Uuid>,
|
||||
analysis_type: Option<String>,
|
||||
pagination: Pagination,
|
||||
) -> AiResult<(Vec<ai_analysis::Model>, u64)> {
|
||||
let mut query = ai_analysis::Entity::find()
|
||||
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_analysis::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(pid) = patient_id {
|
||||
query = query.filter(ai_analysis::Column::PatientId.eq(pid));
|
||||
}
|
||||
if let Some(at) = analysis_type {
|
||||
query = query.filter(ai_analysis::Column::AnalysisType.eq(at));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let items = query
|
||||
.order_by_desc(ai_analysis::Column::CreatedAt)
|
||||
.offset(pagination.offset())
|
||||
.limit(pagination.limit())
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
Ok((items, total))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 get_analysis 方法**
|
||||
|
||||
```rust
|
||||
pub async fn get_analysis(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_analysis::Model> {
|
||||
ai_analysis::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| AiError::AnalysisNotFound(id.to_string()))
|
||||
}
|
||||
```
|
||||
|
||||
注意:上面 filter 需改写为 match 式:
|
||||
|
||||
```rust
|
||||
pub async fn get_analysis(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_analysis::Model> {
|
||||
let model = ai_analysis::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::AnalysisNotFound(id.to_string()))?;
|
||||
if model.tenant_id != tenant_id {
|
||||
return Err(AiError::AnalysisNotFound(id.to_string()));
|
||||
}
|
||||
Ok(model)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译验证**
|
||||
|
||||
Run: `cargo check -p erp-ai`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-ai/src/service/analysis.rs
|
||||
git commit -m "feat(ai): AnalysisService 补全 list/get 查询方法"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: UsageService — 补全聚合查询方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-ai/src/service/usage.rs`
|
||||
|
||||
**现状:** 仅有 `log_usage`。需新增 `get_overview`、`get_trend`、`get_by_type`。
|
||||
|
||||
**注意:** `ai_usage_logs` 表无 `created_by` 字段,用户排行从 `ai_analysis_results.created_by` 聚合。
|
||||
|
||||
- [ ] **Step 1: 添加 get_overview 方法**
|
||||
|
||||
```rust
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, FromQueryResult, QuerySelect, Func};
|
||||
use crate::entity::ai_analysis;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
pub struct UsageOverview {
|
||||
pub total_count: i64,
|
||||
pub total_input_tokens: i64,
|
||||
pub total_output_tokens: i64,
|
||||
}
|
||||
|
||||
pub async fn get_overview(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<UsageOverview> {
|
||||
let result = ai_analysis::Entity::find()
|
||||
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_analysis::Column::Status.eq("completed"))
|
||||
.filter(ai_analysis::Column::DeletedAt.is_null())
|
||||
.select_only()
|
||||
.column_as(ai_analysis::Column::Id.count(), "total_count")
|
||||
.into_model::<UsageOverview>()
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.unwrap_or(UsageOverview {
|
||||
total_count: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
});
|
||||
Ok(result)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 get_by_type 方法**
|
||||
|
||||
```rust
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
pub struct TypeCount {
|
||||
pub analysis_type: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
pub async fn get_by_type(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<Vec<TypeCount>> {
|
||||
let result = ai_analysis::Entity::find()
|
||||
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_analysis::Column::Status.eq("completed"))
|
||||
.filter(ai_analysis::Column::DeletedAt.is_null())
|
||||
.select_only()
|
||||
.column(ai_analysis::Column::AnalysisType)
|
||||
.column_as(ai_analysis::Column::Id.count(), "count")
|
||||
.group_by(ai_analysis::Column::AnalysisType)
|
||||
.into_model::<TypeCount>()
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译验证**
|
||||
|
||||
Run: `cargo check -p erp-ai`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-ai/src/service/usage.rs
|
||||
git commit -m "feat(ai): UsageService 补全 get_overview/get_by_type 聚合方法"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Handler — 补全路由端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-ai/src/handler/mod.rs`
|
||||
- Modify: `crates/erp-ai/src/module.rs` (路由注册)
|
||||
|
||||
**现状:**
|
||||
- 4 个 SSE 端点:可用
|
||||
- `list_analysis` / `get_analysis`:空壳(返回 `ApiResponse::ok(())`)
|
||||
- Prompt CRUD、用量统计:完全缺失
|
||||
|
||||
- [ ] **Step 1: 实现 list_analysis 真实查询**
|
||||
|
||||
替换 `handler/mod.rs` 中的 `list_analysis` 函数(第 272-283 行):
|
||||
|
||||
```rust
|
||||
pub async fn list_analysis<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ListAnalysisQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.list")?;
|
||||
let pagination = erp_core::types::Pagination::new(
|
||||
params.page.unwrap_or(1),
|
||||
params.page_size.unwrap_or(20),
|
||||
);
|
||||
let (items, total) = state.analysis.list_analysis(
|
||||
ctx.tenant_id,
|
||||
params.patient_id,
|
||||
params.analysis_type,
|
||||
pagination,
|
||||
).await?;
|
||||
let data = serde_json::json!({
|
||||
"data": items,
|
||||
"total": total,
|
||||
"page": pagination.page,
|
||||
"page_size": pagination.page_size,
|
||||
});
|
||||
Ok(Json(ApiResponse::ok(data)))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 get_analysis 真实查询**
|
||||
|
||||
替换 `get_analysis` 函数(第 285-296 行):
|
||||
|
||||
```rust
|
||||
pub async fn get_analysis<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<ai_analysis::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.list")?;
|
||||
let analysis = state.analysis.get_analysis(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(analysis)))
|
||||
}
|
||||
```
|
||||
|
||||
需在文件顶部添加 `use crate::entity::ai_analysis;`。
|
||||
|
||||
- [ ] **Step 3: 新增 Prompt CRUD handler 函数**
|
||||
|
||||
在 handler/mod.rs 中添加以下函数(分析历史之后):
|
||||
|
||||
```rust
|
||||
// === Prompt 管理 ===
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListPromptsQuery {
|
||||
pub category: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn list_prompts<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ListPromptsQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.list")?;
|
||||
let pagination = erp_core::types::Pagination::new(
|
||||
params.page.unwrap_or(1),
|
||||
params.page_size.unwrap_or(20),
|
||||
);
|
||||
let (items, total) = state.prompt.list_prompts(
|
||||
ctx.tenant_id, params.category, pagination,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items, "total": total,
|
||||
"page": pagination.page, "page_size": pagination.page_size,
|
||||
}))))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatePromptBody {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub system_prompt: String,
|
||||
pub user_prompt_template: String,
|
||||
pub model_config: serde_json::Value,
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
pub async fn create_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<CreatePromptBody>,
|
||||
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state.prompt.create_prompt(
|
||||
ctx.tenant_id, ctx.user_id,
|
||||
body.name, body.system_prompt, body.user_prompt_template,
|
||||
body.model_config, body.category,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
pub async fn activate_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state.prompt.activate_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
pub async fn rollback_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state.prompt.rollback_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
```
|
||||
|
||||
需在文件顶部添加 `use crate::entity::ai_prompt;`。
|
||||
|
||||
- [ ] **Step 4: 新增用量统计 handler 函数**
|
||||
|
||||
```rust
|
||||
// === 用量统计 ===
|
||||
|
||||
pub async fn usage_overview<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.usage.list")?;
|
||||
let overview = state.usage.get_overview(ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"total_count": overview.total_count,
|
||||
}))))
|
||||
}
|
||||
|
||||
pub async fn usage_by_type<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.usage.list")?;
|
||||
let types = state.usage.get_by_type(ctx.tenant_id).await?;
|
||||
let result: Vec<serde_json::Value> = types.into_iter().map(|t| {
|
||||
serde_json::json!({
|
||||
"analysis_type": t.analysis_type,
|
||||
"count": t.count,
|
||||
})
|
||||
}).collect();
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在 module.rs 注册新路由**
|
||||
|
||||
修改 `AiModule::protected_routes`,在现有路由后追加:
|
||||
|
||||
```rust
|
||||
.route("/ai/prompts", axum::routing::get(crate::handler::list_prompts))
|
||||
.route("/ai/prompts", axum::routing::post(crate::handler::create_prompt))
|
||||
.route("/ai/prompts/{id}/activate", axum::routing::post(crate::handler::activate_prompt))
|
||||
.route("/ai/prompts/{id}/rollback", axum::routing::post(crate::handler::rollback_prompt))
|
||||
.route("/ai/usage/overview", axum::routing::get(crate::handler::usage_overview))
|
||||
.route("/ai/usage/by-type", axum::routing::get(crate::handler::usage_by_type))
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 编译验证**
|
||||
|
||||
Run: `cargo check -p erp-ai && cargo check -p erp-server`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-ai/src/handler/mod.rs crates/erp-ai/src/module.rs
|
||||
git commit -m "feat(ai): 补全 Prompt CRUD + 分析历史 + 用量统计 handler 和路由"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 前端 API 封装
|
||||
|
||||
### Task 5: 创建 AI API service 文件
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/api/ai/prompts.ts`
|
||||
- Create: `apps/web/src/api/ai/analysis.ts`
|
||||
- Create: `apps/web/src/api/ai/usage.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 prompts.ts**
|
||||
|
||||
`apps/web/src/api/ai/prompts.ts`:
|
||||
|
||||
```typescript
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
export interface PromptItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
model_config: Record<string, unknown>;
|
||||
version: number;
|
||||
is_active: boolean;
|
||||
category: string;
|
||||
tags: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreatePromptReq {
|
||||
name: string;
|
||||
description?: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
model_config: Record<string, unknown>;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export const promptApi = {
|
||||
list: async (params?: { category?: string; page?: number; page_size?: number }) => {
|
||||
const resp = await client.get('/ai/prompts', { params });
|
||||
return resp.data.data as PaginatedResponse<PromptItem>;
|
||||
},
|
||||
create: async (data: CreatePromptReq) => {
|
||||
const resp = await client.post('/ai/prompts', data);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
activate: async (id: string) => {
|
||||
const resp = await client.post(`/ai/prompts/${id}/activate`);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
rollback: async (id: string) => {
|
||||
const resp = await client.post(`/ai/prompts/${id}/rollback`);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 analysis.ts**
|
||||
|
||||
`apps/web/src/api/ai/analysis.ts`:
|
||||
|
||||
```typescript
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
export interface AnalysisItem {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
analysis_type: string;
|
||||
source_ref: string;
|
||||
model_used: string;
|
||||
status: string;
|
||||
result_content: string | null;
|
||||
result_metadata: Record<string, unknown> | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const analysisApi = {
|
||||
list: async (params?: { patient_id?: string; analysis_type?: string; page?: number; page_size?: number }) => {
|
||||
const resp = await client.get('/ai/analysis/history', { params });
|
||||
return resp.data.data as PaginatedResponse<AnalysisItem>;
|
||||
},
|
||||
get: async (id: string) => {
|
||||
const resp = await client.get(`/ai/analysis/${id}`);
|
||||
return resp.data.data as AnalysisItem;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 创建 usage.ts**
|
||||
|
||||
`apps/web/src/api/ai/usage.ts`:
|
||||
|
||||
```typescript
|
||||
import client from '../client';
|
||||
|
||||
export interface UsageOverview {
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface TypeDistribution {
|
||||
analysis_type: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const usageApi = {
|
||||
overview: async () => {
|
||||
const resp = await client.get('/ai/usage/overview');
|
||||
return resp.data.data as UsageOverview;
|
||||
},
|
||||
byType: async () => {
|
||||
const resp = await client.get('/ai/usage/by-type');
|
||||
return resp.data.data as TypeDistribution[];
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/api/ai/
|
||||
git commit -m "feat(web): AI API 前端封装 — prompts/analysis/usage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 前端管理页面
|
||||
|
||||
### Task 6: AI Prompt 管理页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/health/AiPromptList.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 AiPromptList 页面**
|
||||
|
||||
使用 Ant Design Table + Modal 模式(参考 PatientList.tsx 结构):
|
||||
|
||||
核心功能:
|
||||
- 表格列:名称 / 类别 / 版本 / 状态(active/inactive) / 更新时间
|
||||
- 新建 Prompt 按钮 → Modal 表单
|
||||
- 操作列:激活 / 回滚
|
||||
- AuthButton 权限控制(`ai.prompt.manage`)
|
||||
|
||||
页面大致结构(骨架,实现时根据 Ant Design 6 API 细化):
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Table, Button, Space, Modal, Form, Input, Select, Tag, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { promptApi, type PromptItem, type CreatePromptReq } from '../../api/ai/prompts';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'lab_report_interpretation', label: '化验单解读' },
|
||||
{ value: 'health_trend_analysis', label: '趋势分析' },
|
||||
{ value: 'personalized_checkup_plan', label: '体检方案' },
|
||||
{ value: 'report_summary_generation', label: '报告摘要' },
|
||||
];
|
||||
|
||||
export default function AiPromptList() {
|
||||
// ... useState, fetchPrompts, columns 定义
|
||||
// 新建/激活/回滚按钮用 <AuthButton code="ai.prompt.manage"> 包裹
|
||||
// 表格渲染 + Modal 表单
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/AiPromptList.tsx
|
||||
git commit -m "feat(web): AI Prompt 管理页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: AI 分析历史页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/health/AiAnalysisList.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 AiAnalysisList 页面**
|
||||
|
||||
核心功能:
|
||||
- 表格列:分析类型 / 患者 ID / 状态(streaming/completed/failed) / 模型 / 创建时间
|
||||
- 状态 Tag:completed=绿色, failed=红色, streaming=蓝色
|
||||
- 详情查看:点击行展开,显示 result_content(Markdown 渲染)
|
||||
- 筛选:分析类型下拉 + 时间范围
|
||||
- AuthButton 权限控制(`ai.analysis.list`)
|
||||
|
||||
- [ ] **Step 2: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/AiAnalysisList.tsx
|
||||
git commit -m "feat(web): AI 分析历史页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: AI 用量统计页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/health/AiUsageDashboard.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 AiUsageDashboard 页面**
|
||||
|
||||
核心功能:
|
||||
- 顶部 StatCard:总分析次数
|
||||
- 饼图:分析类型分布(使用 Ant Design Charts Pie)
|
||||
- AuthButton 权限控制(`ai.usage.list`)
|
||||
|
||||
- [ ] **Step 2: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/AiUsageDashboard.tsx
|
||||
git commit -m "feat(web): AI 用量统计页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 菜单注册 + 路由配置
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/layouts/MainLayout.tsx`
|
||||
- Modify: `apps/web/src/App.tsx`
|
||||
|
||||
- [ ] **Step 1: 在 MainLayout 添加菜单项**
|
||||
|
||||
在 `healthMenuItems` 数组中追加 3 项:
|
||||
|
||||
```typescript
|
||||
{ key: '/health/ai-prompts', label: 'AI Prompt 管理', icon: ... },
|
||||
{ key: '/health/ai-analysis', label: 'AI 分析历史', icon: ... },
|
||||
{ key: '/health/ai-usage', label: 'AI 用量统计', icon: ... },
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 App.tsx 添加路由**
|
||||
|
||||
在健康模块路由区域追加:
|
||||
|
||||
```tsx
|
||||
<Route path="/health/ai-prompts" element={<AiPromptList />} />
|
||||
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
|
||||
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/layouts/MainLayout.tsx apps/web/src/App.tsx
|
||||
git commit -m "feat(web): AI 管理端菜单注册 + 路由配置"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 集成验证
|
||||
|
||||
- [ ] **Step 1: 后端编译 + 启动**
|
||||
|
||||
Run: `cargo check --workspace && cd crates/erp-server && cargo run`
|
||||
|
||||
验证:
|
||||
- `/api/v1/ai/prompts` 返回空列表(200)
|
||||
- `/api/v1/ai/analysis/history` 返回空列表(200)
|
||||
- `/api/v1/ai/usage/overview` 返回 0 计数(200)
|
||||
|
||||
- [ ] **Step 2: 前端编译 + 启动**
|
||||
|
||||
Run: `cd apps/web && pnpm build && pnpm dev`
|
||||
|
||||
验证:
|
||||
- 3 个新页面在菜单中可见
|
||||
- 页面正常加载,无白屏/报错
|
||||
- Prompt 列表为空时显示空状态
|
||||
- 分析历史列表为空时显示空状态
|
||||
|
||||
- [ ] **Step 3: 生产构建**
|
||||
|
||||
Run: `cd apps/web && pnpm build`
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **Step 4: 推送**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
@@ -0,0 +1,539 @@
|
||||
# 切片 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
|
||||
```
|
||||
@@ -0,0 +1,106 @@
|
||||
# 事件驱动架构增强实施计划
|
||||
|
||||
> 设计规格: `docs/superpowers/specs/2026-04-26-event-driven-architecture-design.md`
|
||||
> 日期: 2026-04-26 | 状态: draft | 总周期: 2 周
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 高优先级事件补发(Week 1)
|
||||
|
||||
### Task 1: dialysis_service 添加 dialysis_record.created/reviewed 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/dialysis_service.rs`
|
||||
|
||||
**步骤**: `create_dialysis_record()` 成功后发布 `dialysis_record.created`(data: patient_id, dialysis_type, status, dialysis_date, duration, ultrafiltration_volume)。审核状态变更时发布 `dialysis_record.reviewed`(data: patient_id, reviewer_id, complication_notes)。payload 遵循统一信封(schema_version: "v1")。发布失败仅 warn 不阻断业务。
|
||||
|
||||
**验收**: 创建/审核后 domain_events 表出现对应事件;`cargo test` 通过。
|
||||
|
||||
### Task 2: diagnosis_service 添加 diagnosis.created/updated 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/diagnosis_service.rs`
|
||||
|
||||
**步骤**: `create_diagnosis()` 后发布 `diagnosis.created`(data: patient_id, icd_code, diagnosis_name, severity)。`update_diagnosis()` 后发布 `diagnosis.updated`,计算变更 diff(changed_fields[], old_values{}, new_values{})。
|
||||
|
||||
**验收**: diagnosis.updated 事件 data 含 changed_fields 差异;`cargo test` 通过。
|
||||
|
||||
### Task 3: consent_service 添加 consent.granted/revoked 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/consent_service.rs`
|
||||
|
||||
**步骤**: 签署时发布 `consent.granted`(data: patient_id, consent_type, consent_scope, granted_by, expires_at)。撤销时发布 `consent.revoked`(data: patient_id, consent_type, revoked_by, reason)。
|
||||
|
||||
**验收**: 签署/撤销后 domain_events 表出现事件;`cargo test` 通过。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 中低优先级事件 + Outbox 优化(Week 2)
|
||||
|
||||
### Task 4: points_service 添加 points.earned/exchanged 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/points_service.rs`
|
||||
|
||||
**步骤**: earn 成功后发布 `points.earned`(data: patient_id, points, source_type, balance_after)。exchange 成功后发布 `points.exchanged`(data: patient_id, points, product_name, order_id, balance_after)。确保在事务提交后发布。
|
||||
|
||||
**验收**: 积分变动后 domain_events 出现事件;balance_after 正确反映余额。
|
||||
|
||||
### Task 5: article_service 添加 article.published/rejected 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/article_service.rs`
|
||||
|
||||
**步骤**: 审核通过发布 `article.published`(data: title, author_id, category_id, tags[])。审核驳回发布 `article.rejected`(data: title, reviewer_id, reason)。
|
||||
|
||||
**验收**: 审核操作后 domain_events 出现事件;`cargo test` 通过。
|
||||
|
||||
### Task 6: daily_monitoring_service 添加 daily_monitoring.created 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/daily_monitoring_service.rs`
|
||||
|
||||
**步骤**: 记录创建后发布 `daily_monitoring.created`(data: patient_id, monitoring_date, monitoring_type, values{})。
|
||||
|
||||
**验收**: 创建记录后 domain_events 出现事件;`cargo test` 通过。
|
||||
|
||||
### Task 7: Outbox relay 从轮询改为 LISTEN/NOTIFY
|
||||
|
||||
**涉及文件**: `crates/erp-server/src/outbox.rs`, `crates/erp-core/src/events.rs`
|
||||
|
||||
**步骤**: `EventBus::publish()` 持久化后执行 `NOTIFY outbox_channel, '<event_id>'`。outbox relay 用 `sqlx::PgListener` 监听 + `tokio::select!`(LISTEN 触发 + 30s 兜底轮询)。保留 `process_pending_events()` 不变,仅改变触发方式。PgListener 添加断线自动重连。
|
||||
|
||||
**验收**: 事件延迟 < 100ms;DB 轮询频率从 5s 降为 30s 兜底;`cargo test --workspace` 通过。
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 事件 schema 版本化 + 清理(Week 2)
|
||||
|
||||
### Task 8: 事件 payload 添加 schema_version 字段
|
||||
|
||||
**涉及文件**: `crates/erp-core/src/events.rs`, `crates/erp-health/src/service/` 下所有发布事件的 service
|
||||
|
||||
**步骤**: 在 erp-core 创建 `build_event_payload()` 辅助函数,自动填充 schema_version/timestamp/metadata。逐个 service(14 个模块)替换手动构建为调用辅助函数,统一信封格式。
|
||||
|
||||
**验收**: 所有事件 payload 含 schema_version 字段;`cargo test --workspace` 通过。
|
||||
|
||||
### Task 9: Outbox 表分区或定期清理策略
|
||||
|
||||
**涉及文件**: `migration/src/m000075_domain_events_cleanup.rs`(新增), `erp-server/src/tasks/events_cleanup.rs`(新增)
|
||||
|
||||
**步骤**: 迁移创建 `domain_events_archive` 表,添加 `cleanup_old_published_events()` SQL 函数(>90 天 published 事件迁移到归档表)。后台任务每日执行清理。归档表只读防篡改。
|
||||
|
||||
**验收**: 清理任务正确迁移 >90 天事件;`cargo test` 通过。
|
||||
|
||||
### Task 10: 消费者幂等性(dedup key 检查)
|
||||
|
||||
**涉及文件**: `migration/src/m000076_processed_events.rs`(新增), `crates/erp-core/src/events.rs`
|
||||
|
||||
**步骤**: 迁移创建 `processed_events` 表(event_id + consumer_id 联合主键 + processed_at)。erp-core 添加 `is_processed()` / `mark_processed()` 辅助函数。消费者模式:收到事件 -> 查已处理 -> 跳过或执行 -> 插入记录。添加 7 天 TTL 清理任务。
|
||||
|
||||
**验收**: 重复消费同一事件时第二次被跳过;`cargo test --workspace` 通过。
|
||||
|
||||
---
|
||||
|
||||
## 执行原则
|
||||
|
||||
1. **每 Task 完成后立即提交** — 不积压
|
||||
2. **Phase 1 优先** — P0 事件(透析/诊断)是核心医疗流程
|
||||
3. **事件发布不阻断业务** — publish 失败仅 warn,Outbox relay 兜底
|
||||
4. **统一信封格式** — 使用 `build_event_payload` 保证一致性
|
||||
5. **LISTEN/NOTIFY 保留兜底轮询** — 30s 轮询防 NOTIFY 丢失
|
||||
@@ -0,0 +1,290 @@
|
||||
# 前端工程化改进实施计划
|
||||
|
||||
> 设计规格: `docs/superpowers/specs/2026-04-26-frontend-engineering-design.md`
|
||||
> 日期: 2026-04-26 | 状态: draft | 总周期: 7 天
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 重复模式统一(Day 1-2)
|
||||
|
||||
### Task 1: 增强 useApiRequest hook,统一错误处理
|
||||
|
||||
**目标**: 补齐 loading 状态,消除组件内联 `catch (err) { message.error(...) }` 模式。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/hooks/useApiRequest.ts`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 在 `useApiRequest` 返回值中新增 `loading: boolean` 状态:
|
||||
```typescript
|
||||
interface UseApiRequestReturn {
|
||||
execute: <T>(fn: () => Promise<T>, successMsg?: string) => Promise<T | null>;
|
||||
loading: boolean;
|
||||
}
|
||||
```
|
||||
2. `execute` 内部在调用前 `setLoading(true)`,finally 中 `setLoading(false)`
|
||||
3. 保持现有调用点无需修改 — 返回值是对象解构,新增字段不影响旧代码
|
||||
4. 选取 3 个健康模块页面(PatientList、AppointmentList、FollowUpTaskList)迁移为使用 `execute` + `loading`
|
||||
|
||||
**验收标准**:
|
||||
- `pnpm build` 通过
|
||||
- 3 个迁移页面的 catch 块不再有内联 `message.error`,统一走 `handleApiError`
|
||||
- loading 状态正确绑定到页面按钮/Spin 组件
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 增强 usePaginatedData hook,健康模块页面迁移
|
||||
|
||||
**目标**: 支持泛型筛选参数,迁移 6 个健康列表页使用统一 hook。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/hooks/usePaginatedData.ts`
|
||||
- 修改: `apps/web/src/pages/health/PatientList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/OfflineEventList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/PointsProductList.tsx`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 增强 hook 签名为泛型筛选:
|
||||
```typescript
|
||||
function usePaginatedData<T, F = string>(
|
||||
fetchFn: (page: number, pageSize: number, filters: F) => Promise<{ data: T[]; total: number }>,
|
||||
options?: { pageSize?: number; defaultFilters: F; autoFetch?: boolean }
|
||||
): { data, total, page, loading, filters, setFilters, refresh }
|
||||
```
|
||||
2. 函数重载保持旧 `(fetchFn, pageSize?)` 签名兼容
|
||||
3. 新增 `filters` / `setFilters` 状态,`fetchFn` 调用时传入当前 filters
|
||||
4. 迁移 PatientList(按 status/name/gender 筛选)和 OfflineEventList(按 status/dateRange 筛选)
|
||||
|
||||
**验收标准**:
|
||||
- 旧调用点(不传 filters)行为不变
|
||||
- PatientList 和 OfflineEventList 筛选功能正常,代码行数各减少 15-25 行
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 移除 nameCache,统一用 useHealthStore
|
||||
|
||||
**目标**: 消除 AppointmentList 和 PointsOrderList 自建的 `useState<Record<string, string>>` nameCache。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/stores/health.ts`
|
||||
- 修改: `apps/web/src/pages/health/AppointmentList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/PointsOrderList.tsx`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 在 `useHealthStore` 新增批量解析方法:
|
||||
- `batchResolvePatientNames(ids: string[]): Promise<Record<string, string>>`
|
||||
- `batchResolveDoctorNames(ids: string[]): Promise<Record<string, string>>`
|
||||
2. 内部实现:去重 → 过滤已缓存 → 并发加载(限制 5 并发)→ 写入缓存并返回
|
||||
3. 在 AppointmentList 中移除 nameCache state,改用 store 方法
|
||||
4. 在 PointsOrderList 中同样迁移
|
||||
|
||||
**验收标准**:
|
||||
- 两个页面无 `useState<Record<string, string>>` nameCache 代码
|
||||
- 患者姓名/医生姓名在列表中正确显示
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 大组件拆分(Day 3-5)
|
||||
|
||||
### Task 4: PluginCRUDPage 拆分为 CRUDTable/CRUDForm/DetailDrawer/ImportExport
|
||||
|
||||
**目标**: 将 872 行的 PluginCRUDPage.tsx 拆为容器 + 展示组件。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/src/pages/plugins/components/CRUDTable.tsx` (~150 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/CRUDForm.tsx` (~180 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/DetailDrawer.tsx` (~80 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/ImportExport.tsx` (~100 行)
|
||||
- 新增: `apps/web/src/pages/plugins/hooks/usePluginData.ts` (~120 行)
|
||||
- 修改: `apps/web/src/pages/plugins/PluginCRUDPage.tsx` (缩减至 ~80 行)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 创建 `hooks/usePluginData.ts`:提取 CRUD 操作、导入导出逻辑、Drawer 可见性状态
|
||||
2. 创建 `CRUDTable.tsx`:表格列定义 + 行操作按钮,props 接收 data/onDelete/onEdit/onDetail
|
||||
3. 创建 `CRUDForm.tsx`:新增/编辑表单 + Drawer,包含校验规则
|
||||
4. 创建 `DetailDrawer.tsx`:详情展示 + 操作历史 Timeline
|
||||
5. 创建 `ImportExport.tsx`:导入面板 + 导出按钮
|
||||
6. 改写 `PluginCRUDPage.tsx` 为容器组件:调用 usePluginData hook,组装子组件
|
||||
|
||||
**验收标准**:
|
||||
- `pnpm build` 通过
|
||||
- 插件 CRUD 所有功能正常(新增、编辑、删除、详情、导入、导出)
|
||||
- PluginCRUDPage.tsx <= 100 行,无子组件超过 200 行
|
||||
|
||||
---
|
||||
|
||||
### Task 5: PluginGraphPage 抽取 useGraphCanvas hook
|
||||
|
||||
**目标**: 将 759 行的 PluginGraphPage.tsx 拆为 hook + 展示组件。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/src/pages/plugins/hooks/useGraphLayout.ts` (~100 行)
|
||||
- 新增: `apps/web/src/pages/plugins/hooks/useGraphData.ts` (~80 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/GraphCanvas.tsx` (~200 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/GraphToolbar.tsx` (~60 行)
|
||||
- 修改: `apps/web/src/pages/plugins/PluginGraphPage.tsx` (缩减至 ~60 行)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. `useGraphData.ts`:数据加载、边/节点格式转换、字段映射
|
||||
2. `useGraphLayout.ts`:Dagre/elkjs 布局算法、节点位置计算、自动布局触发
|
||||
3. `GraphCanvas.tsx`:ReactFlow 渲染、自定义节点样式、拖拽交互
|
||||
4. `GraphToolbar.tsx`:缩放控制、自动布局、布局方向切换
|
||||
5. 容器组件组装以上模块
|
||||
|
||||
**验收标准**:
|
||||
- 插件关系图页面正常渲染和交互
|
||||
- 拖拽节点、自动布局、缩放功能正常
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Organizations.tsx 抽象 TreeEntityManager
|
||||
|
||||
**目标**: 将 622 行的 Organizations.tsx 按三层模式拆分。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/src/pages/system/hooks/useOrgTree.ts` (~80 行)
|
||||
- 新增: `apps/web/src/pages/system/components/OrgTree.tsx` (~120 行)
|
||||
- 新增: `apps/web/src/pages/system/components/OrgDetail.tsx` (~150 行)
|
||||
- 新增: `apps/web/src/pages/system/components/DeptMemberList.tsx` (~100 行)
|
||||
- 修改: `apps/web/src/pages/system/Organizations.tsx` (缩减至 ~60 行)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. `useOrgTree.ts`:树数据加载、CRUD 操作、选中节点状态
|
||||
2. `OrgTree.tsx`:左侧树形选择(DirectoryTree + 搜索 + 右键菜单)
|
||||
3. `OrgDetail.tsx`:右侧组织详情/编辑表单
|
||||
4. `DeptMemberList.tsx`:部门成员列表 + 人员分配 Modal
|
||||
5. 容器组件三栏布局组装
|
||||
|
||||
**验收标准**:
|
||||
- 组织管理 CRUD 功能正常(新增/编辑/删除组织、部门、人员分配)
|
||||
- 树形选择、搜索过滤正常
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
### Task 7: StatisticsDashboard 拆分为独立卡片组件
|
||||
|
||||
**目标**: 将 580 行的 StatisticsDashboard.tsx 拆为 hook + 独立图表卡片。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/src/pages/health/hooks/useStatsData.ts` (~100 行)
|
||||
- 新增: `apps/web/src/pages/health/components/PatientTrendChart.tsx` (~80 行)
|
||||
- 新增: `apps/web/src/pages/health/components/AppointmentStats.tsx` (~80 行)
|
||||
- 新增: `apps/web/src/pages/health/components/OverviewCards.tsx` (~60 行)
|
||||
- 新增: `apps/web/src/pages/health/components/TimeRangeSelector.tsx` (~40 行)
|
||||
- 修改: `apps/web/src/pages/health/StatisticsDashboard.tsx` (缩减至 ~50 行)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. `useStatsData.ts`:五个统计 API 并行加载、loading/error 状态、时间范围变更触发刷新
|
||||
2. `PatientTrendChart.tsx`:患者趋势折线图(@ant-design/charts Line)
|
||||
3. `AppointmentStats.tsx`:预约统计饼图/柱状图
|
||||
4. `OverviewCards.tsx`:概览数字卡片组(Statistic + Card)
|
||||
5. `TimeRangeSelector.tsx`:日期范围选择 + 快捷选项(近7天/近30天/近90天)
|
||||
6. 容器组件组装,布局使用 Row + Col
|
||||
|
||||
**验收标准**:
|
||||
- 统计仪表板页面渲染正常,图表数据正确
|
||||
- 时间范围切换触发数据刷新
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Bundle 优化(Day 6-7)
|
||||
|
||||
### Task 8: vite.config.ts manualChunks 拆分重型依赖
|
||||
|
||||
**目标**: 将 @ant-design/charts、@xyflow/react、@wangeditor/editor 拆为独立 chunk,降低主 chunk 体积。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/vite.config.ts`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 在 `manualChunks` 配置中新增三条规则:
|
||||
```typescript
|
||||
if (id.includes('@ant-design/charts') || id.includes('@antv/')) return 'vendor-charts';
|
||||
if (id.includes('@xyflow/react') || id.includes('@reactflow/')) return 'vendor-flow';
|
||||
if (id.includes('@wangeditor/')) return 'vendor-editor';
|
||||
```
|
||||
2. 对应页面添加路由级 `React.lazy()`:
|
||||
- `StatisticsDashboard` → `lazy(() => import('./health/StatisticsDashboard'))`
|
||||
- `PluginGraphPage` → `lazy(() => import('./plugins/PluginGraphPage'))`
|
||||
- `ArticleEditor` → `lazy(() => import('./health/ArticleEditor'))`
|
||||
3. 将 `chunkSizeWarningLimit` 从 600 降至 500
|
||||
4. 运行 `pnpm build` 对比拆分前后各 chunk 大小
|
||||
|
||||
**验收标准**:
|
||||
- 主 chunk 体积 < 400KB(gzip 前约 600KB 以内)
|
||||
- `vendor-charts`、`vendor-flow`、`vendor-editor` 独立生成
|
||||
- `pnpm build` 无警告
|
||||
- 统计仪表板、插件关系图、文章编辑器页面功能正常(懒加载无闪烁)
|
||||
|
||||
---
|
||||
|
||||
### Task 9: columns 配置 useMemo 化
|
||||
|
||||
**目标**: 消除 PluginCRUDPage 和健康模块列表页的 columns 重复创建,减少不必要的 re-render。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/pages/plugins/components/CRUDTable.tsx`(Phase 2 Task 4 产物)
|
||||
- 修改: `apps/web/src/pages/health/PatientList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/AppointmentList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/FollowUpTaskList.tsx`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 在每个列表页中,将 `columns` 数组定义包裹在 `useMemo` 中
|
||||
2. 依赖项包含 columns 中引用的回调函数(如 onDelete、onEdit)
|
||||
3. 确保回调函数通过 `useCallback` 缓存,避免 useMemo 失效
|
||||
4. 使用 React DevTools Profiler 验证翻页/筛选时减少不必要渲染
|
||||
|
||||
**验收标准**:
|
||||
- 列表翻页时 Table 组件不因 columns 引用变化触发全量渲染
|
||||
- 所有列表页功能正常(排序、筛选、操作按钮)
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
### Task 10: API 层新代码统一为对象风格
|
||||
|
||||
**目标**: 确认新增 API 文件采用对象风格(`xxxApi.list()` 而非 `listXxx()`),修改已有文件时顺手迁移。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/api/health/` 下近期新增的 API 文件(如 `alerts.ts`、`deviceReadings.ts`)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 审计 `apps/web/src/api/` 下所有文件,标记函数风格的文件清单
|
||||
2. 近期新增的文件(alerts、deviceReadings 等)统一改为对象风格:
|
||||
```typescript
|
||||
export const alertApi = {
|
||||
list: (params) => client.get('/alerts', { params }),
|
||||
acknowledge: (id) => client.post(`/alerts/${id}/acknowledge`),
|
||||
};
|
||||
```
|
||||
3. 更新引用处的 import(页面组件中的调用方式)
|
||||
4. 旧文件不强制迁移,仅记录待迁移清单
|
||||
|
||||
**验收标准**:
|
||||
- `alerts.ts` 和 `deviceReadings.ts` 为对象风格导出
|
||||
- 对应页面功能正常
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
## 执行原则
|
||||
|
||||
1. **每 Task 完成后立即提交** — 不积压,保持可追溯
|
||||
2. **先基础设施后拆分** — Phase 1 的 hook 增强完成后再做 Phase 2 组件拆分
|
||||
3. **每步验证** — 每个 Task 完成后 `pnpm build` 验证,拆分任务额外验证页面功能
|
||||
4. **渐进迁移** — 重复模式统一采用渐进策略,不一次性全量迁移
|
||||
@@ -0,0 +1,200 @@
|
||||
# 可观测性与运维基础设施实施计划
|
||||
|
||||
> 设计规格: `docs/superpowers/specs/2026-04-26-observability-and-ops-design.md`
|
||||
> 日期: 2026-04-26 | 总周期: 7-9 天
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 健康检查 + Prometheus 指标(Day 1-2)
|
||||
|
||||
### Task 1: 深度健康检查端点
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `crates/erp-server/src/handlers/health.rs`
|
||||
|
||||
**步骤**:
|
||||
1. 拆分为两个端点:
|
||||
- `GET /health/live` — 存活探针(仅返回 `{ status: "ok" }`,不依赖任何外部服务)
|
||||
- `GET /health/ready` — 就绪探针(验证 DB ping + Redis ping + 模块状态)
|
||||
2. `/health/ready` 实现:
|
||||
```rust
|
||||
async fn health_ready(State(state): State<AppState>) -> Json<HealthResponse> {
|
||||
let db_ok = sql_query("SELECT 1").execute(&state.db).await.is_ok();
|
||||
let redis_ok = state.redis.ping().await.is_ok();
|
||||
Json(HealthResponse { status: if db_ok && redis_ok { "ok" } else { "degraded" }, db: db_ok, redis: redis_ok, ... })
|
||||
}
|
||||
```
|
||||
3. 保持旧 `GET /health` 兼容(重定向到 `/health/ready`)
|
||||
|
||||
**验收**: `/health/ready` 在 DB/Redis 正常时返回 200,任一不可达时返回 503 + 降级详情
|
||||
|
||||
### Task 2: Prometheus 指标基础
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `crates/erp-server/Cargo.toml`(添加 `metrics` + `metrics-exporter-prometheus` 依赖)
|
||||
- 新增: `crates/erp-server/src/middleware/metrics.rs`
|
||||
- 修改: `crates/erp-server/src/main.rs`(注册 metrics middleware + 路由)
|
||||
|
||||
**步骤**:
|
||||
1. 在 `Cargo.toml` 添加:
|
||||
```toml
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = "0.16"
|
||||
```
|
||||
2. 创建 `metrics.rs` Axum middleware:
|
||||
- 记录每个请求的 `http_request_duration_seconds`(直方图,按 method/path/status 标签)
|
||||
- 记录 `http_requests_total`(计数器)
|
||||
3. 在 `main.rs` 启动 Prometheus exporter(`/metrics` 端点,端口 9090)
|
||||
4. 在 AppState 中注册 metrics recorder
|
||||
|
||||
**验收**: `curl localhost:9090/metrics` 返回 Prometheus 格式指标,包含请求延迟直方图
|
||||
|
||||
### Task 3: DB 连接池 + EventBus 积压指标
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `crates/erp-server/src/main.rs`
|
||||
- 修改: `crates/erp-core/src/events.rs`
|
||||
|
||||
**步骤**:
|
||||
1. DB 连接池指标:每 30 秒采样 `db_pool_connections_active` / `db_pool_connections_idle`
|
||||
2. EventBus 积压指标:在 `publish()` 中递增 `eventbus_pending_total`,在 relay 处理后递减
|
||||
3. 在 `/metrics` 端点暴露
|
||||
|
||||
**验收**: `/metrics` 包含 DB 连接池使用率和事件积压计数
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: OpenTelemetry + 生产 Docker(Day 3-5)
|
||||
|
||||
### Task 4: OpenTelemetry 条件集成
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `crates/erp-server/Cargo.toml`(添加 `opentelemetry` + `tracing-opentelemetry` + `opentelemetry-otlp`,optional feature)
|
||||
- 新增: `crates/erp-server/src/telemetry.rs`
|
||||
- 修改: `crates/erp-server/src/main.rs`
|
||||
|
||||
**步骤**:
|
||||
1. 添加 optional 依赖:
|
||||
```toml
|
||||
[features]
|
||||
tracing = ["opentelemetry", "tracing-opentelemetry", "opentelemetry-otlp"]
|
||||
```
|
||||
2. 创建 `telemetry.rs`:条件初始化 OpenTelemetry tracer(环境变量 `ERP__TELEMETRY__ENABLED=true` 时启用)
|
||||
3. 配置 OTLP exporter(默认 `http://localhost:4317`,可通过环境变量覆盖)
|
||||
4. 在 `main.rs` 的 tracing subscriber 中条件注册 OpenTelemetry layer
|
||||
5. 在 SeaORM 的 `DatabaseConnection` 包装中添加 span(记录查询耗时)
|
||||
|
||||
**验收**: 启用后 Jaeger/Tempo 可看到请求 → SQL 查询 → 事件发布的完整链路;不启用时零开销
|
||||
|
||||
### Task 5: 生产 Docker 多阶段构建
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `Dockerfile`(项目根目录)
|
||||
- 新增: `docker/docker-compose.production.yml`
|
||||
|
||||
**步骤**:
|
||||
1. 多阶段 Dockerfile:
|
||||
```dockerfile
|
||||
# Stage 1: Build
|
||||
FROM rust:1.82-bookworm AS builder
|
||||
COPY . /app
|
||||
RUN cargo build --release -p erp-server
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM debian:bookworm-slim
|
||||
COPY --from=builder /app/target/release/erp-server /usr/local/bin/
|
||||
COPY --from=builder /app/crates/erp-server/config/default.toml /etc/erp/config.toml
|
||||
EXPOSE 3000 9090
|
||||
CMD ["erp-server"]
|
||||
```
|
||||
2. `docker-compose.production.yml`:
|
||||
- erp-server 服务(限制 1 CPU / 512MB)
|
||||
- PostgreSQL 16 + Redis 7 作为独立服务
|
||||
- 健康检查配置(使用 /health/ready)
|
||||
- 环境变量注入(JWT secret / DB URL / Redis URL 通过 secrets)
|
||||
|
||||
**验收**: `docker build -t hms-server .` 成功,运行时镜像 < 80MB
|
||||
|
||||
### Task 6: 前端生产构建 + Nginx
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/Dockerfile`
|
||||
- 新增: `apps/web/nginx.conf`
|
||||
|
||||
**步骤**:
|
||||
1. 多阶段构建:node 构建 → nginx 运行
|
||||
2. Nginx 配置:SPA fallback + `/api` 代理到后端 3000 端口
|
||||
|
||||
**验收**: Docker 内前端可正常访问,API 代理工作
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 日志聚合 + 告警(Day 5-7)
|
||||
|
||||
### Task 7: Grafana Loki 日志集成
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `docker/loki-config.yaml`
|
||||
- 修改: `docker/docker-compose.production.yml`
|
||||
|
||||
**步骤**:
|
||||
1. 在 production compose 中添加 Loki + Promtail 服务
|
||||
2. Promtail 配置:读取 erp-server 的 JSON 日志输出
|
||||
3. Grafana 数据源配置:Loki + Prometheus
|
||||
|
||||
**验收**: Grafana 可查询和过滤后端日志
|
||||
|
||||
### Task 8: Prometheus 告警规则
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `docker/alert-rules.yml`
|
||||
|
||||
**步骤**:
|
||||
1. 定义 5 条告警规则:
|
||||
```yaml
|
||||
- alert: HighRequestLatency # P95 > 2s 持续 5 分钟
|
||||
- alert: HighErrorRate # 5xx 比率 > 5% 持续 3 分钟
|
||||
- alert: EventBusBacklog # 积压事件 > 100 持续 5 分钟
|
||||
- alert: DatabasePoolExhausted # 活跃连接 > 90% 持续 2 分钟
|
||||
- alert: HealthCheckDegraded # /health/ready 非 ok 持续 1 分钟
|
||||
```
|
||||
2. 配置 Alertmanager 通知渠道(Webhook/邮件)
|
||||
|
||||
**验收**: 触发告警条件时 Alertmanager 发送通知
|
||||
|
||||
### Task 9: Grafana Dashboard 模板
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `docker/grafana/dashboards/hms-overview.json`
|
||||
|
||||
**步骤**:
|
||||
1. 创建 HMS Overview Dashboard,包含面板:
|
||||
- 请求速率 + 延迟分布(P50/P95/P99)
|
||||
- 错误率趋势(按 status code 分组)
|
||||
- DB 连接池使用率
|
||||
- EventBus 发布/消费速率
|
||||
- 健康检查状态
|
||||
|
||||
**验收**: Dashboard 展示实时指标
|
||||
|
||||
### Task 10: 运维文档
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `wiki/observability.md`
|
||||
|
||||
**步骤**:
|
||||
1. 记录监控端点(/health/live, /health/ready, /metrics)
|
||||
2. 记录告警规则和响应流程
|
||||
3. 记录日志查询方法(Grafana Loki)
|
||||
4. 记录 Docker 部署命令
|
||||
|
||||
**验收**: 新团队成员可通过文档独立部署和排查问题
|
||||
|
||||
---
|
||||
|
||||
## 执行原则
|
||||
|
||||
1. **条件编译** — OpenTelemetry 使用 feature gate,不启用时零开销
|
||||
2. **渐进式** — Phase 1 可独立上线(无外部依赖),Phase 2/3 需要 Docker 环境
|
||||
3. **性能优先** — 指标收集使用 `metrics` crate 的无锁实现,不影响请求延迟
|
||||
4. **端口分离** — 业务 API (3000) + Metrics (9090) 分离,避免暴露内部指标
|
||||
@@ -0,0 +1,406 @@
|
||||
# HMS 平台基座回顾与演进设计
|
||||
|
||||
> 日期: 2026-04-26 | 状态: Draft | 方法: 三专家多视角评审
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 回顾目的
|
||||
|
||||
HMS 健康管理平台经过 17 天密集开发(2026-04-10 ~ 2026-04-26),从 ERP 底座演进到包含 16 个 Rust crate、62 个前端页面、27 个小程序页面的综合医疗 SaaS 平台。本次回顾旨在:
|
||||
|
||||
- **验证基座设计** — 星形依赖拓扑、ErpModule trait、事件总线、多租户策略是否经得起实践检验
|
||||
- **评估演进路径** — 从插件开发模式到原生模块开发的决策是否正确
|
||||
- **识别缺口与风险** — 通过多专家视角发现盲点
|
||||
- **制定演进路线** — 基于 P0/P1/P2 优先级指导后续迭代
|
||||
|
||||
### 1.2 评审方法
|
||||
|
||||
采用三专家独立评审,每个专家从不同视角分析相同的诊断和建议:
|
||||
|
||||
| 专家 | 视角 | 关注点 |
|
||||
|------|------|--------|
|
||||
| 高级系统架构师 | 架构可持续性 | 模块边界、事件可靠性、技术债 |
|
||||
| 医疗信息化专家 | 临床安全与合规 | 患者安全、PIPL 合规、领域模型 |
|
||||
| 产品策略专家 | ROI 与开发节奏 | 优先级、技术债量化、路线图现实性 |
|
||||
|
||||
### 1.3 核心结论
|
||||
|
||||
**基座设计方向正确,但深度不足。** 星形依赖、trait 抽象、事件总线等基础架构经受住了实践检验。但在临床安全(危急值告警未闭环)、合规(知情同意缺失)、事件可靠性(无重放机制)方面存在需立即修复的缺口。插件系统已验证可行性但对 HMS 核心业务贡献有限,建议有条件冻结。
|
||||
|
||||
---
|
||||
|
||||
## 2. 基座设计验证
|
||||
|
||||
### 2.1 评分总览
|
||||
|
||||
| 维度 | 评分 | 说明 |
|
||||
|------|------|------|
|
||||
| 模块边界 | ★★★★ | 星形拓扑零循环依赖,trait 契约清晰 |
|
||||
| ErpModule trait | ★★★★ | 生命周期/权限/事件/健康检查统一接口 |
|
||||
| 事件总线 | ★★★☆ | 基础设施扎实(broadcast+outbox),但无重放机制,消费侧不完整 |
|
||||
| 多租户 | ★★★☆ | JWT→TenantContext 全链路贯通,但缺 RLS 兜底和集成测试 |
|
||||
| 权限体系 | ★★★★ | RBAC + 行级数据权限 + 按钮级控制 |
|
||||
| 插件系统 | ★★★☆ | CRUD 场景验证通过,医疗场景天花板明显 |
|
||||
| API 一致性 | ★★★★ | 统一 envelope、分页、OpenAPI 自动文档 |
|
||||
| 数据库迁移 | ★★★★ | 59 个迁移,幂等、可回滚、fixup 模式健康 |
|
||||
| 测试覆盖 | ★☆☆☆ | 36 后端 + 3 前端,覆盖率 < 5% |
|
||||
| 合规性 | ★☆☆☆ | 知情同意缺失,审计不完整,PIE 加密范围不足 |
|
||||
|
||||
### 2.2 星形依赖拓扑
|
||||
|
||||
```
|
||||
erp-core (L1)
|
||||
/ | \ \ \ \
|
||||
erp-auth workflow message config erp-health erp-plugin erp-ai
|
||||
\ | / / / / /
|
||||
erp-server (L3, 组装入口)
|
||||
```
|
||||
|
||||
- `erp-core`:零业务依赖,纯净基础层
|
||||
- 7 个业务 crate:各只依赖 `erp-core`,兄弟间无横向依赖
|
||||
- `erp-server`:唯一组装点,负责路由合并和模块初始化
|
||||
- **无循环依赖** — 架构师验证通过
|
||||
|
||||
### 2.3 ErpModule trait
|
||||
|
||||
当前 trait 提供统一的模块接口:
|
||||
|
||||
- **身份**:`name()` / `id()` / `version()`
|
||||
- **依赖声明**:`dependencies()` — 用于拓扑排序启动顺序
|
||||
- **生命周期**:`on_startup()` / `on_shutdown()` / `health_check()`
|
||||
- **多租户**:`on_tenant_created()` / `on_tenant_deleted()`
|
||||
- **权限自描述**:`permissions()` — 模块声明自己需要的权限码
|
||||
- **事件订阅**:`register_event_handlers()` / `as_any()`
|
||||
|
||||
**已知张力**:路由注册不在 trait 中,而是通过各模块的 inherent method (`public_routes()` / `protected_routes()`) 手动在 `main.rs` 中合并。原因是 Axum 的 `Router<S>` 泛型约束不适合 trait object。这是务实的妥协,但在添加新模块时有 boilerplate 成本。
|
||||
|
||||
### 2.4 事件总线
|
||||
|
||||
**实现机制**:`tokio::sync::broadcast` (容量 1024) + `domain_events` 表持久化(best-effort)+ Outbox relay (5秒轮询,3次重试)
|
||||
|
||||
**发布侧**(已识别的事件类型):
|
||||
|
||||
| 模块 | 事件类型数 | 示例 |
|
||||
|------|-----------|------|
|
||||
| erp-auth | 10 | `user.login`, `user.created`, `role.created` |
|
||||
| erp-workflow | 4 | `process_instance.started`, `task.completed` |
|
||||
| erp-message | 1 | `message.sent` |
|
||||
| erp-health | 13 | `patient.created`, `health_data.critical_alert`, `follow_up.overdue` |
|
||||
| erp-plugin | 2+ | `plugin.config.updated`, `plugin.trigger.*` |
|
||||
|
||||
**消费侧**(已识别的订阅者):
|
||||
|
||||
| 订阅者 | 订阅方式 | 处理的事件 |
|
||||
|--------|---------|-----------|
|
||||
| erp-message | `subscribe()` 全量 | `appointment.*`, `process_instance.*`, `task.*` |
|
||||
| erp-health | `register_handlers_with_state` | `workflow.task.completed` |
|
||||
| erp-plugin 通知 | `subscribe_filtered("plugin.trigger.*")` | 插件触发通知 |
|
||||
| outbox relay | 轮询 DB | 重发 pending 事件 |
|
||||
|
||||
**已识别缺陷**:
|
||||
1. **无重放机制** — 内存 broadcast,服务重启后未消费的事件丢失
|
||||
2. **无幂等保护** — `follow_up.overdue` 每 6 小时检查会重复发布同一条逾期事件
|
||||
3. **全量订阅** — erp-message 使用 `subscribe()` 而非 `subscribe_filtered()`,所有事件都经过消息模块
|
||||
|
||||
### 2.5 多租户
|
||||
|
||||
**已实现**:
|
||||
- JWT claims 提取 `tenant_id` → `TenantContext` 注入请求扩展
|
||||
- 所有 Entity 含 `tenant_id` 字段,BaseFields 统一
|
||||
- 所有 DomainEvent 携带 `tenant_id`
|
||||
- `on_tenant_created()` / `on_tenant_deleted()` 钩子(auth 和 health 已实现)
|
||||
- 部门级数据范围(`department_ids` 在 TenantContext 中)
|
||||
|
||||
**缺失**:
|
||||
- 无 PostgreSQL RLS policy 作为兜底层
|
||||
- 无强制 tenant_id 过滤的查询层机制 — 依赖每个 service 手动 `.filter()`
|
||||
- 当前实际只有 default_tenant,微信登录硬编码使用 `default_tenant_id`
|
||||
- 无多租户管理 API(创建/配置/迁移)
|
||||
|
||||
---
|
||||
|
||||
## 3. 演进路径回顾
|
||||
|
||||
### 3.1 时间线
|
||||
|
||||
```
|
||||
4/10-4/16 基座搭建 (Phase 1-6)
|
||||
→ core → auth → config → workflow → message
|
||||
→ 全部原生 Rust 模块,30+ 数据库表
|
||||
|
||||
4/13-4/18 WASM 插件实验
|
||||
→ 插件系统设计与实现 (Wasmtime + WIT bindgen)
|
||||
→ CRM (5实体) → Inventory (6实体) → Freelance → ITOps
|
||||
→ 证明:CRUD 密集型领域可行,沙盒隔离有效
|
||||
→ 跨插件数据引用未解决
|
||||
|
||||
4/23-4/26 HMS 分叉 — 健康模块原生开发
|
||||
→ 18+ 强类型实体 (患者/家属/医生/预约/排班/随访/咨询/体征/化验/透析/诊断/积分...)
|
||||
→ PII 加密 (AES-256-GCM)、脱敏管道
|
||||
→ AI 模块 (4 SSE 流式端点 + 3 REST 端点)
|
||||
→ 微信小程序 (27 页面)
|
||||
→ 按钮级权限控制
|
||||
```
|
||||
|
||||
### 3.2 从插件到原生的决策链
|
||||
|
||||
**原始插件愿景**(设计规格 2026-04-13):
|
||||
|
||||
- 平台模块原生,行业模块 WASM 插件
|
||||
- 插件通过 9 个 Host API 函数通信(db_insert/query/update/delete、event_publish、config_get 等)
|
||||
- 数据存 JSONB 动态表,路由自动生成
|
||||
- UI 配置驱动,通用 PluginCRUDPage 组件
|
||||
|
||||
**健康模块原生的 5 个硬限制**(设计规格 2026-04-23 §1.3):
|
||||
|
||||
| 限制 | 影响 | 不可妥协原因 |
|
||||
|------|------|-------------|
|
||||
| 20 实体上限 | 健康平台轻松超过 | 18+ 实体已是最低合理粒度 |
|
||||
| JSONB 存储 | 无强类型、无外键约束 | 医疗数据需要引用完整性和精确索引 |
|
||||
| 无自定义 API | 只有自动 CRUD | 趋势分析/统计报表/日历视图无法实现 |
|
||||
| 无文件上传 | 沙盒阻止文件系统访问 | 化验单/体检报告需要文件存储 |
|
||||
| WASM 沙盒限制 | 无 native crypto/外部 API/后台任务 | PII 加密、微信集成、定时任务全部需要 |
|
||||
|
||||
### 3.3 得失评估
|
||||
|
||||
**得 — 正确的决策:**
|
||||
|
||||
| 决策 | 收益 |
|
||||
|------|------|
|
||||
| 星形依赖拓扑 | 模块独立性强,可独立测试和替换 |
|
||||
| ErpModule 统一接口 | 新模块注册流程标准化 |
|
||||
| 事件总线 | 跨模块解耦通信的基础设施已就绪 |
|
||||
| JWT→TenantContext | 多租户全链路贯通 |
|
||||
| 健康模块原生 | 不受沙盒限制,加密/文件/后台任务全部可用 |
|
||||
| 插件实验 | 验证了平台灵活性,CRM/库存可正常使用 |
|
||||
|
||||
**失 — 需要修正的问题:**
|
||||
|
||||
| 决策 | 代价 |
|
||||
|------|------|
|
||||
| 插件系统投入过大 | 22,000 行代码(41% Rust 总量),对 HMS 核心业务贡献接近零 |
|
||||
| 积分系统混入 health | 8 实体/12+ 路由,增加合规复杂度和数据泄露面 |
|
||||
| 事件消费侧忽视 | 13 个事件只有 3 个被消费,危急告警和逾期通知空转 |
|
||||
| 测试覆盖极薄 | 36 后端 + 3 前端测试,覆盖率 < 5% |
|
||||
| 合规意识不足 | 知情同意缺失、审计不完整、PIE 加密范围不足 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 三专家评审摘要
|
||||
|
||||
### 4.1 高级系统架构师
|
||||
|
||||
**诊断准确度:7/10** — 四个张力都真实存在,但优先级和细节有偏差。
|
||||
|
||||
关键补充:
|
||||
|
||||
| 发现 | 严重程度 |
|
||||
|------|---------|
|
||||
| WIT 接口是同步调用(阻塞),WASM 运行时嵌入主进程(故障隔离不足) | 架构隐患 |
|
||||
| EventBus 内存 broadcast 无重放机制,服务重启丢事件 | P1 |
|
||||
| `follow_up.overdue` 无幂等保护,每 6h 检查重复发布 | P0 |
|
||||
| erp-message 用 `subscribe()` 全量订阅,性能隐患 | P1 |
|
||||
| RLS 不是 P0,多租户集成测试才是 | 观点 |
|
||||
| 积分系统(8 独立实体、12+ 路由)不应在 erp-health 内 | 共识 |
|
||||
| 缺监控/可观测性、数据备份策略、API 版本升级路线图 | 盲点 |
|
||||
|
||||
核心原则:**先补测试再重构,先修事件再上功能,先验证再加固。**
|
||||
|
||||
### 4.2 医疗信息化专家
|
||||
|
||||
**发现了比原始诊断更深层的临床安全风险。**
|
||||
|
||||
| 新发现 | 严重程度 |
|
||||
|--------|---------|
|
||||
| 危急值阈值全部硬编码(收缩压 180/80、心率 150/40),不可配置 | P0 |
|
||||
| `daily_monitoring` 表体征数据不经过危急值检测(合并前遗留) | P0 |
|
||||
| 过敏史更新直接覆盖,无变更历史 | P0 |
|
||||
| 知情同意完全缺失(搜索 consent/同意/授权/隐私 零结果) | P0 — PIPL 违规 |
|
||||
| 只有身份证号存储加密,姓名/过敏史/诊断/咨询内容明文 | P1 |
|
||||
| 审计日志不完整 — 只有预约状态变更记录前后值 | P1 |
|
||||
| `ip_address` 和 `user_agent` 从未被填充 | P1 |
|
||||
| 读操作(查看患者详情/化验报告)完全没有审计记录 | P1 |
|
||||
| 诊断记录 `icd_code` 只做字符串约束,无格式校验,无同行审核 | P1 |
|
||||
|
||||
合规评估:PIPL 第 29 条要求处理敏感个人信息须取得单独同意。医疗数据属于敏感个人信息。知情同意缺失是法律红线。
|
||||
|
||||
领域模型建议:积分系统(6 实体 + 2 线下活动实体)应拆分为独立 `erp-points` 或 `erp-engagement` 模块,与健康数据分离以降低合规复杂度。
|
||||
|
||||
### 4.3 产品策略专家
|
||||
|
||||
**开发节奏不可持续但不必恐慌。**
|
||||
|
||||
| 分析 | 结论 |
|
||||
|------|------|
|
||||
| 峰值 68 提交/天,fix 提交占 21.6% | 短期冲刺可以,长期人会耗竭 |
|
||||
| 41% Rust 代码在插件系统,对核心业务贡献接近零 | 最大 ROI 失衡 |
|
||||
| 单人 + AI 的"速度幻觉" | 68 提交/天 = 审查不足,积分混入 health 就是例证 |
|
||||
| 测试覆盖 < 5% | 正确水位不是 80%,而是关键路径不回退(目标 50-80 用例,3-4 天) |
|
||||
|
||||
关键风险缓解建议:
|
||||
- ADR(架构决策记录)强制化
|
||||
- 医疗安全代码双人外部 review
|
||||
- 每日提交上限 15 次
|
||||
- 每月需求裁剪
|
||||
|
||||
V2 血透路线图评估:技术储备已够(`dialysis_service` 286 行骨架在),但缺市场验证。建议先做 3-5 家目标客户调研,确认需求后再做 2 周 MVP 试运行。
|
||||
|
||||
---
|
||||
|
||||
## 5. 共识优先级
|
||||
|
||||
### 5.1 三专家加权共识矩阵
|
||||
|
||||
| 议题 | 架构师 | 医疗专家 | 产品策略 | 共识等级 |
|
||||
|------|--------|---------|---------|---------|
|
||||
| 危急值告警闭环 | P0 | P0 + 硬编码 | P0 | 三方一致 |
|
||||
| 知情同意 (PIPL) | 未涉及 | P0 | P0 | 两方一致 |
|
||||
| 审计日志补全 | 未涉及 | P1 | P0 | P0-P1 |
|
||||
| EventBus 可靠性 | P1 | 未涉及 | P0 | P0-P1 |
|
||||
| 随访逾期通知 | P0 | P0 | P0 | 三方一致 |
|
||||
| 积分系统拆分 | 应拆 | 应拆(合规) | 占 19.5% | 三方一致 |
|
||||
| RLS | 不是 P0 | P1 | P0 | 有分歧 |
|
||||
| 插件系统 | 有条件冻结 | 未涉及 | 冻结 | 两方一致 |
|
||||
| 测试覆盖 | 先补测试 | 上线前必修 | 50-80 用例 | 三方一致 |
|
||||
| V2 血透 | 未涉及 | 缺标准流程 | 先调研 | 两方一致 |
|
||||
|
||||
### 5.2 P0 — 上线前必修(估计 2-3 周)
|
||||
|
||||
| 序号 | 项 | 工作量 | 负责 crate | 说明 |
|
||||
|------|---|--------|-----------|------|
|
||||
| 1 | 危急值告警消费者 | 1 天 | erp-health + erp-message | `health_data.critical_alert` → 推送通知给责任医护 |
|
||||
| 2 | 危急值阈值可配置化 | 2 天 | erp-health | 硬编码阈值改为数据库配置,支持科室/年龄差异化 |
|
||||
| 3 | daily_monitoring 合并后告警验证 | 1 天 | erp-health | 确认合并到 vital_signs 后所有体征数据都经过告警检测 |
|
||||
| 4 | 随访逾期通知 | 1 天 | erp-health + erp-message | `follow_up.overdue` → 催办通知 + 幂等保护 |
|
||||
| 5 | 知情同意记录 | 3 天 | erp-health | 患者数据处理同意获取和记录机制 |
|
||||
| 6 | 审计日志补全 | 3 天 | erp-core + erp-health | 临床数据变更记录前后值、读操作审计、IP/UA 填充 |
|
||||
| 7 | EventBus 持久化增强 | 2 天 | erp-core | 服务重启不丢事件 + overdue 事件幂等 |
|
||||
|
||||
### 5.3 P1 — 治理(2-4 周)
|
||||
|
||||
| 序号 | 项 | 工作量 | 说明 |
|
||||
|------|---|--------|------|
|
||||
| 8 | 积分系统剥离 | 5 天 | 从 erp-health 拆分为独立 erp-engagement crate |
|
||||
| 9 | 关键路径测试 | 4 天 | 多租户隔离、患者安全路径、预约并发(50-80 用例) |
|
||||
| 10 | 插件系统冻结声明 | 0.5 天 | 保留代码,README 声明实验性,不再投入 |
|
||||
| 11 | erp-message 改用 `subscribe_filtered` | 1 天 | 减少无效事件传递 |
|
||||
| 12 | 统一事件消费模式 | 2 天 | 消除 `register_event_handlers` vs `on_startup` 双路径 |
|
||||
| 13 | 过敏史变更历史 | 1 天 | 更新时记录旧值 |
|
||||
|
||||
### 5.4 P2 — 扩展(后续迭代)
|
||||
|
||||
| 序号 | 项 | 前置条件 |
|
||||
|------|---|---------|
|
||||
| 14 | PostgreSQL RLS | P1 测试覆盖完成 |
|
||||
| 15 | 血透专科 | 3-5 家客户调研完成 |
|
||||
| 16 | OCR 化验单提取 | 血透验证后 |
|
||||
| 17 | IM 咨询 | 血透验证后 |
|
||||
| 18 | health 模块按子域重组目录 | P1 测试覆盖完成 |
|
||||
| 19 | 前端测试覆盖提升 | P1 后端测试完成 |
|
||||
| 20 | 动态菜单系统 | 现有计划可用 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险与缓解
|
||||
|
||||
### 6.1 开发模式风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 单人认知单点 | 一人理解 16 个 crate,bus factor = 1 | ADR 强制化,关键决策留文档 |
|
||||
| AI 生成"编译对但逻辑错" | 危急值阈值硬编码、积分混入 health 就是例证 | 医疗安全代码双人外部 review |
|
||||
| 速度幻觉 | 68 提交/天 = 审查不足 | 每日提交上限 15 次 |
|
||||
| AI 回音壁 | AI 不质疑需求合理性 | 每月需求裁剪,引入真实用户反馈 |
|
||||
|
||||
### 6.2 临床安全风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 危急值告警未闭环 | 危急体征值无人响应,可致患者安全事故 | P0-1:实现消费者 + 阈值可配置 |
|
||||
| 逾期随访无催办 | 患者失访,影响医疗质量指标 | P0-4:实现通知 + 幂等保护 |
|
||||
| 过敏史无变更记录 | 无法追溯过敏史变更,用药风险 | P1-13:添加变更历史 |
|
||||
| 告警阈值硬编码 | 无法适应儿科/老年科/血透科不同范围 | P0-2:数据库配置 |
|
||||
|
||||
### 6.3 合规风险
|
||||
|
||||
| 风险 | 法规依据 | 缓解措施 |
|
||||
|------|---------|---------|
|
||||
| 知情同意缺失 | PIPL 第 29 条 | P0-5:实现同意记录机制 |
|
||||
| 审计不完整 | 医疗机构信息化建设要求 | P0-6:补全审计日志 |
|
||||
| PIE 加密范围不足 | PIPL 第 51 条 | P1:扩展加密到姓名/过敏史/诊断 |
|
||||
| 数据删除权缺失 | PIPL 第 47 条 | P2:实现患者数据导出/删除 |
|
||||
|
||||
### 6.4 架构风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| EventBus 无重放 | 服务重启丢事件 | P0-7:增强持久化 |
|
||||
| 全量订阅 | 性能隐患,所有事件经消息模块 | P1-11:改用过滤订阅 |
|
||||
| 路由手动合并 | 新模块 boilerplate 成本 | 长期:ErpModule trait v2 |
|
||||
| erp-health 过大 | 18+ 实体,维护复杂度上升 | P2-18:按子域重组 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 附录
|
||||
|
||||
### 7.1 关键文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `crates/erp-core/src/module.rs` | ErpModule trait + ModuleRegistry (拓扑排序) |
|
||||
| `crates/erp-core/src/events.rs` | EventBus 实现 (broadcast + outbox) |
|
||||
| `crates/erp-core/src/types.rs` | TenantContext, BaseFields, Pagination |
|
||||
| `crates/erp-core/src/rbac.rs` | 权限/角色检查 |
|
||||
| `crates/erp-server/src/main.rs` | 服务组装和手动路由合并 |
|
||||
| `crates/erp-server/src/state.rs` | AppState + FromRef 桥接 |
|
||||
| `crates/erp-server/src/outbox.rs` | Outbox relay (5s 轮询, 3 次重试) |
|
||||
| `crates/erp-auth/src/middleware/jwt_auth.rs` | JWT 认证 + TenantContext 注入 |
|
||||
| `crates/erp-health/src/module.rs` | HealthModule (ErpModule 实现 + 后台任务) |
|
||||
| `crates/erp-health/src/event.rs` | 健康模块事件订阅 |
|
||||
| `crates/erp-health/src/crypto.rs` | AES-256-GCM 加密 |
|
||||
| `crates/erp-health/src/service/masking.rs` | PII 脱敏管道 |
|
||||
| `crates/erp-plugin/src/engine.rs` | WASM 插件引擎 |
|
||||
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | 插件系统设计规格 |
|
||||
| `docs/superpowers/specs/2026-04-23-health-management-module-design.md` | 健康模块设计规格 |
|
||||
| `docs/discussions/2026-04-18-plugin-platform-brainstorm.md` | 插件平台演进讨论 |
|
||||
|
||||
### 7.2 迁移历史时间线
|
||||
|
||||
| 日期 | 迁移范围 | 说明 |
|
||||
|------|---------|------|
|
||||
| 4/10-11 | 核心平台 | 租户、用户、凭证、角色、权限、组织、部门、岗位 |
|
||||
| 4/12 | 配置 + 工作流 | 字典、菜单、设置、编号规则 + 流程定义/实例/令牌/任务 |
|
||||
| 4/13 | 消息 + 审计 | 模板、消息、订阅 + 审计日志 |
|
||||
| 4/14 | 修复 | 唯一索引与软删除冲突、标准字段补全 |
|
||||
| 4/16 | 领域事件 | domain_events 表 |
|
||||
| 4/17 | 插件系统 | 插件表、动态表 |
|
||||
| 4/18 | 搜索 + 权限 | pg_trgm、实体注册表、数据范围 |
|
||||
| 4/19 | 关联修复 | 用户部门、CRM 修复、插件市场 |
|
||||
| 4/23 | 健康表 | 患者、微信用户、文章 |
|
||||
| 4/24 | 索引修复 | 3 个 fixup 迁移 |
|
||||
| 4/25 | 健康扩展 | 患者ID哈希、医生名、透析/化验增强、AI 表、积分 |
|
||||
| 4/26 | 业务改进 | 诊断、列重命名、daily_monitoring 合并、菜单种子 |
|
||||
|
||||
**总计:59 个迁移,17 天内。** fixup 迁移模式健康(不编辑旧迁移,单独修复)。
|
||||
|
||||
### 7.3 项目统计快照 (2026-04-26)
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| Rust crate 数 | 16 |
|
||||
| Rust 代码行 | ~57,000 |
|
||||
| 前端文件数 | 174 (TSX/TS) |
|
||||
| 前端页面 | 62 |
|
||||
| 小程序页面 | 27 |
|
||||
| 数据库迁移 | 59 |
|
||||
| 数据库表 | 30 基础 + 18 健康 + 3 AI |
|
||||
| 后端测试 | 36 |
|
||||
| 前端单元测试 | 3 |
|
||||
| Git 提交 | 237 |
|
||||
| 开发周期 | 17 天 |
|
||||
|
||||
---
|
||||
|
||||
*本文档由三专家多视角评审生成,作为 HMS 平台基座演进的参考基准。后续实施计划将基于本文档的优先级排序展开。*
|
||||
@@ -0,0 +1,714 @@
|
||||
# 架构反思实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 落地架构反思三个结论 — WASM 评估量表插件、透析模块独立、P1 事件消费者补全。
|
||||
|
||||
**Architecture:** 三条独立工作线可并行推进。WASM 插件遵循 erp-plugin-test-sample 模式;透析模块拆分参照 erp-points 拆 crate 模式;事件消费者补全遵循现有 subscribe_filtered + tokio::spawn 模式。
|
||||
|
||||
**Tech Stack:** Rust/SeaORM/Axum/WASM(wit-bindgen 0.55)
|
||||
|
||||
**Spec:** `docs/discussions/2026-04-28-architecture-retrospective.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: WASM 评估量表插件(PHQ-9)
|
||||
|
||||
### Task 1: 创建插件 crate 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-assessment/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-assessment/src/lib.rs`
|
||||
- Create: `crates/erp-plugin-assessment/plugin.toml`
|
||||
|
||||
- [ ] **Step 1: 创建 Cargo.toml**
|
||||
|
||||
参照 `crates/erp-plugin-test-sample/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-assessment"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.55"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 plugin.toml**
|
||||
|
||||
```toml
|
||||
[metadata]
|
||||
id = "assessment"
|
||||
name = "评估量表"
|
||||
version = "0.1.0"
|
||||
description = "标准化医学评估量表(PHQ-9、GAD-7 等)"
|
||||
author = "HMS"
|
||||
min_platform_version = "0.1.0"
|
||||
|
||||
[[permissions]]
|
||||
code = "assessment_scale.list"
|
||||
name = "查看评估量表"
|
||||
description = "查看评估量表列表和详情"
|
||||
|
||||
[[permissions]]
|
||||
code = "assessment_scale.manage"
|
||||
name = "管理评估量表"
|
||||
description = "创建、编辑、删除评估量表"
|
||||
|
||||
[[permissions]]
|
||||
code = "assessment_response.list"
|
||||
name = "查看评估结果"
|
||||
description = "查看患者评估答卷"
|
||||
|
||||
[[permissions]]
|
||||
code = "assessment_response.manage"
|
||||
name = "管理评估结果"
|
||||
description = "提交、编辑评估答卷"
|
||||
|
||||
[[schema.entities]]
|
||||
name = "assessment_scale"
|
||||
display_name = "评估量表"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "scale_code"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "量表编码"
|
||||
unique = true
|
||||
ui_widget = "select"
|
||||
options = ["PHQ-9", "GAD-7", "SF-36", "MMSE", "ADL", "IADL"]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "title"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "量表名称"
|
||||
searchable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "description"
|
||||
field_type = "string"
|
||||
display_name = "描述"
|
||||
ui_widget = "textarea"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "questions_json"
|
||||
field_type = "json"
|
||||
required = true
|
||||
display_name = "题目定义(JSON)"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "scoring_rules_json"
|
||||
field_type = "json"
|
||||
required = true
|
||||
display_name = "评分规则(JSON)"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "status"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "状态"
|
||||
default = "active"
|
||||
ui_widget = "select"
|
||||
options = ["active", "inactive"]
|
||||
|
||||
[[schema.entities]]
|
||||
name = "assessment_response"
|
||||
display_name = "评估答卷"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "scale_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "量表"
|
||||
ui_widget = "entity_select"
|
||||
ref_entity = "assessment_scale"
|
||||
ref_plugin = "assessment"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "patient_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "患者 ID"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "answers_json"
|
||||
field_type = "json"
|
||||
required = true
|
||||
display_name = "答案(JSON)"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "total_score"
|
||||
field_type = "integer"
|
||||
required = true
|
||||
display_name = "总分"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "severity_level"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "严重程度"
|
||||
ui_widget = "select"
|
||||
options = ["normal", "mild", "moderate", "severe"]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "assessed_by"
|
||||
field_type = "uuid"
|
||||
display_name = "评估人"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "status"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "状态"
|
||||
default = "completed"
|
||||
ui_widget = "select"
|
||||
options = ["draft", "completed", "reviewed"]
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "assessment_scale"
|
||||
foreign_key = "scale_id"
|
||||
on_delete = "restrict"
|
||||
name = "scale"
|
||||
type = "belongs_to"
|
||||
display_field = "title"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "assessment_completed"
|
||||
display_name = "评估完成"
|
||||
description = "患者完成评估量表,触发评分计算和后续流程"
|
||||
entity = "assessment_response"
|
||||
on = "create"
|
||||
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
label = "评估量表"
|
||||
icon = "FormOutlined"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 创建 src/lib.rs**
|
||||
|
||||
```rust
|
||||
// crates/erp-plugin-assessment/src/lib.rs
|
||||
//! 评估量表插件 — 标准化医学评估(PHQ-9, GAD-7 等)
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "../../crates/erp-plugin/src/wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||
use crate::erp::plugin::host_api::*;
|
||||
|
||||
struct AssessmentPlugin;
|
||||
|
||||
impl Guest for AssessmentPlugin {
|
||||
fn init() -> Result<(), String> {
|
||||
log_write("info", "AssessmentPlugin initialized");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
|
||||
log_write("info", &format!("AssessmentPlugin: tenant {} created", tenant_id));
|
||||
// 可以为新租户插入默认量表(PHQ-9、GAD-7)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(
|
||||
event_type: String,
|
||||
_event_id: String,
|
||||
_tenant_id: String,
|
||||
_payload: String,
|
||||
) -> Result<(), String> {
|
||||
log_write("debug", &format!("AssessmentPlugin received: {}", event_type));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
export!(AssessmentPlugin);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 注册到 workspace**
|
||||
|
||||
在根 `Cargo.toml` 的 `workspace.members` 中添加 `"crates/erp-plugin-assessment"`。
|
||||
|
||||
- [ ] **Step 5: 编译验证**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-assessment
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-assessment/ Cargo.toml
|
||||
git commit -m "feat(plugin): 评估量表插件骨架 — assessment_scale + assessment_response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: PHQ-9 默认量表数据 + 评分逻辑
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-plugin-assessment/src/lib.rs`(on_tenant_created 插入默认量表)
|
||||
|
||||
- [ ] **Step 1: 在 on_tenant_created 中插入 PHQ-9 默认数据**
|
||||
|
||||
PHQ-9 的 9 道题(每题 0-3 分)和评分规则:
|
||||
|
||||
```json
|
||||
// questions_json
|
||||
[
|
||||
{"id": 1, "text": "做事时提不起劲或没有兴趣", "options": [{"label": "完全不会", "score": 0}, {"label": "好几天", "score": 1}, {"label": "一半以上的天数", "score": 2}, {"label": "几乎每天", "score": 3}]},
|
||||
// ... 共 9 题
|
||||
]
|
||||
|
||||
// scoring_rules_json
|
||||
[
|
||||
{"min": 0, "max": 4, "level": "normal", "label": "无抑郁症状"},
|
||||
{"min": 5, "max": 9, "level": "mild", "label": "轻度抑郁"},
|
||||
{"min": 10, "max": 14, "level": "moderate", "label": "中度抑郁"},
|
||||
{"min": 15, "max": 19, "level": "moderate_severe", "label": "中重度抑郁"},
|
||||
{"min": 20, "max": 27, "level": "severe", "label": "重度抑郁"}
|
||||
]
|
||||
```
|
||||
|
||||
通过 `db_insert` host API 在 `on_tenant_created` 中插入。
|
||||
|
||||
- [ ] **Step 2: 编译 + 验证**
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(plugin): PHQ-9 默认量表数据 + 评分规则"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 编译 WASM + 注册到 erp-server
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-server/src/main.rs`(插件注册,如需手动加载)
|
||||
- Verify: WASM 编译输出
|
||||
|
||||
- [ ] **Step 1: 编译为 WASM Component**
|
||||
|
||||
```bash
|
||||
cd crates/erp-plugin-assessment
|
||||
cargo build --target wasm32-unknown-unknown --release
|
||||
# 或使用项目内的 WASM 编译脚本
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证插件加载**
|
||||
|
||||
启动后端,确认插件系统识别 assessment 插件,动态表创建成功。
|
||||
|
||||
- [ ] **Step 3: 通过 API 测试评估量表 CRUD**
|
||||
|
||||
```bash
|
||||
# 创建量表
|
||||
curl -X POST /api/v1/plugin/assessment/assessment_scale \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"scale_code": "PHQ-9", ...}'
|
||||
|
||||
# 提交答卷
|
||||
curl -X POST /api/v1/plugin/assessment/assessment_response \
|
||||
-d '{"scale_id": "...", "patient_id": "...", "answers_json": [...]}'
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(plugin): 评估量表 WASM 编译 + 端到端验证"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 透析模块拆分为 erp-dialysis
|
||||
|
||||
### Task 4: 创建 erp-dialysis crate 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-dialysis/Cargo.toml`
|
||||
- Create: `crates/erp-dialysis/src/{lib,module,state,error}.rs`
|
||||
- Modify: `Cargo.toml`(workspace members)
|
||||
|
||||
- [ ] **Step 1: 创建 Cargo.toml**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-dialysis"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
sea-orm.workspace = true
|
||||
tokio.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
axum.workspace = true
|
||||
utoipa.workspace = true
|
||||
validator.workspace = true
|
||||
async-trait.workspace = true
|
||||
tracing.workspace = true
|
||||
rust_decimal.workspace = true
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建标准模块文件**
|
||||
|
||||
```rust
|
||||
// crates/erp-dialysis/src/lib.rs
|
||||
pub mod dto;
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod handler;
|
||||
pub mod module;
|
||||
pub mod service;
|
||||
pub mod state;
|
||||
|
||||
pub use module::DialysisModule;
|
||||
pub use state::DialysisState;
|
||||
```
|
||||
|
||||
```rust
|
||||
// crates/erp-dialysis/src/module.rs
|
||||
//! ErpModule trait 实现
|
||||
|
||||
use async_trait::async_trait;
|
||||
use erp_core::module::{ErpModule, ModuleContext, ModuleType, PermissionDescriptor};
|
||||
use erp_core::events::EventBus;
|
||||
use crate::state::DialysisState;
|
||||
|
||||
pub struct DialysisModule {
|
||||
state: DialysisState,
|
||||
}
|
||||
|
||||
impl DialysisModule {
|
||||
pub fn new(db: sea_orm::DatabaseConnection, event_bus: EventBus) -> Self {
|
||||
Self { state: DialysisState { db, event_bus } }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ErpModule for DialysisModule {
|
||||
fn name(&self) -> &str { "透析管理" }
|
||||
fn id(&self) -> &str { "erp-dialysis" }
|
||||
fn version(&self) -> &str { "0.1.0" }
|
||||
fn module_type(&self) -> ModuleType { ModuleType::Builtin }
|
||||
|
||||
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||||
vec![
|
||||
PermissionDescriptor { code: "dialysis.record.list".into(), name: "查看透析记录".into() },
|
||||
PermissionDescriptor { code: "dialysis.record.manage".into(), name: "管理透析记录".into() },
|
||||
PermissionDescriptor { code: "dialysis.prescription.list".into(), name: "查看透析处方".into() },
|
||||
PermissionDescriptor { code: "dialysis.prescription.manage".into(), name: "管理透析处方".into() },
|
||||
]
|
||||
}
|
||||
|
||||
fn on_startup(&self, ctx: &ModuleContext) {
|
||||
crate::event::register_handlers_with_state(self.state.clone());
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 注册到 workspace**
|
||||
|
||||
在根 `Cargo.toml` 的 members 中添加 `"crates/erp-dialysis"`。
|
||||
|
||||
- [ ] **Step 4: 编译验证**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-dialysis
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(dialysis): 创建 erp-dialysis crate 骨架 + ErpModule 实现"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 迁移透析 Entity + Service + Handler + DTO
|
||||
|
||||
**Files:**
|
||||
- Move: 6 个文件从 `erp-health` → `erp-dialysis`
|
||||
- Modify: `crates/erp-health/src/{entity,service,handler,dto}/mod.rs`(删除透析导出)
|
||||
- Modify: 迁移文件中的 `crate::` 引用改为 `erp_core::` 或 erp-dialysis 内部引用
|
||||
|
||||
**待迁移文件清单:**
|
||||
|
||||
| 来源 | 目标 | 行数 |
|
||||
|------|------|------|
|
||||
| `erp-health/src/entity/dialysis_record.rs` | `erp-dialysis/src/entity/` | 82 |
|
||||
| `erp-health/src/entity/dialysis_prescription.rs` | `erp-dialysis/src/entity/` | 78 |
|
||||
| `erp-health/src/service/dialysis_service.rs` | `erp-dialysis/src/service/` | 333 |
|
||||
| `erp-health/src/service/dialysis_prescription_service.rs` | `erp-dialysis/src/service/` | 274 |
|
||||
| `erp-health/src/handler/dialysis_handler.rs` | `erp-dialysis/src/handler/` | 145 |
|
||||
| `erp-health/src/handler/dialysis_prescription_handler.rs` | `erp-dialysis/src/handler/` | 120 |
|
||||
| `erp-health/src/dto/dialysis_dto.rs` | `erp-dialysis/src/dto/` | 125 |
|
||||
| `erp-health/src/dto/dialysis_prescription_dto.rs` | `erp-dialysis/src/dto/` | 107 |
|
||||
|
||||
- [ ] **Step 1: 复制文件到 erp-dialysis**
|
||||
|
||||
```bash
|
||||
# Entity
|
||||
cp crates/erp-health/src/entity/dialysis_record.rs crates/erp-dialysis/src/entity/
|
||||
cp crates/erp-health/src/entity/dialysis_prescription.rs crates/erp-dialysis/src/entity/
|
||||
# Service
|
||||
cp crates/erp-health/src/service/dialysis_service.rs crates/erp-dialysis/src/service/
|
||||
cp crates/erp-health/src/service/dialysis_prescription_service.rs crates/erp-dialysis/src/service/
|
||||
# Handler
|
||||
cp crates/erp-health/src/handler/dialysis_handler.rs crates/erp-dialysis/src/handler/
|
||||
cp crates/erp-health/src/handler/dialysis_prescription_handler.rs crates/erp-dialysis/src/handler/
|
||||
# DTO
|
||||
cp crates/erp-health/src/dto/dialysis_dto.rs crates/erp-dialysis/src/dto/
|
||||
cp crates/erp-health/src/dto/dialysis_prescription_dto.rs crates/erp-dialysis/src/dto/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新 crate 内引用**
|
||||
|
||||
全局替换:
|
||||
- `crate::state::HealthState` → `crate::state::DialysisState`
|
||||
- `crate::error::{HealthError, HealthResult}` → `crate::error::{DialysisError, DialysisResult}`
|
||||
- `crate::entity::` → 保持不变(同 crate 内)
|
||||
- `crate::dto::` → 保持不变
|
||||
- `crate::service::` → 保持不变
|
||||
|
||||
- [ ] **Step 3: 创建 error.rs**
|
||||
|
||||
```rust
|
||||
// crates/erp-dialysis/src/error.rs
|
||||
use erp_core::error::AppError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DialysisError {
|
||||
#[error("透析记录未找到: {0}")]
|
||||
RecordNotFound(uuid::Uuid),
|
||||
#[error("处方未找到: {0}")]
|
||||
PrescriptionNotFound(uuid::Uuid),
|
||||
#[error("状态转换无效: {0} → {1}")]
|
||||
InvalidStatusTransition(String, String),
|
||||
#[error("版本冲突")]
|
||||
VersionConflict,
|
||||
}
|
||||
|
||||
impl From<DialysisError> for AppError {
|
||||
fn from(e: DialysisError) -> Self { AppError::Business(e.to_string()) }
|
||||
}
|
||||
|
||||
pub type DialysisResult<T> = Result<T, DialysisError>;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 从 erp-health 删除透析代码**
|
||||
|
||||
从以下 mod.rs 中移除透析相关 `pub mod` 声明:
|
||||
- `crates/erp-health/src/entity/mod.rs`
|
||||
- `crates/erp-health/src/service/mod.rs`
|
||||
- `crates/erp-health/src/handler/mod.rs`
|
||||
- `crates/erp-health/src/dto/mod.rs`
|
||||
|
||||
- [ ] **Step 5: 在 erp-server 注册新模块**
|
||||
|
||||
在 `crates/erp-server/src/main.rs` 中:
|
||||
- 添加 `use erp_dialysis::DialysisModule;`
|
||||
- 在 registry 链中 `.register(dialysis_module)`
|
||||
- 在路由 merge 中 `.merge(erp_dialysis::DialysisModule::protected_routes())`
|
||||
|
||||
- [ ] **Step 6: 编译 + 全链路验证**
|
||||
|
||||
```bash
|
||||
cargo check --workspace
|
||||
# 启动后端,验证透析相关 API 正常
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor: 透析模块拆分为独立 erp-dialysis crate(2 Entity + 2 Service)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: P1 事件消费者补全
|
||||
|
||||
### Task 6: patient.created → 欢迎消息 + 默认随访
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-health/src/event.rs`(添加消费者)
|
||||
|
||||
- [ ] **Step 1: 在 register_handlers_with_state 中添加 patient.created 消费者**
|
||||
|
||||
```rust
|
||||
// 在 register_handlers_with_state() 中新增:
|
||||
let (mut patient_rx, _) = state.event_bus.subscribe_filtered("patient.".to_string());
|
||||
let patient_db = state.db.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match patient_rx.recv().await {
|
||||
Some(event) if event.event_type == PATIENT_CREATED => {
|
||||
if erp_core::events::is_event_processed(&patient_db, event.id, "patient_welcome").await.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| Uuid::parse_str(s).ok());
|
||||
if let Some(pid) = patient_id {
|
||||
// 1. 发布欢迎消息事件(消息模块消费后发送站内通知)
|
||||
let welcome_event = DomainEvent::new(
|
||||
"message.send",
|
||||
event.tenant_id,
|
||||
erp_core::events::build_event_payload(serde_json::json!({
|
||||
"template": "patient_welcome",
|
||||
"recipient_type": "patient",
|
||||
"recipient_id": pid,
|
||||
})),
|
||||
);
|
||||
// 2. TODO: 创建默认随访计划(后续迭代)
|
||||
tracing::info!(patient_id = %pid, "新患者欢迎流程触发");
|
||||
}
|
||||
let _ = erp_core::events::mark_event_processed(&patient_db, event.id, "patient_welcome").await;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译验证**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-health
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(health): patient.created 消费者 — 新患者欢迎消息"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: appointment.confirmed/cancelled → 通知 + 号源
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-health/src/event.rs`
|
||||
|
||||
- [ ] **Step 1: 添加 appointment 事件消费者**
|
||||
|
||||
```rust
|
||||
let (mut appt_rx, _) = state.event_bus.subscribe_filtered("appointment.".to_string());
|
||||
let appt_db = state.db.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match appt_rx.recv().await {
|
||||
Some(event) if event.event_type == "appointment.confirmed" => {
|
||||
if erp_core::events::is_event_processed(&appt_db, event.id, "appointment_notifier").await.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
// 通知医生
|
||||
let doctor_id = event.payload.get("doctor_id").and_then(|v| v.as_str());
|
||||
let patient_id = event.payload.get("patient_id").and_then(|v| v.as_str());
|
||||
if let (Some(did), Some(pid)) = (doctor_id, patient_id) {
|
||||
tracing::info!(doctor_id = did, patient_id = pid, "预约确认通知触发");
|
||||
// 发布通知事件
|
||||
}
|
||||
let _ = erp_core::events::mark_event_processed(&appt_db, event.id, "appointment_notifier").await;
|
||||
}
|
||||
Some(event) if event.event_type == "appointment.cancelled" => {
|
||||
// 释放号源 + 通知排队患者
|
||||
tracing::info!(event_id = %event.id, "预约取消,号源释放");
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译 + 提交**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-health
|
||||
git commit -m "feat(health): appointment 事件消费者 — 预约确认/取消通知"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: follow_up.overdue → 升级通知
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-health/src/event.rs`
|
||||
|
||||
- [ ] **Step 1: 添加 follow_up.overdue 消费者**
|
||||
|
||||
```rust
|
||||
// 在 register_handlers_with_state 中:
|
||||
// 注意:follow_up 事件的前缀是 "follow_up."
|
||||
let (mut fu_rx, _) = state.event_bus.subscribe_filtered("follow_up.".to_string());
|
||||
let fu_db = state.db.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match fu_rx.recv().await {
|
||||
Some(event) if event.event_type == FOLLOW_UP_OVERDUE => {
|
||||
if erp_core::events::is_event_processed(&fu_db, event.id, "follow_up_escalator").await.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let task_id = event.payload.get("task_id").and_then(|v| v.as_str());
|
||||
let assigned_to = event.payload.get("assigned_to").and_then(|v| v.as_str());
|
||||
if let (Some(tid), Some(uid)) = (task_id, assigned_to) {
|
||||
// 通知随访负责人 + 科室主管
|
||||
tracing::warn!(task_id = tid, assigned_to = uid, "随访逾期升级通知");
|
||||
}
|
||||
let _ = erp_core::events::mark_event_processed(&fu_db, event.id, "follow_up_escalator").await;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译 + 提交**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-health
|
||||
git commit -m "feat(health): follow_up.overdue 消费者 — 逾期随访升级通知"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
| Chunk | Tasks | 内容 | 预估 |
|
||||
|-------|-------|------|------|
|
||||
| 1 | T1-T3 | WASM 评估量表插件(PHQ-9) | 1-2 天 |
|
||||
| 2 | T4-T5 | 透析模块拆 erp-dialysis | 1 天 |
|
||||
| 3 | T6-T8 | P1 事件消费者补全(3 个) | 0.5-1 天 |
|
||||
|
||||
**总计 8 个 Task,预估 2.5-4 天。**
|
||||
|
||||
**依赖关系:**
|
||||
- T1→T2→T3 串行(WASM 插件逐层构建)
|
||||
- T4→T5 串行(先骨架再迁移)
|
||||
- T6/T7/T8 可并行(独立消费者)
|
||||
- Chunk 1/2/3 相互独立,可完全并行
|
||||
|
||||
**与技术债计划的关系:**
|
||||
- 本计划的 Chunk 3(事件消费者)应在技术债批次 B(EventBus dead-letter)之后执行
|
||||
- Chunk 2(透析拆分)应在技术债批次 A(安全修复)之后执行,避免合并冲突
|
||||
- Chunk 1(WASM 插件)完全独立,随时可执行
|
||||
@@ -0,0 +1,346 @@
|
||||
# V1 客户演示方案设计规格
|
||||
|
||||
> 日期: 2026-05-09 | 状态: Draft v2 | 类型: 演示方案
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 为什么做这次演示
|
||||
|
||||
HMS 健康管理平台已完成核心功能开发(700+ 次提交,V2 审计 85% 完成度),进入 V1 发布阶段。需要面向潜在客户(体检中心/血透中心)进行产品演示,目标是:
|
||||
|
||||
- **打动决策者签约** — 展示业务价值,而非功能清单
|
||||
- **收集真实反馈** — 了解客户实际工作流中的痛点,指导 V2 迭代
|
||||
- **验证产品定位** — 确认「AI 驱动主动关怀引擎」的定位是否与客户需求匹配
|
||||
|
||||
### 1.2 当前系统状态
|
||||
|
||||
| 指标 | 状态 |
|
||||
|------|------|
|
||||
| 核心链路 | 11 条端到端链路已验证通过 |
|
||||
| 已知 CRITICAL | 1 个未修复(Token 刷新竞态);其余 CRITICAL(告警权限码拼写、晚间血压丢失、仪表盘 500)均已修复 |
|
||||
| 角色测试通过率 | 84.6%(R01-R05) |
|
||||
| Web 前端 | 55 路由,283 文件,最完整的端 |
|
||||
| 小程序 | 59 页面,118 文件,代码完整 |
|
||||
| AI 模块 | 已对接 Ollama qwen3:4b,SSE 分析可用 |
|
||||
|
||||
### 1.3 演示策略
|
||||
|
||||
- **质量优先** — 修完所有已知问题再发布
|
||||
- **故事线驱动** — 用一个患者的 30 天管理历程展示完整闭环
|
||||
- **单患者深度** — 而非多角色广度,降低演示事故风险
|
||||
|
||||
## 2. 演示信息
|
||||
|
||||
| 项 | 值 |
|
||||
|------|------|
|
||||
| 受众 | 机构决策层 + 医疗团队 |
|
||||
| 时长 | 30-40 分钟 |
|
||||
| 视角 | 患者旅程(张大爷的 30 天) |
|
||||
| 涉及端 | Web 管理端(主力)+ 微信小程序(辅助) |
|
||||
| 涉及角色 | 护士、AI、医生、患者、健康管理师、管理员 |
|
||||
|
||||
## 3. 准备清单
|
||||
|
||||
### 3.1 测试账号
|
||||
|
||||
| 账号 | 角色 | 密码 | 用途 |
|
||||
|------|------|------|------|
|
||||
| `admin` | 管理员 | `Admin@2026` | 场景 7 仪表盘 |
|
||||
| `doctor1` | 医生 | `Admin@2026` | 场景 3 医生审批 |
|
||||
| `nurse1` | 护士 | `Admin@2026` | 场景 1 建档 + 场景 5 告警处理 |
|
||||
| `health_mgr` | 健康管理师 | `Admin@2026` | 场景 6 随访执行 |
|
||||
| `zhang_daye` | 患者(小程序) | 微信登录 | 场景 4/5 患者端操作 |
|
||||
|
||||
### 3.2 预置测试数据
|
||||
|
||||
| 数据 | 说明 | 目的 |
|
||||
|------|------|------|
|
||||
| 患者档案(张大爷) | 张建国,65岁,男,CKD 3期 | 主角 |
|
||||
| 历史化验单 ×2 | 肌酐 88→102 μmol/L 的趋势 | AI 分析需要历史对比发现趋势 |
|
||||
| 随访模板 | "慢性肾病定期随访"模板 | 场景 3 医生一键生成随访 |
|
||||
| 告警规则 | 肌酐>120 或 收缩压>160 | 场景 5 触发告警 |
|
||||
| 健康科普文章 ×3 | CKD 饮食/运动/用药 | 场景 4 小程序内容展示 |
|
||||
|
||||
### 3.3 环境检查
|
||||
|
||||
| 检查项 | 方法 | 通过标准 |
|
||||
|--------|------|----------|
|
||||
| 后端服务 | `cargo run` | 无 panic,Swagger 可访问 |
|
||||
| Web 前端 | `pnpm dev` | 登录页正常加载 |
|
||||
| 小程序 | 微信开发者工具 | 真机预览可扫码 |
|
||||
| 数据库 | 迁移已执行 | 预置数据查询无空结果 |
|
||||
| AI 模块 | Ollama 运行中 | SSE 分析端点可返回结果 |
|
||||
| 浏览器 | Chrome 无痕模式 | 干净环境,无缓存干扰 |
|
||||
|
||||
### 3.4 风险预案
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|----------|
|
||||
| AI 分析响应慢/失败 | 预先跑一次分析,截图备用;口头说明"云端大模型更快" |
|
||||
| 小程序真机扫码失败 | 准备 15 秒录屏视频展示关键页面 |
|
||||
| 后端服务崩溃 | 演示前重启一次确保干净状态 |
|
||||
| 数据库连接断开 | 提前验证 Docker PostgreSQL 健康状态 |
|
||||
| 告警权限码 bug | 演示前验证 AlertDashboard.tsx 权限码已修复(`health.alerts.manage`) |
|
||||
| SSE 长连接断开 | 录制 30 秒 AI 分析过程视频备用 |
|
||||
| Ollama 模型未加载 | 环境检查清单加入 `ollama list` 确认 qwen3:4b 已就绪 |
|
||||
| 多角色登录冲突 | 使用多个 Chrome Profile,每个角色一个独立 Profile |
|
||||
| 演示超时 | 标注可跳过场景(场景 6 可一句话带过) |
|
||||
|
||||
### 3.5 硬件与网络要求
|
||||
|
||||
| 项 | 要求 |
|
||||
|------|------|
|
||||
| 投影仪/大屏 | 分辨率 ≥ 1920x1080 |
|
||||
| 网络 | 演示机器与服务器在同一局域网,延迟 < 10ms |
|
||||
| 浏览器 | Chrome ×2 个 Profile(Web 端两个角色并行),或双屏方案 |
|
||||
| 手机 | 安装微信,可扫小程序码(备用:开发者工具投屏) |
|
||||
| 服务器 | 后端 + PostgreSQL + Ollama 运行在同一台机器,避免网络依赖 |
|
||||
|
||||
### 3.6 角色切换指引
|
||||
|
||||
| 切换点 | 操作 | 预计耗时 |
|
||||
|--------|------|----------|
|
||||
| 场景 1→2 | nurse1 退出 → admin 登录 | 15 秒 |
|
||||
| 场景 2→3 | admin 退出 → doctor1 登录 | 15 秒 |
|
||||
| 场景 3→4 | Web → 微信开发者工具/手机 | 10 秒 |
|
||||
| 场景 4→5 | 小程序录入 → Web nurse1 告警 | 10 秒 |
|
||||
| 场景 5→6 | nurse1 退出 → health_mgr 登录 | 15 秒 |
|
||||
| 场景 6→7 | health_mgr 退出 → admin 登录 | 15 秒 |
|
||||
|
||||
**建议:** 准备 2 个 Chrome Profile(Profile A: nurse1/admin,Profile B: doctor1/health_mgr),减少登录切换。场景 4/5 用独立手机或开发者工具。总切换时间约 1-1.5 分钟。
|
||||
|
||||
## 4. 演示脚本
|
||||
|
||||
### 开场(2 分钟)
|
||||
|
||||
**话术:**
|
||||
> "体检中心最大的痛点是什么?患者体检完,就走了。没有后续管理,没有随访跟进,体检数据躺在系统里没人看。今天给大家演示 HMS 健康管理平台如何解决这个问题——用一个真实场景:张大爷来体检后,系统如何帮他做 30 天的持续健康管理。"
|
||||
|
||||
---
|
||||
|
||||
### 场景 1:张大爷来体检(Day 1 上午)— 护士视角
|
||||
|
||||
**登录:** Web 端 `nurse1` | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 登录后展示护士工作台首页 — 一眼看到今日待办
|
||||
2. 点击「患者管理」→「新建患者」
|
||||
3. 填入:张建国 / 男 / 65岁 / 手机号 / 慢性肾病3期(诊断标签)
|
||||
4. 保存 → 跳转患者详情页
|
||||
5. 在患者详情页点击「体征录入」→ 录入血压 142/88、心率 72、空腹血糖 5.8
|
||||
6. 点击「化验报告」→ 上传预置的化验单图片,显示肌酐值 102 μmol/L
|
||||
|
||||
**话术:**
|
||||
> "张大爷第一次来体检中心。以前护士拿纸质表格登记,现在 30 秒建档。体征数据和化验报告立刻进入系统。"
|
||||
|
||||
**突出能力:** 快速建档、结构化体征录入、化验单数字化
|
||||
|
||||
---
|
||||
|
||||
### 场景 2:AI 自动分析(Day 1 下午)— 系统自动触发
|
||||
|
||||
**登录:** `admin` 或任意管理端账号 | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 展示「AI 分析」页面 → 显示张大爷的分析结果
|
||||
2. 点击分析详情 → 展示 AI 输出:
|
||||
- "肌酐值 88→102 μmol/L,3 个月持续上升趋势"
|
||||
- "建议:加做肾功能全套检查,排除 CKD 进展"
|
||||
- 风险等级:中风险(黄色标签)
|
||||
3. 切到 AI 建议列表 → 展示系统自动生成的「建议加做肾功能检查」行动项
|
||||
|
||||
**话术:**
|
||||
> "护士录入完数据,系统后台自动跑 AI 分析。不需要医生手动触发。AI 发现张大爷肌酐 3 个月在涨,主动建议进一步检查。这就是我们说的「主动关怀」——不是等患者出问题才看,是系统帮你盯着。"
|
||||
|
||||
**突出能力:** AI 自动分析、趋势发现、主动建议生成
|
||||
|
||||
**重要说明:** 当前 Web 端 AI 分析触发入口有限(审计报告指出"仅历史查看有 UI,分析触发无入口")。演示前**必须**执行以下操作之一:
|
||||
- 方案 A:演示前通过 API 手动触发一次分析(`POST /api/v1/ai/analysis/...`),演示时展示已生成的结果
|
||||
- 方案 B:为演示临时添加一个「触发分析」按钮到患者详情页
|
||||
- 推荐方案 A,配合话术调整:"这是系统刚才自动生成的分析结果"
|
||||
|
||||
**预案:** AI 分析慢或失败 → 展示预置截图,口头说明"接入云端大模型后速度更快"
|
||||
|
||||
---
|
||||
|
||||
### 场景 3:医生一秒决策(Day 3)— 医生视角
|
||||
|
||||
**登录:** Web 端 `doctor1` | **时长:** ~5 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 展示医生工作台 → 待办区域显示"1 条 AI 建议待审批"
|
||||
2. 点击进入 → 查看 AI 分析详情 + 患者历史数据
|
||||
3. 点击「同意建议」→ 系统自动:
|
||||
- 生成随访任务("肾功能复查随访",2 周后到期)
|
||||
- 推送小程序消息给患者
|
||||
4. 展示随访任务列表 → 新任务已创建
|
||||
5. 点击「预约管理」→ 演示为张大爷预约复查(选医生、选时间段、确认)
|
||||
|
||||
**话术:**
|
||||
> "李医生早上打开系统,看到 AI 昨天的分析建议。以前要翻纸质报告、手动比对数据,现在 AI 已经帮你分析好了,医生只需要做一个决策:同意还是不同意。点一下,系统自动安排随访、自动通知患者。"
|
||||
|
||||
**突出能力:** AI 辅助决策、一键生成随访、自动通知患者
|
||||
|
||||
---
|
||||
|
||||
### 场景 4:张大爷在家收到提醒(Day 7)— 小程序视角
|
||||
|
||||
**操作:** 微信开发者工具或真机预览 | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 打开小程序首页 → 展示今日摘要:1 条随访待办 + 1 篇健康科普
|
||||
2. 点击「消息」Tab → 显示"您有一条新的随访任务"
|
||||
3. 点击进入随访详情 → 显示随访问卷(饮食情况、用药依从性、症状变化)
|
||||
4. 快速填写 2-3 项 → 提交
|
||||
5. 切回「健康」Tab → 展示张大爷的体征趋势图(血压曲线、肌酐趋势)
|
||||
6. 展示 AI 建议卡片:"您的血压近一周有上升趋势,建议减少盐分摄入"
|
||||
|
||||
**话术:**
|
||||
> "张大爷在家打开手机,不用打电话、不用跑医院,系统自动提醒他有随访要完成。填个问卷 2 分钟,医生那边就能看到。趋势图也让他自己看到身体变化,比口头解释直观得多。"
|
||||
|
||||
**突出能力:** 小程序主动提醒、随访问卷、趋势可视化、AI 健康建议触达
|
||||
|
||||
**预案:** 真机失败 → 播放 15 秒小程序录屏,重点展示随访提醒和趋势图
|
||||
|
||||
---
|
||||
|
||||
### 场景 5:危急值告警(Day 14)— 护士 + 系统联动
|
||||
|
||||
**操作:** 先小程序,再切 Web 端 | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. **小程序端**(快速操作):张大爷录入血压 168/95 → 提交
|
||||
2. **切到 Web 端**(`nurse1` 登录):
|
||||
- 顶部弹出告警通知 "危急值告警:张建国 收缩压 168mmHg"
|
||||
- 点击进入告警列表 → 红色高亮显示
|
||||
- 点击告警详情 → 展示:触发规则(收缩压>160)、当前值、历史趋势
|
||||
- 点击「确认」→ 状态变为"已确认"
|
||||
- 点击「处理」→ 录入处理备注:"已电话通知患者,建议立即到门诊"
|
||||
- 状态变为"已处理"
|
||||
3. **回到小程序端**:张大爷收到消息"您的血压偏高,李医生建议您尽快来院检查"
|
||||
|
||||
**话术:**
|
||||
> "张大爷在家量了个血压,168。以前这种情况没人知道,可能拖到下次复诊才发现。现在数据一传上来,护士工作站立刻弹告警。护士确认后打电话给患者,15 分钟内完成从发现到处理。这才是真正的「主动关怀」。"
|
||||
|
||||
**突出能力:** 实时告警、分级处理、跨端联动(小程序录入→Web 告警→小程序反馈)
|
||||
|
||||
---
|
||||
|
||||
### 场景 6:随访闭环(Day 21)— 健康管理师视角
|
||||
|
||||
**登录:** Web 端 `health_mgr` | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 展示健康管理师工作台 → 随访任务列表显示"张建国 - 肾功能复查随访 - 即将到期"
|
||||
2. 点击执行随访 → 选择"电话随访"
|
||||
3. 录入随访记录:
|
||||
- 患者状态:"已完成肾功能检查,肌酐降至 98"
|
||||
- 遵医行为:"按时服药,控制饮食"
|
||||
- 下一步:"继续观察,3 个月后复查"
|
||||
4. 提交 → 随访状态变为"已完成"
|
||||
5. 展示随访历史时间线 → Day 3 创建 → Day 7 问卷 → Day 21 电话随访,完整记录
|
||||
|
||||
**话术:**
|
||||
> "30 天的管理周期里,每一步都有记录。从 AI 发现问题、医生决策、患者问卷、到健康管理师电话回访,全部可追溯。卫健委来检查,一导出就是完整的健康管理档案。"
|
||||
|
||||
**突出能力:** 随访全流程记录、可追溯、健康管理闭环
|
||||
|
||||
---
|
||||
|
||||
### 场景 7:数据说话(Day 30)— 管理员视角
|
||||
|
||||
**登录:** Web 端 `admin` | **时长:** ~3 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 展示运营仪表盘:
|
||||
- 本月管理患者数
|
||||
- 随访完成率
|
||||
- AI 分析覆盖率
|
||||
- 告警响应平均时间
|
||||
2. 展示趋势图:患者增长曲线、随访完成率趋势
|
||||
3. 切到「内容管理」→ 展示已发布的健康科普文章(阅读量、转发量)
|
||||
4. 切到「积分商城」→ 展示患者积分排行、兑换记录
|
||||
|
||||
**话术:**
|
||||
> "张大爷的故事不是个例。系统帮你管每一个患者,而且每一步都有数据。随访完成率从手工追踪的 40% 提升到系统化管理后能做到 80% 以上。这些数据就是你们向卫健委、向患者证明管理质量的最好证据。"
|
||||
|
||||
**突出能力:** 运营数据可视化、管理质量量化、内容运营
|
||||
|
||||
**重要说明:** 单个患者(张大爷)的数据不足以支撑仪表盘的说服力。演示前**必须**预置 20-30 个背景患者数据 + 若干随访/告警记录,让仪表盘显示有意义的统计数字。在数据预置脚本中一并处理。
|
||||
|
||||
---
|
||||
|
||||
### 收尾(5 分钟)
|
||||
|
||||
**总结话术:**
|
||||
> "总结一下 HMS 带来的三个核心变化:
|
||||
> 1. **从被动到主动** — AI 帮你看数据,系统帮你盯着患者
|
||||
> 2. **从纸质到数字** — 每一步可追溯,检查随时可导出
|
||||
> 3. **从单点到闭环** — 体检不是终点,30 天持续管理才是"
|
||||
|
||||
**收集反馈(3 个问题):**
|
||||
1. "您刚才看到的流程中,哪些环节对您机构最有价值?"
|
||||
2. "有没有我们没覆盖到、但您实际工作中很重要的场景?"
|
||||
3. "您更关心 Web 管理端还是患者小程序端的能力?"
|
||||
|
||||
---
|
||||
|
||||
## 5. V1 发布前必修项
|
||||
|
||||
### 5.1 必修(阻塞发布)
|
||||
|
||||
| # | 问题 | 修复方案 | 工作量估计 |
|
||||
|---|------|----------|-----------|
|
||||
| 1 | Token 刷新并发竞态 | refresh 流程加事务 + SELECT FOR UPDATE | 0.5 天 |
|
||||
|
||||
### 5.2 建议修(提升演示体验)
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | AI 分析预置截图 | 演示前手动跑一次分析,截图备用 |
|
||||
| 2 | 小程序录屏视频 | 15 秒展示随访提醒 + 趋势图 |
|
||||
| 3 | 测试数据脚本 | 一键预置张大爷的完整数据 |
|
||||
| 4 | 演示前全链路冒烟 | 跑一遍 7 个场景确认无阻塞 |
|
||||
|
||||
## 6. 下一步演化方向(演示后收集)
|
||||
|
||||
| 方向 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| HIS 系统集成 | 场景 1 | 演示后可能被问"能不能对接我们现有 HIS" |
|
||||
| 报告导出 | 场景 6 | 卫健委检查需要标准格式报告 |
|
||||
| 多科室支持 | 客户反馈 | 当前以肾病/体检为主,其他科室扩展 |
|
||||
| 微信服务号推送 | 场景 4 | 小程序消息触达有限,服务号更灵活 |
|
||||
| 设备直连 | 场景 5 | 血压计/血糖仪 BLE 直连小程序 |
|
||||
|
||||
## 7. Q&A 异议处理
|
||||
|
||||
### 客户可能提出的问题及建议回答
|
||||
|
||||
**Q: 能不能对接我们现有的 HIS/EMR 系统?**
|
||||
> HMS 提供标准 FHIR R4 接口和 RESTful API,支持 HL7 标准数据交换。具体集成方案需要了解贵院 HIS 的品牌和版本,我们可以安排技术团队做接口评估。通常 2-4 周可以完成基础对接。
|
||||
|
||||
**Q: 患者数据安全如何保障?**
|
||||
> 数据存储采用 PII 加密(姓名/身份证/手机号等敏感字段加密存储),多租户隔离确保不同机构数据完全独立。系统支持私有化部署,数据不出院。后端使用 Rust 语言开发,天然免疫内存安全漏洞。
|
||||
|
||||
**Q: AI 分析的准确率如何?**
|
||||
> 当前 AI 模块定位是「辅助筛查」,发现异常趋势后由医生做最终决策。不是替代医生诊断,而是帮医生从海量数据中找到需要关注的患者。所有 AI 建议都需要医生审批才生效。
|
||||
|
||||
**Q: 部署方式有哪些?**
|
||||
> 支持 SaaS(按年付费,我们运维)和私有化部署(一次性 + 年维护费,部署在客户服务器)。SaaS 适合快速上线,私有化适合数据合规要求高的机构。
|
||||
|
||||
**Q: 价格怎么算?**
|
||||
> 根据机构规模(管理患者数、医护账号数)定制方案。演示后我们可以根据贵院的具体需求出一份详细报价。
|
||||
|
||||
**Q: 医护人员需要培训多久?**
|
||||
> 系统设计遵循「零培训」理念——医生工作台只展示待办,护士录入界面跟纸质表单一样直观。通常 30 分钟上手,1 天熟练。我们提供远程培训和操作手册。
|
||||
|
||||
## 8. DRY RUN 计划
|
||||
|
||||
| 阶段 | 时间 | 内容 |
|
||||
|------|------|------|
|
||||
| D-7 | 演示前 7 天 | 修完 P0 问题(Token 刷新、AI 触发入口验证) |
|
||||
| D-5 | 演示前 5 天 | 编写数据预置脚本,预置张大爷完整数据 |
|
||||
| D-3 | 演示前 3 天 | 第一次 DRY RUN:完整走 7 个场景,记录阻塞点 |
|
||||
| D-2 | 演示前 2 天 | 修复 DRY RUN 发现的问题,预置 20-30 个背景患者数据 |
|
||||
| D-1 | 演示前 1 天 | 第二次 DRY RUN(带投影/网络),确认全链路无阻塞 |
|
||||
| D-Day | 演示当天 | 提前 1 小时启动环境,30 分钟前最终冒烟 |
|
||||
@@ -0,0 +1,476 @@
|
||||
# V1 客户演示准备 — 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 修复已知 CRITICAL 问题,预置演示数据,完成 DRY RUN 验证,确保 V1 客户演示 7 个场景端到端无阻塞。
|
||||
|
||||
**Architecture:** 按依赖关系分 6 个 Task:先修 CRITICAL(Token 竞态),再验证关键链路(告警、AI),然后预置数据,最后全链路冒烟。每个 Task 独立可提交。
|
||||
|
||||
**Tech Stack:** Rust (SeaORM + Axum), TypeScript/React (Web 前端), SQL (数据预置), Taro (小程序)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-09-v1-customer-demo-plan-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Modify | `crates/erp-auth/src/service/token_service.rs:156-176` | revoke 改为原子操作 |
|
||||
| Modify | `crates/erp-auth/src/service/auth_service.rs:187-258` | refresh 流程使用原子 revoke |
|
||||
| Create | `crates/erp-server/tests/integration/auth_concurrent_tests.rs` | 并发刷新测试 |
|
||||
| Create | `scripts/demo-seed.sql` | 演示数据预置脚本 |
|
||||
| Verify | `crates/erp-health/src/service/seed.rs` | 确认告警规则覆盖演示场景 |
|
||||
| Verify | `apps/web/src/pages/health/components/LabReportsTab.tsx:36-57` | 确认 AI 触发按钮可用 |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Token 刷新竞态修复
|
||||
|
||||
### Task 1: 修复 Token 刷新并发竞态(CRITICAL)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/service/token_service.rs` — 新增 `revoke_by_hash_atomic` 方法
|
||||
- Modify: `crates/erp-auth/src/service/auth_service.rs:193-197` — refresh 中改用原子操作
|
||||
- Create: `crates/erp-server/tests/integration/auth_concurrent_tests.rs`
|
||||
|
||||
**设计说明:** JWT claims 中没有 token 数据库 ID(`id` 列),只有 `sub`(user_id) 和 `tid`(tenant_id)。因此原子 CAS 应该使用 `token_hash` 作为匹配条件——先用 JWT 解码获取原始 token,计算 SHA-256 哈希,再用 `UPDATE WHERE token_hash = ? AND revoked_at IS NULL` 做原子操作。这样不需要修改 JWT 结构。
|
||||
|
||||
- [ ] **Step 1: 在 token_service.rs 新增 `revoke_by_hash_atomic` 方法**
|
||||
|
||||
在 `crates/erp-auth/src/service/token_service.rs` 第 176 行(`revoke_token` 方法之后)新增:
|
||||
|
||||
```rust
|
||||
/// 原子操作:通过 token_hash 验证并撤销 refresh token。
|
||||
/// 如果 token 已被撤销(rows_affected == 0),返回 AuthError::TokenRevoked。
|
||||
pub async fn revoke_by_hash_atomic(
|
||||
db: &DatabaseConnection,
|
||||
token_hash: &str,
|
||||
user_id: Uuid,
|
||||
) -> AuthResult<()> {
|
||||
use user_token::Entity as UserToken;
|
||||
let result = UserToken::update_many()
|
||||
.col_expr(
|
||||
user_token::Column::RevokedAt,
|
||||
sea_orm::sea_query::Expr::value(Some(chrono::Utc::now().naive_utc())),
|
||||
)
|
||||
.filter(user_token::Column::TokenHash.eq(token_hash))
|
||||
.filter(user_token::Column::UserId.eq(user_id))
|
||||
.filter(user_token::Column::RevokedAt.is_null())
|
||||
.exec(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(AuthError::TokenRevoked);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
需要新增导入:`use sea_orm::sea_query::Expr;`(参考 `consultation_service.rs:683` 的模式)
|
||||
|
||||
- [ ] **Step 2: 改造 auth_service.rs 的 refresh 流程**
|
||||
|
||||
在 `crates/erp-auth/src/service/auth_service.rs:193-197`,将当前的 validate + revoke 两步替换:
|
||||
|
||||
```rust
|
||||
// 旧代码(第 193-197 行):
|
||||
// let claims = TokenService::validate_refresh_token(&self.token, &self.db).await?;
|
||||
// TokenService::revoke_token(&self.db, &claims.token_id, claims.user_id).await?;
|
||||
|
||||
// 新代码:
|
||||
// 1. JWT 解码获取 claims(不查数据库)
|
||||
let claims = TokenService::decode_refresh_token(&self.token)?;
|
||||
// 2. 计算 token 的 SHA-256 哈希
|
||||
let token_hash = TokenService::hash_token(&self.token);
|
||||
// 3. 原子操作:通过 hash 验证 + 撤销(CAS)
|
||||
TokenService::revoke_by_hash_atomic(&self.db, &token_hash, claims.sub.parse()?).await?;
|
||||
// 4. 后续:查询用户角色权限(第 200-201 行,不变)
|
||||
```
|
||||
|
||||
注意:需要确认 `decode_refresh_token`(仅 JWT 解码)和 `hash_token`(SHA-256 计算)是否已是公开方法。如果 `validate_refresh_token` 内部已有这些逻辑,需要拆分为独立方法。
|
||||
|
||||
- [ ] **Step 3: 编译检查**
|
||||
|
||||
Run: `cargo check --package erp-auth`
|
||||
Expected: 编译通过,无错误
|
||||
|
||||
- [ ] **Step 4: 写并发刷新测试**
|
||||
|
||||
在 `crates/erp-server/tests/integration/auth_concurrent_tests.rs` 中:
|
||||
|
||||
```rust
|
||||
use crate::test_db::TestDb;
|
||||
use erp_auth::service::auth_service::AuthService;
|
||||
use erp_core::config::JwtConfig;
|
||||
|
||||
async fn setup_test_user(db: &TestDb) -> (Uuid, String, String) {
|
||||
// 创建测试用户,返回 (user_id, access_token, refresh_token)
|
||||
// 复用现有集成测试中的用户创建逻辑
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_rotates_token() {
|
||||
let db = TestDb::new().await;
|
||||
let (_user_id, _, refresh_token) = setup_test_user(&db).await;
|
||||
let jwt_config = JwtConfig::default();
|
||||
|
||||
let svc = AuthService::new(db.conn(), &jwt_config);
|
||||
// 第一次 refresh → 成功
|
||||
let result = svc.refresh(&refresh_token).await;
|
||||
assert!(result.is_ok(), "第一次 refresh 应成功");
|
||||
let new_tokens = result.unwrap();
|
||||
// 用旧 refresh_token 再次 refresh → 必须失败
|
||||
let result2 = svc.refresh(&refresh_token).await;
|
||||
assert!(result2.is_err(), "旧 token 必须不可用");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_refresh_token_reuse() {
|
||||
let db = TestDb::new().await;
|
||||
let (_user_id, _, refresh_token) = setup_test_user(&db).await;
|
||||
let jwt_config = JwtConfig::default();
|
||||
|
||||
let svc = AuthService::new(db.conn(), &jwt_config);
|
||||
let token_clone = refresh_token.clone();
|
||||
let svc_clone = // 需要确认 AuthService 是否可 Clone 或用 Arc
|
||||
|
||||
// 使用 tokio::spawn 并发发两个 refresh
|
||||
let handle1 = tokio::spawn(async move { svc.refresh(&refresh_token).await });
|
||||
let handle2 = tokio::spawn(async move { svc_clone.refresh(&token_clone).await });
|
||||
|
||||
let r1 = handle1.await.unwrap();
|
||||
let r2 = handle2.await.unwrap();
|
||||
|
||||
// 恰好一个成功、一个失败
|
||||
let ok_count = [&r1, &r2].iter().filter(|r| r.is_ok()).count();
|
||||
assert_eq!(ok_count, 1, "并发 refresh 中恰好一个成功,另一个失败");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行全部认证测试**
|
||||
|
||||
Run: `cargo test --package erp-auth`
|
||||
Expected: 全部通过
|
||||
|
||||
Run: `cargo test --package erp-server --test integration auth`
|
||||
Expected: 全部通过
|
||||
|
||||
Run: `cargo test --package erp-server --test integration auth_concurrent -- --nocapture`
|
||||
Expected: 两个测试 PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/service/token_service.rs \
|
||||
crates/erp-auth/src/service/auth_service.rs \
|
||||
crates/erp-server/tests/integration/auth_concurrent_tests.rs
|
||||
git commit -m "fix(auth): 修复 Token 刷新并发竞态条件
|
||||
|
||||
使用原子 CAS(UPDATE WHERE token_hash = ? AND revoked_at IS NULL)
|
||||
替代先查后改的非原子操作,防止同一 refresh token 被并发使用两次。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 演示链路验证
|
||||
|
||||
### Task 2: 验证告警链路(场景 5 依赖)
|
||||
|
||||
**Files:**
|
||||
- Verify: `crates/erp-health/src/service/seed.rs` — 确认告警规则
|
||||
- Verify: `apps/web/src/pages/health/AlertDashboard.tsx:51` — 确认权限码
|
||||
- Verify: `crates/erp-health/src/handler/alert_handler.rs:82-115` — 确认操作端点
|
||||
|
||||
- [ ] **Step 1: 启动后端服务**
|
||||
|
||||
Run: `cd crates/erp-server && cargo run`
|
||||
Expected: 服务无 panic 启动在 localhost:3000
|
||||
|
||||
- [ ] **Step 2: 查询已有告警规则**
|
||||
|
||||
Run: `curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:3000/api/v1/health/alert-rules | jq '.data.items[] | {name, metric, operator, threshold}'`
|
||||
|
||||
Expected: 返回 10 条默认规则,包括:
|
||||
- 收缩压偏高 (>=140)
|
||||
- 收缩压危急 (>=180)
|
||||
|
||||
场景 5 需要"张大爷录入血压 168 触发告警"→ 使用已有的"收缩压危急 >=180"不够,需要调整场景 5 话术用血压 185,或添加一条 >=160 的规则。
|
||||
|
||||
- [ ] **Step 3: 手动测试告警触发**
|
||||
|
||||
```
|
||||
1. 以 nurse1 登录 Web
|
||||
2. 找到张大爷患者详情页
|
||||
3. 录入体征:收缩压 185 / 舒张压 95
|
||||
4. 切到告警仪表盘页面
|
||||
5. 确认出现告警条目
|
||||
6. 点击「确认」→ 状态变为已确认
|
||||
7. 点击「处理」→ 输入备注 → 状态变为已处理
|
||||
```
|
||||
|
||||
Expected: 全流程无 403、无 500
|
||||
|
||||
- [ ] **Step 4: 记录验证结果**
|
||||
|
||||
在文件头部注释验证结果。如果告警权限码正确(`health.alerts.manage`),记录为 ✅。
|
||||
如果发现任何问题,记录具体报错信息,新建 Task 修复。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 验证 AI 分析触发(场景 2 依赖)
|
||||
|
||||
**Files:**
|
||||
- Verify: `apps/web/src/pages/health/components/LabReportsTab.tsx:177-183`
|
||||
- Verify: `apps/web/src/api/ai/analysisSse.ts`
|
||||
|
||||
- [ ] **Step 1: 确认 Ollama 模型就绪**
|
||||
|
||||
Run: `ollama list`
|
||||
Expected: 输出包含 `qwen3:4b`
|
||||
|
||||
如果没有:`ollama pull qwen3:4b`
|
||||
|
||||
- [ ] **Step 2: 手动触发 AI 分析**
|
||||
|
||||
```
|
||||
1. 以 admin 登录 Web
|
||||
2. 进入张大爷患者详情页
|
||||
3. 切到「化验报告」Tab
|
||||
4. 找到一条化验报告
|
||||
5. 点击「AI 解读」按钮
|
||||
6. 等待 SSE 流式输出
|
||||
```
|
||||
|
||||
Expected: AI 分析结果流式显示,无 500 错误
|
||||
|
||||
如果 AI 解读按钮不存在或化验报告为空 → 使用预案(预置截图),在脚本中标注
|
||||
|
||||
- [ ] **Step 3: 预置 AI 分析截图(预案)**
|
||||
|
||||
如果 AI 分析成功:截图保存到 `docs/demo/screenshots/ai-analysis.png`
|
||||
如果 AI 分析失败:在实施计划中标注使用备用话术
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 验证 health_manager 测试账号
|
||||
|
||||
**Files:**
|
||||
- Verify: 数据库 `users` 表
|
||||
- Reference: `crates/erp-server/migration/src/m20260506_000125_restructure_menus_and_roles.rs:123-126`
|
||||
|
||||
- [ ] **Step 1: 查询 health_manager 角色是否存在**
|
||||
|
||||
Run: `docker exec erp-postgres psql -U erp -c "SELECT id, name, code FROM roles WHERE code = 'health_manager'"`
|
||||
|
||||
Expected: 返回 1 行
|
||||
|
||||
- [ ] **Step 2: 查询是否有测试用户关联此角色**
|
||||
|
||||
Run: `docker exec erp-postgres psql -U erp -c "SELECT u.username, r.code FROM users u JOIN user_roles ur ON u.id = ur.user_id JOIN roles r ON ur.role_id = r.id WHERE r.code = 'health_manager'"`
|
||||
|
||||
Expected: 返回至少 1 个用户
|
||||
|
||||
如果没有用户:需要通过 Web 管理界面创建一个 `health_mgr` 用户并分配 `health_manager` 角色
|
||||
|
||||
- [ ] **Step 3: 验证 health_manager 用户可登录**
|
||||
|
||||
用 health_manager 用户名 + `Admin@2026` 密码尝试登录 Web 端。
|
||||
Expected: 成功登录,工作台显示「任务工作台」
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 演示数据预置
|
||||
|
||||
### Task 5: 编写演示数据预置脚本
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/demo-seed.sql`
|
||||
|
||||
脚本目标:一键预置以下数据(幂等,可重复执行):
|
||||
|
||||
- 张建国患者档案 + 2 份历史化验单(肌酐 88→102)
|
||||
- 20-30 个背景患者(让仪表盘有数据)
|
||||
- 若干随访任务/告警记录(让仪表盘统计有意义)
|
||||
- 3 篇 CKD 健康科普文章
|
||||
- 收缩压 >=160 告警规则(如 seed 中没有)
|
||||
|
||||
- [ ] **Step 1: 编写 SQL 脚本骨架**
|
||||
|
||||
在 `scripts/demo-seed.sql` 中:
|
||||
|
||||
```sql
|
||||
-- HMS V1 Demo Data Seed
|
||||
-- 用法: docker exec -i erp-postgres psql -U erp < scripts/demo-seed.sql
|
||||
-- 幂等:使用 ON CONFLICT DO NOTHING
|
||||
|
||||
-- 1. 确保租户 ID(从现有租户获取)
|
||||
-- 2. 张建国患者档案
|
||||
-- 3. 2 份历史化验单(3 个月前 肌酐 88,1 个月前 肌酐 102)
|
||||
-- 4. 20 个背景患者(随机姓名,基础体征数据)
|
||||
-- 5. 若干随访任务(不同状态:pending/completed)
|
||||
-- 6. 若干告警记录(不同状态:pending/acknowledged/resolved)
|
||||
-- 7. 3 篇 CKD 科普文章
|
||||
-- 8. 收缩压 >=160 告警规则
|
||||
```
|
||||
|
||||
注意:所有 INSERT 需包含 `tenant_id`、`created_at`、`updated_at`、`created_by`、`updated_by`、`version`、`id`(UUID v7)字段。参考现有 Entity 的字段结构。
|
||||
|
||||
- [ ] **Step 2: 编写张建国患者 + 化验单数据**
|
||||
|
||||
```sql
|
||||
-- 患者档案
|
||||
INSERT INTO patients (id, tenant_id, name, gender, birth_date, phone, ...)
|
||||
VALUES (
|
||||
'019dcd34-bc4d-72c1-8c19-77ce1f4839d6', -- 使用已知测试患者 ID
|
||||
(SELECT id FROM tenants LIMIT 1),
|
||||
'张建国', 'male', '1961-03-15', '13800138001', ...
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 化验单 1:3 个月前 肌酐 88
|
||||
INSERT INTO lab_reports (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
|
||||
-- 化验单 1 的 items:肌酐 88 μmol/L
|
||||
INSERT INTO lab_report_items (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 化验单 2:1 个月前 肌酐 102
|
||||
INSERT INTO lab_reports (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
|
||||
-- 化验单 2 的 items:肌酐 102 μmol/L
|
||||
INSERT INTO lab_report_items (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写背景患者批量数据**
|
||||
|
||||
使用 SQL generate_series 生成 20-30 个虚拟患者:
|
||||
|
||||
```sql
|
||||
INSERT INTO patients (id, tenant_id, name, gender, birth_date, ...)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
(SELECT id FROM tenants LIMIT 1),
|
||||
'测试患者' || i,
|
||||
CASE WHEN i % 2 = 0 THEN 'male' ELSE 'female' END,
|
||||
CURRENT_DATE - (30 + (i * 37) % 50) * INTERVAL '1 year',
|
||||
...
|
||||
FROM generate_series(1, 25) AS i
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编写随访任务和告警记录**
|
||||
|
||||
为背景患者生成不同状态的随访任务和告警记录,让仪表盘统计有意义。
|
||||
|
||||
- [ ] **Step 5: 编写科普文章和告警规则**
|
||||
|
||||
```sql
|
||||
-- 3 篇 CKD 科普文章
|
||||
INSERT INTO articles (title, content, category, status, ...) VALUES
|
||||
('慢性肾病患者的饮食指南', '...', 'nutrition', 'published', ...),
|
||||
('CKD 患者运动建议', '...', 'exercise', 'published', ...),
|
||||
('慢性肾病常用药物说明', '...', 'medication', 'published', ...)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 收缩压 >=160 告警规则(如果 seed 中没有)
|
||||
INSERT INTO alert_rules (name, metric, operator, threshold, ...)
|
||||
VALUES ('收缩压偏高(演示用)', 'systolic_bp', '>=', 160, ...)
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 执行脚本验证**
|
||||
|
||||
Run: `docker exec -i erp-postgres psql -U erp < scripts/demo-seed.sql`
|
||||
Expected: 无错误,所有 INSERT 成功或 ON CONFLICT 跳过
|
||||
|
||||
- [ ] **Step 7: 验证数据完整性**
|
||||
|
||||
```
|
||||
1. 查询张建国患者:SELECT * FROM patients WHERE name = '张建国'
|
||||
2. 查询化验单数量:SELECT count(*) FROM lab_reports WHERE patient_id = ...
|
||||
3. 查询背景患者数:SELECT count(*) FROM patients WHERE name LIKE '测试患者%'
|
||||
4. 查询文章数:SELECT count(*) FROM articles WHERE status = 'published'
|
||||
Expected: 1 张建国 + 2 化验单 + 25 背景患者 + 3 文章
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/demo-seed.sql
|
||||
git commit -m "chore(demo): V1 演示数据预置脚本
|
||||
|
||||
一键预置张建国患者+化验单+25背景患者+随访+告警+科普文章。
|
||||
幂等设计,可重复执行。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: 全链路 DRY RUN
|
||||
|
||||
### Task 6: 端到端 DRY RUN(7 个场景)
|
||||
|
||||
**前置条件:** Task 1-5 全部完成
|
||||
|
||||
- [ ] **Step 1: 环境启动检查**
|
||||
|
||||
```bash
|
||||
# 1. PostgreSQL
|
||||
docker exec erp-postgres pg_isready
|
||||
|
||||
# 2. 后端
|
||||
curl -s http://localhost:3000/api/v1/auth/health | jq .
|
||||
|
||||
# 3. Web 前端
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:5174
|
||||
|
||||
# 4. Ollama
|
||||
ollama list | grep qwen3
|
||||
```
|
||||
|
||||
Expected: 全部 200/ready
|
||||
|
||||
- [ ] **Step 2: 场景 1 — 护士建档**
|
||||
|
||||
登录 nurse1 → 新建患者/查找张建国 → 录入体征 → 查看化验报告
|
||||
Expected: 全流程无报错
|
||||
|
||||
- [ ] **Step 3: 场景 2 — AI 分析**
|
||||
|
||||
进入张建国化验报告 → 点击 AI 解读(或展示预置结果)
|
||||
Expected: AI 输出正常或截图备用
|
||||
|
||||
- [ ] **Step 4: 场景 3 — 医生审批**
|
||||
|
||||
登录 doctor1 → 查看 AI 建议 → 同意 → 查看随访任务
|
||||
Expected: 随访任务自动生成
|
||||
|
||||
- [ ] **Step 5: 场景 4 — 小程序**
|
||||
|
||||
打开小程序(开发者工具)→ 查看消息/随访 → 填写问卷 → 查看趋势
|
||||
Expected: 页面正常渲染,数据正确
|
||||
|
||||
- [ ] **Step 6: 场景 5 — 告警**
|
||||
|
||||
小程序录入血压 185/95 → Web nurse1 查看告警 → 确认 → 处理
|
||||
Expected: 告警实时出现,可操作
|
||||
|
||||
- [ ] **Step 7: 场景 6 — 随访**
|
||||
|
||||
登录 health_manager → 查看随访任务 → 执行 → 录入记录
|
||||
Expected: 随访完成,状态更新
|
||||
|
||||
- [ ] **Step 8: 场景 7 — 仪表盘**
|
||||
|
||||
登录 admin → 查看统计仪表盘 → 查看文章 → 查看积分
|
||||
Expected: 数据有意义(非零)
|
||||
|
||||
- [ ] **Step 9: 记录 DRY RUN 结果**
|
||||
|
||||
在 `docs/qa/demo-dry-run-results.md` 中记录每个场景的结果:
|
||||
- ✅ 通过 / ❌ 失败(附具体错误)
|
||||
- 阻塞问题 → 新建 Task 修复
|
||||
- 可跳过场景标注
|
||||
|
||||
- [ ] **Step 10: Commit DRY RUN 报告**
|
||||
|
||||
```bash
|
||||
git add docs/qa/demo-dry-run-results.md
|
||||
git commit -m "docs: V1 Demo DRY RUN 结果报告"
|
||||
```
|
||||
@@ -0,0 +1,985 @@
|
||||
# WASM 插件系统设计规格
|
||||
|
||||
> 日期:2026-04-13
|
||||
> 状态:审核通过 (v2 — 修复安全/多租户/迁移问题)
|
||||
> 关联:`docs/superpowers/specs/2026-04-10-erp-platform-base-design.md`
|
||||
> Review 历史:v1 首次审核 → 修复 C1-C4 关键问题 + I1-I5 重要问题 → v2 审核通过
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
ERP 平台底座 Phase 1-6 已全部完成,包含 auth、config、workflow、message 四大基础模块。
|
||||
当前系统是一个"模块化形状的单体"——模块以独立 crate 存在,但集成方式是编译时硬编码(main.rs 手动注册路由、合并迁移、启动后台任务)。
|
||||
|
||||
**核心矛盾:** Rust 的静态编译特性不支持运行时热插拔,但产品目标是"通用基座 + 行业插件"架构。
|
||||
|
||||
**本设计的目标:** 引入 WASM 运行时插件系统,使行业模块(进销存、生产、财务等)可以动态安装、启用、停用,无需修改基座代码。
|
||||
|
||||
## 2. 设计决策
|
||||
|
||||
| 决策点 | 选择 | 理由 |
|
||||
|--------|------|------|
|
||||
| 插件范围 | 仅行业模块动态化,基础模块保持 Rust 编译时 | 基础模块变更频率低、可靠性要求高,适合编译时保证 |
|
||||
| 插件技术 | WebAssembly (Wasmtime) | Rust 原生运行时,性能接近原生,沙箱安全 |
|
||||
| 数据库访问 | 宿主代理 API | 宿主自动注入 tenant_id、软删除、审计日志,插件无法绕过 |
|
||||
| 前端 UI | 配置驱动 | ERP 80% 页面是 CRUD,配置驱动覆盖大部分场景 |
|
||||
| 插件管理 | 内置插件商店 | 类似 WordPress 模型,管理后台上传 WASM 包 |
|
||||
| WASM 运行时 | Wasmtime | Bytecode Alliance 维护,Rust 原生,Cranelift JIT |
|
||||
|
||||
## 3. 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ erp-server │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ ModuleRegistry v2 │ │
|
||||
│ │ ┌─────────────────┐ ┌──────────────────────────┐│ │
|
||||
│ │ │ Native Modules │ │ Wasmtime Runtime ││ │
|
||||
│ │ │ ┌──────┐┌──────┐│ │ ┌──────┐┌──────┐┌──────┐││ │
|
||||
│ │ │ │ auth ││config ││ │ │进销存 ││ 生产 ││ 财务 │││ │
|
||||
│ │ │ ├──────┤├──────┤│ │ └──┬───┘└──┬───┘└──┬───┘││ │
|
||||
│ │ │ │workflow│msg ││ │ └────────┼────────┘ ││ │
|
||||
│ │ │ └──────┘└──────┘│ │ Host API Layer ││ │
|
||||
│ │ └─────────────────┘ └──────────────────────────┘│ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
│ ↕ EventBus │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ 统一 Axum Router │ │
|
||||
│ │ /api/v1/auth/* /api/v1/plugins/{id}/* │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
↕
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React SPA) │
|
||||
│ ┌──────────────┐ ┌──────────────────────────────────┐ │
|
||||
│ │ 固定路由 │ │ 动态路由 (PluginRegistry Store) │ │
|
||||
│ │ /users /roles │ │ /inventory/* /production/* │ │
|
||||
│ └──────────────┘ └──────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────┐│
|
||||
│ │ PluginCRUDPage — 配置驱动的通用 CRUD 渲染引擎 ││
|
||||
│ └──────────────────────────────────────────────────────┘│
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. 插件清单 (Plugin Manifest)
|
||||
|
||||
每个 WASM 插件包含一个 `plugin.toml` 清单文件:
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
id = "erp-inventory" # 全局唯一 ID,kebab-case
|
||||
name = "进销存管理" # 显示名称
|
||||
version = "1.0.0" # 语义化版本
|
||||
description = "商品/采购/销售/库存管理"
|
||||
author = "ERP Team"
|
||||
min_platform_version = "1.0.0" # 最低基座版本要求
|
||||
|
||||
[dependencies]
|
||||
modules = ["auth", "workflow"] # 依赖的基础模块 ID 列表
|
||||
|
||||
[permissions]
|
||||
database = true # 需要数据库访问
|
||||
events = true # 需要发布/订阅事件
|
||||
config = true # 需要读取系统配置
|
||||
files = false # 是否需要文件存储
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "inventory_item"
|
||||
fields = [
|
||||
{ name = "sku", type = "string", required = true, unique = true },
|
||||
{ name = "name", type = "string", required = true },
|
||||
{ name = "quantity", type = "integer", default = 0 },
|
||||
{ name = "unit", type = "string", default = "个" },
|
||||
{ name = "category_id", type = "uuid", nullable = true },
|
||||
{ name = "unit_price", type = "decimal", precision = 10, scale = 2 },
|
||||
]
|
||||
indexes = [["sku"], ["category_id"]]
|
||||
|
||||
[[schema.entities]]
|
||||
name = "purchase_order"
|
||||
fields = [
|
||||
{ name = "order_no", type = "string", required = true, unique = true },
|
||||
{ name = "supplier_id", type = "uuid" },
|
||||
{ name = "status", type = "string", default = "draft" },
|
||||
{ name = "total_amount", type = "decimal", precision = 12, scale = 2 },
|
||||
{ name = "order_date", type = "date" },
|
||||
]
|
||||
|
||||
[events]
|
||||
published = ["inventory.stock.low", "purchase_order.created", "purchase_order.approved"]
|
||||
subscribed = ["workflow.task.completed"]
|
||||
|
||||
[ui]
|
||||
[[ui.pages]]
|
||||
name = "商品管理"
|
||||
path = "/inventory/items"
|
||||
entity = "inventory_item"
|
||||
type = "crud"
|
||||
icon = "ShoppingOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[ui.pages]]
|
||||
name = "采购管理"
|
||||
path = "/inventory/purchase"
|
||||
entity = "purchase_order"
|
||||
type = "crud"
|
||||
icon = "ShoppingCartOutlined"
|
||||
menu_group = "进销存"
|
||||
|
||||
[[ui.pages]]
|
||||
name = "库存盘点"
|
||||
path = "/inventory/stocktaking"
|
||||
type = "custom"
|
||||
menu_group = "进销存"
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- `schema.entities` 声明的表自动注入标准字段:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||
- `permissions` 控制插件可调用的宿主 API 范围(最小权限原则)
|
||||
- `ui.pages.type` 为 `crud` 时由通用渲染引擎自动生成页面,`custom` 时由插件处理渲染逻辑
|
||||
- 插件事件命名使用 `{plugin_id}.{entity}.{action}` 三段式,避免与基础模块的 `{module}.{action}` 二段式冲突
|
||||
- 动态创建的表使用 `plugin_{entity_name}` 格式,所有租户共享同一张表,通过 `tenant_id` 列实现行级隔离(与现有表模式一致)
|
||||
|
||||
## 5. 宿主 API (Host Functions)
|
||||
|
||||
WASM 插件通过宿主暴露的函数访问系统资源,这是插件与外部世界的唯一通道:
|
||||
|
||||
### 5.1 API 定义
|
||||
|
||||
```rust
|
||||
/// 宿主暴露给 WASM 插件的 API 接口
|
||||
/// 通过 Wasmtime Linker 注册为 host functions
|
||||
trait PluginHostApi {
|
||||
// === 数据库操作 ===
|
||||
|
||||
/// 插入记录(宿主自动注入 tenant_id, id, created_at 等标准字段)
|
||||
fn db_insert(&mut self, entity: &str, data: &[u8]) -> Result<Vec<u8>>;
|
||||
|
||||
/// 查询记录(自动注入 tenant_id 过滤 + 软删除过滤)
|
||||
fn db_query(&mut self, entity: &str, filter: &[u8], pagination: &[u8]) -> Result<Vec<u8]>;
|
||||
|
||||
/// 更新记录(自动检查 version 乐观锁)
|
||||
fn db_update(&mut self, entity: &str, id: &str, data: &[u8], version: i64) -> Result<Vec<u8]>;
|
||||
|
||||
/// 软删除记录
|
||||
fn db_delete(&mut self, entity: &str, id: &str) -> Result<()>;
|
||||
|
||||
/// 原始查询(仅允许 SELECT,自动注入 tenant_id 过滤)
|
||||
fn db_raw_query(&mut self, sql: &str, params: &[u8]) -> Result<Vec<u8]>;
|
||||
|
||||
// === 事件总线 ===
|
||||
|
||||
/// 发布领域事件
|
||||
fn event_publish(&mut self, event_type: &str, payload: &[u8]) -> Result<()>;
|
||||
|
||||
// === 配置 ===
|
||||
|
||||
/// 读取系统配置(插件作用域内)
|
||||
fn config_get(&mut self, key: &str) -> Result<Vec<u8]>;
|
||||
|
||||
// === 日志 ===
|
||||
|
||||
/// 写日志(自动关联 tenant_id + plugin_id)
|
||||
fn log_write(&mut self, level: &str, message: &str);
|
||||
|
||||
// === 用户/权限 ===
|
||||
|
||||
/// 获取当前用户信息
|
||||
fn current_user(&mut self) -> Result<Vec<u8]>;
|
||||
|
||||
/// 检查当前用户权限
|
||||
fn check_permission(&mut self, permission: &str) -> Result<bool>;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 安全边界
|
||||
|
||||
插件运行在 WASM 沙箱中,安全策略如下:
|
||||
|
||||
1. **权限校验** — 插件只能调用清单 `permissions` 中声明的宿主函数,未声明的调用在加载时被拦截
|
||||
2. **租户隔离** — 所有 `db_*` 操作自动注入 `tenant_id`,插件无法绕过多租户隔离。使用行级隔离(共享表 + tenant_id 过滤),与现有基础模块保持一致
|
||||
3. **资源限制** — 每个插件有独立的资源配额(内存上限、CPU 时间、API 调用频率)
|
||||
4. **审计记录** — 所有写操作自动记录审计日志
|
||||
5. **SQL 安全** — 不暴露原始 SQL 接口,`db_aggregate` 使用结构化查询对象,宿主层安全构建参数化 SQL
|
||||
6. **文件/网络隔离** — 插件不能直接访问文件系统或网络
|
||||
|
||||
### 5.3 数据流
|
||||
|
||||
```
|
||||
WASM 插件 宿主安全层 PostgreSQL
|
||||
┌──────────┐ ┌───────────────┐ ┌──────────┐
|
||||
│ 调用 │ ── Host Call ──→ │ 1. 权限校验 │ │ │
|
||||
│ db_insert │ │ 2. 注入标准字段 │ ── SQL ──→ │ INSERT │
|
||||
│ │ │ 3. 注入 tenant │ │ INTO │
|
||||
│ │ ←─ JSON 结果 ── │ 4. 写审计日志 │ │ │
|
||||
└──────────┘ └───────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## 6. 插件生命周期
|
||||
|
||||
### 6.1 状态机
|
||||
|
||||
```
|
||||
上传 WASM 包
|
||||
│
|
||||
▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ uploaded │───→│ installed │───→│ enabled │───→│ running │
|
||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
│ │
|
||||
│ ┌──────────┘
|
||||
│ ▼
|
||||
┌──────────┐
|
||||
│ disabled │←── 运行时错误自动停用
|
||||
└──────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│uninstalled│ ── 软删除插件记录,保留数据表和数据
|
||||
└──────────┘
|
||||
│
|
||||
▼ (可选,需管理员二次确认)
|
||||
┌──────────┐
|
||||
│ purged │ ── 真正删除数据表 + 数据导出备份
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### 6.2 各阶段操作
|
||||
|
||||
| 阶段 | 操作 |
|
||||
|------|------|
|
||||
| uploaded → installed | 校验清单格式、验证依赖模块存在、检查 min_platform_version |
|
||||
| installed → enabled | 根据 `schema.entities` 创建数据表(带 `plugin_` 前缀)、写入启用状态 |
|
||||
| enabled → running | 服务启动时:Wasmtime 实例化、注册 Host Functions、调用 `init()`、注册事件处理器、注册前端路由 |
|
||||
| running → disabled | 停止 WASM 实例、注销事件处理器、注销路由 |
|
||||
| disabled → uninstalled | 软删除插件记录(设置 `deleted_at`),**保留数据表和数据不变**,清理事件订阅记录 |
|
||||
| uninstalled → purged | 数据导出备份后,删除 `plugin_*` 数据表。**需要管理员二次确认 + 数据导出完成** |
|
||||
|
||||
### 6.3 启动加载流程
|
||||
|
||||
```rust
|
||||
async fn load_plugins(db: &DatabaseConnection) -> Vec<LoadedPlugin> {
|
||||
// 1. 查询所有 enabled 状态的插件
|
||||
let plugins = Plugin::find()
|
||||
.filter(status.eq("enabled"))
|
||||
.filter(deleted_at.is_null())
|
||||
.all(db).await?;
|
||||
|
||||
let mut loaded = Vec::new();
|
||||
for plugin in plugins {
|
||||
// 2. 初始化 Wasmtime Engine(复用全局 Engine)
|
||||
let module = Module::from_binary(&engine, &plugin.wasm_binary)?;
|
||||
|
||||
// 3. 创建 Linker,根据 permissions 注册对应的 Host Functions
|
||||
let mut linker = Linker::new(&engine);
|
||||
register_host_functions(&mut linker, &plugin.permissions)?;
|
||||
|
||||
// 4. 实例化
|
||||
let instance = linker.instantiate_async(&mut store, &module).await?;
|
||||
|
||||
// 5. 调用插件的 init() 入口函数
|
||||
if let Ok(init) = instance.get_typed_func::<(), ()>(&mut store, "init") {
|
||||
init.call_async(&mut store, ()).await?;
|
||||
}
|
||||
|
||||
// 6. 注册事件处理器
|
||||
for sub in &plugin.manifest.events.subscribed {
|
||||
event_bus.subscribe_filtered(sub, plugin_handler(plugin.id, instance.clone()));
|
||||
}
|
||||
|
||||
loaded.push(LoadedPlugin { plugin, instance, store });
|
||||
}
|
||||
|
||||
// 7. 依赖排序验证
|
||||
validate_dependencies(&loaded)?;
|
||||
|
||||
Ok(loaded)
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 数据库 Schema
|
||||
|
||||
### 7.1 新增表
|
||||
|
||||
```sql
|
||||
-- 插件注册表
|
||||
CREATE TABLE plugins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
plugin_id VARCHAR(100) NOT NULL, -- 清单中的唯一 ID
|
||||
name VARCHAR(200) NOT NULL,
|
||||
plugin_version VARCHAR(20) NOT NULL, -- 插件语义化版本(避免与乐观锁 version 混淆)
|
||||
description TEXT,
|
||||
manifest JSONB NOT NULL, -- 完整清单 JSON
|
||||
wasm_binary BYTEA NOT NULL, -- 编译后的 WASM 二进制
|
||||
status VARCHAR(20) DEFAULT 'installed',
|
||||
-- uploaded / installed / enabled / disabled / error
|
||||
permissions JSONB NOT NULL,
|
||||
error_message TEXT,
|
||||
schema_version INTEGER DEFAULT 1, -- 插件数据 schema 版本
|
||||
config JSONB DEFAULT '{}', -- 插件配置
|
||||
-- 标准字段
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID,
|
||||
updated_by UUID,
|
||||
deleted_at TIMESTAMPTZ, -- 软删除(卸载不删数据)
|
||||
row_version INTEGER NOT NULL DEFAULT 1, -- 乐观锁版本
|
||||
UNIQUE(tenant_id, plugin_id)
|
||||
);
|
||||
|
||||
-- 插件 schema 版本跟踪(用于动态表的版本管理)
|
||||
CREATE TABLE plugin_schema_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plugin_id VARCHAR(100) NOT NULL, -- 全局唯一的插件 ID
|
||||
entity_name VARCHAR(100) NOT NULL, -- 实体名
|
||||
schema_version INTEGER NOT NULL DEFAULT 1, -- 当前 schema 版本
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(plugin_id, entity_name)
|
||||
);
|
||||
|
||||
-- 插件事件订阅记录
|
||||
CREATE TABLE plugin_event_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
plugin_id VARCHAR(100) NOT NULL,
|
||||
event_type VARCHAR(200) NOT NULL,
|
||||
handler_name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### 7.2 动态数据表
|
||||
|
||||
插件安装时根据 `manifest.schema.entities` 自动创建数据表:
|
||||
|
||||
- 表名格式:`plugin_{entity_name}`
|
||||
- **行级隔离模式**:所有租户共享同一张 `plugin_*` 表,通过 `tenant_id` 列过滤实现隔离(与现有基础模块的表保持一致)
|
||||
- 首次创建表时使用 `IF NOT EXISTS`(幂等),后续租户安装同一插件时复用已有表
|
||||
- 自动包含标准字段:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version`
|
||||
- 索引自动创建:主键 + `tenant_id`(必选)+ 清单中声明的自定义索引
|
||||
- **注意**:此方式绕过 SeaORM Migration 系统,属于合理偏差——插件是运行时动态加载的,其 schema 无法在编译时通过静态迁移管理。宿主维护 `plugin_schema_versions` 表跟踪每个插件的 schema 版本
|
||||
|
||||
## 8. 配置驱动 UI
|
||||
|
||||
### 8.1 前端架构
|
||||
|
||||
```
|
||||
插件 manifest.ui.pages
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ PluginStore │ Zustand Store,从 /api/v1/plugins/:id/pages 加载
|
||||
│ (前端插件注册表) │ 缓存所有已启用插件的页面配置
|
||||
└───────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ DynamicRouter │ React Router,根据 PluginStore 自动生成路由
|
||||
│ (动态路由层) │ 懒加载 PluginCRUDPage / PluginDashboard
|
||||
└───────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ PluginCRUDPage │ 通用 CRUD 页面组件
|
||||
│ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ SearchBar │ │ 根据 filters 配置自动生成搜索条件
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ DataTable │ │ 根据 columns 配置渲染 Ant Design Table
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ FormDialog │ │ 根据 form 配置渲染新建/编辑表单
|
||||
│ └─────────────┘ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ ActionBar │ │ 根据 actions 配置渲染操作按钮
|
||||
│ └─────────────┘ │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### 8.2 页面配置类型
|
||||
|
||||
```typescript
|
||||
interface PluginPageConfig {
|
||||
name: string;
|
||||
path: string;
|
||||
entity: string;
|
||||
type: "crud" | "dashboard" | "custom";
|
||||
icon?: string;
|
||||
menu_group: string;
|
||||
|
||||
// CRUD 配置(可选,不提供时从 schema.entities 自动推导)
|
||||
// columns 未指定时:从 entity 的 fields 生成,type=select 需显式指定 options
|
||||
// form 未指定时:从 entity 的 fields 生成表单,required 字段为必填
|
||||
columns?: ColumnDef[];
|
||||
filters?: FilterDef[];
|
||||
actions?: ActionDef[];
|
||||
form?: FormDef;
|
||||
}
|
||||
|
||||
interface ColumnDef {
|
||||
field: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "date" | "datetime" | "select"
|
||||
| "multiselect" | "currency" | "status" | "link";
|
||||
width?: number;
|
||||
sortable?: boolean;
|
||||
hidden?: boolean;
|
||||
options?: { label: string; value: string; color?: string }[];
|
||||
}
|
||||
|
||||
interface FormDef {
|
||||
groups?: FormGroup[];
|
||||
fields: FormField[];
|
||||
rules?: ValidationRule[];
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 动态菜单生成
|
||||
|
||||
前端侧边栏从 PluginStore 动态生成菜单项:
|
||||
|
||||
- 基础模块菜单固定(用户、权限、组织、工作流、消息、设置)
|
||||
- 插件菜单按 `menu_group` 分组,动态追加到侧边栏
|
||||
- 菜单数据来自 `/api/v1/plugins/installed` API,启动时加载
|
||||
|
||||
### 8.4 插件 API 路由
|
||||
|
||||
插件的 CRUD API 由宿主自动生成:
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{plugin_id}/{entity} # 列表查询
|
||||
GET /api/v1/plugins/{plugin_id}/{entity}/{id} # 详情
|
||||
POST /api/v1/plugins/{plugin_id}/{entity} # 新建
|
||||
PUT /api/v1/plugins/{plugin_id}/{entity}/{id} # 更新
|
||||
DELETE /api/v1/plugins/{plugin_id}/{entity}/{id} # 删除
|
||||
```
|
||||
|
||||
宿主自动注入 tenant_id、处理分页、乐观锁、软删除。
|
||||
|
||||
### 8.5 自定义页面
|
||||
|
||||
`type: "custom"` 的页面需要额外的渲染指令:
|
||||
|
||||
- 插件 WASM 可以导出 `render_page` 函数,返回 UI 指令 JSON
|
||||
- 宿主前端解析指令并渲染(支持:条件显示、自定义操作、复杂布局)
|
||||
- 复杂交互(如库存盘点)通过事件驱动:前端发送 action → 后端 WASM 处理 → 返回新的 UI 状态
|
||||
|
||||
## 9. 升级后的模块注册系统
|
||||
|
||||
### 9.1 ErpModule trait v2
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait ErpModule: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
|
||||
fn dependencies(&self) -> Vec<&str> { vec![] }
|
||||
fn module_type(&self) -> ModuleType;
|
||||
|
||||
// 生命周期
|
||||
async fn on_startup(&self, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
async fn on_shutdown(&self) -> AppResult<()> { Ok(()) }
|
||||
async fn health_check(&self) -> AppResult<ModuleHealth> {
|
||||
Ok(ModuleHealth { status: "ok".into(), details: None })
|
||||
}
|
||||
|
||||
// 路由
|
||||
fn public_routes(&self) -> Option<Router> { None }
|
||||
fn protected_routes(&self) -> Option<Router> { None }
|
||||
|
||||
// 数据库
|
||||
fn migrations(&self) -> Vec<Box<dyn MigrationTrait>> { vec![] }
|
||||
|
||||
// 事件
|
||||
fn register_event_handlers(&self, bus: &EventBus) {}
|
||||
|
||||
// 租户
|
||||
async fn on_tenant_created(&self, tenant_id: Uuid, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
async fn on_tenant_deleted(&self, tenant_id: Uuid, ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
|
||||
// 配置
|
||||
fn config_schema(&self) -> Option<serde_json::Value> { None }
|
||||
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
|
||||
pub enum ModuleType { Native, Wasm }
|
||||
|
||||
pub struct ModuleHealth {
|
||||
pub status: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ModuleContext {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
pub config: Arc<AppConfig>,
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 ModuleRegistry v2
|
||||
|
||||
```rust
|
||||
pub struct ModuleRegistry {
|
||||
modules: Arc<Vec<Arc<dyn ErpModule>>>,
|
||||
wasm_runtime: Arc<WasmPluginRuntime>,
|
||||
index: Arc<HashMap<String, usize>>,
|
||||
}
|
||||
|
||||
impl ModuleRegistry {
|
||||
pub fn new() -> Self;
|
||||
|
||||
// 注册 Rust 原生模块
|
||||
pub fn register(self, module: impl ErpModule + 'static) -> Self;
|
||||
|
||||
// 从数据库加载 WASM 插件
|
||||
pub async fn load_wasm_plugins(&mut self, db: &DatabaseConnection) -> AppResult<()>;
|
||||
|
||||
// 按依赖顺序启动所有模块
|
||||
pub async fn startup_all(&self, ctx: &ModuleContext) -> AppResult<()>;
|
||||
|
||||
// 聚合健康状态
|
||||
pub async fn health_check_all(&self) -> HashMap<String, ModuleHealth>;
|
||||
|
||||
// 自动收集所有路由
|
||||
pub fn build_routes(&self) -> (Router, Router);
|
||||
|
||||
// 自动收集所有迁移
|
||||
pub fn collect_migrations(&self) -> Vec<Box<dyn MigrationTrait>>;
|
||||
|
||||
// 拓扑排序(基于 dependencies)
|
||||
fn topological_sort(&self) -> AppResult<Vec<Arc<dyn ErpModule>>>;
|
||||
|
||||
// 按 ID 查找模块
|
||||
pub fn get_module(&self, id: &str) -> Option<&Arc<dyn ErpModule>>;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 升级后的 main.rs
|
||||
|
||||
```rust
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// 初始化 DB、Config、EventBus ...
|
||||
|
||||
// 1. 注册 Rust 原生模块
|
||||
let mut registry = ModuleRegistry::new()
|
||||
.register(AuthModule::new())
|
||||
.register(ConfigModule::new())
|
||||
.register(WorkflowModule::new())
|
||||
.register(MessageModule::new());
|
||||
|
||||
// 2. 从数据库加载 WASM 插件
|
||||
registry.load_wasm_plugins(&db).await?;
|
||||
|
||||
// 3. 依赖排序 + 启动所有模块
|
||||
let ctx = ModuleContext { db: db.clone(), event_bus: event_bus.clone(), config: config.clone() };
|
||||
registry.startup_all(&ctx).await?;
|
||||
|
||||
// 4. 自动收集路由(无需手动 merge)
|
||||
let (public, protected) = registry.build_routes();
|
||||
|
||||
// 5. 构建 Axum 服务
|
||||
let app = Router::new()
|
||||
.nest("/api/v1", public.merge(protected))
|
||||
.with_state(app_state);
|
||||
|
||||
// 启动服务 ...
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 插件开发体验
|
||||
|
||||
### 10.1 插件项目结构
|
||||
|
||||
```
|
||||
erp-plugin-inventory/
|
||||
├── Cargo.toml # crate 类型为 cdylib (WASM)
|
||||
├── plugin.toml # 插件清单
|
||||
└── src/
|
||||
└── lib.rs # 插件入口
|
||||
```
|
||||
|
||||
### 10.2 插件 Cargo.toml
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-inventory"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.24" # WIT 接口绑定生成
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
### 10.3 插件代码示例
|
||||
|
||||
```rust
|
||||
use wit_bindgen::generate::Guest;
|
||||
|
||||
// 自动生成宿主 API 绑定
|
||||
export!(Plugin);
|
||||
|
||||
struct Plugin;
|
||||
|
||||
impl Guest for Plugin {
|
||||
fn init() -> Result<(), String> {
|
||||
host::log_write("info", "进销存插件初始化完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
|
||||
// 初始化默认商品分类等
|
||||
host::db_insert("inventory_category", br#"{"name": "默认分类"}"#)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(event_type: String, payload: Vec<u8>) -> Result<(), String> {
|
||||
match event_type.as_str() {
|
||||
"workflow.task.completed" => {
|
||||
// 采购审批通过,更新采购单状态
|
||||
let data: serde_json::Value = serde_json::from_slice(&payload)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let order_id = data["business_id"].as_str().unwrap();
|
||||
host::db_update("purchase_order", order_id,
|
||||
br#"{"status": "approved"}"#, 1)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.4 构建与发布
|
||||
|
||||
```bash
|
||||
# 编译为 WASM
|
||||
cargo build --target wasm32-unknown-unknown --release
|
||||
|
||||
# 打包(WASM 二进制 + 清单文件)
|
||||
erp-plugin pack ./target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm \
|
||||
--manifest ./plugin.toml \
|
||||
--output ./erp-inventory-1.0.0.erp-plugin
|
||||
|
||||
# 上传到平台(通过管理后台或 API)
|
||||
curl -X POST /api/v1/admin/plugins/upload \
|
||||
-F "plugin=@./erp-inventory-1.0.0.erp-plugin"
|
||||
```
|
||||
|
||||
## 11. 管理后台 API
|
||||
|
||||
### 11.1 插件管理接口
|
||||
|
||||
```
|
||||
POST /api/v1/admin/plugins/upload # 上传插件包
|
||||
GET /api/v1/admin/plugins # 列出所有插件
|
||||
GET /api/v1/admin/plugins/{plugin_id} # 插件详情
|
||||
POST /api/v1/admin/plugins/{plugin_id}/enable # 启用插件
|
||||
POST /api/v1/admin/plugins/{plugin_id}/disable # 停用插件
|
||||
DELETE /api/v1/admin/plugins/{plugin_id} # 卸载插件
|
||||
GET /api/v1/admin/plugins/{plugin_id}/health # 插件健康检查
|
||||
PUT /api/v1/admin/plugins/{plugin_id}/config # 更新插件配置
|
||||
POST /api/v1/admin/plugins/{plugin_id}/upgrade # 升级插件版本
|
||||
```
|
||||
|
||||
### 11.2 插件数据接口(自动生成)
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{plugin_id}/{entity} # 列表查询
|
||||
GET /api/v1/plugins/{plugin_id}/{entity}/{id} # 详情
|
||||
POST /api/v1/plugins/{plugin_id}/{entity} # 新建
|
||||
PUT /api/v1/plugins/{plugin_id}/{entity}/{id} # 更新
|
||||
DELETE /api/v1/plugins/{plugin_id}/{entity}/{id} # 删除
|
||||
```
|
||||
|
||||
## 12. 实施路径
|
||||
|
||||
### Phase 7: 插件系统核心
|
||||
|
||||
1. **引入 Wasmtime 依赖**,创建 `erp-plugin-runtime` crate
|
||||
2. **定义 WIT 接口文件**,描述宿主-插件合约
|
||||
3. **实现 Host API 层** — db_insert/query/update/delete、event_publish、config_get 等
|
||||
4. **实现插件加载器** — 从数据库读取 WASM 二进制、实例化、注册路由
|
||||
5. **升级 ErpModule trait** — 添加 lifecycle hooks、routes、migrations 方法
|
||||
6. **升级 ModuleRegistry** — 拓扑排序、自动路由收集、WASM 插件注册
|
||||
7. **插件管理 API** — 上传、启用、停用、卸载
|
||||
8. **插件数据库表** — plugins、plugin_event_subscriptions + 动态建表逻辑
|
||||
|
||||
### Phase 8: 前端配置驱动 UI
|
||||
|
||||
1. **PluginStore** (Zustand) — 管理已安装插件的页面配置
|
||||
2. **DynamicRouter** — 根据 PluginStore 自动生成 React Router 路由
|
||||
3. **PluginCRUDPage** — 通用 CRUD 渲染引擎(表格 + 搜索 + 表单 + 操作)
|
||||
4. **动态菜单** — 从 PluginStore 生成侧边栏菜单
|
||||
5. **插件管理页面** — 上传、启用/停用、配置的管理后台
|
||||
|
||||
### Phase 9: 第一个行业插件(进销存)
|
||||
|
||||
1. 创建 `erp-plugin-inventory` 作为参考实现
|
||||
2. 实现商品、采购、库存管理的核心业务逻辑
|
||||
3. 配置驱动页面覆盖 80% 的 CRUD 场景
|
||||
4. 验证端到端流程:安装 → 启用 → 使用 → 停用 → 卸载
|
||||
|
||||
## 13. 风险与缓解
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|----------|
|
||||
| WASM 插件性能不足 | 低 | 高 | 性能基准测试,关键路径保留 Rust 原生 |
|
||||
| 插件安全问题 | 中 | 高 | 沙箱隔离 + 最小权限 + 审计日志 |
|
||||
| 配置驱动 UI 覆盖不足 | 中 | 中 | 保留 custom 页面类型作为兜底 |
|
||||
| 插件间依赖冲突 | 中 | 中 | 拓扑排序 + 版本约束 + 冲突检测 |
|
||||
| Wasmtime 版本兼容性 | 低 | 中 | 锁定 Wasmtime 大版本,CI 验证 |
|
||||
|
||||
## 附录 A: ErpModule Trait 迁移策略
|
||||
|
||||
### A.1 向后兼容原则
|
||||
|
||||
`ErpModule` trait v2 的所有新增方法均提供**默认实现(no-op)**,确保现有四个模块(AuthModule、ConfigModule、WorkflowModule、MessageModule)无需修改即可编译通过。
|
||||
|
||||
### A.2 迁移清单
|
||||
|
||||
| 现有方法 | v2 变化 | 迁移操作 |
|
||||
|----------|---------|----------|
|
||||
| `fn name(&self) -> &str` | 保留不变,新增 `fn id()` 返回相同值 | 在各模块 impl 中添加 `fn id()` |
|
||||
| `fn version()` | 保留不变 | 无需改动 |
|
||||
| `fn dependencies()` | 保留不变 | 无需改动 |
|
||||
| `fn register_event_handlers()` | 签名不变 | 无需改动 |
|
||||
| `fn on_tenant_created(tenant_id)` | 签名变为 `on_tenant_created(tenant_id, ctx)` | 更新签名,添加 `ctx` 参数 |
|
||||
| `fn on_tenant_deleted(tenant_id)` | 签名变为 `on_tenant_deleted(tenant_id, ctx)` | 更新签名,添加 `ctx` 参数 |
|
||||
| `fn as_any()` | 保留不变 | 无需改动 |
|
||||
| (新增)`fn module_type()` | 默认返回 `ModuleType::Native` | 无需改动 |
|
||||
| (新增)`fn on_startup()` | 默认 no-op | 可选实现 |
|
||||
| (新增)`fn on_shutdown()` | 默认 no-op | 可选实现 |
|
||||
| (新增)`fn health_check()` | 默认返回 ok | 可选实现 |
|
||||
| (新增)`fn public_routes()` | 默认 None | 将现有关联函数迁移到此方法 |
|
||||
| (新增)`fn protected_routes()` | 默认 None | 将现有关联函数迁移到此方法 |
|
||||
| (新增)`fn migrations()` | 默认空 vec | 可选实现 |
|
||||
| (新增)`fn config_schema()` | 默认 None | 可选实现 |
|
||||
|
||||
### A.3 迁移后的 main.rs 变化
|
||||
|
||||
迁移后,main.rs 从手动路由合并变为自动收集:
|
||||
|
||||
```rust
|
||||
// 迁移前(手动)
|
||||
let protected_routes = erp_auth::AuthModule::protected_routes()
|
||||
.merge(erp_config::ConfigModule::protected_routes())
|
||||
.merge(erp_workflow::WorkflowModule::protected_routes())
|
||||
.merge(erp_message::MessageModule::protected_routes());
|
||||
|
||||
// 迁移后(自动)
|
||||
let (public, protected) = registry.build_routes();
|
||||
```
|
||||
|
||||
## 附录 B: EventBus 类型化订阅扩展
|
||||
|
||||
### B.1 现有 EventBus 扩展
|
||||
|
||||
现有的 `EventBus`(`erp-core/src/events.rs`)只有 `subscribe()` 方法返回全部事件的 `Receiver`。需要添加类型化过滤订阅:
|
||||
|
||||
```rust
|
||||
impl EventBus {
|
||||
/// 订阅特定事件类型
|
||||
/// 内部使用 mpmc 通道,为每个事件类型维护独立的分发器
|
||||
pub fn subscribe_filtered(
|
||||
&self,
|
||||
event_type: &str,
|
||||
handler: Box<dyn Fn(DomainEvent) + Send + Sync>,
|
||||
) -> SubscriptionHandle {
|
||||
// 在内部 HashMap<String, Vec<Handler>> 中注册
|
||||
// publish() 时根据 event_type 分发到匹配的 handler
|
||||
}
|
||||
|
||||
/// 取消订阅(用于插件停用时清理)
|
||||
pub fn unsubscribe(&self, handle: SubscriptionHandle) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### B.2 插件事件处理器包装
|
||||
|
||||
```rust
|
||||
struct PluginEventHandler {
|
||||
plugin_id: String,
|
||||
handler_fn: Box<dyn Fn(DomainEvent) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl PluginEventHandler {
|
||||
fn handle(&self, event: DomainEvent) {
|
||||
// 捕获 panic,防止插件崩溃影响宿主
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
(self.handler_fn)(event)
|
||||
});
|
||||
if let Err(_) = result {
|
||||
tracing::error!("插件 {} 事件处理器崩溃", self.plugin_id);
|
||||
// 通知 PluginManager 标记插件为 error 状态
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 C: 管理后台 API 权限控制
|
||||
|
||||
### C.1 权限模型
|
||||
|
||||
| API 端点 | 所需权限 | 角色范围 |
|
||||
|----------|---------|----------|
|
||||
| `POST /admin/plugins/upload` | `plugin:admin` | 仅平台超级管理员 |
|
||||
| `POST /admin/plugins/{id}/enable` | `plugin:manage` | 平台管理员或租户管理员(仅限自己租户的插件) |
|
||||
| `POST /admin/plugins/{id}/disable` | `plugin:manage` | 平台管理员或租户管理员 |
|
||||
| `DELETE /admin/plugins/{id}` | `plugin:manage` | 租户管理员(软删除) |
|
||||
| `DELETE /admin/plugins/{id}/purge` | `plugin:admin` | 仅平台超级管理员 |
|
||||
| `GET /admin/plugins` | `plugin:view` | 租户管理员(仅看到自己租户的插件) |
|
||||
| `PUT /admin/plugins/{id}/config` | `plugin:configure` | 租户管理员 |
|
||||
| `GET /admin/plugins/{id}/health` | `plugin:view` | 租户管理员 |
|
||||
|
||||
### C.2 租户隔离
|
||||
|
||||
- 插件管理 API 自动注入 `tenant_id` 过滤(从 JWT 中提取)
|
||||
- 平台超级管理员可以通过 `/admin/platform/plugins` 查看所有租户的插件
|
||||
- 租户管理员只能管理自己租户安装的插件
|
||||
- 插件上传为平台级操作(所有租户共享同一个 WASM 二进制),但启用/配置为租户级操作
|
||||
|
||||
## 附录 D: WIT 接口定义
|
||||
|
||||
### D.1 插件接口 (`plugin.wit`)
|
||||
|
||||
```wit
|
||||
package erp:plugin;
|
||||
|
||||
interface host {
|
||||
/// 数据库操作
|
||||
db-insert: func(entity: string, data: list<u8>) -> result<list<u8>, string>;
|
||||
db-query: func(entity: string, filter: list<u8>, pagination: list<u8>) -> result<list<u8>, string>;
|
||||
db-update: func(entity: string, id: string, data: list<u8>, version: s64) -> result<list<u8>, string>;
|
||||
db-delete: func(entity: string, id: string) -> result<_, string>;
|
||||
db-aggregate: func(entity: string, query: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 事件总线
|
||||
event-publish: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
|
||||
/// 配置
|
||||
config-get: func(key: string) -> result<list<u8>, string>;
|
||||
|
||||
/// 日志
|
||||
log-write: func(level: string, message: string);
|
||||
|
||||
/// 用户/权限
|
||||
current-user: func() -> result<list<u8>, string>;
|
||||
check-permission: func(permission: string) -> result<bool, string>;
|
||||
}
|
||||
|
||||
interface plugin {
|
||||
/// 插件初始化(加载时调用一次)
|
||||
init: func() -> result<_, string>;
|
||||
|
||||
/// 租户创建时调用
|
||||
on-tenant-created: func(tenant-id: string) -> result<_, string>;
|
||||
|
||||
/// 处理订阅的事件
|
||||
handle-event: func(event-type: string, payload: list<u8>) -> result<_, string>;
|
||||
|
||||
/// 自定义页面渲染(仅 type=custom 页面)
|
||||
render-page: func(page-path: string, params: list<u8>) -> result<list<u8>, string>;
|
||||
|
||||
/// 自定义页面操作处理
|
||||
handle-action: func(page-path: string, action: string, data: list<u8>) -> result<list<u8>, string>;
|
||||
}
|
||||
|
||||
world plugin-world {
|
||||
import host;
|
||||
export plugin;
|
||||
}
|
||||
```
|
||||
|
||||
### D.2 使用方式
|
||||
|
||||
插件开发者使用 `wit-bindgen` 生成绑定代码:
|
||||
|
||||
```bash
|
||||
# 生成 Rust 插件绑定
|
||||
wit-bindgen rust ./plugin.wit --out-dir ./src/generated
|
||||
```
|
||||
|
||||
宿主使用 `wasmtime` 的 `bindgen!` 宏生成调用端代码:
|
||||
|
||||
```rust
|
||||
// 在 erp-plugin-runtime crate 中
|
||||
wasmtime::component::bindgen!({
|
||||
path: "./plugin.wit",
|
||||
world: "plugin-world",
|
||||
async: true,
|
||||
});
|
||||
```
|
||||
|
||||
## 附录 E: 插件崩溃恢复策略
|
||||
|
||||
### E.1 崩溃检测与恢复
|
||||
|
||||
| 场景 | 检测方式 | 恢复策略 |
|
||||
|------|---------|----------|
|
||||
| WASM 执行 panic | `catch_unwind` 捕获 | 记录错误日志,该请求返回 500,插件继续运行 |
|
||||
| 插件 init() 失败 | 返回 Err | 标记插件为 `error` 状态,不加载 |
|
||||
| 事件处理器崩溃 | `catch_unwind` 捕获 | 记录错误日志,事件丢弃(不重试) |
|
||||
| 连续崩溃(>5次/分钟) | 计数器检测 | 自动停用插件,标记 `error`,通知管理员 |
|
||||
| 服务重启 | 启动流程 | 重新加载所有 `enabled` 状态的插件 |
|
||||
|
||||
### E.2 僵尸状态处理
|
||||
|
||||
插件在数据库中为 `enabled` 但实际未运行的情况:
|
||||
|
||||
1. 服务启动时,所有 `enabled` 插件尝试加载
|
||||
2. 加载失败的插件自动标记为 `error`,`error_message` 记录原因
|
||||
3. 管理后台显示 `error` 状态的插件,提供"重试"按钮
|
||||
4. 重试成功后恢复为 `enabled`,重试失败保持 `error`
|
||||
|
||||
### E.3 插件健康检查
|
||||
|
||||
```rust
|
||||
/// 定期健康检查(每 60 秒)
|
||||
async fn health_check_loop(registry: &ModuleRegistry) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let results = registry.health_check_all().await;
|
||||
for (id, health) in results {
|
||||
if health.status != "ok" {
|
||||
tracing::warn!("模块 {} 健康检查异常: {:?}", id, health.details);
|
||||
// 通知管理后台
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 F: Crate 依赖图更新
|
||||
|
||||
```
|
||||
erp-core (无业务依赖)
|
||||
erp-common (无业务依赖)
|
||||
↑
|
||||
erp-auth (→ core)
|
||||
erp-config (→ core)
|
||||
erp-workflow (→ core)
|
||||
erp-message (→ core)
|
||||
erp-plugin-runtime (→ core, wasmtime) ← 新增
|
||||
↑
|
||||
erp-server (→ 所有 crate,组装入口)
|
||||
```
|
||||
|
||||
**规则:**
|
||||
- `erp-plugin-runtime` 依赖 `erp-core`(使用 EventBus、ErpModule trait、AppError)
|
||||
- `erp-plugin-runtime` 依赖 `wasmtime`(WASM 运行时)
|
||||
- `erp-plugin-runtime` 不依赖任何业务 crate(auth/config/workflow/message)
|
||||
- `erp-server` 在组装时引入 `erp-plugin-runtime`
|
||||
@@ -0,0 +1,702 @@
|
||||
# WASM 插件系统实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 ERP 平台引入 WASM 运行时插件系统,使行业模块可动态安装/启用/停用。
|
||||
|
||||
**Architecture:** 基础模块(auth/config/workflow/message)保持 Rust 编译时,新增 `erp-plugin-runtime` crate 封装 Wasmtime 运行时。插件通过宿主代理 API 访问数据库和事件总线,前端使用配置驱动 UI 渲染引擎自动生成 CRUD 页面。
|
||||
|
||||
**Tech Stack:** Rust + Wasmtime 27+ / WIT (wit-bindgen 0.24+) / SeaORM / Axum 0.8 / React 19 + Ant Design 6 + Zustand 5
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### 新建文件
|
||||
|
||||
```
|
||||
crates/erp-plugin-runtime/
|
||||
├── Cargo.toml
|
||||
├── wit/
|
||||
│ └── plugin.wit # WIT 接口定义
|
||||
└── src/
|
||||
├── lib.rs # crate 入口
|
||||
├── manifest.rs # plugin.toml 解析
|
||||
├── engine.rs # Wasmtime 引擎封装
|
||||
├── host_api.rs # 宿主 API(db/event/config/log)
|
||||
├── loader.rs # 插件加载器
|
||||
├── schema.rs # 动态建表逻辑
|
||||
├── error.rs # 插件错误类型
|
||||
└── wasm_module.rs # ErpModule trait 的 WASM 适配器
|
||||
|
||||
crates/erp-server/migration/src/
|
||||
└── m20260413_000032_create_plugins_table.rs # plugins + plugin_schema_versions 表
|
||||
|
||||
crates/erp-server/src/
|
||||
└── handlers/
|
||||
└── plugin.rs # 插件管理 + 数据 CRUD handler
|
||||
|
||||
apps/web/src/
|
||||
├── api/
|
||||
│ └── plugins.ts # 插件 API service
|
||||
├── stores/
|
||||
│ └── plugin.ts # PluginStore (Zustand)
|
||||
├── pages/
|
||||
│ ├── PluginAdmin.tsx # 插件管理页面
|
||||
│ └── PluginCRUDPage.tsx # 通用 CRUD 渲染引擎
|
||||
└── components/
|
||||
└── DynamicMenu.tsx # 动态菜单组件
|
||||
```
|
||||
|
||||
### 修改文件
|
||||
|
||||
```
|
||||
Cargo.toml # 添加 erp-plugin-runtime workspace member
|
||||
crates/erp-core/src/module.rs # 升级 ErpModule trait v2
|
||||
crates/erp-core/src/events.rs # 添加 subscribe_filtered
|
||||
crates/erp-core/src/lib.rs # 导出新类型
|
||||
crates/erp-auth/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-config/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-workflow/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-message/src/module.rs # 迁移到 v2 trait
|
||||
crates/erp-server/src/main.rs # 使用新注册系统 + 加载 WASM 插件
|
||||
crates/erp-server/src/state.rs # 添加 PluginState
|
||||
crates/erp-server/migration/src/lib.rs # 注册新迁移
|
||||
apps/web/src/App.tsx # 添加动态路由 + PluginAdmin 路由
|
||||
apps/web/src/layouts/MainLayout.tsx # 使用 DynamicMenu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: ErpModule Trait v2 迁移 + EventBus 扩展
|
||||
|
||||
### Task 1: 升级 ErpModule trait
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-core/src/module.rs`
|
||||
- Modify: `crates/erp-core/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 升级 ErpModule trait — 添加新方法(全部有默认实现)**
|
||||
|
||||
在 `crates/erp-core/src/module.rs` 中,保留所有现有方法签名不变,追加新方法:
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
|
||||
// 新增类型
|
||||
pub enum ModuleType {
|
||||
Native,
|
||||
Wasm,
|
||||
}
|
||||
|
||||
pub struct ModuleHealth {
|
||||
pub status: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ModuleContext {
|
||||
pub db: sea_orm::DatabaseConnection,
|
||||
pub event_bus: crate::events::EventBus,
|
||||
pub config: Arc<serde_json::Value>,
|
||||
}
|
||||
|
||||
// 在 ErpModule trait 中追加(不改现有方法):
|
||||
fn id(&self) -> &str { self.name() } // 默认等于 name
|
||||
fn module_type(&self) -> ModuleType { ModuleType::Native }
|
||||
async fn on_startup(&self, _ctx: &ModuleContext) -> crate::error::AppResult<()> { Ok(()) }
|
||||
async fn on_shutdown(&self) -> crate::error::AppResult<()> { Ok(()) }
|
||||
async fn health_check(&self) -> crate::error::AppResult<ModuleHealth> {
|
||||
Ok(ModuleHealth { status: "ok".into(), details: None })
|
||||
}
|
||||
fn public_routes(&self) -> Option<axum::Router> { None } // 需要 axum 依赖
|
||||
fn protected_routes(&self) -> Option<axum::Router> { None }
|
||||
fn migrations(&self) -> Vec<Box<dyn sea_orm_migration::MigrationTrait>> { vec![] }
|
||||
fn config_schema(&self) -> Option<serde_json::Value> { None }
|
||||
```
|
||||
|
||||
> **注意:** `on_tenant_created/deleted` 的签名暂不改动(加 ctx 参数是破坏性变更),在 Task 2 中单独处理。
|
||||
|
||||
- [ ] **Step 2: 升级 ModuleRegistry — 添加索引 + 拓扑排序 + build_routes**
|
||||
|
||||
在同一个文件中扩展 `ModuleRegistry`:
|
||||
|
||||
```rust
|
||||
impl ModuleRegistry {
|
||||
pub fn get_module(&self, id: &str) -> Option<&Arc<dyn ErpModule>> { ... }
|
||||
pub fn build_routes(&self) -> (axum::Router, axum::Router) {
|
||||
// 遍历 modules,收集 public_routes + protected_routes
|
||||
}
|
||||
fn topological_sort(&self) -> crate::error::AppResult<Vec<Arc<dyn ErpModule>>> {
|
||||
// 基于 dependencies() 的 Kahn 算法拓扑排序
|
||||
}
|
||||
pub async fn startup_all(&self, ctx: &ModuleContext) -> crate::error::AppResult<()> {
|
||||
// 按拓扑顺序调用 on_startup
|
||||
}
|
||||
pub async fn health_check_all(&self) -> HashMap<String, ModuleHealth> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 更新 lib.rs 导出**
|
||||
|
||||
`crates/erp-core/src/lib.rs` 追加:
|
||||
```rust
|
||||
pub use module::{ModuleType, ModuleHealth, ModuleContext};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 更新 erp-core Cargo.toml 添加 axum 依赖**
|
||||
|
||||
`crates/erp-core/Cargo.toml` 的 `[dependencies]` 添加:
|
||||
```toml
|
||||
axum = { workspace = true }
|
||||
sea-orm-migration = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行 `cargo check --workspace` 确保现有模块编译通过(所有新方法有默认实现)**
|
||||
|
||||
- [ ] **Step 6: 迁移四个现有模块的 routes**
|
||||
|
||||
对 `erp-auth/src/module.rs`、`erp-config/src/module.rs`、`erp-workflow/src/module.rs`、`erp-message/src/module.rs`:
|
||||
- 将 `pub fn public_routes<S>()` 关联函数改为 `fn public_routes(&self) -> Option<Router>` trait 方法
|
||||
- 同样处理 `protected_routes`
|
||||
- 添加 `fn id()` 返回与 `name()` 相同值
|
||||
|
||||
每个模块的改动模式:
|
||||
```rust
|
||||
// 之前: pub fn public_routes<S>() -> Router<S> where ... { Router::new().route(...) }
|
||||
// 之后:
|
||||
fn public_routes(&self) -> Option<axum::Router> {
|
||||
Some(axum::Router::new().route("/auth/login", axum::routing::post(auth_handler::login)).route("/auth/refresh", axum::routing::post(auth_handler::refresh)))
|
||||
}
|
||||
fn protected_routes(&self) -> Option<axum::Router> { Some(...) }
|
||||
fn id(&self) -> &str { "auth" } // 与 name() 相同
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 更新 main.rs 使用 build_routes**
|
||||
|
||||
`crates/erp-server/src/main.rs`:
|
||||
```rust
|
||||
// 替换手动 merge 为:
|
||||
let (public_mod, protected_mod) = registry.build_routes();
|
||||
let public_routes = Router::new()
|
||||
.merge(handlers::health::health_check_router())
|
||||
.merge(public_mod) // 替代 erp_auth::AuthModule::public_routes()
|
||||
.route("/docs/openapi.json", ...)
|
||||
...;
|
||||
let protected_routes = protected_mod // 替代手动 merge 四个模块
|
||||
.merge(handlers::audit_log::audit_log_router())
|
||||
...;
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 运行 `cargo check --workspace` 确认全 workspace 编译通过**
|
||||
|
||||
- [ ] **Step 9: 运行 `cargo test --workspace` 确认测试通过**
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```
|
||||
feat(core): upgrade ErpModule trait v2 with lifecycle hooks, route methods, and auto-collection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: EventBus subscribe_filtered 扩展
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-core/src/events.rs`
|
||||
|
||||
- [ ] **Step 1: 添加类型化订阅支持**
|
||||
|
||||
在 `events.rs` 中扩展 `EventBus`:
|
||||
|
||||
```rust
|
||||
use std::sync::RwLock;
|
||||
|
||||
pub type EventHandler = Box<dyn Fn(DomainEvent) + Send + Sync>;
|
||||
pub type SubscriptionId = Uuid;
|
||||
|
||||
pub struct EventBus {
|
||||
sender: broadcast::Sender<DomainEvent>,
|
||||
handlers: Arc<RwLock<HashMap<String, Vec<(SubscriptionId, EventHandler)>>>>,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
pub fn subscribe_filtered(
|
||||
&self,
|
||||
event_type: &str,
|
||||
handler: EventHandler,
|
||||
) -> SubscriptionId {
|
||||
let id = Uuid::now_v7();
|
||||
let mut handlers = self.handlers.write().unwrap();
|
||||
handlers.entry(event_type.to_string())
|
||||
.or_default()
|
||||
.push((id, handler));
|
||||
id
|
||||
}
|
||||
|
||||
pub fn unsubscribe(&self, id: SubscriptionId) {
|
||||
let mut handlers = self.handlers.write().unwrap();
|
||||
for (_, list) in handlers.iter_mut() {
|
||||
list.retain(|(sid, _)| *sid != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
修改 `broadcast()` 方法,在广播时同时分发给 `handlers` 中匹配的处理器。
|
||||
|
||||
- [ ] **Step 2: 运行 `cargo test --workspace`**
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(core): add typed event subscription to EventBus
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 数据库迁移 + erp-plugin-runtime Crate
|
||||
|
||||
### Task 3: 插件数据库表迁移
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/migration/src/m20260413_000032_create_plugins_table.rs`
|
||||
- Modify: `crates/erp-server/migration/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 编写迁移文件**
|
||||
|
||||
创建 `plugins`、`plugin_schema_versions`、`plugin_event_subscriptions` 三张表(DDL 参见 spec §7.1)。
|
||||
|
||||
- [ ] **Step 2: 注册到 lib.rs 的迁移列表**
|
||||
|
||||
- [ ] **Step 3: 运行 `cargo run -p erp-server` 验证迁移执行**
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat(db): add plugins, plugin_schema_versions, and plugin_event_subscriptions tables
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 创建 erp-plugin-runtime crate 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-runtime/src/lib.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/error.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/manifest.rs`
|
||||
- Modify: `Cargo.toml` (workspace members)
|
||||
|
||||
- [ ] **Step 1: 创建 Cargo.toml**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core = { workspace = true }
|
||||
wasmtime = "27"
|
||||
wasmtime-wasi = "27"
|
||||
wit-bindgen = "0.24"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = "0.8"
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
sea-orm = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新根 Cargo.toml workspace members + dependencies**
|
||||
|
||||
- [ ] **Step 3: 实现 manifest.rs — PluginManifest 类型 + 解析**
|
||||
|
||||
定义 `PluginManifest`、`PluginInfo`、`PermissionSet`、`EntityDef`、`FieldDef`、`PageDef` 等结构体,实现 `fn parse(toml_str: &str) -> Result<PluginManifest>`。
|
||||
|
||||
- [ ] **Step 4: 实现 error.rs**
|
||||
|
||||
```rust
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginError {
|
||||
#[error("Manifest 解析失败: {0}")]
|
||||
ManifestParse(String),
|
||||
#[error("WASM 加载失败: {0}")]
|
||||
WasmLoad(String),
|
||||
#[error("Host API 错误: {0}")]
|
||||
HostApi(String),
|
||||
#[error("插件未找到: {0}")]
|
||||
NotFound(String),
|
||||
#[error("依赖未满足: {0}")]
|
||||
DependencyUnmet(String),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 实现 lib.rs — crate 入口 + re-exports**
|
||||
|
||||
- [ ] **Step 6: 运行 `cargo check --workspace`**
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): create erp-plugin-runtime crate with manifest parsing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: WIT 接口定义
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/wit/plugin.wit`
|
||||
|
||||
- [ ] **Step 1: 编写 WIT 文件**
|
||||
|
||||
参见 spec 附录 D.1 的完整 `plugin.wit` 内容(host interface + plugin interface + plugin-world)。
|
||||
|
||||
- [ ] **Step 2: 验证 WIT 语法**
|
||||
|
||||
```bash
|
||||
cargo install wit-bindgen-cli
|
||||
wit-bindgen rust ./crates/erp-plugin-runtime/wit/plugin.wit --out-dir /tmp/test-bindgen
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): define WIT interface for host-plugin contract
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Host API + 插件加载器
|
||||
|
||||
### Task 6: 实现 Host API 层
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/src/host_api.rs`
|
||||
|
||||
- [ ] **Step 1: 实现 PluginHostState 结构体**
|
||||
|
||||
持有 `db`、`tenant_id`、`plugin_id`、`event_bus` 等上下文。实现 `db_insert`、`db_query`、`db_update`、`db_delete`、`db_aggregate` 方法,每个方法都:
|
||||
1. 自动注入 `tenant_id` 过滤
|
||||
2. 自动注入标准字段(id, created_at 等)
|
||||
3. 参数化 SQL 防注入
|
||||
4. 自动审计日志
|
||||
|
||||
- [ ] **Step 2: 注册为 Wasmtime host functions**
|
||||
|
||||
使用 `wasmtime::Linker::func_wrap` 将 host_api 方法注册到 WASM 实例。
|
||||
|
||||
- [ ] **Step 3: 编写单元测试**
|
||||
|
||||
使用 mock 数据库测试 db_insert 自动注入 tenant_id、db_query 自动过滤。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): implement host API layer with tenant isolation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 实现插件加载器 + 动态建表
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-runtime/src/engine.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/loader.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/schema.rs`
|
||||
- Create: `crates/erp-plugin-runtime/src/wasm_module.rs`
|
||||
|
||||
- [ ] **Step 1: engine.rs — Wasmtime Engine 封装**
|
||||
|
||||
单例 Engine + Store 工厂方法,配置内存限制(64MB 默认)、fuel 消耗限制。
|
||||
|
||||
- [ ] **Step 2: schema.rs — 从 manifest 动态建表**
|
||||
|
||||
`create_entity_table(db, entity_def)` 函数:生成 `CREATE TABLE IF NOT EXISTS plugin_{name} (...)` SQL,包含所有标准字段 + tenant_id 索引。
|
||||
|
||||
- [ ] **Step 3: loader.rs — 从数据库加载 + 实例化**
|
||||
|
||||
`load_plugins(db, engine, event_bus) -> Vec<LoadedPlugin>`:查询 `plugins` 表中 status=enabled 的记录,实例化 WASM,调用 init(),注册事件处理器。
|
||||
|
||||
- [ ] **Step 4: wasm_module.rs — WasmModule(实现 ErpModule trait)**
|
||||
|
||||
包装 WASM 实例,实现 ErpModule trait 的各方法(调用 WASM 导出函数)。
|
||||
|
||||
- [ ] **Step 5: 集成测试**
|
||||
|
||||
测试完整的 load → init → db_insert → db_query 流程(使用真实 PostgreSQL)。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
feat(plugin): implement plugin loader with dynamic schema creation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 插件管理 API + 数据 CRUD API
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/src/handlers/plugin.rs`
|
||||
- Modify: `crates/erp-server/src/main.rs`
|
||||
- Modify: `crates/erp-server/src/state.rs`
|
||||
|
||||
- [ ] **Step 1: 实现 plugin handler**
|
||||
|
||||
上传(解析 plugin.toml + 存储 wasm_binary)、列表、详情、启用(建表+写状态)、停用、卸载(软删除)。
|
||||
|
||||
- [ ] **Step 2: 实现插件数据 CRUD**
|
||||
|
||||
`GET/POST/PUT/DELETE /api/v1/plugins/{plugin_id}/{entity}` — 动态路由,从 manifest 查找 entity,调用 host_api 执行操作。
|
||||
|
||||
- [ ] **Step 3: 注册路由到 main.rs**
|
||||
|
||||
- [ ] **Step 4: 添加 PluginState 到 state.rs**
|
||||
|
||||
```rust
|
||||
impl FromRef<AppState> for erp_plugin_runtime::PluginState { ... }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行 `cargo test --workspace`**
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```
|
||||
feat(server): add plugin management and dynamic CRUD API endpoints
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: 前端配置驱动 UI
|
||||
|
||||
### Task 9: PluginStore + API Service
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/api/plugins.ts`
|
||||
- Create: `apps/web/src/stores/plugin.ts`
|
||||
|
||||
- [ ] **Step 1: plugins.ts API service**
|
||||
|
||||
接口类型定义 + API 函数:`listPlugins`、`getPlugin`、`uploadPlugin`、`enablePlugin`、`disablePlugin`、`uninstallPlugin`、`getPluginConfig`、`updatePluginConfig`、`getPluginData`、`createPluginData`、`updatePluginData`、`deletePluginData`。
|
||||
|
||||
- [ ] **Step 2: plugin.ts PluginStore**
|
||||
|
||||
```typescript
|
||||
interface PluginStore {
|
||||
plugins: PluginInfo[];
|
||||
loading: boolean;
|
||||
fetchPlugins(): Promise<void>;
|
||||
getPageConfigs(): PluginPageConfig[];
|
||||
}
|
||||
```
|
||||
|
||||
启动时调用 `fetchPlugins()` 加载已启用插件列表及页面配置。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(web): add plugin API service and PluginStore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: PluginCRUDPage 通用渲染引擎
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/PluginCRUDPage.tsx`
|
||||
|
||||
- [ ] **Step 1: 实现 PluginCRUDPage 组件**
|
||||
|
||||
接收 `PluginPageConfig` 作为 props,渲染:
|
||||
- **SearchBar**: 从 `filters` 配置生成 Ant Design Form.Item 搜索条件
|
||||
- **DataTable**: 从 `columns` 配置生成 Ant Design Table 列
|
||||
- **FormDialog**: 从 `form` 配置或自动推导的 `schema.entities` 字段生成新建/编辑 Modal 表单
|
||||
- **ActionBar**: 从 `actions` 配置生成操作按钮
|
||||
|
||||
API 调用统一走 `/api/v1/plugins/{plugin_id}/{entity}` 路径。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
feat(web): implement PluginCRUDPage config-driven rendering engine
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 动态路由 + 动态菜单
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/components/DynamicMenu.tsx`
|
||||
- Modify: `apps/web/src/App.tsx`
|
||||
- Modify: `apps/web/src/layouts/MainLayout.tsx`
|
||||
|
||||
- [ ] **Step 1: DynamicMenu 组件**
|
||||
|
||||
从 `usePluginStore` 读取 `getPageConfigs()`,按 `menu_group` 分组生成 Ant Design Menu.Item,追加到侧边栏。
|
||||
|
||||
- [ ] **Step 2: App.tsx 添加动态路由**
|
||||
|
||||
在 private routes 中,遍历 PluginStore 的 pageConfigs,为每个 CRUD 页面生成:
|
||||
```tsx
|
||||
<Route path={page.path} element={<PluginCRUDPage config={page} />} />
|
||||
```
|
||||
同时添加 `/plugin-admin` 路由指向 `PluginAdmin` 页面。
|
||||
|
||||
- [ ] **Step 3: MainLayout.tsx 集成 DynamicMenu**
|
||||
|
||||
替换硬编码的 `bizMenuItems`,追加插件动态菜单。
|
||||
|
||||
- [ ] **Step 4: 运行 `pnpm dev` 验证前端编译通过**
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat(web): add dynamic routing and menu generation from plugin configs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: 插件管理页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/PluginAdmin.tsx`
|
||||
|
||||
- [ ] **Step 1: 实现 PluginAdmin 页面**
|
||||
|
||||
包含:插件列表(Table)、上传按钮(Upload)、启用/停用/卸载操作、配置编辑 Modal。使用 Ant Design 组件。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
feat(web): add plugin admin page with upload/enable/disable/configure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5: 第一个行业插件(进销存)
|
||||
|
||||
### Task 13: 创建 erp-plugin-inventory
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/plugins/inventory/Cargo.toml`
|
||||
- Create: `crates/plugins/inventory/plugin.toml`
|
||||
- Create: `crates/plugins/inventory/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 创建插件项目**
|
||||
|
||||
`Cargo.toml` crate-type = ["cdylib"],依赖 wit-bindgen + serde + serde_json。
|
||||
|
||||
- [ ] **Step 2: 编写 plugin.toml**
|
||||
|
||||
完整清单(spec §4 的进销存示例):inventory_item、purchase_order 两个 entity,3 个 CRUD 页面 + 1 个 custom 页面。
|
||||
|
||||
- [ ] **Step 3: 实现 lib.rs**
|
||||
|
||||
使用 wit-bindgen 生成的绑定,实现 `init()`、`on_tenant_created()`、`handle_event()`。
|
||||
|
||||
- [ ] **Step 4: 编译为 WASM**
|
||||
|
||||
```bash
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo build -p erp-plugin-inventory --target wasm32-unknown-unknown --release
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat(inventory): create erp-plugin-inventory as first industry plugin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: 端到端集成测试
|
||||
|
||||
- [ ] **Step 1: 启动后端服务**
|
||||
|
||||
```bash
|
||||
cd docker && docker compose up -d
|
||||
cd crates/erp-server && cargo run
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 通过 API 上传进销存插件**
|
||||
|
||||
```bash
|
||||
# 打包
|
||||
cp target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm /tmp/
|
||||
# 上传
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/upload \
|
||||
-F "wasm=@/tmp/erp_plugin_inventory.wasm" \
|
||||
-F "manifest=@crates/plugins/inventory/plugin.toml"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 启用插件 + 验证建表**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/erp-inventory/enable
|
||||
docker exec erp-postgres psql -U erp -c "\dt plugin_*"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 测试 CRUD API**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/plugins/erp-inventory/inventory_item \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"sku":"ITEM001","name":"测试商品","quantity":100}'
|
||||
curl http://localhost:3000/api/v1/plugins/erp-inventory/inventory_item \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 前端验证**
|
||||
|
||||
启动 `pnpm dev`,验证:
|
||||
- 侧边栏出现"进销存"菜单组 + 子菜单
|
||||
- 点击"商品管理"显示 PluginCRUDPage
|
||||
- 可以新建/编辑/删除/搜索商品
|
||||
|
||||
- [ ] **Step 6: 测试停用 + 卸载**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/erp-inventory/disable
|
||||
curl -X DELETE http://localhost:3000/api/v1/admin/plugins/erp-inventory
|
||||
# 验证数据表仍在
|
||||
docker exec erp-postgres psql -U erp -c "\dt plugin_*"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```
|
||||
test(inventory): end-to-end integration test for plugin lifecycle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
```
|
||||
Chunk 1 (Tasks 1-2) ← 先做,所有后续依赖 trait v2 和 EventBus 扩展
|
||||
↓
|
||||
Chunk 2 (Tasks 3-5) ← 数据库表 + crate 骨架 + WIT
|
||||
↓
|
||||
Chunk 3 (Tasks 6-8) ← 核心运行时 + API(后端完成)
|
||||
↓
|
||||
Chunk 4 (Tasks 9-12) ← 前端(可与 Chunk 5 并行)
|
||||
↓
|
||||
Chunk 5 (Tasks 13-14) ← 第一个插件 + E2E 验证
|
||||
```
|
||||
|
||||
## 关键风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| Wasmtime 版本与 WIT 不兼容 | 锁定 wasmtime = "27",CI 验证 |
|
||||
| axum Router 在 erp-core 中引入重依赖 | 考虑将 trait routes 方法改为返回路由描述结构体,在 erp-server 层构建 Router |
|
||||
| 动态建表安全性 | 仅允许白名单列类型,禁止 DDL 注入 |
|
||||
| 前端 PluginCRUDPage 覆盖不足 | 先支持 text/number/date/select/currency,custom 页面后续迭代 |
|
||||
1026
docs/archive/superpowers-early/2026-04-16-crm-plugin-design.md
Normal file
1026
docs/archive/superpowers-early/2026-04-16-crm-plugin-design.md
Normal file
File diff suppressed because it is too large
Load Diff
1602
docs/archive/superpowers-early/2026-04-16-crm-plugin-plan.md
Normal file
1602
docs/archive/superpowers-early/2026-04-16-crm-plugin-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,767 @@
|
||||
# CRM 插件基座升级设计规格 v1.0
|
||||
|
||||
> **文档状态:** v1.1 — 已修复评审问题
|
||||
> **创建日期:** 2026-04-17
|
||||
> **范围:** JSONB 存储优化 + 数据完整性框架 + 行级数据权限 + 前端页面能力增强
|
||||
> **评审记录:** code-reviewer 子代理评审通过一轮修复(3 Critical + 7 Important)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
CRM 插件是 ERP 平台的第一个 WASM 行业插件,已完成 3 阶段 24 任务,包含 5 实体、9 权限、7 页面类型。经 6 个专家组深度评审,发现以下结构性问题需要优先解决:
|
||||
|
||||
| 问题 | 严重级别 | 影响 |
|
||||
|------|---------|------|
|
||||
| JSONB 动态表类型安全缺失、排序全表扫描 | High | 万级数据以上性能崩溃 |
|
||||
| JSONB 零外键完整性、零级联策略 | High | 数据"脏"掉,引用断裂 |
|
||||
| 行级数据权限缺失 | Critical | 销售A能看到销售B的所有客户 |
|
||||
| plugin.admin 权限 fallback 过宽 | Critical | 超级用户权限泄露 |
|
||||
| 无关联选择器 (entity_select) | High | UX 极差,客户ID手动输入 |
|
||||
| 无看板/批量操作/图表等页面能力 | Medium | CRM 功能不完整 |
|
||||
|
||||
**核心原则:** 基座优先。所有改进沉淀为插件平台通用能力,CRM 作为第一受益者而非唯一受益者。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计目标
|
||||
|
||||
1. **JSONB 存储优化** — 百万级数据下列表查询 p95 < 200ms,搜索 p95 < 300ms
|
||||
2. **数据完整性框架** — 应用层外键校验、级联策略、字段校验、循环引用检测
|
||||
3. **行级数据权限** — 支持 self/department/department_tree/all 四级数据范围
|
||||
4. **前端页面能力增强** — 关联选择器、看板页面、批量操作、Dashboard 图表、visible_when 增强
|
||||
|
||||
---
|
||||
|
||||
## 3. JSONB 存储优化
|
||||
|
||||
### 3.1 Generated Column 混合存储
|
||||
|
||||
利用 PostgreSQL 12+ 的 `GENERATED ALWAYS AS ... STORED` 列,自动从 JSONB `data` 列提取高频字段到独立列。数据只存一份(在 JSONB 中),Generated Column 是自动派生的,零维护成本。
|
||||
|
||||
**提取规则(在 `dynamic_table.rs` 的 `create_table` 中自动判断):**
|
||||
|
||||
| 字段特征 | 提取策略 | 原因 |
|
||||
|----------|---------|------|
|
||||
| `unique == true` | Generated Column + UNIQUE INDEX | 需要精确唯一性约束 |
|
||||
| `required == true && (sortable \|\| filterable)` | Generated Column + INDEX | 需要类型化排序/筛选 |
|
||||
| `sortable == true` | Generated Column + INDEX | ORDER BY 走 B-tree |
|
||||
| `filterable == true` | Generated Column + INDEX | WHERE 走索引扫描 |
|
||||
| `searchable == true` | 保留 JSONB + pg_trgm GIN 索引 | 模糊搜索用三元组索引 |
|
||||
| 其他字段 | 保留 JSONB | 无需索引 |
|
||||
|
||||
**生成的 DDL 示例:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE plugin_erp_crm_customer (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
-- Generated Columns
|
||||
_f_code TEXT GENERATED ALWAYS AS (data->>'code') STORED,
|
||||
_f_name TEXT GENERATED ALWAYS AS (data->>'name') STORED,
|
||||
_f_customer_type TEXT GENERATED ALWAYS AS (data->>'customer_type') STORED,
|
||||
_f_status TEXT GENERATED ALWAYS AS (data->>'status') STORED,
|
||||
_f_level TEXT GENERATED ALWAYS AS (data->>'level') STORED,
|
||||
-- 标准字段
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID,
|
||||
updated_by UUID,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
version INT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- 复合索引(tenant_id 在前,支持多租户过滤)
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_tenant_cover
|
||||
ON "{t}" (tenant_id, created_at DESC)
|
||||
INCLUDE (id, data, updated_at, version)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_f_name_sort
|
||||
ON "{t}" (tenant_id, _f_name)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_{t}_f_code_uniq
|
||||
ON "{t}" (tenant_id, _f_code)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_f_type_filter
|
||||
ON "{t}" (tenant_id, _f_customer_type)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
**SQL 查询路由:** 在 `dynamic_table.rs` 中新增 `GeneratedColumnInfo` 结构,记录哪些字段被提取为 Generated Column。`build_filtered_query_sql` 和 `build_aggregate_sql` 检测到对应 Generated Column 存在时,自动将 `data->>'field'` 替换为 `_f_{field}`。
|
||||
|
||||
**类型映射:** `data->>'field'` 始终返回 TEXT。对于非字符串类型,Generated Column 需要类型转换以支持正确的排序和比较:
|
||||
|
||||
| field_type | SQL 类型 | Generated Column 表达式 |
|
||||
|------------|---------|------------------------|
|
||||
| String | TEXT | `data->>'field'` |
|
||||
| Integer | INTEGER | `(data->>'field')::INTEGER` |
|
||||
| Float | DOUBLE PRECISION | `(data->>'field')::DOUBLE PRECISION` |
|
||||
| Decimal | NUMERIC(18,4) | `(data->>'field')::NUMERIC` |
|
||||
| Boolean | BOOLEAN | `(data->>'field')::BOOLEAN` |
|
||||
| Date | DATE | `(data->>'field')::DATE` |
|
||||
| DateTime | TIMESTAMPTZ | `(data->>'field')::TIMESTAMPTZ` |
|
||||
| Uuid | UUID | `(data->>'field')::UUID` |
|
||||
|
||||
`dynamic_table.rs` 的 `create_table` 根据 `PluginField.field_type` 自动选择正确的 SQL 类型和类型转换表达式。
|
||||
|
||||
**元数据表:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS plugin_entity_columns (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL, -- 多租户标准字段
|
||||
plugin_entity_id UUID NOT NULL REFERENCES plugin_entities(id),
|
||||
field_name VARCHAR(100) NOT NULL,
|
||||
column_name VARCHAR(100) NOT NULL, -- 如 _f_name
|
||||
sql_type VARCHAR(50) NOT NULL, -- 如 TEXT, INTEGER, UUID
|
||||
is_generated BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Schema 演变策略(重新安装/字段变更):**
|
||||
|
||||
当前 `service.rs` 的 `install` 使用 `CREATE TABLE IF NOT EXISTS`。引入 Generated Column 后,安装流程改为:
|
||||
|
||||
1. **首次安装**:`CREATE TABLE` 包含所有 Generated Column。
|
||||
2. **重新安装(同版本)**:`IF NOT EXISTS` 跳过表创建。比对 `plugin_entity_columns` 元数据与当前 manifest 的字段列表,执行增量 ALTER:
|
||||
- 新增字段:`ALTER TABLE ADD COLUMN _f_{name} {type} GENERATED ALWAYS AS (...) STORED`
|
||||
- 删除字段:`ALTER TABLE DROP COLUMN _f_{name}`(仅删除 Generated Column,JSONB data 中的原始值保留)
|
||||
- 类型变更:PostgreSQL 不支持 ALTER GENERATED COLUMN 的表达式,需 DROP + ADD
|
||||
3. **插件卸载时**:表被删除,元数据自动清理。
|
||||
|
||||
`dynamic_table.rs` 新增 `migrate_table` 方法,接受已有列列表和目标列列表,生成增量 DDL。
|
||||
|
||||
### 3.2 pg_trgm 模糊搜索加速
|
||||
|
||||
**迁移文件:** 在 `erp-server/migration` 中新增迁移启用 pg_trgm 扩展:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
```
|
||||
|
||||
**索引创建:** `create_table` 中 searchable 字段的索引从普通 B-tree 改为 GIN 三元组:
|
||||
|
||||
```sql
|
||||
CREATE INDEX IF NOT EXISTS idx_{t}_{f}_trgm
|
||||
ON "{t}" USING GIN ((data->>'{f}') gin_trgm_ops)
|
||||
WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
启用后 `ILIKE '%keyword%'` 从全表扫描退化为索引扫描,百万级数据搜索从 2-5s 降至 50-200ms。
|
||||
|
||||
### 3.3 Keyset Pagination
|
||||
|
||||
**向后兼容设计:** API 同时支持 OFFSET 和 cursor 两种分页模式。
|
||||
|
||||
`data_dto.rs` 中 `PluginDataListParams` 新增 `cursor` 字段:
|
||||
|
||||
```rust
|
||||
pub struct PluginDataListParams {
|
||||
pub page: Option<u64>, // 保留,向后兼容
|
||||
pub page_size: Option<u64>,
|
||||
pub cursor: Option<String>, // 新增:Base64 编码的游标
|
||||
pub search: Option<String>,
|
||||
pub filter: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
`dynamic_table.rs` 中 SQL 构建逻辑:当 `cursor` 存在时使用 keyset 分页:
|
||||
|
||||
**游标编码格式:** JSON 结构 `{ "v": [value1, value2, ...], "id": "uuid" }`,Base64 编码。`v` 数组存储排序字段的值(与 sort_by 顺序一致),`id` 是记录主键作为最终 tiebreaker。多列排序时 `v` 包含多个值。字段值为 null 时存储 JSON null。
|
||||
|
||||
客户端必须在每次请求中同时发送 `cursor` 和 `sort_by`/`sort_order`(游标不嵌入排序信息,保持无状态)。
|
||||
|
||||
```sql
|
||||
-- 第一页
|
||||
SELECT ... ORDER BY _f_name ASC, id ASC LIMIT 20;
|
||||
|
||||
-- 后续页(cursor 解码后)
|
||||
SELECT ... WHERE (_f_name, id) > ($cursor_sort_val, $cursor_id)
|
||||
ORDER BY _f_name ASC, id ASC LIMIT 20;
|
||||
```
|
||||
|
||||
### 3.4 Schema 缓存
|
||||
|
||||
在 `PluginState` 中添加 `moka` LRU 缓存,消除每次数据请求的 `resolve_entity_info` 查库:
|
||||
|
||||
```rust
|
||||
pub entity_cache: Cache<String, EntityInfo>, // key: "{plugin_id}:{entity_name}:{tenant_id}"
|
||||
```
|
||||
|
||||
TTL 5 分钟,容量 1000 条。
|
||||
|
||||
### 3.5 聚合 Redis 缓存
|
||||
|
||||
`data_service.rs` 的 create/update/delete 成功后增量更新 Redis 统计:
|
||||
|
||||
```
|
||||
plugin:{plugin_id}:{entity}:count:{tenant_id} → 计数值
|
||||
plugin:{plugin_id}:{entity}:agg:{field}:{tenant_id} → JSON {key: count}
|
||||
```
|
||||
|
||||
Dashboard 查询直接从 Redis 读取,TTL 5 分钟兜底。
|
||||
|
||||
### 3.6 性能 SLA 目标
|
||||
|
||||
**测试条件:** PostgreSQL 与应用同机部署(Redis localhost 延迟 < 1ms)。SLA 包含 Redis 往返(schema 缓存 + 部门缓存)。冷启动(Redis 缓存未命中)首次查询允许 3x SLA 宽限。
|
||||
|
||||
| 查询场景 | 数据量 | p50 | p95 | p99 |
|
||||
|----------|--------|-----|-----|-----|
|
||||
| 按 ID 获取单条 | 100万 | < 5ms | < 10ms | < 20ms |
|
||||
| 列表查询(默认排序) | 100万 | < 20ms | < 50ms | < 100ms |
|
||||
| 列表查询(字段排序) | 100万 | < 30ms | < 100ms | < 200ms |
|
||||
| 搜索(ILIKE) | 100万 | < 50ms | < 100ms | < 300ms |
|
||||
| 聚合查询 | 100万 | < 50ms (缓存) | < 500ms (实时) | - |
|
||||
| Dashboard 全量加载 | 100万 | < 200ms | < 500ms | - |
|
||||
|
||||
### 3.7 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | 主要改动 — Generated Column DDL、索引策略、SQL 路由、keyset 分页 |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 缓存逻辑、聚合 Redis 缓存 |
|
||||
| `crates/erp-plugin/src/data_dto.rs` | 新增 cursor 参数 |
|
||||
| `crates/erp-plugin/src/state.rs` | 新增 entity_cache |
|
||||
| `crates/erp-plugin/src/manifest.rs` | PluginEntityColumns 元数据 |
|
||||
| `crates/erp-server/migration/src/` | pg_trgm 扩展 + plugin_entity_columns 表 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据完整性框架
|
||||
|
||||
### 4.1 外键引用声明
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `ref_entity` 字段:
|
||||
|
||||
```rust
|
||||
pub struct PluginField {
|
||||
pub name: String,
|
||||
pub field_type: PluginFieldType,
|
||||
// ...已有字段...
|
||||
pub ref_entity: Option<String>, // 新增:引用的实体名
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "所属客户"
|
||||
ref_entity = "customer" # 声明外键引用
|
||||
```
|
||||
|
||||
### 4.2 应用层外键校验
|
||||
|
||||
在 `data_service.rs` 的 `validate_data` 函数中扩展:
|
||||
|
||||
```
|
||||
create/update 时:
|
||||
遍历 fields,如果 field.ref_entity 存在:
|
||||
1. 从 data 中取出该字段的 UUID 值
|
||||
2. 如果值为 null 或空字符串且 required == false → 跳过校验
|
||||
3. 如果是自引用(ref_entity == 当前实体名)且为 create 操作:
|
||||
a. 如果引用的是自身 ID → 跳过(记录尚不存在,无法校验)
|
||||
b. 如果引用的是其他记录 → 正常校验
|
||||
4. 查询 ref_entity 对应的动态表,验证该记录存在且未删除
|
||||
5. 不存在则返回 ValidationError
|
||||
|
||||
TOCTOU 竞态说明:
|
||||
外键校验与引用记录删除之间存在理论上的竞态窗口。
|
||||
对于 JSONB 动态表,这是可接受的风险——应用层校验已大幅降低孤立引用概率。
|
||||
如果未来需要严格保证,可在 flush_ops 中增加二次校验(事务内 SELECT FOR UPDATE)。
|
||||
```
|
||||
|
||||
### 4.3 级联删除策略
|
||||
|
||||
`manifest.rs` 新增 `PluginRelation` 结构:
|
||||
|
||||
```rust
|
||||
pub struct PluginRelation {
|
||||
pub entity: String, // 关联实体名
|
||||
pub foreign_key: String, // 关联实体中的外键字段名
|
||||
pub on_delete: OnDeleteStrategy, // 级联策略
|
||||
}
|
||||
|
||||
pub enum OnDeleteStrategy {
|
||||
Nullify, // 置空外键字段
|
||||
Cascade, // 级联软删除
|
||||
Restrict, // 存在关联时拒绝删除
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[schema.entities.relations]]
|
||||
entity = "contact"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "nullify"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "communication"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer_tag"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
```
|
||||
|
||||
`data_service.rs` 的 `delete` 方法中,在软删除记录之前:
|
||||
|
||||
```
|
||||
1. 从 manifest 中查找该实体声明的所有 relations
|
||||
2. 对每个 relation:
|
||||
- Restrict: 查询关联实体是否有引用 → 有则拒绝删除
|
||||
- Nullify: 批量 UPDATE 关联记录,将 foreign_key 设为 null
|
||||
- Cascade: 批量软删除关联记录(级联深度上限 3 层,防止 A→B→C→D 无限递归)
|
||||
```
|
||||
|
||||
### 4.4 字段校验规则
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `validation` 子结构:
|
||||
|
||||
```rust
|
||||
pub struct FieldValidation {
|
||||
pub pattern: Option<String>, // 正则表达式
|
||||
pub message: Option<String>, // 校验失败提示
|
||||
}
|
||||
```
|
||||
|
||||
manifest TOML 中的使用方式:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "phone"
|
||||
field_type = "string"
|
||||
display_name = "手机号"
|
||||
validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" }
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "email"
|
||||
field_type = "string"
|
||||
display_name = "邮箱"
|
||||
validation = { pattern = "^[\\w.-]+@[\\w.-]+\\.\\w+$", message = "邮箱格式不正确" }
|
||||
```
|
||||
|
||||
`validate_data` 扩展:对有 `validation.pattern` 的字段,使用 `regex` crate 做正则匹配。
|
||||
|
||||
### 4.5 循环引用检测
|
||||
|
||||
`manifest.rs` 的 `PluginField` 新增 `no_cycle` 字段:
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "parent_id"
|
||||
field_type = "uuid"
|
||||
ref_entity = "customer"
|
||||
no_cycle = true # 声明不允许循环引用
|
||||
```
|
||||
|
||||
`data_service.rs` 的 `update` 方法中,当 `no_cycle == true` 的字段被修改时:
|
||||
|
||||
```
|
||||
1. 从 data 中取出新值 (new_parent_id)
|
||||
2. 初始化 visited = {record_id}
|
||||
3. 循环:查询 current 的 parent_id → 如果在 visited 中则报错 → 加入 visited
|
||||
4. 直到 parent_id 为 null 或到达根节点
|
||||
```
|
||||
|
||||
### 4.6 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-plugin/src/manifest.rs` | 新增 ref_entity / PluginRelation / FieldValidation / no_cycle |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 外键校验 / 级联删除 / 字段校验 / 循环检测 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | 为现有字段添加 ref_entity / relations / validation / no_cycle 声明 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 行级数据权限
|
||||
|
||||
### 5.1 数据范围模型
|
||||
|
||||
在实体级别声明是否启用行级数据权限,在权限级别声明数据范围等级。
|
||||
|
||||
**manifest 扩展:**
|
||||
|
||||
```toml
|
||||
[[schema.entities]]
|
||||
name = "customer"
|
||||
display_name = "客户"
|
||||
data_scope = true # 启用行级数据权限
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "owner_id"
|
||||
field_type = "uuid"
|
||||
display_name = "负责人"
|
||||
scope_role = "owner" # 标记为数据权限的"所有者"字段
|
||||
```
|
||||
|
||||
**权限声明扩展:**
|
||||
|
||||
```toml
|
||||
[[permissions]]
|
||||
code = "customer.list"
|
||||
name = "查看客户"
|
||||
data_scope_levels = ["self", "department", "department_tree", "all"]
|
||||
```
|
||||
|
||||
### 5.2 数据范围等级定义
|
||||
|
||||
| 等级 | 含义 | SQL 条件 |
|
||||
|------|------|---------|
|
||||
| `self` | 只看自己负责/创建的 | `data->>'owner_id' = current_user_id OR created_by = current_user_id` |
|
||||
| `department` | 看本部门所有人的 | `data->>'owner_id' IN (部门用户列表)` |
|
||||
| `department_tree` | 看本部门及下级部门 | `data->>'owner_id' IN (部门树用户列表)` |
|
||||
| `all` | 看全部 | 无额外条件 |
|
||||
|
||||
### 5.3 实现路径
|
||||
|
||||
**TenantContext 扩展:** `erp-core` 的 `TenantContext` 结构新增 `department_ids: Vec<Uuid>` 字段(注意:用户可通过岗位属于多个部门)。JWT claims 中新增 `dept_ids` 字段,JWT 中间件在构造 TenantContext 时填充。
|
||||
|
||||
**多部门用户处理:** 用户通过 Position 关联到多个 Department。`department` 级别取所有所属部门的并集;`department_tree` 取所有所属部门及其下级部门的并集。没有岗位/部门的用户在 `department` 和 `department_tree` 级别下只能看到自己创建的数据(降级为 self)。
|
||||
|
||||
**角色权限表扩展:** `role_permissions` 表新增 `data_scope` 字段(VARCHAR(32),默认值 `'all'`)。新增迁移文件 `m20260418_*_add_data_scope_to_role_permissions.rs`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE role_permissions ADD COLUMN IF NOT EXISTS data_scope VARCHAR(32) NOT NULL DEFAULT 'all';
|
||||
```
|
||||
|
||||
**管理界面适配:** 角色权限分配界面新增"数据范围"下拉选项,管理员为每个权限分配时选择 self/department/department_tree/all。
|
||||
|
||||
**查询注入:** `data_service.rs` 的 `list` / `count` / `aggregate` 方法中:
|
||||
|
||||
```
|
||||
1. 从权限检查结果中获取该权限对应的 data_scope 等级
|
||||
2. 如果实体启用了 data_scope:
|
||||
- self: 注入 owner_id / created_by 过滤条件
|
||||
- department: 查询用户所在部门的所有用户 ID,注入 IN 条件
|
||||
- department_tree: 递归查询部门树,注入 IN 条件
|
||||
- all: 无额外条件
|
||||
3. 将条件追加到 dynamic_table 的 SQL 构建中
|
||||
```
|
||||
|
||||
**部门用户缓存:** 使用 Redis 缓存部门-用户映射关系,TTL 10 分钟,避免每次查询都递归查部门树。当部门分配变更时通过 EventBus 事件 (`department.member_changed`) 失效缓存。
|
||||
|
||||
### 5.4 权限 fallback 收紧
|
||||
|
||||
**当前行为(危险):** `data_handler.rs` 中,如果没有实体级权限,fallback 到 `plugin.admin`,获得所有数据访问权。
|
||||
|
||||
**修改后:** 移除 fallback 逻辑。权限检查链改为:
|
||||
|
||||
```
|
||||
1. 检查实体级权限 ({manifest_id}.{entity}.{action})
|
||||
2. 存在 → 通过,附带 data_scope
|
||||
3. 不存在 → 拒绝 (403)
|
||||
```
|
||||
|
||||
`plugin.admin` 只管理插件生命周期(上传/安装/启用/禁用/卸载),不自动获得数据访问权。需要显式分配实体级权限。
|
||||
|
||||
**迁移策略(避免现有管理员失去访问):** 在收紧 fallback 的迁移中,同时执行以下补偿:
|
||||
|
||||
```sql
|
||||
-- 为所有拥有 plugin.admin 权限的角色,自动分配所有已安装插件的实体级权限
|
||||
-- data_scope 默认设为 'all'(管理员级别)
|
||||
INSERT INTO role_permissions (id, role_id, permission_id, tenant_id, data_scope, ...)
|
||||
SELECT gen_random_uuid(), rp.role_id, p.id, rp.tenant_id, 'all', ...
|
||||
FROM role_permissions rp
|
||||
JOIN permissions p ON p.tenant_id = rp.tenant_id
|
||||
WHERE rp.permission_id = (SELECT id FROM permissions WHERE code = 'plugin.admin')
|
||||
AND p.code LIKE 'erp-%' -- 所有插件实体权限
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp2
|
||||
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = p.id
|
||||
);
|
||||
```
|
||||
|
||||
这确保现有管理员在 fallback 收紧后仍保持完整的数据访问能力。
|
||||
|
||||
### 5.5 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-core/src/types.rs` | TenantContext 新增 department_ids 字段 |
|
||||
| `crates/erp-auth/src/middleware/jwt_auth.rs` | JWT claims 解析 department_ids |
|
||||
| `crates/erp-plugin/src/manifest.rs` | data_scope / scope_role / data_scope_levels |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 查询条件注入 |
|
||||
| `crates/erp-plugin/src/handler/data_handler.rs` | 移除权限 fallback |
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | SQL 构建支持数据范围条件 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | customer 实体添加 data_scope / owner_id |
|
||||
| `crates/erp-server/migration/src/` | 新增 data_scope 列 + 权限补偿迁移 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端页面能力增强
|
||||
|
||||
### 6.1 关联选择器 (entity_select)
|
||||
|
||||
**Schema 扩展:** `PluginFieldSchema` 新增字段:
|
||||
|
||||
```typescript
|
||||
interface PluginFieldSchema {
|
||||
// ...已有字段...
|
||||
ref_entity?: string; // 引用的实体名
|
||||
ref_label_field?: string; // 显示字段
|
||||
ref_search_fields?: string[]; // 搜索字段
|
||||
cascade_from?: string; // 级联过滤来源字段
|
||||
cascade_filter?: string; // 级联过滤目标字段
|
||||
}
|
||||
```
|
||||
|
||||
**新增组件:** `EntitySelect.tsx` — 通用远程搜索选择器
|
||||
|
||||
```
|
||||
Props: pluginId, entity, labelField, searchFields, cascadeFrom?, cascadeFilter?, value?, onChange?
|
||||
内部: listPluginData(pluginId, entity, {search, filter}) → Ant Design Select + showSearch
|
||||
```
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
display_name = "所属客户"
|
||||
ui_widget = "entity_select"
|
||||
ref_entity = "customer"
|
||||
ref_label_field = "name"
|
||||
ref_search_fields = ["name", "code"]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "contact_id"
|
||||
field_type = "uuid"
|
||||
display_name = "关联联系人"
|
||||
ui_widget = "entity_select"
|
||||
ref_entity = "contact"
|
||||
ref_label_field = "name"
|
||||
ref_search_fields = ["name"]
|
||||
cascade_from = "customer_id" # 选了客户后自动过滤
|
||||
cascade_filter = "customer_id"
|
||||
```
|
||||
|
||||
### 6.2 Kanban 看板页面
|
||||
|
||||
**Schema 扩展:** `PluginPageType` 新增 `Kanban` 变体。
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "kanban"
|
||||
entity = "customer"
|
||||
label = "销售漏斗"
|
||||
icon = "swap"
|
||||
lane_field = "level"
|
||||
lane_order = ["potential", "normal", "vip", "svip"]
|
||||
card_title_field = "name"
|
||||
card_subtitle_field = "code"
|
||||
card_fields = ["name", "code", "region", "status"]
|
||||
enable_drag = true
|
||||
```
|
||||
|
||||
**新增组件:** `PluginKanbanPage.tsx`
|
||||
|
||||
- 使用 `@dnd-kit/core` + `@dnd-kit/sortable` 实现跨列拖拽
|
||||
- 每列使用 Ant Design Card 渲染卡片
|
||||
- 每列内支持虚拟滚动(节点数 > 50 时)
|
||||
- 拖拽结束调用 `PATCH /plugins/{id}/{entity}/{recordId}` 更新 lane_field 值
|
||||
|
||||
**后端新增:** `PATCH` 部分更新端点(当前只有 PUT 全量更新):
|
||||
|
||||
```
|
||||
PATCH /api/v1/plugins/{plugin_id}/{entity}/{id}
|
||||
Body: { "data": { "level": "vip" }, "version": 3 }
|
||||
```
|
||||
|
||||
与 PUT 的区别:PATCH 只更新 data 中提供的字段,未提供的字段保持不变。
|
||||
|
||||
### 6.3 批量操作
|
||||
|
||||
**CRUD 页面增强:** `PluginCRUDPage.tsx` 新增 `rowSelection` 和批量操作栏。
|
||||
|
||||
**manifest TOML:**
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = "customer"
|
||||
enable_batch = true
|
||||
|
||||
[[ui.pages.batch_actions]]
|
||||
label = "批量删除"
|
||||
action = "batch_delete"
|
||||
permission = "customer.manage"
|
||||
confirm = true
|
||||
|
||||
[[ui.pages.batch_actions]]
|
||||
label = "批量修改状态"
|
||||
action = "batch_update"
|
||||
update_field = "status"
|
||||
permission = "customer.manage"
|
||||
```
|
||||
|
||||
**后端新增:** `POST /api/v1/plugins/{id}/{entity}/batch`
|
||||
|
||||
```rust
|
||||
pub enum BatchAction {
|
||||
BatchDelete { ids: Vec<Uuid> },
|
||||
BatchUpdate { ids: Vec<Uuid>, data: serde_json::Value },
|
||||
}
|
||||
```
|
||||
|
||||
批量操作在单个事务中执行,有上限(默认 100 条)。
|
||||
|
||||
### 6.4 visible_when 表达式增强
|
||||
|
||||
**当前:** 只支持 `field == 'value'` 单一等式。
|
||||
|
||||
**增强后支持:**
|
||||
|
||||
```toml
|
||||
visible_when = "customer_type == 'enterprise'"
|
||||
visible_when = "customer_type == 'enterprise' AND level == 'vip'"
|
||||
visible_when = "status == 'active' OR status == 'pending'"
|
||||
visible_when = "NOT status == 'blacklist'"
|
||||
visible_when = "customer_type == 'enterprise' AND (level == 'vip' OR level == 'svip')"
|
||||
```
|
||||
|
||||
**前端实现:** 新建 `exprEvaluator.ts`,约 100 行递归下降表达式解析器:
|
||||
|
||||
```typescript
|
||||
interface ExprNode {
|
||||
type: 'eq' | 'and' | 'or' | 'not';
|
||||
field?: string;
|
||||
value?: string;
|
||||
left?: ExprNode;
|
||||
right?: ExprNode;
|
||||
operand?: ExprNode;
|
||||
}
|
||||
|
||||
function parseExpr(input: string): ExprNode;
|
||||
function evaluateExpr(node: ExprNode, values: Record<string, unknown>): boolean;
|
||||
```
|
||||
|
||||
不引入外部依赖,不使用 eval。
|
||||
|
||||
### 6.5 Dashboard 图表增强
|
||||
|
||||
**Schema 扩展:** Dashboard 页面支持 widgets 声明:
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "dashboard"
|
||||
label = "统计概览"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "stat_card"
|
||||
entity = "customer"
|
||||
title = "客户总数"
|
||||
icon = "team"
|
||||
color = "#4F46E5"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "bar_chart"
|
||||
entity = "customer"
|
||||
title = "客户地区分布"
|
||||
dimension_field = "region"
|
||||
metric = "count"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "pie_chart"
|
||||
entity = "customer"
|
||||
title = "客户类型分布"
|
||||
dimension_field = "customer_type"
|
||||
metric = "count"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "funnel_chart"
|
||||
entity = "customer"
|
||||
title = "客户等级漏斗"
|
||||
dimension_field = "level"
|
||||
dimension_order = ["potential", "normal", "vip", "svip"]
|
||||
metric = "count"
|
||||
```
|
||||
|
||||
**图表库:** 使用 `@ant-design/charts`(Ant Design 生态一致,支持按需引入)。
|
||||
|
||||
**后端新增:** timeseries 聚合 API:
|
||||
|
||||
```
|
||||
GET /api/v1/plugins/{id}/{entity}/timeseries?time_field=occurred_at&time_grain=week&start=2026-01-01&end=2026-04-17
|
||||
|
||||
响应:{ "data": [{ "period": "2026-W01", "count": 12 }, ...] }
|
||||
```
|
||||
|
||||
SQL 实现:`date_trunc('week', (data->>'occurred_at')::timestamp)`
|
||||
|
||||
**数据钻取:** 图表点击维度值时跳转到 CRUD 页面并自动带上筛选条件。`PluginCRUDPage` 支持从 URL query 参数初始化筛选。
|
||||
|
||||
### 6.6 前端文件拆分
|
||||
|
||||
| 当前文件 | 行数 | 拆分方案 |
|
||||
|---------|------|---------|
|
||||
| `PluginGraphPage.tsx` | 1081 | → `graphRenderer.ts` + `graphLayout.ts` + `graphInteraction.ts` |
|
||||
| `PluginCRUDPage.tsx` | 617 | → `CrudTable.tsx` + `CrudForm.tsx` + `CrudDetail.tsx` |
|
||||
| `PluginDashboardPage.tsx` | 647 | → `DashboardWidgets.tsx` + `dashboardTypes.ts` |
|
||||
|
||||
拆分后每个文件控制在 400 行以内。
|
||||
|
||||
### 6.7 涉及文件
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `apps/web/src/components/EntitySelect.tsx` | 新增 |
|
||||
| `apps/web/src/pages/PluginKanbanPage.tsx` | 新增 |
|
||||
| `apps/web/src/utils/exprEvaluator.ts` | 新增 |
|
||||
| `apps/web/src/pages/PluginCRUDPage.tsx` | 重构 — 拆分 + 批量操作 + entity_select + visible_when |
|
||||
| `apps/web/src/pages/PluginGraphPage.tsx` | 重构 — 拆分 |
|
||||
| `apps/web/src/pages/PluginDashboardPage.tsx` | 重构 — 图表 + 拆分 |
|
||||
| `apps/web/src/pages/PluginTreePage.tsx` | 优化 — 懒加载 |
|
||||
| `apps/web/src/api/plugins.ts` | Schema 类型扩展 |
|
||||
| `apps/web/src/api/pluginData.ts` | 新增 batch / timeseries / cursor API |
|
||||
| `apps/web/src/App.tsx` | Kanban 路由注册 |
|
||||
| `crates/erp-plugin/src/handler/data_handler.rs` | 新增 PATCH / batch 端点 |
|
||||
| `crates/erp-plugin/src/data_service.rs` | batch / timeseries / partial update(PATCH 只合并 data 中的字段) |
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | 新增 `build_patch_sql` 部分更新 SQL 构建器 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与缓解
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| Generated Column 的 ALTER TABLE 锁表 | 中 | 中 | 插件安装时在低峰期执行;万级数据以内锁表时间 < 1s |
|
||||
| pg_trgm 索引空间开销(约 2-3x 原始文本) | 低 | 低 | 只为 searchable 的短文本字段创建 |
|
||||
| 行级权限的部门查询性能 | 中 | 中 | Redis 缓存部门树,TTL 10 分钟 |
|
||||
| 批量操作事务过大 | 低 | 中 | 上限 100 条;超过则分批执行 |
|
||||
| 前端重构引入回归 | 中 | 高 | 逐文件拆分,每步验证现有功能不变 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 不在范围内(后续版本)
|
||||
|
||||
以下内容在本次设计中**不涉及**,记录为已知需求:
|
||||
|
||||
- WASM Guest 业务逻辑增强 (L2/L3 插件模型)
|
||||
- 插件版本升级迁移框架
|
||||
- 跨插件通信 (事件契约 + 只读查询)
|
||||
- 插件间 RPC / 自定义 API 端点
|
||||
- 插件市场 / 分发架构
|
||||
- CRM 新增实体 (lead / opportunity / activity)
|
||||
- WIT 接口版本化
|
||||
- 图谱 LOD + WebGL 渲染
|
||||
- Iframe / Web Component 自定义 UI
|
||||
|
||||
这些将在后续的设计规格中详细展开。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,604 @@
|
||||
# CRM 插件平台标杆 — P0 基础能力设计规格
|
||||
|
||||
> **版本**: v1.1 (修正版 — 基于代码审查发现,对齐现有实现)
|
||||
> **日期**: 2026-04-18
|
||||
> **状态**: Draft
|
||||
> **定位**: 插件平台标杆 — CRM 是试金石,打磨通用能力
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
### 1.1 为什么要做这个
|
||||
|
||||
CRM 插件是 ERP 平台的第一个行业插件,当前状态是"客户通讯录 + 标签 + 关系图谱",距离一流 CRM(Salesforce/HubSpot/Pipedrive)有显著差距。但更大的问题是:**CRM 暴露的差距不在于 CRM 本身,而在于插件平台的基础能力缺失。**
|
||||
|
||||
具体来说:
|
||||
- ~~5 个实体之间有明确的 FK 关系,但 manifest 无法声明~~ → **已有 `PluginRelation` + 级联删除**,但缺少 `name`/`display_field`/关系类型等前端渲染信息
|
||||
- 35+ 字段有 required/unique/pattern 校验,但缺少 `min_length`/`max_length`/`min_value`/`max_value` 扩展校验
|
||||
- Dashboard/Graph 页面硬编码了 CRM 专属颜色和标题,第二个插件无法复用
|
||||
- CRM 的 `plugin.toml` 没有声明 `relations`,导致现有级联能力未被使用
|
||||
- 批量删除和 PATCH 部分更新绕过了现有校验
|
||||
|
||||
如果不在 P0 阶段补齐这些基础,所有后续业务功能(商机、合同、报价)都会建在不稳固的地基上。
|
||||
|
||||
### 1.2 设计原则
|
||||
|
||||
| 原则 | 含义 |
|
||||
|------|------|
|
||||
| **平台优先** | 每个能力都是平台层的,CRM 只是第一个使用者 |
|
||||
| **零改动复用** | inventory/生产/财务插件不应为这些能力写任何额外代码 |
|
||||
| **Manifest 驱动** | 所有行为由 plugin.toml 声明驱动,不写硬编码 |
|
||||
| **双层保障** | 前端即时反馈 + 后端最终防线,缺一不可 |
|
||||
|
||||
### 1.3 一流 CRM 差距分析摘要
|
||||
|
||||
| 类别 | 差距 | 本规格是否覆盖 |
|
||||
|------|------|--------------|
|
||||
| 实体关系 + 级联删除 | 致命 — 删除客户产生孤儿数据 | **P0-1 覆盖** |
|
||||
| 字段校验 + FK 完整性 | 严重 — 数据质量无保障 | **P0-2 覆盖** |
|
||||
| 前端通用化 | 中等 — 第二个插件无法复用 Dashboard/Graph | **P0-3 覆盖** |
|
||||
| 商机/漏斗/合同 | 严重 — 核心业务缺失 | P2(本规格不覆盖) |
|
||||
| 导入导出/批量操作 | 中等 — ERP 刚需 | P1(后续规格) |
|
||||
| 全局搜索/保存视图 | 中等 — UX 缺失 | P1(后续规格) |
|
||||
| WASM 活化 | 低 — 当前空操作不影响功能 | P2(后续规格) |
|
||||
|
||||
---
|
||||
|
||||
## 2. P0-1: 实体关系声明 + ref_entity + 级联策略
|
||||
|
||||
### 2.1 Manifest Schema 扩展
|
||||
|
||||
**现有基础**:`PluginRelation` 已存在(`manifest.rs:184-189`),包含 `entity`、`foreign_key`、`on_delete` 三个字段。级联删除已在 `data_service.rs:330-395` 中实现。
|
||||
|
||||
**扩展方向**:在现有结构上新增字段,保持向后兼容。
|
||||
|
||||
```toml
|
||||
# === 一对多关系 (customer → contacts) ===
|
||||
[[schema.entities.relations]]
|
||||
entity = "contact" # 目标实体 (已有字段)
|
||||
foreign_key = "customer_id" # FK 字段 (已有字段)
|
||||
on_delete = "cascade" # cascade | nullify | restrict (已有枚举)
|
||||
# ↓ 新增字段 (可选,向后兼容)
|
||||
name = "contacts" # 关系显示名,用于前端标签
|
||||
type = "one_to_many" # 关系类型 (one_to_many | many_to_one | many_to_many)
|
||||
display_field = "name" # EntitySelect 下拉显示字段
|
||||
|
||||
# === 多对一关系 (contact → customer,含自引用) ===
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer"
|
||||
foreign_key = "parent_id"
|
||||
on_delete = "nullify"
|
||||
name = "parent"
|
||||
type = "many_to_one"
|
||||
display_field = "name"
|
||||
|
||||
# === 多对多关系 (customer ↔ customer,通过中间表) ===
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer"
|
||||
foreign_key = "from_customer_id" # 中间表中的源 FK
|
||||
on_delete = "nullify"
|
||||
name = "related_customers"
|
||||
type = "many_to_many"
|
||||
through_entity = "customer_relationship"
|
||||
through_source_field = "from_customer_id"
|
||||
through_target_field = "to_customer_id"
|
||||
```
|
||||
|
||||
#### 关系类型定义 (新增 `type` 字段)
|
||||
|
||||
| 类型 | 含义 | foreign_key 位置 | CRM 场景 |
|
||||
|------|------|-----------------|---------|
|
||||
| `one_to_many` | 一个父 → 多个子 | 子实体上 | customer → contacts |
|
||||
| `many_to_one` | 多个子 → 一个父 | 本实体上 | contact → customer |
|
||||
| `many_to_many` | 双向多对多 | 中间表上 | customer ↔ customer |
|
||||
|
||||
> `type` 字段为 `Option<RelationType>`,默认 `OneToMany`。不声明则现有行为不变。
|
||||
|
||||
#### 级联策略 (保持现有枚举不变)
|
||||
|
||||
| 策略 | TOML 值 | 行为 | 适用场景 |
|
||||
|------|---------|------|---------|
|
||||
| `Cascade` | `"cascade"` | 子记录 `deleted_at = now()` | 强所有权:客户→联系人 |
|
||||
| `Nullify` | `"nullify"` | FK 字段设 NULL | 弱引用:联系人→上级客户 |
|
||||
| `Restrict` | `"restrict"` | 有子记录时阻止删除(409) | 关键数据:不允许孤立 |
|
||||
|
||||
### 2.2 后端实现
|
||||
|
||||
#### 数据结构扩展 (`manifest.rs`)
|
||||
|
||||
**在现有 `PluginRelation` 上新增字段**(不替换):
|
||||
|
||||
```rust
|
||||
// 现有字段保持不变
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginRelation {
|
||||
pub entity: String, // 已有
|
||||
pub foreign_key: String, // 已有
|
||||
pub on_delete: OnDeleteStrategy, // 已有 (Cascade | Nullify | Restrict)
|
||||
// ↓ 新增可选字段
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub relation_type: Option<RelationType>,
|
||||
#[serde(default)]
|
||||
pub display_field: Option<String>,
|
||||
// many_to_many 专属
|
||||
#[serde(default)]
|
||||
pub through_entity: Option<String>,
|
||||
#[serde(default)]
|
||||
pub through_source_field: Option<String>,
|
||||
#[serde(default)]
|
||||
pub through_target_field: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RelationType {
|
||||
#[default]
|
||||
OneToMany,
|
||||
ManyToOne,
|
||||
ManyToMany,
|
||||
}
|
||||
```
|
||||
|
||||
#### 级联删除 (已有,需增强)
|
||||
|
||||
`data_service.rs:330-395` 已实现 `Restrict`/`Nullify`/`Cascade` 三种策略。需增强:
|
||||
|
||||
1. **级联影响信息返回**:Restrict 时返回 `affected_count` 和 `relation.name`,方便前端展示
|
||||
2. **批量删除级联**:`batch_delete` (data_service.rs:417-520) 当前绕过级联,需补充
|
||||
3. **many_to_many 级联**:基于 `through_entity` 清理中间表记录
|
||||
|
||||
#### 级联策略执行 (已有,需增强错误信息)
|
||||
|
||||
现有 `data_service.rs:330-395` 已实现。增强点:
|
||||
|
||||
1. **Restrict 错误增强**:返回 `affected_count` 和 `relation.name`
|
||||
2. **批量删除级联**:`batch_delete` (data_service.rs:417-520) 当前绕过级联,需补充
|
||||
3. **PATCH 校验**:`partial_update` (data_service.rs:291-327) 当前绕过 `validate_data`,需补充
|
||||
4. **many_to_many 级联**:基于 `through_entity` 清理中间表记录
|
||||
|
||||
#### FK 存在性校验 (已有 `validate_ref_entities`)
|
||||
|
||||
`data_service.rs:834-899` 已实现 `validate_ref_entities`。需确保 `partial_update` (PATCH) 也调用此函数。
|
||||
|
||||
### 2.3 前端实现
|
||||
|
||||
#### 前端类型扩展
|
||||
|
||||
`apps/web/src/api/plugins.ts` 需更新:
|
||||
|
||||
```typescript
|
||||
// PluginEntitySchema 新增
|
||||
interface PluginEntitySchema {
|
||||
// ... existing fields
|
||||
relations?: PluginRelationSchema[];
|
||||
}
|
||||
|
||||
interface PluginRelationSchema {
|
||||
entity: string;
|
||||
foreign_key: string;
|
||||
on_delete: 'cascade' | 'nullify' | 'restrict';
|
||||
name?: string;
|
||||
type?: 'one_to_many' | 'many_to_one' | 'many_to_many';
|
||||
display_field?: string;
|
||||
}
|
||||
|
||||
// PluginFieldSchema 新增 validation 属性
|
||||
interface PluginFieldSchema {
|
||||
// ... existing fields
|
||||
validation?: {
|
||||
pattern?: string;
|
||||
message?: string;
|
||||
min_length?: number;
|
||||
max_length?: number;
|
||||
min_value?: number;
|
||||
max_value?: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### EntitySelect 增强 (已有基础)
|
||||
|
||||
字段有 `ref_entity` 属性时,CRUD 表单已自动渲染为 EntitySelect。增强点:
|
||||
- 优先使用 `relation.display_field` 作为下拉显示字段(fallback 到现有 `ref_label_field`)
|
||||
- 关联子表标题使用 `relation.name`
|
||||
|
||||
#### 详情页关联子表自动渲染
|
||||
|
||||
Entity 的 `one_to_many` relations 自动在详情页渲染为内嵌 CRUD 表格:
|
||||
- Compact 模式 + 自动过滤 `fk = parent_record.id`
|
||||
- 支持新增/编辑/删除子记录
|
||||
- 标题使用 `relation.name`
|
||||
|
||||
#### 级联删除确认
|
||||
|
||||
删除有 incoming relations 的记录时,弹出确认:
|
||||
```
|
||||
确定删除客户「{name}」?
|
||||
此操作将同时删除:
|
||||
- 3 条联系人记录
|
||||
- 5 条沟通记录
|
||||
- 2 条标签记录
|
||||
```
|
||||
|
||||
### 2.4 CRM plugin.toml 改造
|
||||
|
||||
为 customer 实体补充 relations:
|
||||
|
||||
```toml
|
||||
[[schema.entities.relations]]
|
||||
entity = "contact"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
name = "contacts"
|
||||
type = "one_to_many"
|
||||
display_field = "name"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "communication"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
name = "communications"
|
||||
type = "one_to_many"
|
||||
display_field = "subject"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer_tag"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
name = "tags"
|
||||
type = "one_to_many"
|
||||
display_field = "tag_name"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer"
|
||||
foreign_key = "parent_id"
|
||||
on_delete = "nullify"
|
||||
name = "parent"
|
||||
type = "many_to_one"
|
||||
display_field = "name"
|
||||
```
|
||||
|
||||
为 contact 实体补充 relations:
|
||||
|
||||
```toml
|
||||
[[schema.entities.relations]]
|
||||
entity = "communication"
|
||||
foreign_key = "contact_id"
|
||||
on_delete = "cascade"
|
||||
name = "communications"
|
||||
type = "one_to_many"
|
||||
display_field = "subject"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. P0-2: 字段校验层
|
||||
|
||||
### 3.1 现有基础
|
||||
|
||||
**已有实现**:
|
||||
- `validate_data` (`data_service.rs:797-831`): required + pattern 正则校验
|
||||
- `validate_ref_entities` (`data_service.rs:834-899`): FK 引用存在性校验
|
||||
- `FieldValidation` (`manifest.rs:53-57`): `pattern` + `message` 字段
|
||||
- unique 检查已在 `create`/`update` 流程中实现
|
||||
|
||||
**缺失部分**:
|
||||
- `min_length` / `max_length` 校验器
|
||||
- `min_value` / `max_value` 校验器
|
||||
- PATCH (partial_update) 绕过所有校验
|
||||
- 前端 TypeScript 类型缺少 `validation` 属性
|
||||
|
||||
### 3.2 Manifest Schema 扩展
|
||||
|
||||
在现有 `[validation]` 上新增字段(`manifest.rs:53-57` 已有 `pattern` + `message`):
|
||||
|
||||
```toml
|
||||
[[schema.entities.fields]]
|
||||
name = "phone"
|
||||
field_type = "string"
|
||||
display_name = "手机号"
|
||||
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^1[3-9]\\d{9}$"
|
||||
message = "请输入有效的手机号码"
|
||||
min_length = 11
|
||||
max_length = 11
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "credit_limit"
|
||||
field_type = "decimal"
|
||||
|
||||
[schema.entities.fields.validation]
|
||||
min_value = 0
|
||||
max_value = 99999999
|
||||
message = "信用额度必须在 0-99999999 之间"
|
||||
```
|
||||
|
||||
#### 校验类型定义
|
||||
|
||||
| 校验器 | manifest 字段 | 状态 | 说明 |
|
||||
|--------|-------------|------|------|
|
||||
| `required` | `field.required` | **已有** | 值不能为 null/空字符串 |
|
||||
| `unique` | `field.unique` | **已有** | 同 tenant 内值唯一 |
|
||||
| `pattern` | `validation.pattern` + `validation.message` | **已有** | 正则匹配 |
|
||||
| `ref_exists` | `field.ref_entity` | **已有** | FK 指向的记录存在且未删除 |
|
||||
| `min_length` / `max_length` | `validation.min_length` / `validation.max_length` | **新增** | 字符串长度范围 |
|
||||
| `min_value` / `max_value` | `validation.min_value` / `validation.max_value` | **新增** | 数值范围 |
|
||||
|
||||
### 3.3 后端实现
|
||||
|
||||
#### 扩展 `FieldValidation` (`manifest.rs:53-57`)
|
||||
|
||||
在现有结构上新增 4 个可选字段:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FieldValidation {
|
||||
pub pattern: Option<String>, // 已有
|
||||
pub message: Option<String>, // 已有
|
||||
// ↓ 新增
|
||||
pub min_length: Option<usize>,
|
||||
pub max_length: Option<usize>,
|
||||
pub min_value: Option<f64>,
|
||||
pub max_value: Option<f64>,
|
||||
}
|
||||
```
|
||||
|
||||
#### 扩展 `validate_data` (`data_service.rs:797-831`)
|
||||
|
||||
在现有函数中追加 min_length/max_length/min_value/max_value 检查:
|
||||
|
||||
```rust
|
||||
// 现有: required + pattern 检查 (已实现)
|
||||
// 新增:
|
||||
if let Some(validation) = &field.validation {
|
||||
// min_length / max_length
|
||||
if let Some(str_val) = val.as_str() {
|
||||
if let Some(min) = validation.min_length {
|
||||
if str_val.len() < min { return Err(...); }
|
||||
}
|
||||
if let Some(max) = validation.max_length {
|
||||
if str_val.len() > max { return Err(...); }
|
||||
}
|
||||
}
|
||||
// min_value / max_value (适用于 number/integer/decimal)
|
||||
if let Some(num_val) = val.as_f64() {
|
||||
if let Some(min) = validation.min_value {
|
||||
if num_val < min { return Err(...); }
|
||||
}
|
||||
if let Some(max) = validation.max_value {
|
||||
if num_val > max { return Err(...); }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 修复 PATCH 校验缺失
|
||||
|
||||
`partial_update` (`data_service.rs:291-327`) 需要添加 `validate_data` 和 `validate_ref_entities` 调用,与 `update` 保持一致。
|
||||
|
||||
**执行位置:** `data_service.rs` 的 `create_record` 和 `update_record` 方法中,数据写入前调用 `validate_record`。
|
||||
|
||||
**错误响应格式:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "数据验证失败",
|
||||
"details": [
|
||||
{ "field": "phone", "message": "请输入有效的手机号码" },
|
||||
{ "field": "customer_id", "message": "引用的客户不存在" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 前端实现
|
||||
|
||||
从 schema 自动生成 Ant Design Form rules(需先修复 TypeScript 类型缺失):
|
||||
|
||||
```typescript
|
||||
function generateFormRules(field: PluginFieldSchema): Rule[] {
|
||||
const rules: Rule[] = [];
|
||||
|
||||
if (field.required) {
|
||||
rules.push({ required: true, message: `${field.display_name}不能为空` });
|
||||
}
|
||||
|
||||
if (field.validation?.pattern) {
|
||||
rules.push({
|
||||
pattern: new RegExp(field.validation.pattern),
|
||||
message: field.validation.message || `${field.display_name}格式不正确`,
|
||||
});
|
||||
}
|
||||
|
||||
if (field.validation?.min_length || field.validation?.max_length) {
|
||||
rules.push({
|
||||
min: field.validation.min_length,
|
||||
max: field.validation.max_length,
|
||||
message: field.validation.message || `${field.display_name}长度不正确`,
|
||||
});
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 CRM plugin.toml 补充校验
|
||||
|
||||
```toml
|
||||
# phone 字段
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^1[3-9]\\d{9}$"
|
||||
message = "请输入有效的手机号码"
|
||||
|
||||
# email 字段
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"
|
||||
message = "请输入有效的邮箱地址"
|
||||
|
||||
# credit_code 字段
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^[0-9A-HJ-NP-RTUW-Y]{2}\\d{6}[0-9A-HJ-NP-RTUW-Y]{10}$"
|
||||
message = "请输入有效的统一社会信用代码"
|
||||
|
||||
# website 字段
|
||||
[schema.entities.fields.validation]
|
||||
pattern = "^https?://[\\w.-]+(?:\\.[\\w.-]+)+[/#?]?.*$"
|
||||
message = "请输入有效的网址"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. P0-3: 前端去硬编码
|
||||
|
||||
### 4.1 Dashboard 通用化
|
||||
|
||||
**涉及文件:**
|
||||
- `apps/web/src/pages/dashboard/dashboardConstants.tsx`
|
||||
- `apps/web/src/pages/dashboard/DashboardWidgets.tsx`
|
||||
- `apps/web/src/pages/PluginDashboardPage.tsx`
|
||||
|
||||
**改造方案:**
|
||||
|
||||
| 当前硬编码 | 通用化方案 |
|
||||
|-----------|-----------|
|
||||
| `ENTITY_COLORS`: customer→indigo, contact→green, ... | 8 色调色板按 entity 顺序自动分配 |
|
||||
| `ENTITY_ICONS`: customer→TeamOutlined, ... | 从 page schema 的 icon 字段读取 |
|
||||
| 标题 "CRM 数据全景视图" | `{manifest.name} 统计概览` |
|
||||
| 副标题 "实时掌握业务动态" | `{manifest.description}` 截取前 50 字 |
|
||||
|
||||
**通用调色板:**
|
||||
|
||||
```typescript
|
||||
const UNIVERSAL_PALETTE = [
|
||||
'#6366f1', // indigo
|
||||
'#22c55e', // green
|
||||
'#f59e0b', // amber
|
||||
'#8b5cf6', // violet
|
||||
'#ef4444', // red
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
'#ec4899', // pink
|
||||
];
|
||||
```
|
||||
|
||||
### 4.2 Graph 通用化
|
||||
|
||||
**涉及文件:** `apps/web/src/pages/plugins/graph/graphConstants.ts`
|
||||
|
||||
**改造方案:**
|
||||
|
||||
| 当前硬编码 | 通用化方案 |
|
||||
|-----------|-----------|
|
||||
| `RELATIONSHIP_COLORS`: parent_child→indigo, ... | 调色板按 option 顺序循环 |
|
||||
| `RELATIONSHIP_LABELS`: parent_child→"母子", ... | 从 field.options[].label 读取 |
|
||||
| `RELATIONSHIP_TYPES` 固定 5 种 | 从 schema 动态生成 |
|
||||
|
||||
### 4.3 CRUD 表格列可配置
|
||||
|
||||
**涉及文件:** `apps/web/src/pages/PluginCRUDPage.tsx`
|
||||
|
||||
**改造方案:**
|
||||
|
||||
manifest page 新增可选字段 `table_columns`:
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
entity = "customer"
|
||||
table_columns = ["code", "name", "customer_type", "level", "status", "owner_id", "region", "industry"]
|
||||
```
|
||||
|
||||
不声明时默认行为:
|
||||
- 取前 8 个非 hidden 非 FK 字段
|
||||
- 替换当前 `fields.slice(0, 5)` 硬编码
|
||||
|
||||
### 4.4 验证标准
|
||||
|
||||
> **测试: 将 CRM 插件替换为 inventory 插件,Dashboard/Graph/CRUD 页面应零改动正确渲染。**
|
||||
|
||||
具体验证:
|
||||
1. Dashboard 显示 inventory 的 6 个实体统计,颜色按顺序分配
|
||||
2. Graph 如果 inventory 有关系数据,渲染正确(无数据则显示空状态)
|
||||
3. CRUD 表格按 `table_columns` 或默认 8 列显示
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键文件清单
|
||||
|
||||
### 后端 Rust
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `crates/erp-plugin/src/manifest.rs` | 修改 | `PluginRelation` 新增 name/type/display_field/through_* 字段;`FieldValidation` 新增 min_length/max_length/min_value/max_value |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 修改 | 扩展 `validate_data` 增加 min/max 校验;`partial_update` 补充校验调用;`batch_delete` 补充级联 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | 修改 | 补充 relations 声明 + validation 规则 |
|
||||
|
||||
> 注意:不新建 `validation.rs`,直接扩展现有 `validate_data` 和 `validate_ref_entities`。
|
||||
|
||||
### 前端 TypeScript
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `apps/web/src/api/plugins.ts` | 修改 | `PluginEntitySchema` 新增 `relations`;`PluginFieldSchema` 新增 `validation` |
|
||||
| `apps/web/src/pages/dashboard/dashboardConstants.tsx` | 修改 | 去硬编码,通用调色板自动分配 |
|
||||
| `apps/web/src/pages/dashboard/DashboardWidgets.tsx` | 修改 | schema 驱动颜色/图标 |
|
||||
| `apps/web/src/pages/PluginDashboardPage.tsx` | 修改 | 通用标题/副标题 |
|
||||
| `apps/web/src/pages/plugins/graph/graphConstants.ts` | 修改 | 关系类型从 options 动态读取 |
|
||||
| `apps/web/src/pages/PluginCRUDPage.tsx` | 修改 | 可配置列数 + Form rules 自动生成 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证方案
|
||||
|
||||
### 6.1 编译与测试
|
||||
|
||||
```bash
|
||||
cargo check # 全 workspace 编译
|
||||
cargo test --workspace # 全量测试
|
||||
```
|
||||
|
||||
### 6.2 单元测试
|
||||
|
||||
- `validation.rs`: 每种校验器独立测试 (required/unique/pattern/ref_exists/length/value range)
|
||||
- `data_service.rs`: 级联策略测试 (cascade_soft_delete/set_null/restrict)
|
||||
|
||||
### 6.3 集成测试 (Testcontainers)
|
||||
|
||||
- 删除客户 → 验证联系人/沟通记录/标签级联软删除
|
||||
- 删除有 restrict 关系的记录 → 验证 409 响应
|
||||
- 创建联系人 → customer_id 不存在时验证 400
|
||||
- 创建客户 → phone 格式不正确时验证 400 + 错误详情
|
||||
- 创建客户 → code 已存在时验证 409
|
||||
|
||||
### 6.4 功能验证
|
||||
|
||||
1. 重新安装 CRM 插件,确认 5 个 relation 正确注册到 entity metadata
|
||||
2. 删除客户 → 确认关联数据正确级联
|
||||
3. 手机号/邮箱格式校验 → 确认前后端双重拦截
|
||||
4. Dashboard → 确认标题/颜色从 schema 动态生成
|
||||
5. 切换 inventory 插件 → Dashboard/Graph 零改动渲染
|
||||
|
||||
### 6.5 前端验证
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm dev
|
||||
```
|
||||
|
||||
手动测试所有 CRM 页面,确认无回归。
|
||||
|
||||
---
|
||||
|
||||
## 7. 不在本规格范围内
|
||||
|
||||
| 项 | 原因 | 计划 |
|
||||
|----|------|------|
|
||||
| 商机 (Opportunity) / 销售漏斗 | CRM 业务功能,P2 范畴 | 后续规格 |
|
||||
| 数据导入导出 (Excel) | 平台能力但工作量大 | P1 规格 |
|
||||
| 通知规则 + 消息中心联动 | 需要跨模块协作 | P1 规格 |
|
||||
| WASM 校验/计算 Hook | 平台能力但依赖 WASM 运行时增强 | P2 规格 |
|
||||
| 全局搜索 / 保存视图 | UX 增强 | P1 规格 |
|
||||
| Lead 线索实体 | CRM 业务功能 | P2 规格 |
|
||||
@@ -0,0 +1,337 @@
|
||||
# ERP 插件平台演进路线图 — 设计规格
|
||||
|
||||
> 日期: 2026-04-18
|
||||
> 来源: 无主题发散式互动探讨
|
||||
> 状态: Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
ERP 平台已完成 Phase 1-6 核心开发和 Q2-Q4 成熟度路线图。当前有两个行业插件(CRM + 进销存)运行在 WASM 插件系统上。通过分析发现四大系统性缺口:
|
||||
|
||||
1. **跨插件数据引用完全不支持** — 进销存的 `customer_id` 只能存裸 UUID
|
||||
2. **插件无通用业务能力** — 导入导出/打印/配置/视图每个插件都要自己实现
|
||||
3. **无质量保障机制** — 第三方插件的安全性和性能无法保证
|
||||
4. **无发现和分发渠道** — 用户无法自助发现和安装插件
|
||||
|
||||
**目标:** 通过搭建财务/应收插件来验证和推动这些平台能力的实现。
|
||||
|
||||
**核心设计原则:**
|
||||
- 插件间**完全独立**,任何插件可自由安装/卸载,不受其他插件影响
|
||||
- 跨插件引用**声明式**,通过 plugin.toml 零代码实现
|
||||
- 通用业务能力**平台层提供**,插件声明式接入
|
||||
- 外部引用问题永远是**软警告**,永不硬阻塞用户操作
|
||||
|
||||
---
|
||||
|
||||
## 2. 跨插件数据引用系统
|
||||
|
||||
### 2.1 Entity Registry (平台实体注册表)
|
||||
|
||||
插件安装时将其所有实体注册到平台级 Entity Registry,其他插件通过 registry 动态发现和引用。
|
||||
|
||||
**数据结构:**
|
||||
|
||||
```
|
||||
entity_registry:
|
||||
- entity_name: string # 实体名 (如 "customer")
|
||||
- plugin_id: string # 注册该实体的插件 ID
|
||||
- display_fields: string[] # 用于下拉显示的字段列表
|
||||
- search_fields: string[] # 用于搜索的字段列表
|
||||
- status: active | inactive # 插件卸载时标记 inactive
|
||||
- registered_at: timestamp
|
||||
- tenant_id: uuid # 多租户隔离
|
||||
```
|
||||
|
||||
**生命周期:**
|
||||
- 插件安装 → 注册所有 entities 到 registry
|
||||
- 插件启用 → status = active
|
||||
- 插件禁用 → status = inactive(数据保留)
|
||||
- 插件卸载 → status = inactive + 标记为 orphaned
|
||||
|
||||
### 2.2 plugin.toml 扩展
|
||||
|
||||
```toml
|
||||
# 可选依赖声明
|
||||
[dependencies.crm]
|
||||
optional = true
|
||||
description = "客户管理 — 自动关联客户数据,未安装时客户字段为手动输入"
|
||||
|
||||
[dependencies.inventory]
|
||||
optional = true
|
||||
description = "进销存 — 自动关联商品数据"
|
||||
|
||||
# 跨插件引用字段
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
display_name = "客户"
|
||||
ref_entity = "customer" # 目标实体名
|
||||
ref_scope = "external" # "internal" (默认) | "external"
|
||||
ref_display_field = "name" # 下拉框显示字段
|
||||
ref_search_fields = ["name", "phone"] # 搜索字段
|
||||
ref_fallback_label = "外部客户" # 降级时显示文本
|
||||
```
|
||||
|
||||
### 2.3 运行时行为
|
||||
|
||||
**写入时校验:**
|
||||
|
||||
| 源插件状态 | 写入行为 | 读取行为 | 前端展示 |
|
||||
|-----------|---------|---------|---------|
|
||||
| 已安装 (active) | 强校验 UUID 存在性 | JOIN 富化 display_field | ✅ 绿色链接 "张三" |
|
||||
| 未安装 (inactive) | 无校验,接受任意 UUID | 返回原始 UUID | ⬜ 灰色 "外部客户" |
|
||||
| 刚重新启用 | 新写入强校验,不回溯已有 | 后台对账扫描 | ⚠️ 黄色警告 (悬空) |
|
||||
|
||||
**悬空引用处理 (插件重新启用时):**
|
||||
1. 后台扫描所有 `ref_scope=external` 且指向本插件实体的字段
|
||||
2. 验证每个 UUID 是否存在于本插件表中
|
||||
3. 生成对账报告: `{ valid: N, dangling: M, details: [...] }`
|
||||
4. 前端展示对账结果,用户逐条处理(映射/清空/忽略)
|
||||
5. 永不硬阻塞用户操作
|
||||
|
||||
### 2.4 需要改造的文件
|
||||
|
||||
| 文件 | 改动 | 复杂度 |
|
||||
|------|------|--------|
|
||||
| `crates/erp-plugin/src/manifest.rs` | 新增 `ref_scope`, `ref_display_field`, `ref_search_fields`, `ref_fallback_label`; 新增 `DependenciesSection` | 低 |
|
||||
| `crates/erp-plugin/src/entity_registry.rs` (新) | 实体注册/发现/inactive 标记/对账 | 中 |
|
||||
| `crates/erp-plugin/src/data_service.rs` | `validate_ref_entities` 支持运行时发现外部引用 | 中 |
|
||||
| `crates/erp-plugin/src/host.rs` | 新增 `resolve_ref_entity` Host API | 中 |
|
||||
| `crates/erp-plugin/wit/plugin.wit` | 新增 `resolve-ref-entity` 接口 | 低 |
|
||||
| `crates/erp-plugin/src/service.rs` | 插件安装/卸载时维护 Entity Registry | 中 |
|
||||
| `apps/web/src/` 前端 | entity_select 组件支持跨插件数据源 + 降级显示 + 对账 UI | 高 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 插件平台通用服务层 (P1)
|
||||
|
||||
### 3.1 数据导入导出服务
|
||||
|
||||
插件在 plugin.toml 中声明哪些实体支持导入导出,平台提供统一的导入导出 UI 和引擎。
|
||||
|
||||
```toml
|
||||
[[schema.entities]]
|
||||
name = "invoice"
|
||||
display_name = "发票"
|
||||
importable = true
|
||||
exportable = true
|
||||
import_template = "invoice_import_template.xlsx"
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- 自动生成导入模板(基于 schema entities fields)
|
||||
- Excel/CSV 解析 + schema 字段校验
|
||||
- 批量写入(支持事务 + 错误行级报告)
|
||||
- 导出为 Excel/CSV(支持筛选条件)
|
||||
- 导入历史记录 + 回滚
|
||||
|
||||
**实现位置:** `crates/erp-plugin/src/import_export.rs` + 前端 `ImportExportModal` 通用组件
|
||||
|
||||
### 3.2 打印模板引擎
|
||||
|
||||
平台提供 HTML → PDF 的模板渲染能力,插件定义模板和字段映射。
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "invoice_pdf"
|
||||
display_name = "发票"
|
||||
entity = "invoice"
|
||||
format = "pdf"
|
||||
template_file = "templates/invoice.html"
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- HTML 模板渲染 → PDF 下载
|
||||
- 模板变量替换(基于实体字段)
|
||||
- 租户级模板自定义(覆盖默认模板)
|
||||
- 打印预览
|
||||
|
||||
### 3.3 插件配置 UI
|
||||
|
||||
插件在 plugin.toml 中声明配置项,平台自动生成配置页面。
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
[[settings.fields]]
|
||||
name = "default_tax_rate"
|
||||
display_name = "默认税率"
|
||||
field_type = "number"
|
||||
default_value = 0.13
|
||||
|
||||
[[settings.fields]]
|
||||
name = "invoice_prefix"
|
||||
display_name = "发票前缀"
|
||||
field_type = "text"
|
||||
default_value = "INV"
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- 根据 settings 声明自动生成配置表单
|
||||
- 配置数据存储在 `plugin_settings` 表(tenant_id + plugin_id + key/value)
|
||||
- 配置变更时通知插件(通过事件)
|
||||
- 支持配置权限控制(仅管理员可改)
|
||||
|
||||
### 3.4 自定义视图
|
||||
|
||||
用户可以保存列表页的列配置和筛选条件。
|
||||
|
||||
```
|
||||
user_views:
|
||||
- id: uuid
|
||||
- user_id: uuid
|
||||
- plugin_id: string
|
||||
- entity_name: string
|
||||
- view_name: string
|
||||
- columns: string[]
|
||||
- filters: json
|
||||
- sort: json
|
||||
- is_default: boolean
|
||||
```
|
||||
|
||||
### 3.5 通知规则
|
||||
|
||||
插件在 plugin.toml 中声明可触发的事件,平台提供通知规则配置 UI。
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "invoice.overdue"
|
||||
display_name = "发票逾期"
|
||||
description = "发票超过付款期限未收款"
|
||||
```
|
||||
|
||||
**平台能力:**
|
||||
- 规则引擎: WHEN event THEN notify [user/role/department]
|
||||
- 复用 erp-message 的通知渠道
|
||||
- 租户级规则配置
|
||||
|
||||
### 3.6 编号规则 (已有基础扩展)
|
||||
|
||||
复用 erp-config 的编号规则服务,扩展为插件可接入。
|
||||
|
||||
```toml
|
||||
[[numbering]]
|
||||
entity = "invoice"
|
||||
prefix = "INV"
|
||||
format = "{PREFIX}-{YEAR}-{SEQ:4}"
|
||||
reset_rule = "yearly"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 插件质量保障
|
||||
|
||||
### 4.1 上传时校验
|
||||
|
||||
```
|
||||
插件上传 → Schema 校验 → WASM 二进制验证 → 安全扫描 → 性能基准 → 发布/拒绝
|
||||
```
|
||||
|
||||
| 阶段 | 校验内容 | 现状 |
|
||||
|------|---------|------|
|
||||
| Schema 校验 | plugin.toml 格式、字段类型、权限码一致性 | 部分已有 |
|
||||
| WASM 验证 | 二进制格式、WIT 兼容性、导出函数检查 | 已有 |
|
||||
| 安全扫描 | 动态表 SQL 注入风险、Fuel 耗尽、内存泄漏 | 缺失 |
|
||||
| 性能基准 | 标准 CRUD 操作在 N 条数据下的响应时间 | 缺失 |
|
||||
| 兼容性 | 平台版本匹配、依赖插件版本兼容 | 缺失 |
|
||||
|
||||
### 4.2 运行时监控
|
||||
|
||||
```
|
||||
plugin_runtime_metrics:
|
||||
- plugin_id: string
|
||||
- error_rate: float
|
||||
- avg_response_ms: float
|
||||
- fuel_consumption: float
|
||||
- memory_peak_mb: float
|
||||
- active_instances: int
|
||||
```
|
||||
|
||||
**告警规则:** 错误率 > 5% / 平均响应 > 2s / Fuel 消耗异常 / 内存持续增长
|
||||
|
||||
---
|
||||
|
||||
## 5. 插件市场/商店
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 插件目录 | 按行业/功能分类浏览 |
|
||||
| 搜索 | 按名称/标签/行业搜索 |
|
||||
| 详情页 | 截图、演示、功能描述、权限说明 |
|
||||
| 一键安装 | 上传 → 自动安装 → 配置 → 启用 |
|
||||
| 评分/评论 | 用户评分和使用反馈 |
|
||||
| 版本管理 | 版本列表、更新日志、回滚 |
|
||||
| 依赖提示 | 安装时提示可选依赖 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证计划 — 财务/应收插件
|
||||
|
||||
### 6.1 实体设计
|
||||
|
||||
| 实体 | 字段概要 | 跨插件引用 |
|
||||
|------|---------|-----------|
|
||||
| invoice (发票) | 编号/客户/金额/税额/状态/到期日 | customer_id → CRM.customer |
|
||||
| invoice_line (发票行) | 发票/商品/数量/单价/税额 | product_id → Inventory.product |
|
||||
| payment (收款) | 发票/金额/方式/日期/状态 | invoice_id → 本插件内部 |
|
||||
| quote (报价单) | 编号/客户/有效期/状态 | customer_id → CRM.customer |
|
||||
| quote_line (报价行) | 报价单/商品/数量/单价 | product_id → Inventory.product |
|
||||
|
||||
### 6.2 验证矩阵
|
||||
|
||||
| 能力 | 验证方式 | 预期结果 |
|
||||
|------|---------|---------|
|
||||
| 跨插件引用 (CRM 安装) | 创建发票时选择客户 | entity_select 下拉显示 CRM 客户列表 |
|
||||
| 跨插件引用 (CRM 卸载) | 创建发票时输入客户 | 降级为文本输入,不阻塞 |
|
||||
| 悬空引用对账 | CRM 卸载→创建发票→重新安装 CRM | 对账报告显示悬空引用,用户可修复 |
|
||||
| 数据导入 | 导入 Excel 客户清单 | 解析+校验+批量写入 |
|
||||
| 数据导出 | 导出发票列表为 Excel | 筛选+下载 |
|
||||
| 打印模板 | 打印发票 PDF | HTML→PDF 渲染 |
|
||||
| 插件配置 | 设置税率/发票前缀 | 自动生成的配置页面 |
|
||||
| 编号规则 | 创建发票自动编号 | INV-2026-0001 |
|
||||
| 通知规则 | 发票逾期通知 | 规则引擎触发通知 |
|
||||
| 独立安装 | 不安装 CRM 单独安装财务 | 所有功能正常,客户字段降级 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施优先级
|
||||
|
||||
```
|
||||
P0 (已完成/进行中): P0 平台能力升级 + 插件系统增强
|
||||
|
||||
P1 (跨插件引用): Entity Registry + ref_scope 扩展 + 前端 entity_select 改造
|
||||
这是所有后续能力的基础
|
||||
|
||||
P2 (平台通用服务): 数据导入导出 → 插件配置 UI → 编号规则扩展 → 通知规则
|
||||
|
||||
P3 (质量保障): 上传时安全扫描 → 性能基准 → 运行时监控
|
||||
|
||||
P4 (插件市场): 插件目录 → 一键安装 → 版本管理 → 评分评论
|
||||
|
||||
验证: 财务/应收插件贯穿 P1-P2,每完成一个 P 就用财务插件验证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| Entity Registry 查询性能 | 每次数据操作都要查注册表 | 内存缓存 + DashMap,注册表数据量极小 |
|
||||
| 悬空引用数据量过大 | 对账扫描耗时长 | 异步后台任务 + 分批处理 + 进度条 |
|
||||
| Excel 导入内存占用 | 大文件解析 OOM | 流式解析 + 批量提交 + 文件大小限制 |
|
||||
| 打印模板安全 | 模板注入攻击 | 沙箱渲染 + 变量白名单 |
|
||||
| 插件市场审核成本 | 人工审核效率低 | 自动化扫描 + 人工抽查 + 社区举报 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 讨论溯源
|
||||
|
||||
本文档基于 2026-04-18 的无主题发散式互动探讨产出,完整讨论过程记录在 `plans/skill-cosmic-pancake.md`。
|
||||
|
||||
关键决策历程:
|
||||
- **Round 1:** 发现跨插件数据引用完全不支持(进销存的 customer_id 是裸 UUID)
|
||||
- **Round 2:** 确定声明式引用 + 完全独立(无硬依赖)+ 软警告对账方案
|
||||
- **Round 3:** 确定导入导出/打印/配置/视图/通知应为平台通用服务
|
||||
- **Round 4:** 收敛为统一设计规格,以财务插件为验证载体
|
||||
@@ -0,0 +1,183 @@
|
||||
# 插件系统增强设计规格
|
||||
|
||||
## Context
|
||||
|
||||
插件系统是 ERP 平台的核心差异化能力,当前声明式层面(manifest schema、动态表、前端页面)已达 90% 成熟度。但 WASM 逻辑层存在根本性限制:
|
||||
|
||||
1. **插件无法自主查询数据** — `db_query` 的 filter/pagination 参数被忽略,只能使用预填充结果
|
||||
2. **无读后写一致性** — 延迟刷新模型导致插件在一次调用中无法读取自己刚写入的数据
|
||||
3. **聚合只有 COUNT** — 缺少 SUM/AVG/MAX/MIN,无法支撑财务、统计类场景
|
||||
4. **热更新无原子回滚** — 旧版本先卸载再加载新版本,中间失败无保障
|
||||
5. **Schema 变更只支持新增实体** — 不支持已有实体的字段演进
|
||||
|
||||
这些限制使插件系统只能支撑"数据管理+展示"型轻量场景(CRM、简单进销存),无法支撑需要复杂业务逻辑的行业(财务、制造、电商)。
|
||||
|
||||
本次增强的目标:**让插件逻辑层从 40% 提升到 80%+,使系统能真正承载不同行业的定制化需求。**
|
||||
|
||||
---
|
||||
|
||||
## 改动 1:混合执行模型(解决查询和读后写一致性)
|
||||
|
||||
### 问题
|
||||
|
||||
`host.rs:99-109` — `db_query` 忽略 `_filter` 和 `_pagination` 参数,只从 `query_results` 预填充缓存取数据。插件无法自主构造查询。
|
||||
|
||||
### 方案:读操作走实时 SQL + 写操作保持延迟批量 + 读前自动 flush
|
||||
|
||||
核心流程变更:
|
||||
|
||||
```
|
||||
当前:
|
||||
WASM 调用 db_insert() → 入队 pending_ops
|
||||
WASM 调用 db_query() → 从预填充缓存读(忽略 filter/pagination)
|
||||
WASM 结束 → flush 全部 pending_ops
|
||||
|
||||
改为:
|
||||
WASM 调用 db_insert() → 入队 pending_ops
|
||||
WASM 调用 db_query() → 先 flush pending_ops → 执行真实 SQL 查询 → 返回结果
|
||||
WASM 结束 → flush 剩余 pending_ops
|
||||
```
|
||||
|
||||
### 改动文件
|
||||
|
||||
#### 1. `crates/erp-plugin/src/host.rs`
|
||||
|
||||
HostState 新增字段:
|
||||
|
||||
```rust
|
||||
pub struct HostState {
|
||||
// ... 现有字段保留 ...
|
||||
pub(crate) db: Option<DatabaseConnection>,
|
||||
pub(crate) event_bus: Option<EventBus>,
|
||||
}
|
||||
```
|
||||
|
||||
db_query 实现变更 — 使用 `tokio::runtime::Handle::current()` 在 `spawn_blocking` 内执行异步 DB 操作:
|
||||
|
||||
1. 先 `block_on(flush_ops(...))` 清空 pending writes
|
||||
2. 解析 filter/pagination 参数
|
||||
3. 调用 `DynamicTableManager::build_query_sql()` 构建查询
|
||||
4. `block_on` 执行查询并返回结果
|
||||
|
||||
向后兼容:`db = None` 时走旧的预填充路径。
|
||||
|
||||
#### 2. `crates/erp-plugin/src/dynamic_table.rs`
|
||||
|
||||
新增 `build_query_sql` 方法,复用 `data_service.rs` 中的查询构建逻辑。
|
||||
|
||||
### 向后兼容
|
||||
|
||||
- `HostState::new()` 不传 db → 走旧的预填充路径
|
||||
- `execute_wasm()` 传 db → 走新的实时查询路径
|
||||
- 现有 WASM 插件无需修改
|
||||
|
||||
---
|
||||
|
||||
## 改动 2:扩展聚合查询
|
||||
|
||||
### 问题
|
||||
|
||||
`data_service.rs:655` 的 `aggregate` 方法只支持 `GROUP BY + COUNT(*)`。
|
||||
|
||||
### 方案
|
||||
|
||||
新增 `aggregate_multi` 方法支持 SUM/AVG/MAX/MIN。
|
||||
|
||||
改动文件:
|
||||
|
||||
1. `data_service.rs` — 新增 `AggregateDef`、`AggregateFunc`、`AggregateResult` 类型和 `aggregate_multi` 方法
|
||||
2. `dynamic_table.rs` — 新增 `build_aggregate_multi_sql` 方法
|
||||
3. `data_handler.rs` — 扩展聚合 API 端点
|
||||
4. 前端 Dashboard Widget 适配多聚合返回格式
|
||||
|
||||
SQL 示例:
|
||||
```sql
|
||||
SELECT _f_status as key,
|
||||
COUNT(*) as count,
|
||||
COALESCE(SUM(_f_amount), 0) as sum_amount,
|
||||
COALESCE(AVG(_f_price), 0) as avg_price
|
||||
FROM plugin_erp_crm__order
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
GROUP BY _f_status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 改动 3:热更新原子回滚
|
||||
|
||||
### 问题
|
||||
|
||||
`service.rs:578-585` — 先 `unload(old)` 再 `load(new)`,中间失败无回滚。
|
||||
|
||||
### 方案:先加载新版本到临时 key,成功后原子替换
|
||||
|
||||
改动文件:
|
||||
|
||||
1. `service.rs` — upgrade 方法改用临时 key 加载新版本
|
||||
2. `engine.rs` — 新增 `rename_plugin` 方法
|
||||
|
||||
安全保证:新版本加载失败 → 旧版本仍在运行,零停机。
|
||||
|
||||
---
|
||||
|
||||
## 改动 4:Schema 演进(ALTER TABLE 支持)
|
||||
|
||||
### 问题
|
||||
|
||||
升级时只处理新增实体(CREATE TABLE),不处理已有实体的字段变更。
|
||||
|
||||
### 方案:利用 JSONB 特性实现轻量级 Schema 演进
|
||||
|
||||
大部分字段变更不需要 DDL(JSONB 天然支持),仅新增 filterable/sortable 字段需 ALTER TABLE ADD Generated Column + 索引。
|
||||
|
||||
改动文件:
|
||||
|
||||
1. `service.rs` — upgrade 方法增加 schema diff 逻辑
|
||||
2. `dynamic_table.rs` — 新增 `FieldDiff`、`diff_entity_fields`、`alter_add_generated_columns`
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
| 阶段 | 改动 | 复杂度 | 影响范围 |
|
||||
|------|------|--------|---------|
|
||||
| 1 | 热更新原子回滚 | 低 | engine.rs + service.rs |
|
||||
| 2 | Schema 演进(ALTER TABLE) | 中低 | service.rs + dynamic_table.rs |
|
||||
| 3 | 扩展聚合查询 | 中 | data_service.rs + data_handler.rs + dynamic_table.rs |
|
||||
| 4 | 混合执行模型(查询能力) | 高 | host.rs + engine.rs + dynamic_table.rs |
|
||||
|
||||
---
|
||||
|
||||
## 验证方案
|
||||
|
||||
### 阶段 1:热更新回滚
|
||||
1. 上传损坏的 WASM 二进制 → 验证旧版本仍在运行
|
||||
2. 上传正确的新版本 → 验证成功切换
|
||||
|
||||
### 阶段 2:Schema 演进
|
||||
1. 升级插件增加 filterable 字段 → 验证 ALTER TABLE 正确执行
|
||||
2. 旧数据上新 Generated Column 值正确填充
|
||||
|
||||
### 阶段 3:聚合查询
|
||||
1. 创建测试数据,调用聚合 API → 验证 SUM/AVG 结果正确
|
||||
2. 前端 Dashboard 展示正确
|
||||
|
||||
### 阶段 4:混合执行模型
|
||||
1. 插件 WASM 中 db_insert 后立即 db_query → 读后写一致性
|
||||
2. 带 filter 的 db_query → 过滤结果正确
|
||||
3. 旧插件(预填充模式)仍能正常工作
|
||||
4. 多次连续 db_query 不超过 Fuel 限制
|
||||
|
||||
---
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|---------|
|
||||
| `crates/erp-plugin/src/host.rs` | 重构 db_query + 新增 db/事件总线字段 |
|
||||
| `crates/erp-plugin/src/engine.rs` | 调整 execute_wasm + 新增 rename_plugin |
|
||||
| `crates/erp-plugin/src/service.rs` | 升级流程回滚安全 + schema diff |
|
||||
| `crates/erp-plugin/src/dynamic_table.rs` | 新增 build_query_sql + alter_add_generated_columns + diff_entity_fields |
|
||||
| `crates/erp-plugin/src/data_service.rs` | 新增 aggregate_multi + AggregateDef |
|
||||
| `crates/erp-plugin/src/data_handler.rs` | 扩展聚合 API |
|
||||
| `apps/web/src/pages/PluginDashboardPage.tsx` | 适配多聚合返回格式 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
||||
# 汕头市智界科技 IT 服务插件 — 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为汕头市智界科技有限公司创建 freelance(自由职业者工作台)和 itops(IT 运维服务台)两个 WASM 插件,覆盖其全部 12 条经营范围。
|
||||
|
||||
**Architecture:** 两个独立的 WASM 插件 crate,每个包含 Cargo.toml(cdylib)、src/lib.rs(Guest trait 实现)、plugin.toml(声明式 schema)。通过插件安装 API 上传到系统,平台自动创建动态表、注册权限、生成前端页面。itops 通过 ref_plugin 跨插件引用 freelance 的 client 实体。
|
||||
|
||||
**Tech Stack:** Rust (wit-bindgen 0.55, cdylib → WASM Component)、TOML manifest、Axum Host API
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: freelance 插件
|
||||
|
||||
### Task 1: 创建 crate 目录和 Cargo.toml
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-freelance/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-freelance/src/lib.rs`(空文件占位)
|
||||
|
||||
- [ ] **Step 1: 创建目录结构**
|
||||
|
||||
```bash
|
||||
mkdir -p crates/erp-plugin-freelance/src
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写 Cargo.toml**
|
||||
|
||||
创建 `crates/erp-plugin-freelance/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-freelance"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "自由职业者工作台 WASM 插件"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.55"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写 src/lib.rs**
|
||||
|
||||
创建 `crates/erp-plugin-freelance/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
//! 自由职业者工作台 WASM 插件
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "../erp-plugin-prototype/wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||
|
||||
struct FreelancePlugin;
|
||||
|
||||
impl Guest for FreelancePlugin {
|
||||
fn init() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
export!(FreelancePlugin);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 注册到 workspace**
|
||||
|
||||
编辑根 `Cargo.toml`,在 `members` 数组末尾添加:
|
||||
|
||||
```toml
|
||||
"crates/erp-plugin-freelance",
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-freelance
|
||||
```
|
||||
|
||||
Expected: 编译通过,无错误
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-freelance/ Cargo.toml
|
||||
git commit -m "feat(freelance): 创建插件 crate 骨架"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 编写 plugin.toml(freelance)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-freelance/plugin.toml`
|
||||
|
||||
- [ ] **Step 1: 从设计规格文档复制完整 plugin.toml 内容**
|
||||
|
||||
从设计规格 `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md` 中提取 2.1(元数据)+ 2.2(权限)+ 2.3(10 个实体)+ 2.4(编号规则)+ 2.5(页面声明)的所有 TOML 内容,合并为完整的 `plugin.toml` 文件。
|
||||
|
||||
文件结构:
|
||||
1. `[metadata]` 段
|
||||
2. `[[permissions]]` × 20
|
||||
3. `[[schema.entities]]` × 10(client, opportunity, quote, quote_line, contract, project, task, time_entry, invoice, expense),每个实体包含 fields 和 relations
|
||||
4. `[[numbering]]` × 3(quote_number, contract_number, invoice_number)
|
||||
5. `[[ui.pages]]` × 7(dashboard, tabs+detail+kanban for client, crud+detail for project, tabs for finance, crud for expense)
|
||||
|
||||
注意要点:
|
||||
- client 实体必须标记 `is_public = true`(被 itops 跨插件引用)
|
||||
- quote 到 quote_line 有 cascade 关系
|
||||
- project 到 task 和 time_entry 有 cascade 关系
|
||||
- 所有 uuid 引用字段使用 `ui_widget = "entity_select"` + `ref_label_field` + `ref_search_fields`
|
||||
- 所有 select 字段使用 `options = [{ label = "X", value = "x" }]` 格式
|
||||
- 长文本使用 `field_type = "string"` + `ui_widget = "textarea"`
|
||||
- 金额使用 `field_type = "decimal"`
|
||||
- 时间戳使用 `field_type = "date_time"`
|
||||
|
||||
- [ ] **Step 2: 验证 TOML 格式**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-freelance
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-freelance/plugin.toml
|
||||
git commit -m "feat(freelance): 添加 plugin.toml — 10 实体/20 权限/7 页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 编译 WASM 并安装
|
||||
|
||||
- [ ] **Step 1: 编译为 WASM**
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-freelance --target wasm32-unknown-unknown --release
|
||||
```
|
||||
|
||||
Expected: 编译成功,产出 `target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm`
|
||||
|
||||
- [ ] **Step 2: 转换为 Component**
|
||||
|
||||
```bash
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm -o target/erp_plugin_freelance.component.wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 检查产物大小**
|
||||
|
||||
```bash
|
||||
ls -la target/erp_plugin_freelance.component.wasm
|
||||
```
|
||||
|
||||
Expected: < 100KB(CRM 约 22KB)
|
||||
|
||||
- [ ] **Step 4: 启动后端服务**
|
||||
|
||||
```bash
|
||||
cd crates/erp-server && cargo run
|
||||
```
|
||||
|
||||
等待服务启动完成(看到 "listening on 0.0.0.0:3000" 日志)
|
||||
|
||||
- [ ] **Step 5: 登录获取 Token**
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:3000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin123"}' | jq -r '.data.access_token'
|
||||
```
|
||||
|
||||
保存输出的 token。
|
||||
|
||||
- [ ] **Step 6: 上传安装插件**
|
||||
|
||||
```bash
|
||||
TOKEN="<上一步的 token>"
|
||||
MANIFEST=$(cat crates/erp-plugin-freelance/plugin.toml)
|
||||
|
||||
curl -s -X POST http://localhost:3000/api/v1/admin/plugins/upload \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "wasm=@target/erp_plugin_freelance.component.wasm" \
|
||||
-F "manifest=$MANIST"
|
||||
```
|
||||
|
||||
Expected: 返回插件 ID,状态为 `installed`
|
||||
|
||||
- [ ] **Step 7: 启用插件**
|
||||
|
||||
使用上一步返回的插件 ID:
|
||||
|
||||
```bash
|
||||
PLUGIN_ID="<返回的插件 ID>"
|
||||
curl -s -X POST "http://localhost:3000/api/v1/admin/plugins/$PLUGIN_ID/enable" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
Expected: 状态变为 `running`
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(freelance): 编译 WASM 并验证安装"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 浏览器验证 freelance 插件
|
||||
|
||||
- [ ] **Step 1: 打开浏览器访问 http://localhost:5174**
|
||||
|
||||
- [ ] **Step 2: 登录后检查侧边栏**
|
||||
|
||||
Expected: 看到"自由职业者工作台"菜单组,包含:工作台、客户管理、商机看板、项目管理、项目详情、财务中心、支出管理
|
||||
|
||||
- [ ] **Step 3: 测试客户 CRUD**
|
||||
|
||||
进入客户管理 → 新增客户(填写名称、联系人、电话、行业等)→ 保存 → 列表中可见
|
||||
|
||||
- [ ] **Step 4: 测试项目 → 任务级联**
|
||||
|
||||
进入项目管理 → 新增项目 → 进入项目详情 → 新增任务 → 验证任务关联到项目
|
||||
|
||||
- [ ] **Step 5: 测试报价 → 报价明细级联**
|
||||
|
||||
进入财务中心 → 报价管理 tab → 新增报价 → 验证明细行可添加
|
||||
|
||||
- [ ] **Step 6: 测试商机看板**
|
||||
|
||||
进入商机看板 → 新增商机 → 拖拽改变阶段 → 验证数据更新
|
||||
|
||||
- [ ] **Step 7: 验证数据库表创建**
|
||||
|
||||
```bash
|
||||
PGPASSWORD=123123 "D:\postgreSQL\bin\psql.exe" -U postgres -h localhost -d erp -c "\dt plugin_erp_freelance_*"
|
||||
```
|
||||
|
||||
Expected: 看到 10 张动态表
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: itops 插件
|
||||
|
||||
### Task 5: 创建 itops 插件 crate
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-itops/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-itops/src/lib.rs`
|
||||
- Create: `crates/erp-plugin-itops/plugin.toml`
|
||||
|
||||
- [ ] **Step 1: 创建目录结构**
|
||||
|
||||
```bash
|
||||
mkdir -p crates/erp-plugin-itops/src
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写 Cargo.toml**
|
||||
|
||||
创建 `crates/erp-plugin-itops/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-itops"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "IT 运维服务台 WASM 插件"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.55"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写 src/lib.rs**
|
||||
|
||||
创建 `crates/erp-plugin-itops/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
//! IT 运维服务台 WASM 插件
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "../erp-plugin-prototype/wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||
|
||||
struct ItopsPlugin;
|
||||
|
||||
impl Guest for ItopsPlugin {
|
||||
fn init() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(_tenant_id: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(_event_type: String, _payload: Vec<u8>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
export!(ItopsPlugin);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编写 plugin.toml**
|
||||
|
||||
从设计规格文档 Section 3 提取完整内容:
|
||||
1. `[metadata]` — id="erp-itops",无 dependencies(松耦合)
|
||||
2. `[[permissions]]` × 8
|
||||
3. `[[schema.entities]]` × 4(service_contract, ticket, check_plan, check_record),每个实体包含 fields 和 relations
|
||||
4. `[[numbering]]` × 1(contract_number)
|
||||
5. `[[ui.pages]]` × 4(crud+detail for service_contract, tabs for ticket center)
|
||||
|
||||
关键注意点:
|
||||
- 4 个实体的 `client_id` 字段都使用 `ref_plugin = "erp-freelance"` + `ref_fallback_label = "外部客户"`
|
||||
- `filterable` 只用于 string 类型的 status/type/category 字段,不用于 uuid 字段
|
||||
- `check_items` 和 `items_data` 使用 `field_type = "json"`
|
||||
- `responded_at` / `resolved_at` / `closed_at` 使用 `field_type = "date_time"`
|
||||
|
||||
- [ ] **Step 5: 注册到 workspace**
|
||||
|
||||
编辑根 `Cargo.toml`,在 members 数组末尾添加:
|
||||
|
||||
```toml
|
||||
"crates/erp-plugin-itops",
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-itops
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-itops/ Cargo.toml
|
||||
git commit -m "feat(itops): 创建 IT 运维服务台插件 — 4 实体/8 权限/4 页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 编译 WASM 并安装 itops
|
||||
|
||||
- [ ] **Step 1: 编译为 WASM**
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-itops --target wasm32-unknown-unknown --release
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 转换为 Component**
|
||||
|
||||
```bash
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_itops.wasm -o target/erp_plugin_itops.component.wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 上传安装插件**
|
||||
|
||||
```bash
|
||||
TOKEN="<之前获取的 token>"
|
||||
MANIFEST=$(cat crates/erp-plugin-itops/plugin.toml)
|
||||
|
||||
curl -s -X POST http://localhost:3000/api/v1/admin/plugins/upload \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "wasm=@target/erp_plugin_itops.component.wasm" \
|
||||
-F "manifest=$MANIFEST"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 启用插件**
|
||||
|
||||
```bash
|
||||
PLUGIN_ID="<返回的插件 ID>"
|
||||
curl -s -X POST "http://localhost:3000/api/v1/admin/plugins/$PLUGIN_ID/enable" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 浏览器验证 itops 插件
|
||||
|
||||
- [ ] **Step 1: 检查侧边栏**
|
||||
|
||||
Expected: 看到"IT 运维服务台"菜单组,包含:合同管理、合同详情、工单中心
|
||||
|
||||
- [ ] **Step 2: 测试维保合同 CRUD**
|
||||
|
||||
进入合同管理 → 新增维保合同(选择客户时验证:如 freelance 已安装,客户下拉显示 freelance 的客户列表)
|
||||
|
||||
- [ ] **Step 3: 测试跨插件引用**
|
||||
|
||||
场景 A(freelance 已安装):创建工单时 client_id 字段显示为下拉选择器,可搜索 freelance.client
|
||||
场景 B(freelance 未安装):client_id 降级为文本输入,显示"外部客户"
|
||||
|
||||
- [ ] **Step 4: 测试合同 → 工单 → 巡检级联**
|
||||
|
||||
进入合同详情 → 工单 tab → 新增工单 → 巡检计划 tab → 新增巡检计划 → 巡检记录 tab → 新增巡检记录
|
||||
|
||||
- [ ] **Step 5: 验证数据库表**
|
||||
|
||||
```bash
|
||||
PGPASSWORD=123123 "D:\postgreSQL\bin\psql.exe" -U postgres -h localhost -d erp -c "\dt plugin_erp_itops_*"
|
||||
```
|
||||
|
||||
Expected: 看到 4 张动态表
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 集成验证
|
||||
|
||||
### Task 8: 全链路端到端验证
|
||||
|
||||
- [ ] **Step 1: 创建客户**
|
||||
|
||||
freelance → 客户管理 → 新增客户"汕头市XX科技有限公司"
|
||||
|
||||
- [ ] **Step 2: 创建商机**
|
||||
|
||||
商机看板 → 新增商机 → 选择客户 → 填写"官网开发"→ 拖拽到"成交"阶段
|
||||
|
||||
- [ ] **Step 3: 创建报价单**
|
||||
|
||||
财务中心 → 报价管理 → 新增报价 → 选择客户 → 添加明细行 → 保存
|
||||
|
||||
- [ ] **Step 4: 创建合同**
|
||||
|
||||
财务中心 → 合同管理 → 新增合同 → 选择客户 → 填写金额和日期 → 保存
|
||||
|
||||
- [ ] **Step 5: 创建项目**
|
||||
|
||||
项目管理 → 新增项目 → 选择客户和合同 → 填写"官网开发项目" → 添加任务 → 记录工时
|
||||
|
||||
- [ ] **Step 6: 创建发票**
|
||||
|
||||
财务中心 → 发票/收款 → 新增发票 → 选择客户和项目 → 填写金额 → 标记已收款
|
||||
|
||||
- [ ] **Step 7: 创建运维工单**
|
||||
|
||||
itops → 合同管理 → 新增维保合同 → 选择客户(验证跨插件引用)→ 保存
|
||||
itops → 工单中心 → 新增工单 → 选择客户和合同 → 保存
|
||||
|
||||
- [ ] **Step 8: 记录支出**
|
||||
|
||||
freelance → 支出管理 → 新增支出 → 选择类别"云服务" → 填写金额 → 保存
|
||||
|
||||
- [ ] **Step 9: 提交并推送**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(freelance,itops): 汕头市智界科技 IT 服务行业插件验证通过"
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键参考文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `crates/erp-plugin-crm/Cargo.toml` | Cargo.toml 模板参考 |
|
||||
| `crates/erp-plugin-crm/src/lib.rs` | lib.rs 代码模式参考 |
|
||||
| `crates/erp-plugin-crm/plugin.toml` | plugin.toml 格式参考(同插件内引用) |
|
||||
| `crates/erp-plugin-inventory/plugin.toml` | 跨插件引用格式参考(ref_plugin) |
|
||||
| `crates/erp-plugin/src/manifest.rs` | PluginField/PluginFieldType 完整定义 |
|
||||
| `crates/erp-plugin-prototype/wit/plugin.wit` | WIT 接口定义 |
|
||||
| `wiki/infrastructure.md` | 数据库连接、端口、登录凭据 |
|
||||
| `wiki/wasm-plugin.md` | 插件制作完整流程 |
|
||||
@@ -0,0 +1,624 @@
|
||||
# freelance + itops 插件增强设计规格
|
||||
|
||||
> 日期: 2026-04-20
|
||||
> 来源: 多专家头脑风暴(UX专家 + 业务顾问 + 运维专家 + 财务专家)
|
||||
> 状态: Draft
|
||||
> 前置: `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
当前插件是「数据录入系统」,不是「赚钱工具」。一人 IT 服务公司的核心痛点:
|
||||
|
||||
1. **钱从哪里来?** — 商机跟进靠人记,没有自动提醒、没有漏斗分析
|
||||
2. **项目做到哪了?** — 任务状态和工时手动填,跟合同金额/应收款脱节
|
||||
3. **钱收回来了吗?** — 报价→合同→开票→收款割裂,没有串联
|
||||
4. **运维服务会不会忘?** — 巡检计划写了没人催,SLA 超时了才知道
|
||||
5. **税和利润算不清?** — 收支分散在不同表里,月底做账要手动汇总
|
||||
|
||||
**问题根因:** 平台已有 trigger_events、settings、templates、cascade_from、visible_when、validation 六大能力,但两个插件完全没有使用。
|
||||
|
||||
**改进目标:** 纯插件层增强,三层递进:
|
||||
- Layer 1: 智能业务引擎 — 让系统主动驱动用户做事
|
||||
- Layer 2: 仪表盘重构 — 一个页面掌控全局
|
||||
- Layer 3: 专业输出 — 一键生成报价单/发票/合同 PDF
|
||||
|
||||
---
|
||||
|
||||
## 2. Layer 1: 智能业务引擎 — freelance 插件
|
||||
|
||||
### 2.1 Settings(插件配置页)
|
||||
|
||||
一次性配置公司信息和业务偏好,后续自动生效:
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
|
||||
# ── 基本信息 ──
|
||||
[[settings.fields]]
|
||||
name = "company_name"
|
||||
display_name = "公司名称"
|
||||
field_type = "text"
|
||||
required = true
|
||||
group = "基本信息"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "currency_symbol"
|
||||
display_name = "货币符号"
|
||||
field_type = "text"
|
||||
default_value = "¥"
|
||||
group = "基本信息"
|
||||
|
||||
# ── 财务 ──
|
||||
[[settings.fields]]
|
||||
name = "default_tax_rate"
|
||||
display_name = "默认税率(%)"
|
||||
field_type = "number"
|
||||
default_value = 6
|
||||
range = [0.0, 100.0]
|
||||
group = "财务"
|
||||
|
||||
# ── 提醒 ──
|
||||
[[settings.fields]]
|
||||
name = "payment_reminder_days"
|
||||
display_name = "收款提前提醒(天)"
|
||||
field_type = "number"
|
||||
default_value = 3
|
||||
range = [1.0, 30.0]
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_contract_expiring"
|
||||
display_name = "合同到期提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_payment_overdue"
|
||||
display_name = "逾期收款提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_opportunity_followup"
|
||||
display_name = "商机跟进提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
```
|
||||
|
||||
### 2.2 Trigger Events(自动事件驱动)
|
||||
|
||||
关键操作时自动发通知,把"人找事"变"事找人":
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "opportunity_stage_changed"
|
||||
display_name = "商机阶段变更"
|
||||
description = "商机阶段发生变化时通知,特别是成交或失败"
|
||||
entity = "opportunity"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "contract_status_changed"
|
||||
display_name = "合同状态变更"
|
||||
description = "合同状态变化时检查到期预警"
|
||||
entity = "contract"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "invoice_status_changed"
|
||||
display_name = "发票状态变更"
|
||||
description = "发票状态变化时检查逾期收款"
|
||||
entity = "invoice"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "task_status_changed"
|
||||
display_name = "任务状态变更"
|
||||
description = "任务完成或取消时通知"
|
||||
entity = "task"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "expense_created"
|
||||
display_name = "新支出记录"
|
||||
description = "记录新支出时通知"
|
||||
entity = "expense"
|
||||
on = "create"
|
||||
```
|
||||
|
||||
### 2.3 Cascade(智能联动下拉)
|
||||
|
||||
选客户后自动过滤其关联数据。以下均为**已有字段追加 cascade 属性**,不是新增字段:
|
||||
|
||||
**contract 实体 — 已有 opportunity_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**contract 实体 — 已有 quote_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**invoice 实体 — 已有 project_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**invoice 实体 — 已有 contract_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**time_entry 实体 — 已有 task_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "project_id"
|
||||
cascade_filter = "project_id"
|
||||
```
|
||||
|
||||
### 2.4 Visible When(条件显示)
|
||||
|
||||
只在有意义时才显示字段。以下为**已有字段追加 visible_when 属性**:
|
||||
|
||||
**invoice 实体 — 已有 payment_date 字段追加:**
|
||||
```toml
|
||||
visible_when = "status == 'paid' || status == 'partial'"
|
||||
```
|
||||
|
||||
**contract 实体 — 已有 paid_amount 字段追加:**
|
||||
```toml
|
||||
visible_when = "status != 'drafting'"
|
||||
```
|
||||
|
||||
**task 实体 — 已有 actual_hours 字段追加:**
|
||||
```toml
|
||||
visible_when = "status != 'todo'"
|
||||
```
|
||||
|
||||
**quote 实体 — 已有 total_amount 字段追加:**
|
||||
```toml
|
||||
visible_when = "status != 'draft'"
|
||||
```
|
||||
|
||||
### 2.5 Validation(字段校验)
|
||||
|
||||
**已有字段追加 validation 属性**,不是新增字段:
|
||||
|
||||
**client 实体 — 已有 email 字段追加:**
|
||||
```toml
|
||||
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
|
||||
```
|
||||
|
||||
**client 实体 — 已有 phone 字段追加:**
|
||||
```toml
|
||||
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Layer 2: 仪表盘重构 — freelance 插件
|
||||
|
||||
将占位符仪表盘升级为真正的指挥中心。通过 `widgets` 声明告诉平台该展示什么。
|
||||
|
||||
> **平台依赖:** 仪表盘 widgets 需要平台层配合:
|
||||
> 1. `manifest.rs` 的 `PluginPageType::Dashboard` 需要新增 `widgets: Option<Vec<PluginWidget>>` 字段
|
||||
> 2. 定义 `PluginWidget` 枚举(stat_cards/action_list/funnel/card_list 类型)
|
||||
> 3. 更新 TOML 解析和验证逻辑
|
||||
> 4. 前端解析 `widgets` 声明并渲染对应组件
|
||||
>
|
||||
> 因此 P5/P6 **不是纯 plugin.toml 改动**,需要平台+前端联合实施。以下 widgets 声明作为设计参考,实施时需先完成平台侧支持。
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "dashboard"
|
||||
label = "工作台"
|
||||
icon = "DashboardOutlined"
|
||||
|
||||
# ── 财务概览卡片 ──
|
||||
[[ui.pages.widgets]]
|
||||
type = "stat_cards"
|
||||
label = "财务概览"
|
||||
cards = [
|
||||
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "type == 'payment' && status != 'overdue'", label = "本月收入", icon = "rise", color = "green" },
|
||||
{ entity = "expense", aggregate = "sum", field = "amount", label = "本月支出", icon = "fall", color = "red" },
|
||||
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "status == 'overdue' || status == 'pending'", label = "应收总额", icon = "dollar", color = "orange" },
|
||||
{ entity = "invoice", aggregate = "count", filter = "status == 'overdue'", label = "逾期笔数", icon = "warning", color = "red" }
|
||||
]
|
||||
|
||||
# ── 紧急待办 ──
|
||||
[[ui.pages.widgets]]
|
||||
type = "action_list"
|
||||
label = "紧急待办"
|
||||
max_items = 5
|
||||
queries = [
|
||||
{ entity = "invoice", filter = "status == 'overdue'", label_field = "invoice_number", subtitle_field = "amount", action = "查看", icon = "warning" },
|
||||
{ entity = "task", filter = "status != 'done' && status != 'cancelled'", sort = "due_date asc", label_field = "title", subtitle_field = "due_date", action = "处理", icon = "clock" },
|
||||
{ entity = "contract", filter = "status == 'active'", sort = "end_date asc", label_field = "title", subtitle_field = "end_date", action = "续约", icon = "file-text" },
|
||||
{ entity = "opportunity", filter = "next_follow_up <= today", label_field = "title", subtitle_field = "next_follow_up", action = "跟进", icon = "phone" }
|
||||
]
|
||||
|
||||
# ── 商机漏斗 ──
|
||||
[[ui.pages.widgets]]
|
||||
type = "funnel"
|
||||
label = "商机漏斗"
|
||||
entity = "opportunity"
|
||||
lane_field = "stage"
|
||||
value_field = "estimated_amount"
|
||||
lane_order = ["visit", "requirement", "quote", "negotiation", "won", "lost"]
|
||||
|
||||
# ── 活跃项目卡片 ──
|
||||
[[ui.pages.widgets]]
|
||||
type = "card_list"
|
||||
label = "活跃项目"
|
||||
entity = "project"
|
||||
filter = "status == 'in_progress'"
|
||||
max_items = 4
|
||||
title_field = "name"
|
||||
subtitle_field = "contract_amount"
|
||||
tags = ["business_type", "status"]
|
||||
```
|
||||
|
||||
**依赖:** 数据源来自平台已有的聚合 API(`/count`、`/aggregate`)。Filter 表达式使用平台过滤 DSL(`==`, `!=`, `||`, `&&`, `<=`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. Layer 3: 专业输出 — freelance 插件
|
||||
|
||||
一键生成专业 PDF,替代手动排 Word。
|
||||
|
||||
> **模板引擎说明:**
|
||||
> - 语法基于 Handlebars(`{{field}}`, `{{#each relation}}...{{/each}}`)
|
||||
> - 当前实体字段直接可用:`{{amount}}`, `{{status}}`
|
||||
> - 关系字段解析:`{{client.name}}` 表示通过 `client_id` 引用的 client 实体的 name 字段,渲染器需自动解析
|
||||
> - `{{#each lines}}` 用于一对多关系(如 quote → quote_line),渲染器查询子实体并遍历
|
||||
> - 平台需要实现 PDF 渲染管道:TOML 模板 → Handlebars 渲染(注入数据)→ HTML → wkhtmltopdf/浏览器打印 → PDF
|
||||
|
||||
### 4.1 报价单模板
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "quote_pdf"
|
||||
display_name = "报价单"
|
||||
entity = "quote"
|
||||
format = "pdf"
|
||||
template_html = """
|
||||
<html>
|
||||
<head><style>
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
|
||||
h1 { text-align: center; border-bottom: 2px solid #333; padding-bottom: 10px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f5f5f5; }
|
||||
.total { text-align: right; font-size: 18px; font-weight: bold; }
|
||||
.footer { margin-top: 40px; color: #666; font-size: 12px; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>报价单 {{quote_number}}</h1>
|
||||
<p>客户:{{client.name}} | 有效期至:{{valid_until}}</p>
|
||||
<table>
|
||||
<tr><th>项目</th><th>描述</th><th>数量</th><th>单价</th><th>金额</th></tr>
|
||||
{{#each lines}}
|
||||
<tr><td>{{item_name}}</td><td>{{description}}</td><td>{{quantity}}</td><td>{{unit_price}}</td><td>{{amount}}</td></tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
<p class="total">小计:{{subtotal}} | 税率:{{tax_rate}}% | 总计:{{total_amount}}</p>
|
||||
<div class="footer">备注:{{notes}}</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
### 4.2 发票模板
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "invoice_pdf"
|
||||
display_name = "发票"
|
||||
entity = "invoice"
|
||||
format = "pdf"
|
||||
template_html = """
|
||||
<html>
|
||||
<head><style>
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
|
||||
h1 { text-align: center; color: #1890ff; border-bottom: 2px solid #1890ff; padding-bottom: 10px; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
|
||||
.info-item { padding: 8px; background: #fafafa; }
|
||||
.amount { font-size: 24px; font-weight: bold; text-align: center; color: #f5222d; margin: 20px 0; }
|
||||
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 4px; background: #f0f0f0; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>发票 {{invoice_number}}</h1>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">客户:{{client.name}}</div>
|
||||
<div class="info-item">类型:{{type}}</div>
|
||||
<div class="info-item">开票日期:{{issue_date}}</div>
|
||||
<div class="info-item">到期日:{{due_date}}</div>
|
||||
</div>
|
||||
<div class="amount">¥{{amount}}</div>
|
||||
<p>状态:<span class="status-badge">{{status}}</span></p>
|
||||
<p>备注:{{notes}}</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
### 4.3 合同模板
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "contract_pdf"
|
||||
display_name = "合同"
|
||||
entity = "contract"
|
||||
format = "pdf"
|
||||
template_html = """
|
||||
<html>
|
||||
<head><style>
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
|
||||
h1 { text-align: center; border-bottom: 3px double #333; padding-bottom: 10px; }
|
||||
.parties { margin: 20px 0; padding: 15px; background: #fafafa; border-left: 4px solid #1890ff; }
|
||||
.signature { margin-top: 60px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
|
||||
.sig-box { border-top: 1px solid #333; padding-top: 10px; text-align: center; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>{{title}}</h1>
|
||||
<p>合同编号:{{contract_number}}</p>
|
||||
<div class="parties">
|
||||
<p>甲方:{{client.name}}</p>
|
||||
<p>合同金额:¥{{amount}} | 已付:¥{{paid_amount}}</p>
|
||||
<p>期限:{{start_date}} 至 {{end_date}}</p>
|
||||
<p>付款条款:{{payment_terms}}</p>
|
||||
</div>
|
||||
<p>备注:{{notes}}</p>
|
||||
<div class="signature">
|
||||
<div class="sig-box">甲方签章</div>
|
||||
<div class="sig-box">乙方签章</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. itops 插件增强
|
||||
|
||||
### 5.1 Settings
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_sla_response"
|
||||
display_name = "默认SLA响应时间(小时)"
|
||||
field_type = "number"
|
||||
default_value = 8
|
||||
range = [1.0, 72.0]
|
||||
group = "SLA"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_sla_resolve"
|
||||
display_name = "默认SLA解决时间(小时)"
|
||||
field_type = "number"
|
||||
default_value = 48
|
||||
range = [1.0, 168.0]
|
||||
group = "SLA"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_sla_breach"
|
||||
display_name = "SLA超标提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_check_due"
|
||||
display_name = "巡检到期提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
```
|
||||
|
||||
### 5.2 Trigger Events
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "ticket_created"
|
||||
display_name = "新工单"
|
||||
description = "创建工单时开始SLA计时并通知"
|
||||
entity = "ticket"
|
||||
on = "create"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "ticket_status_changed"
|
||||
display_name = "工单状态变更"
|
||||
description = "工单状态变化时检查SLA是否达标"
|
||||
entity = "ticket"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "contract_status_changed"
|
||||
display_name = "维保合同状态变更"
|
||||
description = "合同状态变化时检查到期预警"
|
||||
entity = "service_contract"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "check_plan_updated"
|
||||
display_name = "巡检计划更新"
|
||||
description = "巡检计划更新时检查下次巡检日期"
|
||||
entity = "check_plan"
|
||||
on = "update"
|
||||
```
|
||||
|
||||
### 5.3 Cascade
|
||||
|
||||
**已有字段追加 cascade 属性**,不是新增字段:
|
||||
|
||||
**ticket 实体 — 已有 contract_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**check_record 实体 — 已有 contract_id 字段追加:**
|
||||
```toml
|
||||
cascade_from = "plan_id"
|
||||
cascade_filter = "contract_id"
|
||||
```
|
||||
|
||||
### 5.4 Visible When
|
||||
|
||||
**已有字段追加 visible_when 属性**:
|
||||
|
||||
**ticket 实体 — 已有 resolution 字段追加:**
|
||||
```toml
|
||||
visible_when = "status == 'resolved' || status == 'closed'"
|
||||
```
|
||||
|
||||
**ticket 实体 — 已有 responded_at 字段追加:**
|
||||
```toml
|
||||
visible_when = "status != 'open'"
|
||||
```
|
||||
|
||||
**ticket 实体 — 已有 resolved_at 字段追加:**
|
||||
```toml
|
||||
visible_when = "status == 'resolved' || status == 'closed'"
|
||||
```
|
||||
|
||||
**ticket 实体 — 已有 closed_at 字段追加:**
|
||||
```toml
|
||||
visible_when = "status == 'closed'"
|
||||
```
|
||||
|
||||
**check_record 实体 — 已有 issues_found 字段追加:**
|
||||
```toml
|
||||
visible_when = "result == 'abnormal'"
|
||||
```
|
||||
|
||||
**check_record 实体 — 已有 actions_taken 字段追加:**
|
||||
```toml
|
||||
visible_when = "result == 'abnormal'"
|
||||
```
|
||||
|
||||
### 5.5 Validation
|
||||
|
||||
**已有字段追加 validation 属性**:
|
||||
|
||||
**service_contract 实体 — 已有 contract_number 字段追加:**
|
||||
```toml
|
||||
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式:SC-YYYY-NNNN" }
|
||||
```
|
||||
|
||||
### 5.6 Dashboard
|
||||
|
||||
> **同 Layer 2 说明:** widgets 需要平台层配合(manifest.rs 扩展 + 前端渲染),非纯 plugin.toml 改动。此仪表盘页面**插入到现有页面列表最前面**,现有 4 个页面保持不变。
|
||||
|
||||
```toml
|
||||
[[ui.pages]]
|
||||
type = "dashboard"
|
||||
label = "运维概览"
|
||||
icon = "DashboardOutlined"
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "stat_cards"
|
||||
label = "运维概览"
|
||||
cards = [
|
||||
{ entity = "service_contract", aggregate = "count", filter = "status == 'active'", label = "活跃合同", icon = "file-text", color = "blue" },
|
||||
{ entity = "ticket", aggregate = "count", filter = "status == 'open' || status == 'in_progress'", label = "待处理工单", icon = "tool", color = "orange" },
|
||||
{ entity = "ticket", aggregate = "count", filter = "status == 'resolved'", label = "已解决工单", icon = "check-circle", color = "green" },
|
||||
{ entity = "check_plan", aggregate = "count", filter = "status == 'active'", label = "活跃巡检", icon = "schedule", color = "blue" }
|
||||
]
|
||||
|
||||
[[ui.pages.widgets]]
|
||||
type = "action_list"
|
||||
label = "紧急待办"
|
||||
max_items = 5
|
||||
queries = [
|
||||
{ entity = "ticket", filter = "status == 'open'", sort = "priority asc", label_field = "title", subtitle_field = "type", action = "处理", icon = "warning" },
|
||||
{ entity = "service_contract", filter = "status == 'active'", sort = "end_date asc", label_field = "name", subtitle_field = "end_date", action = "续约", icon = "file-text" },
|
||||
{ entity = "check_plan", filter = "status == 'active'", sort = "next_check_date asc", label_field = "name", subtitle_field = "next_check_date", action = "巡检", icon = "schedule" }
|
||||
]
|
||||
```
|
||||
|
||||
### 5.7 Template(维保合同 PDF)
|
||||
|
||||
```toml
|
||||
[[templates]]
|
||||
name = "service_contract_pdf"
|
||||
display_name = "维保合同"
|
||||
entity = "service_contract"
|
||||
format = "pdf"
|
||||
template_html = """
|
||||
<html>
|
||||
<head><style>
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
|
||||
h1 { text-align: center; border-bottom: 3px double #1890ff; padding-bottom: 10px; color: #1890ff; }
|
||||
.sla-box { margin: 20px 0; padding: 15px; background: #e6f7ff; border: 1px solid #91d5ff; border-radius: 4px; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
|
||||
.info-item { padding: 8px; background: #fafafa; }
|
||||
.signature { margin-top: 60px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
|
||||
.sig-box { border-top: 1px solid #333; padding-top: 10px; text-align: center; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>{{name}}</h1>
|
||||
<p>合同编号:{{contract_number}}</p>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">客户:{{client.name}}</div>
|
||||
<div class="info-item">合同金额:¥{{amount}}</div>
|
||||
<div class="info-item">期限:{{start_date}} 至 {{end_date}}</div>
|
||||
<div class="info-item">状态:{{status}}</div>
|
||||
</div>
|
||||
<div class="sla-box">
|
||||
<strong>SLA 承诺:</strong>响应 {{sla_response_hours}} 小时内 / 解决 {{sla_resolve_hours}} 小时内
|
||||
</div>
|
||||
<p>服务范围:{{service_scope}}</p>
|
||||
<p>付款条款:{{payment_terms}}</p>
|
||||
<p>备注:{{notes}}</p>
|
||||
<div class="signature">
|
||||
<div class="sig-box">甲方签章</div>
|
||||
<div class="sig-box">乙方签章</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 改进汇总
|
||||
|
||||
| 层次 | 能力 | freelance | itops |
|
||||
|------|------|-----------|-------|
|
||||
| Layer 1 | settings | 7 个配置项(公司名/税率/提醒偏好) | 4 个配置项(SLA默认值/提醒偏好) |
|
||||
| Layer 1 | trigger_events | 5 个事件(商机/合同/发票/任务/支出) | 4 个事件(工单/合同/巡检) |
|
||||
| Layer 1 | cascade | 4 处联动(合同/发票/工时表单) | 2 处联动(工单/巡检记录) |
|
||||
| Layer 1 | visible_when | 4 个条件字段 | 6 个条件字段 |
|
||||
| Layer 1 | validation | 2 个校验(邮箱/手机) | 1 个校验(合同编号格式) |
|
||||
| Layer 2 | dashboard widgets | 财务卡片+紧急待办+商机漏斗+项目卡片 | 运维卡片+紧急待办 |
|
||||
| Layer 3 | templates | 3 个 PDF(报价单/发票/合同) | 1 个 PDF(维保合同) |
|
||||
|
||||
**总计:** 2 个插件 × 3 层增强,从「数据录入」升级为「赚钱工具」。
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施优先级
|
||||
|
||||
```
|
||||
P1: freelance Layer 1(settings + trigger_events + cascade + visible_when + validation)
|
||||
P2: itops Layer 1(settings + trigger_events + cascade + visible_when + validation)
|
||||
P3: freelance Layer 3(3 个 PDF 模板)
|
||||
P4: itops Layer 3(维保合同 PDF 模板)
|
||||
P5: freelance Layer 2(仪表盘 widgets)
|
||||
P6: itops Layer 2(仪表盘 widgets)
|
||||
```
|
||||
|
||||
P1-P4 是纯 plugin.toml 改动(给已有字段追加 cascade/visible_when/validation 属性,以及新增 settings/trigger_events/templates 段落),可立即实施。P5-P6 的仪表盘 widgets 需要平台层配合:扩展 `manifest.rs` 的 `PluginPageType::Dashboard` 支持 `widgets` 字段 + 前端渲染组件。
|
||||
@@ -0,0 +1,587 @@
|
||||
# freelance + itops 插件增强实施计划
|
||||
|
||||
> 日期: 2026-04-20
|
||||
> 对应规格: `docs/superpowers/specs/2026-04-20-freelance-itops-plugin-enhancement-design.md`
|
||||
> 前置: 两插件已部署(freelance 10 实体/20 权限,itops 4 实体/8 权限)
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| Phase | 内容 | 类型 | 依赖 |
|
||||
|-------|------|------|------|
|
||||
| P1 | freelance Layer 1 — 智能业务引擎 | 纯 plugin.toml | 无 |
|
||||
| P2 | itops Layer 1 — 智能业务引擎 | 纯 plugin.toml | 无 |
|
||||
| P3 | freelance Layer 3 — PDF 模板 | 纯 plugin.toml | 无 |
|
||||
| P4 | itops Layer 3 — PDF 模板 | 纯 plugin.toml | 无 |
|
||||
| P5 | 平台 dashboard widgets 扩展 | manifest.rs + 前端 | P1-P4 完成 |
|
||||
| P6 | freelance + itops Layer 2 — 仪表盘 | plugin.toml + 前端 | P5 完成 |
|
||||
|
||||
P1-P4 可并行,P5-P6 顺序依赖。
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: freelance Layer 1 — 智能业务引擎
|
||||
|
||||
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
|
||||
|
||||
### Task 1.1: 新增 `[settings]` 段落
|
||||
|
||||
在 `[[numbering]]` 之前插入 7 个配置项:
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
|
||||
[[settings.fields]]
|
||||
name = "company_name"
|
||||
display_name = "公司名称"
|
||||
field_type = "text"
|
||||
required = true
|
||||
group = "基本信息"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "currency_symbol"
|
||||
display_name = "货币符号"
|
||||
field_type = "text"
|
||||
default_value = "¥"
|
||||
group = "基本信息"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_tax_rate"
|
||||
display_name = "默认税率(%)"
|
||||
field_type = "number"
|
||||
default_value = 6
|
||||
range = [0.0, 100.0]
|
||||
group = "财务"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "payment_reminder_days"
|
||||
display_name = "收款提前提醒(天)"
|
||||
field_type = "number"
|
||||
default_value = 3
|
||||
range = [1.0, 30.0]
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_contract_expiring"
|
||||
display_name = "合同到期提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_payment_overdue"
|
||||
display_name = "逾期收款提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_opportunity_followup"
|
||||
display_name = "商机跟进提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
```
|
||||
|
||||
### Task 1.2: 新增 `[[trigger_events]]` 段落
|
||||
|
||||
在 `[settings]` 之后插入 5 个触发事件:
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "opportunity_stage_changed"
|
||||
display_name = "商机阶段变更"
|
||||
description = "商机阶段发生变化时通知,特别是成交或失败"
|
||||
entity = "opportunity"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "contract_status_changed"
|
||||
display_name = "合同状态变更"
|
||||
description = "合同状态变化时检查到期预警"
|
||||
entity = "contract"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "invoice_status_changed"
|
||||
display_name = "发票状态变更"
|
||||
description = "发票状态变化时检查逾期收款"
|
||||
entity = "invoice"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "task_status_changed"
|
||||
display_name = "任务状态变更"
|
||||
description = "任务完成或取消时通知"
|
||||
entity = "task"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "expense_created"
|
||||
display_name = "新支出记录"
|
||||
description = "记录新支出时通知"
|
||||
entity = "expense"
|
||||
on = "create"
|
||||
```
|
||||
|
||||
### Task 1.3: 追加 cascade 属性(5 处已有字段)
|
||||
|
||||
**1.3a** contract.opportunity_id(第 450-454 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**1.3b** contract.quote_id(第 456-460 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**1.3c** invoice.project_id(第 796-800 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**1.3d** invoice.contract_id(第 802-806 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**1.3e** time_entry.task_id(第 745-751 行)追加:
|
||||
```toml
|
||||
cascade_from = "project_id"
|
||||
cascade_filter = "project_id"
|
||||
```
|
||||
|
||||
### Task 1.4: 追加 visible_when 属性(4 处已有字段)
|
||||
|
||||
**1.4a** invoice.payment_date(第 860-863 行)追加:
|
||||
```toml
|
||||
visible_when = "status == 'paid' || status == 'partial'"
|
||||
```
|
||||
|
||||
**1.4b** contract.paid_amount(第 516-520 行)追加:
|
||||
```toml
|
||||
visible_when = "status != 'drafting'"
|
||||
```
|
||||
|
||||
**1.4c** task.actual_hours(第 727-730 行)追加:
|
||||
```toml
|
||||
visible_when = "status != 'todo'"
|
||||
```
|
||||
|
||||
**1.4d** quote.total_amount(第 357-361 行)追加:
|
||||
```toml
|
||||
visible_when = "status != 'draft'"
|
||||
```
|
||||
|
||||
### Task 1.5: 追加 validation 属性(2 处已有字段)
|
||||
|
||||
**1.5a** client.phone(第 135-138 行)追加:
|
||||
```toml
|
||||
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
|
||||
```
|
||||
|
||||
**1.5b** client.email(第 140-143 行)追加:
|
||||
```toml
|
||||
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
|
||||
```
|
||||
|
||||
### Task 1.6: 编译 WASM + 升级插件
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-freelance --target wasm32-unknown-unknown --release
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm -o target/erp_plugin_freelance.component.wasm
|
||||
```
|
||||
|
||||
通过 API 升级:
|
||||
```bash
|
||||
# 上传新版本 WASM
|
||||
curl -X POST http://localhost:3000/api/v1/admin/plugins/{plugin_id}/upgrade \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-F "wasm=@target/erp_plugin_freelance.component.wasm" \
|
||||
-F "manifest=@crates/erp-plugin-freelance/plugin.toml"
|
||||
```
|
||||
|
||||
### Task 1.7: 验证
|
||||
|
||||
- [ ] `cargo check` 通过
|
||||
- [ ] 重新登录获取新 JWT(权限可能变化)
|
||||
- [ ] 前端打开 freelance 插件 → 设置页面可见 7 个配置项
|
||||
- [ ] 创建客户 → phone 格式错误时提示校验信息
|
||||
- [ ] 创建客户 → email 格式错误时提示校验信息
|
||||
- [ ] 创建合同 → 选客户后 opportunity_id 和 quote_id 自动过滤
|
||||
- [ ] 创建发票 → 选客户后 project_id 和 contract_id 自动过滤
|
||||
- [ ] 创建工时 → 选项目后 task_id 自动过滤
|
||||
- [ ] invoice 状态为 pending 时,payment_date 字段不显示
|
||||
- [ ] contract 状态为 drafting 时,paid_amount 字段不显示
|
||||
- [ ] 触发事件:更新商机阶段 → 消息中心收到通知
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: itops Layer 1 — 智能业务引擎
|
||||
|
||||
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
|
||||
|
||||
### Task 2.1: 新增 `[settings]` 段落
|
||||
|
||||
在 `[[numbering]]` 之前插入 4 个配置项:
|
||||
|
||||
```toml
|
||||
[settings]
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_sla_response"
|
||||
display_name = "默认SLA响应时间(小时)"
|
||||
field_type = "number"
|
||||
default_value = 8
|
||||
range = [1.0, 72.0]
|
||||
group = "SLA"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "default_sla_resolve"
|
||||
display_name = "默认SLA解决时间(小时)"
|
||||
field_type = "number"
|
||||
default_value = 48
|
||||
range = [1.0, 168.0]
|
||||
group = "SLA"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_sla_breach"
|
||||
display_name = "SLA超标提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
|
||||
[[settings.fields]]
|
||||
name = "notify_check_due"
|
||||
display_name = "巡检到期提醒"
|
||||
field_type = "boolean"
|
||||
default_value = true
|
||||
group = "提醒"
|
||||
```
|
||||
|
||||
### Task 2.2: 新增 `[[trigger_events]]` 段落
|
||||
|
||||
在 `[settings]` 之后插入 4 个触发事件:
|
||||
|
||||
```toml
|
||||
[[trigger_events]]
|
||||
name = "ticket_created"
|
||||
display_name = "新工单"
|
||||
description = "创建工单时开始SLA计时并通知"
|
||||
entity = "ticket"
|
||||
on = "create"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "ticket_status_changed"
|
||||
display_name = "工单状态变更"
|
||||
description = "工单状态变化时检查SLA是否达标"
|
||||
entity = "ticket"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "contract_status_changed"
|
||||
display_name = "维保合同状态变更"
|
||||
description = "合同状态变化时检查到期预警"
|
||||
entity = "service_contract"
|
||||
on = "update"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "check_plan_updated"
|
||||
display_name = "巡检计划更新"
|
||||
description = "巡检计划更新时检查下次巡检日期"
|
||||
entity = "check_plan"
|
||||
on = "update"
|
||||
```
|
||||
|
||||
### Task 2.3: 追加 cascade 属性(2 处已有字段)
|
||||
|
||||
**2.3a** ticket.contract_id(第 186-192 行)追加:
|
||||
```toml
|
||||
cascade_from = "client_id"
|
||||
cascade_filter = "client_id"
|
||||
```
|
||||
|
||||
**2.3b** check_record.contract_id(第 398-400 行)追加:
|
||||
```toml
|
||||
cascade_from = "plan_id"
|
||||
cascade_filter = "contract_id"
|
||||
```
|
||||
|
||||
### Task 2.4: 追加 visible_when 属性(6 处已有字段)
|
||||
|
||||
**2.4a** ticket.resolution → `visible_when = "status == 'resolved' || status == 'closed'"`
|
||||
**2.4b** ticket.responded_at → `visible_when = "status != 'open'"`
|
||||
**2.4c** ticket.resolved_at → `visible_when = "status == 'resolved' || status == 'closed'"`
|
||||
**2.4d** ticket.closed_at → `visible_when = "status == 'closed'"`
|
||||
**2.4e** check_record.issues_found → `visible_when = "result == 'abnormal'"`
|
||||
**2.4f** check_record.actions_taken → `visible_when = "result == 'abnormal'"`
|
||||
|
||||
### Task 2.5: 追加 validation 属性(1 处已有字段)
|
||||
|
||||
**2.5a** service_contract.contract_number(第 73-78 行)追加:
|
||||
```toml
|
||||
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式:SC-YYYY-NNNN" }
|
||||
```
|
||||
|
||||
### Task 2.6: 编译 WASM + 升级插件
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-itops --target wasm32-unknown-unknown --release
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_itops.wasm -o target/erp_plugin_itops.component.wasm
|
||||
```
|
||||
|
||||
### Task 2.7: 验证
|
||||
|
||||
- [ ] `cargo check` 通过
|
||||
- [ ] 重新登录获取新 JWT
|
||||
- [ ] 前端打开 itops 插件 → 设置页面可见 4 个配置项
|
||||
- [ ] 创建工单 → 选客户后 contract_id 自动过滤
|
||||
- [ ] 工单状态为 open 时,resolution/resolved_at/closed_at 不显示
|
||||
- [ ] 工单状态改为 resolved → resolution 和 resolved_at 出现
|
||||
- [ ] 巡检记录结果为 normal → issues_found/actions_taken 不显示
|
||||
- [ ] 巡检记录结果改为 abnormal → issues_found/actions_taken 出现
|
||||
- [ ] 触发事件:创建工单 → 消息中心收到通知
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: freelance Layer 3 — PDF 模板
|
||||
|
||||
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
|
||||
|
||||
### Task 3.1: 新增 `[[templates]]` 段落(3 个模板)
|
||||
|
||||
在 `[[ui.pages]]` 之前插入报价单、发票、合同 3 个 PDF 模板。
|
||||
|
||||
**报价单模板** (`quote_pdf`):
|
||||
- entity = "quote"
|
||||
- 包含 Handlebars 语法:`{{quote_number}}`, `{{client.name}}`, `{{#each lines}}`
|
||||
- 表格渲染:item_name / description / quantity / unit_price / amount
|
||||
- 底部:subtotal / tax_rate / total_amount
|
||||
|
||||
**发票模板** (`invoice_pdf`):
|
||||
- entity = "invoice"
|
||||
- grid 布局:client.name / type / issue_date / due_date
|
||||
- 大字金额:`¥{{amount}}`
|
||||
- 状态 badge
|
||||
|
||||
**合同模板** (`contract_pdf`):
|
||||
- entity = "contract"
|
||||
- 签章区域:甲方/乙方
|
||||
- parties 区块:client.name / amount / paid_amount / 期限 / payment_terms
|
||||
|
||||
### Task 3.2: 编译 WASM + 升级
|
||||
|
||||
同 Task 1.6 流程。
|
||||
|
||||
### Task 3.3: 验证
|
||||
|
||||
- [ ] 前端打开报价单详情 → 可见"生成 PDF"按钮
|
||||
- [ ] 点击生成 → 下载 PDF,内容包含正确的字段值
|
||||
- [ ] 发票 PDF → 金额/客户名正确
|
||||
- [ ] 合同 PDF → 签章区域正确
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: itops Layer 3 — 维保合同 PDF 模板
|
||||
|
||||
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
|
||||
|
||||
### Task 4.1: 新增 `[[templates]]` 段落(1 个模板)
|
||||
|
||||
维保合同模板 (`service_contract_pdf`):
|
||||
- entity = "service_contract"
|
||||
- SLA 承诺框:响应/解决时间
|
||||
- grid 布局:client.name / amount / 期限 / status
|
||||
- 服务范围 / 付款条款 / 签章区
|
||||
|
||||
### Task 4.2: 编译 WASM + 升级
|
||||
|
||||
同 Task 2.6 流程。
|
||||
|
||||
### Task 4.3: 验证
|
||||
|
||||
- [ ] 前端打开维保合同详情 → 可见"生成 PDF"按钮
|
||||
- [ ] 点击生成 → 下载 PDF,SLA 承诺正确
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 平台 dashboard widgets 扩展
|
||||
|
||||
> **注意:** 此阶段需要修改平台 Rust 代码 + 前端代码,不是纯 plugin.toml 改动。
|
||||
|
||||
### Task 5.1: 扩展 manifest.rs — 定义 PluginWidget 类型
|
||||
|
||||
**目标文件:** `crates/erp-plugin/src/manifest.rs`
|
||||
|
||||
在 `PluginPageType::Dashboard` 结构体中新增 `widgets` 字段:
|
||||
|
||||
```rust
|
||||
// PluginPageType::Dashboard 新增字段
|
||||
Dashboard {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default)]
|
||||
widgets: Option<Vec<PluginWidget>>, // 新增
|
||||
},
|
||||
```
|
||||
|
||||
定义 `PluginWidget` 枚举及其子类型:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum PluginWidget {
|
||||
StatCards {
|
||||
label: String,
|
||||
cards: Vec<StatCard>,
|
||||
},
|
||||
ActionList {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
max_items: Option<u32>,
|
||||
queries: Vec<ActionQuery>,
|
||||
},
|
||||
Funnel {
|
||||
label: String,
|
||||
entity: String,
|
||||
lane_field: String,
|
||||
#[serde(default)]
|
||||
value_field: Option<String>,
|
||||
lane_order: Vec<String>,
|
||||
},
|
||||
CardList {
|
||||
label: String,
|
||||
entity: String,
|
||||
#[serde(default)]
|
||||
filter: Option<String>,
|
||||
#[serde(default)]
|
||||
max_items: Option<u32>,
|
||||
title_field: String,
|
||||
#[serde(default)]
|
||||
subtitle_field: Option<String>,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatCard {
|
||||
pub entity: String,
|
||||
#[serde(default)]
|
||||
pub aggregate: Option<String>, // count, sum
|
||||
#[serde(default)]
|
||||
pub field: Option<String>,
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionQuery {
|
||||
pub entity: String,
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sort: Option<String>,
|
||||
pub label_field: String,
|
||||
#[serde(default)]
|
||||
pub subtitle_field: Option<String>,
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### Task 5.2: 扩展插件 API — 返回 widgets 数据
|
||||
|
||||
**目标文件:** `crates/erp-plugin/src/module.rs`
|
||||
|
||||
新增 API 端点,为 dashboard widgets 提供数据:
|
||||
|
||||
- `GET /api/v1/plugins/{plugin_id}/dashboard/widgets` — 返回 widgets 定义
|
||||
- `GET /api/v1/plugins/{plugin_id}/dashboard/data` — 返回 widgets 聚合数据(调用已有 count/aggregate API)
|
||||
|
||||
### Task 5.3: 前端渲染 dashboard widgets
|
||||
|
||||
**目标目录:** `apps/web/src/`
|
||||
|
||||
新增组件:
|
||||
- `PluginDashboard.tsx` — 仪表盘容器,读取 widgets 定义并渲染
|
||||
- `StatCardsWidget.tsx` — 统计卡片组件(4 个指标卡片)
|
||||
- `ActionListWidget.tsx` — 待办列表组件
|
||||
- `FunnelWidget.tsx` — 漏斗图组件
|
||||
- `CardListWidget.tsx` — 卡片列表组件
|
||||
|
||||
### Task 5.4: 验证
|
||||
|
||||
- [ ] `cargo check` 通过
|
||||
- [ ] 前端 `pnpm build` 通过
|
||||
- [ ] manifest.rs 正确解析 widgets TOML
|
||||
- [ ] API 返回 widgets 定义和聚合数据
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: freelance + itops Layer 2 — 仪表盘 widgets
|
||||
|
||||
> **前置:** Phase 5 完成(平台支持 widgets)
|
||||
|
||||
### Task 6.1: freelance — 替换仪表盘页面为 widgets 版本
|
||||
|
||||
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
|
||||
|
||||
将现有的空仪表盘(第 949-952 行)替换为包含 4 个 widgets 的完整仪表盘:
|
||||
1. stat_cards — 财务概览(4 张卡片)
|
||||
2. action_list — 紧急待办(4 种查询)
|
||||
3. funnel — 商机漏斗
|
||||
4. card_list — 活跃项目
|
||||
|
||||
### Task 6.2: itops — 新增仪表盘页面到最前面
|
||||
|
||||
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
|
||||
|
||||
在现有页面列表最前面插入仪表盘页面(2 个 widgets):
|
||||
1. stat_cards — 运维概览(4 张卡片)
|
||||
2. action_list — 紧急待办(3 种查询)
|
||||
|
||||
### Task 6.3: 两个插件各自编译 WASM + 升级
|
||||
|
||||
### Task 6.4: 验证
|
||||
|
||||
- [ ] freelance 仪表盘 → 4 个 widget 正确渲染
|
||||
- [ ] itops 仪表盘 → 2 个 widget 正确渲染
|
||||
- [ ] 财务卡片数值正确(调用 aggregate API)
|
||||
- [ ] 紧急待办列表有数据时显示条目
|
||||
- [ ] 商机漏斗按阶段显示金额分布
|
||||
- [ ] `git add && git commit && git push`
|
||||
|
||||
---
|
||||
|
||||
## 执行策略
|
||||
|
||||
**P1-P4 并行策略:** P1 和 P2 可以同时开始(不同文件),P3 和 P4 在 P1/P2 完成后立即跟进。每个 Phase 独立编译 WASM、独立验证、独立提交。
|
||||
|
||||
**P5-P6 顺序策略:** P5 是平台改动(Rust + 前端),P6 依赖 P5 的平台能力才能生效。
|
||||
|
||||
**预估工作量:**
|
||||
- P1: 30-40 分钟(plugin.toml 编辑 + 编译 + 验证)
|
||||
- P2: 20-30 分钟(规模小于 P1)
|
||||
- P3: 15-20 分钟(3 个模板插入)
|
||||
- P4: 10-15 分钟(1 个模板插入)
|
||||
- P5: 60-90 分钟(manifest 扩展 + API + 前端组件)
|
||||
- P6: 20-30 分钟(plugin.toml widgets 声明)
|
||||
@@ -0,0 +1,372 @@
|
||||
# ERP Platform 系统性联调测试报告
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| **测试日期** | 2026-04-14 |
|
||||
| **测试版本** | v0.1.0 |
|
||||
| **测试环境** | Windows 11 Pro / PostgreSQL 16 / Redis (未启动) |
|
||||
| **后端** | Axum 0.8 + Tokio, localhost:3000 |
|
||||
| **前端** | React 19 + Ant Design 6, localhost:5174 |
|
||||
| **测试账号** | admin (管理员角色, 全权限) |
|
||||
| **测试人员** | Claude Code 自动化联调测试 |
|
||||
|
||||
---
|
||||
|
||||
## 一、测试范围与方法
|
||||
|
||||
### 1.1 测试范围
|
||||
|
||||
| 层级 | 测试内容 | 端点/页面数 |
|
||||
|------|---------|------------|
|
||||
| 基础设施层 | Health Check, OpenAPI, 数据库连接 | 2 端点 |
|
||||
| Auth 模块 | 用户/角色/权限/组织/部门/岗位 CRUD | 27 端点 |
|
||||
| Config 模块 | 字典/菜单/设置/编号规则/主题/语言 | 25 端点 |
|
||||
| Workflow 模块 | 流程定义/实例/任务 生命周期 | 15 端点 |
|
||||
| Message 模块 | 消息/模板/订阅 CRUD + 事件通知 | 9 端点 |
|
||||
| 审计日志 | 操作日志查询 | 1 端点 |
|
||||
| 前端页面 | 7 个主页面 + 15 个子 Tab | 22 页面 |
|
||||
| **合计** | | **81 API 端点 + 22 前端页面** |
|
||||
|
||||
### 1.2 测试方法
|
||||
|
||||
- **API 自动化测试**: 通过 curl + Agent 并行执行 81 个 API 端点的正常/异常/边界场景
|
||||
- **前端浏览器测试**: 通过 Chrome DevTools 协议操作实际页面,验证数据真实性和交互功能
|
||||
- **数据交叉验证**: 前端展示数据与 API 返回数据逐一比对
|
||||
- **跨模块集成测试**: 验证 Workflow 事件 -> Message 通知的完整链路
|
||||
|
||||
### 1.3 通过/不通过标准
|
||||
|
||||
| 指标 | 通过标准 | 实际结果 |
|
||||
|------|---------|---------|
|
||||
| API 功能正确率 | >= 95% | 97.5% (78/80 已验证通过) |
|
||||
| 前端页面可访问性 | 100% | 100% (22/22 页面可访问) |
|
||||
| 数据一致性 | API 数据 == 前端展示 | 仪表盘 4/4 指标一致 |
|
||||
| 跨模块事件集成 | 100% 触发 | 100% (Workflow -> Message 正常) |
|
||||
| API 响应时间 | < 200ms | **不通过** (平均 2.2s) |
|
||||
| 安全认证 | 无 Token 返回 401 | 100% (所有受保护端点) |
|
||||
|
||||
---
|
||||
|
||||
## 二、API 测试结果
|
||||
|
||||
### 2.1 各模块测试概览
|
||||
|
||||
#### Auth 模块 (27 端点)
|
||||
|
||||
| 端点组 | 测试项 | 结果 |
|
||||
|--------|--------|------|
|
||||
| POST /auth/login | 正常登录 | PASS |
|
||||
| POST /auth/login | 错误密码 | PASS (返回 401) |
|
||||
| POST /auth/refresh | Token 刷新 | PASS |
|
||||
| POST /auth/logout | 登出 | PASS |
|
||||
| GET /users | 用户列表 (分页) | PASS |
|
||||
| POST /users | 创建用户 (完整字段) | PASS |
|
||||
| GET /users/{id} | 获取单个用户 | PASS |
|
||||
| PUT /users/{id} | 更新用户 | **FAIL** (见 BUG-01) |
|
||||
| DELETE /users/{id} | 软删除用户 | PASS |
|
||||
| POST /users/{id}/roles | 分配角色 | PASS |
|
||||
| GET /roles | 角色列表 | PASS |
|
||||
| POST /roles | 创建角色 | PASS |
|
||||
| GET /roles/{id} | 获取角色详情 | PASS |
|
||||
| PUT /roles/{id} | 更新角色 | PASS |
|
||||
| DELETE /roles/{id} | 删除角色 | PASS |
|
||||
| GET /roles/{id}/permissions | 获取角色权限 | PASS |
|
||||
| POST /roles/{id}/permissions | 分配权限 | PASS |
|
||||
| GET /permissions | 权限列表 | PASS |
|
||||
| GET /organizations | 组织列表 | PASS |
|
||||
| POST /organizations | 创建组织 | PASS |
|
||||
| PUT /organizations/{id} | 更新组织 | PASS |
|
||||
| DELETE /organizations/{id} | 删除组织 | PASS |
|
||||
| 部门 CRUD (4 端点) | 部门管理 | PASS |
|
||||
| 岗位 CRUD (4 端点) | 岗位管理 | PASS |
|
||||
|
||||
#### Config 模块 (25 端点)
|
||||
|
||||
| 端点组 | 测试项 | 结果 |
|
||||
|--------|--------|------|
|
||||
| 字典 CRUD (8 端点) | 字典+字典项管理 | **PASS** (全部通过) |
|
||||
| 菜单 CRUD (5 端点) | 菜单树管理 | **PASS** (全部通过) |
|
||||
| 系统设置 (3 端点) | 读取/更新/删除 | **FAIL** (见 BUG-02) |
|
||||
| 编号规则 (5 端点) | 规则CRUD+生成编号 | **PASS** (全部通过) |
|
||||
| 主题 (2 端点) | 读取/更新主题 | **FAIL** (见 BUG-02, 依赖 settings) |
|
||||
| 语言 (2 端点) | 列表/更新语言 | **WARN** (见 BUG-03) |
|
||||
|
||||
#### Workflow 模块 (15 端点)
|
||||
|
||||
| 端点 | 测试项 | 结果 |
|
||||
|------|--------|------|
|
||||
| POST definitions | 创建流程定义 | PASS |
|
||||
| GET definitions | 流程定义列表 | PASS |
|
||||
| GET definitions/{id} | 流程定义详情 | PASS |
|
||||
| PUT definitions/{id} | 更新流程定义 | PASS |
|
||||
| POST definitions/{id}/publish | 发布流程 | PASS |
|
||||
| POST instances | 启动流程实例 | PASS |
|
||||
| GET instances | 实例列表 | PASS |
|
||||
| GET instances/{id} | 实例详情 | PASS |
|
||||
| POST instances/{id}/suspend | 挂起实例 | PASS |
|
||||
| POST instances/{id}/resume | 恢复实例 | PASS |
|
||||
| POST instances/{id}/terminate | 终止实例 | PASS |
|
||||
| GET tasks/pending | 待办任务 | PASS |
|
||||
| GET tasks/completed | 已办任务 | PASS |
|
||||
| POST tasks/{id}/complete | 完成任务 | PASS |
|
||||
| POST tasks/{id}/delegate | 委派任务 | PASS |
|
||||
|
||||
#### Message 模块 (9 端点)
|
||||
|
||||
| 端点 | 测试项 | 结果 |
|
||||
|------|--------|------|
|
||||
| GET messages | 消息列表 | PASS |
|
||||
| POST messages | 发送消息 | PASS |
|
||||
| GET messages/unread-count | 未读数 | PASS |
|
||||
| PUT messages/{id}/read | 标记已读 | PASS |
|
||||
| PUT messages/read-all | 全部已读 | PASS |
|
||||
| DELETE messages/{id} | 删除消息 | PASS |
|
||||
| GET message-templates | 模板列表 | PASS |
|
||||
| POST message-templates | 创建模板 | PASS |
|
||||
| PUT message-subscriptions | 更新订阅 | PASS |
|
||||
|
||||
### 2.2 安全测试结果
|
||||
|
||||
| 测试项 | 预期 | 实际 | 结果 |
|
||||
|--------|------|------|------|
|
||||
| 无 Token 访问受保护端点 | 401 | 401 | PASS |
|
||||
| 无效 Token | 401 | 401 | PASS |
|
||||
| 空必填字段 | 400 | 400 | PASS |
|
||||
| 启动未发布流程 | 400 | 400 | PASS |
|
||||
| 重复完成任务 | 400 | 400 | PASS |
|
||||
| 查询不存在资源 | 404 | 404 | PASS |
|
||||
| 删除不存在消息 | 404 | 404 | PASS |
|
||||
| 无效优先级值 | 400 | 400 | PASS |
|
||||
| **通过率** | | | **100%** |
|
||||
|
||||
---
|
||||
|
||||
## 三、前端页面测试结果
|
||||
|
||||
### 3.1 页面可访问性与功能测试
|
||||
|
||||
| 页面 | URL 路由 | 可访问 | 核心功能 | 数据验证 | 问题 |
|
||||
|------|---------|--------|---------|---------|------|
|
||||
| 工作台 (仪表盘) | / | OK | 统计卡片/待办/动态/快捷入口 | 4/4 指标与API一致 | - |
|
||||
| 登录页 | /login | OK | 表单登录/JWT 认证 | 正确返回 token | - |
|
||||
| 用户管理 | /users | OK | 列表/新建/编辑/搜索/分页 | 创建用户成功 | BUG-01 (编辑失败) |
|
||||
| 权限管理 | /roles | OK | 角色列表/权限分配 | 权限树全部加载 | - |
|
||||
| 组织架构 | /organizations | OK | 组织/部门/岗位三栏 | 创建组织成功 | WARN-01 (树节点点击超时) |
|
||||
| 工作流 | /workflow | OK | 4 个 Tab 全部可用 | 3 个流程定义显示 | - |
|
||||
| 消息中心 | /messages | OK | 4 个 Tab 全部可用 | 10 条消息正确显示 | - |
|
||||
| 系统设置 | /settings | OK | 7 个 Tab 全部可用 | 字典/菜单/编号/审计 | BUG-04 (审计日志为空) |
|
||||
|
||||
### 3.2 前端功能交互测试
|
||||
|
||||
| 功能 | 操作 | 预期 | 实际 | 结果 |
|
||||
|------|------|------|------|------|
|
||||
| 创建用户 | 填写完整表单提交 | 成功创建 | 成功,列表更新 | PASS |
|
||||
| 编辑用户 | 修改显示名提交 | 更新成功 | 422 错误 | **FAIL** (BUG-01) |
|
||||
| 搜索用户 | 输入"admin"搜索 | 过滤结果 | 只显示1条 | PASS |
|
||||
| 创建组织 | 填写名称/编码 | 成功创建 | 树形结构更新 | PASS |
|
||||
| 权限分配 | 打开管理员权限弹窗 | 显示权限树 | 50+ 项全选 | PASS |
|
||||
| Tab 切换 | 工作流4个Tab | 切换正常 | 全部可切换 | PASS |
|
||||
| 消息列表 | 查看10条消息 | 数据正确 | 系统消息+用户消息 | PASS |
|
||||
| 主题切换 | 点击暗色模式 | 主题切换 | (未测试) | SKIP |
|
||||
| 通知面板 | 头部铃铛图标 | 弹出通知 | 显示未读消息 | PASS |
|
||||
|
||||
---
|
||||
|
||||
## 四、跨模块集成测试
|
||||
|
||||
### 4.1 Workflow -> Message 事件集成
|
||||
|
||||
| 测试步骤 | 验证内容 | 结果 |
|
||||
|---------|---------|------|
|
||||
| 发布流程定义 | 状态 draft -> published | PASS |
|
||||
| 启动流程实例 | `process_instance.started` 事件触发 | PASS |
|
||||
| 验证系统消息 | 自动生成 sender_type=system, business_type=workflow_instance | PASS |
|
||||
| 完成审批任务 | `task.completed` 事件触发 | PASS |
|
||||
| 验证任务通知 | 自动生成 business_type=workflow_task | PASS |
|
||||
| 验证实例推进 | 流程推进到 completed, active_tokens 清空 | PASS |
|
||||
|
||||
### 4.2 多租户数据隔离验证
|
||||
|
||||
| 测试项 | 结果 |
|
||||
|--------|------|
|
||||
| 所有查询自动带 tenant_id | PASS |
|
||||
| 无法跨租户访问数据 | PASS |
|
||||
| JWT 中 tenant_id 正确注入 | PASS |
|
||||
|
||||
### 4.3 数据一致性验证
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| 乐观锁 version 字段递增 | PASS |
|
||||
| 软删除后数据不可见 | PASS |
|
||||
| 分页参数正确性 | PASS |
|
||||
| 仪表盘统计与API数据一致 | PASS (用户数/角色数/消息数/流程数) |
|
||||
|
||||
---
|
||||
|
||||
## 五、缺陷清单
|
||||
|
||||
### CRITICAL (严重)
|
||||
|
||||
#### BUG-02: Settings 模块完全不可用
|
||||
- **模块**: erp-config
|
||||
- **现象**: 系统设置/主题/语言的读、写、删操作全部失败
|
||||
- **根因**: `setting_service.rs` 中 SeaORM 的 `.filter(Column::ScopeId.eq(None))` 在 `scope_id` 为 NULL 时无法匹配数据库记录
|
||||
- **影响范围**: 系统设置、主题配置、语言配置 3 个功能模块完全失效
|
||||
- **文件**: `crates/erp-config/src/service/setting_service.rs`
|
||||
- **建议修复**: 将 `eq(None)` 改为 `.filter(Column::ScopeId.is_null())` 或使用 raw condition
|
||||
|
||||
### HIGH (高)
|
||||
|
||||
#### BUG-01: 用户编辑功能 422 错误
|
||||
- **模块**: 前端 Users 页面
|
||||
- **现象**: 编辑用户时前端发送 PUT 请求返回 422 Unprocessable Entity
|
||||
- **根因**: 前端未在请求体中包含 `version` 字段,后端要求乐观锁校验
|
||||
- **错误响应**: `missing field 'version' at line 1 column 94`
|
||||
- **影响范围**: 所有实体的编辑功能可能存在同样问题
|
||||
- **建议修复**: 前端编辑表单提交时需携带实体的 `version` 字段
|
||||
|
||||
#### BUG-03: 语言更新返回 name 为空
|
||||
- **模块**: erp-config
|
||||
- **现象**: PUT /api/v1/config/languages/{code} 返回的 `name` 字段始终为空字符串
|
||||
- **根因**: `language_handler.rs` 中返回数据未从存储数据中读取实际名称
|
||||
- **文件**: `crates/erp-config/src/handler/language_handler.rs`
|
||||
|
||||
### MEDIUM (中)
|
||||
|
||||
#### BUG-04: 前端审计日志显示为空
|
||||
- **模块**: 前端 Settings > 审计日志 Tab
|
||||
- **现象**: API 实际有 75 条审计日志,但前端显示 0 条
|
||||
- **可能原因**: 前端请求 token 过期或请求参数格式不匹配
|
||||
- **需排查**: 前端审计日志组件的网络请求
|
||||
|
||||
#### BUG-05: Settings 唯一索引不保护 NULL scope_id
|
||||
- **模块**: 数据库迁移
|
||||
- **现象**: settings 表的唯一索引不保护 `scope_id = NULL` 的行,允许重复数据
|
||||
- **文件**: `crates/erp-server/migration/src/m20260412_000016_create_settings.rs`
|
||||
|
||||
### WARN (警告)
|
||||
|
||||
#### WARN-01: 组织树节点点击超时
|
||||
- **现象**: 创建组织后点击树节点,5 秒超时未响应
|
||||
- **可能原因**: 前端树组件渲染或事件绑定问题
|
||||
|
||||
#### WARN-02: API 响应延迟过高
|
||||
- **现象**: 所有 API 端点响应时间约 2.2 秒(包含 Health Check)
|
||||
- **影响**: 严重影响用户体验
|
||||
- **可能原因**: 数据库连接池获取延迟或 tokio runtime 问题
|
||||
- **建议**: 排查连接池配置,生产环境应预热连接
|
||||
|
||||
#### WARN-03: 未分配 assignee 的任务不可见
|
||||
- **现象**: 当 UserTask 节点未设置 assignee_id 时,创建的任务在待办列表中不可见
|
||||
- **原因**: list_pending 按 assignee_id 过滤,无 assignee 的任务被遗漏
|
||||
- **建议**: 增加按 candidate_groups 的查找逻辑
|
||||
|
||||
---
|
||||
|
||||
## 六、测试覆盖率
|
||||
|
||||
### 6.1 API 端点覆盖率
|
||||
|
||||
| 模块 | 总端点 | 已测试 | 通过 | 失败 | 覆盖率 | 通过率 |
|
||||
|------|--------|--------|------|------|--------|--------|
|
||||
| 基础设施 | 2 | 2 | 2 | 0 | 100% | 100% |
|
||||
| Auth | 27 | 27 | 26 | 1 | 100% | 96.3% |
|
||||
| Config | 25 | 25 | 21 | 4 | 100% | 84.0% |
|
||||
| Workflow | 15 | 15 | 15 | 0 | 100% | 100% |
|
||||
| Message | 9 | 9 | 9 | 0 | 100% | 100% |
|
||||
| 审计日志 | 1 | 1 | 1 | 0 | 100% | 100% |
|
||||
| **合计** | **81** | **81** | **74** | **5** | **100%** | **93.7%** |
|
||||
|
||||
### 6.2 前端页面覆盖率
|
||||
|
||||
| 类别 | 总数 | 已测试 | 通过 | 问题 |
|
||||
|------|------|--------|------|------|
|
||||
| 主页面 | 7 | 7 | 7 | 0 |
|
||||
| Tab 子页面 | 15 | 15 | 14 | 1 |
|
||||
| 功能交互 | 12 | 11 | 10 | 1 |
|
||||
| **合计** | **34** | **33** | **31** | **2** |
|
||||
|
||||
### 6.3 安全测试覆盖率
|
||||
|
||||
| 类别 | 测试数 | 通过率 |
|
||||
|------|--------|--------|
|
||||
| 认证验证 | 2 | 100% |
|
||||
| 输入验证 | 4 | 100% |
|
||||
| 资源不存在 | 2 | 100% |
|
||||
| 业务规则 | 2 | 100% |
|
||||
| **合计** | **10** | **100%** |
|
||||
|
||||
---
|
||||
|
||||
## 七、风险评估
|
||||
|
||||
| 风险 | 严重程度 | 影响 | 建议 |
|
||||
|------|---------|------|------|
|
||||
| Settings 模块完全不可用 | CRITICAL | 系统配置/主题/语言无法使用 | 立即修复 is_null() 查询 |
|
||||
| 实体编辑缺少 version | HIGH | 所有编辑操作无法完成 | 前端统一处理 version |
|
||||
| API 响应 2.2s 延迟 | HIGH | 用户体验极差 | 排查连接池和网络配置 |
|
||||
| 审计日志前端为空 | MEDIUM | 无法查看操作记录 | 修复前端请求 |
|
||||
| 重复 settings 数据 | MEDIUM | 数据一致性风险 | 修改迁移添加 COALESCE 索引 |
|
||||
|
||||
---
|
||||
|
||||
## 八、测试截图索引
|
||||
|
||||
| 截图 | 文件 |
|
||||
|------|------|
|
||||
| 登录页面 | `docs/test-screenshots/erp-login-page.png` |
|
||||
| 仪表盘 | `docs/test-screenshots/erp-dashboard.png` |
|
||||
| 用户管理-列表 | `docs/test-screenshots/erp-users-page.png` |
|
||||
| 用户管理-创建成功 | `docs/test-screenshots/erp-users-created.png` |
|
||||
| 用户管理-编辑BUG | `docs/test-screenshots/erp-users-edit-bug.png` |
|
||||
| 角色管理 | `docs/test-screenshots/erp-roles-page.png` |
|
||||
| 权限分配 | `docs/test-screenshots/erp-roles-permissions.png` |
|
||||
| 组织架构 | `docs/test-screenshots/erp-org-page.png` |
|
||||
| 组织-创建成功 | `docs/test-screenshots/erp-org-created.png` |
|
||||
| 工作流-流程定义 | `docs/test-screenshots/erp-workflow-definitions.png` |
|
||||
| 工作流-流程监控 | `docs/test-screenshots/erp-workflow-monitor.png` |
|
||||
|
||||
---
|
||||
|
||||
## 九、改进建议
|
||||
|
||||
### 优先级 P0 (立即修复)
|
||||
|
||||
1. **修复 Settings 查询**: 将 `eq(None)` 改为 `is_null()` — 影响 3 个模块
|
||||
2. **修复前端编辑**: 所有编辑表单统一携带 `version` 字段 — 影响所有 CRUD 页面
|
||||
|
||||
### 优先级 P1 (本周修复)
|
||||
|
||||
3. **排查 API 延迟**: 分析 2.2s 响应的根因,优化连接池配置
|
||||
4. **修复审计日志前端**: 排查前端请求为什么返回空数据
|
||||
5. **修复语言 name 返回空**: 从存储数据读取实际名称
|
||||
|
||||
### 优先级 P2 (后续优化)
|
||||
|
||||
6. **增加未分配 assignee 的任务可见性**
|
||||
7. **组织树节点交互优化** (解决点击超时)
|
||||
8. **消息模板名称字段冗余查询优化**
|
||||
9. **Settings 表唯一索引补全**
|
||||
|
||||
---
|
||||
|
||||
## 十、测试结论
|
||||
|
||||
### 总体评估: **有条件通过**
|
||||
|
||||
ERP Platform v0.1.0 的核心业务功能基本完整,跨模块事件集成(Workflow -> Message)工作正常,多租户数据隔离和安全认证机制验证通过。
|
||||
|
||||
**主要成就:**
|
||||
- 81 个 API 端点 100% 覆盖测试
|
||||
- Workflow/Message 模块 24/24 端点全部通过
|
||||
- 跨模块事件通知 100% 触发成功
|
||||
- 安全认证 100% 通过
|
||||
- 前端 22 个页面全部可访问
|
||||
|
||||
**阻塞问题:**
|
||||
- Settings 模块完全不可用 (CRITICAL)
|
||||
- 所有实体编辑功能因缺少 version 字段而失败 (HIGH)
|
||||
- API 响应延迟 2.2s 严重影响用户体验 (HIGH)
|
||||
|
||||
**建议**: 修复 P0 和 P1 级别问题后进行回归测试,通过后方可进入下一阶段。
|
||||
Reference in New Issue
Block a user