docs: 全局文档梳理归档 — 删除过期文件 + 归档 V1/早期设计 + wiki 数据校正 + CLAUDE.md 规则优化

**根目录清理:**
- 删除 CLAUDE-1.md(ZCLAW 旧项目配置,HMS 已完全脱离)
- 移动 DESIGN.md → docs/archive/(ERP 旧设计系统)
- 删除 plans/ 98 个临时会话计划文件

**归档重组:**
- V1 审计(12 文件)→ docs/archive/audits-v1/
- 早期 CRM/插件迭代设计(13 文件)→ docs/archive/superpowers-early/
- 已完成/已取代设计(28 文件)→ docs/archive/superpowers-completed/
- 早期讨论/测试报告 → docs/archive/discussions-early/ + test-reports-early/
- QA 重复文件清理(3 个旧版 result 文件)

**wiki 数据校正:**
- 迁移数 137→145,源文件 599→649,提交数 720→800+
- 小程序文件 124→163,Web 前端 297→332
- 后端测试 999→943(实际统计),权限码 75+→128
- 文档索引新增归档目录说明

**CLAUDE.md 规则优化:**
- §2.5 闭环工作法:提交+文档+推送三合一 + wiki 更新触发条件
- §2.6 Feature DoD:新增文档一致性检查项
- §6 反模式:新增 wiki 更新滞后/推送不及时警告
This commit is contained in:
iven
2026-05-15 09:29:04 +08:00
parent dc983945ff
commit 18fa6ce6d4
92 changed files with 53 additions and 10253 deletions

View File

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

View File

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

View File

@@ -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.rsGuest trait 实现)**
```rust
// crates/erp-plugin-inventory/src/lib.rs
wit_bindgen::generate!({
path: "../erp-plugin-prototype/wit",
world: "plugin-world",
});
struct InventoryPlugin;
impl Guest for InventoryPlugin {
fn init() -> Result<(), String> { Ok(()) }
fn on_tenant_created(_tenant_id: String) -> Result<(), String> { Ok(()) }
fn handle_event(_event_type: String, _event_data: String) -> Result<(), String> { Ok(()) }
}
export_plugin!(InventoryPlugin);
```
- [ ] **Step 4: 添加到 workspace**
在根 `Cargo.toml``members` 中添加 `"crates/erp-plugin-inventory"`
- [ ] **Step 5: 验证编译**
```bash
cargo check -p erp-plugin-inventory
```
- [ ] **Step 6: Commit**
```bash
git add crates/erp-plugin-inventory/ Cargo.toml
git commit -m "feat(inventory): 创建进销存插件 crate 骨架"
```
---
### Task 11: 定义实体 Schema
**Files:**
- Modify: `crates/erp-plugin-inventory/manifest.toml`
- [ ] **Step 1: 定义 6 个实体**
参考 CRM 插件 manifest 格式,定义:
| 实体 | 关键字段 | 关联 | 页面类型 |
|------|---------|------|---------|
| product | code, name, spec, unit, category, price, cost | — | CRUD |
| warehouse | code, name, address, manager, status | — | CRUD |
| stock | product_id, warehouse_id, qty, cost, alert_line | → product, warehouse | CRUD |
| supplier | code, name, contact, phone, address | — | CRUD |
| purchase_order | supplier_id, total_amount, status, date | → supplier, stock | CRUD + Dashboard |
| sales_order | customer_id, total_amount, status, date | → customer(CRM), stock | CRUD + Kanban |
- [ ] **Step 2: 定义 6 个页面**4 CRUD + 1 Dashboard 库存汇总 + 1 Kanban 销售看板)
- [ ] **Step 3: 定义 9 个权限**(每个实体 list/create/update/delete + 全局 manage
- [ ] **Step 4: Commit**
```bash
git add crates/erp-plugin-inventory/manifest.toml
git commit -m "feat(inventory): 定义 6 实体/6 页面/9 权限 manifest"
```
---
### Task 12: 编译 WASM 并测试安装
**Files:**
- Build output: `apps/web/public/inventory.wasm`
- [ ] **Step 1: 编译为 WASM Component**
```bash
cargo build -p erp-plugin-inventory --target wasm32-unknown-unknown --release
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm -o target/erp_plugin_inventory.component.wasm
```
- [ ] **Step 2: 复制到前端 public 目录**
```bash
cp target/erp_plugin_inventory.component.wasm apps/web/public/inventory.wasm
```
- [ ] **Step 3: 通过 API 安装插件并验证**
使用 curl 或前端插件管理页面上传 `inventory.wasm`验证动态表创建成功CRUD 页面正常工作。
- [ ] **Step 4: Commit**
```bash
git add apps/web/public/inventory.wasm
git commit -m "feat(inventory): 编译并部署进销存插件 WASM"
```
---
## Chunk 4: 插件热更新
### Task 13: 添加 upgrade 端点
**Files:**
- Modify: `crates/erp-plugin/src/handler/plugin_handler.rs`
- Modify: `crates/erp-plugin/src/service.rs`
- [ ] **Step 1: 在 plugin_handler 中添加 upgrade 路由**
```rust
pub fn protected_routes() -> Router<AppState> {
Router::new()
// ... 现有路由 ...
.route("/admin/plugins/:plugin_id/upgrade", post(upgrade_plugin))
}
```
- [ ] **Step 2: 实现 upgrade handler**
接收新 WASM 文件,调用 service 层执行升级。
- [ ] **Step 3: 在 service 中实现升级逻辑**
```rust
pub async fn upgrade_plugin(
plugin_id: Uuid,
tenant_id: Uuid,
new_wasm_bytes: Vec<u8>,
db: &DatabaseConnection,
) -> PluginResult<()> {
// 1. 解析新 manifest
let new_manifest = parse_manifest_from_wasm(&new_wasm_bytes)?;
// 2. 获取当前插件信息
let current = find_by_id(plugin_id, tenant_id, db).await?;
// 3. 对比 schema 变更,生成增量 DDL
let schema_diff = compare_schemas(&current.manifest, &new_manifest)?;
// 4. 暂存新 WASM尝试验证初始化
// 5. 初始化成功后,在事务中执行 DDL + 状态更新
// 6. 失败时保持旧 WASM 继续运行
// 详见 spec §4.4 回滚策略
todo!()
}
```
- [ ] **Step 4: 验证编译**
```bash
cargo check -p erp-plugin
```
- [ ] **Step 5: Commit**
```bash
git add crates/erp-plugin/src/
git commit -m "feat(plugin): 添加插件热更新 upgrade 端点"
```
---
## Chunk 5: 文档更新与清理
### Task 14: 更新 Wiki 文档
**Files:**
- Modify: `wiki/frontend.md`
- Modify: `wiki/database.md`
- Modify: `wiki/testing.md`
- Modify: `wiki/index.md`
- [ ] **Step 1: 更新 `wiki/frontend.md`**
更新为反映当前 16 条路由、6 种插件页面类型、Zustand stores 等实际状态。
- [ ] **Step 2: 更新 `wiki/testing.md`**
更新测试数量、添加 Testcontainers 集成测试和 Playwright E2E 描述。
- [ ] **Step 3: 更新 `wiki/index.md`**
添加进销存插件到模块导航树,更新开发进度表。
- [ ] **Step 4: Commit**
```bash
git add wiki/
git commit -m "docs: 更新 Wiki 文档到当前状态"
```
---
### Task 15: CLAUDE.md 版本号修正 + 根目录清理
**Files:**
- Modify: `CLAUDE.md`
- Cleanup: 根目录未跟踪文件
- [ ] **Step 1: 修正 CLAUDE.md 版本号**
`React 18 + Ant Design 5` 改为 `React 19 + Ant Design 6`
- [ ] **Step 2: 清理根目录未跟踪文件**
删除开发临时文件截图、heap dump、perf trace、agent plan 文件。
```bash
rm -f current-page.png home-full.png home-improved.png docs/debug-*.png
rm -f docs/memory-snapshot-*.heapsnapshot docs/perf-trace-*.json
rm -f test_api_auth.py test_users.py
```
- [ ] **Step 3: 处理 integration-tests/ 目录**
验证 `integration-tests/` 中的测试是否能编译。若已失效则删除(新的集成测试在 `crates/erp-server/tests/integration/`)。若仍有效则添加到 workspace。
- [ ] **Step 4: Commit**
```bash
git add CLAUDE.md
git commit -m "docs: 修正 CLAUDE.md 版本号 (React 19 / AD 6) 并清理临时文件"
```
---
## 验证清单
- [ ] **V1: 全 workspace 编译和测试**
```bash
cargo check && cargo test --workspace
```
- [ ] **V2: 集成测试通过**
```bash
cargo test -p erp-server --test integration
```
注意:需要 Docker 运行
- [ ] **V3: 前端构建**
```bash
cd apps/web && pnpm build
```
- [ ] **V4: E2E 测试**
```bash
cd apps/web && pnpm exec playwright test
```
- [ ] **V5: 进销存插件安装验证**
通过 API 安装 inventory.wasm验证动态表和 CRUD 页面正常。
- [ ] **V6: Wiki 文档同步**
确认 Wiki 描述与代码实际状态一致。

View File

@@ -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 个季度的分层改进,将平台从"功能完整"推进到"生产就绪"
- **Q24-5月**:消除安全风险,建立自动化质量门
- **Q36-8月**:强化架构,提升前端工程化水平
- **Q49-11月**:补齐测试覆盖,扩展插件生态
### 1.4 约束
- **独立开发者** + Claude 辅助 — 每季度聚焦单一维度
- **SaaS 优先**部署 — 多租户安全是硬性要求
- **不破坏现有功能** — 所有改进必须向后兼容
---
## 2. Q2安全地基 + CI/CD4-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 + 进销存插件 + 热更新 + 文档清理)。调整为两个子阶段:
- **Q4a9-10月**:测试基础设施 — Testcontainers 集成测试框架 + Playwright E2E + 文档清理
- **Q4b11月+**:插件生态 — 进销存插件 + 热更新
热更新功能可视 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 → 增量 DDLADD 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 11Testcontainers 对 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 文档与代码同步

View File

@@ -0,0 +1,710 @@
# 健康管理系统 — erp-health 模块设计规格
> **文档版本**: 1.0
> **日期**: 2026-04-23
> **状态**: 已确认
> **范围**: V1 — 患者管理 + 健康数据 + 预约排班 + 随访管理 + 咨询管理
---
## 1. 项目背景
### 1.1 产品定位
构建一个面向体检中心/医疗机构的**综合型健康管理平台**,以体检中心为数据源,汇集不同情况的患者,提供全生命周期的健康管理服务。
本系统从 ERP 平台底座分叉独立,作为 **Health Management System (HMS)** 产品演进。ERP 底座提供身份权限、工作流、消息通知、系统配置等基础能力,`erp-health` 作为原生 Rust 模块承载所有医疗业务逻辑。
### 1.2 系统架构
```
📱 患者端(微信小程序) ──┐
├──→ 🔀 API 网关 ──→ 🖥️ ERP 后端HMS
👨‍⚕️ 医护端(小程序/H5 ──┘ │ │
│ ├── erp-auth用户/角色/权限)
│ ├── erp-workflow工作流引擎
│ ├── erp-message消息通知
│ ├── erp-config字典/配置)
│ └── erp-health健康管理★ 新增
└──→ 💾 PostgreSQL + Redis
```
**关键决策:**
- ERP 只负责 **PC 管理后台**功能
- 小程序(患者端/医护端)作为**独立系统**开发
- 数据共享通过 **API 网关**实现
- 健康管理使用**原生 Rust 模块**(非 WASM 插件),获得完整的数据库访问和自定义 API 能力
### 1.3 为什么不用 WASM 插件
| 限制 | 影响 |
|------|------|
| 实体上限 20 个 | 综合健康平台轻松超过 |
| JSONB 存储 | 医疗数据需要强类型、索引、关联 |
| 无自定义 API | 趋势分析、统计报表需要专用端点 |
| 无文件上传 | 化验单、体检报告无法存储 |
| WASM 沙箱限制 | 无法引入加密、AI、外部 API |
原生模块遵循现有模式(如 erp-auth、erp-workflow。**注意:**`ErpModule` trait 没有 `register_routes` 方法。模块通过固有方法 `public_routes()``protected_routes()` 暴露路由,在 `erp-server``main.rs` 中通过 `.nest("/api/v1/health", HealthModule::protected_routes())` 集成。通过 EventBus 通信,未来可平滑拆分为独立微服务。
---
## 2. V1 功能范围
| 模块 | 功能 | 页面数 |
|------|------|--------|
| ① 患者与医护管理 | 患者档案、家庭成员、医护档案、患者标签 | 3 |
| ② 健康数据管理 | 体检记录、日常监测、化验报告、趋势分析 | 3 |
| ③ 预约与排班 | 预约管理、医生排班、日历视图 | 2 |
| ④ 随访管理 | 随访任务、随访记录台账 | 2 |
| ⑤ 咨询管理 | 会话管理、对话记录查看/导出 | 2 |
| ⑥ 医护管理 | 医护人员列表 | 1 |
| **合计** | | **13** |
**V2 预留:** 积分商城、数据统计中心、内容管理增强。
---
## 3. 实体模型
### 3.1 设计原则
- 患者和医护的**账号**走 `erp-auth``users` 表,`erp-health` 只存医疗业务扩展字段
- 通过 `user_id` 外键关联 `users`
- 所有表含 `tenant_id`(多租户隔离)、`id`UUIDv7`created_at``updated_at``created_by``updated_by``deleted_at``version`
- 多对多关系使用中间表
### 3.2 实体定义
#### ① 患者与医护管理
**patient — 患者档案**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | UUIDv7 |
| tenant_id | UUID NOT NULL | 租户 ID |
| user_id | UUID FK → users | 关联 erp-auth 账号 |
| name | VARCHAR(100) | 姓名 |
| gender | VARCHAR(10) | 性别 (male/female/other) |
| birth_date | DATE | 出生日期 |
| blood_type | VARCHAR(10) | 血型 (A/B/AB/O/RH-/RH+) |
| id_number | VARCHAR(20) | 身份证号 |
| allergy_history | TEXT | 过敏史 |
| medical_history_summary | TEXT | 病史摘要 |
| emergency_contact_name | VARCHAR(100) | 紧急联系人姓名 |
| emergency_contact_phone | VARCHAR(20) | 紧急联系人电话 |
| status | VARCHAR(20) | 状态 (active/inactive/deceased) |
| verification_status | VARCHAR(20) | 实名认证 (pending/verified/rejected) |
| source | VARCHAR(100) | 来源(体检中心名称) |
| notes | TEXT | 备注 |
| created_at, updated_at, created_by, updated_by, deleted_at, version | — | 标准字段 |
索引:`(tenant_id, name)`, `(tenant_id, status)`, `(tenant_id, id_number) UNIQUE WHERE deleted_at IS NULL`
**patient_family_member — 家庭成员**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | 患者关联 |
| name | VARCHAR(100) | 姓名 |
| relationship | VARCHAR(50) | 关系(父亲/母亲/配偶/子女等) |
| phone | VARCHAR(20) | 电话 |
| birth_date | DATE | 出生日期 |
| notes | TEXT | 备注 |
| 标准 ERP 字段 | — | |
**doctor_profile — 医护档案**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| user_id | UUID FK → users | 关联 erp-auth 账号 |
| department | VARCHAR(100) | 科室 |
| title | VARCHAR(50) | 职称(主任医师/副主任医师/主治医师等) |
| specialty | VARCHAR(200) | 专长 |
| license_number | VARCHAR(50) | 执业证号 |
| bio | TEXT | 简介 |
| online_status | VARCHAR(20) | 在线状态 (online/offline/busy) |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, patient_id)`
**patient_tag — 患者标签**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| name | VARCHAR(50) | 标签名 |
| color | VARCHAR(20) | 颜色值 |
| description | TEXT | 描述 |
| is_system | BOOLEAN | 系统标签(不可删除) |
| 标准 ERP 字段 | — | |
索引:`UNIQUE (tenant_id, name) WHERE deleted_at IS NULL`
**patient_tag_relation — 患者-标签关联**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| tag_id | UUID FK → patient_tag | |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ | |
| created_by | UUID | |
| updated_by | UUID | |
| deleted_at | TIMESTAMPTZ | 软删除 |
索引:`(tenant_id, patient_id)`, `(tenant_id, tag_id)`
**patient_doctor_relation — 医患关系**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| doctor_id | UUID FK → doctor_profile | |
| relationship_type | VARCHAR(20) | 类型 (primary/consulting) |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ | |
| created_by | UUID | |
| updated_by | UUID | |
| deleted_at | TIMESTAMPTZ | 软删除 |
索引:`(tenant_id, patient_id)`, `(tenant_id, doctor_id)`
#### ② 健康数据管理
**health_record — 体检/就诊记录**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| record_type | VARCHAR(20) | 类型 (checkup/outpatient/inpatient) |
| record_date | DATE | 记录日期 |
| source | VARCHAR(200) | 来源(体检中心/医院名称) |
| overall_assessment | TEXT | 总体评估 |
| report_file_url | VARCHAR(500) | 报告文件 URL |
| notes | TEXT | 备注 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, patient_id, record_date DESC)`
**vital_signs — 日常监测数据**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| record_date | DATE | 记录日期 |
| systolic_bp_morning | INTEGER | 晨起收缩压 |
| diastolic_bp_morning | INTEGER | 晨起舒张压 |
| systolic_bp_evening | INTEGER | 晚间收缩压 |
| diastolic_bp_evening | INTEGER | 晚间舒张压 |
| heart_rate | INTEGER | 心率 |
| weight | DECIMAL(5,1) | 体重 (kg) |
| blood_sugar | DECIMAL(5,1) | 血糖 (mmol/L) |
| water_intake_ml | INTEGER | 饮水量 (ml) |
| urine_output_ml | INTEGER | 尿量 (ml) |
| notes | TEXT | 备注 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, patient_id, record_date DESC)`
**lab_report — 化验报告**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| report_date | DATE | 报告日期 |
| report_type | VARCHAR(50) | 报告类型(肾功能/血常规/尿常规等) |
| indicators | JSONB | 指标数据 [{name, value, unit, ref_range, is_abnormal}] |
| image_urls | JSONB | 图片 URLs [url1, url2, ...] |
| doctor_interpretation | TEXT | 医生解读 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, patient_id, report_date DESC)`, GIN on `indicators`, `(tenant_id, report_type)`
**health_trend — 健康趋势报告**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| period_start | DATE | 周期开始 |
| period_end | DATE | 周期结束 |
| indicator_summary | JSONB | 指标摘要 |
| abnormal_items | JSONB | 异常项 |
| generation_type | VARCHAR(20) | 生成方式 (auto/manual) |
| report_file_url | VARCHAR(500) | 报告文件 URL |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, patient_id, period_start DESC)`
#### ③ 预约排班
**appointment — 预约记录**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| doctor_id | UUID FK → doctor_profile | |
| appointment_type | VARCHAR(20) | 类型 (dialysis/recheck/outpatient) |
| appointment_date | DATE | 预约日期 |
| start_time | TIME | 开始时间 |
| end_time | TIME | 结束时间 |
| status | VARCHAR(20) | 状态 (pending/confirmed/cancelled/completed/no_show) |
| cancel_reason | TEXT | 取消原因 |
| notes | TEXT | 备注 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, appointment_date, status)`, `(tenant_id, doctor_id, appointment_date)`
**doctor_schedule — 医生排班**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| doctor_id | UUID FK → doctor_profile | |
| schedule_date | DATE | 排班日期 |
| period_type | VARCHAR(20) | 时段 (am/pm/night/full_day) |
| start_time | TIME | 开始时间 |
| end_time | TIME | 结束时间 |
| max_appointments | INTEGER | 最大预约数 |
| current_appointments | INTEGER | 已预约数(默认 0 |
| status | VARCHAR(20) | 状态 (enabled/disabled) |
| 标准 ERP 字段 | — |
索引:`(tenant_id, doctor_id, schedule_date)`, `UNIQUE (tenant_id, doctor_id, schedule_date, period_type) WHERE deleted_at IS NULL`
**预约并发控制:** 创建预约时使用原子 CAS 操作 `UPDATE doctor_schedule SET current_appointments = current_appointments + 1 WHERE id = $1 AND current_appointments < max_appointments RETURNING *`,防止超额预约。
#### ④ 随访管理
**follow_up_task — 随访任务**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| assigned_to | UUID FK → users | 负责医护 |
| follow_up_type | VARCHAR(20) | 类型 (phone/face_to_face/online) |
| planned_date | DATE | 计划日期 |
| status | VARCHAR(20) | 状态 (pending/in_progress/completed/overdue/cancelled) |
| content_template | TEXT | 随访内容模板 |
| related_appointment_id | UUID FK → appointment | 关联预约 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, assigned_to, status)`, `(tenant_id, planned_date, status)`
**follow_up_record — 随访记录**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| task_id | UUID FK → follow_up_task | |
| executed_by | UUID FK → users | 执行医护 |
| executed_date | DATE | 执行日期 |
| result | VARCHAR(20) | 结果 (followed_up/unreachable/refused/other) |
| patient_condition | TEXT | 患者状况 |
| medical_advice | TEXT | 医嘱建议 |
| next_follow_up_date | DATE | 下次随访日期 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, task_id)`, `(tenant_id, executed_date)`
#### ⑤ 咨询管理
**consultation_session — 咨询会话**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| patient_id | UUID FK → patient | |
| doctor_id | UUID FK → doctor_profile | |
| type | VARCHAR(20) | 类型 (customer_service/doctor) |
| status | VARCHAR(20) | 状态 (waiting/active/closed) |
| last_message_at | TIMESTAMPTZ | 最后消息时间 |
| unread_count_patient | INTEGER | 患者未读数 |
| unread_count_doctor | INTEGER | 医生未读数 |
| 标准 ERP 字段 | — | |
索引:`(tenant_id, doctor_id, status)`, `(tenant_id, patient_id, status)`
**consultation_message — 咨询消息**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID PK | |
| tenant_id | UUID NOT NULL | |
| session_id | UUID FK → consultation_session | |
| sender_id | UUID | 发送者 ID |
| sender_role | VARCHAR(20) | 角色 (patient/doctor/system) |
| content_type | VARCHAR(20) | 类型 (text/image/voice/file) |
| content | TEXT | 内容 |
| is_read | BOOLEAN | 已读状态(默认 false |
| created_at | TIMESTAMPTZ | 发送时间 |
| updated_at | TIMESTAMPTZ | |
| created_by | UUID | |
| updated_by | UUID | |
| deleted_at | TIMESTAMPTZ | 软删除(内容审核用) |
| version | INT NOT NULL DEFAULT 1 | 乐观锁 |
索引:`(tenant_id, session_id, created_at)`
**数据增长策略:**`created_at` 按月分区PostgreSQL table partitioning超过 1 年的已关闭会话消息归档到冷存储。
**说明:**
- `patient.user_id` 允许 NULL — 患者可先创建档案(如体检中心导入),后续再绑定 erp-auth 账号
- `consultation_message.sender_id` 引用 `users.id` — 统一使用 erp-auth 用户体系标识发送者
---
## 3.3 状态机定义
### appointment.status 转换
```
pending ──→ confirmed ──→ completed
│ │
│ └──→ no_show预约时间过后系统自动或前台手动触发
└──→ cancelled任意时刻可取消需填 cancel_reason
```
### follow_up_task.status 转换
```
pending ──→ in_progress ──→ completed
│ │
└──→ cancelled └──→ overdue系统定时任务planned_date 已过且仍 pending 自动标记)
```
### consultation_session.status 转换
```
waiting ──→ active第一条消息发送时自动触发──→ closed手动关闭或超时自动关闭
```
### patient.status 转换
```
active ──→ inactive手动停用
active ──→ deceased标记死亡不可逆
inactive ──→ active重新激活
```
### patient.verification_status 转换
```
pending ──→ verified实名认证通过
pending ──→ rejected认证被拒
rejected ──→ pending重新提交认证
```
---
## 4. API 设计
所有端点前缀: `/api/v1/health/`
### 4.1 患者管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/patients` | 患者列表(分页、搜索、标签筛选) |
| POST | `/patients` | 创建患者 |
| GET | `/patients/:id` | 患者详情 |
| PUT | `/patients/:id` | 更新患者 |
| DELETE | `/patients/:id` | 软删除 |
| POST | `/patients/:id/tags` | 管理标签(批量设置) |
| GET | `/patients/:id/health-summary` | 健康摘要 |
| GET | `/patients/:id/family-members` | 家庭成员列表 |
| POST | `/patients/:id/family-members` | 新增家庭成员 |
| PUT | `/patients/:id/family-members/:fid` | 更新家庭成员 |
| DELETE | `/patients/:id/family-members/:fid` | 删除家庭成员 |
| POST | `/patients/:id/doctors` | 分配主治医生 |
| DELETE | `/patients/:id/doctors/:did` | 移除医患关系 |
### 4.2 健康数据
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/patients/:id/vital-signs` | 日常监测列表 |
| POST | `/patients/:id/vital-signs` | 新增监测数据 |
| GET | `/patients/:id/lab-reports` | 化验报告列表 |
| POST | `/patients/:id/lab-reports` | 新增化验报告 |
| GET | `/patients/:id/health-records` | 体检/就诊记录 |
| POST | `/patients/:id/health-records` | 新增记录 |
| GET | `/patients/:id/trends` | 趋势报告 |
| POST | `/patients/:id/trends/generate` | 生成趋势报告 |
| GET | `/patients/:id/trends/:indicator` | 单指标时序数据 |
| PUT | `/patients/:id/vital-signs/:vid` | 更新监测数据 |
| DELETE | `/patients/:id/vital-signs/:vid` | 删除监测数据 |
| PUT | `/patients/:id/lab-reports/:rid` | 更新化验报告 |
| DELETE | `/patients/:id/lab-reports/:rid` | 删除化验报告 |
| PUT | `/patients/:id/health-records/:rid` | 更新体检记录 |
| DELETE | `/patients/:id/health-records/:rid` | 删除体检记录 |
### 4.3 预约排班
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/appointments` | 预约列表 |
| POST | `/appointments` | 创建预约 |
| PUT | `/appointments/:id/status` | 更新状态 |
| GET | `/doctor-schedules` | 排班列表 |
| POST | `/doctor-schedules` | 创建排班 |
| PUT | `/doctor-schedules/:id` | 更新排班 |
| GET | `/doctor-schedules/calendar` | 日历视图 |
### 4.4 随访管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/follow-up-tasks` | 任务列表 |
| POST | `/follow-up-tasks` | 创建任务 |
| PUT | `/follow-up-tasks/:id` | 更新任务 |
| DELETE | `/follow-up-tasks/:id` | 删除任务 |
| POST | `/follow-up-tasks/:id/records` | 填写随访记录 |
| GET | `/follow-up-records` | 随访台账 |
### 4.5 咨询管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/consultation-sessions` | 会话列表 |
| GET | `/consultation-sessions/:id/messages` | 消息记录 |
| PUT | `/consultation-sessions/:id/close` | 关闭会话 |
| POST | `/consultation-messages` | 写入消息API 网关用) |
| GET | `/consultation-sessions/export` | 导出 |
### 4.6 医护管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/doctors` | 医护列表 |
| POST | `/doctors` | 创建医护档案 |
| GET | `/doctors/:id` | 医护详情 |
| PUT | `/doctors/:id` | 更新医护档案 |
| DELETE | `/doctors/:id` | 软删除医护档案 |
---
## 5. 前端页面设计
文件位置: `apps/web/src/pages/health/`
### 5.1 页面清单
| # | 页面 | 文件名 | 类型 |
|---|------|--------|------|
| 1 | 患者列表 | PatientList.tsx | 表格+搜索+标签筛选+导出 |
| 2 | 患者详情 | PatientDetail.tsx | Tab布局基本信息/健康趋势/化验报告/就诊记录/随访记录 |
| 3 | 标签管理 | PatientTagManage.tsx | CRUD+颜色+批量打标 |
| 4 | 日常监测 | VitalSignsList.tsx | 按患者+日期+ECharts趋势折线图 |
| 5 | 化验报告 | LabReportList.tsx | 列表+图片预览+指标详情+解读 |
| 6 | 体检记录 | HealthRecordList.tsx | 类型筛选+报告文件查看/上传 |
| 7 | 预约管理 | AppointmentList.tsx | 列表/日历切换+状态流转 |
| 8 | 排班管理 | DoctorSchedule.tsx | 周/月日历+排班模板 |
| 9 | 随访任务 | FollowUpTaskList.tsx | 任务CRUD+分配+关联工作流 |
| 10 | 随访台账 | FollowUpRecordList.tsx | 按患者/医护/日期筛选+导出 |
| 11 | 会话管理 | ConsultationList.tsx | 列表+未回复统计 |
| 12 | 对话记录 | ConsultationDetail.tsx | 聊天气泡+图片/语音查看+导出 |
| 13 | 医护列表 | DoctorList.tsx | 列表+科室筛选+在线状态 |
### 5.2 技术要点
- **ECharts 趋势图** — 血压/体重/血糖曲线图,按日期范围展示
- **文件上传/预览** — 化验单图片、体检报告 PDF需新增基础能力
- **日历组件** — Ant Design Calendar 用于排班和预约视图
- **聊天 UI** — 消息气泡展示(只读,非实时聊天)
- **导出** — 随访台账、咨询记录导出为 Excel
---
## 6. 事件集成
### 6.1 发布事件
| 事件类型 | 触发时机 | 载荷 |
|----------|----------|------|
| `patient.created` | 创建患者 | `{patient_id, name, tenant_id}` |
| `patient.updated` | 更新患者信息 | `{patient_id, changed_fields}` |
| `appointment.created` | 创建预约 | `{appointment_id, patient_id, doctor_id, date}` |
| `appointment.confirmed` | 确认预约 | `{appointment_id}` |
| `appointment.cancelled` | 取消预约 | `{appointment_id, cancel_reason}` |
| `appointment.completed` | 完成就诊 | `{appointment_id}` |
| `follow_up.created` | 创建随访任务 | `{task_id, patient_id, assigned_to, planned_date}` |
| `follow_up.completed` | 完成随访 | `{task_id, record_id, result}` |
| `lab_report.uploaded` | 上传化验报告 | `{report_id, patient_id, report_type, abnormal_count}` |
| `consultation.opened` | 开启咨询 | `{session_id, patient_id, doctor_id}` |
| `consultation.closed` | 关闭咨询 | `{session_id}` |
| `patient.deceased` | 患者死亡标记 | `{patient_id}` |
| `patient.verified` | 实名认证通过 | `{patient_id, id_number}` |
| `follow_up.overdue` | 随访任务逾期 | `{task_id, patient_id, planned_date}` |
| `doctor.online_status_changed` | 医护在线状态变更 | `{doctor_id, old_status, new_status}` |
**随访记录自动创建后续任务:**`follow_up_record.next_follow_up_date` 不为空时,服务层自动创建新的 `follow_up_task`planned_date = next_follow_up_dateassigned_to 沿用当前医护)。
### 6.2 订阅事件
| 事件类型 | 处理逻辑 |
|----------|----------|
| `workflow.task.completed` | 工作流任务完成时更新随访任务状态 |
| `message.sent` | 消息发送时联动咨询会话的 last_message_at |
---
## 7. 模块结构
```
crates/erp-health/
├── Cargo.toml
├── src/
│ ├── lib.rs ← ErpModule trait + public_routes() / protected_routes()
│ ├── error.rs ← HealthError → AppError
│ ├── state.rs ← HealthState (共享状态)
│ ├── entity/ ← SeaORM Entity
│ │ ├── mod.rs
│ │ ├── patient.rs
│ │ ├── patient_family_member.rs
│ │ ├── patient_tag.rs
│ │ ├── patient_tag_relation.rs
│ │ ├── patient_doctor_relation.rs
│ │ ├── doctor_profile.rs
│ │ ├── health_record.rs
│ │ ├── vital_signs.rs
│ │ ├── lab_report.rs
│ │ ├── health_trend.rs
│ │ ├── appointment.rs
│ │ ├── doctor_schedule.rs
│ │ ├── follow_up_task.rs
│ │ ├── follow_up_record.rs
│ │ ├── consultation_session.rs
│ │ └── consultation_message.rs
│ ├── service/ ← 业务逻辑
│ │ ├── mod.rs
│ │ ├── patient_service.rs
│ │ ├── health_data_service.rs
│ │ ├── appointment_service.rs
│ │ ├── follow_up_service.rs
│ │ └── consultation_service.rs
│ ├── handler/ ← Axum 路由
│ │ ├── mod.rs
│ │ ├── patient_handler.rs
│ │ ├── health_data_handler.rs
│ │ ├── appointment_handler.rs
│ │ ├── follow_up_handler.rs
│ │ └── consultation_handler.rs
│ ├── dto/ ← 请求/响应结构体
│ │ ├── mod.rs
│ │ ├── patient_dto.rs
│ │ ├── health_data_dto.rs
│ │ ├── appointment_dto.rs
│ │ ├── follow_up_dto.rs
│ │ └── consultation_dto.rs
│ └── event.rs ← 事件定义和处理器
```
---
## 8. 权限定义
### 8.1 权限码
| 权限码 | 名称 | 说明 |
|--------|------|------|
| `health.patient.list` | 查看患者列表 | 查看和搜索患者列表、详情 |
| `health.patient.manage` | 管理患者 | 创建、编辑、删除患者 |
| `health.health-data.list` | 查看健康数据 | 查看体检记录、监测数据、化验报告 |
| `health.health-data.manage` | 管理健康数据 | 录入、编辑、删除健康数据 |
| `health.appointment.list` | 查看预约 | 查看预约列表和排班 |
| `health.appointment.manage` | 管理预约 | 创建、确认、取消预约 |
| `health.follow-up.list` | 查看随访 | 查看随访任务和记录 |
| `health.follow-up.manage` | 管理随访 | 创建、分配、完成随访任务 |
| `health.consultation.list` | 查看咨询 | 查看咨询会话和消息记录 |
| `health.consultation.manage` | 管理咨询 | 关闭会话、导出记录 |
| `health.doctor.list` | 查看医护 | 查看医护列表和详情 |
| `health.doctor.manage` | 管理医护 | 创建、编辑医护档案、排班 |
### 8.2 数据范围
| 实体 | 支持的数据范围级别 | 说明 |
|------|-------------------|------|
| patient | self, department, department_tree, all | 医生只能看自己负责的患者或本科室患者 |
| follow_up_task | self, department, department_tree, all | 医护只能看分配给自己的随访任务 |
| appointment | self, department, department_tree, all | 按科室隔离预约数据 |
### 8.3 角色模板
| 角色 | 权限 |
|------|------|
| health_admin | 全部 health.* 权限 |
| doctor | health.patient.list, health.health-data.*, health.appointment.list, health.follow-up.*, health.consultation.list, health.doctor.list |
| nurse | health.patient.list, health.health-data.*, health.follow-up.*, health.appointment.list |
| receptionist | health.patient.*, health.appointment.*, health.doctor.list |
---
## 9. 能力扩展
V1 需要新增以下基础能力(在 erp-core 或独立模块中):
1. **文件上传服务** — 文件存储(本地/OSS、URL 生成、图片缩略图
2. **趋势分析** — 时序数据聚合、异常检测逻辑
3. **报告批注** — 医生对化验报告的解读/批注能力
4. **导出增强** — 健康数据导出为 Excel/PDF
---
## 10. 实施步骤
### Phase 1: 项目初始化
- 拷贝 ERP 到 hms
- 验证编译和构建
### Phase 2: erp-health 骨架
- 创建 crate 结构
- 实现 ErpModule trait + `public_routes()` / `protected_routes()` 固有方法
- 注册到 workspace
### Phase 3: 数据库迁移
- 16 张表14 业务实体 + 2 关联表)的迁移文件
- 索引创建、唯一约束
### Phase 4: 业务逻辑(按域迭代)
- ① 患者与医护管理
- ② 健康数据管理
- ③ 预约排班
- ④ 随访管理
- ⑤ 咨询管理
### Phase 5: 前端页面
- 13 个自定义 React 页面
- 路由注册和侧边栏菜单
### Phase 6: 集成测试
- API 端点测试
- 多租户隔离验证
- 端到端功能验证

View File

@@ -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.requestJWT 注入、错误处理)
│ │ ├── 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

View File

@@ -0,0 +1,671 @@
# 健康管理模块全面迭代设计
> **文档版本**: 1.0
> **日期**: 2026-04-24
> **状态**: 待评审
> **基于**: 5 位专家(后端架构/前端架构/医疗业务/安全质量/产品策略)深度审查
---
## 0. 审查发现总览
### 0.1 V1 发布阻塞项
| # | 阻塞项 | 来源 | 影响 |
|---|--------|------|------|
| B1 | Web 健康模块 10 页面未实现 | 前端架构/产品策略 | 无法演示和交付 |
| B2 | 医疗数据安全不合规 | 安全质量 | 零 sanitize / 零审计 / 身证明文 / 零测试 |
| B3 | 数据一致性缺陷 | 医疗业务/后端架构 | 排班可超额 / 名额释放可能失败 / 随访逾期未实现 |
| B4 | 事件处理器空壳 | 后端架构 | 随访状态/咨询消息不联动 |
### 0.2 当前完成度
| 层级 | 模块 | 完成度 |
|------|------|--------|
| 后端 | erp-health16 实体/8 服务/7 handler/40+ API | 95% |
| 后端 | 事件处理器业务逻辑 | 0%(框架已搭建,需填充 db 操作) |
| 后端 | sanitize / 审计 / 加密 | 0% |
| 后端 | 测试覆盖 | 0% |
| Web 前端 | 健康模块页面 | 0% |
| Web 前端 | 健康模块 API 服务层 | 0% |
| 小程序 | 初版 21 页面 | 85% |
---
## 1. 安全省基(阶段 11.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` FamilyMemberReqcreate + 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. 后端补完(阶段 21.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 页面(阶段 33.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 表格
- 科室筛选 + 在线状态 Badgeonline=绿/busy=黄/offline=灰)
- 详情 Drawer
#### AppointmentList.tsx中复杂度2 天)
- `Segmented` 切换列表/日历视图
- 列表模式:表格 + 状态筛选 + 日期筛选
- 日历模式:`Calendar` + `cellRender` 显示当日预约数
- 状态流转 Dropdownpending → 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 CRUD0.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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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. **切片 2AI 管理端** — 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` — 受保护的按钮内容
行为: 无权限时不渲染 childrenhidden 模式)。
**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. 切片 2AI 管理端 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.*`),不硬编码中文
- 权限数据加载失败时默认无权限(安全降级,宁可少显示按钮也不暴露越权操作)

View File

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

View File

@@ -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 + 路线图** | |

View File

@@ -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_ruleearn 类型) |
| order_id | UUID | FK → points_orderspend 类型,可空) |
| 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 + QRUUID 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
医护端为独立小程序(独立 AppIDV2 实现。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 增量 |
| 积分通胀 | 积分价值稀释 | 可配置每日上限 + 过期机制 + 运营调整积分价格 |

View File

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

View File

@@ -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 / 分析历史查询 / 用量统计端点为空壳或缺失。先补全后端 APIhandler + service 方法),再实现前端 API 封装和 3 个管理页面,最后注册菜单和路由。
**Tech Stack:** Rust/Axum (后端) + React 19/TypeScript/Ant Design 6 (前端)
**设计规格:** `docs/superpowers/specs/2026-04-25-feature-completion-design.md` §3
---
## Chunk 1: 后端 API 补全
### Task 1: PromptService — 补全 CRUD 方法
**Files:**
- Modify: `crates/erp-ai/src/service/prompt.rs`
**现状:** 仅有 `get_active_prompt` + `create_prompt`。需新增 `list_prompts``update_prompt``activate_prompt``rollback_prompt`
- [ ] **Step 1: 添加 list_prompts 方法**
`PromptService` impl 中追加:
```rust
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set};
use erp_core::types::Pagination;
pub async fn list_prompts(
&self,
tenant_id: Uuid,
category: Option<String>,
pagination: Pagination,
) -> AiResult<(Vec<ai_prompt::Model>, u64)> {
let mut query = ai_prompt::Entity::find()
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
.filter(ai_prompt::Column::DeletedAt.is_null());
if let Some(cat) = category {
query = query.filter(ai_prompt::Column::Category.eq(cat));
}
let total = query.clone().count(&self.db).await?;
let items = query
.order_by_desc(ai_prompt::Column::UpdatedAt)
.offset(pagination.offset())
.limit(pagination.limit())
.all(&self.db)
.await?;
Ok((items, total))
}
```
- [ ] **Step 2: 添加 update_prompt 方法**
```rust
pub async fn update_prompt(
&self,
id: Uuid,
tenant_id: Uuid,
user_id: Uuid,
system_prompt: Option<String>,
user_prompt_template: Option<String>,
model_config: Option<serde_json::Value>,
description: Option<String>,
) -> AiResult<ai_prompt::Model> {
let entity = ai_prompt::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
if entity.tenant_id != tenant_id {
return Err(AiError::Validation("跨租户操作".into()));
}
// 创建新版本
let new_id = Uuid::now_v7();
let now = chrono::Utc::now();
let active = ai_prompt::ActiveModel {
id: Set(new_id),
tenant_id: Set(tenant_id),
name: Set(entity.name.clone()),
description: Set(description.unwrap_or(entity.description.clone())),
system_prompt: Set(system_prompt.unwrap_or(entity.system_prompt.clone())),
user_prompt_template: Set(user_prompt_template.unwrap_or(entity.user_prompt_template.clone())),
variables_schema: Set(entity.variables_schema.clone()),
model_config: Set(model_config.unwrap_or(entity.model_config.clone())),
version: Set(entity.version + 1),
is_active: Set(entity.is_active),
category: Set(entity.category.clone()),
tags: Set(entity.tags.clone()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(Some(user_id)),
updated_by: Set(Some(user_id)),
deleted_at: Set(None),
version_lock: Set(1),
};
Ok(active.insert(&self.db).await?)
}
```
- [ ] **Step 3: 添加 activate_prompt 方法**
```rust
pub async fn activate_prompt(
&self,
id: Uuid,
tenant_id: Uuid,
) -> AiResult<ai_prompt::Model> {
let entity = ai_prompt::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
if entity.tenant_id != tenant_id {
return Err(AiError::Validation("跨租户操作".into()));
}
// 停用同 name + category 的其他版本
let siblings = ai_prompt::Entity::find()
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
.filter(ai_prompt::Column::Name.eq(&entity.name))
.filter(ai_prompt::Column::Category.eq(&entity.category))
.filter(ai_prompt::Column::IsActive.eq(true))
.filter(ai_prompt::Column::DeletedAt.is_null())
.all(&self.db)
.await?;
for sibling in siblings {
let mut active: ai_prompt::ActiveModel = sibling.into();
active.is_active = Set(false);
active.updated_at = Set(chrono::Utc::now());
active.update(&self.db).await?;
}
// 激活目标
let mut active: ai_prompt::ActiveModel = entity.into();
active.is_active = Set(true);
active.updated_at = Set(chrono::Utc::now());
Ok(active.update(&self.db).await?)
}
```
- [ ] **Step 4: 添加 rollback_prompt激活指定旧版本**
```rust
pub async fn rollback_prompt(
&self,
id: Uuid,
tenant_id: Uuid,
) -> AiResult<ai_prompt::Model> {
// 回滚 = 激活指定版本
self.activate_prompt(id, tenant_id).await
}
```
- [ ] **Step 5: 编译验证**
Run: `cargo check -p erp-ai`
Expected: 编译通过
- [ ] **Step 6: 提交**
```bash
git add crates/erp-ai/src/service/prompt.rs
git commit -m "feat(ai): PromptService 补全 list/update/activate/rollback 方法"
```
---
### Task 2: AnalysisService — 补全查询方法
**Files:**
- Modify: `crates/erp-ai/src/service/analysis.rs`
**现状:**`stream_analyze``complete_analysis``fail_analysis``find_cached`。需新增 `list_analysis``get_analysis`
- [ ] **Step 1: 添加 list_analysis 方法**
`AnalysisService` impl 中追加:
```rust
use erp_core::types::Pagination;
pub async fn list_analysis(
&self,
tenant_id: Uuid,
patient_id: Option<Uuid>,
analysis_type: Option<String>,
pagination: Pagination,
) -> AiResult<(Vec<ai_analysis::Model>, u64)> {
let mut query = ai_analysis::Entity::find()
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
.filter(ai_analysis::Column::DeletedAt.is_null());
if let Some(pid) = patient_id {
query = query.filter(ai_analysis::Column::PatientId.eq(pid));
}
if let Some(at) = analysis_type {
query = query.filter(ai_analysis::Column::AnalysisType.eq(at));
}
let total = query.clone().count(&self.db).await?;
let items = query
.order_by_desc(ai_analysis::Column::CreatedAt)
.offset(pagination.offset())
.limit(pagination.limit())
.all(&self.db)
.await?;
Ok((items, total))
}
```
- [ ] **Step 2: 添加 get_analysis 方法**
```rust
pub async fn get_analysis(
&self,
id: Uuid,
tenant_id: Uuid,
) -> AiResult<ai_analysis::Model> {
ai_analysis::Entity::find_by_id(id)
.one(&self.db)
.await?
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
.ok_or_else(|| AiError::AnalysisNotFound(id.to_string()))
}
```
注意:上面 filter 需改写为 match 式:
```rust
pub async fn get_analysis(
&self,
id: Uuid,
tenant_id: Uuid,
) -> AiResult<ai_analysis::Model> {
let model = ai_analysis::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AiError::AnalysisNotFound(id.to_string()))?;
if model.tenant_id != tenant_id {
return Err(AiError::AnalysisNotFound(id.to_string()));
}
Ok(model)
}
```
- [ ] **Step 3: 编译验证**
Run: `cargo check -p erp-ai`
Expected: 编译通过
- [ ] **Step 4: 提交**
```bash
git add crates/erp-ai/src/service/analysis.rs
git commit -m "feat(ai): AnalysisService 补全 list/get 查询方法"
```
---
### Task 3: UsageService — 补全聚合查询方法
**Files:**
- Modify: `crates/erp-ai/src/service/usage.rs`
**现状:** 仅有 `log_usage`。需新增 `get_overview``get_trend``get_by_type`
**注意:** `ai_usage_logs` 表无 `created_by` 字段,用户排行从 `ai_analysis_results.created_by` 聚合。
- [ ] **Step 1: 添加 get_overview 方法**
```rust
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, FromQueryResult, QuerySelect, Func};
use crate::entity::ai_analysis;
#[derive(Debug, FromQueryResult)]
pub struct UsageOverview {
pub total_count: i64,
pub total_input_tokens: i64,
pub total_output_tokens: i64,
}
pub async fn get_overview(
&self,
tenant_id: Uuid,
) -> AiResult<UsageOverview> {
let result = ai_analysis::Entity::find()
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
.filter(ai_analysis::Column::Status.eq("completed"))
.filter(ai_analysis::Column::DeletedAt.is_null())
.select_only()
.column_as(ai_analysis::Column::Id.count(), "total_count")
.into_model::<UsageOverview>()
.one(&self.db)
.await?
.unwrap_or(UsageOverview {
total_count: 0,
total_input_tokens: 0,
total_output_tokens: 0,
});
Ok(result)
}
```
- [ ] **Step 2: 添加 get_by_type 方法**
```rust
#[derive(Debug, FromQueryResult)]
pub struct TypeCount {
pub analysis_type: String,
pub count: i64,
}
pub async fn get_by_type(
&self,
tenant_id: Uuid,
) -> AiResult<Vec<TypeCount>> {
let result = ai_analysis::Entity::find()
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
.filter(ai_analysis::Column::Status.eq("completed"))
.filter(ai_analysis::Column::DeletedAt.is_null())
.select_only()
.column(ai_analysis::Column::AnalysisType)
.column_as(ai_analysis::Column::Id.count(), "count")
.group_by(ai_analysis::Column::AnalysisType)
.into_model::<TypeCount>()
.all(&self.db)
.await?;
Ok(result)
}
```
- [ ] **Step 3: 编译验证**
Run: `cargo check -p erp-ai`
Expected: 编译通过
- [ ] **Step 4: 提交**
```bash
git add crates/erp-ai/src/service/usage.rs
git commit -m "feat(ai): UsageService 补全 get_overview/get_by_type 聚合方法"
```
---
### Task 4: Handler — 补全路由端点
**Files:**
- Modify: `crates/erp-ai/src/handler/mod.rs`
- Modify: `crates/erp-ai/src/module.rs` (路由注册)
**现状:**
- 4 个 SSE 端点:可用
- `list_analysis` / `get_analysis`:空壳(返回 `ApiResponse::ok(())`
- Prompt CRUD、用量统计完全缺失
- [ ] **Step 1: 实现 list_analysis 真实查询**
替换 `handler/mod.rs` 中的 `list_analysis` 函数(第 272-283 行):
```rust
pub async fn list_analysis<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<ListAnalysisQuery>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.analysis.list")?;
let pagination = erp_core::types::Pagination::new(
params.page.unwrap_or(1),
params.page_size.unwrap_or(20),
);
let (items, total) = state.analysis.list_analysis(
ctx.tenant_id,
params.patient_id,
params.analysis_type,
pagination,
).await?;
let data = serde_json::json!({
"data": items,
"total": total,
"page": pagination.page,
"page_size": pagination.page_size,
});
Ok(Json(ApiResponse::ok(data)))
}
```
- [ ] **Step 2: 实现 get_analysis 真实查询**
替换 `get_analysis` 函数(第 285-296 行):
```rust
pub async fn get_analysis<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<ai_analysis::Model>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.analysis.list")?;
let analysis = state.analysis.get_analysis(id, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(analysis)))
}
```
需在文件顶部添加 `use crate::entity::ai_analysis;`
- [ ] **Step 3: 新增 Prompt CRUD handler 函数**
在 handler/mod.rs 中添加以下函数(分析历史之后):
```rust
// === Prompt 管理 ===
#[derive(Debug, Deserialize)]
pub struct ListPromptsQuery {
pub category: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
pub async fn list_prompts<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<ListPromptsQuery>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.list")?;
let pagination = erp_core::types::Pagination::new(
params.page.unwrap_or(1),
params.page_size.unwrap_or(20),
);
let (items, total) = state.prompt.list_prompts(
ctx.tenant_id, params.category, pagination,
).await?;
Ok(Json(ApiResponse::ok(serde_json::json!({
"data": items, "total": total,
"page": pagination.page, "page_size": pagination.page_size,
}))))
}
#[derive(Debug, Deserialize)]
pub struct CreatePromptBody {
pub name: String,
pub description: Option<String>,
pub system_prompt: String,
pub user_prompt_template: String,
pub model_config: serde_json::Value,
pub category: String,
}
pub async fn create_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Json(body): Json<CreatePromptBody>,
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.manage")?;
let prompt = state.prompt.create_prompt(
ctx.tenant_id, ctx.user_id,
body.name, body.system_prompt, body.user_prompt_template,
body.model_config, body.category,
).await?;
Ok(Json(ApiResponse::ok(prompt)))
}
pub async fn activate_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.manage")?;
let prompt = state.prompt.activate_prompt(id, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(prompt)))
}
pub async fn rollback_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.manage")?;
let prompt = state.prompt.rollback_prompt(id, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(prompt)))
}
```
需在文件顶部添加 `use crate::entity::ai_prompt;`
- [ ] **Step 4: 新增用量统计 handler 函数**
```rust
// === 用量统计 ===
pub async fn usage_overview<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.usage.list")?;
let overview = state.usage.get_overview(ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(serde_json::json!({
"total_count": overview.total_count,
}))))
}
pub async fn usage_by_type<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.usage.list")?;
let types = state.usage.get_by_type(ctx.tenant_id).await?;
let result: Vec<serde_json::Value> = types.into_iter().map(|t| {
serde_json::json!({
"analysis_type": t.analysis_type,
"count": t.count,
})
}).collect();
Ok(Json(ApiResponse::ok(result)))
}
```
- [ ] **Step 5: 在 module.rs 注册新路由**
修改 `AiModule::protected_routes`,在现有路由后追加:
```rust
.route("/ai/prompts", axum::routing::get(crate::handler::list_prompts))
.route("/ai/prompts", axum::routing::post(crate::handler::create_prompt))
.route("/ai/prompts/{id}/activate", axum::routing::post(crate::handler::activate_prompt))
.route("/ai/prompts/{id}/rollback", axum::routing::post(crate::handler::rollback_prompt))
.route("/ai/usage/overview", axum::routing::get(crate::handler::usage_overview))
.route("/ai/usage/by-type", axum::routing::get(crate::handler::usage_by_type))
```
- [ ] **Step 6: 编译验证**
Run: `cargo check -p erp-ai && cargo check -p erp-server`
Expected: 编译通过
- [ ] **Step 7: 提交**
```bash
git add crates/erp-ai/src/handler/mod.rs crates/erp-ai/src/module.rs
git commit -m "feat(ai): 补全 Prompt CRUD + 分析历史 + 用量统计 handler 和路由"
```
---
## Chunk 2: 前端 API 封装
### Task 5: 创建 AI API service 文件
**Files:**
- Create: `apps/web/src/api/ai/prompts.ts`
- Create: `apps/web/src/api/ai/analysis.ts`
- Create: `apps/web/src/api/ai/usage.ts`
- [ ] **Step 1: 创建 prompts.ts**
`apps/web/src/api/ai/prompts.ts`:
```typescript
import client from '../client';
import type { PaginatedResponse } from '../types';
export interface PromptItem {
id: string;
name: string;
description: string;
system_prompt: string;
user_prompt_template: string;
model_config: Record<string, unknown>;
version: number;
is_active: boolean;
category: string;
tags: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
export interface CreatePromptReq {
name: string;
description?: string;
system_prompt: string;
user_prompt_template: string;
model_config: Record<string, unknown>;
category: string;
}
export const promptApi = {
list: async (params?: { category?: string; page?: number; page_size?: number }) => {
const resp = await client.get('/ai/prompts', { params });
return resp.data.data as PaginatedResponse<PromptItem>;
},
create: async (data: CreatePromptReq) => {
const resp = await client.post('/ai/prompts', data);
return resp.data.data as PromptItem;
},
activate: async (id: string) => {
const resp = await client.post(`/ai/prompts/${id}/activate`);
return resp.data.data as PromptItem;
},
rollback: async (id: string) => {
const resp = await client.post(`/ai/prompts/${id}/rollback`);
return resp.data.data as PromptItem;
},
};
```
- [ ] **Step 2: 创建 analysis.ts**
`apps/web/src/api/ai/analysis.ts`:
```typescript
import client from '../client';
import type { PaginatedResponse } from '../types';
export interface AnalysisItem {
id: string;
patient_id: string;
analysis_type: string;
source_ref: string;
model_used: string;
status: string;
result_content: string | null;
result_metadata: Record<string, unknown> | null;
error_message: string | null;
created_at: string;
updated_at: string;
}
export const analysisApi = {
list: async (params?: { patient_id?: string; analysis_type?: string; page?: number; page_size?: number }) => {
const resp = await client.get('/ai/analysis/history', { params });
return resp.data.data as PaginatedResponse<AnalysisItem>;
},
get: async (id: string) => {
const resp = await client.get(`/ai/analysis/${id}`);
return resp.data.data as AnalysisItem;
},
};
```
- [ ] **Step 3: 创建 usage.ts**
`apps/web/src/api/ai/usage.ts`:
```typescript
import client from '../client';
export interface UsageOverview {
total_count: number;
}
export interface TypeDistribution {
analysis_type: string;
count: number;
}
export const usageApi = {
overview: async () => {
const resp = await client.get('/ai/usage/overview');
return resp.data.data as UsageOverview;
},
byType: async () => {
const resp = await client.get('/ai/usage/by-type');
return resp.data.data as TypeDistribution[];
},
};
```
- [ ] **Step 4: 验证编译**
Run: `cd apps/web && npx tsc --noEmit`
Expected: 无错误
- [ ] **Step 5: 提交**
```bash
git add apps/web/src/api/ai/
git commit -m "feat(web): AI API 前端封装 — prompts/analysis/usage"
```
---
## Chunk 3: 前端管理页面
### Task 6: AI Prompt 管理页面
**Files:**
- Create: `apps/web/src/pages/health/AiPromptList.tsx`
- [ ] **Step 1: 创建 AiPromptList 页面**
使用 Ant Design Table + Modal 模式(参考 PatientList.tsx 结构):
核心功能:
- 表格列:名称 / 类别 / 版本 / 状态(active/inactive) / 更新时间
- 新建 Prompt 按钮 → Modal 表单
- 操作列:激活 / 回滚
- AuthButton 权限控制(`ai.prompt.manage`
页面大致结构(骨架,实现时根据 Ant Design 6 API 细化):
```tsx
import { useEffect, useState, useCallback } from 'react';
import { Table, Button, Space, Modal, Form, Input, Select, Tag, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { promptApi, type PromptItem, type CreatePromptReq } from '../../api/ai/prompts';
import { AuthButton } from '../../components/AuthButton';
import { useThemeMode } from '../../hooks/useThemeMode';
const CATEGORIES = [
{ value: 'lab_report_interpretation', label: '化验单解读' },
{ value: 'health_trend_analysis', label: '趋势分析' },
{ value: 'personalized_checkup_plan', label: '体检方案' },
{ value: 'report_summary_generation', label: '报告摘要' },
];
export default function AiPromptList() {
// ... useState, fetchPrompts, columns 定义
// 新建/激活/回滚按钮用 <AuthButton code="ai.prompt.manage"> 包裹
// 表格渲染 + Modal 表单
}
```
- [ ] **Step 2: 验证编译 + 提交**
```bash
cd apps/web && npx tsc --noEmit
git add apps/web/src/pages/health/AiPromptList.tsx
git commit -m "feat(web): AI Prompt 管理页面"
```
---
### Task 7: AI 分析历史页面
**Files:**
- Create: `apps/web/src/pages/health/AiAnalysisList.tsx`
- [ ] **Step 1: 创建 AiAnalysisList 页面**
核心功能:
- 表格列:分析类型 / 患者 ID / 状态(streaming/completed/failed) / 模型 / 创建时间
- 状态 Tagcompleted=绿色, failed=红色, streaming=蓝色
- 详情查看:点击行展开,显示 result_contentMarkdown 渲染)
- 筛选:分析类型下拉 + 时间范围
- AuthButton 权限控制(`ai.analysis.list`
- [ ] **Step 2: 验证编译 + 提交**
```bash
cd apps/web && npx tsc --noEmit
git add apps/web/src/pages/health/AiAnalysisList.tsx
git commit -m "feat(web): AI 分析历史页面"
```
---
### Task 8: AI 用量统计页面
**Files:**
- Create: `apps/web/src/pages/health/AiUsageDashboard.tsx`
- [ ] **Step 1: 创建 AiUsageDashboard 页面**
核心功能:
- 顶部 StatCard总分析次数
- 饼图:分析类型分布(使用 Ant Design Charts Pie
- AuthButton 权限控制(`ai.usage.list`
- [ ] **Step 2: 验证编译 + 提交**
```bash
cd apps/web && npx tsc --noEmit
git add apps/web/src/pages/health/AiUsageDashboard.tsx
git commit -m "feat(web): AI 用量统计页面"
```
---
### Task 9: 菜单注册 + 路由配置
**Files:**
- Modify: `apps/web/src/layouts/MainLayout.tsx`
- Modify: `apps/web/src/App.tsx`
- [ ] **Step 1: 在 MainLayout 添加菜单项**
`healthMenuItems` 数组中追加 3 项:
```typescript
{ key: '/health/ai-prompts', label: 'AI Prompt 管理', icon: ... },
{ key: '/health/ai-analysis', label: 'AI 分析历史', icon: ... },
{ key: '/health/ai-usage', label: 'AI 用量统计', icon: ... },
```
- [ ] **Step 2: 在 App.tsx 添加路由**
在健康模块路由区域追加:
```tsx
<Route path="/health/ai-prompts" element={<AiPromptList />} />
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
```
- [ ] **Step 3: 验证编译 + 提交**
```bash
cd apps/web && npx tsc --noEmit
git add apps/web/src/layouts/MainLayout.tsx apps/web/src/App.tsx
git commit -m "feat(web): AI 管理端菜单注册 + 路由配置"
```
---
### Task 10: 集成验证
- [ ] **Step 1: 后端编译 + 启动**
Run: `cargo check --workspace && cd crates/erp-server && cargo run`
验证:
- `/api/v1/ai/prompts` 返回空列表200
- `/api/v1/ai/analysis/history` 返回空列表200
- `/api/v1/ai/usage/overview` 返回 0 计数200
- [ ] **Step 2: 前端编译 + 启动**
Run: `cd apps/web && pnpm build && pnpm dev`
验证:
- 3 个新页面在菜单中可见
- 页面正常加载,无白屏/报错
- Prompt 列表为空时显示空状态
- 分析历史列表为空时显示空状态
- [ ] **Step 3: 生产构建**
Run: `cd apps/web && pnpm build`
Expected: 构建成功
- [ ] **Step 4: 推送**
```bash
git push
```

View File

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

View File

@@ -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`,计算变更 diffchanged_fields[], old_values{}, new_values{})。
**验收**: diagnosis.updated 事件 data 含 changed_fields 差异;`cargo test` 通过。
### Task 3: consent_service 添加 consent.granted/revoked 事件
**涉及文件**: `crates/erp-health/src/service/consent_service.rs`
**步骤**: 签署时发布 `consent.granted`data: patient_id, consent_type, consent_scope, granted_by, expires_at。撤销时发布 `consent.revoked`data: patient_id, consent_type, revoked_by, reason
**验收**: 签署/撤销后 domain_events 表出现事件;`cargo test` 通过。
---
## Phase 2: 中低优先级事件 + Outbox 优化Week 2
### Task 4: points_service 添加 points.earned/exchanged 事件
**涉及文件**: `crates/erp-health/src/service/points_service.rs`
**步骤**: earn 成功后发布 `points.earned`data: patient_id, points, source_type, balance_after。exchange 成功后发布 `points.exchanged`data: patient_id, points, product_name, order_id, balance_after。确保在事务提交后发布。
**验收**: 积分变动后 domain_events 出现事件balance_after 正确反映余额。
### Task 5: article_service 添加 article.published/rejected 事件
**涉及文件**: `crates/erp-health/src/service/article_service.rs`
**步骤**: 审核通过发布 `article.published`data: title, author_id, category_id, tags[])。审核驳回发布 `article.rejected`data: title, reviewer_id, reason
**验收**: 审核操作后 domain_events 出现事件;`cargo test` 通过。
### Task 6: daily_monitoring_service 添加 daily_monitoring.created 事件
**涉及文件**: `crates/erp-health/src/service/daily_monitoring_service.rs`
**步骤**: 记录创建后发布 `daily_monitoring.created`data: patient_id, monitoring_date, monitoring_type, values{})。
**验收**: 创建记录后 domain_events 出现事件;`cargo test` 通过。
### Task 7: Outbox relay 从轮询改为 LISTEN/NOTIFY
**涉及文件**: `crates/erp-server/src/outbox.rs`, `crates/erp-core/src/events.rs`
**步骤**: `EventBus::publish()` 持久化后执行 `NOTIFY outbox_channel, '<event_id>'`。outbox relay 用 `sqlx::PgListener` 监听 + `tokio::select!`LISTEN 触发 + 30s 兜底轮询)。保留 `process_pending_events()` 不变仅改变触发方式。PgListener 添加断线自动重连。
**验收**: 事件延迟 < 100msDB 轮询频率从 5s 降为 30s 兜底;`cargo test --workspace` 通过。
---
## Phase 3: 事件 schema 版本化 + 清理Week 2
### Task 8: 事件 payload 添加 schema_version 字段
**涉及文件**: `crates/erp-core/src/events.rs`, `crates/erp-health/src/service/` 下所有发布事件的 service
**步骤**: 在 erp-core 创建 `build_event_payload()` 辅助函数,自动填充 schema_version/timestamp/metadata。逐个 service14 个模块)替换手动构建为调用辅助函数,统一信封格式。
**验收**: 所有事件 payload 含 schema_version 字段;`cargo test --workspace` 通过。
### Task 9: Outbox 表分区或定期清理策略
**涉及文件**: `migration/src/m000075_domain_events_cleanup.rs`(新增), `erp-server/src/tasks/events_cleanup.rs`(新增)
**步骤**: 迁移创建 `domain_events_archive` 表,添加 `cleanup_old_published_events()` SQL 函数(>90 天 published 事件迁移到归档表)。后台任务每日执行清理。归档表只读防篡改。
**验收**: 清理任务正确迁移 >90 天事件;`cargo test` 通过。
### Task 10: 消费者幂等性dedup key 检查)
**涉及文件**: `migration/src/m000076_processed_events.rs`(新增), `crates/erp-core/src/events.rs`
**步骤**: 迁移创建 `processed_events`event_id + consumer_id 联合主键 + processed_at。erp-core 添加 `is_processed()` / `mark_processed()` 辅助函数。消费者模式:收到事件 -> 查已处理 -> 跳过或执行 -> 插入记录。添加 7 天 TTL 清理任务。
**验收**: 重复消费同一事件时第二次被跳过;`cargo test --workspace` 通过。
---
## 执行原则
1. **每 Task 完成后立即提交** — 不积压
2. **Phase 1 优先** — P0 事件(透析/诊断)是核心医疗流程
3. **事件发布不阻断业务** — publish 失败仅 warnOutbox relay 兜底
4. **统一信封格式** — 使用 `build_event_payload` 保证一致性
5. **LISTEN/NOTIFY 保留兜底轮询** — 30s 轮询防 NOTIFY 丢失

View File

@@ -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 体积 < 400KBgzip 前约 600KB 以内)
- `vendor-charts``vendor-flow``vendor-editor` 独立生成
- `pnpm build` 无警告
- 统计仪表板、插件关系图、文章编辑器页面功能正常(懒加载无闪烁)
---
### Task 9: columns 配置 useMemo 化
**目标**: 消除 PluginCRUDPage 和健康模块列表页的 columns 重复创建,减少不必要的 re-render。
**涉及文件**:
- 修改: `apps/web/src/pages/plugins/components/CRUDTable.tsx`Phase 2 Task 4 产物)
- 修改: `apps/web/src/pages/health/PatientList.tsx`
- 修改: `apps/web/src/pages/health/AppointmentList.tsx`
- 修改: `apps/web/src/pages/health/FollowUpTaskList.tsx`
**详细步骤**:
1. 在每个列表页中,将 `columns` 数组定义包裹在 `useMemo`
2. 依赖项包含 columns 中引用的回调函数(如 onDelete、onEdit
3. 确保回调函数通过 `useCallback` 缓存,避免 useMemo 失效
4. 使用 React DevTools Profiler 验证翻页/筛选时减少不必要渲染
**验收标准**:
- 列表翻页时 Table 组件不因 columns 引用变化触发全量渲染
- 所有列表页功能正常(排序、筛选、操作按钮)
- `pnpm build` 通过
---
### Task 10: API 层新代码统一为对象风格
**目标**: 确认新增 API 文件采用对象风格(`xxxApi.list()` 而非 `listXxx()`),修改已有文件时顺手迁移。
**涉及文件**:
- 修改: `apps/web/src/api/health/` 下近期新增的 API 文件(如 `alerts.ts``deviceReadings.ts`
**详细步骤**:
1. 审计 `apps/web/src/api/` 下所有文件,标记函数风格的文件清单
2. 近期新增的文件alerts、deviceReadings 等)统一改为对象风格:
```typescript
export const alertApi = {
list: (params) => client.get('/alerts', { params }),
acknowledge: (id) => client.post(`/alerts/${id}/acknowledge`),
};
```
3. 更新引用处的 import页面组件中的调用方式
4. 旧文件不强制迁移,仅记录待迁移清单
**验收标准**:
- `alerts.ts``deviceReadings.ts` 为对象风格导出
- 对应页面功能正常
- `pnpm build` 通过
---
## 执行原则
1. **每 Task 完成后立即提交** — 不积压,保持可追溯
2. **先基础设施后拆分** — Phase 1 的 hook 增强完成后再做 Phase 2 组件拆分
3. **每步验证** — 每个 Task 完成后 `pnpm build` 验证,拆分任务额外验证页面功能
4. **渐进迁移** — 重复模式统一采用渐进策略,不一次性全量迁移

View File

@@ -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 + 生产 DockerDay 3-5
### Task 4: OpenTelemetry 条件集成
**涉及文件**:
- 修改: `crates/erp-server/Cargo.toml`(添加 `opentelemetry` + `tracing-opentelemetry` + `opentelemetry-otlp`optional feature
- 新增: `crates/erp-server/src/telemetry.rs`
- 修改: `crates/erp-server/src/main.rs`
**步骤**:
1. 添加 optional 依赖:
```toml
[features]
tracing = ["opentelemetry", "tracing-opentelemetry", "opentelemetry-otlp"]
```
2. 创建 `telemetry.rs`:条件初始化 OpenTelemetry tracer环境变量 `ERP__TELEMETRY__ENABLED=true` 时启用)
3. 配置 OTLP exporter默认 `http://localhost:4317`,可通过环境变量覆盖)
4.`main.rs` 的 tracing subscriber 中条件注册 OpenTelemetry layer
5. 在 SeaORM 的 `DatabaseConnection` 包装中添加 span记录查询耗时
**验收**: 启用后 Jaeger/Tempo 可看到请求 → SQL 查询 → 事件发布的完整链路;不启用时零开销
### Task 5: 生产 Docker 多阶段构建
**涉及文件**:
- 新增: `Dockerfile`(项目根目录)
- 新增: `docker/docker-compose.production.yml`
**步骤**:
1. 多阶段 Dockerfile:
```dockerfile
# Stage 1: Build
FROM rust:1.82-bookworm AS builder
COPY . /app
RUN cargo build --release -p erp-server
# Stage 2: Runtime
FROM debian:bookworm-slim
COPY --from=builder /app/target/release/erp-server /usr/local/bin/
COPY --from=builder /app/crates/erp-server/config/default.toml /etc/erp/config.toml
EXPOSE 3000 9090
CMD ["erp-server"]
```
2. `docker-compose.production.yml`:
- erp-server 服务(限制 1 CPU / 512MB
- PostgreSQL 16 + Redis 7 作为独立服务
- 健康检查配置(使用 /health/ready
- 环境变量注入JWT secret / DB URL / Redis URL 通过 secrets
**验收**: `docker build -t hms-server .` 成功,运行时镜像 < 80MB
### Task 6: 前端生产构建 + Nginx
**涉及文件**:
- 新增: `apps/web/Dockerfile`
- 新增: `apps/web/nginx.conf`
**步骤**:
1. 多阶段构建node 构建 → nginx 运行
2. Nginx 配置SPA fallback + `/api` 代理到后端 3000 端口
**验收**: Docker 内前端可正常访问API 代理工作
---
## Phase 3: 日志聚合 + 告警Day 5-7
### Task 7: Grafana Loki 日志集成
**涉及文件**:
- 新增: `docker/loki-config.yaml`
- 修改: `docker/docker-compose.production.yml`
**步骤**:
1. 在 production compose 中添加 Loki + Promtail 服务
2. Promtail 配置:读取 erp-server 的 JSON 日志输出
3. Grafana 数据源配置Loki + Prometheus
**验收**: Grafana 可查询和过滤后端日志
### Task 8: Prometheus 告警规则
**涉及文件**:
- 新增: `docker/alert-rules.yml`
**步骤**:
1. 定义 5 条告警规则:
```yaml
- alert: HighRequestLatency # P95 > 2s 持续 5 分钟
- alert: HighErrorRate # 5xx 比率 > 5% 持续 3 分钟
- alert: EventBusBacklog # 积压事件 > 100 持续 5 分钟
- alert: DatabasePoolExhausted # 活跃连接 > 90% 持续 2 分钟
- alert: HealthCheckDegraded # /health/ready 非 ok 持续 1 分钟
```
2. 配置 Alertmanager 通知渠道Webhook/邮件)
**验收**: 触发告警条件时 Alertmanager 发送通知
### Task 9: Grafana Dashboard 模板
**涉及文件**:
- 新增: `docker/grafana/dashboards/hms-overview.json`
**步骤**:
1. 创建 HMS Overview Dashboard包含面板
- 请求速率 + 延迟分布P50/P95/P99
- 错误率趋势(按 status code 分组)
- DB 连接池使用率
- EventBus 发布/消费速率
- 健康检查状态
**验收**: Dashboard 展示实时指标
### Task 10: 运维文档
**涉及文件**:
- 新增: `wiki/observability.md`
**步骤**:
1. 记录监控端点(/health/live, /health/ready, /metrics
2. 记录告警规则和响应流程
3. 记录日志查询方法Grafana Loki
4. 记录 Docker 部署命令
**验收**: 新团队成员可通过文档独立部署和排查问题
---
## 执行原则
1. **条件编译** — OpenTelemetry 使用 feature gate不启用时零开销
2. **渐进式** — Phase 1 可独立上线无外部依赖Phase 2/3 需要 Docker 环境
3. **性能优先** — 指标收集使用 `metrics` crate 的无锁实现,不影响请求延迟
4. **端口分离** — 业务 API (3000) + Metrics (9090) 分离,避免暴露内部指标

View File

@@ -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 个 cratebus 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 平台基座演进的参考基准。后续实施计划将基于本文档的优先级排序展开。*

View File

@@ -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/WASMwit-bindgen 0.55
**Spec:** `docs/discussions/2026-04-28-architecture-retrospective.md`
---
## Chunk 1: WASM 评估量表插件PHQ-9
### Task 1: 创建插件 crate 骨架
**Files:**
- Create: `crates/erp-plugin-assessment/Cargo.toml`
- Create: `crates/erp-plugin-assessment/src/lib.rs`
- Create: `crates/erp-plugin-assessment/plugin.toml`
- [ ] **Step 1: 创建 Cargo.toml**
参照 `crates/erp-plugin-test-sample/Cargo.toml`
```toml
[package]
name = "erp-plugin-assessment"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.55"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
```
- [ ] **Step 2: 创建 plugin.toml**
```toml
[metadata]
id = "assessment"
name = "评估量表"
version = "0.1.0"
description = "标准化医学评估量表PHQ-9、GAD-7 等)"
author = "HMS"
min_platform_version = "0.1.0"
[[permissions]]
code = "assessment_scale.list"
name = "查看评估量表"
description = "查看评估量表列表和详情"
[[permissions]]
code = "assessment_scale.manage"
name = "管理评估量表"
description = "创建、编辑、删除评估量表"
[[permissions]]
code = "assessment_response.list"
name = "查看评估结果"
description = "查看患者评估答卷"
[[permissions]]
code = "assessment_response.manage"
name = "管理评估结果"
description = "提交、编辑评估答卷"
[[schema.entities]]
name = "assessment_scale"
display_name = "评估量表"
[[schema.entities.fields]]
name = "scale_code"
field_type = "string"
required = true
display_name = "量表编码"
unique = true
ui_widget = "select"
options = ["PHQ-9", "GAD-7", "SF-36", "MMSE", "ADL", "IADL"]
[[schema.entities.fields]]
name = "title"
field_type = "string"
required = true
display_name = "量表名称"
searchable = true
[[schema.entities.fields]]
name = "description"
field_type = "string"
display_name = "描述"
ui_widget = "textarea"
[[schema.entities.fields]]
name = "questions_json"
field_type = "json"
required = true
display_name = "题目定义JSON"
[[schema.entities.fields]]
name = "scoring_rules_json"
field_type = "json"
required = true
display_name = "评分规则JSON"
[[schema.entities.fields]]
name = "status"
field_type = "string"
required = true
display_name = "状态"
default = "active"
ui_widget = "select"
options = ["active", "inactive"]
[[schema.entities]]
name = "assessment_response"
display_name = "评估答卷"
[[schema.entities.fields]]
name = "scale_id"
field_type = "uuid"
required = true
display_name = "量表"
ui_widget = "entity_select"
ref_entity = "assessment_scale"
ref_plugin = "assessment"
[[schema.entities.fields]]
name = "patient_id"
field_type = "uuid"
required = true
display_name = "患者 ID"
[[schema.entities.fields]]
name = "answers_json"
field_type = "json"
required = true
display_name = "答案JSON"
[[schema.entities.fields]]
name = "total_score"
field_type = "integer"
required = true
display_name = "总分"
[[schema.entities.fields]]
name = "severity_level"
field_type = "string"
required = true
display_name = "严重程度"
ui_widget = "select"
options = ["normal", "mild", "moderate", "severe"]
[[schema.entities.fields]]
name = "assessed_by"
field_type = "uuid"
display_name = "评估人"
[[schema.entities.fields]]
name = "status"
field_type = "string"
required = true
display_name = "状态"
default = "completed"
ui_widget = "select"
options = ["draft", "completed", "reviewed"]
[[schema.entities.relations]]
entity = "assessment_scale"
foreign_key = "scale_id"
on_delete = "restrict"
name = "scale"
type = "belongs_to"
display_field = "title"
[[trigger_events]]
name = "assessment_completed"
display_name = "评估完成"
description = "患者完成评估量表,触发评分计算和后续流程"
entity = "assessment_response"
on = "create"
[[ui.pages]]
type = "crud"
label = "评估量表"
icon = "FormOutlined"
```
- [ ] **Step 3: 创建 src/lib.rs**
```rust
// crates/erp-plugin-assessment/src/lib.rs
//! 评估量表插件 — 标准化医学评估PHQ-9, GAD-7 等)
wit_bindgen::generate!({
path: "../../crates/erp-plugin/src/wit/plugin.wit",
world: "plugin-world",
});
use crate::exports::erp::plugin::plugin_api::Guest;
use crate::erp::plugin::host_api::*;
struct AssessmentPlugin;
impl Guest for AssessmentPlugin {
fn init() -> Result<(), String> {
log_write("info", "AssessmentPlugin initialized");
Ok(())
}
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
log_write("info", &format!("AssessmentPlugin: tenant {} created", tenant_id));
// 可以为新租户插入默认量表PHQ-9、GAD-7
Ok(())
}
fn handle_event(
event_type: String,
_event_id: String,
_tenant_id: String,
_payload: String,
) -> Result<(), String> {
log_write("debug", &format!("AssessmentPlugin received: {}", event_type));
Ok(())
}
}
export!(AssessmentPlugin);
```
- [ ] **Step 4: 注册到 workspace**
在根 `Cargo.toml``workspace.members` 中添加 `"crates/erp-plugin-assessment"`
- [ ] **Step 5: 编译验证**
```bash
cargo check -p erp-plugin-assessment
```
- [ ] **Step 6: 提交**
```bash
git add crates/erp-plugin-assessment/ Cargo.toml
git commit -m "feat(plugin): 评估量表插件骨架 — assessment_scale + assessment_response"
```
---
### Task 2: PHQ-9 默认量表数据 + 评分逻辑
**Files:**
- Modify: `crates/erp-plugin-assessment/src/lib.rs`on_tenant_created 插入默认量表)
- [ ] **Step 1: 在 on_tenant_created 中插入 PHQ-9 默认数据**
PHQ-9 的 9 道题(每题 0-3 分)和评分规则:
```json
// questions_json
[
{"id": 1, "text": "做事时提不起劲或没有兴趣", "options": [{"label": "完全不会", "score": 0}, {"label": "好几天", "score": 1}, {"label": "一半以上的天数", "score": 2}, {"label": "几乎每天", "score": 3}]},
// ... 共 9 题
]
// scoring_rules_json
[
{"min": 0, "max": 4, "level": "normal", "label": "无抑郁症状"},
{"min": 5, "max": 9, "level": "mild", "label": "轻度抑郁"},
{"min": 10, "max": 14, "level": "moderate", "label": "中度抑郁"},
{"min": 15, "max": 19, "level": "moderate_severe", "label": "中重度抑郁"},
{"min": 20, "max": 27, "level": "severe", "label": "重度抑郁"}
]
```
通过 `db_insert` host API 在 `on_tenant_created` 中插入。
- [ ] **Step 2: 编译 + 验证**
- [ ] **Step 3: 提交**
```bash
git commit -m "feat(plugin): PHQ-9 默认量表数据 + 评分规则"
```
---
### Task 3: 编译 WASM + 注册到 erp-server
**Files:**
- Modify: `crates/erp-server/src/main.rs`(插件注册,如需手动加载)
- Verify: WASM 编译输出
- [ ] **Step 1: 编译为 WASM Component**
```bash
cd crates/erp-plugin-assessment
cargo build --target wasm32-unknown-unknown --release
# 或使用项目内的 WASM 编译脚本
```
- [ ] **Step 2: 验证插件加载**
启动后端,确认插件系统识别 assessment 插件,动态表创建成功。
- [ ] **Step 3: 通过 API 测试评估量表 CRUD**
```bash
# 创建量表
curl -X POST /api/v1/plugin/assessment/assessment_scale \
-H "Authorization: Bearer $TOKEN" \
-d '{"scale_code": "PHQ-9", ...}'
# 提交答卷
curl -X POST /api/v1/plugin/assessment/assessment_response \
-d '{"scale_id": "...", "patient_id": "...", "answers_json": [...]}'
```
- [ ] **Step 4: 提交**
```bash
git commit -m "feat(plugin): 评估量表 WASM 编译 + 端到端验证"
```
---
## Chunk 2: 透析模块拆分为 erp-dialysis
### Task 4: 创建 erp-dialysis crate 骨架
**Files:**
- Create: `crates/erp-dialysis/Cargo.toml`
- Create: `crates/erp-dialysis/src/{lib,module,state,error}.rs`
- Modify: `Cargo.toml`workspace members
- [ ] **Step 1: 创建 Cargo.toml**
```toml
[package]
name = "erp-dialysis"
version.workspace = true
edition.workspace = true
[dependencies]
erp-core.workspace = true
sea-orm.workspace = true
tokio.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
uuid.workspace = true
chrono.workspace = true
axum.workspace = true
utoipa.workspace = true
validator.workspace = true
async-trait.workspace = true
tracing.workspace = true
rust_decimal.workspace = true
```
- [ ] **Step 2: 创建标准模块文件**
```rust
// crates/erp-dialysis/src/lib.rs
pub mod dto;
pub mod entity;
pub mod error;
pub mod event;
pub mod handler;
pub mod module;
pub mod service;
pub mod state;
pub use module::DialysisModule;
pub use state::DialysisState;
```
```rust
// crates/erp-dialysis/src/module.rs
//! ErpModule trait 实现
use async_trait::async_trait;
use erp_core::module::{ErpModule, ModuleContext, ModuleType, PermissionDescriptor};
use erp_core::events::EventBus;
use crate::state::DialysisState;
pub struct DialysisModule {
state: DialysisState,
}
impl DialysisModule {
pub fn new(db: sea_orm::DatabaseConnection, event_bus: EventBus) -> Self {
Self { state: DialysisState { db, event_bus } }
}
}
#[async_trait]
impl ErpModule for DialysisModule {
fn name(&self) -> &str { "透析管理" }
fn id(&self) -> &str { "erp-dialysis" }
fn version(&self) -> &str { "0.1.0" }
fn module_type(&self) -> ModuleType { ModuleType::Builtin }
fn permissions(&self) -> Vec<PermissionDescriptor> {
vec![
PermissionDescriptor { code: "dialysis.record.list".into(), name: "查看透析记录".into() },
PermissionDescriptor { code: "dialysis.record.manage".into(), name: "管理透析记录".into() },
PermissionDescriptor { code: "dialysis.prescription.list".into(), name: "查看透析处方".into() },
PermissionDescriptor { code: "dialysis.prescription.manage".into(), name: "管理透析处方".into() },
]
}
fn on_startup(&self, ctx: &ModuleContext) {
crate::event::register_handlers_with_state(self.state.clone());
}
fn as_any(&self) -> &dyn std::any::Any { self }
}
```
- [ ] **Step 3: 注册到 workspace**
在根 `Cargo.toml` 的 members 中添加 `"crates/erp-dialysis"`
- [ ] **Step 4: 编译验证**
```bash
cargo check -p erp-dialysis
```
- [ ] **Step 5: 提交**
```bash
git commit -m "feat(dialysis): 创建 erp-dialysis crate 骨架 + ErpModule 实现"
```
---
### Task 5: 迁移透析 Entity + Service + Handler + DTO
**Files:**
- Move: 6 个文件从 `erp-health``erp-dialysis`
- Modify: `crates/erp-health/src/{entity,service,handler,dto}/mod.rs`(删除透析导出)
- Modify: 迁移文件中的 `crate::` 引用改为 `erp_core::` 或 erp-dialysis 内部引用
**待迁移文件清单:**
| 来源 | 目标 | 行数 |
|------|------|------|
| `erp-health/src/entity/dialysis_record.rs` | `erp-dialysis/src/entity/` | 82 |
| `erp-health/src/entity/dialysis_prescription.rs` | `erp-dialysis/src/entity/` | 78 |
| `erp-health/src/service/dialysis_service.rs` | `erp-dialysis/src/service/` | 333 |
| `erp-health/src/service/dialysis_prescription_service.rs` | `erp-dialysis/src/service/` | 274 |
| `erp-health/src/handler/dialysis_handler.rs` | `erp-dialysis/src/handler/` | 145 |
| `erp-health/src/handler/dialysis_prescription_handler.rs` | `erp-dialysis/src/handler/` | 120 |
| `erp-health/src/dto/dialysis_dto.rs` | `erp-dialysis/src/dto/` | 125 |
| `erp-health/src/dto/dialysis_prescription_dto.rs` | `erp-dialysis/src/dto/` | 107 |
- [ ] **Step 1: 复制文件到 erp-dialysis**
```bash
# Entity
cp crates/erp-health/src/entity/dialysis_record.rs crates/erp-dialysis/src/entity/
cp crates/erp-health/src/entity/dialysis_prescription.rs crates/erp-dialysis/src/entity/
# Service
cp crates/erp-health/src/service/dialysis_service.rs crates/erp-dialysis/src/service/
cp crates/erp-health/src/service/dialysis_prescription_service.rs crates/erp-dialysis/src/service/
# Handler
cp crates/erp-health/src/handler/dialysis_handler.rs crates/erp-dialysis/src/handler/
cp crates/erp-health/src/handler/dialysis_prescription_handler.rs crates/erp-dialysis/src/handler/
# DTO
cp crates/erp-health/src/dto/dialysis_dto.rs crates/erp-dialysis/src/dto/
cp crates/erp-health/src/dto/dialysis_prescription_dto.rs crates/erp-dialysis/src/dto/
```
- [ ] **Step 2: 更新 crate 内引用**
全局替换:
- `crate::state::HealthState``crate::state::DialysisState`
- `crate::error::{HealthError, HealthResult}``crate::error::{DialysisError, DialysisResult}`
- `crate::entity::` → 保持不变(同 crate 内)
- `crate::dto::` → 保持不变
- `crate::service::` → 保持不变
- [ ] **Step 3: 创建 error.rs**
```rust
// crates/erp-dialysis/src/error.rs
use erp_core::error::AppError;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DialysisError {
#[error("透析记录未找到: {0}")]
RecordNotFound(uuid::Uuid),
#[error("处方未找到: {0}")]
PrescriptionNotFound(uuid::Uuid),
#[error("状态转换无效: {0} → {1}")]
InvalidStatusTransition(String, String),
#[error("版本冲突")]
VersionConflict,
}
impl From<DialysisError> for AppError {
fn from(e: DialysisError) -> Self { AppError::Business(e.to_string()) }
}
pub type DialysisResult<T> = Result<T, DialysisError>;
```
- [ ] **Step 4: 从 erp-health 删除透析代码**
从以下 mod.rs 中移除透析相关 `pub mod` 声明:
- `crates/erp-health/src/entity/mod.rs`
- `crates/erp-health/src/service/mod.rs`
- `crates/erp-health/src/handler/mod.rs`
- `crates/erp-health/src/dto/mod.rs`
- [ ] **Step 5: 在 erp-server 注册新模块**
`crates/erp-server/src/main.rs` 中:
- 添加 `use erp_dialysis::DialysisModule;`
- 在 registry 链中 `.register(dialysis_module)`
- 在路由 merge 中 `.merge(erp_dialysis::DialysisModule::protected_routes())`
- [ ] **Step 6: 编译 + 全链路验证**
```bash
cargo check --workspace
# 启动后端,验证透析相关 API 正常
```
- [ ] **Step 7: 提交**
```bash
git commit -m "refactor: 透析模块拆分为独立 erp-dialysis crate2 Entity + 2 Service"
```
---
## Chunk 3: P1 事件消费者补全
### Task 6: patient.created → 欢迎消息 + 默认随访
**Files:**
- Modify: `crates/erp-health/src/event.rs`(添加消费者)
- [ ] **Step 1: 在 register_handlers_with_state 中添加 patient.created 消费者**
```rust
// 在 register_handlers_with_state() 中新增:
let (mut patient_rx, _) = state.event_bus.subscribe_filtered("patient.".to_string());
let patient_db = state.db.clone();
tokio::spawn(async move {
loop {
match patient_rx.recv().await {
Some(event) if event.event_type == PATIENT_CREATED => {
if erp_core::events::is_event_processed(&patient_db, event.id, "patient_welcome").await.unwrap_or(false) {
continue;
}
let patient_id = event.payload.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok());
if let Some(pid) = patient_id {
// 1. 发布欢迎消息事件(消息模块消费后发送站内通知)
let welcome_event = DomainEvent::new(
"message.send",
event.tenant_id,
erp_core::events::build_event_payload(serde_json::json!({
"template": "patient_welcome",
"recipient_type": "patient",
"recipient_id": pid,
})),
);
// 2. TODO: 创建默认随访计划(后续迭代)
tracing::info!(patient_id = %pid, "新患者欢迎流程触发");
}
let _ = erp_core::events::mark_event_processed(&patient_db, event.id, "patient_welcome").await;
}
Some(_) => {}
None => break,
}
}
});
```
- [ ] **Step 2: 编译验证**
```bash
cargo check -p erp-health
```
- [ ] **Step 3: 提交**
```bash
git commit -m "feat(health): patient.created 消费者 — 新患者欢迎消息"
```
---
### Task 7: appointment.confirmed/cancelled → 通知 + 号源
**Files:**
- Modify: `crates/erp-health/src/event.rs`
- [ ] **Step 1: 添加 appointment 事件消费者**
```rust
let (mut appt_rx, _) = state.event_bus.subscribe_filtered("appointment.".to_string());
let appt_db = state.db.clone();
tokio::spawn(async move {
loop {
match appt_rx.recv().await {
Some(event) if event.event_type == "appointment.confirmed" => {
if erp_core::events::is_event_processed(&appt_db, event.id, "appointment_notifier").await.unwrap_or(false) {
continue;
}
// 通知医生
let doctor_id = event.payload.get("doctor_id").and_then(|v| v.as_str());
let patient_id = event.payload.get("patient_id").and_then(|v| v.as_str());
if let (Some(did), Some(pid)) = (doctor_id, patient_id) {
tracing::info!(doctor_id = did, patient_id = pid, "预约确认通知触发");
// 发布通知事件
}
let _ = erp_core::events::mark_event_processed(&appt_db, event.id, "appointment_notifier").await;
}
Some(event) if event.event_type == "appointment.cancelled" => {
// 释放号源 + 通知排队患者
tracing::info!(event_id = %event.id, "预约取消,号源释放");
}
Some(_) => {}
None => break,
}
}
});
```
- [ ] **Step 2: 编译 + 提交**
```bash
cargo check -p erp-health
git commit -m "feat(health): appointment 事件消费者 — 预约确认/取消通知"
```
---
### Task 8: follow_up.overdue → 升级通知
**Files:**
- Modify: `crates/erp-health/src/event.rs`
- [ ] **Step 1: 添加 follow_up.overdue 消费者**
```rust
// 在 register_handlers_with_state 中:
// 注意follow_up 事件的前缀是 "follow_up."
let (mut fu_rx, _) = state.event_bus.subscribe_filtered("follow_up.".to_string());
let fu_db = state.db.clone();
tokio::spawn(async move {
loop {
match fu_rx.recv().await {
Some(event) if event.event_type == FOLLOW_UP_OVERDUE => {
if erp_core::events::is_event_processed(&fu_db, event.id, "follow_up_escalator").await.unwrap_or(false) {
continue;
}
let task_id = event.payload.get("task_id").and_then(|v| v.as_str());
let assigned_to = event.payload.get("assigned_to").and_then(|v| v.as_str());
if let (Some(tid), Some(uid)) = (task_id, assigned_to) {
// 通知随访负责人 + 科室主管
tracing::warn!(task_id = tid, assigned_to = uid, "随访逾期升级通知");
}
let _ = erp_core::events::mark_event_processed(&fu_db, event.id, "follow_up_escalator").await;
}
Some(_) => {}
None => break,
}
}
});
```
- [ ] **Step 2: 编译 + 提交**
```bash
cargo check -p erp-health
git commit -m "feat(health): follow_up.overdue 消费者 — 逾期随访升级通知"
```
---
## 执行摘要
| Chunk | Tasks | 内容 | 预估 |
|-------|-------|------|------|
| 1 | T1-T3 | WASM 评估量表插件PHQ-9 | 1-2 天 |
| 2 | T4-T5 | 透析模块拆 erp-dialysis | 1 天 |
| 3 | T6-T8 | P1 事件消费者补全3 个) | 0.5-1 天 |
**总计 8 个 Task预估 2.5-4 天。**
**依赖关系:**
- T1→T2→T3 串行WASM 插件逐层构建)
- T4→T5 串行(先骨架再迁移)
- T6/T7/T8 可并行(独立消费者)
- Chunk 1/2/3 相互独立,可完全并行
**与技术债计划的关系:**
- 本计划的 Chunk 3事件消费者应在技术债批次 BEventBus dead-letter之后执行
- Chunk 2透析拆分应在技术债批次 A安全修复之后执行避免合并冲突
- Chunk 1WASM 插件)完全独立,随时可执行

View File

@@ -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:4bSSE 分析可用 |
### 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` | 无 panicSwagger 可访问 |
| 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 个 ProfileWeb 端两个角色并行),或双屏方案 |
| 手机 | 安装微信,可扫小程序码(备用:开发者工具投屏) |
| 服务器 | 后端 + 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 ProfileProfile A: nurse1/adminProfile 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 秒建档。体征数据和化验报告立刻进入系统。"
**突出能力:** 快速建档、结构化体征录入、化验单数字化
---
### 场景 2AI 自动分析Day 1 下午)— 系统自动触发
**登录:** `admin` 或任意管理端账号 | **时长:** ~4 分钟
**操作步骤:**
1. 展示「AI 分析」页面 → 显示张大爷的分析结果
2. 点击分析详情 → 展示 AI 输出:
- "肌酐值 88→102 μmol/L3 个月持续上升趋势"
- "建议:加做肾功能全套检查,排除 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 分钟前最终冒烟 |

View File

@@ -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先修 CRITICALToken 竞态再验证关键链路告警、AI然后预置数据最后全链路冒烟。每个 Task 独立可提交。
**Tech Stack:** Rust (SeaORM + Axum), TypeScript/React (Web 前端), SQL (数据预置), Taro (小程序)
**Spec:** `docs/superpowers/specs/2026-05-09-v1-customer-demo-plan-design.md`
---
## File Structure
| 操作 | 文件 | 职责 |
|------|------|------|
| Modify | `crates/erp-auth/src/service/token_service.rs:156-176` | revoke 改为原子操作 |
| Modify | `crates/erp-auth/src/service/auth_service.rs:187-258` | refresh 流程使用原子 revoke |
| Create | `crates/erp-server/tests/integration/auth_concurrent_tests.rs` | 并发刷新测试 |
| Create | `scripts/demo-seed.sql` | 演示数据预置脚本 |
| Verify | `crates/erp-health/src/service/seed.rs` | 确认告警规则覆盖演示场景 |
| Verify | `apps/web/src/pages/health/components/LabReportsTab.tsx:36-57` | 确认 AI 触发按钮可用 |
---
## Chunk 1: Token 刷新竞态修复
### Task 1: 修复 Token 刷新并发竞态CRITICAL
**Files:**
- Modify: `crates/erp-auth/src/service/token_service.rs` — 新增 `revoke_by_hash_atomic` 方法
- Modify: `crates/erp-auth/src/service/auth_service.rs:193-197` — refresh 中改用原子操作
- Create: `crates/erp-server/tests/integration/auth_concurrent_tests.rs`
**设计说明:** JWT claims 中没有 token 数据库 ID`id` 列),只有 `sub`(user_id) 和 `tid`(tenant_id)。因此原子 CAS 应该使用 `token_hash` 作为匹配条件——先用 JWT 解码获取原始 token计算 SHA-256 哈希,再用 `UPDATE WHERE token_hash = ? AND revoked_at IS NULL` 做原子操作。这样不需要修改 JWT 结构。
- [ ] **Step 1: 在 token_service.rs 新增 `revoke_by_hash_atomic` 方法**
`crates/erp-auth/src/service/token_service.rs` 第 176 行(`revoke_token` 方法之后)新增:
```rust
/// 原子操作:通过 token_hash 验证并撤销 refresh token。
/// 如果 token 已被撤销rows_affected == 0返回 AuthError::TokenRevoked。
pub async fn revoke_by_hash_atomic(
db: &DatabaseConnection,
token_hash: &str,
user_id: Uuid,
) -> AuthResult<()> {
use user_token::Entity as UserToken;
let result = UserToken::update_many()
.col_expr(
user_token::Column::RevokedAt,
sea_orm::sea_query::Expr::value(Some(chrono::Utc::now().naive_utc())),
)
.filter(user_token::Column::TokenHash.eq(token_hash))
.filter(user_token::Column::UserId.eq(user_id))
.filter(user_token::Column::RevokedAt.is_null())
.exec(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if result.rows_affected == 0 {
return Err(AuthError::TokenRevoked);
}
Ok(())
}
```
需要新增导入:`use sea_orm::sea_query::Expr;`(参考 `consultation_service.rs:683` 的模式)
- [ ] **Step 2: 改造 auth_service.rs 的 refresh 流程**
`crates/erp-auth/src/service/auth_service.rs:193-197`,将当前的 validate + revoke 两步替换:
```rust
// 旧代码(第 193-197 行):
// let claims = TokenService::validate_refresh_token(&self.token, &self.db).await?;
// TokenService::revoke_token(&self.db, &claims.token_id, claims.user_id).await?;
// 新代码:
// 1. JWT 解码获取 claims不查数据库
let claims = TokenService::decode_refresh_token(&self.token)?;
// 2. 计算 token 的 SHA-256 哈希
let token_hash = TokenService::hash_token(&self.token);
// 3. 原子操作:通过 hash 验证 + 撤销CAS
TokenService::revoke_by_hash_atomic(&self.db, &token_hash, claims.sub.parse()?).await?;
// 4. 后续:查询用户角色权限(第 200-201 行,不变)
```
注意:需要确认 `decode_refresh_token`(仅 JWT 解码)和 `hash_token`SHA-256 计算)是否已是公开方法。如果 `validate_refresh_token` 内部已有这些逻辑,需要拆分为独立方法。
- [ ] **Step 3: 编译检查**
Run: `cargo check --package erp-auth`
Expected: 编译通过,无错误
- [ ] **Step 4: 写并发刷新测试**
`crates/erp-server/tests/integration/auth_concurrent_tests.rs` 中:
```rust
use crate::test_db::TestDb;
use erp_auth::service::auth_service::AuthService;
use erp_core::config::JwtConfig;
async fn setup_test_user(db: &TestDb) -> (Uuid, String, String) {
// 创建测试用户,返回 (user_id, access_token, refresh_token)
// 复用现有集成测试中的用户创建逻辑
}
#[tokio::test]
async fn test_refresh_rotates_token() {
let db = TestDb::new().await;
let (_user_id, _, refresh_token) = setup_test_user(&db).await;
let jwt_config = JwtConfig::default();
let svc = AuthService::new(db.conn(), &jwt_config);
// 第一次 refresh → 成功
let result = svc.refresh(&refresh_token).await;
assert!(result.is_ok(), "第一次 refresh 应成功");
let new_tokens = result.unwrap();
// 用旧 refresh_token 再次 refresh → 必须失败
let result2 = svc.refresh(&refresh_token).await;
assert!(result2.is_err(), "旧 token 必须不可用");
}
#[tokio::test]
async fn test_concurrent_refresh_token_reuse() {
let db = TestDb::new().await;
let (_user_id, _, refresh_token) = setup_test_user(&db).await;
let jwt_config = JwtConfig::default();
let svc = AuthService::new(db.conn(), &jwt_config);
let token_clone = refresh_token.clone();
let svc_clone = // 需要确认 AuthService 是否可 Clone 或用 Arc
// 使用 tokio::spawn 并发发两个 refresh
let handle1 = tokio::spawn(async move { svc.refresh(&refresh_token).await });
let handle2 = tokio::spawn(async move { svc_clone.refresh(&token_clone).await });
let r1 = handle1.await.unwrap();
let r2 = handle2.await.unwrap();
// 恰好一个成功、一个失败
let ok_count = [&r1, &r2].iter().filter(|r| r.is_ok()).count();
assert_eq!(ok_count, 1, "并发 refresh 中恰好一个成功,另一个失败");
}
```
- [ ] **Step 5: 运行全部认证测试**
Run: `cargo test --package erp-auth`
Expected: 全部通过
Run: `cargo test --package erp-server --test integration auth`
Expected: 全部通过
Run: `cargo test --package erp-server --test integration auth_concurrent -- --nocapture`
Expected: 两个测试 PASS
- [ ] **Step 6: Commit**
```bash
git add crates/erp-auth/src/service/token_service.rs \
crates/erp-auth/src/service/auth_service.rs \
crates/erp-server/tests/integration/auth_concurrent_tests.rs
git commit -m "fix(auth): 修复 Token 刷新并发竞态条件
使用原子 CASUPDATE WHERE token_hash = ? AND revoked_at IS NULL
替代先查后改的非原子操作,防止同一 refresh token 被并发使用两次。"
```
---
## Chunk 2: 演示链路验证
### Task 2: 验证告警链路(场景 5 依赖)
**Files:**
- Verify: `crates/erp-health/src/service/seed.rs` — 确认告警规则
- Verify: `apps/web/src/pages/health/AlertDashboard.tsx:51` — 确认权限码
- Verify: `crates/erp-health/src/handler/alert_handler.rs:82-115` — 确认操作端点
- [ ] **Step 1: 启动后端服务**
Run: `cd crates/erp-server && cargo run`
Expected: 服务无 panic 启动在 localhost:3000
- [ ] **Step 2: 查询已有告警规则**
Run: `curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:3000/api/v1/health/alert-rules | jq '.data.items[] | {name, metric, operator, threshold}'`
Expected: 返回 10 条默认规则,包括:
- 收缩压偏高 (>=140)
- 收缩压危急 (>=180)
场景 5 需要"张大爷录入血压 168 触发告警"→ 使用已有的"收缩压危急 >=180"不够,需要调整场景 5 话术用血压 185或添加一条 >=160 的规则。
- [ ] **Step 3: 手动测试告警触发**
```
1. 以 nurse1 登录 Web
2. 找到张大爷患者详情页
3. 录入体征:收缩压 185 / 舒张压 95
4. 切到告警仪表盘页面
5. 确认出现告警条目
6. 点击「确认」→ 状态变为已确认
7. 点击「处理」→ 输入备注 → 状态变为已处理
```
Expected: 全流程无 403、无 500
- [ ] **Step 4: 记录验证结果**
在文件头部注释验证结果。如果告警权限码正确(`health.alerts.manage`),记录为 ✅。
如果发现任何问题,记录具体报错信息,新建 Task 修复。
---
### Task 3: 验证 AI 分析触发(场景 2 依赖)
**Files:**
- Verify: `apps/web/src/pages/health/components/LabReportsTab.tsx:177-183`
- Verify: `apps/web/src/api/ai/analysisSse.ts`
- [ ] **Step 1: 确认 Ollama 模型就绪**
Run: `ollama list`
Expected: 输出包含 `qwen3:4b`
如果没有:`ollama pull qwen3:4b`
- [ ] **Step 2: 手动触发 AI 分析**
```
1. 以 admin 登录 Web
2. 进入张大爷患者详情页
3. 切到「化验报告」Tab
4. 找到一条化验报告
5. 点击「AI 解读」按钮
6. 等待 SSE 流式输出
```
Expected: AI 分析结果流式显示,无 500 错误
如果 AI 解读按钮不存在或化验报告为空 → 使用预案(预置截图),在脚本中标注
- [ ] **Step 3: 预置 AI 分析截图(预案)**
如果 AI 分析成功:截图保存到 `docs/demo/screenshots/ai-analysis.png`
如果 AI 分析失败:在实施计划中标注使用备用话术
---
### Task 4: 验证 health_manager 测试账号
**Files:**
- Verify: 数据库 `users`
- Reference: `crates/erp-server/migration/src/m20260506_000125_restructure_menus_and_roles.rs:123-126`
- [ ] **Step 1: 查询 health_manager 角色是否存在**
Run: `docker exec erp-postgres psql -U erp -c "SELECT id, name, code FROM roles WHERE code = 'health_manager'"`
Expected: 返回 1 行
- [ ] **Step 2: 查询是否有测试用户关联此角色**
Run: `docker exec erp-postgres psql -U erp -c "SELECT u.username, r.code FROM users u JOIN user_roles ur ON u.id = ur.user_id JOIN roles r ON ur.role_id = r.id WHERE r.code = 'health_manager'"`
Expected: 返回至少 1 个用户
如果没有用户:需要通过 Web 管理界面创建一个 `health_mgr` 用户并分配 `health_manager` 角色
- [ ] **Step 3: 验证 health_manager 用户可登录**
用 health_manager 用户名 + `Admin@2026` 密码尝试登录 Web 端。
Expected: 成功登录,工作台显示「任务工作台」
---
## Chunk 3: 演示数据预置
### Task 5: 编写演示数据预置脚本
**Files:**
- Create: `scripts/demo-seed.sql`
脚本目标:一键预置以下数据(幂等,可重复执行):
- 张建国患者档案 + 2 份历史化验单(肌酐 88→102
- 20-30 个背景患者(让仪表盘有数据)
- 若干随访任务/告警记录(让仪表盘统计有意义)
- 3 篇 CKD 健康科普文章
- 收缩压 >=160 告警规则(如 seed 中没有)
- [ ] **Step 1: 编写 SQL 脚本骨架**
`scripts/demo-seed.sql` 中:
```sql
-- HMS V1 Demo Data Seed
-- 用法: docker exec -i erp-postgres psql -U erp < scripts/demo-seed.sql
-- 幂等:使用 ON CONFLICT DO NOTHING
-- 1. 确保租户 ID从现有租户获取
-- 2. 张建国患者档案
-- 3. 2 份历史化验单3 个月前 肌酐 881 个月前 肌酐 102
-- 4. 20 个背景患者(随机姓名,基础体征数据)
-- 5. 若干随访任务不同状态pending/completed
-- 6. 若干告警记录不同状态pending/acknowledged/resolved
-- 7. 3 篇 CKD 科普文章
-- 8. 收缩压 >=160 告警规则
```
注意:所有 INSERT 需包含 `tenant_id``created_at``updated_at``created_by``updated_by``version``id`UUID v7字段。参考现有 Entity 的字段结构。
- [ ] **Step 2: 编写张建国患者 + 化验单数据**
```sql
-- 患者档案
INSERT INTO patients (id, tenant_id, name, gender, birth_date, phone, ...)
VALUES (
'019dcd34-bc4d-72c1-8c19-77ce1f4839d6', -- 使用已知测试患者 ID
(SELECT id FROM tenants LIMIT 1),
'张建国', 'male', '1961-03-15', '13800138001', ...
) ON CONFLICT (id) DO NOTHING;
-- 化验单 13 个月前 肌酐 88
INSERT INTO lab_reports (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
-- 化验单 1 的 items肌酐 88 μmol/L
INSERT INTO lab_report_items (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
-- 化验单 21 个月前 肌酐 102
INSERT INTO lab_reports (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
-- 化验单 2 的 items肌酐 102 μmol/L
INSERT INTO lab_report_items (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
```
- [ ] **Step 3: 编写背景患者批量数据**
使用 SQL generate_series 生成 20-30 个虚拟患者:
```sql
INSERT INTO patients (id, tenant_id, name, gender, birth_date, ...)
SELECT
gen_random_uuid(),
(SELECT id FROM tenants LIMIT 1),
'测试患者' || i,
CASE WHEN i % 2 = 0 THEN 'male' ELSE 'female' END,
CURRENT_DATE - (30 + (i * 37) % 50) * INTERVAL '1 year',
...
FROM generate_series(1, 25) AS i
ON CONFLICT DO NOTHING;
```
- [ ] **Step 4: 编写随访任务和告警记录**
为背景患者生成不同状态的随访任务和告警记录,让仪表盘统计有意义。
- [ ] **Step 5: 编写科普文章和告警规则**
```sql
-- 3 篇 CKD 科普文章
INSERT INTO articles (title, content, category, status, ...) VALUES
('慢性肾病患者的饮食指南', '...', 'nutrition', 'published', ...),
('CKD 患者运动建议', '...', 'exercise', 'published', ...),
('慢性肾病常用药物说明', '...', 'medication', 'published', ...)
ON CONFLICT DO NOTHING;
-- 收缩压 >=160 告警规则(如果 seed 中没有)
INSERT INTO alert_rules (name, metric, operator, threshold, ...)
VALUES ('收缩压偏高(演示用)', 'systolic_bp', '>=', 160, ...)
ON CONFLICT DO NOTHING;
```
- [ ] **Step 6: 执行脚本验证**
Run: `docker exec -i erp-postgres psql -U erp < scripts/demo-seed.sql`
Expected: 无错误,所有 INSERT 成功或 ON CONFLICT 跳过
- [ ] **Step 7: 验证数据完整性**
```
1. 查询张建国患者SELECT * FROM patients WHERE name = '张建国'
2. 查询化验单数量SELECT count(*) FROM lab_reports WHERE patient_id = ...
3. 查询背景患者数SELECT count(*) FROM patients WHERE name LIKE '测试患者%'
4. 查询文章数SELECT count(*) FROM articles WHERE status = 'published'
Expected: 1 张建国 + 2 化验单 + 25 背景患者 + 3 文章
```
- [ ] **Step 8: Commit**
```bash
git add scripts/demo-seed.sql
git commit -m "chore(demo): V1 演示数据预置脚本
一键预置张建国患者+化验单+25背景患者+随访+告警+科普文章。
幂等设计,可重复执行。"
```
---
## Chunk 4: 全链路 DRY RUN
### Task 6: 端到端 DRY RUN7 个场景)
**前置条件:** Task 1-5 全部完成
- [ ] **Step 1: 环境启动检查**
```bash
# 1. PostgreSQL
docker exec erp-postgres pg_isready
# 2. 后端
curl -s http://localhost:3000/api/v1/auth/health | jq .
# 3. Web 前端
curl -s -o /dev/null -w "%{http_code}" http://localhost:5174
# 4. Ollama
ollama list | grep qwen3
```
Expected: 全部 200/ready
- [ ] **Step 2: 场景 1 — 护士建档**
登录 nurse1 → 新建患者/查找张建国 → 录入体征 → 查看化验报告
Expected: 全流程无报错
- [ ] **Step 3: 场景 2 — AI 分析**
进入张建国化验报告 → 点击 AI 解读(或展示预置结果)
Expected: AI 输出正常或截图备用
- [ ] **Step 4: 场景 3 — 医生审批**
登录 doctor1 → 查看 AI 建议 → 同意 → 查看随访任务
Expected: 随访任务自动生成
- [ ] **Step 5: 场景 4 — 小程序**
打开小程序(开发者工具)→ 查看消息/随访 → 填写问卷 → 查看趋势
Expected: 页面正常渲染,数据正确
- [ ] **Step 6: 场景 5 — 告警**
小程序录入血压 185/95 → Web nurse1 查看告警 → 确认 → 处理
Expected: 告警实时出现,可操作
- [ ] **Step 7: 场景 6 — 随访**
登录 health_manager → 查看随访任务 → 执行 → 录入记录
Expected: 随访完成,状态更新
- [ ] **Step 8: 场景 7 — 仪表盘**
登录 admin → 查看统计仪表盘 → 查看文章 → 查看积分
Expected: 数据有意义(非零)
- [ ] **Step 9: 记录 DRY RUN 结果**
`docs/qa/demo-dry-run-results.md` 中记录每个场景的结果:
- ✅ 通过 / ❌ 失败(附具体错误)
- 阻塞问题 → 新建 Task 修复
- 可跳过场景标注
- [ ] **Step 10: Commit DRY RUN 报告**
```bash
git add docs/qa/demo-dry-run-results.md
git commit -m "docs: V1 Demo DRY RUN 结果报告"
```