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:
@@ -0,0 +1,618 @@
|
||||
# ERP Platform Base - Design Specification
|
||||
|
||||
**Date:** 2026-04-10
|
||||
**Status:** Draft (Review Round 2)
|
||||
**Author:** Claude + User
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Build a commercial SaaS ERP product from scratch using a "platform base + industry plugins" architecture. The base provides core infrastructure (auth, workflow, messaging, configuration), enabling rapid deployment of industry-specific modules (inventory, manufacturing, finance, HR, etc.) on top.
|
||||
|
||||
The system targets progressive scaling: start with small businesses, expand to mid and large enterprises. Multi-tenant SaaS deployment is the default, with private deployment as an option.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Overall: Modular Monolith (Progressive)
|
||||
|
||||
Start as a single Rust backend service with well-defined module boundaries. Modules communicate through an internal event bus and shared traits. When a module needs independent scaling, it can be extracted into a standalone service without changing interfaces.
|
||||
|
||||
### System Layers
|
||||
|
||||
```
|
||||
Web Frontend (Vite + React 18 + Ant Design 5)
|
||||
├── Shell / Layout / Navigation
|
||||
└── Module UI (dynamically loaded per tenant config)
|
||||
│
|
||||
API Layer (REST + WebSocket)
|
||||
│
|
||||
Rust Backend Service (Axum + Tokio)
|
||||
├── Auth Module (identity, roles, permissions, tenants)
|
||||
├── Workflow Engine (BPMN processes, tasks, approvals)
|
||||
├── Message Center (notifications, templates, channels)
|
||||
└── Config Module (menus, dictionaries, settings, numbering)
|
||||
│
|
||||
Core Shared Layer (tenant context, audit, events, caching)
|
||||
│
|
||||
PostgreSQL (primary) + Redis (cache + session + pub/sub)
|
||||
```
|
||||
|
||||
> **Note:** Tauri 桌面端为可选方案,未来行业模块(如工厂仓库)需要硬件集成时启用。主力前端为 Web SPA。
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Module isolation**: Each business module is an independent Rust crate, interfaces defined via traits
|
||||
2. **Multi-tenant built-in**: All data tables include `tenant_id`, middleware auto-injects tenant context
|
||||
3. **Event-driven**: Modules communicate via event bus, no direct coupling
|
||||
4. **Plugin extensibility**: Industry modules register through standard interfaces, support dynamic enable/disable
|
||||
|
||||
### Error Handling Strategy
|
||||
|
||||
```rust
|
||||
// erp-core defines the unified error hierarchy
|
||||
// Uses thiserror for typed errors across crate boundaries
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
// Axum IntoResponse impl maps to HTTP status codes
|
||||
// Validation errors include field-level detail for UI rendering
|
||||
```
|
||||
|
||||
**Decision**: `thiserror` for crate boundaries (typed, catchable), `anyhow` never crosses crate boundaries (internal use only for prototyping). Each module defines its own error variants that wrap `AppError`.
|
||||
|
||||
### Event Bus Specification
|
||||
|
||||
```
|
||||
EventBus (tokio::sync::broadcast based, in-process)
|
||||
|
||||
Event {
|
||||
id: UUID v7
|
||||
event_type: String (e.g., "user.created", "workflow.task.completed")
|
||||
tenant_id: UUID
|
||||
payload: serde_json::Value
|
||||
timestamp: DateTime<Utc>
|
||||
correlation_id: UUID (for tracing)
|
||||
}
|
||||
|
||||
Delivery Guarantees:
|
||||
- At-least-once delivery within the process
|
||||
- Events persisted to `domain_events` table before dispatch (outbox pattern)
|
||||
- Failed handlers log to dead-letter storage, trigger alert
|
||||
- No cross-process delivery in Phase 1 (single binary)
|
||||
```
|
||||
|
||||
### Plugin / Module Registration Interface
|
||||
|
||||
```rust
|
||||
// erp-core defines the plugin trait
|
||||
pub trait ErpModule: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> &str;
|
||||
fn dependencies(&self) -> Vec<&str>; // required modules
|
||||
|
||||
fn register_routes(&self, router: Router) -> Router;
|
||||
fn register_event_handlers(&self, bus: &EventBus);
|
||||
fn on_tenant_created(&self, tenant_id: Uuid) -> Result<()>;
|
||||
fn on_tenant_deleted(&self, tenant_id: Uuid) -> Result<()>;
|
||||
}
|
||||
|
||||
// erp-server assembles modules at startup
|
||||
fn build_app(modules: Vec<Box<dyn ErpModule>>) -> Router { ... }
|
||||
```
|
||||
|
||||
Industry modules implement `ErpModule` and are discovered via configuration, not compile-time.
|
||||
|
||||
### API Versioning & Contract Governance
|
||||
|
||||
- Code-first with utoipa: derive OpenAPI from Rust types
|
||||
- Auto-generated Swagger UI at `/docs` in development
|
||||
- `/api/v1/` prefix for all endpoints; v2 only when breaking changes needed
|
||||
- Client sends `X-API-Version: 1` header; server rejects unsupported versions
|
||||
- Tauri client version and server version must be compatible (checked on connect)
|
||||
|
||||
### Concurrency & Transaction Strategy
|
||||
|
||||
- **Optimistic locking**: All mutable entities carry `version` column; updates fail on mismatch
|
||||
- **Idempotency**: Write endpoints accept optional `Idempotency-Key` header
|
||||
- **Cross-module transactions**: Avoided by design; event bus + saga pattern for consistency
|
||||
- **Numbering sequences**: PostgreSQL sequences with `advisory_lock` per tenant per rule
|
||||
|
||||
### Audit Logging
|
||||
|
||||
```
|
||||
AuditLog {
|
||||
id: UUID v7
|
||||
tenant_id: UUID
|
||||
user_id: UUID
|
||||
action: String (e.g., "user.update", "role.create")
|
||||
resource_type: String
|
||||
resource_id: UUID
|
||||
changes: JSONB { before: {}, after: {} }
|
||||
ip_address: String
|
||||
user_agent: String
|
||||
timestamp: DateTime<Utc>
|
||||
}
|
||||
|
||||
Retention: 90 days hot, archive to cold storage after
|
||||
Query API: GET /api/v1/audit-logs?resource_type=user&from=...
|
||||
```
|
||||
|
||||
### Frontend-Backend Communication
|
||||
|
||||
| Aspect | Decision |
|
||||
|--------|----------|
|
||||
| Auth flow | Login via REST → JWT stored in httpOnly cookie (web) → sent as Bearer header |
|
||||
| REST calls | Standard fetch/axios from browser to backend |
|
||||
| WebSocket | Connect on page load, auth via first message with JWT, auto-reconnect with exponential backoff |
|
||||
| File upload/download | Standard HTTP multipart + blob download |
|
||||
| CORS | Whitelist per tenant, deny by default |
|
||||
|
||||
### Security Measures
|
||||
|
||||
- CORS: Whitelist per tenant, deny by default
|
||||
- Rate limiting: Per-IP + per-user via Redis token bucket
|
||||
- Secret management: Environment variables + vault (HashiCorp Vault for production)
|
||||
- Data encryption: TLS in transit, AES-256 at rest for PII fields (optional per tenant config)
|
||||
- Input validation: Schema-based (JSON Schema for complex inputs, types for simple)
|
||||
- SQL injection: Prevented by SeaORM parameterized queries
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend (Rust)
|
||||
|
||||
| Component | Choice | Rationale |
|
||||
|-----------|--------|-----------|
|
||||
| Web framework | Axum 0.8 | Tokio-team maintained, best ecosystem |
|
||||
| Async runtime | Tokio | Rust async standard |
|
||||
| ORM | SeaORM | Async, type-safe, migration support |
|
||||
| DB migration | SeaORM Migration | Versioned schema management |
|
||||
| Cache | redis-rs | Official Redis client |
|
||||
| JWT | jsonwebtoken | Lightweight, reliable |
|
||||
| Serialization | serde + serde_json | Rust standard |
|
||||
| Logging | tracing + tracing-subscriber | Structured logging |
|
||||
| Config | config-rs | Multi-format support |
|
||||
| API docs | utoipa (OpenAPI 3) | Auto-generate Swagger |
|
||||
| Testing | Built-in + tokio-test | Unit + integration |
|
||||
|
||||
### Web Frontend
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Build tool | Vite 6 |
|
||||
| UI framework | React 18 + TypeScript |
|
||||
| Component library | Ant Design 5 |
|
||||
| State management | Zustand |
|
||||
| Routing | React Router 7 |
|
||||
| Styling | TailwindCSS + CSS Variables |
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| Primary database | PostgreSQL 16+ |
|
||||
| Cache / Session / PubSub | Redis 7+ |
|
||||
| Containerization | Docker + Docker Compose (dev) |
|
||||
|
||||
---
|
||||
|
||||
## Crate Structure
|
||||
|
||||
```
|
||||
erp/
|
||||
├── crates/
|
||||
│ ├── erp-core/ # Shared: error handling, types, traits, events
|
||||
│ ├── erp-auth/ # Identity & permissions module
|
||||
│ ├── erp-workflow/ # Workflow engine module
|
||||
│ ├── erp-message/ # Message center module
|
||||
│ ├── erp-config/ # System configuration module
|
||||
│ ├── erp-server/ # Axum server entry, assembles all modules
|
||||
│ └── erp-common/ # Shared utilities, macros
|
||||
├── apps/
|
||||
│ └── web/ # Vite + React SPA (primary frontend)
|
||||
├── desktop/ # (Optional) Tauri desktop, enabled per industry need
|
||||
├── packages/
|
||||
│ └── ui-components/ # React shared component library
|
||||
├── migrations/ # Database migrations
|
||||
├── docs/ # Documentation
|
||||
└── docker/ # Docker configurations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module 1: Identity & Permissions (Auth)
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
Tenant
|
||||
├── Organization
|
||||
│ └── Department
|
||||
│ └── Position
|
||||
├── User
|
||||
│ ├── UserCredential (password / OAuth / SSO)
|
||||
│ ├── UserProfile
|
||||
│ └── UserToken (session)
|
||||
├── Role
|
||||
│ └── Permission
|
||||
└── Policy (ABAC rules)
|
||||
```
|
||||
|
||||
### Permission Model: RBAC + ABAC Hybrid
|
||||
|
||||
- **RBAC**: User -> Role -> Permission, for standard scenarios
|
||||
- **ABAC**: Attribute-based rules (e.g., "department manager can only approve own department's requests")
|
||||
- **Data-level**: Row filtering (e.g., "only see own department's data")
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|------------|
|
||||
| Username/Password | Basic auth, Argon2 hash |
|
||||
| OAuth 2.0 | Third-party login (WeChat, DingTalk, WeCom) |
|
||||
| SSO (SAML/OIDC) | Enterprise SSO, required for private deployment |
|
||||
| TOTP | Two-factor authentication |
|
||||
|
||||
### Key APIs
|
||||
|
||||
```
|
||||
POST /api/v1/auth/login
|
||||
POST /api/v1/auth/logout
|
||||
POST /api/v1/auth/refresh
|
||||
POST /api/v1/auth/revoke # Revoke a specific token
|
||||
GET /api/v1/users
|
||||
POST /api/v1/users
|
||||
PUT /api/v1/users/:id
|
||||
DELETE /api/v1/users/:id (soft delete)
|
||||
GET /api/v1/roles
|
||||
POST /api/v1/roles
|
||||
PUT /api/v1/roles/:id
|
||||
DELETE /api/v1/roles/:id
|
||||
POST /api/v1/roles/:id/permissions
|
||||
GET /api/v1/permissions # List all available permissions
|
||||
GET /api/v1/tenants/:id/users
|
||||
GET /api/v1/organizations
|
||||
POST /api/v1/organizations
|
||||
PUT /api/v1/organizations/:id
|
||||
DELETE /api/v1/organizations/:id
|
||||
GET /api/v1/organizations/:id/departments
|
||||
POST /api/v1/organizations/:id/departments
|
||||
GET /api/v1/positions
|
||||
POST /api/v1/positions
|
||||
GET /api/v1/policies
|
||||
POST /api/v1/policies
|
||||
PUT /api/v1/policies/:id
|
||||
DELETE /api/v1/policies/:id
|
||||
```
|
||||
|
||||
### Multi-tenant Isolation
|
||||
|
||||
- **Default**: Shared database + `tenant_id` column isolation (cost-optimal)
|
||||
- **Switchable**: Independent schema per tenant (for private deployment)
|
||||
- Middleware auto-injects `tenant_id`, application code is tenant-agnostic
|
||||
|
||||
### Multi-tenant Migration Strategy
|
||||
|
||||
- Schema migrations run once globally, affect all tenants' rows
|
||||
- New tenant provisioning: seed data script (default roles, admin user, org structure, menus)
|
||||
- Migrations are versioned and idempotent; failed migrations halt startup
|
||||
- Per-tenant data migrations (e.g., adding default config) trigger on `on_tenant_created` hook
|
||||
|
||||
---
|
||||
|
||||
## Module 2: Workflow Engine
|
||||
|
||||
### Design Goals
|
||||
|
||||
- BPMN 2.0 **subset** compatible visual process designer
|
||||
- Low latency, high throughput (Rust advantage)
|
||||
- Support conditional branches, parallel gateways, sub-processes
|
||||
- Embeddable into any business module
|
||||
|
||||
### BPMN Subset Scope (Phase 4)
|
||||
|
||||
**Included in Phase 4:**
|
||||
- Start/End events
|
||||
- User Tasks (with assignee, candidate groups)
|
||||
- Service Tasks (HTTP call, script execution)
|
||||
- Exclusive Gateways (conditional branching)
|
||||
- Parallel Gateways (fork/join)
|
||||
- Sequence Flows with conditions
|
||||
- Process variables (basic types: string, number, boolean, date)
|
||||
|
||||
**Deferred to later phases:**
|
||||
- Inclusive Gateways
|
||||
- Sub-Processes (call activity)
|
||||
- Timer events (intermediate, boundary)
|
||||
- Signal/Message events
|
||||
- Error boundary events
|
||||
- Multi-instance (loop) activities
|
||||
- Data objects and stores
|
||||
|
||||
### Core Concepts
|
||||
|
||||
```
|
||||
ProcessDefinition
|
||||
├── Node Types
|
||||
│ ├── StartNode
|
||||
│ ├── EndNode
|
||||
│ ├── UserTask (human task)
|
||||
│ ├── ServiceTask (system task)
|
||||
│ ├── Gateway (exclusive / parallel / inclusive)
|
||||
│ └── SubProcess
|
||||
├── Flow (connections)
|
||||
│ └── Condition (expressions)
|
||||
└── ProcessInstance
|
||||
├── Token (tracks execution position)
|
||||
├── Task (pending tasks)
|
||||
└── Variable (process variables)
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|------------|
|
||||
| Visual designer | React flowchart editor, drag-and-drop |
|
||||
| Condition expressions | EL expressions: `amount > 10000 && dept == "finance"` |
|
||||
| Countersign / Or-sign | Multi-person approval: all approve / any approve |
|
||||
| Delegate / Transfer | Tasks can be delegated to others |
|
||||
| Reminder / Timeout | Auto-remind, auto-handle on timeout |
|
||||
| Version management | Process definitions versioned, running instances use old version |
|
||||
|
||||
### Key APIs
|
||||
|
||||
```
|
||||
POST /api/v1/workflow/definitions
|
||||
GET /api/v1/workflow/definitions/:id
|
||||
PUT /api/v1/workflow/definitions/:id
|
||||
POST /api/v1/workflow/instances
|
||||
GET /api/v1/workflow/instances/:id
|
||||
GET /api/v1/workflow/tasks (my pending)
|
||||
POST /api/v1/workflow/tasks/:id/approve
|
||||
POST /api/v1/workflow/tasks/:id/reject
|
||||
POST /api/v1/workflow/tasks/:id/delegate
|
||||
GET /api/v1/workflow/instances/:id/diagram (highlighted)
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
- **Auth**: Task assignment based on roles/org structure
|
||||
- **Message**: Pending task notifications, reminders, approval results
|
||||
- **Config**: Process categories, numbering rules
|
||||
|
||||
---
|
||||
|
||||
## Module 3: Message Center
|
||||
|
||||
### Message Channels
|
||||
|
||||
| Channel | Use Case |
|
||||
|---------|----------|
|
||||
| In-app notifications | Foundation for all messages |
|
||||
| WebSocket | Real-time push, instant desktop alerts |
|
||||
| Email | Important approvals, scheduled reports |
|
||||
| SMS | Verification codes, urgent alerts |
|
||||
| WeCom / DingTalk | Enterprise messaging integration |
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
MessageTemplate
|
||||
├── Channel type
|
||||
├── Template content (variable interpolation: {{user_name}})
|
||||
└── Multi-language versions
|
||||
|
||||
Message
|
||||
├── Sender (system / user)
|
||||
├── Recipient (user / role / department / all)
|
||||
├── Priority (normal / important / urgent)
|
||||
├── Read status
|
||||
└── Business reference (deep link to specific page)
|
||||
|
||||
MessageSubscription
|
||||
├── User notification preferences
|
||||
├── Do-not-disturb periods
|
||||
└── Channel preferences (e.g., approvals via in-app + WeCom, reports via email)
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Message aggregation**: Group similar messages (e.g., "You have 5 pending approvals")
|
||||
- **Read/unread**: Read receipts, unread count query
|
||||
- **Message recall**: Sender can recall unread messages
|
||||
- **Scheduled sending**: Set delivery time
|
||||
- **Message archive**: Auto-archive history, searchable
|
||||
|
||||
### Key APIs
|
||||
|
||||
```
|
||||
GET /api/v1/messages (list with pagination)
|
||||
GET /api/v1/messages/unread-count
|
||||
PUT /api/v1/messages/:id/read
|
||||
PUT /api/v1/messages/read-all
|
||||
DELETE /api/v1/messages/:id
|
||||
POST /api/v1/messages/send
|
||||
GET /api/v1/message-templates
|
||||
POST /api/v1/message-templates
|
||||
PUT /api/v1/message-subscriptions (update preferences)
|
||||
WS /ws/v1/messages (real-time push)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module 4: System Configuration
|
||||
|
||||
### Configuration Hierarchy
|
||||
|
||||
```
|
||||
Platform (global)
|
||||
└── Tenant
|
||||
└── Organization
|
||||
└── User
|
||||
```
|
||||
|
||||
Lower-level overrides higher-level. Priority: User > Organization > Tenant > Platform.
|
||||
|
||||
### Capabilities
|
||||
|
||||
| Capability | Description |
|
||||
|-----------|------------|
|
||||
| Dynamic menus | Tenants customize menu structure, display by role |
|
||||
| Data dictionaries | System-level and tenant-level enum management |
|
||||
| Numbering rules | Document number generation with concurrency-safe sequences |
|
||||
| Multi-language | i18n resource management, runtime switching |
|
||||
| System parameters | Key-value general configuration |
|
||||
| Theme customization | Tenant-level UI theme (colors, logo) |
|
||||
|
||||
### Key APIs
|
||||
|
||||
```
|
||||
GET /api/v1/config/menus # Tenant from middleware
|
||||
PUT /api/v1/config/menus
|
||||
GET /api/v1/config/dictionaries
|
||||
POST /api/v1/config/dictionaries
|
||||
PUT /api/v1/config/dictionaries/:id
|
||||
GET /api/v1/config/settings/:key
|
||||
PUT /api/v1/config/settings/:key
|
||||
GET /api/v1/config/numbering-rules
|
||||
POST /api/v1/config/numbering-rules
|
||||
PUT /api/v1/config/numbering-rules/:id
|
||||
GET /api/v1/config/languages
|
||||
PUT /api/v1/config/languages/:code
|
||||
GET /api/v1/config/themes # Tenant theme
|
||||
PUT /api/v1/config/themes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Design Principles
|
||||
|
||||
- All tables include: `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`
|
||||
- Soft delete via `deleted_at` (no hard deletes)
|
||||
- UUID v7 as primary keys (time-sortable + unique)
|
||||
- JSONB columns for flexible extension data
|
||||
- Indexes on `tenant_id` + business keys for multi-tenant queries
|
||||
|
||||
---
|
||||
|
||||
## Web UI Design
|
||||
|
||||
### Layout
|
||||
|
||||
Classic SaaS admin panel layout (responsive, mobile-friendly):
|
||||
|
||||
```
|
||||
+----------------------------------------------+
|
||||
| LOGO Search... 🔔 5 👤 Admin ▾ | ← Top nav bar
|
||||
+--------+-------------------------------------+
|
||||
| Home | |
|
||||
| Users | Main Content Area |
|
||||
| Roles | (Dynamic per menu selection) |
|
||||
| Flows | Multi-tab support |
|
||||
| Messages| |
|
||||
| Settings| |
|
||||
|--------| |
|
||||
| Inv. | |
|
||||
| Mfg. | |
|
||||
| Finance| |
|
||||
|--------| |
|
||||
| More > | |
|
||||
+--------+-------------------------------------+
|
||||
```
|
||||
|
||||
### Key UI Features
|
||||
|
||||
- **Collapsible sidebar**: Multi-level menus, grouped (base modules / industry modules)
|
||||
- **Multi-tab content**: Switch between open pages like browser tabs
|
||||
- **Global search**: Search menus, users, documents
|
||||
- **Notification panel**: Click bell icon to expand message list
|
||||
- **Dark/Light theme**: Toggle support, follow system preference
|
||||
- **Responsive**: Mobile/tablet adaptive layout
|
||||
- **Browser notifications**: Web Notification API for real-time alerts
|
||||
|
||||
---
|
||||
|
||||
## Development Roadmap
|
||||
|
||||
### Phase 1 - Foundation (2-3 weeks)
|
||||
- Rust workspace scaffolding + Vite + React setup
|
||||
- erp-core: error types, shared types, trait definitions, event bus
|
||||
- ErpModule trait + module registration system
|
||||
- Database migration framework (SeaORM) with tenant provisioning
|
||||
- Docker dev environment (PostgreSQL + Redis)
|
||||
- CI/CD pipeline setup
|
||||
|
||||
### Phase 2 - Identity & Permissions (2-3 weeks)
|
||||
- User, Role, Organization, Department, Position CRUD
|
||||
- RBAC + ABAC permission model
|
||||
- JWT auth (access + refresh tokens, token revocation)
|
||||
- httpOnly cookie for web JWT storage
|
||||
- Multi-tenant middleware
|
||||
- Login page UI + user management pages
|
||||
|
||||
### Phase 3 - System Configuration (1-2 weeks)
|
||||
- Data dictionaries
|
||||
- Dynamic menus
|
||||
- System parameters (hierarchical override)
|
||||
- Numbering rules (concurrency-safe PostgreSQL sequences)
|
||||
- i18n framework
|
||||
- Settings pages UI
|
||||
|
||||
### Phase 4 - Workflow Engine (4-6 weeks)
|
||||
- Process definition storage and versioning
|
||||
- BPMN subset parser (start/end, user/service tasks, exclusive/parallel gateways)
|
||||
- Execution engine with token tracking
|
||||
- Task assignment, countersign, delegation
|
||||
- Condition expression evaluator
|
||||
- React visual flowchart designer
|
||||
- Process diagram viewer (highlighted current node)
|
||||
- Reminder and timeout handling
|
||||
|
||||
### Phase 5 - Message Center (2 weeks)
|
||||
- Message templates with variable interpolation
|
||||
- In-app notification CRUD
|
||||
- WebSocket real-time push (auth, reconnect)
|
||||
- Notification panel UI
|
||||
- Message aggregation and read tracking
|
||||
|
||||
### Phase 6 - Integration & Polish (2-3 weeks)
|
||||
- Cross-module integration testing
|
||||
- Audit logging verification
|
||||
- Web app deployment and optimization
|
||||
- Performance optimization
|
||||
- Documentation
|
||||
|
||||
---
|
||||
|
||||
## Verification Plan
|
||||
|
||||
1. **Unit tests**: Each module has comprehensive unit tests (80%+ coverage target)
|
||||
2. **Integration tests**: API endpoint tests against real PostgreSQL/Redis
|
||||
3. **E2E tests**: Desktop client test automation via Tauri WebDriver
|
||||
4. **Multi-tenant tests**: Verify data isolation between tenants
|
||||
5. **Workflow tests**: Full process lifecycle (define -> start -> approve -> complete)
|
||||
6. **Performance benchmarks**: API response time < 100ms (p99), WebSocket push < 50ms
|
||||
7. **Security audit**: OWASP top 10 check before release
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,871 @@
|
||||
# Q2 安全地基 + CI/CD 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 消除所有 CRITICAL/HIGH 安全风险,建立 CI/CD 自动化质量门,完成审计日志补全和 Docker 生产化。
|
||||
|
||||
**Architecture:** 密钥外部化通过环境变量强制注入 + 启动检查拒绝默认值;CI/CD 使用 Gitea Actions 四 job 并行;限流改为 fail-closed;审计日志补全 IP/UA 和变更值。
|
||||
|
||||
**Tech Stack:** Rust (Axum, SeaORM), Gitea Actions, Docker Compose, PostgreSQL 16, Redis 7
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-17-platform-maturity-roadmap-design.md` §2
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Modify | `crates/erp-server/config/default.toml` | 敏感值改为占位符 |
|
||||
| Modify | `crates/erp-server/src/main.rs` | 启动时拒绝默认密钥 |
|
||||
| Modify | `crates/erp-auth/src/module.rs:149-150` | 移除密码 fallback |
|
||||
| Modify | `crates/erp-auth/src/error.rs:46-53` | 移除 `From<AppError> for AuthError` 反向映射 |
|
||||
| Modify | `crates/erp-auth/src/service/auth_service.rs:177-181` | refresh 添加 tenant_id 过滤 |
|
||||
| Modify | `crates/erp-auth/src/service/user_service.rs` | get_by_id/update/delete 改为 DB 级 tenant 过滤 |
|
||||
| Modify | `crates/erp-server/src/middleware/rate_limit.rs:122-124,135-137` | fail-closed |
|
||||
| Modify | `crates/erp-core/src/audit.rs` | `with_request_info` 类型扩展 |
|
||||
| Modify | `crates/erp-auth/src/service/auth_service.rs` | login/logout/change_password 添加审计 |
|
||||
| Modify | `crates/erp-plugin/src/data_service.rs` | CRUD 操作添加审计 |
|
||||
| Modify | `docker/docker-compose.yml` | 端口不暴露、Redis 密码、资源限制 |
|
||||
| Modify | `.gitignore` | 添加 `.test_token` |
|
||||
| Create | `.gitea/workflows/ci.yml` | CI/CD 流水线 |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: 密钥外部化与启动强制检查
|
||||
|
||||
### Task 1: 清理 `.test_token` 和 `.gitignore`
|
||||
|
||||
**Files:**
|
||||
- Modify: `.gitignore`
|
||||
- Delete: `.test_token`(仅本地文件)
|
||||
|
||||
- [ ] **Step 1: 验证 `.test_token` 是否曾提交到 git 历史**
|
||||
|
||||
```bash
|
||||
git log --all --oneline -- .test_token
|
||||
```
|
||||
|
||||
Expected: 无输出(从未提交)。如果有输出,需额外执行 BFG 清理。
|
||||
|
||||
- [ ] **Step 2: 添加 `.test_token` 到 `.gitignore`**
|
||||
|
||||
在 `.gitignore` 末尾添加:
|
||||
|
||||
```
|
||||
# Test artifacts
|
||||
.test_token
|
||||
*.heapsnapshot
|
||||
perf-trace-*.json
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitignore
|
||||
git commit -m "chore: 添加 .test_token 和测试产物到 .gitignore"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `default.toml` 敏感值改为占位符
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-server/config/default.toml`
|
||||
|
||||
- [ ] **Step 1: 替换敏感值**
|
||||
|
||||
将 `crates/erp-server/config/default.toml` 中的:
|
||||
|
||||
```toml
|
||||
url = "postgres://erp:erp_dev_2024@localhost:5432/erp"
|
||||
```
|
||||
改为:
|
||||
```toml
|
||||
url = "__MUST_SET_VIA_ENV__"
|
||||
```
|
||||
|
||||
将:
|
||||
```toml
|
||||
secret = "change-me-in-production"
|
||||
```
|
||||
改为:
|
||||
```toml
|
||||
secret = "__MUST_SET_VIA_ENV__"
|
||||
```
|
||||
|
||||
将:
|
||||
```toml
|
||||
super_admin_password = "Admin@2026"
|
||||
```
|
||||
改为:
|
||||
```toml
|
||||
super_admin_password = "__MUST_SET_VIA_ENV__"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 `.env.development` 供本地开发使用**
|
||||
|
||||
在项目根目录创建 `.env.development`(已被 `.gitignore` 中 `*.env.local` 覆盖,但需显式添加 `.env.development`):
|
||||
|
||||
```
|
||||
# .env.development — 本地开发用,不提交到仓库
|
||||
# 注意:此文件需要手动 source 或通过 dotenv 工具加载,config crate 不会自动读取
|
||||
ERP__DATABASE__URL=postgres://erp:erp_dev_2024@localhost:5432/erp
|
||||
ERP__JWT__SECRET=dev-local-secret-change-me
|
||||
ERP__SUPER_ADMIN_PASSWORD=Admin@2026
|
||||
```
|
||||
|
||||
更新 `.gitignore`,添加 `.env.development`。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/config/default.toml .gitignore
|
||||
git commit -m "fix(security): default.toml 敏感值改为占位符,强制通过环境变量注入"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 启动检查 — 拒绝默认密钥
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-server/src/main.rs`(在服务启动前添加检查)
|
||||
|
||||
- [ ] **Step 1: 在 `main.rs` 的配置加载后、服务启动前添加安全检查**
|
||||
|
||||
在配置加载完成后(`let config = ...` 之后),添加:
|
||||
|
||||
```rust
|
||||
// ── 安全检查:拒绝默认密钥 ──────────────────────────
|
||||
if config.jwt.secret == "__MUST_SET_VIA_ENV__" || config.jwt.secret == "change-me-in-production" {
|
||||
tracing::error!(
|
||||
"JWT 密钥为默认值,拒绝启动。请设置环境变量 ERP__JWT__SECRET"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
if config.database.url == "__MUST_SET_VIA_ENV__" {
|
||||
tracing::error!(
|
||||
"数据库 URL 为默认占位值,拒绝启动。请设置环境变量 ERP__DATABASE__URL"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证默认配置启动被拒绝**
|
||||
|
||||
```bash
|
||||
ERP__JWT__SECRET="__MUST_SET_VIA_ENV__" cargo run -p erp-server
|
||||
```
|
||||
|
||||
Expected: 进程退出,输出包含 "JWT 密钥为默认值,拒绝启动"
|
||||
|
||||
- [ ] **Step 3: 验证环境变量设置后正常启动**
|
||||
|
||||
```bash
|
||||
ERP__JWT__SECRET="my-real-secret" ERP__DATABASE__URL="postgres://erp:erp_dev_2024@localhost:5432/erp" ERP__AUTH__SUPER_ADMIN_PASSWORD="TestPass123" cargo run -p erp-server
|
||||
```
|
||||
|
||||
Expected: 服务正常启动(或因数据库未运行而失败,但不应因安全检查退出)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/src/main.rs
|
||||
git commit -m "fix(security): 启动时拒绝默认 JWT 密钥和数据库 URL"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 移除密码 fallback 硬编码
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/module.rs:149-150`
|
||||
|
||||
- [ ] **Step 1: 将 `unwrap_or_else` 改为显式错误处理**
|
||||
|
||||
将:
|
||||
```rust
|
||||
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD")
|
||||
.unwrap_or_else(|_| "Admin@2026".to_string());
|
||||
```
|
||||
|
||||
改为:
|
||||
```rust
|
||||
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD")
|
||||
.map_err(|_| {
|
||||
tracing::error!("环境变量 ERP__SUPER_ADMIN_PASSWORD 未设置,无法初始化租户认证");
|
||||
erp_core::error::AppError::Internal(
|
||||
"ERP__SUPER_ADMIN_PASSWORD 未设置".to_string(),
|
||||
)
|
||||
})?;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译通过**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth
|
||||
```
|
||||
|
||||
Expected: 编译成功
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/module.rs
|
||||
git commit -m "fix(security): 移除 super_admin_password 硬编码 fallback"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 移除 `From<AppError> for AuthError` 反向映射
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/error.rs:46-53`
|
||||
|
||||
- [ ] **Step 1: 删除反向映射 impl**
|
||||
|
||||
删除 `crates/erp-auth/src/error.rs` 中的整个 impl 块:
|
||||
|
||||
```rust
|
||||
// 删除以下代码
|
||||
impl From<AppError> for AuthError {
|
||||
fn from(err: AppError) -> Self {
|
||||
match err {
|
||||
AppError::VersionMismatch => AuthError::VersionMismatch,
|
||||
other => AuthError::Validation(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 修复所有依赖此反向映射的调用点**
|
||||
|
||||
反向映射主要用于 `on_tenant_created` / `on_tenant_deleted` 中。检查这两个函数 — 它们已经返回 `AppResult<()>`(不是 `AuthResult`),所以不会直接受影响。
|
||||
|
||||
真正受影响的是 `auth_service.rs` 中可能从其他 crate 传入 `AppError` 并隐式转为 `AuthError` 的路径。逐一检查:
|
||||
- `auth_service.rs` — 所有 `.map_err()` 调用是否仍能编译
|
||||
- `user_service.rs` — 同上
|
||||
- 如果有编译错误,在调用点使用显式 `.map_err(|e| AuthError::Validation(e.to_string()))` 而非依赖隐式转换
|
||||
|
||||
- [ ] **Step 3: 删除反向映射的测试**
|
||||
|
||||
删除 `crates/erp-auth/src/error.rs` 测试中的 `app_error_version_mismatch_roundtrip` 和 `app_error_other_maps_to_auth_validation` 测试。
|
||||
|
||||
- [ ] **Step 4: 验证编译和测试**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth && cargo test -p erp-auth
|
||||
```
|
||||
|
||||
Expected: 编译成功,所有测试通过
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/
|
||||
git commit -m "refactor(auth): 移除 From<AppError> for AuthError 反向映射"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 多租户安全加固 + 限流 fail-closed
|
||||
|
||||
### Task 6: `auth_service::refresh()` 添加 tenant_id 过滤
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/service/auth_service.rs:177-181`
|
||||
|
||||
- [ ] **Step 1: 修改 refresh 中的用户查询**
|
||||
|
||||
将:
|
||||
```rust
|
||||
let user_model = user::Entity::find_by_id(claims.sub)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or(AuthError::TokenRevoked)?;
|
||||
```
|
||||
|
||||
改为:
|
||||
```rust
|
||||
let user_model = user::Entity::find_by_id(claims.sub)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or(AuthError::TokenRevoked)?;
|
||||
|
||||
// 验证用户属于 JWT 中声明的租户
|
||||
// 注意:JWT claims 中租户 ID 字段名为 `tid`(与 TokenService 签发时一致)
|
||||
if user_model.tenant_id != claims.tid {
|
||||
tracing::warn!(
|
||||
user_id = %claims.sub,
|
||||
jwt_tenant = %claims.tid,
|
||||
actual_tenant = %user_model.tenant_id,
|
||||
"Token tenant_id 与用户实际租户不匹配"
|
||||
);
|
||||
return Err(AuthError::TokenRevoked);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/service/auth_service.rs
|
||||
git commit -m "fix(auth): refresh token 流程添加 tenant_id 校验"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: `user_service` 改为 DB 级 tenant_id 过滤
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/service/user_service.rs`(`get_by_id`、`update`、`delete`、`assign_roles` 四个函数)
|
||||
|
||||
- [ ] **Step 1: 修改 `get_by_id`(约第 129-134 行)**
|
||||
|
||||
将 `find_by_id` + 内存 `.filter()` 模式改为数据库级查询:
|
||||
|
||||
```rust
|
||||
pub async fn get_by_id(id: Uuid, tenant_id: Uuid, db: &DatabaseConnection) -> AuthResult<user::Model> {
|
||||
user::Entity::find()
|
||||
.filter(user::Column::Id.eq(id))
|
||||
.filter(user::Column::TenantId.eq(tenant_id))
|
||||
.filter(user::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||
.ok_or(AuthError::Validation("用户不存在".to_string()))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 同样修改 `update`、`delete` 和 `assign_roles` 函数**
|
||||
|
||||
将这三个函数中的 `find_by_id` + 内存 `.filter()` 改为相同的 DB 级过滤模式。注意:`login`、`list` 函数已正确使用数据库级过滤,无需修改。
|
||||
|
||||
- [ ] **Step 3: 验证编译和测试**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth && cargo test -p erp-auth
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/service/user_service.rs
|
||||
git commit -m "fix(auth): get_by_id/update/delete 改为数据库级 tenant_id 过滤"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7.5: 登录租户解析
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/handler/auth_handler.rs`(登录 handler 提取租户信息)
|
||||
- Modify: `crates/erp-auth/src/service/auth_service.rs`(login 函数签名调整)
|
||||
|
||||
- [ ] **Step 1: 在 `auth_handler.rs` 的 `login` handler 中提取租户 ID**
|
||||
|
||||
从请求头 `X-Tenant-ID` 提取租户 ID,若无此头则使用默认租户(向后兼容):
|
||||
|
||||
```rust
|
||||
let tenant_id = headers
|
||||
.get("X-Tenant-ID")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| Uuid::parse_str(v).ok())
|
||||
.unwrap_or(state.default_tenant_id);
|
||||
```
|
||||
|
||||
将 `tenant_id` 传入 `AuthService::login`。
|
||||
|
||||
- [ ] **Step 2: 更新 `AuthService::login` 签名**
|
||||
|
||||
如果当前签名不含 `tenant_id` 参数,添加 `tenant_id: Uuid` 参数,替换函数内部对 `state.default_tenant_id` 的使用。
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/handler/auth_handler.rs crates/erp-auth/src/service/auth_service.rs
|
||||
git commit -m "feat(auth): 登录接口支持 X-Tenant-ID 请求头租户解析"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 限流 fail-closed
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-server/src/middleware/rate_limit.rs:122-137`
|
||||
|
||||
- [ ] **Step 1: 将 Redis 不可达时的放行改为拒绝**
|
||||
|
||||
在 `apply_rate_limit` 函数中,将三处 `return next.run(req).await;` 改为返回 429:
|
||||
|
||||
```rust
|
||||
// 第一处:Redis 不可达快速检查(约第 122-124 行)
|
||||
if !avail.should_try().await {
|
||||
tracing::warn!("Redis 不可达,启用 fail-closed 限流保护");
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "服务暂时不可用,请稍后重试".to_string(),
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
|
||||
// 第二处:连接失败(约第 135-137 行)
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Redis 连接失败,fail-closed 限流保护");
|
||||
avail.mark_failed().await;
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "服务暂时不可用,请稍后重试".to_string(),
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
|
||||
// 第三处:INCR 失败(约第 143-145 行)
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Redis INCR 失败,fail-closed 限流保护");
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "服务暂时不可用,请稍后重试".to_string(),
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
```
|
||||
|
||||
注意:`RateLimitResponse` 已在模块级别定义(第 17-20 行),无需移动。使用 `(StatusCode, Json)` 元组模式与现有代码一致。
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-server
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/src/middleware/rate_limit.rs
|
||||
git commit -m "fix(server): 限流改为 fail-closed — Redis 不可达时拒绝请求"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 审计日志补全
|
||||
|
||||
### Task 9: 登录/登出/密码修改添加审计日志
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/service/auth_service.rs`
|
||||
- Reference: `crates/erp-core/src/audit.rs`
|
||||
|
||||
- [ ] **Step 1: 在 `login` 函数成功路径添加审计**
|
||||
|
||||
在登录成功后(签发 token 之后)添加:
|
||||
|
||||
```rust
|
||||
// 审计日志:登录成功
|
||||
// AuditLog::new 签名:(tenant_id: Uuid, user_id: Option<Uuid>, action: &str, resource_type: &str)
|
||||
audit_service::record(
|
||||
audit::AuditLog::new(user_model.tenant_id, Some(user_model.id), "user.login", "user"),
|
||||
db,
|
||||
).await;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 `login` 函数失败路径添加审计**
|
||||
|
||||
失败审计需区分两种情况:
|
||||
|
||||
a) **用户不存在**(`find_by_username` 返回 None)— 此时无 `user_model`,使用 `Uuid::nil()` 作为 user_id:
|
||||
```rust
|
||||
// 在 Ok(None) => return Err(AuthError::InvalidCredentials) 之前添加
|
||||
audit_service::record(
|
||||
audit::AuditLog::new(tenant_id, None, "user.login_failed", "user")
|
||||
.with_resource_id("username", &req.username),
|
||||
db,
|
||||
).await;
|
||||
```
|
||||
|
||||
b) **密码错误** — 此时已有 `user_model`:
|
||||
```rust
|
||||
// 在密码验证失败返回 InvalidCredentials 之前添加
|
||||
audit_service::record(
|
||||
audit::AuditLog::new(tenant_id, Some(user_model.id), "user.login_failed", "user"),
|
||||
db,
|
||||
).await;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 `logout` 函数添加审计**
|
||||
|
||||
- [ ] **Step 4: 在 `change_password` 函数添加审计**
|
||||
|
||||
- [ ] **Step 5: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/service/auth_service.rs
|
||||
git commit -m "feat(auth): 登录/登出/密码修改添加审计日志"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 审计日志添加 IP 和 User-Agent
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-core/src/audit.rs`(确保 `with_request_info` 接受 IP + UA)
|
||||
- Modify: `crates/erp-auth/src/handler/auth_handler.rs`(从请求提取信息传入 service)
|
||||
|
||||
- [ ] **Step 1: 确认 `audit.rs` 中 `with_request_info` 的签名**
|
||||
|
||||
确认 `AuditLogBuilder::with_request_info(ip: String, user_agent: String)` 存在且类型正确。如果不存在则添加。
|
||||
|
||||
- [ ] **Step 2: 在 auth handler 中提取 IP 和 UA 并传给 service**
|
||||
|
||||
**必须修改**以下函数签名(不仅仅是 `login`):
|
||||
|
||||
- `AuthService::login` — 添加 `client_info: Option<ClientInfo>` 参数
|
||||
- `AuthService::logout` — 同上
|
||||
- `AuthService::change_password` — 同上
|
||||
|
||||
在 `auth_handler.rs` 中创建辅助函数提取请求信息:
|
||||
|
||||
```rust
|
||||
struct ClientInfo {
|
||||
ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
}
|
||||
|
||||
fn extract_client_info(req: &Request) -> ClientInfo {
|
||||
let ip = req.headers()
|
||||
.get("X-Forwarded-For")
|
||||
.or_else(|| req.headers().get("X-Real-IP"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string());
|
||||
let user_agent = req.headers()
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
ClientInfo { ip, user_agent }
|
||||
}
|
||||
```
|
||||
|
||||
在每个 auth handler 函数中调用 `extract_client_info` 并传给 service。
|
||||
|
||||
- [ ] **Step 3: 在审计日志记录时调用 `.with_request_info(ip, user_agent)`**
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth && cargo check -p erp-core
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/ crates/erp-core/src/audit.rs
|
||||
git commit -m "feat(audit): 审计日志添加 IP 地址和 User-Agent"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 关键实体 update 添加变更前后值
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/service/user_service.rs`(`update` 函数)
|
||||
- Modify: `crates/erp-auth/src/service/role_service.rs`(`update` 函数)
|
||||
|
||||
- [ ] **Step 1: 在 `user_service::update` 中,先查询旧值再更新**
|
||||
|
||||
在 update 函数中,获取旧模型后、执行更新前,记录:
|
||||
|
||||
```rust
|
||||
let old_json = serde_json::to_value(&old_user)
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
// ... 执行更新 ...
|
||||
let new_json = serde_json::to_value(&updated_user)
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
// AuditLog::new 签名:(tenant_id, user_id, action, resource_type)
|
||||
// with_changes 签名:(Option<Value>, Option<Value>)
|
||||
audit_service::record(
|
||||
audit::AuditLog::new(tenant_id, Some(operator_id), "user.update", "user")
|
||||
.with_resource_id("user_id", &old_user.id.to_string())
|
||||
.with_changes(Some(old_json), Some(new_json)),
|
||||
db,
|
||||
).await;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 同样修改 `role_service::update`**
|
||||
|
||||
- [ ] **Step 3: 确认 `with_changes` 方法签名**
|
||||
|
||||
实际签名为 `with_changes(mut self, old: Option<Value>, new: Option<Value>) -> Self`,已在 `audit.rs` 第 51-59 行定义。调用时用 `Some()` 包装值。
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-auth
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/ crates/erp-core/src/audit.rs
|
||||
git commit -m "feat(audit): 用户/角色更新记录变更前后值"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: 插件 CRUD 添加审计日志
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-plugin/src/data_service.rs`
|
||||
|
||||
- [ ] **Step 1: 添加审计日志 import 和调用**
|
||||
|
||||
首先在 `data_service.rs` 顶部添加 import:
|
||||
```rust
|
||||
use erp_core::{audit, audit_service};
|
||||
```
|
||||
|
||||
然后在 `create_record`、`update_record`(含 `partial_update`)、`delete_record` 中添加审计日志。审计调用需要 `tenant_id` 和 `operator_id`:
|
||||
- `tenant_id` 从函数参数获取
|
||||
- `operator_id` 从函数参数获取(若函数缺少此参数则需补充)
|
||||
|
||||
示例:
|
||||
```rust
|
||||
// create_record 审计
|
||||
audit_service::record(
|
||||
audit::AuditLog::new(tenant_id, Some(operator_id), "plugin.data.create", entity_name),
|
||||
db,
|
||||
).await;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin/src/data_service.rs
|
||||
git commit -m "feat(plugin): 数据 CRUD 操作添加审计日志"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: CI/CD + Docker 生产化
|
||||
|
||||
### Task 13: 创建 Gitea Actions CI/CD 流水线
|
||||
|
||||
**Files:**
|
||||
- Create: `.gitea/workflows/ci.yml`
|
||||
|
||||
- [ ] **Step 1: 创建工作流目录**
|
||||
|
||||
```bash
|
||||
mkdir -p .gitea/workflows
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 CI 流水线文件**
|
||||
|
||||
创建 `.gitea/workflows/ci.yml`:
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
rust-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
- run: cargo fmt --check --all
|
||||
- run: cargo clippy -- -D warnings
|
||||
|
||||
rust-test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: erp_test
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
- run: cargo test --workspace
|
||||
env:
|
||||
ERP__DATABASE__URL: postgres://test:test@localhost:5432/erp_test
|
||||
ERP__JWT__SECRET: ci-test-secret
|
||||
ERP__AUTH__SUPER_ADMIN_PASSWORD: CI_Test_Pass_2026
|
||||
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: cd apps/web && corepack enable && pnpm install --frozen-lockfile
|
||||
- run: cd apps/web && pnpm build
|
||||
|
||||
security-audit:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo install cargo-audit && cargo audit
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: cd apps/web && corepack enable && pnpm install --frozen-lockfile && pnpm audit
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitea/
|
||||
git commit -m "ci: 添加 Gitea Actions CI/CD 流水线"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Docker 生产化
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker/docker-compose.yml`
|
||||
|
||||
- [ ] **Step 1: 移除端口暴露,添加 Redis 密码和资源限制**
|
||||
|
||||
将 PostgreSQL 的 `ports: "5432:5432"` 改为 `expose: ["5432"]`(仅容器网络内部可访问)。
|
||||
将 Redis 的 `ports: "6379:6379"` 改为 `expose: ["6379"]`,并添加命令 `--requirepass ${REDIS_PASSWORD:-erp_redis_dev}`。
|
||||
为两个服务添加资源限制:
|
||||
|
||||
```yaml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.0"
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建开发用 `docker-compose.override.yml`**
|
||||
|
||||
由于生产配置移除了端口暴露,本地开发需要 override 文件恢复端口访问:
|
||||
|
||||
```yaml
|
||||
# docker/docker-compose.override.yml — 本地开发用,不提交到仓库
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
redis:
|
||||
ports:
|
||||
- "6379:6379"
|
||||
```
|
||||
|
||||
将 `docker-compose.override.yml` 添加到 `.gitignore`。Docker Compose 会自动合并 `docker-compose.yml` 和 `docker-compose.override.yml`。
|
||||
|
||||
- [ ] **Step 3: 更新 `.env.example`**
|
||||
|
||||
添加 `REDIS_PASSWORD` 变量说明。
|
||||
|
||||
- [ ] **Step 3: 更新 `default.toml` 的 Redis URL 格式**
|
||||
|
||||
如果 Redis 需要密码,URL 格式改为 `redis://:password@localhost:6379`。
|
||||
|
||||
- [ ] **Step 4: 验证 Docker Compose 配置有效**
|
||||
|
||||
```bash
|
||||
cd docker && docker compose config
|
||||
```
|
||||
|
||||
Expected: 无语法错误
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docker/
|
||||
git commit -m "fix(docker): 生产化配置 — 端口不暴露、Redis 密码、资源限制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
完成所有 Task 后,执行以下验证:
|
||||
|
||||
- [ ] **V1: 默认配置拒绝启动**
|
||||
```bash
|
||||
cargo run -p erp-server
|
||||
```
|
||||
Expected: 进程退出,日志包含 "JWT 密钥为默认值,拒绝启动"
|
||||
|
||||
- [ ] **V2: 环境变量设置后正常启动**
|
||||
```bash
|
||||
ERP__JWT__SECRET="test-secret" ERP__DATABASE__URL="postgres://erp:erp_dev_2024@localhost:5432/erp" ERP__AUTH__SUPER_ADMIN_PASSWORD="TestPass123" cargo run -p erp-server
|
||||
```
|
||||
Expected: 服务正常启动
|
||||
|
||||
- [ ] **V3: 全量编译和测试**
|
||||
```bash
|
||||
cargo check && cargo test --workspace
|
||||
```
|
||||
Expected: 全部通过
|
||||
|
||||
- [ ] **V4: 前端构建**
|
||||
```bash
|
||||
cd apps/web && pnpm build
|
||||
```
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **V5: Docker Compose 正常启动**
|
||||
```bash
|
||||
cd docker && docker compose up -d && docker compose ps
|
||||
```
|
||||
Expected: PostgreSQL 和 Redis 状态 healthy
|
||||
|
||||
- [ ] **V6: Push 到远程仓库**
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
Expected: Gitea Actions 触发 CI 流水线
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,706 @@
|
||||
# Q4 测试覆盖 + 插件生态 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 建立 Testcontainers 集成测试框架覆盖核心模块;Playwright E2E 覆盖关键用户旅程;开发进销存插件验证插件系统扩展性;实现插件热更新能力。
|
||||
|
||||
**Architecture:** Testcontainers 启动真实 PostgreSQL 容器运行迁移后执行集成测试;Playwright 驱动浏览器完成端到端验证;进销存插件复用 CRM 插件的 manifest + dynamic_table 模式;热更新通过版本对比 + 增量 DDL + 两阶段提交实现。
|
||||
|
||||
**Tech Stack:** Rust (testcontainers, testcontainers-modules), Playwright, WASM (wit-bindgen), SeaORM Migration
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-17-platform-maturity-roadmap-design.md` §4
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Create | `crates/erp-server/tests/integration/mod.rs` | 集成测试入口 |
|
||||
| Create | `crates/erp-server/tests/integration/test_db.rs` | Testcontainers 测试基座 |
|
||||
| Create | `crates/erp-server/tests/integration/auth_tests.rs` | Auth 模块集成测试 |
|
||||
| Create | `crates/erp-server/tests/integration/plugin_tests.rs` | Plugin 模块集成测试 |
|
||||
| Create | `crates/erp-server/tests/integration/workflow_tests.rs` | Workflow 模块集成测试 |
|
||||
| Create | `crates/erp-server/tests/integration/event_tests.rs` | EventBus 端到端测试 |
|
||||
| Create | `apps/web/e2e/login.spec.ts` | 登录流程 E2E |
|
||||
| Create | `apps/web/e2e/users.spec.ts` | 用户管理 E2E |
|
||||
| Create | `apps/web/e2e/plugins.spec.ts` | 插件安装 E2E |
|
||||
| Create | `apps/web/e2e/tenant-isolation.spec.ts` | 多租户隔离 E2E |
|
||||
| Create | `apps/web/playwright.config.ts` | Playwright 配置 |
|
||||
| Create | `crates/erp-plugin-inventory/` | 进销存插件 crate |
|
||||
| Modify | `Cargo.toml` | workspace 添加新 crate |
|
||||
| Modify | `crates/erp-plugin/src/engine.rs` | 热更新 upgrade 端点支持 |
|
||||
| Modify | `crates/erp-plugin/src/service.rs` | upgrade 生命周期 |
|
||||
| Modify | `crates/erp-plugin/src/handler/plugin_handler.rs` | upgrade 路由 |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: 集成测试框架
|
||||
|
||||
### Task 1: 添加 Testcontainers 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-server/Cargo.toml`(dev-dependencies)
|
||||
|
||||
- [ ] **Step 1: 在 `erp-server` 的 `[dev-dependencies]` 中添加**
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
testcontainers = "0.23"
|
||||
testcontainers-modules = { version = "0.11", features = ["postgres"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
```
|
||||
|
||||
注意:版本号需与 workspace 已有依赖兼容。如果 workspace 已有 `testcontainers`,使用 workspace 引用。
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-server
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/Cargo.toml Cargo.lock
|
||||
git commit -m "chore(server): 添加 testcontainers 开发依赖"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 创建测试基座
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/tests/integration/mod.rs`
|
||||
- Create: `crates/erp-server/tests/integration/test_db.rs`
|
||||
|
||||
- [ ] **Step 1: 创建测试模块入口**
|
||||
|
||||
```rust
|
||||
// crates/erp-server/tests/integration/mod.rs
|
||||
mod test_db;
|
||||
mod auth_tests;
|
||||
mod plugin_tests;
|
||||
```
|
||||
|
||||
注意:需要确保 `erp-server` 的 `Cargo.toml` 中有 `[[test]]` 配置或集成测试自动发现。
|
||||
|
||||
- [ ] **Step 2: 创建 Testcontainers 测试基座**
|
||||
|
||||
```rust
|
||||
// crates/erp-server/tests/integration/test_db.rs
|
||||
use testcontainers_modules::postgres::Postgres;
|
||||
use testcontainers::runners::AsyncRunner;
|
||||
use sea_orm::{Database, DatabaseConnection};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 测试数据库容器 — 使用 once_cell 确保每进程一个容器
|
||||
pub struct TestDb {
|
||||
pub db: DatabaseConnection,
|
||||
pub container: testcontainers::ContainerAsync<Postgres>,
|
||||
}
|
||||
|
||||
impl TestDb {
|
||||
pub async fn new() -> Self {
|
||||
let postgres = Postgres::default()
|
||||
.with_db_name("erp_test")
|
||||
.with_user("test")
|
||||
.with_password("test");
|
||||
|
||||
let container = postgres.start().await
|
||||
.expect("Failed to start PostgreSQL container");
|
||||
|
||||
let host_port = container.get_host_port_ipv4(5432).await
|
||||
.expect("Failed to get port");
|
||||
|
||||
let url = format!("postgres://test:test@localhost:{}/erp_test", host_port);
|
||||
let db = Database::connect(&url).await
|
||||
.expect("Failed to connect to test database");
|
||||
|
||||
// 运行所有迁移
|
||||
run_migrations(&db).await;
|
||||
|
||||
Self { db, container }
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_migrations(db: &DatabaseConnection) {
|
||||
use migration::{Migrator, MigratorTrait};
|
||||
Migrator::up(db, None).await.expect("Failed to run migrations");
|
||||
}
|
||||
```
|
||||
|
||||
注意:需要确保 `migration` crate 可被测试引用。可能需要调整 `Cargo.toml` 的依赖。
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-server
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/tests/
|
||||
git commit -m "test(server): 创建 Testcontainers 集成测试基座"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Auth 模块集成测试
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/tests/integration/auth_tests.rs`
|
||||
|
||||
- [ ] **Step 1: 编写用户 CRUD 测试**
|
||||
|
||||
```rust
|
||||
// crates/erp-server/tests/integration/auth_tests.rs
|
||||
use super::test_db::TestDb;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_crud() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = &test_db.db;
|
||||
let tenant_id = uuid::Uuid::new_v4();
|
||||
|
||||
// 创建用户
|
||||
let user = erp_auth::service::UserService::create(
|
||||
tenant_id,
|
||||
uuid::Uuid::new_v4(),
|
||||
erp_auth::dto::CreateUserReq {
|
||||
username: "testuser".to_string(),
|
||||
password: "TestPass123".to_string(),
|
||||
email: Some("test@example.com".to_string()),
|
||||
phone: None,
|
||||
display_name: Some("测试用户".to_string()),
|
||||
},
|
||||
db,
|
||||
&erp_core::events::EventBus::new(100),
|
||||
).await.expect("Failed to create user");
|
||||
|
||||
assert_eq!(user.username, "testuser");
|
||||
|
||||
// 查询用户
|
||||
let found = erp_auth::service::UserService::get_by_id(user.id, tenant_id, db)
|
||||
.await.expect("Failed to get user");
|
||||
assert_eq!(found.username, "testuser");
|
||||
|
||||
// 列表查询
|
||||
let (users, total) = erp_auth::service::UserService::list(
|
||||
tenant_id, erp_core::types::Pagination { page: 1, page_size: 10 }, None, db,
|
||||
).await.expect("Failed to list users");
|
||||
assert_eq!(total, 1);
|
||||
assert_eq!(users[0].username, "testuser");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写多租户隔离测试**
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_tenant_isolation() {
|
||||
let test_db = TestDb::new().await;
|
||||
let db = &test_db.db;
|
||||
let tenant_a = uuid::Uuid::new_v4();
|
||||
let tenant_b = uuid::Uuid::new_v4();
|
||||
|
||||
// 租户 A 创建用户
|
||||
let user_a = erp_auth::service::UserService::create(
|
||||
tenant_a,
|
||||
uuid::Uuid::new_v4(),
|
||||
erp_auth::dto::CreateUserReq {
|
||||
username: "user_a".to_string(),
|
||||
password: "Pass123!".to_string(),
|
||||
email: None, phone: None, display_name: None,
|
||||
},
|
||||
db,
|
||||
&erp_core::events::EventBus::new(100),
|
||||
).await.unwrap();
|
||||
|
||||
// 租户 B 查询不应看到租户 A 的用户
|
||||
let (users_b, total_b) = erp_auth::service::UserService::list(
|
||||
tenant_b, erp_core::types::Pagination { page: 1, page_size: 10 }, None, db,
|
||||
).await.unwrap();
|
||||
|
||||
assert_eq!(total_b, 0);
|
||||
assert!(users_b.is_empty());
|
||||
|
||||
// 租户 B 通过 ID 查询租户 A 的用户应返回 NotFound
|
||||
let result = erp_auth::service::UserService::get_by_id(user_a.id, tenant_b, db).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试验证**
|
||||
|
||||
```bash
|
||||
cargo test -p erp-server --test integration auth_tests
|
||||
```
|
||||
|
||||
注意:需要 Docker 运行。Windows 上可能需要 WSL2。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/tests/integration/auth_tests.rs
|
||||
git commit -m "test(auth): 添加用户 CRUD 和多租户隔离集成测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Plugin 模块集成测试
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/tests/integration/plugin_tests.rs`
|
||||
|
||||
- [ ] **Step 1: 编写插件生命周期测试**
|
||||
|
||||
测试 install → enable → data CRUD → disable → uninstall 完整流程。
|
||||
|
||||
- [ ] **Step 2: 编写 JSONB 查询测试**
|
||||
|
||||
验证 dynamic_table 的 generated column、pg_trgm 索引是否正确创建。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/tests/integration/plugin_tests.rs
|
||||
git commit -m "test(plugin): 添加插件生命周期和 JSONB 集成测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Workflow + EventBus 集成测试
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-server/tests/integration/workflow_tests.rs`
|
||||
- Create: `crates/erp-server/tests/integration/event_tests.rs`
|
||||
|
||||
- [ ] **Step 1: Workflow 测试 — 流程实例启动和任务完成**
|
||||
|
||||
- [ ] **Step 2: EventBus 测试 — 发布/订阅端到端 + outbox relay**
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-server/tests/integration/
|
||||
git commit -m "test: 添加 workflow 和 EventBus 集成测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: E2E 测试
|
||||
|
||||
### Task 6: Playwright 环境搭建
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/playwright.config.ts`
|
||||
- Modify: `apps/web/package.json`
|
||||
|
||||
- [ ] **Step 1: 安装 Playwright**
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm add -D @playwright/test && pnpm exec playwright install chromium
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 Playwright 配置**
|
||||
|
||||
```ts
|
||||
// apps/web/playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30000,
|
||||
retries: 1,
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
headless: true,
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/playwright.config.ts apps/web/package.json
|
||||
git commit -m "test(web): 搭建 Playwright E2E 测试环境"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 登录流程 E2E 测试
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/e2e/login.spec.ts`
|
||||
|
||||
- [ ] **Step 1: 编写登录 E2E 测试**
|
||||
|
||||
```ts
|
||||
// apps/web/e2e/login.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('完整登录流程', async ({ page }) => {
|
||||
await page.goto('/#/login');
|
||||
await expect(page.locator('h2, .ant-card-head-title')).toContainText('登录');
|
||||
|
||||
// 输入凭据
|
||||
await page.fill('input[placeholder*="用户名"]', 'admin');
|
||||
await page.fill('input[placeholder*="密码"]', 'Admin@2026');
|
||||
await page.click('button:has-text("登录")');
|
||||
|
||||
// 验证跳转到首页
|
||||
await page.waitForURL('**/'),
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
});
|
||||
```
|
||||
|
||||
注意:此测试需要后端服务运行。可在 CI 中使用 service container 或手动启动。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/e2e/login.spec.ts
|
||||
git commit -m "test(web): 添加登录流程 E2E 测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 用户管理 E2E 测试
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/e2e/users.spec.ts`
|
||||
|
||||
- [ ] **Step 1: 编写用户管理闭环测试**
|
||||
|
||||
创建 → 搜索 → 编辑 → 软删除 → 验证列表不显示。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/e2e/users.spec.ts
|
||||
git commit -m "test(web): 添加用户管理 E2E 测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 插件安装 + 多租户 E2E 测试
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/e2e/plugins.spec.ts`
|
||||
- Create: `apps/web/e2e/tenant-isolation.spec.ts`
|
||||
|
||||
- [ ] **Step 1: 插件安装 E2E 测试**
|
||||
|
||||
上传 → 安装 → 验证菜单 → 数据 CRUD → 卸载。
|
||||
|
||||
- [ ] **Step 2: 多租户隔离 E2E 测试**
|
||||
|
||||
租户 A 创建数据 → 切换租户 B → 验证不可见。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/e2e/
|
||||
git commit -m "test(web): 添加插件安装和多租户 E2E 测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 进销存插件
|
||||
|
||||
### Task 10: 创建插件 crate 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-inventory/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-inventory/src/lib.rs`
|
||||
- Create: `crates/erp-plugin-inventory/manifest.toml`
|
||||
- Modify: `Cargo.toml`(workspace members)
|
||||
|
||||
- [ ] **Step 1: 创建 Cargo.toml**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-inventory"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.38"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 manifest.toml**
|
||||
|
||||
定义 6 个实体(product, warehouse, stock, supplier, purchase_order, sales_order)的完整 schema,包括字段、关系、页面、权限声明。参考 CRM 插件的 `crates/erp-plugin-crm/manifest.toml`。
|
||||
|
||||
- [ ] **Step 3: 创建 lib.rs(Guest trait 实现)**
|
||||
|
||||
```rust
|
||||
// crates/erp-plugin-inventory/src/lib.rs
|
||||
wit_bindgen::generate!({
|
||||
path: "../erp-plugin-prototype/wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
struct InventoryPlugin;
|
||||
|
||||
impl Guest for InventoryPlugin {
|
||||
fn init() -> Result<(), String> { Ok(()) }
|
||||
fn on_tenant_created(_tenant_id: String) -> Result<(), String> { Ok(()) }
|
||||
fn handle_event(_event_type: String, _event_data: String) -> Result<(), String> { Ok(()) }
|
||||
}
|
||||
|
||||
export_plugin!(InventoryPlugin);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 添加到 workspace**
|
||||
|
||||
在根 `Cargo.toml` 的 `members` 中添加 `"crates/erp-plugin-inventory"`。
|
||||
|
||||
- [ ] **Step 5: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-inventory
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-inventory/ Cargo.toml
|
||||
git commit -m "feat(inventory): 创建进销存插件 crate 骨架"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 定义实体 Schema
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-plugin-inventory/manifest.toml`
|
||||
|
||||
- [ ] **Step 1: 定义 6 个实体**
|
||||
|
||||
参考 CRM 插件 manifest 格式,定义:
|
||||
|
||||
| 实体 | 关键字段 | 关联 | 页面类型 |
|
||||
|------|---------|------|---------|
|
||||
| product | code, name, spec, unit, category, price, cost | — | CRUD |
|
||||
| warehouse | code, name, address, manager, status | — | CRUD |
|
||||
| stock | product_id, warehouse_id, qty, cost, alert_line | → product, warehouse | CRUD |
|
||||
| supplier | code, name, contact, phone, address | — | CRUD |
|
||||
| purchase_order | supplier_id, total_amount, status, date | → supplier, stock | CRUD + Dashboard |
|
||||
| sales_order | customer_id, total_amount, status, date | → customer(CRM), stock | CRUD + Kanban |
|
||||
|
||||
- [ ] **Step 2: 定义 6 个页面**(4 CRUD + 1 Dashboard 库存汇总 + 1 Kanban 销售看板)
|
||||
|
||||
- [ ] **Step 3: 定义 9 个权限**(每个实体 list/create/update/delete + 全局 manage)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-inventory/manifest.toml
|
||||
git commit -m "feat(inventory): 定义 6 实体/6 页面/9 权限 manifest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: 编译 WASM 并测试安装
|
||||
|
||||
**Files:**
|
||||
- Build output: `apps/web/public/inventory.wasm`
|
||||
|
||||
- [ ] **Step 1: 编译为 WASM Component**
|
||||
|
||||
```bash
|
||||
cargo build -p erp-plugin-inventory --target wasm32-unknown-unknown --release
|
||||
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_inventory.wasm -o target/erp_plugin_inventory.component.wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 复制到前端 public 目录**
|
||||
|
||||
```bash
|
||||
cp target/erp_plugin_inventory.component.wasm apps/web/public/inventory.wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 通过 API 安装插件并验证**
|
||||
|
||||
使用 curl 或前端插件管理页面上传 `inventory.wasm`,验证动态表创建成功,CRUD 页面正常工作。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/public/inventory.wasm
|
||||
git commit -m "feat(inventory): 编译并部署进销存插件 WASM"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: 插件热更新
|
||||
|
||||
### Task 13: 添加 upgrade 端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-plugin/src/handler/plugin_handler.rs`
|
||||
- Modify: `crates/erp-plugin/src/service.rs`
|
||||
|
||||
- [ ] **Step 1: 在 plugin_handler 中添加 upgrade 路由**
|
||||
|
||||
```rust
|
||||
pub fn protected_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
// ... 现有路由 ...
|
||||
.route("/admin/plugins/:plugin_id/upgrade", post(upgrade_plugin))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 upgrade handler**
|
||||
|
||||
接收新 WASM 文件,调用 service 层执行升级。
|
||||
|
||||
- [ ] **Step 3: 在 service 中实现升级逻辑**
|
||||
|
||||
```rust
|
||||
pub async fn upgrade_plugin(
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
new_wasm_bytes: Vec<u8>,
|
||||
db: &DatabaseConnection,
|
||||
) -> PluginResult<()> {
|
||||
// 1. 解析新 manifest
|
||||
let new_manifest = parse_manifest_from_wasm(&new_wasm_bytes)?;
|
||||
|
||||
// 2. 获取当前插件信息
|
||||
let current = find_by_id(plugin_id, tenant_id, db).await?;
|
||||
|
||||
// 3. 对比 schema 变更,生成增量 DDL
|
||||
let schema_diff = compare_schemas(¤t.manifest, &new_manifest)?;
|
||||
|
||||
// 4. 暂存新 WASM,尝试验证初始化
|
||||
// 5. 初始化成功后,在事务中执行 DDL + 状态更新
|
||||
// 6. 失败时保持旧 WASM 继续运行
|
||||
// 详见 spec §4.4 回滚策略
|
||||
todo!()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin/src/
|
||||
git commit -m "feat(plugin): 添加插件热更新 upgrade 端点"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5: 文档更新与清理
|
||||
|
||||
### Task 14: 更新 Wiki 文档
|
||||
|
||||
**Files:**
|
||||
- Modify: `wiki/frontend.md`
|
||||
- Modify: `wiki/database.md`
|
||||
- Modify: `wiki/testing.md`
|
||||
- Modify: `wiki/index.md`
|
||||
|
||||
- [ ] **Step 1: 更新 `wiki/frontend.md`**
|
||||
|
||||
更新为反映当前 16 条路由、6 种插件页面类型、Zustand stores 等实际状态。
|
||||
|
||||
- [ ] **Step 2: 更新 `wiki/testing.md`**
|
||||
|
||||
更新测试数量、添加 Testcontainers 集成测试和 Playwright E2E 描述。
|
||||
|
||||
- [ ] **Step 3: 更新 `wiki/index.md`**
|
||||
|
||||
添加进销存插件到模块导航树,更新开发进度表。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add wiki/
|
||||
git commit -m "docs: 更新 Wiki 文档到当前状态"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 15: CLAUDE.md 版本号修正 + 根目录清理
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
- Cleanup: 根目录未跟踪文件
|
||||
|
||||
- [ ] **Step 1: 修正 CLAUDE.md 版本号**
|
||||
|
||||
将 `React 18 + Ant Design 5` 改为 `React 19 + Ant Design 6`。
|
||||
|
||||
- [ ] **Step 2: 清理根目录未跟踪文件**
|
||||
|
||||
删除开发临时文件:截图、heap dump、perf trace、agent plan 文件。
|
||||
|
||||
```bash
|
||||
rm -f current-page.png home-full.png home-improved.png docs/debug-*.png
|
||||
rm -f docs/memory-snapshot-*.heapsnapshot docs/perf-trace-*.json
|
||||
rm -f test_api_auth.py test_users.py
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 处理 integration-tests/ 目录**
|
||||
|
||||
验证 `integration-tests/` 中的测试是否能编译。若已失效则删除(新的集成测试在 `crates/erp-server/tests/integration/`)。若仍有效则添加到 workspace。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: 修正 CLAUDE.md 版本号 (React 19 / AD 6) 并清理临时文件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
- [ ] **V1: 全 workspace 编译和测试**
|
||||
```bash
|
||||
cargo check && cargo test --workspace
|
||||
```
|
||||
|
||||
- [ ] **V2: 集成测试通过**
|
||||
```bash
|
||||
cargo test -p erp-server --test integration
|
||||
```
|
||||
注意:需要 Docker 运行
|
||||
|
||||
- [ ] **V3: 前端构建**
|
||||
```bash
|
||||
cd apps/web && pnpm build
|
||||
```
|
||||
|
||||
- [ ] **V4: E2E 测试**
|
||||
```bash
|
||||
cd apps/web && pnpm exec playwright test
|
||||
```
|
||||
|
||||
- [ ] **V5: 进销存插件安装验证**
|
||||
|
||||
通过 API 安装 inventory.wasm,验证动态表和 CRUD 页面正常。
|
||||
|
||||
- [ ] **V6: Wiki 文档同步**
|
||||
|
||||
确认 Wiki 描述与代码实际状态一致。
|
||||
@@ -0,0 +1,456 @@
|
||||
# ERP 平台底座 — 全面成熟度提升路线图
|
||||
|
||||
> 创建日期:2026-04-17
|
||||
> 状态:审查修订完成
|
||||
> 范围:安全、架构、测试、前端体验、插件生态 — 3 季度分层推进
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 项目现状
|
||||
|
||||
ERP 平台底座已完成 Phase 1-6 基础设施建设 + WASM 插件系统集成 + CRM 客户管理插件。当前具备:
|
||||
|
||||
- 6 个业务模块(auth, config, workflow, message, plugin, server)
|
||||
- 36 个数据库迁移
|
||||
- 完整的 WASM 插件运行时
|
||||
- Schema 驱动的动态前端(6 种页面类型)
|
||||
- React 19 + Ant Design 6 + Zustand 5 前端 SPA
|
||||
|
||||
### 1.2 分析发现摘要
|
||||
|
||||
| 维度 | 评分 | 关键问题 |
|
||||
|------|------|---------|
|
||||
| 架构健壮性 | 8/10 | ErpModule trait 死代码、路由注册未自动化 |
|
||||
| 代码质量 | 7/10 | N+1 查询、错误映射过宽、 oversized 组件 |
|
||||
| 安全性 | 5/10 | 3 个 CRITICAL(硬编码密钥/密码)、4 个 HIGH |
|
||||
| 测试覆盖 | 4/10 | 零数据库集成测试、关键流程未覆盖 |
|
||||
| 前端体验 | 7/10 | 无 i18n、无 Error Boundary、无虚拟滚动 |
|
||||
| 基础设施 | 4/10 | 无 CI/CD、Wiki 过时、大量未跟踪文件 |
|
||||
|
||||
### 1.3 目标
|
||||
|
||||
通过 3 个季度的分层改进,将平台从"功能完整"推进到"生产就绪":
|
||||
|
||||
- **Q2(4-5月)**:消除安全风险,建立自动化质量门
|
||||
- **Q3(6-8月)**:强化架构,提升前端工程化水平
|
||||
- **Q4(9-11月)**:补齐测试覆盖,扩展插件生态
|
||||
|
||||
### 1.4 约束
|
||||
|
||||
- **独立开发者** + Claude 辅助 — 每季度聚焦单一维度
|
||||
- **SaaS 优先**部署 — 多租户安全是硬性要求
|
||||
- **不破坏现有功能** — 所有改进必须向后兼容
|
||||
|
||||
---
|
||||
|
||||
## 2. Q2:安全地基 + CI/CD(4-5月)
|
||||
|
||||
### 2.1 密钥外部化与启动强制检查
|
||||
|
||||
**问题:**
|
||||
- JWT 密钥 `"change-me-in-production"` 硬编码在 `crates/erp-server/config/default.toml`
|
||||
- 管理员密码 `"Admin@2026"` 硬编码 + fallback
|
||||
- 数据库凭据 `postgres://erp:erp_dev_2024@...` 硬编码
|
||||
- `.test_token` 含有效 admin JWT 提交到仓库
|
||||
|
||||
**方案:**
|
||||
|
||||
1. **配置强制化**:`default.toml` 只保留开发环境默认值。生产敏感值通过环境变量 `ERP__` 前缀注入(已有机制)
|
||||
2. **启动检查**:服务启动时检测 JWT 密钥是否为默认值,若是则 **拒绝启动**(返回错误退出码,不只是警告)
|
||||
3. **密码初始化**:`seed_tenant_auth` 从环境变量 `ERP__SUPER_ADMIN_PASSWORD` 读取初始密码(与现有 `module.rs:149` 中的变量名一致),未设置则拒绝初始化(移除 fallback 到硬编码值的逻辑)
|
||||
4. **清理 `.test_token`**:立即加入 `.gitignore`。验证该文件是否曾被提交到 git 历史 — 如果曾提交,需使用 BFG Repo-Cleaner 清理历史(因包含用硬编码密钥签名的 admin JWT,等同于密钥泄露)
|
||||
5. **`default.toml` 占位符**:敏感字段改为 `"__MUST_SET_VIA_ENV__"` 之类的明显占位值
|
||||
|
||||
**验证标准:**
|
||||
- 默认配置启动时服务拒绝运行
|
||||
- 环境变量设置后正常启动
|
||||
- `.test_token` 不再出现在仓库中
|
||||
|
||||
### 2.2 Gitea Actions CI/CD
|
||||
|
||||
**流水线设计:**
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
rust-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo fmt --check --all
|
||||
- run: cargo clippy -- -D warnings
|
||||
|
||||
rust-test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env: { POSTGRES_DB: erp_test, POSTGRES_USER: test, POSTGRES_PASSWORD: test }
|
||||
ports: ["5432:5432"]
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --workspace
|
||||
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- run: cd apps/web && pnpm install && pnpm build
|
||||
|
||||
security-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo audit
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- run: cd apps/web && pnpm audit
|
||||
```
|
||||
|
||||
**关键决策:**
|
||||
- 使用 Gitea Actions(与 GitHub Actions 语法兼容)
|
||||
- 每个 job 包含 `actions/checkout@v4` + 对应语言 toolchain setup
|
||||
- Rust 使用 `Swatinem/rust-cache@v2` 缓存编译产物,避免每次全量编译
|
||||
- PostgreSQL 通过 service 容器提供
|
||||
- 四个 job 并行运行,互不依赖
|
||||
- 后续可扩展:Redis service、Playwright E2E、Docker 镜像构建推送
|
||||
|
||||
### 2.3 审计日志补全
|
||||
|
||||
**当前缺口与改进:**
|
||||
|
||||
| 缺口 | 改进方案 |
|
||||
|------|---------|
|
||||
| 登录/登出只发 DomainEvent,不写审计日志 | 在 `auth_service` 的 login/logout/change_password 中调用 `audit_service::record()` |
|
||||
| 审计日志缺少 `old_value`/`new_value` | 关键实体(user/role/permission/org)的 update 操作添加 `.with_changes(old, new)`。序列化完整的旧模型和新模型为 JSON,由审计日志消费者计算 diff — 比应用层计算细粒度 diff 更简单健壮 |
|
||||
| 缺少 IP 地址和 User-Agent | `AuditLogBuilder::with_request_info()` 在 handler 层传入请求上下文 |
|
||||
| 插件 CRUD 无审计 | `data_service` 的 create/update/delete 操作添加审计日志记录 |
|
||||
| 登录失败无记录 | 添加失败登录审计(含尝试的用户名/IP),用于入侵检测 |
|
||||
|
||||
**验证标准:**
|
||||
- 登录成功/失败均写入审计日志
|
||||
- 用户更新操作记录变更前后值
|
||||
- 审计日志包含 IP 和 User-Agent
|
||||
|
||||
### 2.4 Docker 生产化
|
||||
|
||||
| 改进项 | 当前 | 目标 |
|
||||
|--------|------|------|
|
||||
| PostgreSQL 端口 | `ports: "5432:5432"` 暴露到宿主机 | 移除 `ports:`,使用 Docker 网络内部通信 |
|
||||
| Redis 端口 | `ports: "6379:6379"` 无认证 | 移除 `ports:`,添加 `--requirepass` |
|
||||
| 容器资源限制 | 无 | CPU 1核 / 内存 512MB |
|
||||
| 应用镜像 | 无 Dockerfile | 多阶段构建:Rust build → 精简 runtime 镜像 |
|
||||
| Redis 宕机时限流 | fail-open(无限流) | fail-closed(拒绝请求) |
|
||||
|
||||
**限流 fail-closed 改动:**
|
||||
`crates/erp-server/src/middleware/rate_limit.rs` 中 Redis 不可用时,返回 `429 Too Many Requests` 而非放行。
|
||||
|
||||
### 2.5 多租户安全加固
|
||||
|
||||
| 问题 | 改进方案 |
|
||||
|------|---------|
|
||||
| 登录使用硬编码 `default_tenant_id` | 登录接口增加租户解析(从子域名/请求头 `X-Tenant-ID`) |
|
||||
| `auth_service::refresh()` 用户查询缺少 tenant_id(`auth_service.rs:177`) | `find_by_id` 添加 `.filter(user::Column::TenantId.eq(claims.tenant_id))` |
|
||||
| 内存级 tenant_id 过滤(`user_service.rs` 的 `get_by_id`/`update`/`delete`) | 改为数据库级 `.filter(Column::TenantId.eq(tenant_id))` 查询。注意:`login`/`list`/`assign_roles` 已正确使用数据库级过滤,无需修改 |
|
||||
|
||||
**涉及文件:**
|
||||
- `crates/erp-auth/src/handler/auth_handler.rs`
|
||||
- `crates/erp-auth/src/service/auth_service.rs`
|
||||
- `crates/erp-auth/src/service/user_service.rs`
|
||||
- `crates/erp-auth/src/middleware/jwt_auth.rs`
|
||||
|
||||
---
|
||||
|
||||
## 3. Q3:架构强化 + 前端体验(6-8月)
|
||||
|
||||
### 3.1 ErpModule Trait 重构
|
||||
|
||||
**当前问题:**
|
||||
- `register_event_handlers` 是死代码 — 所有模块实现为空操作
|
||||
- 路由注册需在 `main.rs` 手动编辑两处
|
||||
- 事件订阅在 `main.rs` 中手动调用,绕过 trait
|
||||
|
||||
**改进方案:**
|
||||
|
||||
基于当前 trait 签名(`erp-core/src/module.rs`),新增双路由注册和权限声明。保持与现有 `ModuleContext` 参数一致,不引入 `AppState` 依赖(避免 `erp-core` → `erp-server` 反向依赖):
|
||||
|
||||
```rust
|
||||
pub trait ErpModule: Send + Sync + 'static {
|
||||
// 保留已有方法
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> &str;
|
||||
fn module_type(&self) -> &str { "business" }
|
||||
fn dependencies(&self) -> Vec<&str> { vec![] }
|
||||
fn id(&self) -> Uuid { /* 默认实现 */ }
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
|
||||
// 新增:双路由注册(匹配现有 public/protected 分离模式)
|
||||
fn register_public_routes(&self, router: Router) -> Router { router }
|
||||
fn register_protected_routes(&self, router: Router) -> Router { router }
|
||||
|
||||
// 重构:事件订阅真正生效(当前所有模块实现为空操作)
|
||||
fn register_event_handlers(&self, bus: &EventBus) {}
|
||||
|
||||
// 新增:模块权限声明
|
||||
fn permissions(&self) -> Vec<PermissionDef> { vec![] }
|
||||
|
||||
// 保留已有生命周期钩子(保持 ModuleContext 参数签名)
|
||||
async fn on_startup(&self, _ctx: &ModuleContext) -> AppResult<()> { Ok(()) }
|
||||
async fn on_shutdown(&self) -> AppResult<()> { Ok(()) }
|
||||
async fn on_tenant_created(&self, _tenant_id: Uuid, _db: &DatabaseConnection, _bus: &EventBus) -> AppResult<()> { Ok(()) }
|
||||
async fn on_tenant_deleted(&self, _tenant_id: Uuid, _db: &DatabaseConnection, _bus: &EventBus) -> AppResult<()> { Ok(()) }
|
||||
async fn health_check(&self) -> AppResult<serde_json::Value> { Ok(serde_json::json!({})) }
|
||||
}
|
||||
```
|
||||
|
||||
`ModuleRegistry::build()` 自动收集路由、事件处理器和权限,`main.rs` 简化为:
|
||||
|
||||
```rust
|
||||
let (registry, public_routes, protected_routes) = ModuleRegistry::new()
|
||||
.register(auth_module)
|
||||
.register(config_module)
|
||||
.register(workflow_module)
|
||||
.register(message_module)
|
||||
.register(plugin_module)
|
||||
.build();
|
||||
|
||||
// 自动组合:public_routes 直接挂载,protected_routes 包裹 JWT 中间件
|
||||
let app = Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(protected_routes.layer(jwt_middleware));
|
||||
```
|
||||
|
||||
**迁移策略:** 逐模块迁移 — 每个模块从静态 `public_routes()`/`protected_routes()` 函数改为 trait 方法实现,`main.rs` 逐步简化。
|
||||
|
||||
**已知例外:** PluginModule 的两阶段初始化(先注册再启动事件监听器)在初期保持独立处理,不强行纳入自动化。`MessageModule::start_event_listener`、`WorkflowModule::start_timeout_checker`、`outbox::start_outbox_relay` 等独立生命周期钩子作为范围排除项,后续迭代再统一。
|
||||
|
||||
**迁移策略:** 逐模块迁移 — 每个模块从静态函数改为 trait 方法实现,`main.rs` 逐步简化。
|
||||
|
||||
### 3.2 错误映射修正 + N+1 查询优化
|
||||
|
||||
**错误映射修正:**
|
||||
|
||||
当前 `erp-auth` 服务中直接 `.map_err(|e| AuthError::Validation(e.to_string()))` 将所有 `DbErr` 映射为 `Validation`,绕过了 `erp-core` 中已有的 `From<DbErr> for AppError` 语义映射(该映射已正确处理 `RecordNotFound` → `NotFound`、重复键 → `Conflict`)。
|
||||
|
||||
**修复策略:** `erp-auth` 服务层停止手动包装 `DbErr`,改为通过 `?` 操作符依赖 `DbErr → AppError` 的核心映射,通过现有的 `From<AuthError> for AppError` 转换传播。这样数据库连接错误会正确显示为 `Internal`,唯一约束冲突会正确显示为 `Conflict`。
|
||||
|
||||
移除 `From<AppError> for AuthError` 的反向映射(当前是 lossy wrapping — `AppError::NotFound` 变为 `AuthError::Validation`,丢失语义信息)。
|
||||
|
||||
**N+1 查询优化:**
|
||||
|
||||
`user_service.rs` 的 `list()` 方法改为批量查询:
|
||||
1. 先查询当前页用户列表
|
||||
2. 收集所有 `user_id`
|
||||
3. 一次 `WHERE user_id IN (...)` 查询 `user_role` + `role`
|
||||
4. 内存中按 `user_id` 分组组装
|
||||
|
||||
从 N+1 查询降为 3 次固定查询(用户列表 + 角色关联 + 角色详情)。
|
||||
|
||||
### 3.3 前端 Error Boundary + hooks 提取
|
||||
|
||||
**Error Boundary:**
|
||||
- `App.tsx` 根组件包裹全局 Error Boundary(捕获未预期崩溃)
|
||||
- 每个懒加载页面外包裹页面级 Error Boundary(隔离单页面崩溃)
|
||||
- 失败时展示友好错误页面 + 重试按钮
|
||||
|
||||
**hooks 提取:**
|
||||
|
||||
| Hook | 提取来源 | 用途 |
|
||||
|------|---------|------|
|
||||
| `usePaginatedData<T>` | 6+ 页面的分页加载逻辑 | 统一分页/搜索/加载状态 |
|
||||
| `useDarkMode` | 8+ 文件的 `token.colorBgContainer` 字符串比较 | 提供可靠的 boolean 暗色模式判断 |
|
||||
| `useCountUp` | Home.tsx + DashboardWidgets 重复实现 | 计数动画复用 |
|
||||
| `useDebouncedValue` | Users.tsx 等搜索输入 | 防抖搜索,避免每次按键触发 API |
|
||||
| `useApiRequest` | 所有页面的 try/catch + message.error | 统一 API 错误处理和消息提示 |
|
||||
|
||||
### 3.4 i18n 基础设施搭建
|
||||
|
||||
**方案:react-i18next**
|
||||
|
||||
- 安装 `react-i18next` + `i18next`
|
||||
- 创建 `locales/zh-CN.json`,提取所有硬编码中文为 key
|
||||
- 配置 i18next 初始化,默认 `zh-CN`
|
||||
- 用 `useTranslation()` hook 替换硬编码字符串
|
||||
|
||||
**实施策略:** 增量式 — 新页面强制使用 i18n,旧页面按模块逐步迁移。不强求一次性替换。
|
||||
|
||||
**命名规范:**
|
||||
- 页面文案:`{module}.{page}.{element}` 如 `auth.login.username`
|
||||
- 通用文案:`common.{action}` 如 `common.save`, `common.cancel`
|
||||
- 错误消息:`error.{type}` 如 `error.network`, `error.unauthorized`
|
||||
|
||||
### 3.5 行级数据权限接线
|
||||
|
||||
**当前状态:** 数据库列、SQL 条件构建器、manifest 声明已就绪,handler 层有 TODO 未实现。
|
||||
|
||||
**完成步骤:**
|
||||
1. JWT 中间件注入 `department_ids`(完成 `jwt_auth.rs:50` 的 TODO)
|
||||
2. `data_handler` 查询接口注入 data scope 条件
|
||||
3. 前端角色权限编辑页添加 `data_scope` 选择控件
|
||||
4. 端到端验证:创建测试角色 → 设置数据范围 → 验证查询过滤
|
||||
|
||||
### 3.6 前端共享类型统一
|
||||
|
||||
- `PaginatedResponse<T>` 从 `users.ts` 提取到 `api/types.ts`
|
||||
- 错误提取工具函数 `extractErrorMessage(err: unknown): string` → `api/errors.ts`
|
||||
- 插件 Schema 类型定义集中到 `types/plugin.ts`
|
||||
- 移除 `api/client.ts` 中已废弃的 `CancelToken`,改用 `AbortController`
|
||||
|
||||
---
|
||||
|
||||
## 4. Q4:测试覆盖 + 插件生态(9-11月)
|
||||
|
||||
### 4.1 Q4 范围调整说明
|
||||
|
||||
Q4 原始范围较大(Testcontainers + Playwright + 进销存插件 + 热更新 + 文档清理)。调整为两个子阶段:
|
||||
|
||||
- **Q4a(9-10月)**:测试基础设施 — Testcontainers 集成测试框架 + Playwright E2E + 文档清理
|
||||
- **Q4b(11月+)**:插件生态 — 进销存插件 + 热更新
|
||||
|
||||
热更新功能可视 Q4a 进度推迟到 Q1 2027,避免在单季度内承载过多工作。
|
||||
|
||||
### 4.2 数据库集成测试框架
|
||||
|
||||
**方案:Testcontainers + PostgreSQL**
|
||||
|
||||
创建 `crates/erp-server/tests/integration/` 目录,使用 `testcontainers` crate 启动真实 PostgreSQL 容器。
|
||||
|
||||
**测试基座:**
|
||||
- 每个测试套件共享一个 PostgreSQL 容器
|
||||
- 自动运行所有迁移
|
||||
- 提供 `setup_test_db()` 辅助函数返回连接池
|
||||
- 测试结束自动清理
|
||||
|
||||
**覆盖优先级:**
|
||||
|
||||
| 优先级 | 模块 | 测试场景 |
|
||||
|--------|------|---------|
|
||||
| P0 | erp-auth | 用户 CRUD、角色权限分配、登录/JWT 完整流程 |
|
||||
| P0 | erp-auth | 多租户隔离 — 租户 A 数据对租户 B 不可见 |
|
||||
| P0 | erp-plugin | 插件生命周期(install→enable→disable→uninstall) |
|
||||
| P1 | erp-auth | 乐观锁并发冲突、软删除恢复 |
|
||||
| P1 | erp-plugin | 行级数据权限过滤、JSONB 查询/索引 |
|
||||
| P1 | erp-plugin | 动态表 DDL 正确性(generated column、pg_trgm 索引) |
|
||||
| P1 | erp-workflow | 流程实例启动、任务完成、网关分支 |
|
||||
| P1 | erp-core | 事件总线发布/订阅端到端、outbox relay 补偿 |
|
||||
|
||||
### 4.2 核心流程 E2E 测试
|
||||
|
||||
**方案:Playwright**
|
||||
|
||||
放在 `apps/web/e2e/` 目录,CI 中作为独立 job 运行。
|
||||
|
||||
**覆盖场景(4 个关键旅程):**
|
||||
|
||||
| 场景 | 步骤 |
|
||||
|------|------|
|
||||
| 完整登录流程 | 打开登录页 → 输入密码 → 验证 token → 刷新 token → 登出 → 验证跳转 |
|
||||
| 用户管理闭环 | 创建用户 → 分配角色 → 搜索用户 → 编辑 → 软删除 → 验证列表不显示 |
|
||||
| 插件安装流程 | 上传 WASM → 安装 → 验证菜单出现 → 数据 CRUD → 卸载 → 验证菜单消失 |
|
||||
| 多租户隔离 | 租户 A 创建用户 → 切换租户 B → 验证查询结果为空 |
|
||||
|
||||
### 4.3 第二个行业插件 — 进销存(Inventory)
|
||||
|
||||
**选择理由:**
|
||||
- 与 CRM 有天然关联(客户 → 订单 → 出库)
|
||||
- 实体数量适中(5-8 个),复杂度可控
|
||||
- 能验证插件系统的复用性和跨实体关联能力
|
||||
- 为后续财务模块铺垫
|
||||
|
||||
**实体设计:**
|
||||
|
||||
| 实体 | 字段 | 关联 |
|
||||
|------|------|------|
|
||||
| product 商品 | 名称/编码/规格/单位/分类/售价/成本价 | — |
|
||||
| warehouse 仓库 | 名称/地址/负责人/状态 | — |
|
||||
| stock 库存 | 商品/仓库/数量/成本/预警线 | → product, warehouse |
|
||||
| purchase_order 采购单 | 供应商/总金额/状态/日期 | → supplier(CRM), stock |
|
||||
| sales_order 销售单 | 客户/总金额/状态/日期 | → customer(CRM), stock |
|
||||
| supplier 供应商 | 名称/编码/联系方式/地址 | — |
|
||||
|
||||
**需要验证的插件能力:**
|
||||
- 跨实体关联(订单 → 商品 → 库存联动)
|
||||
- 事务性事件(库存扣减在订单确认时原子执行)
|
||||
- 页面间导航(从订单跳转客户详情)
|
||||
- 报表/统计页面(库存汇总、进销存明细)
|
||||
|
||||
### 4.4 插件热更新能力
|
||||
|
||||
**当前限制:** 更新插件需要完整 uninstall/reinstall。
|
||||
|
||||
**改进方案:**
|
||||
- 新增 `POST /api/v1/admin/plugins/{id}/upgrade` 端点
|
||||
- 升级流程:上传新 WASM → 对比 manifest schema → 增量 DDL(ADD COLUMN 等) → 热替换 WASM 模块
|
||||
- 数据安全:`tenant_id` 数据不丢失
|
||||
- 版本兼容性检查:新版本必须向后兼容或提供迁移脚本
|
||||
|
||||
**回滚策略:** 升级前创建 schema 备份点。升级流程分两步执行:
|
||||
1. 先暂存新 WASM 并尝试验证初始化(不应用 DDL)
|
||||
2. 初始化成功后,在单事务中执行 DDL 变更 + 状态转换
|
||||
3. 如果新 WASM 初始化失败,保持旧 WASM 继续运行,回滚暂存状态
|
||||
4. DDL 已应用但 WASM 运行异常时,保留旧 WASM 可加载作为 fallback
|
||||
|
||||
### 4.5 文档更新与清理
|
||||
|
||||
| 项目 | 改进 |
|
||||
|------|------|
|
||||
| Wiki 文档 | 全面更新到当前状态(前端路由、测试数量、模块能力、插件系统) |
|
||||
| CLAUDE.md | 版本号修正(React 19 / Ant Design 6) |
|
||||
| 根目录清理 | 删除未跟踪的开发临时文件(截图、heap dump、perf trace、agent plan 文件) |
|
||||
| integration-tests/ | 验证现有测试是否能编译。若已失效则删除,用新的 Testcontainers 框架替代;若仍有效则纳入 Cargo workspace |
|
||||
| N+1 查询(plugin) | `plugin_service.rs` 的列表查询也存在 N+1 问题(每条插件单独查询 entities),需一并优化 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险与缓解
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| 安全修复引入新 bug | 中 | 高 | 每个修复配有对应的测试用例 |
|
||||
| ErpModule trait 重构影响所有模块 | 高 | 中 | 逐模块迁移,每步验证 `cargo test` |
|
||||
| i18n 迁移工作量大 | 中 | 低 | 增量式,不追求一次性完成 |
|
||||
| Testcontainers 在 CI 环境不稳定 | 低 | 中 | 本地开发可跳过集成测试,CI 用 service container 兜底 |
|
||||
| Testcontainers 在 Windows (WSL2) 上兼容性 | 中 | 中 | 主开发环境为 Windows 11,Testcontainers 对 Windows 支持有限。本地开发可依赖 CI service container 运行集成测试,或使用 WSL2 环境 |
|
||||
| 进销存插件实体设计变更 | 中 | 低 | 先完成最小实体集,后续迭代扩展 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 成功标准
|
||||
|
||||
**Q2 完成标准:**
|
||||
- [ ] 3 个 CRITICAL 安全问题全部修复
|
||||
- [ ] Gitea Actions CI/CD 流水线运行通过
|
||||
- [ ] 默认配置启动被拒绝
|
||||
- [ ] 登录/登出写入审计日志
|
||||
- [ ] Docker 生产化配置就绪
|
||||
|
||||
**Q3 完成标准:**
|
||||
- [ ] ErpModule trait 路由注册自动化
|
||||
- [ ] N+1 查询优化,用户列表查询次数固定为 3
|
||||
- [ ] 前端 Error Boundary 覆盖全局 + 页面级
|
||||
- [ ] 5 个自定义 hooks 提取完成
|
||||
- [ ] i18n 基础设施可用,至少 1 个页面完成迁移
|
||||
- [ ] 行级数据权限端到端验证通过
|
||||
|
||||
**Q4 完成标准:**
|
||||
- [ ] 集成测试覆盖 auth + plugin 核心流程
|
||||
- [ ] 4 个 E2E 测试场景通过
|
||||
- [ ] 进销存插件 6 个实体可用
|
||||
- [ ] 插件热更新功能可用
|
||||
- [ ] Wiki 文档与代码同步
|
||||
@@ -0,0 +1,710 @@
|
||||
# 健康管理系统 — erp-health 模块设计规格
|
||||
|
||||
> **文档版本**: 1.0
|
||||
> **日期**: 2026-04-23
|
||||
> **状态**: 已确认
|
||||
> **范围**: V1 — 患者管理 + 健康数据 + 预约排班 + 随访管理 + 咨询管理
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目背景
|
||||
|
||||
### 1.1 产品定位
|
||||
|
||||
构建一个面向体检中心/医疗机构的**综合型健康管理平台**,以体检中心为数据源,汇集不同情况的患者,提供全生命周期的健康管理服务。
|
||||
|
||||
本系统从 ERP 平台底座分叉独立,作为 **Health Management System (HMS)** 产品演进。ERP 底座提供身份权限、工作流、消息通知、系统配置等基础能力,`erp-health` 作为原生 Rust 模块承载所有医疗业务逻辑。
|
||||
|
||||
### 1.2 系统架构
|
||||
|
||||
```
|
||||
📱 患者端(微信小程序) ──┐
|
||||
├──→ 🔀 API 网关 ──→ 🖥️ ERP 后端(HMS)
|
||||
👨⚕️ 医护端(小程序/H5) ──┘ │ │
|
||||
│ ├── erp-auth(用户/角色/权限)
|
||||
│ ├── erp-workflow(工作流引擎)
|
||||
│ ├── erp-message(消息通知)
|
||||
│ ├── erp-config(字典/配置)
|
||||
│ └── erp-health(健康管理)★ 新增
|
||||
│
|
||||
└──→ 💾 PostgreSQL + Redis
|
||||
```
|
||||
|
||||
**关键决策:**
|
||||
- ERP 只负责 **PC 管理后台**功能
|
||||
- 小程序(患者端/医护端)作为**独立系统**开发
|
||||
- 数据共享通过 **API 网关**实现
|
||||
- 健康管理使用**原生 Rust 模块**(非 WASM 插件),获得完整的数据库访问和自定义 API 能力
|
||||
|
||||
### 1.3 为什么不用 WASM 插件
|
||||
|
||||
| 限制 | 影响 |
|
||||
|------|------|
|
||||
| 实体上限 20 个 | 综合健康平台轻松超过 |
|
||||
| JSONB 存储 | 医疗数据需要强类型、索引、关联 |
|
||||
| 无自定义 API | 趋势分析、统计报表需要专用端点 |
|
||||
| 无文件上传 | 化验单、体检报告无法存储 |
|
||||
| WASM 沙箱限制 | 无法引入加密、AI、外部 API |
|
||||
|
||||
原生模块遵循现有模式(如 erp-auth、erp-workflow)。**注意:**`ErpModule` trait 没有 `register_routes` 方法。模块通过固有方法 `public_routes()` 和 `protected_routes()` 暴露路由,在 `erp-server` 的 `main.rs` 中通过 `.nest("/api/v1/health", HealthModule::protected_routes())` 集成。通过 EventBus 通信,未来可平滑拆分为独立微服务。
|
||||
|
||||
---
|
||||
|
||||
## 2. V1 功能范围
|
||||
|
||||
| 模块 | 功能 | 页面数 |
|
||||
|------|------|--------|
|
||||
| ① 患者与医护管理 | 患者档案、家庭成员、医护档案、患者标签 | 3 |
|
||||
| ② 健康数据管理 | 体检记录、日常监测、化验报告、趋势分析 | 3 |
|
||||
| ③ 预约与排班 | 预约管理、医生排班、日历视图 | 2 |
|
||||
| ④ 随访管理 | 随访任务、随访记录台账 | 2 |
|
||||
| ⑤ 咨询管理 | 会话管理、对话记录查看/导出 | 2 |
|
||||
| ⑥ 医护管理 | 医护人员列表 | 1 |
|
||||
| **合计** | | **13** |
|
||||
|
||||
**V2 预留:** 积分商城、数据统计中心、内容管理增强。
|
||||
|
||||
---
|
||||
|
||||
## 3. 实体模型
|
||||
|
||||
### 3.1 设计原则
|
||||
|
||||
- 患者和医护的**账号**走 `erp-auth` 的 `users` 表,`erp-health` 只存医疗业务扩展字段
|
||||
- 通过 `user_id` 外键关联 `users` 表
|
||||
- 所有表含 `tenant_id`(多租户隔离)、`id`(UUIDv7)、`created_at`、`updated_at`、`created_by`、`updated_by`、`deleted_at`、`version`
|
||||
- 多对多关系使用中间表
|
||||
|
||||
### 3.2 实体定义
|
||||
|
||||
#### ① 患者与医护管理
|
||||
|
||||
**patient — 患者档案**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | UUIDv7 |
|
||||
| tenant_id | UUID NOT NULL | 租户 ID |
|
||||
| user_id | UUID FK → users | 关联 erp-auth 账号 |
|
||||
| name | VARCHAR(100) | 姓名 |
|
||||
| gender | VARCHAR(10) | 性别 (male/female/other) |
|
||||
| birth_date | DATE | 出生日期 |
|
||||
| blood_type | VARCHAR(10) | 血型 (A/B/AB/O/RH-/RH+) |
|
||||
| id_number | VARCHAR(20) | 身份证号 |
|
||||
| allergy_history | TEXT | 过敏史 |
|
||||
| medical_history_summary | TEXT | 病史摘要 |
|
||||
| emergency_contact_name | VARCHAR(100) | 紧急联系人姓名 |
|
||||
| emergency_contact_phone | VARCHAR(20) | 紧急联系人电话 |
|
||||
| status | VARCHAR(20) | 状态 (active/inactive/deceased) |
|
||||
| verification_status | VARCHAR(20) | 实名认证 (pending/verified/rejected) |
|
||||
| source | VARCHAR(100) | 来源(体检中心名称) |
|
||||
| notes | TEXT | 备注 |
|
||||
| created_at, updated_at, created_by, updated_by, deleted_at, version | — | 标准字段 |
|
||||
|
||||
索引:`(tenant_id, name)`, `(tenant_id, status)`, `(tenant_id, id_number) UNIQUE WHERE deleted_at IS NULL`
|
||||
|
||||
**patient_family_member — 家庭成员**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | 患者关联 |
|
||||
| name | VARCHAR(100) | 姓名 |
|
||||
| relationship | VARCHAR(50) | 关系(父亲/母亲/配偶/子女等) |
|
||||
| phone | VARCHAR(20) | 电话 |
|
||||
| birth_date | DATE | 出生日期 |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
**doctor_profile — 医护档案**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| user_id | UUID FK → users | 关联 erp-auth 账号 |
|
||||
| department | VARCHAR(100) | 科室 |
|
||||
| title | VARCHAR(50) | 职称(主任医师/副主任医师/主治医师等) |
|
||||
| specialty | VARCHAR(200) | 专长 |
|
||||
| license_number | VARCHAR(50) | 执业证号 |
|
||||
| bio | TEXT | 简介 |
|
||||
| online_status | VARCHAR(20) | 在线状态 (online/offline/busy) |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id)`
|
||||
|
||||
**patient_tag — 患者标签**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| name | VARCHAR(50) | 标签名 |
|
||||
| color | VARCHAR(20) | 颜色值 |
|
||||
| description | TEXT | 描述 |
|
||||
| is_system | BOOLEAN | 系统标签(不可删除) |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`UNIQUE (tenant_id, name) WHERE deleted_at IS NULL`
|
||||
|
||||
**patient_tag_relation — 患者-标签关联**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| tag_id | UUID FK → patient_tag | |
|
||||
| created_at | TIMESTAMPTZ | |
|
||||
| updated_at | TIMESTAMPTZ | |
|
||||
| created_by | UUID | |
|
||||
| updated_by | UUID | |
|
||||
| deleted_at | TIMESTAMPTZ | 软删除 |
|
||||
|
||||
索引:`(tenant_id, patient_id)`, `(tenant_id, tag_id)`
|
||||
|
||||
**patient_doctor_relation — 医患关系**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| relationship_type | VARCHAR(20) | 类型 (primary/consulting) |
|
||||
| created_at | TIMESTAMPTZ | |
|
||||
| updated_at | TIMESTAMPTZ | |
|
||||
| created_by | UUID | |
|
||||
| updated_by | UUID | |
|
||||
| deleted_at | TIMESTAMPTZ | 软删除 |
|
||||
|
||||
索引:`(tenant_id, patient_id)`, `(tenant_id, doctor_id)`
|
||||
|
||||
#### ② 健康数据管理
|
||||
|
||||
**health_record — 体检/就诊记录**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| record_type | VARCHAR(20) | 类型 (checkup/outpatient/inpatient) |
|
||||
| record_date | DATE | 记录日期 |
|
||||
| source | VARCHAR(200) | 来源(体检中心/医院名称) |
|
||||
| overall_assessment | TEXT | 总体评估 |
|
||||
| report_file_url | VARCHAR(500) | 报告文件 URL |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, record_date DESC)`
|
||||
|
||||
**vital_signs — 日常监测数据**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| record_date | DATE | 记录日期 |
|
||||
| systolic_bp_morning | INTEGER | 晨起收缩压 |
|
||||
| diastolic_bp_morning | INTEGER | 晨起舒张压 |
|
||||
| systolic_bp_evening | INTEGER | 晚间收缩压 |
|
||||
| diastolic_bp_evening | INTEGER | 晚间舒张压 |
|
||||
| heart_rate | INTEGER | 心率 |
|
||||
| weight | DECIMAL(5,1) | 体重 (kg) |
|
||||
| blood_sugar | DECIMAL(5,1) | 血糖 (mmol/L) |
|
||||
| water_intake_ml | INTEGER | 饮水量 (ml) |
|
||||
| urine_output_ml | INTEGER | 尿量 (ml) |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, record_date DESC)`
|
||||
|
||||
**lab_report — 化验报告**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| report_date | DATE | 报告日期 |
|
||||
| report_type | VARCHAR(50) | 报告类型(肾功能/血常规/尿常规等) |
|
||||
| indicators | JSONB | 指标数据 [{name, value, unit, ref_range, is_abnormal}] |
|
||||
| image_urls | JSONB | 图片 URLs [url1, url2, ...] |
|
||||
| doctor_interpretation | TEXT | 医生解读 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, report_date DESC)`, GIN on `indicators`, `(tenant_id, report_type)`
|
||||
|
||||
**health_trend — 健康趋势报告**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| period_start | DATE | 周期开始 |
|
||||
| period_end | DATE | 周期结束 |
|
||||
| indicator_summary | JSONB | 指标摘要 |
|
||||
| abnormal_items | JSONB | 异常项 |
|
||||
| generation_type | VARCHAR(20) | 生成方式 (auto/manual) |
|
||||
| report_file_url | VARCHAR(500) | 报告文件 URL |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, patient_id, period_start DESC)`
|
||||
|
||||
#### ③ 预约排班
|
||||
|
||||
**appointment — 预约记录**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| appointment_type | VARCHAR(20) | 类型 (dialysis/recheck/outpatient) |
|
||||
| appointment_date | DATE | 预约日期 |
|
||||
| start_time | TIME | 开始时间 |
|
||||
| end_time | TIME | 结束时间 |
|
||||
| status | VARCHAR(20) | 状态 (pending/confirmed/cancelled/completed/no_show) |
|
||||
| cancel_reason | TEXT | 取消原因 |
|
||||
| notes | TEXT | 备注 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, appointment_date, status)`, `(tenant_id, doctor_id, appointment_date)`
|
||||
|
||||
**doctor_schedule — 医生排班**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| schedule_date | DATE | 排班日期 |
|
||||
| period_type | VARCHAR(20) | 时段 (am/pm/night/full_day) |
|
||||
| start_time | TIME | 开始时间 |
|
||||
| end_time | TIME | 结束时间 |
|
||||
| max_appointments | INTEGER | 最大预约数 |
|
||||
| current_appointments | INTEGER | 已预约数(默认 0) |
|
||||
| status | VARCHAR(20) | 状态 (enabled/disabled) |
|
||||
| 标准 ERP 字段 | — |
|
||||
|
||||
索引:`(tenant_id, doctor_id, schedule_date)`, `UNIQUE (tenant_id, doctor_id, schedule_date, period_type) WHERE deleted_at IS NULL`
|
||||
|
||||
**预约并发控制:** 创建预约时使用原子 CAS 操作 `UPDATE doctor_schedule SET current_appointments = current_appointments + 1 WHERE id = $1 AND current_appointments < max_appointments RETURNING *`,防止超额预约。
|
||||
|
||||
#### ④ 随访管理
|
||||
|
||||
**follow_up_task — 随访任务**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| assigned_to | UUID FK → users | 负责医护 |
|
||||
| follow_up_type | VARCHAR(20) | 类型 (phone/face_to_face/online) |
|
||||
| planned_date | DATE | 计划日期 |
|
||||
| status | VARCHAR(20) | 状态 (pending/in_progress/completed/overdue/cancelled) |
|
||||
| content_template | TEXT | 随访内容模板 |
|
||||
| related_appointment_id | UUID FK → appointment | 关联预约 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, assigned_to, status)`, `(tenant_id, planned_date, status)`
|
||||
|
||||
**follow_up_record — 随访记录**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| task_id | UUID FK → follow_up_task | |
|
||||
| executed_by | UUID FK → users | 执行医护 |
|
||||
| executed_date | DATE | 执行日期 |
|
||||
| result | VARCHAR(20) | 结果 (followed_up/unreachable/refused/other) |
|
||||
| patient_condition | TEXT | 患者状况 |
|
||||
| medical_advice | TEXT | 医嘱建议 |
|
||||
| next_follow_up_date | DATE | 下次随访日期 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, task_id)`, `(tenant_id, executed_date)`
|
||||
|
||||
#### ⑤ 咨询管理
|
||||
|
||||
**consultation_session — 咨询会话**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| patient_id | UUID FK → patient | |
|
||||
| doctor_id | UUID FK → doctor_profile | |
|
||||
| type | VARCHAR(20) | 类型 (customer_service/doctor) |
|
||||
| status | VARCHAR(20) | 状态 (waiting/active/closed) |
|
||||
| last_message_at | TIMESTAMPTZ | 最后消息时间 |
|
||||
| unread_count_patient | INTEGER | 患者未读数 |
|
||||
| unread_count_doctor | INTEGER | 医生未读数 |
|
||||
| 标准 ERP 字段 | — | |
|
||||
|
||||
索引:`(tenant_id, doctor_id, status)`, `(tenant_id, patient_id, status)`
|
||||
|
||||
**consultation_message — 咨询消息**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID NOT NULL | |
|
||||
| session_id | UUID FK → consultation_session | |
|
||||
| sender_id | UUID | 发送者 ID |
|
||||
| sender_role | VARCHAR(20) | 角色 (patient/doctor/system) |
|
||||
| content_type | VARCHAR(20) | 类型 (text/image/voice/file) |
|
||||
| content | TEXT | 内容 |
|
||||
| is_read | BOOLEAN | 已读状态(默认 false) |
|
||||
| created_at | TIMESTAMPTZ | 发送时间 |
|
||||
| updated_at | TIMESTAMPTZ | |
|
||||
| created_by | UUID | |
|
||||
| updated_by | UUID | |
|
||||
| deleted_at | TIMESTAMPTZ | 软删除(内容审核用) |
|
||||
| version | INT NOT NULL DEFAULT 1 | 乐观锁 |
|
||||
|
||||
索引:`(tenant_id, session_id, created_at)`
|
||||
|
||||
**数据增长策略:** 对 `created_at` 按月分区(PostgreSQL table partitioning),超过 1 年的已关闭会话消息归档到冷存储。
|
||||
|
||||
**说明:**
|
||||
- `patient.user_id` 允许 NULL — 患者可先创建档案(如体检中心导入),后续再绑定 erp-auth 账号
|
||||
- `consultation_message.sender_id` 引用 `users.id` — 统一使用 erp-auth 用户体系标识发送者
|
||||
|
||||
---
|
||||
|
||||
## 3.3 状态机定义
|
||||
|
||||
### appointment.status 转换
|
||||
|
||||
```
|
||||
pending ──→ confirmed ──→ completed
|
||||
│ │
|
||||
│ └──→ no_show(预约时间过后,系统自动或前台手动触发)
|
||||
│
|
||||
└──→ cancelled(任意时刻可取消,需填 cancel_reason)
|
||||
```
|
||||
|
||||
### follow_up_task.status 转换
|
||||
|
||||
```
|
||||
pending ──→ in_progress ──→ completed
|
||||
│ │
|
||||
└──→ cancelled └──→ overdue(系统定时任务:planned_date 已过且仍 pending 自动标记)
|
||||
```
|
||||
|
||||
### consultation_session.status 转换
|
||||
|
||||
```
|
||||
waiting ──→ active(第一条消息发送时自动触发)──→ closed(手动关闭或超时自动关闭)
|
||||
```
|
||||
|
||||
### patient.status 转换
|
||||
|
||||
```
|
||||
active ──→ inactive(手动停用)
|
||||
active ──→ deceased(标记死亡,不可逆)
|
||||
inactive ──→ active(重新激活)
|
||||
```
|
||||
|
||||
### patient.verification_status 转换
|
||||
|
||||
```
|
||||
pending ──→ verified(实名认证通过)
|
||||
pending ──→ rejected(认证被拒)
|
||||
rejected ──→ pending(重新提交认证)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 设计
|
||||
|
||||
所有端点前缀: `/api/v1/health/`
|
||||
|
||||
### 4.1 患者管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/patients` | 患者列表(分页、搜索、标签筛选) |
|
||||
| POST | `/patients` | 创建患者 |
|
||||
| GET | `/patients/:id` | 患者详情 |
|
||||
| PUT | `/patients/:id` | 更新患者 |
|
||||
| DELETE | `/patients/:id` | 软删除 |
|
||||
| POST | `/patients/:id/tags` | 管理标签(批量设置) |
|
||||
| GET | `/patients/:id/health-summary` | 健康摘要 |
|
||||
| GET | `/patients/:id/family-members` | 家庭成员列表 |
|
||||
| POST | `/patients/:id/family-members` | 新增家庭成员 |
|
||||
| PUT | `/patients/:id/family-members/:fid` | 更新家庭成员 |
|
||||
| DELETE | `/patients/:id/family-members/:fid` | 删除家庭成员 |
|
||||
| POST | `/patients/:id/doctors` | 分配主治医生 |
|
||||
| DELETE | `/patients/:id/doctors/:did` | 移除医患关系 |
|
||||
|
||||
### 4.2 健康数据
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/patients/:id/vital-signs` | 日常监测列表 |
|
||||
| POST | `/patients/:id/vital-signs` | 新增监测数据 |
|
||||
| GET | `/patients/:id/lab-reports` | 化验报告列表 |
|
||||
| POST | `/patients/:id/lab-reports` | 新增化验报告 |
|
||||
| GET | `/patients/:id/health-records` | 体检/就诊记录 |
|
||||
| POST | `/patients/:id/health-records` | 新增记录 |
|
||||
| GET | `/patients/:id/trends` | 趋势报告 |
|
||||
| POST | `/patients/:id/trends/generate` | 生成趋势报告 |
|
||||
| GET | `/patients/:id/trends/:indicator` | 单指标时序数据 |
|
||||
| PUT | `/patients/:id/vital-signs/:vid` | 更新监测数据 |
|
||||
| DELETE | `/patients/:id/vital-signs/:vid` | 删除监测数据 |
|
||||
| PUT | `/patients/:id/lab-reports/:rid` | 更新化验报告 |
|
||||
| DELETE | `/patients/:id/lab-reports/:rid` | 删除化验报告 |
|
||||
| PUT | `/patients/:id/health-records/:rid` | 更新体检记录 |
|
||||
| DELETE | `/patients/:id/health-records/:rid` | 删除体检记录 |
|
||||
|
||||
### 4.3 预约排班
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/appointments` | 预约列表 |
|
||||
| POST | `/appointments` | 创建预约 |
|
||||
| PUT | `/appointments/:id/status` | 更新状态 |
|
||||
| GET | `/doctor-schedules` | 排班列表 |
|
||||
| POST | `/doctor-schedules` | 创建排班 |
|
||||
| PUT | `/doctor-schedules/:id` | 更新排班 |
|
||||
| GET | `/doctor-schedules/calendar` | 日历视图 |
|
||||
|
||||
### 4.4 随访管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/follow-up-tasks` | 任务列表 |
|
||||
| POST | `/follow-up-tasks` | 创建任务 |
|
||||
| PUT | `/follow-up-tasks/:id` | 更新任务 |
|
||||
| DELETE | `/follow-up-tasks/:id` | 删除任务 |
|
||||
| POST | `/follow-up-tasks/:id/records` | 填写随访记录 |
|
||||
| GET | `/follow-up-records` | 随访台账 |
|
||||
|
||||
### 4.5 咨询管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/consultation-sessions` | 会话列表 |
|
||||
| GET | `/consultation-sessions/:id/messages` | 消息记录 |
|
||||
| PUT | `/consultation-sessions/:id/close` | 关闭会话 |
|
||||
| POST | `/consultation-messages` | 写入消息(API 网关用) |
|
||||
| GET | `/consultation-sessions/export` | 导出 |
|
||||
|
||||
### 4.6 医护管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/doctors` | 医护列表 |
|
||||
| POST | `/doctors` | 创建医护档案 |
|
||||
| GET | `/doctors/:id` | 医护详情 |
|
||||
| PUT | `/doctors/:id` | 更新医护档案 |
|
||||
| DELETE | `/doctors/:id` | 软删除医护档案 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端页面设计
|
||||
|
||||
文件位置: `apps/web/src/pages/health/`
|
||||
|
||||
### 5.1 页面清单
|
||||
|
||||
| # | 页面 | 文件名 | 类型 |
|
||||
|---|------|--------|------|
|
||||
| 1 | 患者列表 | PatientList.tsx | 表格+搜索+标签筛选+导出 |
|
||||
| 2 | 患者详情 | PatientDetail.tsx | Tab布局:基本信息/健康趋势/化验报告/就诊记录/随访记录 |
|
||||
| 3 | 标签管理 | PatientTagManage.tsx | CRUD+颜色+批量打标 |
|
||||
| 4 | 日常监测 | VitalSignsList.tsx | 按患者+日期+ECharts趋势折线图 |
|
||||
| 5 | 化验报告 | LabReportList.tsx | 列表+图片预览+指标详情+解读 |
|
||||
| 6 | 体检记录 | HealthRecordList.tsx | 类型筛选+报告文件查看/上传 |
|
||||
| 7 | 预约管理 | AppointmentList.tsx | 列表/日历切换+状态流转 |
|
||||
| 8 | 排班管理 | DoctorSchedule.tsx | 周/月日历+排班模板 |
|
||||
| 9 | 随访任务 | FollowUpTaskList.tsx | 任务CRUD+分配+关联工作流 |
|
||||
| 10 | 随访台账 | FollowUpRecordList.tsx | 按患者/医护/日期筛选+导出 |
|
||||
| 11 | 会话管理 | ConsultationList.tsx | 列表+未回复统计 |
|
||||
| 12 | 对话记录 | ConsultationDetail.tsx | 聊天气泡+图片/语音查看+导出 |
|
||||
| 13 | 医护列表 | DoctorList.tsx | 列表+科室筛选+在线状态 |
|
||||
|
||||
### 5.2 技术要点
|
||||
|
||||
- **ECharts 趋势图** — 血压/体重/血糖曲线图,按日期范围展示
|
||||
- **文件上传/预览** — 化验单图片、体检报告 PDF(需新增基础能力)
|
||||
- **日历组件** — Ant Design Calendar 用于排班和预约视图
|
||||
- **聊天 UI** — 消息气泡展示(只读,非实时聊天)
|
||||
- **导出** — 随访台账、咨询记录导出为 Excel
|
||||
|
||||
---
|
||||
|
||||
## 6. 事件集成
|
||||
|
||||
### 6.1 发布事件
|
||||
|
||||
| 事件类型 | 触发时机 | 载荷 |
|
||||
|----------|----------|------|
|
||||
| `patient.created` | 创建患者 | `{patient_id, name, tenant_id}` |
|
||||
| `patient.updated` | 更新患者信息 | `{patient_id, changed_fields}` |
|
||||
| `appointment.created` | 创建预约 | `{appointment_id, patient_id, doctor_id, date}` |
|
||||
| `appointment.confirmed` | 确认预约 | `{appointment_id}` |
|
||||
| `appointment.cancelled` | 取消预约 | `{appointment_id, cancel_reason}` |
|
||||
| `appointment.completed` | 完成就诊 | `{appointment_id}` |
|
||||
| `follow_up.created` | 创建随访任务 | `{task_id, patient_id, assigned_to, planned_date}` |
|
||||
| `follow_up.completed` | 完成随访 | `{task_id, record_id, result}` |
|
||||
| `lab_report.uploaded` | 上传化验报告 | `{report_id, patient_id, report_type, abnormal_count}` |
|
||||
| `consultation.opened` | 开启咨询 | `{session_id, patient_id, doctor_id}` |
|
||||
| `consultation.closed` | 关闭咨询 | `{session_id}` |
|
||||
| `patient.deceased` | 患者死亡标记 | `{patient_id}` |
|
||||
| `patient.verified` | 实名认证通过 | `{patient_id, id_number}` |
|
||||
| `follow_up.overdue` | 随访任务逾期 | `{task_id, patient_id, planned_date}` |
|
||||
| `doctor.online_status_changed` | 医护在线状态变更 | `{doctor_id, old_status, new_status}` |
|
||||
|
||||
**随访记录自动创建后续任务:** 当 `follow_up_record.next_follow_up_date` 不为空时,服务层自动创建新的 `follow_up_task`(planned_date = next_follow_up_date,assigned_to 沿用当前医护)。
|
||||
|
||||
### 6.2 订阅事件
|
||||
|
||||
| 事件类型 | 处理逻辑 |
|
||||
|----------|----------|
|
||||
| `workflow.task.completed` | 工作流任务完成时更新随访任务状态 |
|
||||
| `message.sent` | 消息发送时联动咨询会话的 last_message_at |
|
||||
|
||||
---
|
||||
|
||||
## 7. 模块结构
|
||||
|
||||
```
|
||||
crates/erp-health/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── lib.rs ← ErpModule trait + public_routes() / protected_routes()
|
||||
│ ├── error.rs ← HealthError → AppError
|
||||
│ ├── state.rs ← HealthState (共享状态)
|
||||
│ ├── entity/ ← SeaORM Entity
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient.rs
|
||||
│ │ ├── patient_family_member.rs
|
||||
│ │ ├── patient_tag.rs
|
||||
│ │ ├── patient_tag_relation.rs
|
||||
│ │ ├── patient_doctor_relation.rs
|
||||
│ │ ├── doctor_profile.rs
|
||||
│ │ ├── health_record.rs
|
||||
│ │ ├── vital_signs.rs
|
||||
│ │ ├── lab_report.rs
|
||||
│ │ ├── health_trend.rs
|
||||
│ │ ├── appointment.rs
|
||||
│ │ ├── doctor_schedule.rs
|
||||
│ │ ├── follow_up_task.rs
|
||||
│ │ ├── follow_up_record.rs
|
||||
│ │ ├── consultation_session.rs
|
||||
│ │ └── consultation_message.rs
|
||||
│ ├── service/ ← 业务逻辑
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient_service.rs
|
||||
│ │ ├── health_data_service.rs
|
||||
│ │ ├── appointment_service.rs
|
||||
│ │ ├── follow_up_service.rs
|
||||
│ │ └── consultation_service.rs
|
||||
│ ├── handler/ ← Axum 路由
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient_handler.rs
|
||||
│ │ ├── health_data_handler.rs
|
||||
│ │ ├── appointment_handler.rs
|
||||
│ │ ├── follow_up_handler.rs
|
||||
│ │ └── consultation_handler.rs
|
||||
│ ├── dto/ ← 请求/响应结构体
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── patient_dto.rs
|
||||
│ │ ├── health_data_dto.rs
|
||||
│ │ ├── appointment_dto.rs
|
||||
│ │ ├── follow_up_dto.rs
|
||||
│ │ └── consultation_dto.rs
|
||||
│ └── event.rs ← 事件定义和处理器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 权限定义
|
||||
|
||||
### 8.1 权限码
|
||||
|
||||
| 权限码 | 名称 | 说明 |
|
||||
|--------|------|------|
|
||||
| `health.patient.list` | 查看患者列表 | 查看和搜索患者列表、详情 |
|
||||
| `health.patient.manage` | 管理患者 | 创建、编辑、删除患者 |
|
||||
| `health.health-data.list` | 查看健康数据 | 查看体检记录、监测数据、化验报告 |
|
||||
| `health.health-data.manage` | 管理健康数据 | 录入、编辑、删除健康数据 |
|
||||
| `health.appointment.list` | 查看预约 | 查看预约列表和排班 |
|
||||
| `health.appointment.manage` | 管理预约 | 创建、确认、取消预约 |
|
||||
| `health.follow-up.list` | 查看随访 | 查看随访任务和记录 |
|
||||
| `health.follow-up.manage` | 管理随访 | 创建、分配、完成随访任务 |
|
||||
| `health.consultation.list` | 查看咨询 | 查看咨询会话和消息记录 |
|
||||
| `health.consultation.manage` | 管理咨询 | 关闭会话、导出记录 |
|
||||
| `health.doctor.list` | 查看医护 | 查看医护列表和详情 |
|
||||
| `health.doctor.manage` | 管理医护 | 创建、编辑医护档案、排班 |
|
||||
|
||||
### 8.2 数据范围
|
||||
|
||||
| 实体 | 支持的数据范围级别 | 说明 |
|
||||
|------|-------------------|------|
|
||||
| patient | self, department, department_tree, all | 医生只能看自己负责的患者或本科室患者 |
|
||||
| follow_up_task | self, department, department_tree, all | 医护只能看分配给自己的随访任务 |
|
||||
| appointment | self, department, department_tree, all | 按科室隔离预约数据 |
|
||||
|
||||
### 8.3 角色模板
|
||||
|
||||
| 角色 | 权限 |
|
||||
|------|------|
|
||||
| health_admin | 全部 health.* 权限 |
|
||||
| doctor | health.patient.list, health.health-data.*, health.appointment.list, health.follow-up.*, health.consultation.list, health.doctor.list |
|
||||
| nurse | health.patient.list, health.health-data.*, health.follow-up.*, health.appointment.list |
|
||||
| receptionist | health.patient.*, health.appointment.*, health.doctor.list |
|
||||
|
||||
---
|
||||
|
||||
## 9. 能力扩展
|
||||
|
||||
V1 需要新增以下基础能力(在 erp-core 或独立模块中):
|
||||
|
||||
1. **文件上传服务** — 文件存储(本地/OSS)、URL 生成、图片缩略图
|
||||
2. **趋势分析** — 时序数据聚合、异常检测逻辑
|
||||
3. **报告批注** — 医生对化验报告的解读/批注能力
|
||||
4. **导出增强** — 健康数据导出为 Excel/PDF
|
||||
|
||||
---
|
||||
|
||||
## 10. 实施步骤
|
||||
|
||||
### Phase 1: 项目初始化
|
||||
- 拷贝 ERP 到 hms
|
||||
- 验证编译和构建
|
||||
|
||||
### Phase 2: erp-health 骨架
|
||||
- 创建 crate 结构
|
||||
- 实现 ErpModule trait + `public_routes()` / `protected_routes()` 固有方法
|
||||
- 注册到 workspace
|
||||
|
||||
### Phase 3: 数据库迁移
|
||||
- 16 张表(14 业务实体 + 2 关联表)的迁移文件
|
||||
- 索引创建、唯一约束
|
||||
|
||||
### Phase 4: 业务逻辑(按域迭代)
|
||||
- ① 患者与医护管理
|
||||
- ② 健康数据管理
|
||||
- ③ 预约排班
|
||||
- ④ 随访管理
|
||||
- ⑤ 咨询管理
|
||||
|
||||
### Phase 5: 前端页面
|
||||
- 13 个自定义 React 页面
|
||||
- 路由注册和侧边栏菜单
|
||||
|
||||
### Phase 6: 集成测试
|
||||
- API 端点测试
|
||||
- 多租户隔离验证
|
||||
- 端到端功能验证
|
||||
@@ -0,0 +1,509 @@
|
||||
# HMS 患者小程序设计规格
|
||||
|
||||
> **版本**: v1.0
|
||||
> **日期**: 2026-04-23
|
||||
> **状态**: 草案
|
||||
> **关联**: 健康模块设计规格 `2026-04-23-health-management-module-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 产品定位
|
||||
|
||||
HMS 患者小程序是**综合健康管理入口**,面向体检中心/医疗机构的患者。覆盖体检预约、报告查询、健康数据长期监测、随访管理、家庭健康管理等场景。
|
||||
|
||||
医护端以 PC 管理后台(`apps/web/`)为主力,小程序聚焦患者体验。医护端小程序可在后续按需补一个轻量版(随访提醒、排班查看),不在本规格范围内。
|
||||
|
||||
### 1.2 核心决策
|
||||
|
||||
| 维度 | 决策 | 原因 |
|
||||
|------|------|------|
|
||||
| 技术选型 | Taro 4 + React 19 | 与 Web 端 React 技能复用,支持多端编译 |
|
||||
| 架构方案 | 直连后端 | MVP 阶段最务实,复用 erp-server API |
|
||||
| 登录方式 | 微信授权 + 手机号补充 | 降低门槛 + 确保身份可靠 |
|
||||
| 代码位置 | `apps/miniprogram/`(Monorepo) | 方便接口同步,共享类型定义 |
|
||||
| 目标平台 | 微信小程序优先 | 覆盖最广泛用户,后续可扩展 |
|
||||
| 数据录入 | 手动 + 蓝牙预留接口 | MVP 快速交付,后续对接设备 |
|
||||
| 视觉风格 | 医疗清新(青色主调) | 专业可靠,沿用现有 HTML 原型风格 |
|
||||
|
||||
### 1.3 MVP 功能范围
|
||||
|
||||
**MVP 包含(7 个功能模块):**
|
||||
1. 登录 + 个人中心
|
||||
2. 健康数据录入 + 趋势图
|
||||
3. 预约挂号
|
||||
4. 报告查询
|
||||
5. 随访管理
|
||||
6. 家庭健康管理(就诊人切换)
|
||||
7. 健康资讯 + 用药提醒
|
||||
|
||||
**后续版本:**
|
||||
- 在线咨询(即时通讯,WebSocket 长连接)
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目结构
|
||||
|
||||
```
|
||||
apps/miniprogram/
|
||||
├── config/ # Taro 编译配置
|
||||
│ ├── index.ts # 通用配置
|
||||
│ ├── dev.ts # 开发环境
|
||||
│ └── prod.ts # 生产环境
|
||||
├── project.config.json # 微信小程序项目配置
|
||||
├── src/
|
||||
│ ├── app.config.ts # Taro 全局配置(TabBar、页面路由)
|
||||
│ ├── app.tsx # 入口组件
|
||||
│ ├── app.scss # 全局样式(医疗清新主题变量)
|
||||
│ ├── components/ # 通用组件
|
||||
│ │ ├── HealthCard/ # 健康指标卡片(血压/血糖/体重)
|
||||
│ │ ├── AppointmentCard/ # 预约卡片
|
||||
│ │ ├── ReportItem/ # 报告列表项
|
||||
│ │ ├── FamilyPicker/ # 就诊人切换器
|
||||
│ │ ├── EmptyState/ # 空状态占位
|
||||
│ │ └── TrendChart/ # 趋势图(echarts-taro3-react)
|
||||
│ ├── pages/
|
||||
│ │ ├── index/ # 首页(今日健康+快捷入口+待办)
|
||||
│ │ ├── health/ # 健康数据(录入+趋势图)
|
||||
│ │ ├── appointment/ # 预约(列表+新建预约)
|
||||
│ │ ├── report/ # 报告(体检报告+化验单)
|
||||
│ │ ├── followup/ # 随访(任务+问卷填写)
|
||||
│ │ ├── article/ # 健康资讯(文章列表+详情)
|
||||
│ │ ├── profile/ # 我的(个人信息+就诊人管理+设置)
|
||||
│ │ └── login/ # 登录(微信授权+手机号)
|
||||
│ ├── services/ # API 调用层
|
||||
│ │ ├── request.ts # 封装 Taro.request(JWT 注入、错误处理)
|
||||
│ │ ├── auth.ts # 登录/刷新 token
|
||||
│ │ ├── health.ts # 健康数据 CRUD
|
||||
│ │ ├── appointment.ts # 预约 CRUD
|
||||
│ │ ├── report.ts # 报告查询
|
||||
│ │ ├── followup.ts # 随访任务/记录
|
||||
│ │ └── article.ts # 资讯/科普
|
||||
│ ├── stores/ # Zustand 状态管理
|
||||
│ │ ├── auth.ts # 登录态、用户信息、就诊人列表
|
||||
│ │ └── health.ts # 健康数据缓存
|
||||
│ ├── utils/
|
||||
│ │ ├── bluetooth.ts # 蓝牙接口预留(MVP 不实现)
|
||||
│ │ ├── format.ts # 日期/数值格式化
|
||||
│ │ └── constants.ts # 常量定义
|
||||
│ └── styles/
|
||||
│ ├── variables.scss # 主题变量(青色主调)
|
||||
│ └── mixins.scss # 常用样式混入
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
**设计原则:**
|
||||
- services 层与 Web 端 `apps/web/src/api/` 职责对齐,用 Taro.request 替代 fetch
|
||||
- stores 复用 Zustand 模式,与 Web 端保持一致的状态管理风格
|
||||
- 组件命名 PascalCase 目录,与 Web 端风格统一
|
||||
- MVP 阶段不强抽取 `packages/shared/`,等两端跑起来后根据重复度决定
|
||||
|
||||
---
|
||||
|
||||
## 3. 认证流程
|
||||
|
||||
### 3.1 整体流程
|
||||
|
||||
```
|
||||
用户打开小程序
|
||||
↓
|
||||
检查本地 storage 有无有效 JWT
|
||||
├── 有且未过期 → 直接进入首页
|
||||
└── 无或已过期 ↓
|
||||
|
||||
Step 1: 微信静默登录
|
||||
wx.login() → code
|
||||
→ POST /api/v1/auth/wechat/login { code }
|
||||
→ 后端用 code 换 openid,查找绑定用户
|
||||
├── 已绑定 → 签发 JWT { token, user }
|
||||
└── 未绑定 → 返回 { need_bind: true, openid }
|
||||
|
||||
Step 2: 手机号绑定(仅新用户)
|
||||
wx.getPhoneNumber 按钮组件 → encryptedData + iv
|
||||
→ POST /api/v1/auth/wechat/bind-phone { openid, encryptedData, iv }
|
||||
→ 后端解密手机号,创建/关联 user + patient 档案
|
||||
→ 签发 JWT { token, user, patient }
|
||||
|
||||
Step 3: 补充档案(首次绑定后)
|
||||
→ 引导填写姓名、性别、出生日期、身份证号(可选)
|
||||
→ PUT /api/v1/health/patients/me { name, gender, birthday }
|
||||
```
|
||||
|
||||
### 3.2 后端新增内容
|
||||
|
||||
**`erp-auth` 新增:**
|
||||
|
||||
| 新增 | 说明 |
|
||||
|------|------|
|
||||
| `wechat_users` 表 | `id, openid, union_id, user_id, phone, created_at, updated_at` |
|
||||
| `POST /api/v1/auth/wechat/login` | code → openid 查询,返回绑定状态 |
|
||||
| `POST /api/v1/auth/wechat/bind-phone` | 绑定手机号,创建 user + patient |
|
||||
| `GET /api/v1/auth/wechat/qrcode` | 生成带参数小程序码(PC 端扫码登录场景) |
|
||||
|
||||
`wechat_users` 表必须包含 `tenant_id`(多租户隔离)和标准审计字段(`created_at`, `updated_at`, `deleted_at`)。
|
||||
|
||||
### 3.3 Token 策略
|
||||
|
||||
| Token | 有效期 | 存储 |
|
||||
|-------|--------|------|
|
||||
| Access Token (JWT) | 15 分钟 | 内存 + Taro.setStorage |
|
||||
| Refresh Token | 7 天 | Taro.setStorage |
|
||||
|
||||
自动刷新机制:`services/request.ts` 拦截 401 → 调用 `POST /auth/refresh` → 重试原请求。刷新失败则跳转登录页。
|
||||
|
||||
### 3.4 多就诊人
|
||||
|
||||
- 一个微信账号可管理多个 patient(本人 + 家人)
|
||||
- 切换就诊人时请求 header 带 `X-Patient-Id`
|
||||
- 后端校验该 patient 属于当前 user
|
||||
|
||||
---
|
||||
|
||||
## 4. 页面结构与导航
|
||||
|
||||
### 4.1 Tab Bar
|
||||
|
||||
底部导航栏 5 个入口:
|
||||
|
||||
| Tab | 图标 | 页面路径 |
|
||||
|-----|------|----------|
|
||||
| 首页 | 🏠 | /pages/index/index |
|
||||
| 健康 | 📊 | /pages/health/index |
|
||||
| 预约 | 📅 | /pages/appointment/index |
|
||||
| 资讯 | 📰 | /pages/article/index |
|
||||
| 我的 | 👤 | /pages/profile/index |
|
||||
|
||||
### 4.2 页面层级
|
||||
|
||||
```
|
||||
Tab: 首页 /pages/index
|
||||
├── /pages/notifications/index # 通知列表
|
||||
└── /pages/followup/detail/index # 随访任务详情
|
||||
|
||||
Tab: 健康 /pages/health
|
||||
├── /pages/health/input/index # 录入数据
|
||||
├── /pages/health/trend/index # 指标趋势
|
||||
└── /pages/health/history/index # 历史记录
|
||||
|
||||
Tab: 预约 /pages/appointment
|
||||
├── /pages/appointment/create/index # 新建预约
|
||||
└── /pages/appointment/detail/index # 预约详情
|
||||
|
||||
Tab: 资讯 /pages/article
|
||||
└── /pages/article/detail/index # 文章详情
|
||||
|
||||
Tab: 我的 /pages/profile
|
||||
├── /pages/profile/family/index # 就诊人管理
|
||||
├── /pages/profile/family-add/index # 添加就诊人
|
||||
├── /pages/profile/reports/index # 我的报告
|
||||
├── /pages/profile/followups/index # 我的随访
|
||||
├── /pages/profile/medication/index # 用药提醒
|
||||
└── /pages/profile/settings/index # 设置
|
||||
|
||||
独立页面(不在 Tab 内):
|
||||
├── /pages/login/index # 登录
|
||||
└── /pages/login/profile/index # 档案补全
|
||||
```
|
||||
|
||||
### 4.3 首页布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ 问候栏(渐变青色背景) │ 用户名 + 日期 + 通知铃铛
|
||||
├─────────────────────────────┤
|
||||
│ 今日健康卡片(上浮 -20px) │ 血压/心率/血糖/体重 2×2 网格
|
||||
├─────────────────────────────┤
|
||||
│ 快捷服务(4 宫格) │ 录数据/预约/报告/随访
|
||||
├─────────────────────────────┤
|
||||
│ 即将到来 │ 最近 1 条预约卡片
|
||||
├─────────────────────────────┤
|
||||
│ 待办随访 │ 最多 2 条待办 + 查看全部
|
||||
├─────────────────────────────┤
|
||||
│ [ 首页 ] [ 健康 ] [ 预约 ] [ 资讯 ] [ 我的 ] │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 核心功能数据流
|
||||
|
||||
### 5.1 健康数据录入
|
||||
|
||||
```
|
||||
选择指标类型 → 输入数值 + 测量时间 → 添加备注(可选)→ POST /vital-signs
|
||||
↓
|
||||
成功 → 更新首页卡片 + 趋势缓存
|
||||
失败 → Toast + 本地暂存
|
||||
```
|
||||
|
||||
**MVP 支持的指标类型:**
|
||||
|
||||
| 指标 | 单位 | 输入控件 |
|
||||
|------|------|---------|
|
||||
| 收缩压 / 舒张压 | mmHg | 两个数字输入框 |
|
||||
| 心率 | bpm | 数字输入框 |
|
||||
| 空腹血糖 | mmol/L | 数字输入框 |
|
||||
| 餐后血糖 | mmol/L | 数字输入框 |
|
||||
| 体重 | kg | 数字输入框(1 位小数) |
|
||||
| 体温 | ℃ | 数字输入框(1 位小数) |
|
||||
|
||||
每次可同时填多项或只填一项。录入时间默认当前,可手动调整为当天任意时间。
|
||||
|
||||
### 5.2 预约挂号
|
||||
|
||||
```
|
||||
选择科室 → 选择医生 → 选择日期(排班日历)→ 选择时段 → 确认预约
|
||||
↓
|
||||
POST /appointments
|
||||
↓
|
||||
成功 → 订阅消息通知 + 日历同步
|
||||
满员 → 提示"该时段已满"
|
||||
```
|
||||
|
||||
**关键交互:**
|
||||
- 排班日历用周视图,有排班的日期标绿点
|
||||
- 点击日期后展示该日可用时段
|
||||
- 时段显示"剩余 X 位"
|
||||
- 预约成功后支持微信订阅消息提醒
|
||||
|
||||
### 5.3 报告查询
|
||||
|
||||
```
|
||||
报告列表(分页,时间倒序)
|
||||
↓ 点击某份报告
|
||||
报告详情 → 基本信息卡 + 指标列表 + PDF/图片附件预览
|
||||
```
|
||||
|
||||
**指标状态标记:**
|
||||
- 异常偏高:红色 + ↑ 箭头
|
||||
- 异常偏低:红色 + ↓ 箭头
|
||||
- 正常范围:灰色
|
||||
|
||||
### 5.4 随访管理
|
||||
|
||||
```
|
||||
待办列表(按截止日期排序)
|
||||
↓ 支持"待完成/已完成/已过期"筛选
|
||||
点击任务 → 动态表单(后端定义字段)→ 提交 → 标记完成
|
||||
```
|
||||
|
||||
问卷由 PC 端医护创建(follow_up_task),小程序负责展示和填写。提交后创建 follow_up_record。
|
||||
|
||||
### 5.5 家庭健康管理
|
||||
|
||||
```
|
||||
就诊人列表(本人 + 已添加家属)
|
||||
↓ 点击头像或下拉切换
|
||||
切换就诊人 → 全局 X-Patient-Id 更新 → 所有页面数据刷新
|
||||
↓ 添加家属
|
||||
填写信息 → 姓名 + 关系 + 身份证号(可选)→ POST /patients
|
||||
```
|
||||
|
||||
切换就诊人通过全局 store 更新,所有 service 请求自动携带新 `X-Patient-Id`。
|
||||
|
||||
### 5.6 健康资讯 + 用药提醒
|
||||
|
||||
**资讯:**
|
||||
- `GET /articles` → 分页列表(缩略图 + 标题 + 摘要 + 时间)
|
||||
- 文章详情使用 Taro `RichText` 组件渲染富文本
|
||||
|
||||
**用药提醒(MVP):**
|
||||
- 小程序本地 storage 存储提醒规则(药品名 + 频率 + 时间)
|
||||
- 每日触发检查
|
||||
- 通过微信订阅消息推送提醒
|
||||
- 不依赖后端新表
|
||||
|
||||
---
|
||||
|
||||
## 6. API 集成与状态管理
|
||||
|
||||
### 6.1 请求层封装
|
||||
|
||||
`services/request.ts` 职责:
|
||||
|
||||
| 拦截点 | 行为 |
|
||||
|--------|------|
|
||||
| 请求拦截 | 自动注入 `Authorization: Bearer {token}` |
|
||||
| 请求拦截 | 自动注入 `X-Patient-Id`(当前选中就诊人) |
|
||||
| 请求拦截 | 自动注入 `X-Tenant-Id`(从登录信息获取) |
|
||||
| 响应拦截 | 401 → 静默刷新 token → 重试原请求 |
|
||||
| 响应拦截 | 刷新失败 → 跳转登录页 |
|
||||
| 错误处理 | 网络错误 / 业务错误 / 超时统一处理 |
|
||||
|
||||
多租户处理:患者只属于一个租户。登录时后端返回 `tenant_id`,前端每次请求带上。不走 `tenant_id` 中间件自动注入。
|
||||
|
||||
### 6.2 Zustand Stores
|
||||
|
||||
**auth store:**
|
||||
|
||||
```typescript
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
refreshToken: string | null
|
||||
user: { id: string; name: string; phone: string; avatar: string } | null
|
||||
currentPatient: Patient | null
|
||||
patients: Patient[]
|
||||
setCurrentPatient: (id: string) => void
|
||||
login: (code: string) => Promise<void>
|
||||
bindPhone: (data: BindPhoneData) => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**health store:**
|
||||
|
||||
```typescript
|
||||
interface HealthState {
|
||||
todaySummary: VitalSigns | null
|
||||
trendData: Record<string, TrendPoint[]>
|
||||
refreshToday: () => Promise<void>
|
||||
getTrend: (type: string, range: '7d' | '30d' | '90d') => Promise<TrendPoint[]>
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 API 端点对应表
|
||||
|
||||
| 小程序 service | 后端端点 | 方法 |
|
||||
|----------------|----------|------|
|
||||
| `auth.login(code)` | `/api/v1/auth/wechat/login` | POST |
|
||||
| `auth.bindPhone(data)` | `/api/v1/auth/wechat/bind-phone` | POST |
|
||||
| `auth.refresh()` | `/api/v1/auth/refresh` | POST |
|
||||
| `health.getToday()` | `/api/v1/health/vital-signs?date=today` | GET |
|
||||
| `health.input(data)` | `/api/v1/health/vital-signs` | POST |
|
||||
| `health.getTrend(type, range)` | `/api/v1/health/vital-signs/trend` | GET |
|
||||
| `appointment.list()` | `/api/v1/health/appointments` | GET |
|
||||
| `appointment.create(data)` | `/api/v1/health/appointments` | POST |
|
||||
| `appointment.cancel(id)` | `/api/v1/health/appointments/:id/cancel` | PUT |
|
||||
| `schedule.getByDoctor(id)` | `/api/v1/health/doctor-schedules` | GET |
|
||||
| `report.list()` | `/api/v1/health/lab-reports` | GET |
|
||||
| `report.detail(id)` | `/api/v1/health/lab-reports/:id` | GET |
|
||||
| `followup.list()` | `/api/v1/health/follow-up-tasks` | GET |
|
||||
| `followup.submit(id, data)` | `/api/v1/health/follow-up-records` | POST |
|
||||
| `patient.list()` | `/api/v1/health/patients` | GET |
|
||||
| `patient.create(data)` | `/api/v1/health/patients` | POST |
|
||||
| `patient.update(id, data)` | `/api/v1/health/patients/:id` | PUT |
|
||||
|
||||
**后端需新增的端点(尚未实现):**
|
||||
|
||||
| 端点 | 说明 |
|
||||
|------|------|
|
||||
| `POST /auth/wechat/login` | 微信登录 |
|
||||
| `POST /auth/wechat/bind-phone` | 手机号绑定 |
|
||||
| `GET /vital-signs/trend` | 趋势聚合查询 |
|
||||
| `GET /doctor-schedules` | 按科室/医生查询排班 |
|
||||
| `GET /articles` | 健康资讯列表 |
|
||||
| `GET /articles/:id` | 资讯详情 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 视觉设计
|
||||
|
||||
### 7.1 主题色
|
||||
|
||||
沿用现有 HTML 原型的医疗清新风格:
|
||||
|
||||
| 用途 | 色值 | 说明 |
|
||||
|------|------|------|
|
||||
| 主色 | `#0891B2` | 青色,按钮、导航、强调 |
|
||||
| 主色浅 | `#E0F7FA` | 背景、卡片高亮 |
|
||||
| 主色深 | `#065A73` | 渐变、按压态 |
|
||||
| 辅助色 | `#059669` | 绿色,成功、正常指标 |
|
||||
| 危险色 | `#DC2626` | 红色,异常指标、删除 |
|
||||
| 警告色 | `#D97706` | 琥珀,待办、提醒 |
|
||||
| 背景色 | `#F0FDFA` | 页面底色 |
|
||||
| 卡片色 | `#FFFFFF` | 卡片背景 |
|
||||
| 主文字 | `#134E4A` | 标题、正文 |
|
||||
| 副文字 | `#6B7280` | 说明、标签 |
|
||||
| 轻文字 | `#94A3B8` | 时间戳、占位符 |
|
||||
|
||||
### 7.2 圆角规范
|
||||
|
||||
| 元素 | 圆角 |
|
||||
|------|------|
|
||||
| 卡片 | 12px |
|
||||
| 按钮 | 8px |
|
||||
| 输入框 | 8px |
|
||||
| 头像 | 50% |
|
||||
| 快捷图标 | 14px |
|
||||
|
||||
### 7.3 阴影规范
|
||||
|
||||
| 层级 | 值 |
|
||||
|------|---|
|
||||
| 轻阴影 | `0 1px 3px rgba(0,0,0,.04)` |
|
||||
| 标准阴影 | `0 2px 8px rgba(0,0,0,.06)` |
|
||||
| 中阴影 | `0 4px 16px rgba(0,0,0,.08)` |
|
||||
| 重阴影 | `0 8px 32px rgba(0,0,0,.12)` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 开发工作流
|
||||
|
||||
### 8.1 开发环境
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
cd apps/miniprogram && pnpm install
|
||||
|
||||
# 开发模式(需配合微信开发者工具)
|
||||
pnpm dev:weapp # Taro 编译 + watch → dist/
|
||||
# 用微信开发者工具打开 dist/ 目录预览
|
||||
|
||||
# 生产构建
|
||||
pnpm build:weapp # 压缩 + tree-shaking
|
||||
|
||||
# 后端联调
|
||||
# 需同时运行 erp-server (port 3000)
|
||||
# 小程序开发设置中关闭域名校验(开发阶段)
|
||||
```
|
||||
|
||||
### 8.2 与 Web 端的代码复用
|
||||
|
||||
| 复用内容 | 方式 | 说明 |
|
||||
|---------|------|------|
|
||||
| TypeScript 类型 | 按需引用 Web 端 DTO 类型 | API 请求/响应结构一致 |
|
||||
| 主题变量值 | Web CSS 变量 → SCSS 变量 | 青色主调色值保持一致 |
|
||||
| Zustand 模式 | 相同 store 设计模式 | 各自独立实现 |
|
||||
| API 接口定义 | service 层函数签名对齐 | Web 用 fetch,小程序用 Taro.request |
|
||||
|
||||
### 8.3 后端需同步开发的内容
|
||||
|
||||
| 优先级 | 内容 | 涉及 crate |
|
||||
|--------|------|-----------|
|
||||
| P0 | `wechat_users` 表 + 微信登录/绑定 API | erp-auth |
|
||||
| P0 | `vital_signs` 趋势查询 API | erp-health |
|
||||
| P0 | `doctor_schedules` 按科室/医生查询 API | erp-health |
|
||||
| P1 | `lab_reports` 指标异常标注字段 | erp-health |
|
||||
| P1 | `follow_up_tasks` 动态问卷字段扩展 | erp-health |
|
||||
| P2 | `articles` 表 + CRUD | erp-health |
|
||||
| P2 | 微信订阅消息模板注册 | erp-server |
|
||||
|
||||
---
|
||||
|
||||
## 9. 分期交付计划
|
||||
|
||||
| 阶段 | 内容 | 目标 |
|
||||
|------|------|------|
|
||||
| Phase 1 | 项目骨架 + 登录流程 + 首页(静态数据) | 基础搭建 |
|
||||
| Phase 2 | 健康数据录入 + 趋势图 | 核心功能 |
|
||||
| Phase 3 | 预约挂号 + 排班日历 | 核心功能 |
|
||||
| Phase 4 | 报告查询 + 家庭管理 | 扩展功能 |
|
||||
| Phase 5 | 随访 + 资讯 + 用药提醒 | 扩展功能 |
|
||||
| Phase 6 | 打磨 + 真机测试 + 提审 | 上线准备 |
|
||||
|
||||
每个 Phase 内部遵循:先对接后端 API → 再实现 UI → 真机验证 → 提交。
|
||||
|
||||
---
|
||||
|
||||
## 10. 约束与风险
|
||||
|
||||
| 约束/风险 | 应对策略 |
|
||||
|-----------|---------|
|
||||
| 小程序包体积限制(2MB 主包) | 按功能分包加载,图表库按需引入 |
|
||||
| 微信审核周期(3-7 天) | Phase 6 预留充足审核时间 |
|
||||
| 后端 API 部分未实现 | 小程序开发与后端同步推进,优先实现 P0 端点 |
|
||||
| 微信订阅消息需用户主动触发 | 在预约成功、随访提交等场景引导用户订阅 |
|
||||
| 蓝牙设备适配复杂 | MVP 预留接口不实现,后续按设备型号逐一对接 |
|
||||
| 多就诊人数据隔离 | 后端严格校验 user-patient 归属关系 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,671 @@
|
||||
# 健康管理模块全面迭代设计
|
||||
|
||||
> **文档版本**: 1.0
|
||||
> **日期**: 2026-04-24
|
||||
> **状态**: 待评审
|
||||
> **基于**: 5 位专家(后端架构/前端架构/医疗业务/安全质量/产品策略)深度审查
|
||||
|
||||
---
|
||||
|
||||
## 0. 审查发现总览
|
||||
|
||||
### 0.1 V1 发布阻塞项
|
||||
|
||||
| # | 阻塞项 | 来源 | 影响 |
|
||||
|---|--------|------|------|
|
||||
| B1 | Web 健康模块 10 页面未实现 | 前端架构/产品策略 | 无法演示和交付 |
|
||||
| B2 | 医疗数据安全不合规 | 安全质量 | 零 sanitize / 零审计 / 身证明文 / 零测试 |
|
||||
| B3 | 数据一致性缺陷 | 医疗业务/后端架构 | 排班可超额 / 名额释放可能失败 / 随访逾期未实现 |
|
||||
| B4 | 事件处理器空壳 | 后端架构 | 随访状态/咨询消息不联动 |
|
||||
|
||||
### 0.2 当前完成度
|
||||
|
||||
| 层级 | 模块 | 完成度 |
|
||||
|------|------|--------|
|
||||
| 后端 | erp-health(16 实体/8 服务/7 handler/40+ API) | 95% |
|
||||
| 后端 | 事件处理器业务逻辑 | 0%(框架已搭建,需填充 db 操作) |
|
||||
| 后端 | sanitize / 审计 / 加密 | 0% |
|
||||
| 后端 | 测试覆盖 | 0% |
|
||||
| Web 前端 | 健康模块页面 | 0% |
|
||||
| Web 前端 | 健康模块 API 服务层 | 0% |
|
||||
| 小程序 | 初版 21 页面 | 85% |
|
||||
|
||||
---
|
||||
|
||||
## 1. 安全省基(阶段 1,1.5-2 周)
|
||||
|
||||
### 1.1 sanitize 全覆盖
|
||||
|
||||
**问题**: erp-health 模块没有任何对 `strip_html_tags` 的调用,攻击者可在患者姓名、病史等字段注入 XSS payload。
|
||||
|
||||
**参考实现**: `crates/erp-auth/src/dto.rs` 第 96-118 行,`CreateUserReq` 和 `UpdateUserReq` 已实现 `sanitize()` 方法。
|
||||
|
||||
**修复方案**: 为每个 DTO 的字符串输入字段添加 sanitize。
|
||||
|
||||
**覆盖字段清单**:
|
||||
|
||||
| DTO 文件 | 字段 |
|
||||
|----------|------|
|
||||
| `patient_dto.rs` CreatePatientReq / UpdatePatientReq | name, notes, allergy_history, medical_history_summary, emergency_contact_name, source |
|
||||
| `patient_dto.rs` FamilyMemberReq(create + update 共用) | name, notes |
|
||||
| `patient_handler.rs` AssignDoctorReq(位于 handler 非 dto) | — (无字符串字段) |
|
||||
| `health_data_dto.rs` CreateVitalSignsReq | notes |
|
||||
| `health_data_dto.rs` CreateLabReportReq | doctor_interpretation |
|
||||
| `health_data_dto.rs` CreateHealthRecordReq | source, overall_assessment, notes |
|
||||
| `appointment_dto.rs` CreateAppointmentReq | notes, cancel_reason |
|
||||
| `follow_up_dto.rs` CreateFollowUpTaskReq / UpdateFollowUpTaskReq | content_template |
|
||||
| `follow_up_dto.rs` CreateFollowUpRecordReq | patient_condition, medical_advice |
|
||||
| `consultation_dto.rs` CreateMessageReq | content |
|
||||
| `consultation_dto.rs` CreateSessionReq | — (无字符串字段) |
|
||||
| `doctor_dto.rs` CreateDoctorReq / UpdateDoctorReq | department, title, specialty, bio |
|
||||
|
||||
**实现模式**:
|
||||
|
||||
```rust
|
||||
// 封装 sanitize 辅助函数(与 erp-auth 的 sanitize_option 模式一致)
|
||||
fn sanitize_option_string(opt: Option<String>) -> Option<String> {
|
||||
opt.map(|s| strip_html_tags(&s))
|
||||
}
|
||||
|
||||
// 在每个 DTO 的 impl 中添加 sanitize 方法
|
||||
impl CreatePatientReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.name = strip_html_tags(&self.name);
|
||||
self.notes = sanitize_option_string(self.notes.take());
|
||||
self.allergy_history = sanitize_option_string(self.allergy_history.take());
|
||||
self.medical_history_summary = sanitize_option_string(self.medical_history_summary.take());
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// 在 handler 调用 service 前执行
|
||||
async fn create_patient(/* ... */) -> AppResult<Json<ApiResponse<PatientResp>>> {
|
||||
let mut req: CreatePatientReq = Json(req).0;
|
||||
req.sanitize();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**前端安全**: ChatBubble 组件必须使用 React 默认 JSX 转义渲染文本内容(不使用 `dangerouslySetInnerHTML`),图片消息 URL 需做白名单校验。
|
||||
|
||||
### 1.2 审计日志注入
|
||||
|
||||
**问题**: erp-health 整个模块没有任何对 `audit_service::record` 的调用。
|
||||
|
||||
**参考实现**: `crates/erp-auth/src/service/auth_service.rs` 第 168-177 行。
|
||||
|
||||
**修复方案**: 在所有写入操作的 service 层添加审计记录。
|
||||
|
||||
**覆盖操作清单**:
|
||||
|
||||
| Service | 操作 | 审计 action |
|
||||
|---------|------|------------|
|
||||
| patient_service | create_patient | `patient.created` |
|
||||
| patient_service | update_patient | `patient.updated` |
|
||||
| patient_service | delete_patient | `patient.deleted` |
|
||||
| patient_service | manage_patient_tags | `patient.tags_updated` |
|
||||
| health_data_service | create_vital_signs | `vital_signs.created` |
|
||||
| health_data_service | create_lab_report | `lab_report.created` |
|
||||
| health_data_service | create_health_record | `health_record.created` |
|
||||
| appointment_service | create_appointment | `appointment.created` |
|
||||
| appointment_service | update_appointment_status | `appointment.status_changed` |
|
||||
| follow_up_service | create_task | `follow_up_task.created` |
|
||||
| follow_up_service | create_record | `follow_up_record.created` |
|
||||
| consultation_service | create_session | `consultation.opened` |
|
||||
| consultation_service | close_session | `consultation.closed` |
|
||||
| consultation_service | create_message | `consultation.message_sent` |
|
||||
| doctor_service | create/update/delete_doctor | `doctor.*` |
|
||||
|
||||
**审计日志内容**: tenant_id、user_id、action、resource_type、resource_id、变更前后值摘要。
|
||||
|
||||
**注意**: 当前 `audit_service::record` 是 fire-and-forget,审计日志丢失对医疗合规不可接受。修复方案:
|
||||
1. 新增 `record_in_txn(log: AuditLog, txn: &DatabaseTransaction)` 方法,在事务内 await 写入
|
||||
2. 保留原 `record` 方法用于不要求事务保证的场景
|
||||
3. erp-health 的关键写入操作使用 `record_in_txn`,失败时回滚整个事务
|
||||
4. 需要改为事务包裹的 service 方法:create_patient、update_patient、delete_patient、create_appointment、update_appointment_status、create_record(随访)、create_message(咨询)
|
||||
|
||||
### 1.3 身份证号加密存储
|
||||
|
||||
**问题**: `patient.id_number` 明文存储在数据库中,违反《个人信息保护法》。
|
||||
|
||||
**方案**: AES-256-GCM 应用层加密。
|
||||
|
||||
**新增文件**: `crates/erp-health/src/crypto.rs`
|
||||
|
||||
```rust
|
||||
pub struct HealthCrypto { key: [u8; 32] }
|
||||
|
||||
impl HealthCrypto {
|
||||
pub fn from_env() -> Self { /* 从 ERP__HEALTH__ENCRYPTION_KEY 读取 */ }
|
||||
pub fn encrypt(&self, plaintext: &str) -> AppResult<String> { /* AES-256-GCM + Base64 */ }
|
||||
pub fn decrypt(&self, ciphertext: &str) -> AppResult<String> { /* 解密 */ }
|
||||
}
|
||||
```
|
||||
|
||||
**集成点**:
|
||||
- `patient_service::create_patient` — 加密 id_number 后存储
|
||||
- `patient_service::update_patient` — 同上
|
||||
- `patient_service::get_patient` — 解密后返回
|
||||
- `patient_service::list_patients` — 列表不返回 id_number(脱敏)
|
||||
|
||||
**密钥管理**: 环境变量 `ERP__HEALTH__ENCRYPTION_KEY`(32 字节 hex),必须在 `default.toml` 中标记为 `__MUST_SET_VIA_ENV__`。
|
||||
|
||||
**搜索兼容**: `patient.id_number` 的模糊搜索(`contains`)改为精确匹配(`eq`),在加密后使用 HMAC 索引做等值查询。
|
||||
|
||||
**HMAC 索引详情**:
|
||||
- 新增数据库列 `id_number_hash VARCHAR(64)`,存储 HMAC-SHA256 哈希
|
||||
- HMAC 密钥独立于 AES 密钥,从环境变量 `ERP__HEALTH__HMAC_KEY` 读取
|
||||
- 创建/更新患者时同时写入 hash 列,等值查询使用 `WHERE id_number_hash = hmac(输入值)`
|
||||
- 迁移 SQL:新增列 → 批量加密现有明文 → 删除原明文列(可选)
|
||||
|
||||
**数据迁移方案**:
|
||||
1. 停机窗口(预估 1-2 小时,视数据量)
|
||||
2. 迁移脚本:`SELECT id, id_number FROM patients WHERE id_number IS NOT NULL AND deleted_at IS NULL` → 批量加密 → `UPDATE patients SET id_number = $encrypted WHERE id = $id`
|
||||
3. 同步写入 `id_number_hash` 列
|
||||
4. 验证脚本:抽样解密比对原值
|
||||
5. 回滚方案:保留明文备份表 `patients_id_number_backup`,72 小时后确认无误再删除
|
||||
|
||||
**问题**: 列表接口直接返回完整身份证号、病史等敏感字段。
|
||||
|
||||
**修复方案**: 拆分响应 DTO。
|
||||
|
||||
```rust
|
||||
// 列表用 — 不含敏感字段
|
||||
pub struct PatientListResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub gender: Option<String>,
|
||||
pub birth_date: Option<NaiveDate>,
|
||||
pub status: String,
|
||||
pub tags: Vec<TagResp>,
|
||||
// 无 id_number, allergy_history, medical_history_summary, emergency_contact_phone 等
|
||||
}
|
||||
|
||||
// 详情用 — 敏感字段掩码
|
||||
pub struct PatientDetailResp {
|
||||
// ... 全部字段
|
||||
pub id_number: Option<String>, // "320***********1234"
|
||||
pub emergency_contact_phone: Option<String>, // "138****1234"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 后端补完(阶段 2,1.5 周)
|
||||
|
||||
### 2.1 事件处理器实现
|
||||
|
||||
**问题**: `event.rs` 中两个事件处理器只有 `tracing::info`,无实际业务逻辑。且 handler 中没有 `DatabaseConnection`,无法执行数据库操作。
|
||||
|
||||
**方案**: 在 `HealthModule::on_startup` 中创建 `HealthState` 并注册需要数据库访问的事件处理器。将现有 `register_event_handlers` 中的空壳代码迁移到 `on_startup`,`register_event_handlers` 改为空实现。
|
||||
|
||||
**修改 `crates/erp-health/src/module.rs`**:
|
||||
|
||||
```rust
|
||||
// register_event_handlers 改为空实现
|
||||
fn register_event_handlers(&self, _bus: &EventBus) {
|
||||
// 事件处理器迁移到 on_startup,此处不再注册
|
||||
}
|
||||
|
||||
// on_startup 中注册带 db 的事件处理器
|
||||
async fn on_startup(&self, ctx: &ModuleContext) -> AppResult<()> {
|
||||
let state = HealthState {
|
||||
db: ctx.db.clone(),
|
||||
event_bus: ctx.event_bus.clone(),
|
||||
};
|
||||
crate::event::register_handlers_with_state(state);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**修改 `crates/erp-health/src/event.rs`**:
|
||||
|
||||
新增 `register_handlers_with_state(state: HealthState)` 函数替代原有 `register_handlers`。
|
||||
|
||||
**事件处理器业务逻辑**:
|
||||
|
||||
`workflow.task.completed`:
|
||||
1. 从 payload 中提取 `task_id`
|
||||
2. 查询 `follow_up_task WHERE related_appointment_id` 或通过 payload 映射
|
||||
3. 更新随访任务状态为 `completed`
|
||||
|
||||
`message.sent`:
|
||||
1. 从 payload 中提取 `session_id`(或通过 sender/recipient 关联)
|
||||
2. 更新 `consultation_session SET last_message_at = NOW(), unread_count = unread_count + 1`
|
||||
3. 使用 `check_version` 乐观锁
|
||||
|
||||
### 2.2 数据一致性修复
|
||||
|
||||
#### 2.2.1 排班名额保护
|
||||
|
||||
**问题**: `update_schedule` 可以将 `max_appointments` 改为小于 `current_appointments` 的值。
|
||||
|
||||
**修复**: 在 `appointment_service.rs` 的 `update_schedule` 方法中增加校验:
|
||||
|
||||
```rust
|
||||
if req.max_appointments < model.current_appointments {
|
||||
return Err(HealthError::Validation(
|
||||
"max_appointments 不能小于当前已预约数".into()
|
||||
).into());
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2.2 取消预约名额释放
|
||||
|
||||
**问题**: `update_appointment_status` 中取消时名额释放失败只 log error 不回滚。
|
||||
|
||||
**修复**: 将名额释放作为事务的一部分,失败时回滚整个操作(包括状态更新)。
|
||||
|
||||
#### 2.2.3 咨询消息原子性
|
||||
|
||||
**问题**: `create_message` 中消息已插入,但后续 CAS 更新 session 失败时返回错误 — 消息已持久化但 session 元数据未更新。
|
||||
|
||||
**修复**: 将消息 INSERT + session CAS 更新放在同一个事务中。
|
||||
|
||||
### 2.3 随访逾期定时任务
|
||||
|
||||
**问题**: 设计规格定义了 `overdue` 状态和定时任务自动标记,但代码中:
|
||||
- `validation.rs` 不允许转换到 `overdue`
|
||||
- 没有后台定时任务
|
||||
|
||||
**修复**:
|
||||
|
||||
1. 在 `validation.rs` 中添加 `overdue` 转换规则:`pending -> overdue`(仅限系统自动触发)
|
||||
2. 在 `erp-server/src/main.rs` 后台任务区增加逾期检查器,使用与现有 `start_timeout_checker` 一致的 `tokio::spawn` + `loop` + `tokio::time::interval` 模式(每 6 小时执行一次,非 cron 表达式):
|
||||
|
||||
```rust
|
||||
// erp-server/src/main.rs 后台任务区
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(6 * 3600));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
// 调用 health module 的 check_overdue_tasks
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
3. 在 `erp-health` module 中添加一个公开方法 `check_overdue_tasks` 供定时任务调用。
|
||||
|
||||
### 2.4 article 管理 CRUD
|
||||
|
||||
**问题**: 权限声明中有 `health.articles.manage`,但 service/handler 只有 list 和 get。
|
||||
|
||||
**修复**: 在 `article_service.rs` 和 `article_handler.rs` 中补充 create/update/delete 方法。在 `module.rs` 中添加路由。**工时估算**: 0.5 天。
|
||||
|
||||
---
|
||||
|
||||
## 3. Web 前端 10 页面(阶段 3,3.5-4 周)
|
||||
|
||||
### 3.1 页面文件组织
|
||||
|
||||
```
|
||||
apps/web/src/
|
||||
├── api/health/
|
||||
│ ├── patients.ts # 12 端点
|
||||
│ ├── healthData.ts # 13 端点
|
||||
│ ├── appointments.ts # 6 端点
|
||||
│ ├── followUp.ts # 6 端点
|
||||
│ ├── consultations.ts # 6 端点
|
||||
│ └── doctors.ts # 4 端点
|
||||
├── pages/health/
|
||||
│ ├── PatientList.tsx # 患者列表
|
||||
│ ├── PatientDetail.tsx # 患者详情(5 Tab)
|
||||
│ ├── PatientTagManage.tsx # 标签管理
|
||||
│ ├── DoctorList.tsx # 医护列表
|
||||
│ ├── AppointmentList.tsx # 预约管理
|
||||
│ ├── DoctorSchedule.tsx # 排班管理
|
||||
│ ├── FollowUpTaskList.tsx # 随访任务
|
||||
│ ├── FollowUpRecordList.tsx # 随访台账
|
||||
│ ├── ConsultationList.tsx # 会话管理
|
||||
│ ├── ConsultationDetail.tsx # 对话详情
|
||||
│ └── components/
|
||||
│ ├── StatusTag.tsx # 通用状态标签
|
||||
│ ├── PatientSelect.tsx # 患者搜索选择器
|
||||
│ ├── DoctorSelect.tsx # 医护选择器
|
||||
│ ├── VitalSignsChart.tsx # ECharts 趋势图
|
||||
│ ├── CalendarView.tsx # 日历视图
|
||||
│ ├── ChatBubble.tsx # 聊天气泡
|
||||
│ ├── ImagePreview.tsx # 图片预览
|
||||
│ └── ExportButton.tsx # 导出按钮
|
||||
```
|
||||
|
||||
### 3.2 API 服务层设计
|
||||
|
||||
每个 service 文件遵循现有 `api/users.ts` 的解构模式:
|
||||
|
||||
```typescript
|
||||
// api/health/patients.ts
|
||||
import client from '../client';
|
||||
|
||||
export interface Patient {
|
||||
id: string;
|
||||
name: string;
|
||||
gender?: string;
|
||||
birth_date?: string;
|
||||
status: string;
|
||||
tags: Tag[];
|
||||
// ...
|
||||
}
|
||||
|
||||
export interface CreatePatientReq {
|
||||
name: string;
|
||||
gender?: string;
|
||||
// ...
|
||||
}
|
||||
|
||||
export const patientApi = {
|
||||
list: async (params: ListParams) => {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<Patient> }>(
|
||||
'/health/patients', { params }
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
get: async (id: string) => {
|
||||
const { data } = await client.get<{ success: boolean; data: Patient }>(
|
||||
`/health/patients/${id}`
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
create: async (req: CreatePatientReq) => {
|
||||
const { data } = await client.post<{ success: boolean; data: Patient }>(
|
||||
'/health/patients', req
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 3.3 路由注册
|
||||
|
||||
在 `App.tsx` 中新增:
|
||||
|
||||
```typescript
|
||||
// lazy imports
|
||||
const PatientList = lazy(() => import('./pages/health/PatientList'));
|
||||
const PatientDetail = lazy(() => import('./pages/health/PatientDetail'));
|
||||
// ... 共 10 个路由组件
|
||||
|
||||
// Routes 内
|
||||
<Route path="/health/patients" element={<PatientList />} />
|
||||
<Route path="/health/patients/:id" element={<PatientDetail />} />
|
||||
<Route path="/health/tags" element={<PatientTagManage />} />
|
||||
<Route path="/health/doctors" element={<DoctorList />} />
|
||||
<Route path="/health/appointments" element={<AppointmentList />} />
|
||||
<Route path="/health/schedules" element={<DoctorSchedule />} />
|
||||
<Route path="/health/follow-up-tasks" element={<FollowUpTaskList />} />
|
||||
<Route path="/health/follow-up-records" element={<FollowUpRecordList />} />
|
||||
<Route path="/health/consultations" element={<ConsultationList />} />
|
||||
<Route path="/health/consultations/:id" element={<ConsultationDetail />} />
|
||||
```
|
||||
|
||||
### 3.4 侧边栏菜单
|
||||
|
||||
在 `MainLayout.tsx` 中新增 `healthMenuItems` 数组(参照现有 `bizMenuItems` 模式),使用 `@ant-design/icons` 图标(如 `MedicineBoxOutlined`、`HeartOutlined`、`CalendarOutlined`、`PhoneOutlined`、`CommentOutlined`、`TagsOutlined`):
|
||||
|
||||
```
|
||||
侧边栏布局:
|
||||
├── 首页 (HomeOutlined)
|
||||
├── 用户管理 (UserOutlined)
|
||||
├── 权限管理 (SafetyOutlined)
|
||||
├── 工作流 (ApartmentOutlined)
|
||||
├── 消息中心 (BellOutlined)
|
||||
├── ─────────
|
||||
├── 健康管理 (MedicineBoxOutlined) ← 新增组
|
||||
│ ├── 患者管理 (TeamOutlined)
|
||||
│ ├── 医护管理 (HeartOutlined)
|
||||
│ ├── 预约排班 (CalendarOutlined)
|
||||
│ ├── 随访管理 (PhoneOutlined)
|
||||
│ ├── 咨询管理 (CommentOutlined)
|
||||
│ └── 标签管理 (TagsOutlined)
|
||||
├── ─────────
|
||||
├── 插件管理 (AppstoreOutlined)
|
||||
├── 系统设置 (SettingOutlined)
|
||||
```
|
||||
|
||||
### 3.5 前端权限集成
|
||||
|
||||
后端已有完整权限体系(14 个权限码),前端 V1 阶段采用以下策略:
|
||||
|
||||
1. **路由级权限**: 所有健康模块路由在 `PrivateRoute` 内(已实现),后端 `require_permission` 拦截无权限请求返回 403
|
||||
2. **按钮级权限(V1 简化)**: 不做前端按钮级权限控制,依赖后端 403 响应。后续可扩展 `usePermission` hook
|
||||
3. **菜单可见性**: 健康模块菜单组始终显示,但无权限用户点击任何页面会收到 403 提示
|
||||
|
||||
### 3.5 13 页面逐一设计
|
||||
|
||||
#### PatientList.tsx(中复杂度,1.5 天)
|
||||
|
||||
- Ant Design `Table` 组件(与 Users.tsx 模式一致,不使用 ProTable)
|
||||
- 搜索:姓名模糊 + 状态筛选 + 标签多选筛选
|
||||
- 每行显示患者标签为 `Tag` 组件列表
|
||||
- 行点击跳转 `/health/patients/:id`
|
||||
- 批量操作:批量打标
|
||||
- 导出功能
|
||||
|
||||
#### PatientDetail.tsx(高复杂度,3 天)
|
||||
|
||||
- 顶部:患者摘要卡片(姓名/性别/年龄/状态/标签)
|
||||
- Ant Design `Tabs` 5 个 Tab:
|
||||
1. **基本信息** — `Descriptions` 展示 + 编辑 Modal
|
||||
2. **健康趋势** — `VitalSignsChart` 组件 + 时间范围选择器
|
||||
3. **化验报告** — 报告卡片列表 + `ImagePreview` 指标详情
|
||||
4. **就诊记录** — 嵌套列表(体检/门诊/住院)
|
||||
5. **随访记录** — 嵌套列表 + 关联的随访记录
|
||||
|
||||
#### PatientTagManage.tsx(低复杂度,0.5 天)
|
||||
|
||||
- 标准 CRUD 表格
|
||||
- 颜色选择器(Ant Design `ColorPicker`)
|
||||
- 批量打标功能
|
||||
|
||||
#### DoctorList.tsx(低复杂度,0.5 天)
|
||||
|
||||
- 标准 CRUD 表格
|
||||
- 科室筛选 + 在线状态 Badge(online=绿/busy=黄/offline=灰)
|
||||
- 详情 Drawer
|
||||
|
||||
#### AppointmentList.tsx(中复杂度,2 天)
|
||||
|
||||
- `Segmented` 切换列表/日历视图
|
||||
- 列表模式:表格 + 状态筛选 + 日期筛选
|
||||
- 日历模式:`Calendar` + `cellRender` 显示当日预约数
|
||||
- 状态流转 Dropdown(pending → confirmed → completed/no_show/cancelled)
|
||||
- 创建预约 Modal(选择患者 + 医生 + 日期时段 + 检查排班余量)
|
||||
|
||||
#### DoctorSchedule.tsx(高复杂度,2.5 天)
|
||||
|
||||
- 选择医生后展示其排班
|
||||
- 周视图(自定义 7 列网格,每列显示一天的排班时段)
|
||||
- 月视图(Ant Design Calendar)
|
||||
- 批量创建排班(选择日期范围 + 时段模板)
|
||||
- 显示已预约/最大预约数
|
||||
|
||||
#### FollowUpTaskList.tsx(中复杂度,1.5 天)
|
||||
|
||||
- 表格 + 状态筛选(pending/in_progress/completed/overdue/cancelled)
|
||||
- 分配给医护(`DoctorSelect`)
|
||||
- 创建任务 Modal
|
||||
- 快捷"填写随访记录"按钮打开子 Modal
|
||||
|
||||
#### FollowUpRecordList.tsx(低复杂度,0.5 天)
|
||||
|
||||
- 纯只读台账
|
||||
- 筛选:日期范围、患者、任务、结果
|
||||
- 导出功能(`ExportButton`)
|
||||
|
||||
#### ConsultationList.tsx(中复杂度,1 天)
|
||||
|
||||
- 表格 + 状态筛选(waiting/active/closed)
|
||||
- 未读消息数 Badge
|
||||
- 最后消息时间
|
||||
- 关闭会话操作
|
||||
- 点击跳转 `/health/consultations/:id`
|
||||
|
||||
#### ConsultationDetail.tsx(高复杂度,2 天)
|
||||
|
||||
- `ChatBubble` 组件渲染聊天气泡
|
||||
- 根据 `sender_role` 区分左右对齐
|
||||
- 支持内容类型:text / image(`ImagePreview`)/ voice / file
|
||||
- 消息按时间排列,支持滚动加载更多(分页)
|
||||
- 导出按钮
|
||||
|
||||
### 3.6 技术难点方案
|
||||
|
||||
#### ECharts 趋势图
|
||||
|
||||
使用已安装的 `@ant-design/charts` 的 `Line` 组件。
|
||||
|
||||
- 后端 API `/patients/:id/trends/:indicator` 返回时序数据
|
||||
- 前端转换为 `{ date: string, value: number }[]`
|
||||
- 支持多指标叠加(血压收缩压/舒张压双线)
|
||||
- 封装为 `VitalSignsChart`,接收 `patientId` + `indicators` 参数
|
||||
- 时间范围选择器(7天/30天/90天)
|
||||
|
||||
#### 日历视图
|
||||
|
||||
Ant Design `Calendar` + 自定义 `cellRender`:
|
||||
- DoctorSchedule:每个日期格显示排班时段标签
|
||||
- AppointmentList:每个日期格显示预约数量气泡
|
||||
|
||||
#### 聊天 UI
|
||||
|
||||
自定义 `ChatBubble` 组件,基于 Ant Design `Typography.Paragraph` + `Avatar`:
|
||||
- 根据 `sender_role` 区分样式
|
||||
- 只读模式(PC 后台只查看不发送)
|
||||
- 图片消息使用 `Image.PreviewGroup`
|
||||
|
||||
#### 导出
|
||||
|
||||
后端 blob 导出 + 前端触发下载,参照 `PluginCRUDPage` 中已有的 `exportPluginDataAsBlob` 模式。
|
||||
|
||||
#### 文件上传/预览
|
||||
|
||||
- 上传:Ant Design `Upload.Dragger`,上传到后端文件接口
|
||||
- 图片预览:Ant Design `Image.PreviewGroup`
|
||||
- PDF 预览:新窗口打开(V1 简化方案)
|
||||
|
||||
### 3.7 开发顺序
|
||||
|
||||
| Phase | 内容 | 天数 | 依赖 |
|
||||
|-------|------|------|------|
|
||||
| 1 | API 层 6 文件 + 通用组件 + 路由菜单 | 1.5 | 无 |
|
||||
| 2 | PatientList + PatientTagManage + PatientDetail 基本信息Tab | 2 | Phase 1 |
|
||||
| 3 | VitalSignsChart + 健康趋势 Tab + LabReportList + HealthRecordList | 3 | Phase 2 |
|
||||
| 4 | DoctorList + AppointmentList + DoctorSchedule | 3 | Phase 1 |
|
||||
| 5 | FollowUpTaskList + FollowUpRecordList + ConsultationList + ConsultationDetail | 3 | Phase 1 |
|
||||
| 6 | 打磨(暗色主题 + 响应式 + 联调) | 1 | Phase 2-5 |
|
||||
| **合计** | | **13.5 天** | |
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试策略(阶段 2-3 交叉进行)
|
||||
|
||||
### 4.1 优先级排序
|
||||
|
||||
| 优先级 | 测试目标 | 预估用例数 | 工作量 |
|
||||
|--------|---------|-----------|--------|
|
||||
| P0 | `validation.rs` 纯函数 | 20-30 | 1 天 |
|
||||
| P0 | `appointment_service` CAS + 状态流转 | 15-20 | 2 天 |
|
||||
| P0 | `patient_service` CRUD + 状态机 | 15-20 | 2 天 |
|
||||
| P1 | `consultation_service` 消息原子性 | 10-15 | 2 天 |
|
||||
| P1 | `health_data_service` 指标数据 | 10-15 | 1 天 |
|
||||
| P2 | `follow_up_service` 链式任务 | 10 | 1 天 |
|
||||
|
||||
### 4.2 测试基础设施
|
||||
|
||||
在 `erp-health/Cargo.toml` 中添加 `[dev-dependencies]`:
|
||||
- `tokio` 的 `test` 和 `macros` feature
|
||||
- `sea-orm` 的 `mock` feature(用于简单单元测试,如 validation 纯函数)
|
||||
|
||||
对于涉及事务和 CAS 的集成测试(预约并发、消息原子性),使用 testcontainers-postgreSQL 做真实数据库测试,因为 SeaORM 的 `MockDatabaseConnection` 不支持复杂事务模拟。
|
||||
|
||||
创建 `tests/test_helpers.rs` 提供:
|
||||
- `create_test_health_state()` — 带 mock db 的 HealthState(单元测试用)
|
||||
- `create_integration_db()` — testcontainers PostgreSQL 实例(集成测试用)
|
||||
- 共享 fixture 工厂
|
||||
|
||||
### 4.3 关键测试场景
|
||||
|
||||
**预约 CAS 并发**:
|
||||
- 排班已满 → 创建预约失败
|
||||
- 排班有余 → CAS 成功 + 名额减 1
|
||||
- 并发创建 → 只有 max_appointments 个成功
|
||||
|
||||
**状态机转换**:
|
||||
- 合法转换:pending → confirmed → completed
|
||||
- 非法转换:completed → pending → 拒绝
|
||||
- 取消:任意状态 → cancelled(填 cancel_reason)
|
||||
|
||||
**随访链式任务**:
|
||||
- next_follow_up_date 不为空 → 自动创建新任务
|
||||
- 新任务的 assigned_to 沿用当前医护
|
||||
- next_follow_up_date 为空 → 不创建新任务
|
||||
|
||||
---
|
||||
|
||||
## 5. 实施路线图
|
||||
|
||||
### 5.1 总时间线(调整为 7 周)
|
||||
|
||||
```
|
||||
Week 1-2 | 安全地基(1.5-2 周)
|
||||
| ├── sanitize 全覆盖(2 天)
|
||||
| ├── 审计日志注入(2 天)
|
||||
| ├── 身份证号加密 + HMAC 索引 + 数据迁移(3-4 天)
|
||||
| └── 字段级脱敏(1-2 天)
|
||||
|
||||
Week 2-4 | 后端补完 + 测试(1.5-2 周)
|
||||
| ├── 事件处理器实现(2 天)
|
||||
| ├── 数据一致性修复(2 天)
|
||||
| ├── 随访逾期定时任务(1 天)
|
||||
| ├── article CRUD(0.5 天)
|
||||
| └── 核心路径测试(5-6 天)
|
||||
|
||||
Week 4-7 | Web 前端(3.5-4 周)
|
||||
| ├── Phase 1: API 层 + 通用组件 + 路由菜单(1.5 天)
|
||||
| ├── Phase 2: 核心入口页面(2 天)
|
||||
| ├── Phase 3: 健康数据页面(3 天)
|
||||
| ├── Phase 4: 预约排班页面(3 天)
|
||||
| ├── Phase 5: 随访咨询页面(3 天)
|
||||
| └── Phase 6: 打磨联调(1 天)
|
||||
|
||||
Week 7-8 | 端到端验证(1 周)
|
||||
| ├── 小程序联调
|
||||
| ├── 种子数据填充
|
||||
| ├── Docker 演示环境
|
||||
| └── 文档更新
|
||||
```
|
||||
|
||||
### 5.2 里程碑
|
||||
|
||||
| 里程碑 | 交付物 | 验收标准 |
|
||||
|--------|--------|---------|
|
||||
| M1 | 安全省基完成 | sanitize + 审计 + 加密 + 脱敏全部到位,cargo test 通过 |
|
||||
| M2 | 后端功能完整 | 事件处理器 + 数据一致性 + 测试覆盖,cargo test 通过 |
|
||||
| M3 | Web 3 核心页面 | PatientList + AppointmentList + DoctorSchedule 可操作 |
|
||||
| M4 | Web 10 页面完成 | 所有页面功能可用,pnpm build 通过 |
|
||||
| M5 | 端到端验证 | Web + 小程序 + 后端全链路可演示 |
|
||||
|
||||
### 5.3 风险和缓解
|
||||
|
||||
| 风险 | 概率 | 缓解 |
|
||||
|------|------|------|
|
||||
| ECharts 集成复杂度高 | 中 | 使用 @ant-design/charts 已安装,降低自研成本 |
|
||||
| 身份证加密影响现有查询 | 中 | HMAC 索引 + 数据迁移脚本 + 备份表 + 回滚方案 |
|
||||
| 10 页面开发时间超预期 | 高 | 按优先级裁剪,MVP 先做 3 核心页面 |
|
||||
| 文件上传能力未就绪 | 中 | V1 先支持 URL 存储,文件上传推迟到 V1.1 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 不在本设计范围内(推迟到 V2)
|
||||
|
||||
- 积分商城
|
||||
- 数据统计中心 / 运营驾驶舱
|
||||
- AI 辅助诊断/报告解读
|
||||
- 实时 WebSocket 在线咨询
|
||||
- 咨询消息按月分区
|
||||
- 事件幂等性(processed_events 去重表)
|
||||
- Polling Outbox 重试机制
|
||||
- HealthState 扩展 Redis 缓存
|
||||
- 国际化(英文等多语言)
|
||||
- 小程序医护端
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,469 @@
|
||||
# HMS 患者小程序迭代设计规格
|
||||
|
||||
> **版本**: v1.0
|
||||
> **日期**: 2026-04-24
|
||||
> **状态**: 草案
|
||||
> **关联**: 小程序初版设计 `2026-04-23-hms-miniprogram-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
小程序初版已完成 21 个页面、7 个 API service 的基础实现,覆盖登录、健康数据、预约挂号、检验报告、随访管理、用药提醒、健康资讯、个人中心。当前处于**开发阶段**,工程质量和用户体验存在明显短板,距测试阶段尚有差距。
|
||||
|
||||
### 1.2 问题全景
|
||||
|
||||
| 优先级 | 问题 | 影响 |
|
||||
|--------|------|------|
|
||||
| P0 | 大量重复代码(profile/reports ≈ report/index, profile/followups ≈ followup/index) | 维护成本翻倍 |
|
||||
| P0 | 预约详情通过 Storage 缓存传递而非 API 获取 | 数据不一致 |
|
||||
| P0 | EmptyState 导入方式不一致导致运行时报错 | 页面崩溃 |
|
||||
| P0 | 手机号绑定后端硬编码 `"13800000000"` | 无法上线 |
|
||||
| P0 | `getTodaySummary()` 调用的后端端点不存在 | 首页/健康页数据无法加载 |
|
||||
| P1 | ErrorState 组件定义但未使用 | 错误处理不统一 |
|
||||
| P1 | mixins.scss 定义但未使用 | 样式重复内联 |
|
||||
| P1 | 无全局错误边界 | 页面崩溃无兜底 |
|
||||
| P1 | tryRefreshToken 静默吞异常 | 调试困难 |
|
||||
| P1 | 趋势图缓存永不过期 | 数据过时 |
|
||||
| P1 | 随访详情获取低效(listTasks().find()) | 性能浪费 |
|
||||
| P1 | 首页/健康页缺少 loading 状态 | 体验空白 |
|
||||
| P1 | 用药提醒纯本地 Storage | 换设备即丢失(后续版本解决) |
|
||||
| P2 | 路径别名 @/* 未使用 | 代码可读性差 |
|
||||
| P2 | 无 schema 验证库 | 表单验证脆弱 |
|
||||
| P2 | 趋势图纯 CSS 柱状图 | 无交互能力 |
|
||||
| P2 | 用药提醒时间选择器未实现 | 功能不完整 |
|
||||
| P2 | 无日志/埋点/上报 | 无法追踪问题 |
|
||||
|
||||
### 1.3 迭代策略:混合策略
|
||||
|
||||
采用**先基建再模块**的混合策略,分 4 个 Sprint 交付:
|
||||
|
||||
```
|
||||
Sprint 0 (2-3天) Sprint 1 (3-4天) Sprint 2 (3-4天) Sprint 3 (4-5天)
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ 工程基础修复 │ → │ 健康数据打磨 │ → │ 预约+通知 │ → │ 报告/随访/ │
|
||||
│ │ │ │ │ │ │ 安全+增长 │
|
||||
│ · 消除重复代码│ │ · ECharts图表│ │ · 步骤指示器 │ │ · 指标卡片 │
|
||||
│ · 统一错误处理│ │ · 缓存TTL │ │ · 周视图日历 │ │ · Token加密 │
|
||||
│ · 修复数据传递│ │ · zod验证 │ │ · 订阅消息 │ │ · 手机号解密 │
|
||||
│ · 统一Loading │ │ · 状态色卡片 │ │ · 时段可视化 │ │ · 埋点+分享 │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
**原则**:Sprint 0 铺路,后续每个 Sprint 都受益于基础设施改善。Sprint 0 只修不建,不引入新依赖。
|
||||
|
||||
---
|
||||
|
||||
## 2. Sprint 0:工程基础修复
|
||||
|
||||
**目标**:消除最痛的工程问题,为后续所有 Sprint 铺路。约束 2-3 天完成。
|
||||
|
||||
### 2.1 修复阻断性 API 端点缺失
|
||||
|
||||
**现状**:前端 `services/health.ts` 的 `getTodaySummary()` 调用 `GET /health/vital-signs?date=today`,但后端路由中**不存在此端点**。后端仅有 `GET /health/patients/{id}/vital-signs`(需 patient_id 路径参数)。这意味着首页"今日健康"卡片和健康页的数据从一开始就**无法加载**。
|
||||
|
||||
**方案**:
|
||||
|
||||
- 后端在 `erp-health` 新增小程序专用端点 `GET /health/vital-signs/today`,通过 JWT `user_id` 自动关联 patient(类似已有的 `GET /health/vital-signs/trend` 模式)
|
||||
- 前端 `services/health.ts` 的 `getTodaySummary()` 调整为调用新端点
|
||||
- 此项为 **Sprint 0 最高优先级**,阻塞首页和健康页基本功能
|
||||
|
||||
**涉及文件**:
|
||||
- 后端新增:`erp-health` handler + 路由注册
|
||||
- 修改:`services/health.ts`
|
||||
|
||||
### 2.2 消除重复页面
|
||||
|
||||
**现状**:`pages/report/index` 与 `pages/profile/reports/index` 几乎完全重复,`pages/followup/index` 与 `pages/profile/followups/index` 同理。且 `report/index` 和 `followup/index` 没有明确的导航入口。
|
||||
|
||||
**方案**:
|
||||
|
||||
1. 删除 `pages/report/index` 和 `pages/followup/index` 及其 SCSS 文件
|
||||
2. 从 `app.config.ts` 移除对应路由注册
|
||||
3. 首页快捷入口和 profile 菜单统一指向 `profile/reports` 和 `profile/followups`
|
||||
4. 如果后续需要独立入口,则抽取共享组件 `components/ReportList` 和 `components/FollowupList`,两个页面只做薄壳路由
|
||||
|
||||
**涉及文件**:
|
||||
- 删除:`pages/report/index.tsx`、`pages/report/index.scss`
|
||||
- 删除:`pages/followup/index.tsx`、`pages/followup/index.scss`
|
||||
- 修改:`app.config.ts`(移除路由)
|
||||
- 修改:`pages/index/index.tsx`(快捷入口路径)
|
||||
|
||||
### 2.2 统一错误处理
|
||||
|
||||
**现状**:`ErrorState` 组件已定义但未被任何页面使用,各页面内联 `showToast` 错误提示。无全局错误边界。
|
||||
|
||||
**方案**:
|
||||
|
||||
1. 所有列表页、详情页统一使用 `ErrorState` 组件,替换内联错误提示
|
||||
2. 在 `app.tsx` 添加 React Error Boundary 组件,兜底页面崩溃
|
||||
3. 新建 `components/ErrorBoundary/index.tsx`
|
||||
4. 修复 `tryRefreshToken` 的 catch 块,添加 `console.error` 日志
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`components/ErrorBoundary/index.tsx`
|
||||
- 修改:`app.tsx`(包裹 ErrorBoundary)
|
||||
- 修改:所有列表页和详情页(替换内联错误处理为 ErrorState)
|
||||
- 修改:`services/request.ts`(tryRefreshToken 日志)
|
||||
|
||||
### 2.3 修复数据传递问题
|
||||
|
||||
**预约详情**:
|
||||
- 移除 `appointment_detail_cache` Storage 传递
|
||||
- 改为进入页面时通过 `GET /health/appointments/:id` 获取数据
|
||||
- **后端需新增此端点**(当前仅有列表 `GET`、创建 `POST`、状态更新 `PUT`,缺少单条查询 `GET`)
|
||||
|
||||
**随访详情**:
|
||||
- 后端**需新增** `GET /health/follow-up-tasks/:id` 单条查询端点(当前 `{id}` 路由仅注册了 `PUT` 和 `DELETE`,缺少 `GET`)
|
||||
- 前端替换 `listTasks().find()` 为直接按 ID 查询
|
||||
|
||||
> **注意**:以上后端新增端点为 Sprint 0 前置阻塞项。如果后端资源有限,前端先做"调用端点"的准备代码,后端并行实现。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/appointment/detail/index.tsx`
|
||||
- 修改:`services/appointment.ts`(新增 getDetail 方法)
|
||||
- 修改:`services/followup.ts`(新增 getTaskDetail 方法)
|
||||
- 后端新增:`erp-health` 预约单条查询 + 随访单条查询端点
|
||||
|
||||
### 2.4 统一 Loading 状态
|
||||
|
||||
**现状**:首页和健康页的 `loading` 状态已在 store 中定义但未在 UI 层消费。详情页使用内联 `<Text>加载中...</Text>`。
|
||||
|
||||
**方案**:
|
||||
|
||||
1. 首页和健康页在数据加载时展示 `Loading` 组件
|
||||
2. 所有详情页统一使用 `Loading` 组件替换内联文字
|
||||
3. 预约创建页三步骤切换时也展示 loading
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/index/index.tsx`(消费 loading 状态)
|
||||
- 修改:`pages/health/index.tsx`(消费 loading 状态)
|
||||
- 修改:所有详情页 tsx(替换内联加载文字)
|
||||
|
||||
### 2.5 杂项修复
|
||||
|
||||
| 项目 | 方案 |
|
||||
|------|------|
|
||||
| EmptyState 导入 bug | 首页 `import { EmptyState }` 改为 `import EmptyState`(默认导入) |
|
||||
| 路径别名启用 | `services/` 和 `stores/` 层的 import 逐步改为 `@/` 别名 |
|
||||
| mixins.scss 复用 | 新写的页面样式使用 `@include card`、`@include flex-center`、`@include safe-bottom` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Sprint 1:健康数据模块打磨
|
||||
|
||||
**目标**:升级健康数据录入、展示和趋势分析体验,从"能用"到"好用"。
|
||||
|
||||
### 3.1 健康卡片状态色
|
||||
|
||||
**现状**:四张健康卡片(血压/心率/血糖/体重)样式统一灰色,无状态区分。
|
||||
|
||||
**方案**:
|
||||
|
||||
每张卡片根据指标状态着色:
|
||||
- **正常**:左侧绿色边条 + 绿色"正常 ─"标签
|
||||
- **偏高**:左侧红色边条 + 红色"偏高 ▲{差值}"标签
|
||||
- **偏低**:左侧红色边条 + 红色"偏低 ▼{差值}"标签
|
||||
- **无数据**:灰色,保持现状
|
||||
|
||||
异常指标数值变红,卡片底部显示参考范围。
|
||||
|
||||
**后端配合**:后端需在新增的 `GET /health/vital-signs/today` 端点中返回 `status`(normal/high/low)和 `reference_range`。前端 `TodaySummary` 类型同步新增 `reference_range` 字段(当前已有 `status` 字段但后端无对应返回)。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/health/index.tsx`(卡片样式逻辑)
|
||||
- 修改:`pages/index/index.tsx`(首页健康卡片同步更新)
|
||||
- 修改:`services/health.ts`(类型定义增加 status 字段)
|
||||
|
||||
### 3.2 ECharts 趋势图
|
||||
|
||||
**现状**:纯 CSS div 柱状图,无交互、无缩放、无 tooltip。
|
||||
|
||||
**方案**:
|
||||
|
||||
引入 `echarts-taro3-react`(设计规格中已规划)。**前置条件**:Sprint 1 开始前需做技术预研(spike),验证 `echarts-taro3-react` 在 Taro 4.2.0 + webpack5 下的兼容性。如果不可用,备选方案为 `echarts-for-weixin` + 手动封装为 React 组件。
|
||||
|
||||
实现:
|
||||
|
||||
- **折线图**:数据点连线,异常点标红放大
|
||||
- **参考范围色带**:正常值区间以半透明绿色背景显示
|
||||
- **Tooltip**:长按/点击显示具体数值和日期
|
||||
- **时间范围切换**:7天/30天/90天 三个 tab
|
||||
- **缓存 TTL**:趋势数据缓存 5 分钟后自动过期,强制重新请求
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`components/TrendChart/index.tsx`、`components/TrendChart/index.scss`
|
||||
- 重写:`pages/health/trend/index.tsx`
|
||||
- 修改:`stores/health.ts`(缓存 TTL 机制)
|
||||
- 新增依赖:`echarts-taro3-react`
|
||||
|
||||
### 3.3 表单验证升级
|
||||
|
||||
**现状**:所有表单验证为手动 if 判断,无 schema 约束。
|
||||
|
||||
**方案**:
|
||||
|
||||
引入 `zod`(~3KB gzip),为每个表单定义验证 schema:
|
||||
|
||||
```typescript
|
||||
// 示例:体征录入验证
|
||||
const vitalSignSchema = z.object({
|
||||
indicator_type: z.enum(['blood_pressure', 'heart_rate', 'blood_sugar', 'weight']),
|
||||
value: z.number().positive(),
|
||||
extra: z.object({ systolic: z.number().min(60).max(250).optional(),
|
||||
diastolic: z.number().min(40).max(150).optional() }).optional(),
|
||||
measured_at: z.string().datetime().optional(),
|
||||
note: z.string().max(200).optional()
|
||||
});
|
||||
```
|
||||
|
||||
异常值即时警告(如收缩压 > 180 显示红色提示"请及时就医")。
|
||||
|
||||
录入成功后自动刷新首页卡片 + 清除趋势缓存。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/health/input/index.tsx`(zod schema 验证)
|
||||
- 修改:`stores/health.ts`(录入成功后清除缓存)
|
||||
- 新增依赖:`zod`
|
||||
|
||||
---
|
||||
|
||||
## 4. Sprint 2:预约挂号 + 通知触达
|
||||
|
||||
**目标**:优化预约三步流程体验,建立微信订阅消息通知机制。
|
||||
|
||||
### 4.1 三步流程升级
|
||||
|
||||
**现状**:三个步骤(选科室 → 选医生 → 选日期时段)无进度指示,排班信息为纯文字列表。
|
||||
|
||||
**方案**:
|
||||
|
||||
**步骤指示器**:
|
||||
- 新增 `components/StepIndicator/index.tsx`
|
||||
- 顶部固定 1→2→3 步骤条,当前步骤高亮,已完成步骤可点击回退
|
||||
- 步骤间切换带过渡动画
|
||||
|
||||
**科室选择**:
|
||||
- 从文字列表改为宫格卡片(图标 + 科室名 + 医生数)
|
||||
- 每个科室卡片可点击,选中后高亮边框
|
||||
|
||||
**排班日历**:
|
||||
- 新增 `components/WeekCalendar/index.tsx` 周视图日历
|
||||
- 有排班的日期标记绿点,无排班的日期灰色
|
||||
- 点击日期展示该日可用时段卡片
|
||||
- 时段卡片按剩余名额着色:>3 绿色、1-3 橙色、0 灰色不可选
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`components/StepIndicator/index.tsx`
|
||||
- 新增:`components/WeekCalendar/index.tsx`
|
||||
- 重写:`pages/appointment/create/index.tsx`
|
||||
|
||||
### 4.2 微信订阅消息
|
||||
|
||||
**现状**:无任何推送通知机制。
|
||||
|
||||
**方案**:
|
||||
|
||||
1. 后端在微信公众平台注册订阅消息模板:
|
||||
- 预约就诊提醒(就诊前 1 天推送)
|
||||
- 随访任务提醒(截止前 1 天推送)
|
||||
- 报告出具通知(新报告发布时推送)
|
||||
|
||||
2. 前端在关键场景引导用户订阅:
|
||||
- 预约成功后弹出订阅授权
|
||||
- 随访提交后引导订阅下次提醒
|
||||
|
||||
3. 后端定时任务检查待推送消息并触发
|
||||
|
||||
4. **降级设计**:用户拒绝订阅时,消息仍写入 `erp-message` 消息中心。小程序"我的"页面顶部显示未读消息数量红点,作为消息触达的备选渠道。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/appointment/detail/index.tsx`(预约成功后订阅引导)
|
||||
- 修改:`pages/followup/detail/index.tsx`(随访提交后订阅引导)
|
||||
- 后端新增:`erp-server` 订阅消息模板注册 + 定时推送任务
|
||||
|
||||
---
|
||||
|
||||
## 5. Sprint 3:报告/随访/个人中心 + 安全 + 增长
|
||||
|
||||
**目标**:打磨剩余模块,完成安全加固和增长基础建设,达到可测试状态。
|
||||
|
||||
### 5.1 报告详情页升级
|
||||
|
||||
**现状**:所有指标卡片样式相同,无法一眼区分正常/异常。
|
||||
|
||||
**方案**:
|
||||
|
||||
指标卡片按状态着色:
|
||||
- **正常**:绿色背景 + 绿色"✓ 正常"标签 + 绿色数值
|
||||
- **偏高**:红色背景 + 红色"↑ 偏高"标签 + 红色数值
|
||||
- **偏低**:红色背景 + 红色"↓ 偏低"标签 + 红色数值
|
||||
|
||||
顶部汇总标签:`2 项异常 · 1 项正常`,一眼掌握整体状况。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/report/detail/index.tsx`
|
||||
- 修改:`pages/profile/reports/index.tsx`(如果仍独立存在)
|
||||
|
||||
### 5.2 随访 UX 细节
|
||||
|
||||
- 任务卡片增加截止日期倒计时("还剩 2 天",红色紧迫)
|
||||
- 过期任务灰色标记
|
||||
- 提交记录后增加"提交成功"确认动画(checkmark 缩放)
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/profile/followups/index.tsx`
|
||||
- 修改:`pages/followup/detail/index.tsx`
|
||||
|
||||
### 5.3 个人中心改进
|
||||
|
||||
**用药提醒**:
|
||||
- 实现时间选择器 Picker(替换当前静态文本)
|
||||
- 增加"提醒开关"(enabled/disabled)
|
||||
- 注:用药提醒数据仍为本地 Storage 存储,**后端同步作为后续版本事项**。MVP 阶段接受"换设备即丢失"的限制。
|
||||
|
||||
**就诊人管理**:
|
||||
- 增加编辑功能(当前只能添加不能编辑)
|
||||
- 复用 `family-add` 页面,传入已有数据进入编辑模式
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/profile/medication/index.tsx`(时间 Picker)
|
||||
- 修改:`pages/profile/family/index.tsx`(编辑入口)
|
||||
- 修改:`pages/profile/family-add/index.tsx`(编辑模式支持)
|
||||
|
||||
### 5.4 安全加固
|
||||
|
||||
#### 5.4.1 Token 安全
|
||||
|
||||
**现状**:Access Token 和 Refresh Token 明文存储在 `Taro.setStorageSync`。
|
||||
|
||||
**方案**:
|
||||
|
||||
MVP 阶段采用简化方案:微信小程序的 Storage 本身有沙箱隔离,明文存储的边际风险有限。做以下最低成本改进:
|
||||
|
||||
- 使用 `wx.getRandomValues()` 生成随机密钥,单独 key 存储
|
||||
- Token 存储时用此密钥做简单混淆(XOR 或 AES-ECB 单块加密)
|
||||
- 目的:防止 Storage 被直接明文读取,非追求密码学安全级别
|
||||
|
||||
> **后续版本**:如果合规要求提高,再升级为完整的 AES-GCM 方案。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`utils/crypto.ts`(轻量混淆工具)
|
||||
- 修改:`stores/auth.ts`(Storage 读写走混淆层)
|
||||
|
||||
#### 5.4.2 手机号真实解密
|
||||
|
||||
**现状**:`wechat_service.rs` 第 82 行硬编码 `"13800000000"`。
|
||||
|
||||
**方案**:
|
||||
- 后端接入微信 `phonenumber.getPhoneNumber` 接口
|
||||
- 使用 `encryptedData` + `iv` + `session_key` 解密真实手机号
|
||||
- 前端无需改动(已传递正确的 encryptedData 和 iv)
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`crates/erp-auth/src/service/wechat_service.rs`
|
||||
|
||||
#### 5.4.3 用户协议与隐私政策
|
||||
|
||||
- 新增 `pages/agreement/index.tsx` 页面
|
||||
- 登录页增加"阅读并同意《用户协议》和《隐私政策》"勾选
|
||||
- 权限使用说明文案(获取手机号用途声明)
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`pages/agreement/index.tsx`
|
||||
- 修改:`pages/login/index.tsx`(协议勾选)
|
||||
- 修改:`app.config.ts`(新增路由)
|
||||
|
||||
### 5.5 增长基础
|
||||
|
||||
#### 5.5.1 数据埋点
|
||||
|
||||
新增 `services/analytics.ts`,轻量事件记录:
|
||||
|
||||
```typescript
|
||||
// 核心事件类型
|
||||
type AnalyticsEvent =
|
||||
| { type: 'page_view'; page: string; duration_ms?: number }
|
||||
| { type: 'feature_use'; feature: string; action: string }
|
||||
| { type: 'error'; message: string; stack?: string }
|
||||
```
|
||||
|
||||
- 页面进入/离开自动记录 `page_view`
|
||||
- 关键操作(录入数据、创建预约、提交随访)记录 `feature_use`
|
||||
- 捕获的错误记录 `error`
|
||||
- MVP 阶段:事件写入本地 `console.info` + Taro Storage 缓存(最近 100 条)
|
||||
- 后续版本:批量上报到后端 `POST /api/v1/analytics/events`
|
||||
|
||||
**涉及文件**:
|
||||
- 新增:`services/analytics.ts`
|
||||
- 修改:`app.tsx`(全局页面进入/离开监听)
|
||||
|
||||
#### 5.5.2 分享能力
|
||||
|
||||
- 文章详情页支持分享到微信好友/朋友圈
|
||||
- 自定义分享卡片(标题 + 摘要 + 封面图)通过 `onShareAppMessage` 和 `onShareTimeline` 实现
|
||||
|
||||
> **后续版本**:健康报告 Canvas 分享图片生成、PC 扫码登录。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改:`pages/article/detail/index.tsx`(onShareAppMessage + onShareTimeline)
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件变更总览
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 | Sprint |
|
||||
|------|------|--------|
|
||||
| `components/ErrorBoundary/index.tsx` | 全局错误边界 | 0 |
|
||||
| `components/TrendChart/index.tsx` | ECharts 趋势图 | 1 |
|
||||
| `components/StepIndicator/index.tsx` | 步骤指示器 | 2 |
|
||||
| `components/WeekCalendar/index.tsx` | 周视图日历 | 2 |
|
||||
| `pages/agreement/index.tsx` | 用户协议/隐私政策 | 3 |
|
||||
| `utils/crypto.ts` | Token 加密工具 | 3 |
|
||||
| `services/analytics.ts` | 数据埋点 | 3 |
|
||||
|
||||
### 删除文件
|
||||
|
||||
| 文件 | 原因 | Sprint |
|
||||
|------|------|--------|
|
||||
| `pages/report/index.tsx` | 与 profile/reports 重复 | 0 |
|
||||
| `pages/report/index.scss` | 同上 | 0 |
|
||||
| `pages/followup/index.tsx` | 与 profile/followups 重复 | 0 |
|
||||
| `pages/followup/index.scss` | 同上 | 0 |
|
||||
|
||||
### 新增依赖
|
||||
|
||||
| 依赖 | 用途 | 体积 | Sprint |
|
||||
|------|------|------|--------|
|
||||
| `echarts-taro3-react` | 交互式图表 | 封装层 ~5KB + echarts 按需 ~100-200KB (gzip) | 1 |
|
||||
| `zod` | 表单 schema 验证(长期投资) | ~3KB (gzip) | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 约束与风险
|
||||
|
||||
| 风险 | 应对策略 |
|
||||
|------|---------|
|
||||
| ECharts 增大包体积 | 按需引入 echarts 模块,不引入全量包;监控主包大小不超过 2MB |
|
||||
| 微信订阅消息需用户主动触发 | 在预约成功、随访提交等高意愿场景引导订阅 |
|
||||
| Token 加密增加启动耗时 | AES-GCM 加解密 < 1ms,可忽略 |
|
||||
| zod 增加包体积 | 3KB gzip,远小于自写验证代码量 |
|
||||
| Sprint 0 范围膨胀 | 严格只修不建,不引入新依赖,不重构架构 |
|
||||
| 后端端点未实现阻塞前端 | Sprint 0/1 的端点基本已实现;Sprint 2 订阅消息、Sprint 3 analytics 需后端配合 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
每个 Sprint 完成时必须满足:
|
||||
|
||||
- [ ] `pnpm build:weapp` 生产构建通过
|
||||
- [ ] 微信开发者工具无编译错误
|
||||
- [ ] 所有涉及页面真机预览功能正常
|
||||
- [ ] 无 console.error 或未捕获异常
|
||||
- [ ] 已修改的页面 loading/error/empty 三态完整
|
||||
- [ ] 所有代码已提交
|
||||
File diff suppressed because it is too large
Load Diff
2217
docs/archive/superpowers-completed/2026-04-25-erp-ai-phase1-mvp.md
Normal file
2217
docs/archive/superpowers-completed/2026-04-25-erp-ai-phase1-mvp.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
# HMS 功能完善迭代设计规格
|
||||
|
||||
> 日期: 2026-04-25
|
||||
> 状态: 已确认(审查修正版)
|
||||
> 关联: `docs/superpowers/specs/2026-04-25-erp-ai-module-design.md`
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 项目现状
|
||||
|
||||
HMS 健康管理平台已完成核心业务开发(237 次提交、57k 行 Rust + 174 前端文件),但存在以下功能缺口:
|
||||
|
||||
- **按钮级权限控制缺失** — 路由守卫已有,但操作按钮(新增/编辑/删除)未做权限过滤;前端缺少权限数据源(`UserInfo` 接口不含 `permissions` 字段)
|
||||
- **AI 模块管理端空白** — 后端 6 个 API 端点中 4 个 SSE 流式端点可用,但 Prompt CRUD、分析历史查询、用量统计端点均为空壳或缺失
|
||||
- **小程序端 AI 不可见** — 患者无法查看 AI 分析报告
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
通过纵向切片方式逐步交付三个功能域,每个切片从前到后完整打通:
|
||||
|
||||
1. **切片 1:按钮级权限** — 基础设施,后续页面的前置依赖
|
||||
2. **切片 2:AI 管理端** — 3 个 PC 管理页面
|
||||
3. **切片 3:小程序报告** — 患者端只读查看
|
||||
|
||||
---
|
||||
|
||||
## 2. 切片 1:按钮级权限控制
|
||||
|
||||
### 2.1 架构
|
||||
|
||||
```
|
||||
后端新增 /api/v1/auth/me/permissions → 返回当前用户权限码列表
|
||||
↓
|
||||
auth store (permissions: string[]) ← 登录时从新端点加载
|
||||
↓
|
||||
usePermission(code) → { hasPermission: boolean }
|
||||
↓
|
||||
<AuthButton code="health.patient.manage"> ... </AuthButton>
|
||||
↓
|
||||
无权限 → 不渲染(hidden 模式)
|
||||
有权限 → 正常渲染子元素
|
||||
```
|
||||
|
||||
**前置依赖:** 当前 `UserInfo` 接口不含 `permissions` 字段(仅含 `roles`)。需后端新增 `/api/v1/auth/me/permissions` 端点,返回当前用户所有权限码的扁平列表(从角色 → 权限关联表聚合)。超级管理员(`is_system: true`)默认返回全部权限码。
|
||||
|
||||
### 2.2 组件设计
|
||||
|
||||
**usePermission hook**
|
||||
|
||||
位置: `apps/web/src/hooks/usePermission.ts`
|
||||
|
||||
```typescript
|
||||
function usePermission(code: string): { hasPermission: boolean }
|
||||
```
|
||||
|
||||
- 从 auth store 读取当前用户 permissions 数组
|
||||
- 返回 code 是否在权限列表中
|
||||
- 权限数据加载失败时默认无权限(安全降级)
|
||||
|
||||
**AuthButton 组件**
|
||||
|
||||
位置: `apps/web/src/components/AuthButton.tsx`
|
||||
|
||||
Props:
|
||||
- `code: string` — 权限码(如 `health.patient.manage`)
|
||||
- `children: ReactNode` — 受保护的按钮内容
|
||||
|
||||
行为: 无权限时不渲染 children(hidden 模式)。
|
||||
|
||||
**AuthGuard 组件**
|
||||
|
||||
位置: `apps/web/src/components/AuthGuard.tsx`
|
||||
|
||||
Props:
|
||||
- `code: string` — 权限码
|
||||
- `children: ReactNode` — 受保护的内容块
|
||||
|
||||
行为: 同 AuthButton,用于包裹非按钮内容(如整个 Tab、区块)。
|
||||
|
||||
### 2.3 改造范围
|
||||
|
||||
优先改造健康模块 15 个页面中的操作按钮:
|
||||
|
||||
| 页面 | 按钮权限码 |
|
||||
|------|-----------|
|
||||
| PatientList | health.patient.manage |
|
||||
| PatientDetail | health.patient.manage |
|
||||
| AppointmentList | health.appointment.manage |
|
||||
| DoctorList | health.doctor.manage |
|
||||
| DoctorSchedule | health.doctor.manage |
|
||||
| FollowUpTaskList | health.follow-up.manage |
|
||||
| FollowUpRecordList | health.follow-up.manage |
|
||||
| ConsultationList | health.consultation.manage |
|
||||
| ConsultationDetail | health.consultation.manage |
|
||||
| OfflineEventList | health.articles.manage |
|
||||
| PatientTagManage | health.patient.manage |
|
||||
| StatisticsDashboard | health.health-data.list (只读) |
|
||||
| PointsProductList | health.points.manage |
|
||||
| PointsOrderList | health.points.list |
|
||||
| PointsRuleList | health.points.manage |
|
||||
|
||||
扩展到基础模块页面(Users, Roles, Organizations, Workflow 等)。
|
||||
|
||||
### 2.4 验证标准
|
||||
|
||||
- [ ] 无权限用户看不到操作按钮
|
||||
- [ ] 有权限用户操作正常
|
||||
- [ ] 权限变更后界面实时更新(无需刷新)
|
||||
|
||||
---
|
||||
|
||||
## 3. 切片 2:AI 管理端 3 页面
|
||||
|
||||
### 3.1 路由设计
|
||||
|
||||
```
|
||||
/health/ai/prompts → Prompt 管理
|
||||
/health/ai/analysis → 分析历史
|
||||
/health/ai/usage → 用量统计
|
||||
```
|
||||
|
||||
### 3.2 页面 A — Prompt 管理
|
||||
|
||||
位置: `apps/web/src/pages/health/AiPromptList.tsx`
|
||||
|
||||
**功能清单:**
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 列表展示 | 表格:名称/类型(化验单解读、趋势分析、体检方案、报告摘要)/版本号/状态(active/draft)/更新时间 |
|
||||
| 新建 Prompt | Modal 表单:名称、类型(下拉)、系统提示词、用户提示词模板(支持 `{{variable}}` 占位符) |
|
||||
| 编辑 Prompt | 同新建,自动递增版本号 |
|
||||
| 激活/停用 | 切换按钮,激活时停用同类型旧模板 |
|
||||
| 版本历史 | 展开行显示所有历史版本,支持一键回滚 |
|
||||
|
||||
**API 封装:**
|
||||
|
||||
位置: `apps/web/src/api/ai/prompts.ts`
|
||||
|
||||
```typescript
|
||||
// 后端需新增 Prompt CRUD 端点(当前仅有 service 层的 get_active_prompt + create_prompt)
|
||||
getPrompts(params: ListParams): Promise<PaginatedResponse<Prompt>> // GET /api/v1/ai/prompts
|
||||
createPrompt(data: CreatePromptDto): Promise<Prompt> // POST /api/v1/ai/prompts
|
||||
updatePrompt(id: string, data: UpdatePromptDto): Promise<Prompt> // PUT /api/v1/ai/prompts/{id}
|
||||
activatePrompt(id: string): Promise<Prompt> // POST /api/v1/ai/prompts/{id}/activate
|
||||
rollbackPrompt(id: string): Promise<Prompt> // POST /api/v1/ai/prompts/{id}/rollback
|
||||
```
|
||||
|
||||
**版本回滚机制:** 每次编辑 Prompt 创建新记录(递增 version),回滚 = 将目标旧版本 `is_active` 设为 `true` 并将当前激活版本 `is_active` 设为 `false`。不删除任何版本记录。
|
||||
|
||||
**权限码:** `ai.prompt.list`(查看)、`ai.prompt.manage`(编辑/激活/回滚)
|
||||
|
||||
### 3.3 页面 B — 分析历史
|
||||
|
||||
位置: `apps/web/src/pages/health/AiAnalysisList.tsx`
|
||||
|
||||
**功能清单:**
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 列表展示 | 表格:分析类型/患者姓名/状态(streaming/completed/failed)/创建时间/token 用量 |
|
||||
| 详情查看 | 点击行展开/Modal 展示完整分析结果(Markdown 渲染) |
|
||||
| 筛选 | 按类型(4 种)、时间范围(DateRangePicker)、患者(PatientSelect 组件复用) |
|
||||
| 重新分析 | 对 failed 记录支持重新发起分析 |
|
||||
|
||||
**API 封装:**
|
||||
|
||||
位置: `apps/web/src/api/ai/analysis.ts`
|
||||
|
||||
```typescript
|
||||
// 后端当前 list_analysis/get_analysis 为空壳(返回 ApiResponse::ok(())),需实现真实查询
|
||||
getAnalysisHistory(params: AnalysisQueryParams): Promise<PaginatedResponse<Analysis>> // GET /api/v1/ai/analysis/history
|
||||
getAnalysisDetail(id: string): Promise<Analysis> // GET /api/v1/ai/analysis/{id}
|
||||
```
|
||||
|
||||
**权限码:** `ai.analysis.list`(查看)、`ai.analysis.manage`(重新分析)
|
||||
|
||||
### 3.4 页面 C — 用量统计
|
||||
|
||||
位置: `apps/web/src/pages/health/AiUsageDashboard.tsx`
|
||||
|
||||
**功能清单:**
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 概览卡片 | 4 张 StatCard:总用量/本月/今日/平均 token |
|
||||
| 趋势图 | Ant Design Charts 折线图,按日/周/月切换 |
|
||||
| 类型分布 | 饼图展示 4 种分析类型的占比 |
|
||||
| 用户排行 | 表格展示用户维度用量排名 |
|
||||
|
||||
**API 封装:**
|
||||
|
||||
位置: `apps/web/src/api/ai/usage.ts`
|
||||
|
||||
```typescript
|
||||
// 后端需完全新增:路由、handler、聚合 service
|
||||
// ai_usage_logs 表需增加 created_by 列(当前缺失 user_id),或复用 ai_analysis_results.created_by 做用户排行
|
||||
getUsageOverview(): Promise<UsageOverview> // GET /api/v1/ai/usage/overview
|
||||
getUsageTrend(params: TrendParams): Promise<TrendData[]> // GET /api/v1/ai/usage/trend
|
||||
getUsageByType(): Promise<TypeDistribution[]> // GET /api/v1/ai/usage/by-type
|
||||
getUsageByUser(params: UserRankingParams): Promise<PaginatedResponse<UserUsage>> // GET /api/v1/ai/usage/by-user
|
||||
```
|
||||
|
||||
**后端补充:** 需在 erp-ai 中新增用量统计聚合端点。优先方案:复用 `ai_analysis_results` 表的 `created_by` 字段做用户维度排行,避免修改 `ai_usage_logs` 表结构。如需精确 token 统计,后续可加迁移增加 `user_id` 列。
|
||||
|
||||
**权限码:** `ai.usage.list`
|
||||
|
||||
### 3.5 菜单注册
|
||||
|
||||
在 `apps/web/src/layouts/MainLayout.tsx` 健康管理菜单组下新增 AI 分析入口。当前菜单为扁平结构(无子菜单折叠),新增项直接追加为同级菜单项:
|
||||
|
||||
```
|
||||
健康管理
|
||||
├── 患者管理
|
||||
├── 医护管理
|
||||
├── 预约管理
|
||||
├── 随访管理
|
||||
├── 咨询管理
|
||||
├── 积分商城
|
||||
├── 统计看板
|
||||
├── AI Prompt 管理 ← 新增(扁平)
|
||||
├── AI 分析历史 ← 新增(扁平)
|
||||
└── AI 用量统计 ← 新增(扁平)
|
||||
```
|
||||
|
||||
### 3.6 验证标准
|
||||
|
||||
- [ ] Prompt CRUD 全流程可用(创建/编辑/激活/回滚)
|
||||
- [ ] 分析历史可筛选、可查看详情(Markdown 正确渲染)
|
||||
- [ ] 用量统计图表数据正确
|
||||
- [ ] 所有操作按钮受 AuthButton 权限控制
|
||||
- [ ] 页面响应式布局正常
|
||||
|
||||
---
|
||||
|
||||
## 4. 切片 3:小程序 AI 报告查看
|
||||
|
||||
### 4.1 新增页面
|
||||
|
||||
**AI 报告列表页**
|
||||
|
||||
位置: `apps/miniprogram/src/pages/ai-report/list/index.tsx`
|
||||
|
||||
- 调用 `GET /api/v1/ai/analysis/history` (后端需实现,根据 JWT user_id → patient_id 自动过滤)
|
||||
- 列表展示分析记录(类型图标 + 时间 + 状态标签)
|
||||
- 点击进入详情
|
||||
|
||||
**AI 报告详情页**
|
||||
|
||||
位置: `apps/miniprogram/src/pages/ai-report/detail/index.tsx`
|
||||
|
||||
- 调用 `GET /api/v1/ai/analysis/{id}`
|
||||
- 使用 `taro-markdown` 组件渲染 Markdown 格式的分析结果(需先验证兼容性)
|
||||
- 底部展示分析时间和 token 用量信息
|
||||
|
||||
### 4.2 路由集成
|
||||
|
||||
在首页(`pages/index/index.tsx`)健康数据区域增加"AI 报告"入口卡片。
|
||||
|
||||
### 4.3 后端依赖
|
||||
|
||||
后端 `list_analysis` 和 `get_analysis` 当前为空壳(仅验证权限后返回空值),需实现:
|
||||
- 根据 JWT 中的 user_id 查找关联 patient_id
|
||||
- 从 `ai_analysis_results` 表查询该患者的分析记录
|
||||
- 返回不含 PII 的脱敏结果
|
||||
|
||||
### 4.4 验证标准
|
||||
|
||||
- [ ] 患者可查看自己的 AI 分析历史
|
||||
- [ ] 详情页 Markdown 正确渲染
|
||||
- [ ] 无法查看其他患者的报告
|
||||
- [ ] 无报告时显示空状态提示
|
||||
|
||||
---
|
||||
|
||||
## 5. 实施顺序
|
||||
|
||||
| 阶段 | 内容 | 依赖 | 预计工作量 |
|
||||
|------|------|------|-----------|
|
||||
| P0a | 后端:新增 `/api/v1/auth/me/permissions` 端点 | 无 | erp-auth handler + service |
|
||||
| P0b | 后端:实现 Prompt CRUD 端点(list/create/update/activate/rollback) | 无 | erp-ai handler + service |
|
||||
| P0c | 后端:实现分析历史查询(list_analysis/get_analysis 从空壳到真实查询) | 无 | erp-ai handler + service |
|
||||
| P0d | 后端:新增用量统计聚合端点(overview/trend/by-type/by-user) | 无 | erp-ai handler + service + 可能迁移 |
|
||||
| P1 | 前端:usePermission hook + AuthButton/AuthGuard 组件 + auth store 加载 permissions | P0a | 3 文件 |
|
||||
| P2 | 前端:健康模块页面按钮权限改造 | P1 | 15 文件 |
|
||||
| P3 | 前端:AI API 封装(3 个 service 文件) | P0b-d | 3 文件 |
|
||||
| P4 | 前端:AI Prompt 管理页面 | P1, P3 | 1 文件 |
|
||||
| P5 | 前端:AI 分析历史页面 | P1, P3 | 1 文件 |
|
||||
| P6 | 前端:AI 用量统计页面 | P1, P3 | 1 文件 |
|
||||
| P7 | 前端:菜单注册 + 路由配置 | P4-P6 | 2 文件 |
|
||||
| P8 | 小程序:验证 taro-markdown 兼容性 + AI 报告列表/详情页 | P0c | 3 文件 |
|
||||
| P9 | 小程序:首页入口集成 | P8 | 1 文件 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 非目标(明确排除)
|
||||
|
||||
- 不涉及 CI/CD 流水线建设(属于安全与稳定性方向)
|
||||
- 不涉及 erp-plugin unwrap 修复(属于代码质量方向)
|
||||
- 不涉及 TypeScript strict 模式开启(属于质量方向,单独处理)
|
||||
- 不涉及新的 AI 提供商接入(仅使用现有 Claude 提供商)
|
||||
- 不涉及用量配额/计费功能(后续迭代)
|
||||
|
||||
---
|
||||
|
||||
## 7. 技术约束
|
||||
|
||||
- 前端组件使用 Ant Design 6 现有组件
|
||||
- 图表使用 Ant Design Charts(项目已有依赖)
|
||||
- 小程序 Markdown 渲染使用 taro-markdown 组件(P8 开始前验证兼容性)
|
||||
- 后端新增端点遵循现有 handler/service/entity 模式
|
||||
- 所有新页面使用 i18n key(前缀约定:`health.ai.*`),不硬编码中文
|
||||
- 权限数据加载失败时默认无权限(安全降级,宁可少显示按钮也不暴露越权操作)
|
||||
@@ -0,0 +1,806 @@
|
||||
# HMS 健康模块业务改进实施计划
|
||||
|
||||
> 日期: 2026-04-25
|
||||
> 设计规格: [2026-04-25-health-module-business-analysis-design.md](../specs/2026-04-25-health-module-business-analysis-design.md)
|
||||
> 总计: 4 Phase / 28 改进项 / 12-17 人天 + 路线图
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 产品可信度修复 (P0)
|
||||
|
||||
> 预计 2-3 人天 | 影响: 管理者无法决策、患者安全风险、跨模块联动断裂
|
||||
|
||||
### 1.1 修复 Dashboard 统计数据
|
||||
|
||||
**问题**: `StatisticsDashboard.tsx` 调用的 3 个非积分统计 API 全部是伪实现(list 接口取 total + 硬编码 0)。
|
||||
|
||||
**后端改动**:
|
||||
|
||||
文件: `crates/erp-health/src/handler/health_data_handler.rs` (或新建 `stats_handler.rs`)
|
||||
|
||||
新增 3 个统计端点:
|
||||
|
||||
```
|
||||
GET /api/v1/health/admin/statistics/patients
|
||||
→ { total, new_this_month, new_this_week, active_this_month }
|
||||
|
||||
GET /api/v1/health/admin/statistics/consultations
|
||||
→ { total_sessions, pending_reply, avg_response_time_minutes, this_month }
|
||||
|
||||
GET /api/v1/health/admin/statistics/follow-ups
|
||||
→ { total_tasks, completed, pending, overdue, completion_rate }
|
||||
```
|
||||
|
||||
SQL 聚合实现 (在 `health_data_service.rs` 或新建 `stats_service.rs`):
|
||||
- `new_this_month`: `SELECT COUNT(*) FROM patient WHERE created_at >= date_trunc('month', NOW()) AND tenant_id = $1`
|
||||
- `active_this_month`: `SELECT COUNT(DISTINCT patient_id) FROM points_transaction WHERE created_at >= date_trunc('month', NOW()) AND tenant_id = $1`
|
||||
- `completion_rate`: `(completed::float / NULLIF(completed + pending + overdue, 0)) * 100`
|
||||
- `avg_response_time_minutes`: `AVG(EXTRACT(EPOCH FROM (first_message.created_at - session.created_at)) / 60)`
|
||||
- `overdue`: `SELECT COUNT(*) FROM follow_up_task WHERE status = 'overdue' AND tenant_id = $1`
|
||||
|
||||
**前端改动**:
|
||||
|
||||
文件: `apps/web/src/api/health/points.ts`
|
||||
- 修改 `getPatientStats()`/`getConsultationStats()`/`getFollowUpStats()` 调用新端点
|
||||
- 移除 `list?page_size=1` 的伪实现
|
||||
|
||||
**DTO 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/dto/` 新建 `stats_dto.rs`
|
||||
- `PatientStatisticsResp`, `ConsultationStatisticsResp`, `FollowUpStatisticsResp`
|
||||
|
||||
**验证**:
|
||||
- Dashboard 四组统计卡片均显示真实数据
|
||||
- 切换租户后数据隔离正确
|
||||
|
||||
---
|
||||
|
||||
### 1.2 补全事件发布
|
||||
|
||||
**问题**: `event.rs` 只消费事件不发布。`check_overdue_tasks` 标记逾期但不发事件通知。
|
||||
|
||||
**改动**:
|
||||
|
||||
文件: `crates/erp-health/src/event.rs`
|
||||
- 定义事件常量: `const FOLLOW_UP_OVERDUE: &str = "follow_up.overdue";`
|
||||
|
||||
文件: `crates/erp-health/src/service/follow_up_service.rs`
|
||||
- 在 `check_overdue_tasks` 函数末尾,对每个被标记为 overdue 的任务发布事件
|
||||
- 事件 payload: `{ task_id, patient_id, assigned_to, planned_date, tenant_id }`
|
||||
|
||||
文件: `crates/erp-health/src/module.rs`
|
||||
- 确保 `HealthState` 持有 `EventBus` 的 `Arc` 引用
|
||||
- 将 `EventBus` 传递给 `follow_up_service::check_overdue_tasks`
|
||||
|
||||
**验证**:
|
||||
- 创建 `planned_date = 昨天` 的 pending 任务
|
||||
- 运行逾期检查后确认事件已发布到 `domain_events` 表
|
||||
|
||||
---
|
||||
|
||||
### 1.3 合并 vital_signs 和 daily_monitoring
|
||||
|
||||
**问题**: 两张表 91% 字段重叠,命名不一致(`systolic_bp_morning` vs `morning_bp_systolic`),`trend_service.rs` 只查 `vital_signs` 忽略 `daily_monitoring`。
|
||||
|
||||
**策略**: 保留 `vital_signs` 作为主表,将 `daily_monitoring` 的独有字段迁移过来,然后废弃 `daily_monitoring`。
|
||||
|
||||
**迁移文件**: `crates/erp-server/migration/src/m20260425_000001_merge_vital_signs.rs`
|
||||
|
||||
```sql
|
||||
-- 1. 给 vital_signs 添加 daily_monitoring 的独有字段
|
||||
ALTER TABLE vital_signs ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'manual';
|
||||
-- source: 'manual' | 'device' | 'daily_monitoring'
|
||||
|
||||
-- 2. 迁移 daily_monitoring 数据到 vital_signs
|
||||
INSERT INTO vital_signs (
|
||||
id, tenant_id, patient_id, record_date,
|
||||
systolic_bp_morning, diastolic_bp_morning,
|
||||
systolic_bp_evening, diastolic_bp_evening,
|
||||
heart_rate, weight, blood_sugar,
|
||||
water_intake_ml, urine_output_ml, notes,
|
||||
source, created_at, updated_at, created_by, updated_by, version
|
||||
)
|
||||
SELECT
|
||||
id, tenant_id, patient_id, record_date,
|
||||
morning_bp_systolic, morning_bp_diastolic,
|
||||
evening_bp_systolic, evening_bp_diastolic,
|
||||
NULL, weight, blood_sugar,
|
||||
fluid_intake, urine_output, notes,
|
||||
'daily_monitoring', created_at, updated_at, created_by, updated_by, 1
|
||||
FROM daily_monitoring
|
||||
WHERE deleted_at IS NULL
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
```
|
||||
|
||||
**Entity 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/entity/vital_signs.rs`
|
||||
- 添加 `source` 字段 (String, 默认 "manual")
|
||||
|
||||
**Service 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/service/trend_service.rs`
|
||||
- `generate_trend` 和 `get_mini_today` 无需改动(已在查 `vital_signs`,合并后数据自然包含)
|
||||
|
||||
文件: `crates/erp-health/src/service/daily_monitoring_service.rs`
|
||||
- 改为委托 `health_data_service::create_vital_signs`,设置 `source = "daily_monitoring"`
|
||||
- 标记为 `#[deprecated]`,保留接口兼容
|
||||
|
||||
**DTO 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/dto/health_data_dto.rs`
|
||||
- `CreateVitalSignsReq` 添加 `source: Option<String>`
|
||||
|
||||
**前端改动**:
|
||||
|
||||
文件: `apps/web/src/api/health/healthData.ts`
|
||||
- 无需改动(前端已统一走 vital_signs 接口)
|
||||
|
||||
**验证**:
|
||||
- `cargo test --workspace` 通过
|
||||
- 原有 `daily_monitoring` 数据可在 `vital_signs` 查询中看到
|
||||
- 小程序 `get_mini_today` 返回合并后的数据
|
||||
|
||||
---
|
||||
|
||||
### 1.4 增加实时异常预警
|
||||
|
||||
**问题**: 体征录入时无自动异常检测。血压 180/110 等危急值不会触发报警。
|
||||
|
||||
**策略**: 在 `create_vital_signs` 和 `create_lab_report` 中增加异常检测,发布预警事件。
|
||||
|
||||
**后端改动**:
|
||||
|
||||
文件: `crates/erp-health/src/service/health_data_service.rs`
|
||||
- 新增 `check_vital_signs_alert(patient_id, data, tenant_id, event_bus)` 函数
|
||||
- 危急值阈值:
|
||||
- 收缩压 ≥ 180 或 ≤ 80
|
||||
- 舒张压 ≥ 110 或 ≤ 50
|
||||
- 心率 ≥ 150 或 ≤ 40
|
||||
- 血糖 ≥ 25 或 ≤ 2.5
|
||||
- 检测到危急值时发布 `health_data.critical_alert` 事件
|
||||
- 事件 payload: `{ patient_id, indicator, value, threshold, level: "critical", tenant_id }`
|
||||
- 在 `create_vital_signs` 末尾调用 `check_vital_signs_alert`
|
||||
|
||||
文件: `crates/erp-health/src/event.rs`
|
||||
- 添加 `health_data.critical_alert` 事件常量
|
||||
- 订阅此事件,调用 `erp-message` 发送站内通知给负责医护
|
||||
|
||||
**前端改动** (P1 延后):
|
||||
- 本次仅后端发布事件,前端告警 UI 放入 Phase 2
|
||||
|
||||
**验证**:
|
||||
- 创建收缩压 = 185 的体征记录
|
||||
- 确认 `domain_events` 表中出现 `health_data.critical_alert` 事件
|
||||
|
||||
---
|
||||
|
||||
### 1.5 增加 ICD-10 诊断编码支持
|
||||
|
||||
**问题**: 系统无结构化诊断,随访/趋势分析缺乏医学语义锚点。
|
||||
|
||||
**新建实体**: `diagnosis`
|
||||
|
||||
文件: `crates/erp-health/src/entity/diagnosis.rs`
|
||||
```rust
|
||||
// 关键字段:
|
||||
// id: Uuid (PK)
|
||||
// tenant_id: Uuid
|
||||
// patient_id: Uuid (FK -> patient)
|
||||
// health_record_id: Option<Uuid> (FK -> health_record)
|
||||
// icd_code: String (如 "I10" 高血压、"E11.9" 2型糖尿病)
|
||||
// diagnosis_name: String (中文诊断名)
|
||||
// diagnosis_type: String (primary/secondary/comorbid)
|
||||
// diagnosed_date: Date
|
||||
// status: String (active/resolved/chronic)
|
||||
// diagnosed_by: Option<Uuid> (医生 ID)
|
||||
// notes: Option<String>
|
||||
// + 标准字段 (created_at, updated_at, version, ...)
|
||||
```
|
||||
|
||||
**迁移文件**: `crates/erp-server/migration/src/m20260425_000002_diagnosis.rs`
|
||||
|
||||
**Service 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/service/` 新建 `diagnosis_service.rs`
|
||||
- CRUD: `create_diagnosis`, `list_diagnoses`, `update_diagnosis`, `delete_diagnosis`
|
||||
|
||||
**Handler 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/handler/` 新建 `diagnosis_handler.rs`
|
||||
- 端点:
|
||||
- `POST /api/v1/health/patients/{id}/diagnoses`
|
||||
- `GET /api/v1/health/patients/{id}/diagnoses`
|
||||
- `PUT /api/v1/health/diagnoses/{id}`
|
||||
- `DELETE /api/v1/health/diagnoses/{id}`
|
||||
|
||||
**DTO 改动**:
|
||||
|
||||
文件: `crates/erp-health/src/dto/` 新建 `diagnosis_dto.rs`
|
||||
- `CreateDiagnosisReq`, `UpdateDiagnosisReq`, `DiagnosisResp`
|
||||
|
||||
**注册路由**:
|
||||
|
||||
文件: `crates/erp-health/src/module.rs`
|
||||
- 在 `protected_routes` 中注册诊断端点
|
||||
|
||||
**前端改动** (P1 延后):
|
||||
- 本次仅后端,患者详情页诊断 Tab 放入 Phase 2
|
||||
|
||||
**验证**:
|
||||
- `cargo check` 通过
|
||||
- `POST /patients/{id}/diagnoses` 创建诊断成功
|
||||
- 诊断列表按 `tenant_id` 正确过滤
|
||||
|
||||
---
|
||||
|
||||
### 1.6 实现积分过期清理定时任务
|
||||
|
||||
**问题**: `points_transaction.expires_at` 写入后无定时检查,`total_expired` 永远为 0。
|
||||
|
||||
**后端改动**:
|
||||
|
||||
文件: `crates/erp-health/src/service/points_service.rs`
|
||||
- 新增 `expire_points(state: &HealthState) -> AppResult<u64>` 函数
|
||||
- 查找所有 `expires_at < NOW() AND type = 'earn' AND expires_at IS NOT NULL` 的未处理交易
|
||||
- 计算过期积分总额
|
||||
- 扣减对应积分账户余额 (CAS with version)
|
||||
- 发布 `points.expired` 事件
|
||||
|
||||
文件: `crates/erp-health/src/module.rs`
|
||||
- 在 `on_startup` 中新增定时任务 `start_points_expiration_checker`
|
||||
- 每天凌晨 2:00 执行一次 (或使用 `tokio::time::interval(Duration::from_secs(86400))`)
|
||||
- 类似 `start_overdue_checker` 的实现模式
|
||||
|
||||
**验证**:
|
||||
- 创建 `expires_at = 昨天` 的 earn 交易
|
||||
- 运行过期清理后确认余额已扣减、`total_expired` 已更新
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 执行顺序
|
||||
|
||||
```
|
||||
1.1 Dashboard 统计 ──→ 1.6 积分过期 (独立,可并行)
|
||||
1.2 事件发布 ──→ 1.4 异常预警 (依赖 EventBus 传递)
|
||||
1.3 合并体征表 (独立)
|
||||
1.5 诊断编码 (独立)
|
||||
```
|
||||
|
||||
建议并行组:
|
||||
- 组 A: 1.1 + 1.6 (统计/定时任务,无依赖)
|
||||
- 组 B: 1.2 + 1.4 (事件链路)
|
||||
- 组 C: 1.3 (数据迁移,需谨慎)
|
||||
- 组 D: 1.5 (新实体,无依赖)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 核心业务能力补全 (P1)
|
||||
|
||||
> 预计 5-7 人天 | 影响: 临床实用性不足、患者参与度低、运营效率差
|
||||
|
||||
### 2.1 结构化随访模板系统
|
||||
|
||||
**问题**: `content_template` 是纯文本,`result`/`medical_advice` 也是自由文本,无法做统计分析。
|
||||
|
||||
**新建实体**: `follow_up_template` + `follow_up_template_field`
|
||||
|
||||
```
|
||||
follow_up_template:
|
||||
id, tenant_id, name, description, disease_type (关联 ICD),
|
||||
target_audience, frequency_days, field_count,
|
||||
+ 标准字段
|
||||
|
||||
follow_up_template_field:
|
||||
id, tenant_id, template_id, field_key, field_label,
|
||||
field_type (text/number/select/multiselect/scale),
|
||||
required, sort_order, options (JSONB, 用于 select 类型的选项),
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**Service**: 新建 `follow_up_template_service.rs`
|
||||
- `create_template`, `list_templates`, `get_template`, `update_template`, `delete_template`
|
||||
|
||||
**前端**: 新建 `FollowUpTemplateList.tsx` 页面
|
||||
- 模板 CRUD + 字段拖拽排序 + 预览
|
||||
|
||||
**关联改动**:
|
||||
- `follow_up_task` 添加 `template_id: Option<Uuid>` 字段
|
||||
- `follow_up_record` 添加 `structured_data: Option<Json>` 字段(JSONB 存储表单填写结果)
|
||||
|
||||
---
|
||||
|
||||
### 2.2 用药记录实体
|
||||
|
||||
**问题**: 小程序有 `profile/medication` 页面但后端无对应实体。
|
||||
|
||||
**新建实体**: `medication_record`
|
||||
|
||||
```
|
||||
medication_record:
|
||||
id, tenant_id, patient_id,
|
||||
medication_name, generic_name, dosage, unit,
|
||||
frequency (daily/bid/tid/qid/prn),
|
||||
route (oral/injection/topical/inhalation),
|
||||
start_date, end_date, is_current,
|
||||
prescribed_by (doctor_id), notes,
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**迁移**: `m20260425_000003_medication_record.rs`
|
||||
|
||||
**端点**:
|
||||
- `POST/GET /api/v1/health/patients/{id}/medications`
|
||||
- `PUT/DELETE /api/v1/health/medications/{id}`
|
||||
|
||||
**小程序改动**:
|
||||
- 对接 `profile/medication` 页面到新 API
|
||||
|
||||
---
|
||||
|
||||
### 2.3 透析方案管理
|
||||
|
||||
**问题**: 透析无方案管理,每次需重新输入相同参数。
|
||||
|
||||
**新建实体**: `dialysis_prescription`
|
||||
|
||||
```
|
||||
dialysis_prescription:
|
||||
id, tenant_id, patient_id,
|
||||
dialyzer_model, membrane_area,
|
||||
dialysate_potassium, dialysate_calcium, dialysate_bicarbonate,
|
||||
anticoagulation_type (heparin/lmwh/heparin_free),
|
||||
anticoagulation_dose,
|
||||
target_ultrafiltration_ml, target_dry_weight,
|
||||
blood_flow_rate, dialysate_flow_rate,
|
||||
frequency_per_week, duration_minutes,
|
||||
vascular_access_type (avf/avg/cvc),
|
||||
vascular_access_location,
|
||||
effective_from, effective_to, status (active/discontinued),
|
||||
prescribed_by, notes,
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**Service**: 新建 `dialysis_prescription_service.rs`
|
||||
|
||||
**端点**:
|
||||
- `POST/GET /api/v1/health/patients/{id}/dialysis-prescriptions`
|
||||
- `PUT/DELETE /api/v1/health/dialysis-prescriptions/{id}`
|
||||
- `GET /api/v1/health/patients/{id}/dialysis-prescriptions/current` (获取当前有效方案)
|
||||
|
||||
**关联改动**:
|
||||
- `dialysis_record` 添加 `prescription_id: Option<Uuid>` 字段
|
||||
- 创建透析记录时可选继承方案参数
|
||||
|
||||
---
|
||||
|
||||
### 2.4 体征增加体温/SpO2/血糖类型
|
||||
|
||||
**问题**: `vital_signs` 缺体温和血氧,血糖无类型标记。
|
||||
|
||||
**迁移**: `m20260425_000004_vital_signs_fields.rs`
|
||||
|
||||
```sql
|
||||
ALTER TABLE vital_signs ADD COLUMN IF NOT EXISTS body_temperature DECIMAL(4,1);
|
||||
ALTER TABLE vital_signs ADD COLUMN IF NOT EXISTS spo2 INTEGER;
|
||||
ALTER TABLE vital_signs ADD COLUMN IF NOT EXISTS blood_sugar_type VARCHAR(20) DEFAULT 'fasting';
|
||||
-- blood_sugar_type: fasting / postprandial / random / ogtt
|
||||
```
|
||||
|
||||
**Entity/DTO 改动**:
|
||||
- `vital_signs.rs`: 添加 `body_temperature`, `spo2`, `blood_sugar_type` 字段
|
||||
- `CreateVitalSignsReq`: 添加对应字段
|
||||
|
||||
**趋势分析改动**:
|
||||
- `trend_service.rs`: `generate_trend` 增加体温/SpO2 异常检测
|
||||
- 体温: < 35.0 或 > 38.5
|
||||
- SpO2: < 90%
|
||||
- 血糖: 根据类型使用不同阈值
|
||||
|
||||
**前端改动**:
|
||||
- `VitalSignsTab.tsx` 和小程序体征录入页添加新字段
|
||||
|
||||
---
|
||||
|
||||
### 2.5 消息推送集成
|
||||
|
||||
**问题**: `erp-message` 存在但 `erp-health` 不利用。
|
||||
|
||||
**策略**: 在关键业务节点发布事件,`erp-message` 订阅后发送站内通知。
|
||||
|
||||
**触发场景**:
|
||||
|
||||
| 事件 | 触发条件 | 通知对象 |
|
||||
|------|---------|---------|
|
||||
| `follow_up.due_reminder` | 随访任务到期前 1 天 | assigned_to 医护 |
|
||||
| `appointment.reminder` | 预约前 1 天 | 患者小程序 |
|
||||
| `health_data.critical_alert` | 危急值 | 负责医生 |
|
||||
| `points.expiring_soon` | 积分 7 天内过期 | 患者小程序 |
|
||||
| `lab_report.reviewed` | 化验报告审阅完成 | 患者小程序 |
|
||||
|
||||
**改动**:
|
||||
|
||||
文件: `crates/erp-health/src/module.rs`
|
||||
- 新增定时任务 `start_due_reminder_checker` (每天 8:00 执行)
|
||||
- 查询明天到期的随访任务,发布 `follow_up.due_reminder` 事件
|
||||
|
||||
文件: `crates/erp-health/src/event.rs`
|
||||
- 添加所有新事件常量和 payload 定义
|
||||
|
||||
**注意**: `erp-message` 侧的事件订阅和通知模板创建需同步进行。
|
||||
|
||||
---
|
||||
|
||||
### 2.6 批量随访操作
|
||||
|
||||
**问题**: 不支持批量创建/分配/完成,护士每天 30-50 条逐个操作效率极低。
|
||||
|
||||
**新增端点**:
|
||||
|
||||
```
|
||||
POST /api/v1/health/follow-up-tasks/batch
|
||||
Body: { patient_ids: [uuid], template_id?, assigned_to, planned_date, follow_up_type }
|
||||
→ 为多个患者批量创建随访任务
|
||||
|
||||
PUT /api/v1/health/follow-up-tasks/batch-assign
|
||||
Body: { task_ids: [uuid], assigned_to }
|
||||
→ 批量分配负责人
|
||||
|
||||
PUT /api/v1/health/follow-up-tasks/batch-complete
|
||||
Body: { task_ids: [uuid], result, patient_condition }
|
||||
→ 批量标记完成
|
||||
```
|
||||
|
||||
**Service 改动**:
|
||||
- `follow_up_service.rs` 新增 `batch_create_tasks`, `batch_assign`, `batch_complete`
|
||||
- 使用事务包裹批量操作
|
||||
|
||||
**前端改动**:
|
||||
- `FollowUpTaskList.tsx` 添加多选 + 批量操作工具栏
|
||||
|
||||
---
|
||||
|
||||
### 2.7 修复随访类型前后端不一致
|
||||
|
||||
**问题**: 后端 `phone`/`face_to_face`/`online` vs 前端 `phone`/`outpatient`/`home_visit`/`wechat`。
|
||||
|
||||
**策略**: 统一为 5 种类型:
|
||||
|
||||
```rust
|
||||
// validation.rs
|
||||
pub fn validate_follow_up_type(t: &str) -> bool {
|
||||
matches!(t, "phone" | "outpatient" | "home_visit" | "online" | "wechat")
|
||||
}
|
||||
```
|
||||
|
||||
**改动**:
|
||||
- `crates/erp-health/src/service/validation.rs`: 更新验证函数
|
||||
- `apps/web/src/pages/health/FollowUpTaskList.tsx`: 更新类型选项
|
||||
- 数据迁移: 将 `face_to_face` 更新为 `outpatient`
|
||||
|
||||
---
|
||||
|
||||
### 2.8 咨询 WebSocket 实时推送 (高复杂度)
|
||||
|
||||
**问题**: 咨询消息只有 HTTP API,无实时推送。
|
||||
|
||||
**策略**: 使用 Axum WebSocket 升级。
|
||||
|
||||
**后端改动**:
|
||||
|
||||
文件: `crates/erp-health/src/handler/consultation_handler.rs`
|
||||
- 新增 `ws_handler` 端点
|
||||
- `GET /api/v1/health/consultation/ws` → WebSocket 升级
|
||||
- 连接时验证 JWT,订阅 `consultation.{session_id}` channel
|
||||
|
||||
文件: `crates/erp-health/src/service/consultation_service.rs`
|
||||
- `create_message` 时向 channel 广播消息
|
||||
- 使用 `tokio::sync::broadcast` channel
|
||||
|
||||
**前端改动**:
|
||||
- 新建 `useConsultationWebSocket` hook
|
||||
- `ConsultationDetail.tsx` 集成 WebSocket
|
||||
|
||||
**注意**: 此项复杂度高,可拆分为独立迭代。
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 执行顺序
|
||||
|
||||
```
|
||||
2.7 随访类型修复 (快速修复,优先)
|
||||
↓
|
||||
2.4 体征字段扩展 (低复杂度)
|
||||
↓
|
||||
2.2 用药记录 + 2.3 透析方案 (新实体,可并行)
|
||||
↓
|
||||
2.6 批量随访 (依赖类型修复完成)
|
||||
↓
|
||||
2.1 随访模板 (依赖用药记录和类型修复)
|
||||
↓
|
||||
2.5 消息推送 (依赖 Phase 1 事件发布)
|
||||
↓
|
||||
2.8 WebSocket (独立迭代,最高复杂度)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 运营增强 (P2)
|
||||
|
||||
> 预计 5-7 人天 | 影响: 差异化竞争力、患者留存、商业变现
|
||||
|
||||
### 3.1 患者健康评分体系 (Health Score)
|
||||
|
||||
**新建实体**: `health_score`
|
||||
|
||||
```
|
||||
health_score:
|
||||
id, tenant_id, patient_id,
|
||||
total_score (0-100),
|
||||
dimensions: JSONB {
|
||||
vital_signs: 0-25, // 体征数据完整度 + 达标率
|
||||
follow_up: 0-25, // 随访依从性
|
||||
checkup: 0-25, // 体检按时完成率
|
||||
engagement: 0-25 // 平台活跃度(签到/咨询/数据上报)
|
||||
},
|
||||
computed_at, next_compute_at,
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**计算逻辑**: 定时任务每周重算,基于最近 90 天数据:
|
||||
- 体征: `按时录入天数 / 90 * 25`,达标率加权
|
||||
- 随访: `已完成随访 / 应完成随访 * 25`
|
||||
- 体检: `年度体检是否完成 * 25`
|
||||
- 活跃度: `(签到天数 + 数据上报次数 + 咨询次数) / 目标值 * 25`
|
||||
|
||||
**端点**: `GET /api/v1/health/patients/{id}/health-score`
|
||||
|
||||
**前端**: 患者详情页新增 Health Score 卡片 + 趋势图
|
||||
|
||||
---
|
||||
|
||||
### 3.2 会员等级和营销工具
|
||||
|
||||
**新建实体**: `membership_level` + `coupon`
|
||||
|
||||
```
|
||||
membership_level:
|
||||
id, tenant_id, name, level (1-5),
|
||||
min_score, max_score,
|
||||
benefits: JSONB { discount_rate, priority_booking, exclusive_products },
|
||||
+ 标准字段
|
||||
|
||||
coupon:
|
||||
id, tenant_id, code, type (percentage/fixed/free_shipping),
|
||||
value, min_order_amount,
|
||||
valid_from, valid_to, max_uses, used_count,
|
||||
applicable_products: Option<Json>, scope (all/specific),
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**Service**: 新建 `membership_service.rs` + `coupon_service.rs`
|
||||
|
||||
**端点**:
|
||||
- `GET /api/v1/health/patients/{id}/membership`
|
||||
- `POST/GET /api/v1/health/admin/coupons`
|
||||
- `POST /api/v1/health/coupons/{code}/redeem`
|
||||
|
||||
---
|
||||
|
||||
### 3.3 预约资源绑定
|
||||
|
||||
**新建实体**: `resource` + `resource_schedule`
|
||||
|
||||
```
|
||||
resource:
|
||||
id, tenant_id, name, type (dialysis_machine/exam_room/bed),
|
||||
location, capacity, status (available/maintenance/disabled),
|
||||
+ 标准字段
|
||||
|
||||
resource_schedule:
|
||||
id, tenant_id, resource_id, schedule_date,
|
||||
period_type (am/pm/full_day), max_appointments,
|
||||
current_appointments,
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**关联改动**:
|
||||
- `appointment` 添加 `resource_id: Option<Uuid>`
|
||||
- 预约创建时 CAS 检查资源可用性(类似医生排班的 CAS 逻辑)
|
||||
|
||||
---
|
||||
|
||||
### 3.4 个性化异常阈值配置
|
||||
|
||||
**新建实体**: `patient_threshold_config`
|
||||
|
||||
```
|
||||
patient_threshold_config:
|
||||
id, tenant_id, patient_id,
|
||||
indicator (heart_rate/blood_sugar/systolic_bp/...),
|
||||
low_threshold, high_threshold,
|
||||
alert_level (warning/critical),
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**改动**:
|
||||
- `trend_service.rs`: `compute_status` 优先查 `patient_threshold_config`,无则用默认阈值
|
||||
- `health_data_service.rs`: 危急值检测同理
|
||||
|
||||
---
|
||||
|
||||
### 3.5 化验指标标准化字典 (LOINC)
|
||||
|
||||
**新建实体**: `lab_indicator_dict`
|
||||
|
||||
```
|
||||
lab_indicator_dict:
|
||||
id, tenant_id,
|
||||
loinc_code: Option<String>,
|
||||
name_cn, name_en,
|
||||
category (blood/urine/biochemistry/...),
|
||||
default_unit, reference_low, reference_high,
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**改动**:
|
||||
- `lab_report` items 中的 `name` 关联 `lab_indicator_dict.name_cn`
|
||||
- 趋势分析按 `loinc_code` 聚合,解决"肌酐"/"CREA"不一致问题
|
||||
|
||||
---
|
||||
|
||||
### 3.6 批量排班/排班模板
|
||||
|
||||
**新建实体**: `schedule_template`
|
||||
|
||||
```
|
||||
schedule_template:
|
||||
id, tenant_id, doctor_id, name,
|
||||
periods: JSONB [{ day_of_week, period_type, max_appointments }],
|
||||
effective_from, effective_to,
|
||||
+ 标准字段
|
||||
```
|
||||
|
||||
**端点**:
|
||||
- `POST /api/v1/health/schedule-templates`
|
||||
- `POST /api/v1/health/schedule-templates/{id}/generate`
|
||||
→ 按模板批量生成指定日期范围的 `doctor_schedule` 记录
|
||||
|
||||
---
|
||||
|
||||
### 3.7 小程序分析埋点后端
|
||||
|
||||
**问题**: `apps/miniprogram/src/services/analytics.ts` 的 `flushEvents` 发到 `/analytics/batch`,后端未实现。
|
||||
|
||||
**新建**: `crates/erp-health/src/handler/analytics_handler.rs`
|
||||
|
||||
```rust
|
||||
POST /api/v1/health/analytics/batch
|
||||
Body: { events: [{ event_type, page, timestamp, properties }] }
|
||||
→ 写入 analytics_events 表
|
||||
```
|
||||
|
||||
**新建实体**: `analytics_event` (轻量表,仅用于行为分析)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 执行顺序
|
||||
|
||||
```
|
||||
3.7 分析埋点后端 (独立,快速)
|
||||
↓
|
||||
3.5 化验指标字典 (Phase 1 趋势分析的增强)
|
||||
↓
|
||||
3.4 个性化阈值 + 3.1 Health Score (可并行)
|
||||
↓
|
||||
3.3 预约资源绑定 (依赖 Phase 1 预约逻辑)
|
||||
↓
|
||||
3.6 批量排班 (依赖 3.3 资源模型)
|
||||
↓
|
||||
3.2 会员等级 (依赖 3.1 Health Score)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 长期竞争力 (P3, 路线图)
|
||||
|
||||
> Q3-Q4 路线图 | 影响: 市场准入、技术领先
|
||||
|
||||
### 4.1 血管通路管理
|
||||
|
||||
新建 `vascular_access` 实体,管理透析患者通路的完整生命周期:
|
||||
通路类型 (AVF/AVG/CVC)、位置、建立日期、定期评估 (血流量/静脉压/再循环率)、并发症记录 (狭窄/血栓/感染)、介入/手术史。
|
||||
|
||||
依赖: Phase 2 透析方案管理
|
||||
|
||||
### 4.2 疾病风险评分模型
|
||||
|
||||
实现临床风险评分:
|
||||
- 心血管: Framingham / ASCVD 10 年风险
|
||||
- 肾病: KDOQI 分期 (基于 eGFR 计算)
|
||||
- 营养: MNA-SF (迷你营养评估)
|
||||
- 糖尿病: 糖尿病风险评分
|
||||
|
||||
定时任务定期重算,风险变化时触发预警。
|
||||
|
||||
依赖: Phase 1 ICD-10 + Phase 3 个性化阈值
|
||||
|
||||
### 4.3 AI 辅助诊断 (erp-ai 集成)
|
||||
|
||||
将 `erp-ai` 模块的 SSE 流式分析能力集成到健康模块:
|
||||
- 化验报告智能解读 (异常指标说明 + 建议)
|
||||
- 趋势分析自然语言描述
|
||||
- 随访记录摘要生成
|
||||
- 健康风险评估建议
|
||||
|
||||
依赖: erp-ai 模块完成 MVP
|
||||
|
||||
### 4.4 可配置表单能力
|
||||
|
||||
通过 JSON Schema 定义自定义采集表单:
|
||||
- 新建 `form_schema` 实体 (名称 + JSON Schema 定义)
|
||||
- 新建 `form_submission` 实体 (关联 patient + schema + JSONB 数据)
|
||||
- 前端: 动态表单渲染引擎
|
||||
- 适用: 不同机构自定义体检表、评估量表、随访表单
|
||||
|
||||
### 4.5 影像管理集成 (DICOM/PACS)
|
||||
|
||||
设计 DICOM proxy 或 WADO-RS 集成:
|
||||
- DICOM 文件上传和元数据提取
|
||||
- 影像预览 (通过 Cornerstone.js 或 OHIF Viewer)
|
||||
- 与 `lab_report` / `health_record` 关联
|
||||
|
||||
### 4.6 合规认证 (等保三级)
|
||||
|
||||
制定等保三级认证路线图:
|
||||
- 安全审计日志完善 (全操作覆盖)
|
||||
- 数据备份与灾难恢复方案
|
||||
- 访问控制增强 (强密码策略、MFA)
|
||||
- 网络安全 (WAF、入侵检测)
|
||||
|
||||
### 4.7 HL7 FHIR R4 数据互操作
|
||||
|
||||
设计 FHIR R4 接口层:
|
||||
- Patient → FHIR Patient
|
||||
- Diagnosis → FHIR Condition
|
||||
- VitalSigns → FHIR Observation
|
||||
- MedicationRecord → FHIR MedicationStatement
|
||||
- LabReport → FHIR DiagnosticReport
|
||||
|
||||
支持与 HIS/LIS/EMR 系统的数据交换。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
### 新增实体汇总
|
||||
|
||||
| Phase | 新增实体 |
|
||||
|-------|---------|
|
||||
| Phase 1 | `diagnosis` |
|
||||
| Phase 2 | `follow_up_template`, `follow_up_template_field`, `medication_record`, `dialysis_prescription` |
|
||||
| Phase 3 | `health_score`, `membership_level`, `coupon`, `resource`, `resource_schedule`, `patient_threshold_config`, `lab_indicator_dict`, `schedule_template`, `analytics_event` |
|
||||
| Phase 4 | `vascular_access`, `form_schema`, `form_submission` |
|
||||
|
||||
### 新增迁移文件汇总
|
||||
|
||||
| 迁移文件 | Phase |
|
||||
|---------|-------|
|
||||
| `m20260425_000001_merge_vital_signs.rs` | P1 |
|
||||
| `m20260425_000002_diagnosis.rs` | P1 |
|
||||
| `m20260425_000003_medication_record.rs` | P2 |
|
||||
| `m20260425_000004_vital_signs_fields.rs` | P2 |
|
||||
| `m20260425_000005_dialysis_prescription.rs` | P2 |
|
||||
| `m20260425_000006_follow_up_template.rs` | P2 |
|
||||
| `m20260425_000007_follow_up_template_field.rs` | P2 |
|
||||
| `m20260425_000008_follow_up_enhancements.rs` | P2 (task.template_id, record.structured_data) |
|
||||
|
||||
### 工作量估算
|
||||
|
||||
| Phase | 人天 | 新增实体 | 新增迁移 | 优先级 |
|
||||
|-------|------|---------|---------|--------|
|
||||
| Phase 1: P0 可信度修复 | 2-3 | 1 | 2 | 立即 |
|
||||
| Phase 2: P1 核心能力 | 5-7 | 4 | 5 | 本迭代 |
|
||||
| Phase 3: P2 运营增强 | 5-7 | 9 | 9 | 下迭代 |
|
||||
| Phase 4: P3 长期竞争力 | 路线图 | 3+ | 3+ | Q3-Q4 |
|
||||
| **合计** | **12-17 + 路线图** | **17+** | **19+** | |
|
||||
@@ -0,0 +1,450 @@
|
||||
# HMS 健康管理模块业务流程合理性分析
|
||||
|
||||
> 日期: 2026-04-25
|
||||
> 状态: Draft
|
||||
> 分析方法: 三专家组并行深度审查(临床业务 + 运营管理 + 产品架构)
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
HMS 健康管理模块已完成初始实现,包含 27 个数据库实体、14 个权限描述符(7 组 .list/.manage)、16 个 Web 页面、21 个小程序页面。本次分析从**临床业务流程、运营管理有效性、产品架构竞争力**三个维度进行深度审查。
|
||||
|
||||
### 核心结论
|
||||
|
||||
**工程基础设施达到生产级水准。** 多租户隔离、CAS 并发控制、乐观锁、事件驱动架构、数据加密(AES-256 + HMAC)、审计日志等能力一应俱全。
|
||||
|
||||
**业务深度存在三个核心缺口:**
|
||||
|
||||
| 缺口 | 影响 | 紧急度 |
|
||||
|------|------|--------|
|
||||
| 诊断编码缺失 | 随访/趋势分析/统计报表缺乏医学语义锚点,与 HIS/LIS 无法对接 | P0 |
|
||||
| 统计数据造假 | Dashboard 硬编码 0 值,管理者无法做运营决策 | P0 |
|
||||
| 消息推送未集成 | 设计规格定义 11 种事件,代码发布 9 种(缺失 `follow_up.overdue` 等),患者触达手段严重不足 | P0 |
|
||||
|
||||
### 发现总计
|
||||
|
||||
- **P0 关键问题**: 6 个(产品可信度 + 患者安全)
|
||||
- **P1 核心缺失**: 8 个(业务能力补全)
|
||||
- **P2 运营增强**: 7 个(差异化竞争力)
|
||||
- **P3 长期规划**: 7 个(市场准入 + 技术领先)
|
||||
|
||||
### 差异化竞争优势
|
||||
|
||||
1. **Rust 技术性能壁垒** — 高并发预约场景显著优势,单体二进制部署比竞品微服务架构简单一个数量级
|
||||
2. **血透专科深度** — 竞品中少有的专科化设计,补全方案/通路/充分性后更具竞争力
|
||||
3. **患者运营闭环** — 积分商城+签到+活动+资讯,医疗 SaaS 中罕见的互联网运营思维
|
||||
4. **多租户原生设计** — 从第一天内置隔离,竞品多为后期改造
|
||||
|
||||
---
|
||||
|
||||
## 1. 临床业务流程评估
|
||||
|
||||
> 专家角色: 资深临床医疗信息化专家(15 年医院信息系统设计经验)
|
||||
|
||||
### 1.1 患者全生命周期
|
||||
|
||||
**评分: 6/10 — "两头有、中间空"**
|
||||
|
||||
已覆盖的环节:
|
||||
- 建档阶段扎实:加密存储、标签分类、实名认证状态流转、家庭关系、医患关联
|
||||
- 健康摘要 API (`get_health_summary`) 聚合最新体征/化验/预约/随访,提供一站式概览
|
||||
|
||||
缺失的关键环节:
|
||||
|
||||
| 环节 | 说明 | 影响 |
|
||||
|------|------|------|
|
||||
| 分诊 (Triage) | 无主诉、分诊科室、紧急程度的记录 | 预约直接绑定医生,跳过分诊 |
|
||||
| 诊疗记录 (EMR) | `health_record` 仅含 `overall_assessment` + 文件 URL | 缺主诉、现病史、体格检查、诊断、处方 |
|
||||
| 诊断编码 (ICD) | 无 ICD-10/ICD-11 支持 | 随访/趋势分析缺乏医学语义锚点 |
|
||||
| 转诊流程 | 无科室间/院间转诊记录和追踪 | 多学科协作无法支撑 |
|
||||
| 入组/出组管理 | 只有标签这一非结构化方式 | 健康管理项目无法规范化 |
|
||||
|
||||
### 1.2 医疗数据管理
|
||||
|
||||
**评分: 5/10 — 基本可用但临床精细度不足**
|
||||
|
||||
优点:
|
||||
- 体征数据晨/晚血压区分符合慢病管理实践
|
||||
- 化验报告 V2 JSON 结构 `[{name, value, unit, reference_low, reference_high, is_abnormal}]` 灵活实用
|
||||
- 透析记录覆盖干体重、超滤量、血流量、透析类型 (HD/HDF/HF)
|
||||
|
||||
关键缺失:
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| `vital_signs` 与 `daily_monitoring` 字段 91% 重叠 | 数据冗余 + 命名不一致(`systolic_bp_morning` vs `morning_bp_systolic`),趋势分析只查 `vital_signs` 表,`daily_monitoring` 数据完全被忽略 |
|
||||
| 缺乏采集时间精度 | 只有 `record_date`,血压需精确到分钟,血糖需标注空腹/餐后 |
|
||||
| 缺乏体温和 SpO2 | 透析感染监测和呼吸系统疾病管理的必备指标 |
|
||||
| 血糖无类型标记 | 空腹/餐后/随机/OGTT 混在一起,参考范围完全不同 |
|
||||
| 无用药记录 | 小程序有 `profile/medication` 页面但后端无对应实体 |
|
||||
| 化验指标未标准化 | "肌酐"/"CREA"/"Creatinine" 多种写法影响趋势分析 |
|
||||
| 出入量记录过于简单 | 仅有饮水量和尿量,临床需区分口服/静脉/引流/失血 |
|
||||
|
||||
### 1.3 预约排班
|
||||
|
||||
**评分: 7/10 — 核心流程优秀,运营辅助不足**
|
||||
|
||||
优点:
|
||||
- CAS 原子操作防超额预约,取消时自动释放名额,事务保证一致性
|
||||
- 状态机完整: pending → confirmed → completed/no_show/cancelled
|
||||
- 5 种预约类型覆盖主要场景(透析/复诊/门诊/体检/咨询)
|
||||
|
||||
缺失:
|
||||
- 无资源维度绑定(透析机位、检查室)
|
||||
- 无批量排班/排班模板(透析中心需周期性排班)
|
||||
- 无候补机制(号源满后直接拒绝)
|
||||
- no_show 无自动触发(定时任务缺失)
|
||||
- 排班不区分节假日
|
||||
|
||||
### 1.4 随访管理
|
||||
|
||||
**评分: 6/10 — 基础流程完整,临床实用性不足**
|
||||
|
||||
优点:
|
||||
- 逾期自动检查 (`check_overdue_tasks`) + 自动创建后续任务(`next_follow_up_date` 机制)
|
||||
- 乐观锁保护所有更新操作
|
||||
|
||||
缺失:
|
||||
- 无结构化随访模板(`content_template` 是纯文本)
|
||||
- 随访结果无结构化(`result`/`patient_condition`/`medical_advice` 均为自由文本)
|
||||
- 无随访到期提醒通知
|
||||
- 无随访方案模板(一次性生成 1/3/6/12 月任务组)
|
||||
- 无优先级字段
|
||||
- **前后端类型不一致**: 后端 `phone`/`face_to_face`/`online`,前端 `phone`/`outpatient`/`home_visit`/`wechat`
|
||||
|
||||
### 1.5 透析管理
|
||||
|
||||
**评分: 4/10 — "透析记录本"而非"透析管理系统"**
|
||||
|
||||
已实现: 透析记录覆盖核心物理参数(体重变化/血压/超滤/血流量),审阅流程 (draft → reviewed)。
|
||||
|
||||
三大核心支柱缺失:
|
||||
|
||||
| 支柱 | 说明 | 临床影响 |
|
||||
|------|------|---------|
|
||||
| 透析方案 (Prescription) | 无透析器型号、透析液配方、抗凝方案、目标超滤、血管通路类型 | 每次透析需重新输入相同参数 |
|
||||
| 血管通路管理 | 无通路类型/位置/评估/并发症/手术史 | 通路是透析患者的"生命线" |
|
||||
| 充分性评估 | 无 Kt/V 和 URR 计算 | 无法评估透析质量 |
|
||||
|
||||
其他缺失: 透析中动态监测、透析药物管理 (EPO/铁剂/磷结合剂)、症状/并发症未结构化。此外,entity 注释中定义了 `completed` 状态但无代码路径可达,存在"幽灵状态"问题。
|
||||
|
||||
### 1.6 咨询管理
|
||||
|
||||
**评分: 5/10 — 适合"留言板",无法支撑"在线问诊"**
|
||||
|
||||
优点: 消息模型合理(text/image/voice/file 四种类型、CAS 未读计数、会话状态机 waiting → active → closed)。
|
||||
|
||||
缺失:
|
||||
- **无实时推送**: 只有 HTTP API,无 WebSocket/SSE
|
||||
- 消息已读标记 API 缺失
|
||||
- 无会话超时自动关闭
|
||||
- 无满意度评价
|
||||
- 无智能分配机制(轮转/科室匹配)
|
||||
- 无关联健康数据展示
|
||||
|
||||
### 1.7 临床决策支持
|
||||
|
||||
**评分: 3/10 — "概念验证"阶段**
|
||||
|
||||
已实现: 趋势分析框架、异常值基本检测(心率 60-100、血糖 3.9-11.1、血压 90-140/60-90)、化验 `is_abnormal` 标记。
|
||||
|
||||
关键问题:
|
||||
- **阈值硬编码** — 不考虑患者个体差异(老年/糖尿病/透析/儿童不同目标)
|
||||
- **血糖阈值不一致** — 趋势分析用 3.9-11.1,小程序摘要用 3.9-6.1
|
||||
- **无实时预警** — 趋势分析是手动触发,血压 180/110 不会自动报警
|
||||
- **不整合化验数据** — 肌酐/eGFR/血红蛋白/电解质趋势同样重要
|
||||
- **无风险评分** — Framingham/KDOQI/MNA 等临床评分模型完全缺失
|
||||
|
||||
---
|
||||
|
||||
## 2. 运营管理评估
|
||||
|
||||
> 专家角色: 资深健康管理运营专家(10+ 年体检中心/健康管理机构运营经验)
|
||||
|
||||
### 2.1 积分激励体系
|
||||
|
||||
**评分: 6/10 — 架构优秀,激励有效性不足**
|
||||
|
||||
优点:
|
||||
- 事件驱动积分发放,`daily_cap` 每日上限,FIFO 消费模型,12 个月过期
|
||||
- 连续签到阶梯奖励 (7/14/30 天)
|
||||
- 全链路 CAS 并发安全
|
||||
|
||||
关键问题:
|
||||
|
||||
| 问题 | 影响 |
|
||||
|------|------|
|
||||
| 事件类型有限 | 只有 6 种行为获积分,缺少 `vital_signs_input`/`annual_checkup`/`followup_adherence` 等真正驱动健康行为的激励点 |
|
||||
| 积分通胀无控制 | 无发放/消费比率监控、无月度预算上限 |
|
||||
| 积分过期未清理 | `expires_at` 字段写入后无定时检查,`total_expired` 永远为 0 |
|
||||
| 订单过期未取消 | 兑换订单 30 天过期但无定时任务退还积分 |
|
||||
| 无过期提醒 | 患者无法得知积分即将过期 |
|
||||
| 无补签机制 | 中断一天即重置,缺少积分兑换补签卡 |
|
||||
|
||||
### 2.2 患者参与度
|
||||
|
||||
**评分: 4/10 — 触达手段严重不足**
|
||||
|
||||
已有渠道: 每日打卡、积分商城、线下活动、咨询管理、随访任务、体征上报。
|
||||
|
||||
缺失的关键互动:
|
||||
|
||||
| 互动方式 | 状态 | 影响 |
|
||||
|----------|------|------|
|
||||
| 消息推送 (Push) | 未集成 erp-message | 随访/积分/报告/预约提醒全部缺失 |
|
||||
| 健康目标与挑战 | 无 | 无法设定"30 天降压"目标,无社区排行 |
|
||||
| 成就体系 (Badge) | 无 | 无"连续打卡 30 天"等徽章激励 |
|
||||
| 社交互动 | 无 | 家庭成员表仅记录信息,无家庭健康管理联动 |
|
||||
| 内容运营 | 基础 | 文章有 CRUD 但无推荐/阅读积分/分享积分 |
|
||||
|
||||
### 2.3 随访管理运营
|
||||
|
||||
**评分: 5/10 — 单条操作效率低**
|
||||
|
||||
**核心问题: 不支持批量操作。** 实际体检中心一个护士每天要处理 30-50 个随访任务,逐个操作效率极低。
|
||||
|
||||
缺失:
|
||||
- 批量创建(按患者标签/疾病类型)
|
||||
- 批量分配负责人
|
||||
- 批量标记完成
|
||||
- 随访工作量统计(按护士维度的完成率/响应时间)
|
||||
- 随访计划模板(按疾病类型的标准化方案)
|
||||
|
||||
### 2.4 健康干预闭环
|
||||
|
||||
**评分: 4/10 — 闭环完整度约 40%**
|
||||
|
||||
```
|
||||
数据采集 → 异常识别 → 干预建议 → 执行跟踪 → 效果评估
|
||||
80% 20% 0% 30% 0%
|
||||
```
|
||||
|
||||
- 数据采集基本完整(体征/化验/透析)
|
||||
- 异常识别仅有简单阈值判断,无实时预警
|
||||
- 干预建议完全缺失(`medical_advice` 是自由文本,无标准化方案库)
|
||||
- 执行跟踪仅有随访链式创建
|
||||
- 效果评估完全缺失(无 Health Score、无干预前后对比)
|
||||
|
||||
### 2.5 数据分析与报表
|
||||
|
||||
**评分: 2/10 — 不可用于运营决策**
|
||||
|
||||
**严重问题: Dashboard 数据大部分是假的。**
|
||||
|
||||
| 指标 | 实现方式 | 可信度 |
|
||||
|------|---------|--------|
|
||||
| 患者总数 | `list?page_size=1` 取 total | 部分 |
|
||||
| 本月新增 | 硬编码 `0` | 不可信 |
|
||||
| 本周新增 | 硬编码 `0` | 不可信 |
|
||||
| 本月活跃 | 硬编码 `0` | 不可信 |
|
||||
| 随访完成率 | 硬编码 `0` | 不可信 |
|
||||
| 咨询总量 | `list?page_size=1` 取 total | 部分 |
|
||||
| 积分统计 | SQL 聚合 | 可信 |
|
||||
| 积分排行 | SQL 排序 | 可信 |
|
||||
|
||||
缺失的关键运营指标: 患者覆盖率、随访完成率趋势、积分使用率、活动参与率、患者留存率、医生工作量分布。无时间维度分析,无数据导出能力。
|
||||
|
||||
### 2.6 多角色协作
|
||||
|
||||
**评分: 5/10 — 职责边界模糊**
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| 护士/健康管理师无独立 profile | 随访 `assigned_to` 指向医生选择器,但护士才是随访主力 |
|
||||
| 职责边界不清 | `patient_doctor_relation` 只有 `primary`/`consulting`,无"健康管理师"角色 |
|
||||
| 前台核销不流畅 | 需手动输入 UUID,无扫码枪集成 |
|
||||
| 无转诊协作机制 | 无医生间转诊流程,无 MDT 任务分配 |
|
||||
| 无智能工作负载分配 | 随访任务完全手动分配 |
|
||||
|
||||
### 2.7 商业变现能力
|
||||
|
||||
**评分: 4/10 — 基础模型在,变现工具缺失**
|
||||
|
||||
积分商城当前只有获取+兑换的基础闭环。缺失:
|
||||
- 积分充值通道(现金购买)
|
||||
- 会员等级体系 (VIP/SVIP)
|
||||
- 营销工具(优惠券/限时活动/邀请有礼/满减)
|
||||
- 供应商管理(商品采购成本/物流)
|
||||
- 数据分析(商品热度/用户分层 RFM)
|
||||
|
||||
---
|
||||
|
||||
## 3. 产品架构评估
|
||||
|
||||
> 专家角色: 资深医疗 SaaS 产品架构师(10+ 年医疗信息化产品设计经验)
|
||||
|
||||
### 3.1 模块边界与耦合
|
||||
|
||||
**评分: 8/10 — 边界清晰,事件契约有进步空间**
|
||||
|
||||
优点:
|
||||
- 对 erp-core 仅依赖共享类型(AppError/EventBus/PaginatedResponse)
|
||||
- 对 erp-auth 通过 `user_id` 外键松耦合,`Option<Uuid>` 允许患者先建档后绑定
|
||||
- 声明 `dependencies() = vec!["auth"]` 语义明确
|
||||
|
||||
关键问题:
|
||||
- **事件发布不完整**: 设计规格定义 11 种事件,代码实际发布 9 种(含 2 种设计规格外的: `doctor.online_status_changed`/`lab_report.uploaded`)。缺失 `follow_up.overdue` 等关键事件
|
||||
- **message.sent 订阅为空操作**: 只打 `tracing::info`,但 `consultation_session.last_message_at` 已在 `create_message` 方法中通过 CAS 直接更新,此事件订阅是预留扩展点而非功能缺失
|
||||
- 缺少密钥轮换机制
|
||||
|
||||
### 3.2 数据模型完整性
|
||||
|
||||
**评分: 6/10 — 覆盖核心域,存在医疗级缺口**
|
||||
|
||||
已覆盖(27 实体): 患者管理(完整)、医护管理(基本完整)、健康数据(完整)、预约排班(完整)、随访管理(完整)、咨询管理(完整)、专科血透(扩展完整)、患者运营(超预期)。
|
||||
|
||||
缺失的核心实体(按重要性排序):
|
||||
|
||||
| 实体 | 优先级 | 理由 |
|
||||
|------|--------|------|
|
||||
| 诊断记录 (Diagnosis) | P0 | ICD-10 编码,所有临床活动的锚点 |
|
||||
| 用药记录 (Medication) | P1 | 慢病管理核心数据,小程序页面已存在 |
|
||||
| 处方/医嘱 (Prescription) | P1 | 结构化处方是慢病管理核心 |
|
||||
| 健康计划 (Care Plan) | P2 | 从"数据记录"到"主动干预"的关键 |
|
||||
| 过敏详细记录 (Allergy) | P2 | 药物过敏交叉检查需要结构化数据 |
|
||||
|
||||
### 3.3 API 设计质量
|
||||
|
||||
**评分: 7/10 — RESTful 规范,高级特性缺失**
|
||||
|
||||
优点: 统一分页 `PaginatedResponse<T>`、乐观锁 `DeleteWithVersion`、状态机校验、管理端/患者端路由分离。
|
||||
|
||||
不足:
|
||||
- 无批量操作 API
|
||||
- 排序参数未暴露(硬编码 `order_by_desc(CreatedAt)`)
|
||||
- 日期范围过滤缺失(预约只有单日过滤)
|
||||
- 统计 API 不专业(用 `list?page_size=1` 模拟)
|
||||
- 咨询无实时机制
|
||||
- 导出功能单一
|
||||
|
||||
### 3.4 前端架构
|
||||
|
||||
**评分: 7/10 — 组件化程度高,全局状态管理缺失**
|
||||
|
||||
Web 前端优点: API 层按领域拆分(8 个 TS 文件)、12 个共享组件、Tab 化详情页、主题适配。
|
||||
|
||||
不足:
|
||||
- 无 Zustand 全局状态(健康模块共享数据如当前患者、名称缓存无法跨页面)
|
||||
- StatisticsDashboard 数据质量低
|
||||
- 日期处理类型不够安全
|
||||
|
||||
小程序优点: 27 页面覆盖全面、服务层与 Web 端一一对应。
|
||||
|
||||
不足: 无离线缓存策略、无统一错误处理/重试。
|
||||
|
||||
### 3.5 多租户适配
|
||||
|
||||
**评分: 8/10 — 基础隔离完整,差异化配置不足**
|
||||
|
||||
已实现: 全实体 `tenant_id` 过滤、租户生命周期钩子(seed/soft_delete)、AES-256-GCM 加密 + HMAC 索引、数据脱敏、行级数据权限。
|
||||
|
||||
缺失: 不同医疗机构类型(体检中心/社区中心/血透中心/专科诊所)的差异化配置能力。`patient` 模型是"大一统"设计,无法自定义采集项、评估量表、报告模板。
|
||||
|
||||
### 3.6 扩展性与可配置性
|
||||
|
||||
**评分: 5/10 — 架构级良好,业务层不足**
|
||||
|
||||
架构级(良好): ErpModule trait 注册、EventBus 解耦、WASM 插件保留、SeaORM Entity + Migration 扩展。
|
||||
|
||||
业务层(不足): 无自定义表单(EAV 或 JSONB + Schema)、无自定义工作流模板、无报告模板。积分规则和标签系统是系统中少有的可配置业务模块。
|
||||
|
||||
### 3.7 竞品对比
|
||||
|
||||
| 维度 | HMS | 杏树林 | 微医 | 平安好医生 |
|
||||
|------|-----|--------|------|-----------|
|
||||
| 技术性能 | 极高 (Rust) | 中等 (Java) | 中等 | 中等 |
|
||||
| 多租户 | 原生支持 | 后期改造 | 有限 | 有限 |
|
||||
| 患者运营 | 有(积分+商城) | 无 | 无 | 有 |
|
||||
| 血透专科 | 有(待完善) | 无 | 无 | 无 |
|
||||
| AI 能力 | 开发中 | 成熟 | 部分 | 成熟 |
|
||||
| 实时通讯 | 无 | 音视频 | 音视频 | 音视频 |
|
||||
| 医疗标准 | 无 | 有 (ICD) | 有 | 有 |
|
||||
| 影像管理 | 无 | 有 | 有 | 无 |
|
||||
| 合规认证 | 无 | 等保三级 | 等保三级 | 等保三级 |
|
||||
|
||||
**核心差距**: AI 能力、实时通讯、医疗数据标准、影像管理、合规认证。
|
||||
|
||||
---
|
||||
|
||||
## 4. 综合改进路线图
|
||||
|
||||
### Phase 1: 产品可信度修复 (P0, 2-3 人天)
|
||||
|
||||
> 影响: 管理者无法决策、患者安全风险、跨模块联动断裂
|
||||
|
||||
| # | 改进项 | 涉及文件 | 复杂度 |
|
||||
|---|--------|---------|--------|
|
||||
| 1 | 修复 Dashboard 统计数据 | `points.ts` + `points_service.rs` + `StatisticsDashboard.tsx` | 中 |
|
||||
| 2 | 补全事件发布(至少 `follow_up.overdue`) | `event.rs` + `follow_up_service.rs` | 中 |
|
||||
| 3 | 合并 vital_signs 和 daily_monitoring | entity + service + DTO + migration | 中 |
|
||||
| 4 | 增加实时异常预警 | `health_data_service.rs` + `trend_service.rs` | 中 |
|
||||
| 5 | 增加 ICD-10 诊断编码支持 | 新建 entity + migration + service | 中 |
|
||||
| 6 | 实现积分过期清理定时任务 | `points_service.rs` + `module.rs` | 低 |
|
||||
|
||||
### Phase 2: 核心业务能力补全 (P1, 5-7 人天)
|
||||
|
||||
> 影响: 临床实用性不足、患者参与度低、运营效率差
|
||||
|
||||
| # | 改进项 | 复杂度 |
|
||||
|---|--------|--------|
|
||||
| 7 | 结构化随访模板系统 | 高 |
|
||||
| 8 | 用药记录实体 | 中 |
|
||||
| 9 | 透析方案管理 | 中 |
|
||||
| 10 | 体征增加体温/SpO2/血糖类型 | 低 |
|
||||
| 11 | 消息推送集成 | 中 |
|
||||
| 12 | 批量随访操作 | 中 |
|
||||
| 13 | 修复随访类型前后端不一致 | 低 |
|
||||
| 14 | 咨询 WebSocket 实时推送 | 高 |
|
||||
|
||||
### Phase 3: 运营增强 (P2, 5-7 人天)
|
||||
|
||||
> 影响: 差异化竞争力、患者留存、商业变现
|
||||
|
||||
| # | 改进项 | 复杂度 |
|
||||
|---|--------|--------|
|
||||
| 15 | 患者健康评分体系 (Health Score) | 中 |
|
||||
| 16 | 会员等级和营销工具 | 中 |
|
||||
| 17 | 预约资源绑定 | 中 |
|
||||
| 18 | 个性化异常阈值配置 | 中 |
|
||||
| 19 | 化验指标标准化字典 (LOINC) | 中 |
|
||||
| 20 | 批量排班/排班模板 | 中 |
|
||||
| 21 | 小程序分析埋点后端 | 低 |
|
||||
|
||||
### Phase 4: 长期竞争力 (P3, 路线图)
|
||||
|
||||
| # | 改进项 |
|
||||
|---|--------|
|
||||
| 22 | 血管通路管理 |
|
||||
| 23 | 疾病风险评分模型 |
|
||||
| 24 | AI 辅助诊断 (erp-ai 集成) |
|
||||
| 25 | 可配置表单能力 |
|
||||
| 26 | 影像管理集成 (DICOM/PACS) |
|
||||
| 27 | 合规认证 (等保三级) |
|
||||
| 28 | HL7 FHIR R4 数据互操作 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A: 与 QA 审计计划的交叉引用
|
||||
|
||||
本分析与 [QA 审计计划](../../plans/qa-review-brainstorm-floofy-finch.md) 的发现高度重叠,以下是交叉对照:
|
||||
|
||||
| QA 审计编号 | 业务分析对应 | 状态 |
|
||||
|------------|-------------|------|
|
||||
| 1.1 逾期随访检查器未启动 | §1.4 随访管理 — 逾期自动检查已实现但需修复 | 需修复 |
|
||||
| 1.2 积分并发余额损坏 | §2.1 积分激励体系 — CAS 并发安全 | 需修复 |
|
||||
| 2.4 HealthDataProvider 全 stub | §1.7 临床决策支持 — AI 集成依赖 | 需决策 |
|
||||
| 2.6 小程序 DTO 不匹配 | §1.2 医疗数据管理 — 数据模型对齐 | 需修复 |
|
||||
| 3.1 咨询列表显示截断 UUID | §3.4 前端架构 — 名称缓存 | 已修复 |
|
||||
| 3.2 积分订单列表显示 UUID | §3.4 前端架构 — 需名称解析 | 待修复 |
|
||||
| 3.4 预约状态变更无确认 | §1.3 预约排班 — 已在 AppointmentList 中实现 | 已修复 |
|
||||
|
||||
## 附录 B: 工作量估算
|
||||
|
||||
| 阶段 | 人天 | 优先级 |
|
||||
|------|------|--------|
|
||||
| Phase 1: P0 可信度修复 | 2-3 | 立即 |
|
||||
| Phase 2: P1 核心能力 | 5-7 | 本迭代 |
|
||||
| Phase 3: P2 运营增强 | 5-7 | 下迭代 |
|
||||
| Phase 4: P3 长期竞争力 | 路线图 | Q3-Q4 |
|
||||
| **合计** | **12-17 + 路线图** | |
|
||||
@@ -0,0 +1,670 @@
|
||||
# HMS V2 迭代设计 — 血透专科健康管理平台
|
||||
|
||||
> **日期**: 2026-04-25
|
||||
> **状态**: 待评审
|
||||
> **前置文档**: `2026-04-23-health-management-module-design.md`, `2026-04-24-health-module-iteration-design.md`
|
||||
> **需求来源**: 客户功能需求文档 `docs/健康管理/管理系统功能文档(1).xlsx`
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
### 1.1 业务定位变化
|
||||
|
||||
V1 的健康管理模块定位为**通用健康管理平台**,覆盖患者管理、健康数据、预约排班、随访管理、咨询管理五大功能。
|
||||
|
||||
客户反馈后,明确业务定位为**血透(透析)专科健康管理平台**,面向肾病/透析患者和医护群体。这带来三个重大变化:
|
||||
|
||||
1. **数据模型专科化** — 从通用健康指标(血压/心率/血糖/体重/体温)扩展为血透专科指标(透析记录、化验报告、日常监测)
|
||||
2. **新增积分商城** — 替代原计划的完整电商,用积分体系驱动患者活跃度
|
||||
3. **新增医护端小程序** — 独立入口,医护可查看患者数据、填写透析记录、回复咨询
|
||||
|
||||
### 1.2 客户需求来源
|
||||
|
||||
客户通过 Excel 功能文档定义了三端需求:
|
||||
|
||||
| 端 | 核心功能 |
|
||||
|---|---|
|
||||
| 患者端小程序 | 首页、数据上报(透析/化验/日常)、在线咨询、积分商城、预约与随访、个人中心 |
|
||||
| 医护端小程序 | 数据概览、患者管理、咨询回复、随访管理、报告解读 |
|
||||
| PC 管理后台 | 系统管理、患者管理、健康数据中心、咨询管理、商城管理、内容管理、统计报表、系统设置 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 需求-实现差距分析
|
||||
|
||||
### 2.1 已有功能匹配度
|
||||
|
||||
| 客户需求 | 已有实现 | 匹配度 | 需要的工作 |
|
||||
|---------|---------|--------|-----------|
|
||||
| 患者列表/档案 | Patient CRUD + 18 实体 | 90% | 微调 |
|
||||
| 在线咨询(图文) | Consultation 模块 | 70% | 加语音/客服通道 |
|
||||
| 预约管理 | Appointment 模块 | 60% | 加透析专项预约 |
|
||||
| 随访任务/台账 | Follow-up 模块 | 80% | 微调 |
|
||||
| 科普文章 | Article 模块 | 90% | 微调 |
|
||||
| 医护管理 | Doctor 模块 | 80% | 微调 |
|
||||
| 账号权限/操作日志 | erp-auth RBAC | 95% | 基本不变 |
|
||||
| 患者标签 | Tag 模块 | 90% | 微调 |
|
||||
| 小程序健康数据 | 6 种指标录入 + ECharts 趋势图 | 80% | 扩展指标 + 透析数据 |
|
||||
|
||||
### 2.2 全新模块
|
||||
|
||||
| 模块 | 说明 | 复杂度 |
|
||||
|------|------|--------|
|
||||
| 血透透析记录 | 透析日期/时间、干体重、透前/后血压、心率、超滤量、症状 | 中 |
|
||||
| 日常监测扩展 | 饮水量、尿量 + 打卡模式 | 低 |
|
||||
| 化验报告上传 | 拍照上传 + 指标录入 | 中 |
|
||||
| 积分商城 | 积分获取、商品兑换、二维码核销 | 高 |
|
||||
| 线下活动 | 活动管理、报名、扫码签到 | 中 |
|
||||
| 在线咨询(IM) | 客服通道、医生通道、图文/语音消息 | 高 |
|
||||
| 医护端小程序 | 独立小程序,医护专属功能 | 高 |
|
||||
| 统计报表中心 | 患者增长、咨询量、随访完成率、商城销售 | 中-高 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 积分商城设计
|
||||
|
||||
### 3.1 核心链路
|
||||
|
||||
**赚积分 → 攒积分 → 花积分 → 核销**
|
||||
|
||||
### 3.2 实现方式
|
||||
|
||||
放在 `erp-health` 原生模块内,直接复用现有患者体系和事件机制。
|
||||
|
||||
### 3.3 积分获取渠道(数据库可配置)
|
||||
|
||||
| 渠道 | 积分 | 说明 |
|
||||
|------|------|------|
|
||||
| 每日健康打卡 | 可配置/天 | 完成日常监测数据填写触发 |
|
||||
| 数据上报 | 可配置/次 | 上传化验单、填透析记录 |
|
||||
| 线下活动签到 | 活动配置 | 到院参加讲座/义诊等 |
|
||||
| 连续打卡奖励 | 阶梯配置 | 连续 7/14/30 天额外加分 |
|
||||
| 医生互动 | 可配置/次 | 完成一次咨询、回复随访问卷 |
|
||||
|
||||
积分获取规则通过 `points_rule` 表配置,管理员可在 PC 端调整积分值、每日上限、连续奖励等参数。
|
||||
|
||||
### 3.4 兑换品类
|
||||
|
||||
| 类型 | 兑换物 | 履约方式 |
|
||||
|------|--------|---------|
|
||||
| 实物 | 血透护理用品、肾病食品、慢病器械 | 到院自提(二维码核销) |
|
||||
| 服务券 | 免费抽血、肾功能检查、营养咨询 | 生成预约券 → 线下二维码核销 |
|
||||
| 权益 | 免费停车券、优先预约权 | 虚拟权益即时生效 |
|
||||
|
||||
### 3.5 核销方式
|
||||
|
||||
用户兑换后生成二维码(UUID),到院后工作人员扫码核销。
|
||||
|
||||
服务券类型核销后可自动关联预约系统创建预约。
|
||||
|
||||
### 3.6 积分过期机制
|
||||
|
||||
**滚动 12 个月过期,FIFO 先进先出结算。**
|
||||
|
||||
- 每笔积分(earn)有独立 `expires_at`(创建时间 + 12 个月)
|
||||
- 每日后台任务扫描并标记过期积分
|
||||
- 消费时从最老的未过期积分开始扣减
|
||||
- 每笔积分支持部分消费(`remaining_amount` 字段)
|
||||
|
||||
### 3.7 数据模型(8 张新表)
|
||||
|
||||
#### points_account(积分账户)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient,唯一约束 |
|
||||
| balance | i32 | 当前可用积分 |
|
||||
| total_earned | i32 | 累计获得 |
|
||||
| total_spent | i32 | 累计消耗 |
|
||||
| total_expired | i32 | 累计过期 |
|
||||
| version | i32 | 乐观锁 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| created_at / updated_at / created_by / updated_by / deleted_at | — | 标准字段 |
|
||||
|
||||
#### points_rule(积分规则,数据库可配置)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| event_type | String | 触发事件:daily_checkin / data_report / lab_upload / event_checkin / consultation_complete / followup_complete |
|
||||
| name | String | 规则名称(展示用) |
|
||||
| description | String | 规则描述 |
|
||||
| points_value | i32 | 单次获得积分 |
|
||||
| daily_cap | i32 | 每日上限(0 = 无限制) |
|
||||
| streak_7d_bonus | i32 | 连续 7 天额外奖励 |
|
||||
| streak_14d_bonus | i32 | 连续 14 天额外奖励 |
|
||||
| streak_30d_bonus | i32 | 连续 30 天额外奖励 |
|
||||
| is_active | bool | 是否启用 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### points_transaction(积分流水,FIFO 桶模型)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| account_id | UUID | FK → points_account |
|
||||
| type | Enum | earn / spend / expired / refund |
|
||||
| amount | i32 | 正数=获得,负数=消耗 |
|
||||
| remaining_amount | i32 | 该笔积分剩余可用量(earn 类型) |
|
||||
| status | Enum | active / expired / consumed |
|
||||
| expires_at | DateTime | 过期时间(earn 类型:创建 + 12 个月) |
|
||||
| balance_after | i32 | 操作后账户余额快照 |
|
||||
| rule_id | UUID | FK → points_rule(earn 类型) |
|
||||
| order_id | UUID | FK → points_order(spend 类型,可空) |
|
||||
| description | String | 流水描述 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### points_product(兑换商品)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| name | String | 商品名称 |
|
||||
| type | Enum | physical / service / privilege |
|
||||
| points_cost | i32 | 兑换所需积分 |
|
||||
| stock | i32 | 库存数量(-1 = 无限) |
|
||||
| image_url | String | 商品图片 |
|
||||
| description | Text | 商品描述 |
|
||||
| service_config | JSON | 服务类型配置(service 类型:关联检查项目、有效期等) |
|
||||
| is_active | bool | 是否上架 |
|
||||
| sort_order | i32 | 排序权重 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### points_order(兑换订单)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| product_id | UUID | FK → points_product |
|
||||
| points_cost | i32 | 消耗积分(冗余,防商品价格变化) |
|
||||
| status | Enum | pending / verified / cancelled / expired |
|
||||
| qr_code | UUID | 核销二维码(UUID v4) |
|
||||
| verified_by | UUID | 核销人 FK → user |
|
||||
| verified_at | DateTime | 核销时间 |
|
||||
| expires_at | DateTime | 订单过期时间(实物/服务券有效期) |
|
||||
| notes | String | 备注 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### points_checkin(每日打卡)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| checkin_date | Date | 打卡日期 |
|
||||
| consecutive_days | i32 | 连续打卡天数(计算值) |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| created_at | DateTime | — |
|
||||
|
||||
唯一约束:(patient_id, checkin_date)
|
||||
|
||||
#### offline_event(线下活动)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| title | String | 活动标题 |
|
||||
| description | Text | 活动描述 |
|
||||
| event_date | Date | 活动日期 |
|
||||
| start_time / end_time | Time | 活动时间 |
|
||||
| location | String | 活动地点 |
|
||||
| points_reward | i32 | 参与奖励积分 |
|
||||
| max_participants | i32 | 最大参与人数(0 = 无限制) |
|
||||
| current_participants | i32 | 已报名人数 |
|
||||
| status | Enum | draft / published / ongoing / completed / cancelled |
|
||||
| image_url | String | 活动封面图 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
#### offline_event_registration(活动报名 + 签到)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| event_id | UUID | FK → offline_event |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| status | Enum | registered / checked_in / cancelled |
|
||||
| checked_in_at | DateTime | 签到时间 |
|
||||
| checked_in_by | UUID | 签到确认人 FK → user |
|
||||
| points_granted | bool | 是否已发放积分 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
唯一约束:(event_id, patient_id)
|
||||
|
||||
### 3.8 积分获取链路
|
||||
|
||||
```
|
||||
健康打卡 → 触发事件 → points_rule 匹配规则 → 写入 points_transaction → 更新 points_account.balance
|
||||
数据上报 → 同上 ↓
|
||||
咨询完成 → 同上 连续打卡检查
|
||||
线下签到 → 同上 → 达到 7/14/30 天?
|
||||
→ 额外 bonus transaction
|
||||
```
|
||||
|
||||
### 3.9 兑换核销链路
|
||||
|
||||
```
|
||||
用户浏览商品 → 兑换(扣积分)→ 生成 points_order + QR(UUID v4)
|
||||
↓
|
||||
用户到院出示二维码
|
||||
↓
|
||||
工作人员扫码(小程序/PC)→ 状态变 verified
|
||||
↓
|
||||
type=service → 自动创建预约券
|
||||
type=physical → 库存扣减
|
||||
```
|
||||
|
||||
### 3.10 后台任务
|
||||
|
||||
| 任务 | 频率 | 说明 |
|
||||
|------|------|------|
|
||||
| 积分过期扫描 | 每日 | 扫描 expires_at < now 的 earn 记录,标记 expired,扣减 balance |
|
||||
| 订单过期扫描 | 每日 | 扫描未核销且过期的订单,标记 expired,退还积分 |
|
||||
| 连续打卡计算 | 每日 | 计算各患者连续打卡天数,触发阶梯奖励 |
|
||||
|
||||
### 3.11 API 端点
|
||||
|
||||
**患者端:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/points/account | 查看我的积分账户 |
|
||||
| POST | /api/v1/points/checkin | 每日打卡 |
|
||||
| GET | /api/v1/points/checkin/status | 打卡状态(连续天数等) |
|
||||
| GET | /api/v1/points/transactions | 积分流水(分页) |
|
||||
| GET | /api/v1/points/products | 商品列表(分页、按类型筛选) |
|
||||
| GET | /api/v1/points/products/{id} | 商品详情 |
|
||||
| POST | /api/v1/points/exchange | 兑换商品 |
|
||||
| GET | /api/v1/points/orders | 我的兑换订单 |
|
||||
| GET | /api/v1/points/orders/{id} | 订单详情(含二维码) |
|
||||
| GET | /api/v1/offline-events | 线下活动列表 |
|
||||
| GET | /api/v1/offline-events/{id} | 活动详情 |
|
||||
| POST | /api/v1/offline-events/{id}/register | 报名活动 |
|
||||
|
||||
**医护/管理员端:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/v1/points/verify | 扫码核销 |
|
||||
| CRUD | /api/v1/admin/points/rules | 积分规则管理 |
|
||||
| CRUD | /api/v1/admin/points/products | 商品管理 |
|
||||
| GET | /api/v1/admin/points/orders | 订单管理(导出) |
|
||||
| GET | /api/v1/admin/points/statistics | 积分统计 |
|
||||
| CRUD | /api/v1/admin/offline-events | 线下活动管理 |
|
||||
| POST | /api/v1/admin/offline-events/{id}/checkin | 活动扫码签到 |
|
||||
|
||||
### 3.12 PC 管理后台新增页面
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| 积分规则管理 | 增删改规则、启用/禁用、调整积分值和上限 |
|
||||
| 商品管理 | 增删改兑换品、设置库存、上传图片、设置积分价格 |
|
||||
| 订单管理 | 查看兑换记录、手动核销、导出 |
|
||||
| 线下活动管理 | 创建活动、设置积分奖励、查看报名/签到名单 |
|
||||
| 积分统计 | 总发放/总消耗/活跃用户排行、积分流水查询 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 血透专科数据模型
|
||||
|
||||
### 4.1 设计决策
|
||||
|
||||
- **独立实体表**:不复用现有 health_data 键值对结构,每类数据一张独立表
|
||||
- **医护+患者协同上报**:透析记录由医护填写,化验报告由患者上传/医护审阅,日常监测由患者填写
|
||||
|
||||
### 4.2 权限矩阵
|
||||
|
||||
| 数据 | 患者端 | 医护端 | PC 管理后台 |
|
||||
|------|--------|--------|------------|
|
||||
| 透析记录 | 查看(只读) | 创建/编辑 | 查看/导出/统计 |
|
||||
| 化验报告 | 上传照片/查看 | 审阅/标注/解读 | 查看/导出 |
|
||||
| 日常监测 | 创建/查看 | 查看/预警 | 查看/统计 |
|
||||
| AI 健康报告 | 查看 | 查看/编辑 | 查看/导出 |
|
||||
|
||||
### 4.3 新增实体
|
||||
|
||||
#### dialysis_record(透析记录)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| dialysis_date | Date | 透析日期 |
|
||||
| start_time | Time | 开始时间 |
|
||||
| end_time | Time | 结束时间 |
|
||||
| dry_weight | Decimal(5,1) | 干体重 (kg) |
|
||||
| pre_weight | Decimal(5,1) | 透前体重 (kg) |
|
||||
| post_weight | Decimal(5,1) | 透后体重 (kg) |
|
||||
| pre_bp_systolic | i32 | 透前收缩压 |
|
||||
| pre_bp_diastolic | i32 | 透前舒张压 |
|
||||
| post_bp_systolic | i32 | 透后收缩压 |
|
||||
| post_bp_diastolic | i32 | 透后舒张压 |
|
||||
| pre_heart_rate | i32 | 透前心率 |
|
||||
| post_heart_rate | i32 | 透后心率 |
|
||||
| ultrafiltration_volume | i32 | 超滤量 (ml) |
|
||||
| dialysis_duration | i32 | 透析时长 (min) |
|
||||
| blood_flow_rate | i32 | 血流量 (ml/min) |
|
||||
| dialysis_type | Enum | HD / HDF / HF |
|
||||
| symptoms | JSON | 不适症状数组 ["低血压","恶心","抽筋"] |
|
||||
| complication_notes | Text | 并发症备注 |
|
||||
| status | Enum | draft / completed / reviewed |
|
||||
| reviewed_by | UUID | FK → user,审阅医生 |
|
||||
| reviewed_at | DateTime | 审阅时间 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | created_at / updated_at / created_by / updated_by / deleted_at / version |
|
||||
|
||||
#### lab_report(化验报告)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| report_date | Date | 化验日期 |
|
||||
| report_type | Enum | kidney_function / blood_routine / electrolyte / liver_function / other |
|
||||
| source | Enum | manual_input / photo_upload |
|
||||
| image_urls | JSON | 化验单照片 URL 数组 |
|
||||
| items | JSON | 指标数据数组(见下方结构) |
|
||||
| doctor_notes | Text | 医生解读/批注 |
|
||||
| reviewed_by | UUID | FK → user,审阅医生 |
|
||||
| reviewed_at | DateTime | 审阅时间 |
|
||||
| status | Enum | pending / reviewed |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
**items JSON 结构:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "肌酐",
|
||||
"value": "856",
|
||||
"unit": "μmol/L",
|
||||
"reference_low": 44,
|
||||
"reference_high": 133,
|
||||
"is_abnormal": true
|
||||
},
|
||||
{
|
||||
"name": "血钾",
|
||||
"value": "6.2",
|
||||
"unit": "mmol/L",
|
||||
"reference_low": 3.5,
|
||||
"reference_high": 5.3,
|
||||
"is_abnormal": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### daily_monitoring(日常监测)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID v7 | PK |
|
||||
| patient_id | UUID | FK → patient |
|
||||
| record_date | Date | 记录日期 |
|
||||
| morning_bp_systolic | i32 | 晨起收缩压 |
|
||||
| morning_bp_diastolic | i32 | 晨起舒张压 |
|
||||
| evening_bp_systolic | i32 | 晚间收缩压 |
|
||||
| evening_bp_diastolic | i32 | 晚间舒张压 |
|
||||
| weight | Decimal(5,1) | 体重 (kg) |
|
||||
| blood_sugar | Decimal(4,1) | 血糖 (mmol/L) |
|
||||
| fluid_intake | i32 | 饮水量 (ml) |
|
||||
| urine_output | i32 | 尿量 (ml) |
|
||||
| notes | Text | 备注 |
|
||||
| tenant_id | UUID | 租户 |
|
||||
| 标准字段 | — | — |
|
||||
|
||||
唯一约束:(patient_id, record_date)
|
||||
|
||||
### 4.4 与现有模块的关系
|
||||
|
||||
```
|
||||
patient(已有)
|
||||
├── dialysis_record(新增) 1:N 每次透析一条
|
||||
├── lab_report(新增) 1:N 每次化验一份
|
||||
├── daily_monitoring(新增) 1:N 每天一条
|
||||
├── health_data(已有,保留) 1:N 通用指标仍可用
|
||||
├── appointment(已有) 1:N 透析预约走这里
|
||||
└── follow_up(已有) 1:N 随访任务
|
||||
```
|
||||
|
||||
### 4.5 化验报告 items 用 JSON 的原因
|
||||
|
||||
- 化验项目数量和类型因报告而异(肾功能 8 项 vs 血常规 20+ 项)
|
||||
- 不需要按单个指标做复杂查询(都是按患者+日期范围查整份报告)
|
||||
- JSON 内含 `is_abnormal` 标记,前端直接渲染异常标红
|
||||
- PostgreSQL JSONB 支持按单个指标查询趋势(未来可加物化视图展平)
|
||||
|
||||
### 4.6 数据上报协同流程
|
||||
|
||||
**透析记录**:医护在医护端小程序/PC 填写 → 患者端只读查看
|
||||
|
||||
**化验报告**:患者拍照上传照片 + 手动填写指标 → 医护审阅/标注/解读 → 患者查看异常标红+医生解读
|
||||
|
||||
**日常监测**:患者每日填写(血压/体重/血糖/饮水量/尿量)→ 触发积分获取 → 医护端查看趋势+异常预警
|
||||
|
||||
### 4.7 新增 API 端点
|
||||
|
||||
**透析记录:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/health/patients/{id}/dialysis-records | 患者透析记录列表 |
|
||||
| GET | /api/v1/health/dialysis-records/{id} | 透析记录详情 |
|
||||
| POST | /api/v1/health/dialysis-records | 创建透析记录(医护) |
|
||||
| PUT | /api/v1/health/dialysis-records/{id} | 更新透析记录(医护) |
|
||||
|
||||
**化验报告:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/health/patients/{id}/lab-reports | 患者化验报告列表 |
|
||||
| GET | /api/v1/health/lab-reports/{id} | 报告详情 |
|
||||
| POST | /api/v1/health/lab-reports | 上传化验报告(患者/医护) |
|
||||
| PUT | /api/v1/health/lab-reports/{id}/review | 医生审阅(标注+解读) |
|
||||
|
||||
**日常监测:**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/health/patients/{id}/daily-monitoring | 患者日常监测列表 |
|
||||
| POST | /api/v1/health/daily-monitoring | 上报日常监测(患者) |
|
||||
| GET | /api/v1/health/daily-monitoring/trend | 趋势数据查询 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 小程序迭代设计
|
||||
|
||||
### 5.1 患者端小程序
|
||||
|
||||
#### 新 TabBar 结构
|
||||
|
||||
```
|
||||
首页 | 上报 | 咨询 | 商城 | 我的
|
||||
```
|
||||
|
||||
替换现有:~~首页 | 健康 | 预约 | 资讯 | 我的~~
|
||||
|
||||
#### 首页(改造)
|
||||
|
||||
- 公告轮播(Banner 组件)
|
||||
- 功能入口 Grid:数据上报、我的医生、在线咨询、血透预约、积分商城
|
||||
- 今日健康打卡入口(未打卡时醒目提示)
|
||||
- 健康概览卡片:血压、体重、最近透析记录
|
||||
- 今日提醒列表:透析预约、血压测量、用药提醒
|
||||
|
||||
#### 上报 Tab(改造自"健康")
|
||||
|
||||
- 打卡区:血压/体重/血糖/饮水/尿量 快捷填写入口
|
||||
- 数据类型切换:日常监测 / 透析记录(只读)/ 化验报告
|
||||
- 趋势图(ECharts):血压/体重/血糖 折线图
|
||||
- 子页面:
|
||||
- 日常监测填写表单
|
||||
- 化验报告上传(拍照 + 指标填写)
|
||||
- 透析记录详情(只读)
|
||||
- 化验报告详情(含医生批注)
|
||||
- 趋势分析详情(大图 + AI 解读)
|
||||
|
||||
#### 咨询 Tab(全新)
|
||||
|
||||
- 咨询类型切换:医生 / 客服
|
||||
- 最近对话列表(未读红点)
|
||||
- 子页面:
|
||||
- 选择医生
|
||||
- 聊天界面(图文/语音 + 上传报告)
|
||||
- 客服对话
|
||||
|
||||
#### 商城 Tab(全新)
|
||||
|
||||
- 我的积分 + 签到按钮
|
||||
- 商品分类:全部 / 实物 / 检查 / 权益
|
||||
- 商品列表(网格)
|
||||
- 线下活动入口
|
||||
- 子页面:
|
||||
- 商品详情 + 兑换
|
||||
- 兑换确认(生成二维码)
|
||||
- 我的订单(待核销/已核销/已过期)
|
||||
- 线下活动详情 + 报名
|
||||
- 积分明细
|
||||
|
||||
#### 我的 Tab(改造)
|
||||
|
||||
- 个人信息 + 积分展示 + 连续打卡天数
|
||||
- 就诊人管理(已有)
|
||||
- 健康档案
|
||||
- 我的医生
|
||||
- 我的预约(从 TabBar 降级为菜单入口)
|
||||
- 我的报告(已有)
|
||||
- 我的随访(已有)
|
||||
- 我的订单(积分商城)
|
||||
- 消息通知(新增)
|
||||
- 用药提醒(已有,需接入后端)
|
||||
- 设置(已有)
|
||||
|
||||
### 5.2 医护端小程序(V2)
|
||||
|
||||
医护端为独立小程序(独立 AppID),V2 实现。V1 阶段医护功能通过 PC 管理后台覆盖。
|
||||
|
||||
#### V2 页面结构(约 18 页)
|
||||
|
||||
TabBar:概览 | 患者 | 咨询 | 随访 | 我的
|
||||
|
||||
- **概览**:今日待回复咨询数、异常预警列表、今日透析患者数、随访任务数
|
||||
- **患者**:患者列表(按透析/高危/标签筛选)、患者详情(档案+透析记录+化验+趋势图+标签)、填写透析记录、报告解读
|
||||
- **咨询**:未读消息列表、图文/语音回复、发送科普文章
|
||||
- **随访**:随访任务列表、填写记录、台账导出
|
||||
- **我的**:医生信息、我的患者、排班日历、科普文章管理
|
||||
|
||||
### 5.3 页面工作量统计
|
||||
|
||||
| 端 | 改造页 | 新增页 | 总计 |
|
||||
|----|--------|--------|------|
|
||||
| 患者端 | 5(首页/健康/我的/预约/资讯改造) | ~15(咨询3+商城6+上报子页4+通知2) | ~20 页 |
|
||||
| 医护端(V2) | 0 | 18 | 18 页 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 在线咨询设计(V2 详情待补充)
|
||||
|
||||
### 6.1 核心需求
|
||||
|
||||
- 客服通道:订单/物流/使用问题
|
||||
- 医生通道:选择医生、发送问题、上传报告、语音/文字沟通
|
||||
- 留言功能:医生离线时留存问题
|
||||
|
||||
### 6.2 技术方向
|
||||
|
||||
- V1 可先实现基于轮询的图文消息(复用现有 Consultation 模块)
|
||||
- V2 升级为 WebSocket 实时通信 + 语音消息
|
||||
- 客服通道可对接第三方客服系统(如美洽、智齿)
|
||||
|
||||
---
|
||||
|
||||
## 7. PC 管理后台新增页面
|
||||
|
||||
### 7.1 健康数据中心(新增)
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| 透析数据统计 | 透析次数趋势、干体重变化、超滤量统计 |
|
||||
| 异常指标排行 | 血钾/血磷/肌酐异常患者排行 |
|
||||
| 上报率统计 | 患者数据上报活跃度、打卡率 |
|
||||
|
||||
### 7.2 商城管理(新增)
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| 积分规则管理 | 增删改积分获取规则 |
|
||||
| 商品管理 | 增删改兑换商品、库存管理 |
|
||||
| 订单管理 | 兑换记录查看、手动核销、导出 |
|
||||
| 线下活动管理 | 活动创建、报名/签到管理 |
|
||||
| 积分统计 | 发放/消耗/活跃排行 |
|
||||
|
||||
### 7.3 统计报表(新增)
|
||||
|
||||
| 页面 | 功能 |
|
||||
|------|------|
|
||||
| 患者增长 | 新增患者趋势、活跃度 |
|
||||
| 咨询量 | 咨询次数/回复率/满意度 |
|
||||
| 随访完成率 | 随访任务执行统计 |
|
||||
| 商城数据 | 兑换排行、库存周转 |
|
||||
|
||||
---
|
||||
|
||||
## 8. AI 分析能力(V2)
|
||||
|
||||
### 8.1 V1 预留
|
||||
|
||||
- 趋势图已通过 ECharts 实现(现有 TrendChart 组件)
|
||||
- 异常指标通过 `is_abnormal` 标记和阈值校验实现
|
||||
- 健康报告可生成基础版(数据汇总 + 异常标注)
|
||||
|
||||
### 8.2 V2 增强
|
||||
|
||||
- LLM 集成:自然语言健康报告生成
|
||||
- AI 辅助:化验单 OCR 自动识别
|
||||
- 智能预警:基于历史数据的异常趋势预测
|
||||
|
||||
---
|
||||
|
||||
## 9. 分期建议
|
||||
|
||||
### V1.1(建议优先)
|
||||
|
||||
1. 血透专科数据模型(3 张新表 + API)
|
||||
2. 患者端小程序改造(TabBar + 首页 + 上报扩展)
|
||||
3. 积分商城后端 + 小程序前端
|
||||
4. 线下活动管理
|
||||
|
||||
### V1.2
|
||||
|
||||
1. 在线咨询(轮询版)
|
||||
2. 化验报告上传 + 审阅流程
|
||||
3. PC 管理后台新增页面
|
||||
4. 统计报表
|
||||
|
||||
### V2
|
||||
|
||||
1. 医护端小程序
|
||||
2. 在线咨询升级(WebSocket + 语音)
|
||||
3. AI 分析增强(LLM + OCR)
|
||||
4. Redis 缓存层
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 积分 FIFO 结算复杂度 | 并发消费时积分桶冲突 | 使用数据库事务 + 乐观锁(account.version) |
|
||||
| 在线咨询工作量超预期 | V1.2 延期 | V1 先做轮询图文,WebSocket 推迟到 V2 |
|
||||
| 医护端小程序工作量大 | V2 延期 | V1 阶段用 PC 后台替代医护端功能 |
|
||||
| 化验单 OCR 准确率 | 用户体验差 | V1 先做手动填写,OCR 作为 V2 AI 增量 |
|
||||
| 积分通胀 | 积分价值稀释 | 可配置每日上限 + 过期机制 + 运营调整积分价格 |
|
||||
@@ -0,0 +1,657 @@
|
||||
# 切片 1: 按钮级权限控制 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现前端按钮级权限控制,让无权限用户看不到操作按钮(hidden 模式)。
|
||||
|
||||
**Architecture:** JWT claims 已包含 `permissions: Vec<String>`(后端登录时写入),前端复用 `client.ts` 的 `decodeJwtPayload` 提取权限码列表,存入 Zustand auth store。新增 `usePermission` hook + `AuthButton` / `AuthGuard` 声明式组件,包裹健康模块 15 个页面的操作按钮。
|
||||
|
||||
**Tech Stack:** React 19 + TypeScript + Zustand 5 + Ant Design 6
|
||||
|
||||
**设计规格:** `docs/superpowers/specs/2026-04-25-feature-completion-design.md` §2
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: 权限基础设施
|
||||
|
||||
### Task 1: 从 JWT 提取 permissions 并存入 auth store
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/stores/auth.ts`
|
||||
|
||||
**背景:** JWT payload 已包含 `permissions` 字段(string 数组)。`client.ts` 已有 `decodeJwtPayload` 函数。auth store 登录时已存 `access_token` 到 localStorage,可从中解码权限。
|
||||
|
||||
- [ ] **Step 1: 在 auth store 中添加 permissions 状态和提取逻辑**
|
||||
|
||||
在 `apps/web/src/stores/auth.ts` 中:
|
||||
|
||||
1. 新增辅助函数(文件顶部,import 之后):
|
||||
|
||||
```typescript
|
||||
function extractPermissions(): string[] {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return [];
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return [];
|
||||
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return Array.isArray(payload.permissions) ? payload.permissions : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. 修改 `restoreInitialState` 返回值,增加 `permissions`:
|
||||
|
||||
```typescript
|
||||
function restoreInitialState(): { user: UserInfo | null; isAuthenticated: boolean; permissions: string[] } {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (token && userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr) as UserInfo;
|
||||
return { user, isAuthenticated: true, permissions: extractPermissions() };
|
||||
} catch {
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
return { user: null, isAuthenticated: false, permissions: [] };
|
||||
}
|
||||
```
|
||||
|
||||
3. 修改 `AuthState` 接口,增加 `permissions`:
|
||||
|
||||
```typescript
|
||||
interface AuthState {
|
||||
user: UserInfo | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
permissions: string[];
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
loadFromStorage: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
4. 修改 store 创建,初始化 `permissions`,在 login/logout 中同步更新:
|
||||
|
||||
```typescript
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: initial.user,
|
||||
isAuthenticated: initial.isAuthenticated,
|
||||
loading: false,
|
||||
permissions: initial.permissions,
|
||||
|
||||
login: async (username, password) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const resp = await apiLogin({ username, password });
|
||||
localStorage.setItem('access_token', resp.access_token);
|
||||
localStorage.setItem('refresh_token', resp.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(resp.user));
|
||||
set({ user: resp.user, isAuthenticated: true, loading: false, permissions: extractPermissions() });
|
||||
} catch (error) {
|
||||
set({ loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await apiLogout();
|
||||
} catch {
|
||||
// Ignore logout API errors
|
||||
}
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
set({ user: null, isAuthenticated: false, permissions: [] });
|
||||
},
|
||||
|
||||
loadFromStorage: () => {
|
||||
const state = restoreInitialState();
|
||||
set({ user: state.user, isAuthenticated: state.isAuthenticated, permissions: state.permissions });
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译通过**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 无类型错误
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/stores/auth.ts
|
||||
git commit -m "feat(web): auth store 添加 permissions 状态,从 JWT 解码提取"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 创建 usePermission hook
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/hooks/usePermission.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 usePermission hook**
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
export function usePermission(code: string): { hasPermission: boolean } {
|
||||
const permissions = useAuthStore((s) => s.permissions);
|
||||
return { hasPermission: permissions.includes(code) };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译通过**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 无类型错误
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/hooks/usePermission.ts
|
||||
git commit -m "feat(web): 添加 usePermission hook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 创建 AuthButton + AuthGuard 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/components/AuthButton.tsx`
|
||||
- Create: `apps/web/src/components/AuthGuard.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 AuthButton 组件**
|
||||
|
||||
`apps/web/src/components/AuthButton.tsx`:
|
||||
|
||||
```typescript
|
||||
import type { ReactNode } from 'react';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
|
||||
interface AuthButtonProps {
|
||||
code: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthButton({ code, children }: AuthButtonProps) {
|
||||
const { hasPermission } = usePermission(code);
|
||||
if (!hasPermission) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 AuthGuard 组件**
|
||||
|
||||
`apps/web/src/components/AuthGuard.tsx`:
|
||||
|
||||
```typescript
|
||||
import type { ReactNode } from 'react';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
|
||||
interface AuthGuardProps {
|
||||
code: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthGuard({ code, children }: AuthGuardProps) {
|
||||
const { hasPermission } = usePermission(code);
|
||||
if (!hasPermission) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译通过**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 无类型错误
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/components/AuthButton.tsx apps/web/src/components/AuthGuard.tsx
|
||||
git commit -m "feat(web): 添加 AuthButton/AuthGuard 声明式权限组件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 健康模块页面按钮权限改造
|
||||
|
||||
### Task 4: PatientList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/PatientList.tsx`
|
||||
|
||||
**改造目标:**
|
||||
- 第 304 行 `新建患者` 按钮 → `<AuthButton code="health.patient.manage">`
|
||||
- 第 242-267 行 操作列的编辑/删除按钮 → `<AuthButton code="health.patient.manage">`
|
||||
|
||||
- [ ] **Step 1: 添加 import**
|
||||
|
||||
在 PatientList.tsx 顶部 import 区域添加:
|
||||
|
||||
```typescript
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 包裹新建患者按钮**
|
||||
|
||||
将第 304 行:
|
||||
```tsx
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
新建患者
|
||||
</Button>
|
||||
```
|
||||
|
||||
改为:
|
||||
```tsx
|
||||
<AuthButton code="health.patient.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
新建患者
|
||||
</Button>
|
||||
</AuthButton>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 包裹操作列按钮**
|
||||
|
||||
将 columns 操作列的 render(第 241-270 行):
|
||||
```tsx
|
||||
render: (_: unknown, record: PatientListItem) => (
|
||||
<Space size={4}>
|
||||
<Button ... />
|
||||
<Popconfirm ...><Button ... /></Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
```
|
||||
|
||||
改为:
|
||||
```tsx
|
||||
render: (_: unknown, record: PatientListItem) => (
|
||||
<AuthButton code="health.patient.manage">
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEditModal(record);
|
||||
}}
|
||||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此患者?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDelete(record.id);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</AuthButton>
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证编译通过**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 无类型错误
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/pages/health/PatientList.tsx
|
||||
git commit -m "feat(web): PatientList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: AppointmentList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/AppointmentList.tsx`
|
||||
|
||||
**改造模式同 Task 4:**
|
||||
- 新建预约按钮 → `<AuthButton code="health.appointment.manage">`
|
||||
- 操作列(编辑/取消/状态变更) → `<AuthButton code="health.appointment.manage">`
|
||||
|
||||
- [ ] **Step 1: 读取文件,识别所有操作按钮位置**
|
||||
|
||||
Run: `grep -n "Button\|onClick\|Popconfirm" apps/web/src/pages/health/AppointmentList.tsx`
|
||||
|
||||
- [ ] **Step 2: 添加 import + 包裹所有操作按钮**
|
||||
|
||||
添加 `import { AuthButton } from '../../components/AuthButton';`
|
||||
用 `<AuthButton code="health.appointment.manage">` 包裹:
|
||||
- 顶部新建按钮
|
||||
- 表格操作列中的所有按钮
|
||||
|
||||
- [ ] **Step 3: 验证编译通过**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/pages/health/AppointmentList.tsx
|
||||
git commit -m "feat(web): AppointmentList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: DoctorList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/DoctorList.tsx`
|
||||
|
||||
**权限码:** `health.doctor.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
模式同 Task 4-5。新建按钮 + 操作列用 `<AuthButton code="health.doctor.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/DoctorList.tsx
|
||||
git commit -m "feat(web): DoctorList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: DoctorSchedule 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/DoctorSchedule.tsx`
|
||||
|
||||
**权限码:** `health.doctor.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建排班 + 操作列用 `<AuthButton code="health.doctor.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/DoctorSchedule.tsx
|
||||
git commit -m "feat(web): DoctorSchedule 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: FollowUpTaskList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/FollowUpTaskList.tsx`
|
||||
|
||||
**权限码:** `health.follow-up.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建随访 + 操作列(编辑/完成/取消)用 `<AuthButton code="health.follow-up.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/FollowUpTaskList.tsx
|
||||
git commit -m "feat(web): FollowUpTaskList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: FollowUpRecordList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/FollowUpRecordList.tsx`
|
||||
|
||||
**权限码:** `health.follow-up.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
添加记录 + 操作列用 `<AuthButton code="health.follow-up.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/FollowUpRecordList.tsx
|
||||
git commit -m "feat(web): FollowUpRecordList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: ConsultationList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/ConsultationList.tsx`
|
||||
|
||||
**权限码:** `health.consultation.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建会话 + 操作列(关闭/导出)用 `<AuthButton code="health.consultation.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/ConsultationList.tsx
|
||||
git commit -m "feat(web): ConsultationList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: ConsultationDetail 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/ConsultationDetail.tsx`
|
||||
|
||||
**权限码:** `health.consultation.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
发送消息 + 关闭会话 + 导出按钮用 `<AuthButton code="health.consultation.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/ConsultationDetail.tsx
|
||||
git commit -m "feat(web): ConsultationDetail 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: OfflineEventList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/OfflineEventList.tsx`
|
||||
|
||||
**权限码:** `health.articles.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建活动 + 操作列用 `<AuthButton code="health.articles.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/OfflineEventList.tsx
|
||||
git commit -m "feat(web): OfflineEventList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 13: PatientDetail 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/PatientDetail.tsx`
|
||||
|
||||
**权限码:** `health.patient.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
编辑患者信息按钮 + 标签管理 + 新增健康数据按钮用 `<AuthButton code="health.patient.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/PatientDetail.tsx
|
||||
git commit -m "feat(web): PatientDetail 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: PatientTagManage 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/PatientTagManage.tsx`
|
||||
|
||||
**权限码:** `health.patient.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建标签 + 编辑/删除标签用 `<AuthButton code="health.patient.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/PatientTagManage.tsx
|
||||
git commit -m "feat(web): PatientTagManage 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 15: PointsProductList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/PointsProductList.tsx`
|
||||
|
||||
**权限码:** `health.points.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建商品 + 编辑/删除/上下架用 `<AuthButton code="health.points.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/PointsProductList.tsx
|
||||
git commit -m "feat(web): PointsProductList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 16: PointsOrderList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/PointsOrderList.tsx`
|
||||
|
||||
**权限码:** `health.points.list`(只读列表,如有核销操作用 `health.points.manage`)
|
||||
|
||||
- [ ] **Step 1: 读取文件,识别是否有写操作按钮**
|
||||
|
||||
Run: `grep -n "Button\|onClick" apps/web/src/pages/health/PointsOrderList.tsx`
|
||||
|
||||
- [ ] **Step 2: 如有核销/管理按钮,用 `<AuthButton code="health.points.manage">` 包裹**
|
||||
|
||||
- [ ] **Step 3: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/PointsOrderList.tsx
|
||||
git commit -m "feat(web): PointsOrderList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 17: PointsRuleList 按钮权限
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/pages/health/PointsRuleList.tsx`
|
||||
|
||||
**权限码:** `health.points.manage`
|
||||
|
||||
- [ ] **Step 1: 添加 import + 包裹操作按钮**
|
||||
|
||||
新建规则 + 编辑/删除用 `<AuthButton code="health.points.manage">` 包裹。
|
||||
|
||||
- [ ] **Step 2: 验证 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/PointsRuleList.tsx
|
||||
git commit -m "feat(web): PointsRuleList 添加按钮级权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 18: 集成验证
|
||||
|
||||
- [ ] **Step 1: 全量 TypeScript 编译检查**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 2: 启动前端开发服务器**
|
||||
|
||||
Run: `cd apps/web && pnpm dev`
|
||||
|
||||
- [ ] **Step 3: 功能验证**
|
||||
|
||||
1. 用管理员账号登录 → 所有按钮可见
|
||||
2. 创建一个无权限的测试角色(仅 `health.patient.list`)→ 分配给测试用户
|
||||
3. 用测试用户登录 → 仅患者列表可见,新建/编辑/删除按钮隐藏
|
||||
4. 确认表格行点击导航(如患者详情页)仍然正常
|
||||
|
||||
- [ ] **Step 4: 生产构建验证**
|
||||
|
||||
Run: `cd apps/web && pnpm build`
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **Step 5: 推送所有提交**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 权限码速查表
|
||||
|
||||
| 页面 | 文件 | 权限码 |
|
||||
|------|------|--------|
|
||||
| PatientList | PatientList.tsx | health.patient.manage |
|
||||
| PatientDetail | PatientDetail.tsx | health.patient.manage |
|
||||
| PatientTagManage | PatientTagManage.tsx | health.patient.manage |
|
||||
| AppointmentList | AppointmentList.tsx | health.appointment.manage |
|
||||
| DoctorList | DoctorList.tsx | health.doctor.manage |
|
||||
| DoctorSchedule | DoctorSchedule.tsx | health.doctor.manage |
|
||||
| FollowUpTaskList | FollowUpTaskList.tsx | health.follow-up.manage |
|
||||
| FollowUpRecordList | FollowUpRecordList.tsx | health.follow-up.manage |
|
||||
| ConsultationList | ConsultationList.tsx | health.consultation.manage |
|
||||
| ConsultationDetail | ConsultationDetail.tsx | health.consultation.manage |
|
||||
| OfflineEventList | OfflineEventList.tsx | health.articles.manage |
|
||||
| PointsProductList | PointsProductList.tsx | health.points.manage |
|
||||
| PointsOrderList | PointsOrderList.tsx | health.points.manage |
|
||||
| PointsRuleList | PointsRuleList.tsx | health.points.manage |
|
||||
| StatisticsDashboard | StatisticsDashboard.tsx | health.health-data.list (只读,无操作按钮) |
|
||||
@@ -0,0 +1,884 @@
|
||||
# 切片 2: AI 管理端 3 页面 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现 AI 模块的 PC 管理端 — Prompt 管理、分析历史、用量统计 3 个页面,以及对应的后端 API 补全。
|
||||
|
||||
**Architecture:** 后端 4 个 SSE 端点已可用,但 Prompt CRUD / 分析历史查询 / 用量统计端点为空壳或缺失。先补全后端 API(handler + service 方法),再实现前端 API 封装和 3 个管理页面,最后注册菜单和路由。
|
||||
|
||||
**Tech Stack:** Rust/Axum (后端) + React 19/TypeScript/Ant Design 6 (前端)
|
||||
|
||||
**设计规格:** `docs/superpowers/specs/2026-04-25-feature-completion-design.md` §3
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: 后端 API 补全
|
||||
|
||||
### Task 1: PromptService — 补全 CRUD 方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-ai/src/service/prompt.rs`
|
||||
|
||||
**现状:** 仅有 `get_active_prompt` + `create_prompt`。需新增 `list_prompts`、`update_prompt`、`activate_prompt`、`rollback_prompt`。
|
||||
|
||||
- [ ] **Step 1: 添加 list_prompts 方法**
|
||||
|
||||
在 `PromptService` impl 中追加:
|
||||
|
||||
```rust
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set};
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
pub async fn list_prompts(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
category: Option<String>,
|
||||
pagination: Pagination,
|
||||
) -> AiResult<(Vec<ai_prompt::Model>, u64)> {
|
||||
let mut query = ai_prompt::Entity::find()
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(cat) = category {
|
||||
query = query.filter(ai_prompt::Column::Category.eq(cat));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let items = query
|
||||
.order_by_desc(ai_prompt::Column::UpdatedAt)
|
||||
.offset(pagination.offset())
|
||||
.limit(pagination.limit())
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
Ok((items, total))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 update_prompt 方法**
|
||||
|
||||
```rust
|
||||
pub async fn update_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
system_prompt: Option<String>,
|
||||
user_prompt_template: Option<String>,
|
||||
model_config: Option<serde_json::Value>,
|
||||
description: Option<String>,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
|
||||
|
||||
if entity.tenant_id != tenant_id {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
// 创建新版本
|
||||
let new_id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
let active = ai_prompt::ActiveModel {
|
||||
id: Set(new_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(entity.name.clone()),
|
||||
description: Set(description.unwrap_or(entity.description.clone())),
|
||||
system_prompt: Set(system_prompt.unwrap_or(entity.system_prompt.clone())),
|
||||
user_prompt_template: Set(user_prompt_template.unwrap_or(entity.user_prompt_template.clone())),
|
||||
variables_schema: Set(entity.variables_schema.clone()),
|
||||
model_config: Set(model_config.unwrap_or(entity.model_config.clone())),
|
||||
version: Set(entity.version + 1),
|
||||
is_active: Set(entity.is_active),
|
||||
category: Set(entity.category.clone()),
|
||||
tags: Set(entity.tags.clone()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Some(user_id)),
|
||||
updated_by: Set(Some(user_id)),
|
||||
deleted_at: Set(None),
|
||||
version_lock: Set(1),
|
||||
};
|
||||
Ok(active.insert(&self.db).await?)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 添加 activate_prompt 方法**
|
||||
|
||||
```rust
|
||||
pub async fn activate_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
let entity = ai_prompt::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
|
||||
|
||||
if entity.tenant_id != tenant_id {
|
||||
return Err(AiError::Validation("跨租户操作".into()));
|
||||
}
|
||||
|
||||
// 停用同 name + category 的其他版本
|
||||
let siblings = ai_prompt::Entity::find()
|
||||
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_prompt::Column::Name.eq(&entity.name))
|
||||
.filter(ai_prompt::Column::Category.eq(&entity.category))
|
||||
.filter(ai_prompt::Column::IsActive.eq(true))
|
||||
.filter(ai_prompt::Column::DeletedAt.is_null())
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
for sibling in siblings {
|
||||
let mut active: ai_prompt::ActiveModel = sibling.into();
|
||||
active.is_active = Set(false);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.update(&self.db).await?;
|
||||
}
|
||||
|
||||
// 激活目标
|
||||
let mut active: ai_prompt::ActiveModel = entity.into();
|
||||
active.is_active = Set(true);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
Ok(active.update(&self.db).await?)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 添加 rollback_prompt(激活指定旧版本)**
|
||||
|
||||
```rust
|
||||
pub async fn rollback_prompt(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_prompt::Model> {
|
||||
// 回滚 = 激活指定版本
|
||||
self.activate_prompt(id, tenant_id).await
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 编译验证**
|
||||
|
||||
Run: `cargo check -p erp-ai`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-ai/src/service/prompt.rs
|
||||
git commit -m "feat(ai): PromptService 补全 list/update/activate/rollback 方法"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: AnalysisService — 补全查询方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-ai/src/service/analysis.rs`
|
||||
|
||||
**现状:** 有 `stream_analyze`、`complete_analysis`、`fail_analysis`、`find_cached`。需新增 `list_analysis`、`get_analysis`。
|
||||
|
||||
- [ ] **Step 1: 添加 list_analysis 方法**
|
||||
|
||||
在 `AnalysisService` impl 中追加:
|
||||
|
||||
```rust
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
pub async fn list_analysis(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Option<Uuid>,
|
||||
analysis_type: Option<String>,
|
||||
pagination: Pagination,
|
||||
) -> AiResult<(Vec<ai_analysis::Model>, u64)> {
|
||||
let mut query = ai_analysis::Entity::find()
|
||||
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_analysis::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(pid) = patient_id {
|
||||
query = query.filter(ai_analysis::Column::PatientId.eq(pid));
|
||||
}
|
||||
if let Some(at) = analysis_type {
|
||||
query = query.filter(ai_analysis::Column::AnalysisType.eq(at));
|
||||
}
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let items = query
|
||||
.order_by_desc(ai_analysis::Column::CreatedAt)
|
||||
.offset(pagination.offset())
|
||||
.limit(pagination.limit())
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
Ok((items, total))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 get_analysis 方法**
|
||||
|
||||
```rust
|
||||
pub async fn get_analysis(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_analysis::Model> {
|
||||
ai_analysis::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| AiError::AnalysisNotFound(id.to_string()))
|
||||
}
|
||||
```
|
||||
|
||||
注意:上面 filter 需改写为 match 式:
|
||||
|
||||
```rust
|
||||
pub async fn get_analysis(
|
||||
&self,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<ai_analysis::Model> {
|
||||
let model = ai_analysis::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AiError::AnalysisNotFound(id.to_string()))?;
|
||||
if model.tenant_id != tenant_id {
|
||||
return Err(AiError::AnalysisNotFound(id.to_string()));
|
||||
}
|
||||
Ok(model)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译验证**
|
||||
|
||||
Run: `cargo check -p erp-ai`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-ai/src/service/analysis.rs
|
||||
git commit -m "feat(ai): AnalysisService 补全 list/get 查询方法"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: UsageService — 补全聚合查询方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-ai/src/service/usage.rs`
|
||||
|
||||
**现状:** 仅有 `log_usage`。需新增 `get_overview`、`get_trend`、`get_by_type`。
|
||||
|
||||
**注意:** `ai_usage_logs` 表无 `created_by` 字段,用户排行从 `ai_analysis_results.created_by` 聚合。
|
||||
|
||||
- [ ] **Step 1: 添加 get_overview 方法**
|
||||
|
||||
```rust
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, FromQueryResult, QuerySelect, Func};
|
||||
use crate::entity::ai_analysis;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
pub struct UsageOverview {
|
||||
pub total_count: i64,
|
||||
pub total_input_tokens: i64,
|
||||
pub total_output_tokens: i64,
|
||||
}
|
||||
|
||||
pub async fn get_overview(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<UsageOverview> {
|
||||
let result = ai_analysis::Entity::find()
|
||||
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_analysis::Column::Status.eq("completed"))
|
||||
.filter(ai_analysis::Column::DeletedAt.is_null())
|
||||
.select_only()
|
||||
.column_as(ai_analysis::Column::Id.count(), "total_count")
|
||||
.into_model::<UsageOverview>()
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.unwrap_or(UsageOverview {
|
||||
total_count: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
});
|
||||
Ok(result)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 get_by_type 方法**
|
||||
|
||||
```rust
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
pub struct TypeCount {
|
||||
pub analysis_type: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
pub async fn get_by_type(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
) -> AiResult<Vec<TypeCount>> {
|
||||
let result = ai_analysis::Entity::find()
|
||||
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_analysis::Column::Status.eq("completed"))
|
||||
.filter(ai_analysis::Column::DeletedAt.is_null())
|
||||
.select_only()
|
||||
.column(ai_analysis::Column::AnalysisType)
|
||||
.column_as(ai_analysis::Column::Id.count(), "count")
|
||||
.group_by(ai_analysis::Column::AnalysisType)
|
||||
.into_model::<TypeCount>()
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译验证**
|
||||
|
||||
Run: `cargo check -p erp-ai`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-ai/src/service/usage.rs
|
||||
git commit -m "feat(ai): UsageService 补全 get_overview/get_by_type 聚合方法"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Handler — 补全路由端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-ai/src/handler/mod.rs`
|
||||
- Modify: `crates/erp-ai/src/module.rs` (路由注册)
|
||||
|
||||
**现状:**
|
||||
- 4 个 SSE 端点:可用
|
||||
- `list_analysis` / `get_analysis`:空壳(返回 `ApiResponse::ok(())`)
|
||||
- Prompt CRUD、用量统计:完全缺失
|
||||
|
||||
- [ ] **Step 1: 实现 list_analysis 真实查询**
|
||||
|
||||
替换 `handler/mod.rs` 中的 `list_analysis` 函数(第 272-283 行):
|
||||
|
||||
```rust
|
||||
pub async fn list_analysis<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ListAnalysisQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.list")?;
|
||||
let pagination = erp_core::types::Pagination::new(
|
||||
params.page.unwrap_or(1),
|
||||
params.page_size.unwrap_or(20),
|
||||
);
|
||||
let (items, total) = state.analysis.list_analysis(
|
||||
ctx.tenant_id,
|
||||
params.patient_id,
|
||||
params.analysis_type,
|
||||
pagination,
|
||||
).await?;
|
||||
let data = serde_json::json!({
|
||||
"data": items,
|
||||
"total": total,
|
||||
"page": pagination.page,
|
||||
"page_size": pagination.page_size,
|
||||
});
|
||||
Ok(Json(ApiResponse::ok(data)))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 get_analysis 真实查询**
|
||||
|
||||
替换 `get_analysis` 函数(第 285-296 行):
|
||||
|
||||
```rust
|
||||
pub async fn get_analysis<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<ai_analysis::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.list")?;
|
||||
let analysis = state.analysis.get_analysis(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(analysis)))
|
||||
}
|
||||
```
|
||||
|
||||
需在文件顶部添加 `use crate::entity::ai_analysis;`。
|
||||
|
||||
- [ ] **Step 3: 新增 Prompt CRUD handler 函数**
|
||||
|
||||
在 handler/mod.rs 中添加以下函数(分析历史之后):
|
||||
|
||||
```rust
|
||||
// === Prompt 管理 ===
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListPromptsQuery {
|
||||
pub category: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn list_prompts<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ListPromptsQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.list")?;
|
||||
let pagination = erp_core::types::Pagination::new(
|
||||
params.page.unwrap_or(1),
|
||||
params.page_size.unwrap_or(20),
|
||||
);
|
||||
let (items, total) = state.prompt.list_prompts(
|
||||
ctx.tenant_id, params.category, pagination,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": items, "total": total,
|
||||
"page": pagination.page, "page_size": pagination.page_size,
|
||||
}))))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatePromptBody {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub system_prompt: String,
|
||||
pub user_prompt_template: String,
|
||||
pub model_config: serde_json::Value,
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
pub async fn create_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<CreatePromptBody>,
|
||||
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state.prompt.create_prompt(
|
||||
ctx.tenant_id, ctx.user_id,
|
||||
body.name, body.system_prompt, body.user_prompt_template,
|
||||
body.model_config, body.category,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
pub async fn activate_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state.prompt.activate_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
|
||||
pub async fn rollback_prompt<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<ai_prompt::Model>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.prompt.manage")?;
|
||||
let prompt = state.prompt.rollback_prompt(id, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(prompt)))
|
||||
}
|
||||
```
|
||||
|
||||
需在文件顶部添加 `use crate::entity::ai_prompt;`。
|
||||
|
||||
- [ ] **Step 4: 新增用量统计 handler 函数**
|
||||
|
||||
```rust
|
||||
// === 用量统计 ===
|
||||
|
||||
pub async fn usage_overview<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.usage.list")?;
|
||||
let overview = state.usage.get_overview(ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"total_count": overview.total_count,
|
||||
}))))
|
||||
}
|
||||
|
||||
pub async fn usage_by_type<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.usage.list")?;
|
||||
let types = state.usage.get_by_type(ctx.tenant_id).await?;
|
||||
let result: Vec<serde_json::Value> = types.into_iter().map(|t| {
|
||||
serde_json::json!({
|
||||
"analysis_type": t.analysis_type,
|
||||
"count": t.count,
|
||||
})
|
||||
}).collect();
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在 module.rs 注册新路由**
|
||||
|
||||
修改 `AiModule::protected_routes`,在现有路由后追加:
|
||||
|
||||
```rust
|
||||
.route("/ai/prompts", axum::routing::get(crate::handler::list_prompts))
|
||||
.route("/ai/prompts", axum::routing::post(crate::handler::create_prompt))
|
||||
.route("/ai/prompts/{id}/activate", axum::routing::post(crate::handler::activate_prompt))
|
||||
.route("/ai/prompts/{id}/rollback", axum::routing::post(crate::handler::rollback_prompt))
|
||||
.route("/ai/usage/overview", axum::routing::get(crate::handler::usage_overview))
|
||||
.route("/ai/usage/by-type", axum::routing::get(crate::handler::usage_by_type))
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 编译验证**
|
||||
|
||||
Run: `cargo check -p erp-ai && cargo check -p erp-server`
|
||||
Expected: 编译通过
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-ai/src/handler/mod.rs crates/erp-ai/src/module.rs
|
||||
git commit -m "feat(ai): 补全 Prompt CRUD + 分析历史 + 用量统计 handler 和路由"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 前端 API 封装
|
||||
|
||||
### Task 5: 创建 AI API service 文件
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/api/ai/prompts.ts`
|
||||
- Create: `apps/web/src/api/ai/analysis.ts`
|
||||
- Create: `apps/web/src/api/ai/usage.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 prompts.ts**
|
||||
|
||||
`apps/web/src/api/ai/prompts.ts`:
|
||||
|
||||
```typescript
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
export interface PromptItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
model_config: Record<string, unknown>;
|
||||
version: number;
|
||||
is_active: boolean;
|
||||
category: string;
|
||||
tags: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreatePromptReq {
|
||||
name: string;
|
||||
description?: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
model_config: Record<string, unknown>;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export const promptApi = {
|
||||
list: async (params?: { category?: string; page?: number; page_size?: number }) => {
|
||||
const resp = await client.get('/ai/prompts', { params });
|
||||
return resp.data.data as PaginatedResponse<PromptItem>;
|
||||
},
|
||||
create: async (data: CreatePromptReq) => {
|
||||
const resp = await client.post('/ai/prompts', data);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
activate: async (id: string) => {
|
||||
const resp = await client.post(`/ai/prompts/${id}/activate`);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
rollback: async (id: string) => {
|
||||
const resp = await client.post(`/ai/prompts/${id}/rollback`);
|
||||
return resp.data.data as PromptItem;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 analysis.ts**
|
||||
|
||||
`apps/web/src/api/ai/analysis.ts`:
|
||||
|
||||
```typescript
|
||||
import client from '../client';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
export interface AnalysisItem {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
analysis_type: string;
|
||||
source_ref: string;
|
||||
model_used: string;
|
||||
status: string;
|
||||
result_content: string | null;
|
||||
result_metadata: Record<string, unknown> | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const analysisApi = {
|
||||
list: async (params?: { patient_id?: string; analysis_type?: string; page?: number; page_size?: number }) => {
|
||||
const resp = await client.get('/ai/analysis/history', { params });
|
||||
return resp.data.data as PaginatedResponse<AnalysisItem>;
|
||||
},
|
||||
get: async (id: string) => {
|
||||
const resp = await client.get(`/ai/analysis/${id}`);
|
||||
return resp.data.data as AnalysisItem;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 创建 usage.ts**
|
||||
|
||||
`apps/web/src/api/ai/usage.ts`:
|
||||
|
||||
```typescript
|
||||
import client from '../client';
|
||||
|
||||
export interface UsageOverview {
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface TypeDistribution {
|
||||
analysis_type: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const usageApi = {
|
||||
overview: async () => {
|
||||
const resp = await client.get('/ai/usage/overview');
|
||||
return resp.data.data as UsageOverview;
|
||||
},
|
||||
byType: async () => {
|
||||
const resp = await client.get('/ai/usage/by-type');
|
||||
return resp.data.data as TypeDistribution[];
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
Run: `cd apps/web && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/web/src/api/ai/
|
||||
git commit -m "feat(web): AI API 前端封装 — prompts/analysis/usage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 前端管理页面
|
||||
|
||||
### Task 6: AI Prompt 管理页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/health/AiPromptList.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 AiPromptList 页面**
|
||||
|
||||
使用 Ant Design Table + Modal 模式(参考 PatientList.tsx 结构):
|
||||
|
||||
核心功能:
|
||||
- 表格列:名称 / 类别 / 版本 / 状态(active/inactive) / 更新时间
|
||||
- 新建 Prompt 按钮 → Modal 表单
|
||||
- 操作列:激活 / 回滚
|
||||
- AuthButton 权限控制(`ai.prompt.manage`)
|
||||
|
||||
页面大致结构(骨架,实现时根据 Ant Design 6 API 细化):
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Table, Button, Space, Modal, Form, Input, Select, Tag, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { promptApi, type PromptItem, type CreatePromptReq } from '../../api/ai/prompts';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'lab_report_interpretation', label: '化验单解读' },
|
||||
{ value: 'health_trend_analysis', label: '趋势分析' },
|
||||
{ value: 'personalized_checkup_plan', label: '体检方案' },
|
||||
{ value: 'report_summary_generation', label: '报告摘要' },
|
||||
];
|
||||
|
||||
export default function AiPromptList() {
|
||||
// ... useState, fetchPrompts, columns 定义
|
||||
// 新建/激活/回滚按钮用 <AuthButton code="ai.prompt.manage"> 包裹
|
||||
// 表格渲染 + Modal 表单
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/AiPromptList.tsx
|
||||
git commit -m "feat(web): AI Prompt 管理页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: AI 分析历史页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/health/AiAnalysisList.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 AiAnalysisList 页面**
|
||||
|
||||
核心功能:
|
||||
- 表格列:分析类型 / 患者 ID / 状态(streaming/completed/failed) / 模型 / 创建时间
|
||||
- 状态 Tag:completed=绿色, failed=红色, streaming=蓝色
|
||||
- 详情查看:点击行展开,显示 result_content(Markdown 渲染)
|
||||
- 筛选:分析类型下拉 + 时间范围
|
||||
- AuthButton 权限控制(`ai.analysis.list`)
|
||||
|
||||
- [ ] **Step 2: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/AiAnalysisList.tsx
|
||||
git commit -m "feat(web): AI 分析历史页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: AI 用量统计页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/pages/health/AiUsageDashboard.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 AiUsageDashboard 页面**
|
||||
|
||||
核心功能:
|
||||
- 顶部 StatCard:总分析次数
|
||||
- 饼图:分析类型分布(使用 Ant Design Charts Pie)
|
||||
- AuthButton 权限控制(`ai.usage.list`)
|
||||
|
||||
- [ ] **Step 2: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/pages/health/AiUsageDashboard.tsx
|
||||
git commit -m "feat(web): AI 用量统计页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 菜单注册 + 路由配置
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/src/layouts/MainLayout.tsx`
|
||||
- Modify: `apps/web/src/App.tsx`
|
||||
|
||||
- [ ] **Step 1: 在 MainLayout 添加菜单项**
|
||||
|
||||
在 `healthMenuItems` 数组中追加 3 项:
|
||||
|
||||
```typescript
|
||||
{ key: '/health/ai-prompts', label: 'AI Prompt 管理', icon: ... },
|
||||
{ key: '/health/ai-analysis', label: 'AI 分析历史', icon: ... },
|
||||
{ key: '/health/ai-usage', label: 'AI 用量统计', icon: ... },
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 App.tsx 添加路由**
|
||||
|
||||
在健康模块路由区域追加:
|
||||
|
||||
```tsx
|
||||
<Route path="/health/ai-prompts" element={<AiPromptList />} />
|
||||
<Route path="/health/ai-analysis" element={<AiAnalysisList />} />
|
||||
<Route path="/health/ai-usage" element={<AiUsageDashboard />} />
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/web && npx tsc --noEmit
|
||||
git add apps/web/src/layouts/MainLayout.tsx apps/web/src/App.tsx
|
||||
git commit -m "feat(web): AI 管理端菜单注册 + 路由配置"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 集成验证
|
||||
|
||||
- [ ] **Step 1: 后端编译 + 启动**
|
||||
|
||||
Run: `cargo check --workspace && cd crates/erp-server && cargo run`
|
||||
|
||||
验证:
|
||||
- `/api/v1/ai/prompts` 返回空列表(200)
|
||||
- `/api/v1/ai/analysis/history` 返回空列表(200)
|
||||
- `/api/v1/ai/usage/overview` 返回 0 计数(200)
|
||||
|
||||
- [ ] **Step 2: 前端编译 + 启动**
|
||||
|
||||
Run: `cd apps/web && pnpm build && pnpm dev`
|
||||
|
||||
验证:
|
||||
- 3 个新页面在菜单中可见
|
||||
- 页面正常加载,无白屏/报错
|
||||
- Prompt 列表为空时显示空状态
|
||||
- 分析历史列表为空时显示空状态
|
||||
|
||||
- [ ] **Step 3: 生产构建**
|
||||
|
||||
Run: `cd apps/web && pnpm build`
|
||||
Expected: 构建成功
|
||||
|
||||
- [ ] **Step 4: 推送**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
@@ -0,0 +1,539 @@
|
||||
# 切片 3: 小程序 AI 报告查看 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 患者可在微信小程序查看 AI 分析报告(只读),从首页入口进入列表页,点击查看详情。
|
||||
|
||||
**Architecture:** 后端 `list_analysis` / `get_analysis` 端点在切片 2 中已补全。小程序复用现有 `services/request.ts` 的 `api` 封装,新增 `services/ai-analysis.ts` 调用后端 API。新增 2 个页面(列表 + 详情),在首页添加入口卡片。
|
||||
|
||||
**Tech Stack:** Taro 4.2 + React 18 + TypeScript
|
||||
|
||||
**设计规格:** `docs/superpowers/specs/2026-04-25-feature-completion-design.md` §4
|
||||
|
||||
**依赖:** 切片 2 Task 1-4(后端 API 补全)必须先完成
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: API 层 + 页面
|
||||
|
||||
### Task 1: 创建 AI 分析 API service
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/miniprogram/src/services/ai-analysis.ts`
|
||||
|
||||
**参考模式:** `services/report.ts`(化验报告 service)
|
||||
|
||||
- [ ] **Step 1: 创建 service 文件**
|
||||
|
||||
`apps/miniprogram/src/services/ai-analysis.ts`:
|
||||
|
||||
```typescript
|
||||
import { api } from './request';
|
||||
|
||||
export interface AiAnalysisItem {
|
||||
id: string;
|
||||
patient_id: string;
|
||||
analysis_type: string;
|
||||
model_used: string;
|
||||
status: string;
|
||||
result_content: string | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function listAiAnalysis(page = 1, pageSize = 20) {
|
||||
return api.get<{ data: AiAnalysisItem[]; total: number }>(
|
||||
'/ai/analysis/history',
|
||||
{ page, page_size: pageSize },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAiAnalysisDetail(id: string) {
|
||||
return api.get<AiAnalysisItem>(`/ai/analysis/${id}`);
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** 后端 `list_analysis` 会根据 JWT 中的 `user_id` → `patient_id` 自动过滤(小程序端通过 `X-Patient-Id` header 传递)。若后端未自动过滤,需在请求参数中传 `patient_id`。
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
Run: `cd apps/miniprogram && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/miniprogram/src/services/ai-analysis.ts
|
||||
git commit -m "feat(miniprogram): AI 分析 API service"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: AI 报告列表页
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/miniprogram/src/pages/ai-report/list/index.tsx`
|
||||
- Create: `apps/miniprogram/src/pages/ai-report/list/index.scss`
|
||||
|
||||
**参考模式:** `pages/report/detail/index.tsx` + `pages/article/index.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建列表页组件**
|
||||
|
||||
`apps/miniprogram/src/pages/ai-report/list/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { listAiAnalysis, type AiAnalysisItem } from '@/services/ai-analysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
lab_report: '化验单解读',
|
||||
trend: '趋势分析',
|
||||
checkup_plan: '体检方案',
|
||||
report_summary: '报告摘要',
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, { text: string; className: string }> = {
|
||||
completed: { text: '已完成', className: 'status-completed' },
|
||||
streaming: { text: '分析中', className: 'status-streaming' },
|
||||
failed: { text: '失败', className: 'status-failed' },
|
||||
};
|
||||
|
||||
export default function AiReportList() {
|
||||
const [list, setList] = useState<AiAnalysisItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadList(1);
|
||||
}, []);
|
||||
|
||||
const loadList = async (p: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listAiAnalysis(p, 20);
|
||||
const items = res.data || [];
|
||||
setList(p === 1 ? items : [...list, ...items]);
|
||||
setPage(p);
|
||||
setHasMore(items.length >= 20);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goDetail = (id: string) => {
|
||||
Taro.navigateTo({ url: `/pages/ai-report/detail/index?id=${id}` });
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
if (hasMore && !loading) loadList(page + 1);
|
||||
};
|
||||
|
||||
if (loading && list.length === 0) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<View className='ai-report-page'>
|
||||
<EmptyState text='暂无 AI 分析报告' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='ai-report-page'>
|
||||
<View className='page-title'>AI 分析报告</View>
|
||||
<ScrollView scrollY className='report-scroll' onScrollToLower={loadMore}>
|
||||
{list.map((item) => {
|
||||
const statusInfo = STATUS_MAP[item.status] || { text: item.status, className: '' };
|
||||
return (
|
||||
<View
|
||||
key={item.id}
|
||||
className='report-card'
|
||||
onClick={() => item.status === 'completed' && goDetail(item.id)}
|
||||
>
|
||||
<View className='card-header'>
|
||||
<Text className='card-type'>{TYPE_LABELS[item.analysis_type] || item.analysis_type}</Text>
|
||||
<Text className={`card-status ${statusInfo.className}`}>{statusInfo.text}</Text>
|
||||
</View>
|
||||
<View className='card-footer'>
|
||||
<Text className='card-time'>{new Date(item.created_at).toLocaleString('zh-CN')}</Text>
|
||||
<Text className='card-model'>{item.model_used}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{loading && <Loading />}
|
||||
{!hasMore && list.length > 0 && <Text className='no-more'>没有更多了</Text>}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建列表页样式**
|
||||
|
||||
`apps/miniprogram/src/pages/ai-report/list/index.scss`:
|
||||
|
||||
```scss
|
||||
.ai-report-page {
|
||||
min-height: 100vh;
|
||||
background: #f1f5f9;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.report-scroll {
|
||||
height: calc(100vh - 80px);
|
||||
}
|
||||
|
||||
.report-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-type {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.card-status {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
color: #16a34a;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.status-streaming {
|
||||
color: #2563eb;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
color: #dc2626;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-time {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.card-model {
|
||||
font-size: 11px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
padding: 16px 0;
|
||||
display: block;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
Run: `cd apps/miniprogram && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/miniprogram/src/pages/ai-report/list/
|
||||
git commit -m "feat(miniprogram): AI 报告列表页"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: AI 报告详情页
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/miniprogram/src/pages/ai-report/detail/index.tsx`
|
||||
- Create: `apps/miniprogram/src/pages/ai-report/detail/index.scss`
|
||||
|
||||
- [ ] **Step 1: 创建详情页组件**
|
||||
|
||||
`apps/miniprogram/src/pages/ai-report/detail/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, RichText } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { getAiAnalysisDetail, type AiAnalysisItem } from '@/services/ai-analysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
lab_report: '化验单解读',
|
||||
trend: '趋势分析',
|
||||
checkup_plan: '体检方案',
|
||||
report_summary: '报告摘要',
|
||||
};
|
||||
|
||||
function markdownToHtml(md: string): string {
|
||||
return md
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
||||
.replace(/\n\n/g, '<br/><br/>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
}
|
||||
|
||||
export default function AiReportDetail() {
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
|
||||
const [analysis, setAnalysis] = useState<AiAnalysisItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
getAiAnalysisDetail(id)
|
||||
.then((data) => setAnalysis(data))
|
||||
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
if (!analysis) {
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<Text className='empty-text'>报告不存在</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const htmlContent = analysis.result_content
|
||||
? markdownToHtml(analysis.result_content)
|
||||
: '<p>暂无分析结果</p>';
|
||||
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
<View className='detail-card'>
|
||||
<Text className='detail-type'>{TYPE_LABELS[analysis.analysis_type] || analysis.analysis_type}</Text>
|
||||
<View className='detail-meta'>
|
||||
<Text className='meta-item'>模型: {analysis.model_used}</Text>
|
||||
<Text className='meta-item'>{new Date(analysis.created_at).toLocaleString('zh-CN')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='content-card'>
|
||||
<RichText className='report-content' nodes={htmlContent} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建详情页样式**
|
||||
|
||||
`apps/miniprogram/src/pages/ai-report/detail/index.scss`:
|
||||
|
||||
```scss
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
background: #f1f5f9;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.detail-type {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.report-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证编译**
|
||||
|
||||
Run: `cd apps/miniprogram && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add apps/miniprogram/src/pages/ai-report/detail/
|
||||
git commit -m "feat(miniprogram): AI 报告详情页"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 集成
|
||||
|
||||
### Task 4: 注册页面路由
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/miniprogram/src/app.config.ts`
|
||||
|
||||
- [ ] **Step 1: 在 pages 数组中注册新页面**
|
||||
|
||||
在 `app.config.ts` 的 `pages` 数组中,在 `pages/report/detail/index` 之后添加:
|
||||
|
||||
```typescript
|
||||
'pages/ai-report/list/index',
|
||||
'pages/ai-report/detail/index',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/miniprogram && npx tsc --noEmit
|
||||
git add apps/miniprogram/src/app.config.ts
|
||||
git commit -m "feat(miniprogram): 注册 AI 报告页面路由"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 首页入口卡片
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/miniprogram/src/pages/index/index.tsx`
|
||||
- Modify: `apps/miniprogram/src/pages/index/index.scss`
|
||||
|
||||
- [ ] **Step 1: 在首页添加 AI 报告入口**
|
||||
|
||||
在 `pages/index/index.tsx` 中,找到功能入口区域,添加 AI 报告卡片。
|
||||
|
||||
在页面 JSX 中添加一个导航卡片:
|
||||
|
||||
```tsx
|
||||
<View
|
||||
className='feature-card ai-card'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/ai-report/list/index' })}
|
||||
>
|
||||
<Text className='feature-icon'>🤖</Text>
|
||||
<Text className='feature-title'>AI 分析报告</Text>
|
||||
<Text className='feature-desc'>查看智能健康分析结果</Text>
|
||||
</View>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加入口卡片样式(如需要)**
|
||||
|
||||
在 `pages/index/index.scss` 中,根据现有功能卡片样式添加 `.ai-card` 样式(如果现有的 `.feature-card` 已足够,可跳过)。
|
||||
|
||||
- [ ] **Step 3: 验证编译 + 提交**
|
||||
|
||||
```bash
|
||||
cd apps/miniprogram && npx tsc --noEmit
|
||||
git add apps/miniprogram/src/pages/index/
|
||||
git commit -m "feat(miniprogram): 首页添加 AI 报告入口卡片"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 集成验证
|
||||
|
||||
- [ ] **Step 1: 编译检查**
|
||||
|
||||
Run: `cd apps/miniprogram && npx tsc --noEmit`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 2: 启动后端服务**
|
||||
|
||||
Run: `cd crates/erp-server && cargo run`
|
||||
|
||||
确保 `/api/v1/ai/analysis/history` 和 `/api/v1/ai/analysis/{id}` 端点可用。
|
||||
|
||||
- [ ] **Step 3: 小程序编译**
|
||||
|
||||
Run: `cd apps/miniprogram && pnpm build:weapp`
|
||||
|
||||
Expected: 编译成功
|
||||
|
||||
- [ ] **Step 4: 功能验证(微信开发者工具)**
|
||||
|
||||
1. 登录小程序(绑定有 AI 分析记录的患者)
|
||||
2. 首页可见"AI 分析报告"入口卡片
|
||||
3. 点击进入列表页 → 显示该患者的 AI 分析记录
|
||||
4. 点击一条已完成的记录 → 进入详情页,Markdown 内容正常渲染
|
||||
5. 无记录时显示空状态
|
||||
6. 失败记录显示"失败"标签,不可点击进入详情
|
||||
|
||||
- [ ] **Step 5: 推送**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
@@ -0,0 +1,106 @@
|
||||
# 事件驱动架构增强实施计划
|
||||
|
||||
> 设计规格: `docs/superpowers/specs/2026-04-26-event-driven-architecture-design.md`
|
||||
> 日期: 2026-04-26 | 状态: draft | 总周期: 2 周
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 高优先级事件补发(Week 1)
|
||||
|
||||
### Task 1: dialysis_service 添加 dialysis_record.created/reviewed 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/dialysis_service.rs`
|
||||
|
||||
**步骤**: `create_dialysis_record()` 成功后发布 `dialysis_record.created`(data: patient_id, dialysis_type, status, dialysis_date, duration, ultrafiltration_volume)。审核状态变更时发布 `dialysis_record.reviewed`(data: patient_id, reviewer_id, complication_notes)。payload 遵循统一信封(schema_version: "v1")。发布失败仅 warn 不阻断业务。
|
||||
|
||||
**验收**: 创建/审核后 domain_events 表出现对应事件;`cargo test` 通过。
|
||||
|
||||
### Task 2: diagnosis_service 添加 diagnosis.created/updated 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/diagnosis_service.rs`
|
||||
|
||||
**步骤**: `create_diagnosis()` 后发布 `diagnosis.created`(data: patient_id, icd_code, diagnosis_name, severity)。`update_diagnosis()` 后发布 `diagnosis.updated`,计算变更 diff(changed_fields[], old_values{}, new_values{})。
|
||||
|
||||
**验收**: diagnosis.updated 事件 data 含 changed_fields 差异;`cargo test` 通过。
|
||||
|
||||
### Task 3: consent_service 添加 consent.granted/revoked 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/consent_service.rs`
|
||||
|
||||
**步骤**: 签署时发布 `consent.granted`(data: patient_id, consent_type, consent_scope, granted_by, expires_at)。撤销时发布 `consent.revoked`(data: patient_id, consent_type, revoked_by, reason)。
|
||||
|
||||
**验收**: 签署/撤销后 domain_events 表出现事件;`cargo test` 通过。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 中低优先级事件 + Outbox 优化(Week 2)
|
||||
|
||||
### Task 4: points_service 添加 points.earned/exchanged 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/points_service.rs`
|
||||
|
||||
**步骤**: earn 成功后发布 `points.earned`(data: patient_id, points, source_type, balance_after)。exchange 成功后发布 `points.exchanged`(data: patient_id, points, product_name, order_id, balance_after)。确保在事务提交后发布。
|
||||
|
||||
**验收**: 积分变动后 domain_events 出现事件;balance_after 正确反映余额。
|
||||
|
||||
### Task 5: article_service 添加 article.published/rejected 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/article_service.rs`
|
||||
|
||||
**步骤**: 审核通过发布 `article.published`(data: title, author_id, category_id, tags[])。审核驳回发布 `article.rejected`(data: title, reviewer_id, reason)。
|
||||
|
||||
**验收**: 审核操作后 domain_events 出现事件;`cargo test` 通过。
|
||||
|
||||
### Task 6: daily_monitoring_service 添加 daily_monitoring.created 事件
|
||||
|
||||
**涉及文件**: `crates/erp-health/src/service/daily_monitoring_service.rs`
|
||||
|
||||
**步骤**: 记录创建后发布 `daily_monitoring.created`(data: patient_id, monitoring_date, monitoring_type, values{})。
|
||||
|
||||
**验收**: 创建记录后 domain_events 出现事件;`cargo test` 通过。
|
||||
|
||||
### Task 7: Outbox relay 从轮询改为 LISTEN/NOTIFY
|
||||
|
||||
**涉及文件**: `crates/erp-server/src/outbox.rs`, `crates/erp-core/src/events.rs`
|
||||
|
||||
**步骤**: `EventBus::publish()` 持久化后执行 `NOTIFY outbox_channel, '<event_id>'`。outbox relay 用 `sqlx::PgListener` 监听 + `tokio::select!`(LISTEN 触发 + 30s 兜底轮询)。保留 `process_pending_events()` 不变,仅改变触发方式。PgListener 添加断线自动重连。
|
||||
|
||||
**验收**: 事件延迟 < 100ms;DB 轮询频率从 5s 降为 30s 兜底;`cargo test --workspace` 通过。
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 事件 schema 版本化 + 清理(Week 2)
|
||||
|
||||
### Task 8: 事件 payload 添加 schema_version 字段
|
||||
|
||||
**涉及文件**: `crates/erp-core/src/events.rs`, `crates/erp-health/src/service/` 下所有发布事件的 service
|
||||
|
||||
**步骤**: 在 erp-core 创建 `build_event_payload()` 辅助函数,自动填充 schema_version/timestamp/metadata。逐个 service(14 个模块)替换手动构建为调用辅助函数,统一信封格式。
|
||||
|
||||
**验收**: 所有事件 payload 含 schema_version 字段;`cargo test --workspace` 通过。
|
||||
|
||||
### Task 9: Outbox 表分区或定期清理策略
|
||||
|
||||
**涉及文件**: `migration/src/m000075_domain_events_cleanup.rs`(新增), `erp-server/src/tasks/events_cleanup.rs`(新增)
|
||||
|
||||
**步骤**: 迁移创建 `domain_events_archive` 表,添加 `cleanup_old_published_events()` SQL 函数(>90 天 published 事件迁移到归档表)。后台任务每日执行清理。归档表只读防篡改。
|
||||
|
||||
**验收**: 清理任务正确迁移 >90 天事件;`cargo test` 通过。
|
||||
|
||||
### Task 10: 消费者幂等性(dedup key 检查)
|
||||
|
||||
**涉及文件**: `migration/src/m000076_processed_events.rs`(新增), `crates/erp-core/src/events.rs`
|
||||
|
||||
**步骤**: 迁移创建 `processed_events` 表(event_id + consumer_id 联合主键 + processed_at)。erp-core 添加 `is_processed()` / `mark_processed()` 辅助函数。消费者模式:收到事件 -> 查已处理 -> 跳过或执行 -> 插入记录。添加 7 天 TTL 清理任务。
|
||||
|
||||
**验收**: 重复消费同一事件时第二次被跳过;`cargo test --workspace` 通过。
|
||||
|
||||
---
|
||||
|
||||
## 执行原则
|
||||
|
||||
1. **每 Task 完成后立即提交** — 不积压
|
||||
2. **Phase 1 优先** — P0 事件(透析/诊断)是核心医疗流程
|
||||
3. **事件发布不阻断业务** — publish 失败仅 warn,Outbox relay 兜底
|
||||
4. **统一信封格式** — 使用 `build_event_payload` 保证一致性
|
||||
5. **LISTEN/NOTIFY 保留兜底轮询** — 30s 轮询防 NOTIFY 丢失
|
||||
@@ -0,0 +1,290 @@
|
||||
# 前端工程化改进实施计划
|
||||
|
||||
> 设计规格: `docs/superpowers/specs/2026-04-26-frontend-engineering-design.md`
|
||||
> 日期: 2026-04-26 | 状态: draft | 总周期: 7 天
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 重复模式统一(Day 1-2)
|
||||
|
||||
### Task 1: 增强 useApiRequest hook,统一错误处理
|
||||
|
||||
**目标**: 补齐 loading 状态,消除组件内联 `catch (err) { message.error(...) }` 模式。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/hooks/useApiRequest.ts`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 在 `useApiRequest` 返回值中新增 `loading: boolean` 状态:
|
||||
```typescript
|
||||
interface UseApiRequestReturn {
|
||||
execute: <T>(fn: () => Promise<T>, successMsg?: string) => Promise<T | null>;
|
||||
loading: boolean;
|
||||
}
|
||||
```
|
||||
2. `execute` 内部在调用前 `setLoading(true)`,finally 中 `setLoading(false)`
|
||||
3. 保持现有调用点无需修改 — 返回值是对象解构,新增字段不影响旧代码
|
||||
4. 选取 3 个健康模块页面(PatientList、AppointmentList、FollowUpTaskList)迁移为使用 `execute` + `loading`
|
||||
|
||||
**验收标准**:
|
||||
- `pnpm build` 通过
|
||||
- 3 个迁移页面的 catch 块不再有内联 `message.error`,统一走 `handleApiError`
|
||||
- loading 状态正确绑定到页面按钮/Spin 组件
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 增强 usePaginatedData hook,健康模块页面迁移
|
||||
|
||||
**目标**: 支持泛型筛选参数,迁移 6 个健康列表页使用统一 hook。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/hooks/usePaginatedData.ts`
|
||||
- 修改: `apps/web/src/pages/health/PatientList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/OfflineEventList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/PointsProductList.tsx`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 增强 hook 签名为泛型筛选:
|
||||
```typescript
|
||||
function usePaginatedData<T, F = string>(
|
||||
fetchFn: (page: number, pageSize: number, filters: F) => Promise<{ data: T[]; total: number }>,
|
||||
options?: { pageSize?: number; defaultFilters: F; autoFetch?: boolean }
|
||||
): { data, total, page, loading, filters, setFilters, refresh }
|
||||
```
|
||||
2. 函数重载保持旧 `(fetchFn, pageSize?)` 签名兼容
|
||||
3. 新增 `filters` / `setFilters` 状态,`fetchFn` 调用时传入当前 filters
|
||||
4. 迁移 PatientList(按 status/name/gender 筛选)和 OfflineEventList(按 status/dateRange 筛选)
|
||||
|
||||
**验收标准**:
|
||||
- 旧调用点(不传 filters)行为不变
|
||||
- PatientList 和 OfflineEventList 筛选功能正常,代码行数各减少 15-25 行
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 移除 nameCache,统一用 useHealthStore
|
||||
|
||||
**目标**: 消除 AppointmentList 和 PointsOrderList 自建的 `useState<Record<string, string>>` nameCache。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/stores/health.ts`
|
||||
- 修改: `apps/web/src/pages/health/AppointmentList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/PointsOrderList.tsx`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 在 `useHealthStore` 新增批量解析方法:
|
||||
- `batchResolvePatientNames(ids: string[]): Promise<Record<string, string>>`
|
||||
- `batchResolveDoctorNames(ids: string[]): Promise<Record<string, string>>`
|
||||
2. 内部实现:去重 → 过滤已缓存 → 并发加载(限制 5 并发)→ 写入缓存并返回
|
||||
3. 在 AppointmentList 中移除 nameCache state,改用 store 方法
|
||||
4. 在 PointsOrderList 中同样迁移
|
||||
|
||||
**验收标准**:
|
||||
- 两个页面无 `useState<Record<string, string>>` nameCache 代码
|
||||
- 患者姓名/医生姓名在列表中正确显示
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 大组件拆分(Day 3-5)
|
||||
|
||||
### Task 4: PluginCRUDPage 拆分为 CRUDTable/CRUDForm/DetailDrawer/ImportExport
|
||||
|
||||
**目标**: 将 872 行的 PluginCRUDPage.tsx 拆为容器 + 展示组件。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/src/pages/plugins/components/CRUDTable.tsx` (~150 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/CRUDForm.tsx` (~180 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/DetailDrawer.tsx` (~80 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/ImportExport.tsx` (~100 行)
|
||||
- 新增: `apps/web/src/pages/plugins/hooks/usePluginData.ts` (~120 行)
|
||||
- 修改: `apps/web/src/pages/plugins/PluginCRUDPage.tsx` (缩减至 ~80 行)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 创建 `hooks/usePluginData.ts`:提取 CRUD 操作、导入导出逻辑、Drawer 可见性状态
|
||||
2. 创建 `CRUDTable.tsx`:表格列定义 + 行操作按钮,props 接收 data/onDelete/onEdit/onDetail
|
||||
3. 创建 `CRUDForm.tsx`:新增/编辑表单 + Drawer,包含校验规则
|
||||
4. 创建 `DetailDrawer.tsx`:详情展示 + 操作历史 Timeline
|
||||
5. 创建 `ImportExport.tsx`:导入面板 + 导出按钮
|
||||
6. 改写 `PluginCRUDPage.tsx` 为容器组件:调用 usePluginData hook,组装子组件
|
||||
|
||||
**验收标准**:
|
||||
- `pnpm build` 通过
|
||||
- 插件 CRUD 所有功能正常(新增、编辑、删除、详情、导入、导出)
|
||||
- PluginCRUDPage.tsx <= 100 行,无子组件超过 200 行
|
||||
|
||||
---
|
||||
|
||||
### Task 5: PluginGraphPage 抽取 useGraphCanvas hook
|
||||
|
||||
**目标**: 将 759 行的 PluginGraphPage.tsx 拆为 hook + 展示组件。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/src/pages/plugins/hooks/useGraphLayout.ts` (~100 行)
|
||||
- 新增: `apps/web/src/pages/plugins/hooks/useGraphData.ts` (~80 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/GraphCanvas.tsx` (~200 行)
|
||||
- 新增: `apps/web/src/pages/plugins/components/GraphToolbar.tsx` (~60 行)
|
||||
- 修改: `apps/web/src/pages/plugins/PluginGraphPage.tsx` (缩减至 ~60 行)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. `useGraphData.ts`:数据加载、边/节点格式转换、字段映射
|
||||
2. `useGraphLayout.ts`:Dagre/elkjs 布局算法、节点位置计算、自动布局触发
|
||||
3. `GraphCanvas.tsx`:ReactFlow 渲染、自定义节点样式、拖拽交互
|
||||
4. `GraphToolbar.tsx`:缩放控制、自动布局、布局方向切换
|
||||
5. 容器组件组装以上模块
|
||||
|
||||
**验收标准**:
|
||||
- 插件关系图页面正常渲染和交互
|
||||
- 拖拽节点、自动布局、缩放功能正常
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Organizations.tsx 抽象 TreeEntityManager
|
||||
|
||||
**目标**: 将 622 行的 Organizations.tsx 按三层模式拆分。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/src/pages/system/hooks/useOrgTree.ts` (~80 行)
|
||||
- 新增: `apps/web/src/pages/system/components/OrgTree.tsx` (~120 行)
|
||||
- 新增: `apps/web/src/pages/system/components/OrgDetail.tsx` (~150 行)
|
||||
- 新增: `apps/web/src/pages/system/components/DeptMemberList.tsx` (~100 行)
|
||||
- 修改: `apps/web/src/pages/system/Organizations.tsx` (缩减至 ~60 行)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. `useOrgTree.ts`:树数据加载、CRUD 操作、选中节点状态
|
||||
2. `OrgTree.tsx`:左侧树形选择(DirectoryTree + 搜索 + 右键菜单)
|
||||
3. `OrgDetail.tsx`:右侧组织详情/编辑表单
|
||||
4. `DeptMemberList.tsx`:部门成员列表 + 人员分配 Modal
|
||||
5. 容器组件三栏布局组装
|
||||
|
||||
**验收标准**:
|
||||
- 组织管理 CRUD 功能正常(新增/编辑/删除组织、部门、人员分配)
|
||||
- 树形选择、搜索过滤正常
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
### Task 7: StatisticsDashboard 拆分为独立卡片组件
|
||||
|
||||
**目标**: 将 580 行的 StatisticsDashboard.tsx 拆为 hook + 独立图表卡片。
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/src/pages/health/hooks/useStatsData.ts` (~100 行)
|
||||
- 新增: `apps/web/src/pages/health/components/PatientTrendChart.tsx` (~80 行)
|
||||
- 新增: `apps/web/src/pages/health/components/AppointmentStats.tsx` (~80 行)
|
||||
- 新增: `apps/web/src/pages/health/components/OverviewCards.tsx` (~60 行)
|
||||
- 新增: `apps/web/src/pages/health/components/TimeRangeSelector.tsx` (~40 行)
|
||||
- 修改: `apps/web/src/pages/health/StatisticsDashboard.tsx` (缩减至 ~50 行)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. `useStatsData.ts`:五个统计 API 并行加载、loading/error 状态、时间范围变更触发刷新
|
||||
2. `PatientTrendChart.tsx`:患者趋势折线图(@ant-design/charts Line)
|
||||
3. `AppointmentStats.tsx`:预约统计饼图/柱状图
|
||||
4. `OverviewCards.tsx`:概览数字卡片组(Statistic + Card)
|
||||
5. `TimeRangeSelector.tsx`:日期范围选择 + 快捷选项(近7天/近30天/近90天)
|
||||
6. 容器组件组装,布局使用 Row + Col
|
||||
|
||||
**验收标准**:
|
||||
- 统计仪表板页面渲染正常,图表数据正确
|
||||
- 时间范围切换触发数据刷新
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Bundle 优化(Day 6-7)
|
||||
|
||||
### Task 8: vite.config.ts manualChunks 拆分重型依赖
|
||||
|
||||
**目标**: 将 @ant-design/charts、@xyflow/react、@wangeditor/editor 拆为独立 chunk,降低主 chunk 体积。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/vite.config.ts`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 在 `manualChunks` 配置中新增三条规则:
|
||||
```typescript
|
||||
if (id.includes('@ant-design/charts') || id.includes('@antv/')) return 'vendor-charts';
|
||||
if (id.includes('@xyflow/react') || id.includes('@reactflow/')) return 'vendor-flow';
|
||||
if (id.includes('@wangeditor/')) return 'vendor-editor';
|
||||
```
|
||||
2. 对应页面添加路由级 `React.lazy()`:
|
||||
- `StatisticsDashboard` → `lazy(() => import('./health/StatisticsDashboard'))`
|
||||
- `PluginGraphPage` → `lazy(() => import('./plugins/PluginGraphPage'))`
|
||||
- `ArticleEditor` → `lazy(() => import('./health/ArticleEditor'))`
|
||||
3. 将 `chunkSizeWarningLimit` 从 600 降至 500
|
||||
4. 运行 `pnpm build` 对比拆分前后各 chunk 大小
|
||||
|
||||
**验收标准**:
|
||||
- 主 chunk 体积 < 400KB(gzip 前约 600KB 以内)
|
||||
- `vendor-charts`、`vendor-flow`、`vendor-editor` 独立生成
|
||||
- `pnpm build` 无警告
|
||||
- 统计仪表板、插件关系图、文章编辑器页面功能正常(懒加载无闪烁)
|
||||
|
||||
---
|
||||
|
||||
### Task 9: columns 配置 useMemo 化
|
||||
|
||||
**目标**: 消除 PluginCRUDPage 和健康模块列表页的 columns 重复创建,减少不必要的 re-render。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/pages/plugins/components/CRUDTable.tsx`(Phase 2 Task 4 产物)
|
||||
- 修改: `apps/web/src/pages/health/PatientList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/AppointmentList.tsx`
|
||||
- 修改: `apps/web/src/pages/health/FollowUpTaskList.tsx`
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 在每个列表页中,将 `columns` 数组定义包裹在 `useMemo` 中
|
||||
2. 依赖项包含 columns 中引用的回调函数(如 onDelete、onEdit)
|
||||
3. 确保回调函数通过 `useCallback` 缓存,避免 useMemo 失效
|
||||
4. 使用 React DevTools Profiler 验证翻页/筛选时减少不必要渲染
|
||||
|
||||
**验收标准**:
|
||||
- 列表翻页时 Table 组件不因 columns 引用变化触发全量渲染
|
||||
- 所有列表页功能正常(排序、筛选、操作按钮)
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
### Task 10: API 层新代码统一为对象风格
|
||||
|
||||
**目标**: 确认新增 API 文件采用对象风格(`xxxApi.list()` 而非 `listXxx()`),修改已有文件时顺手迁移。
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `apps/web/src/api/health/` 下近期新增的 API 文件(如 `alerts.ts`、`deviceReadings.ts`)
|
||||
|
||||
**详细步骤**:
|
||||
|
||||
1. 审计 `apps/web/src/api/` 下所有文件,标记函数风格的文件清单
|
||||
2. 近期新增的文件(alerts、deviceReadings 等)统一改为对象风格:
|
||||
```typescript
|
||||
export const alertApi = {
|
||||
list: (params) => client.get('/alerts', { params }),
|
||||
acknowledge: (id) => client.post(`/alerts/${id}/acknowledge`),
|
||||
};
|
||||
```
|
||||
3. 更新引用处的 import(页面组件中的调用方式)
|
||||
4. 旧文件不强制迁移,仅记录待迁移清单
|
||||
|
||||
**验收标准**:
|
||||
- `alerts.ts` 和 `deviceReadings.ts` 为对象风格导出
|
||||
- 对应页面功能正常
|
||||
- `pnpm build` 通过
|
||||
|
||||
---
|
||||
|
||||
## 执行原则
|
||||
|
||||
1. **每 Task 完成后立即提交** — 不积压,保持可追溯
|
||||
2. **先基础设施后拆分** — Phase 1 的 hook 增强完成后再做 Phase 2 组件拆分
|
||||
3. **每步验证** — 每个 Task 完成后 `pnpm build` 验证,拆分任务额外验证页面功能
|
||||
4. **渐进迁移** — 重复模式统一采用渐进策略,不一次性全量迁移
|
||||
@@ -0,0 +1,200 @@
|
||||
# 可观测性与运维基础设施实施计划
|
||||
|
||||
> 设计规格: `docs/superpowers/specs/2026-04-26-observability-and-ops-design.md`
|
||||
> 日期: 2026-04-26 | 总周期: 7-9 天
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 健康检查 + Prometheus 指标(Day 1-2)
|
||||
|
||||
### Task 1: 深度健康检查端点
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `crates/erp-server/src/handlers/health.rs`
|
||||
|
||||
**步骤**:
|
||||
1. 拆分为两个端点:
|
||||
- `GET /health/live` — 存活探针(仅返回 `{ status: "ok" }`,不依赖任何外部服务)
|
||||
- `GET /health/ready` — 就绪探针(验证 DB ping + Redis ping + 模块状态)
|
||||
2. `/health/ready` 实现:
|
||||
```rust
|
||||
async fn health_ready(State(state): State<AppState>) -> Json<HealthResponse> {
|
||||
let db_ok = sql_query("SELECT 1").execute(&state.db).await.is_ok();
|
||||
let redis_ok = state.redis.ping().await.is_ok();
|
||||
Json(HealthResponse { status: if db_ok && redis_ok { "ok" } else { "degraded" }, db: db_ok, redis: redis_ok, ... })
|
||||
}
|
||||
```
|
||||
3. 保持旧 `GET /health` 兼容(重定向到 `/health/ready`)
|
||||
|
||||
**验收**: `/health/ready` 在 DB/Redis 正常时返回 200,任一不可达时返回 503 + 降级详情
|
||||
|
||||
### Task 2: Prometheus 指标基础
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `crates/erp-server/Cargo.toml`(添加 `metrics` + `metrics-exporter-prometheus` 依赖)
|
||||
- 新增: `crates/erp-server/src/middleware/metrics.rs`
|
||||
- 修改: `crates/erp-server/src/main.rs`(注册 metrics middleware + 路由)
|
||||
|
||||
**步骤**:
|
||||
1. 在 `Cargo.toml` 添加:
|
||||
```toml
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = "0.16"
|
||||
```
|
||||
2. 创建 `metrics.rs` Axum middleware:
|
||||
- 记录每个请求的 `http_request_duration_seconds`(直方图,按 method/path/status 标签)
|
||||
- 记录 `http_requests_total`(计数器)
|
||||
3. 在 `main.rs` 启动 Prometheus exporter(`/metrics` 端点,端口 9090)
|
||||
4. 在 AppState 中注册 metrics recorder
|
||||
|
||||
**验收**: `curl localhost:9090/metrics` 返回 Prometheus 格式指标,包含请求延迟直方图
|
||||
|
||||
### Task 3: DB 连接池 + EventBus 积压指标
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `crates/erp-server/src/main.rs`
|
||||
- 修改: `crates/erp-core/src/events.rs`
|
||||
|
||||
**步骤**:
|
||||
1. DB 连接池指标:每 30 秒采样 `db_pool_connections_active` / `db_pool_connections_idle`
|
||||
2. EventBus 积压指标:在 `publish()` 中递增 `eventbus_pending_total`,在 relay 处理后递减
|
||||
3. 在 `/metrics` 端点暴露
|
||||
|
||||
**验收**: `/metrics` 包含 DB 连接池使用率和事件积压计数
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: OpenTelemetry + 生产 Docker(Day 3-5)
|
||||
|
||||
### Task 4: OpenTelemetry 条件集成
|
||||
|
||||
**涉及文件**:
|
||||
- 修改: `crates/erp-server/Cargo.toml`(添加 `opentelemetry` + `tracing-opentelemetry` + `opentelemetry-otlp`,optional feature)
|
||||
- 新增: `crates/erp-server/src/telemetry.rs`
|
||||
- 修改: `crates/erp-server/src/main.rs`
|
||||
|
||||
**步骤**:
|
||||
1. 添加 optional 依赖:
|
||||
```toml
|
||||
[features]
|
||||
tracing = ["opentelemetry", "tracing-opentelemetry", "opentelemetry-otlp"]
|
||||
```
|
||||
2. 创建 `telemetry.rs`:条件初始化 OpenTelemetry tracer(环境变量 `ERP__TELEMETRY__ENABLED=true` 时启用)
|
||||
3. 配置 OTLP exporter(默认 `http://localhost:4317`,可通过环境变量覆盖)
|
||||
4. 在 `main.rs` 的 tracing subscriber 中条件注册 OpenTelemetry layer
|
||||
5. 在 SeaORM 的 `DatabaseConnection` 包装中添加 span(记录查询耗时)
|
||||
|
||||
**验收**: 启用后 Jaeger/Tempo 可看到请求 → SQL 查询 → 事件发布的完整链路;不启用时零开销
|
||||
|
||||
### Task 5: 生产 Docker 多阶段构建
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `Dockerfile`(项目根目录)
|
||||
- 新增: `docker/docker-compose.production.yml`
|
||||
|
||||
**步骤**:
|
||||
1. 多阶段 Dockerfile:
|
||||
```dockerfile
|
||||
# Stage 1: Build
|
||||
FROM rust:1.82-bookworm AS builder
|
||||
COPY . /app
|
||||
RUN cargo build --release -p erp-server
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM debian:bookworm-slim
|
||||
COPY --from=builder /app/target/release/erp-server /usr/local/bin/
|
||||
COPY --from=builder /app/crates/erp-server/config/default.toml /etc/erp/config.toml
|
||||
EXPOSE 3000 9090
|
||||
CMD ["erp-server"]
|
||||
```
|
||||
2. `docker-compose.production.yml`:
|
||||
- erp-server 服务(限制 1 CPU / 512MB)
|
||||
- PostgreSQL 16 + Redis 7 作为独立服务
|
||||
- 健康检查配置(使用 /health/ready)
|
||||
- 环境变量注入(JWT secret / DB URL / Redis URL 通过 secrets)
|
||||
|
||||
**验收**: `docker build -t hms-server .` 成功,运行时镜像 < 80MB
|
||||
|
||||
### Task 6: 前端生产构建 + Nginx
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `apps/web/Dockerfile`
|
||||
- 新增: `apps/web/nginx.conf`
|
||||
|
||||
**步骤**:
|
||||
1. 多阶段构建:node 构建 → nginx 运行
|
||||
2. Nginx 配置:SPA fallback + `/api` 代理到后端 3000 端口
|
||||
|
||||
**验收**: Docker 内前端可正常访问,API 代理工作
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 日志聚合 + 告警(Day 5-7)
|
||||
|
||||
### Task 7: Grafana Loki 日志集成
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `docker/loki-config.yaml`
|
||||
- 修改: `docker/docker-compose.production.yml`
|
||||
|
||||
**步骤**:
|
||||
1. 在 production compose 中添加 Loki + Promtail 服务
|
||||
2. Promtail 配置:读取 erp-server 的 JSON 日志输出
|
||||
3. Grafana 数据源配置:Loki + Prometheus
|
||||
|
||||
**验收**: Grafana 可查询和过滤后端日志
|
||||
|
||||
### Task 8: Prometheus 告警规则
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `docker/alert-rules.yml`
|
||||
|
||||
**步骤**:
|
||||
1. 定义 5 条告警规则:
|
||||
```yaml
|
||||
- alert: HighRequestLatency # P95 > 2s 持续 5 分钟
|
||||
- alert: HighErrorRate # 5xx 比率 > 5% 持续 3 分钟
|
||||
- alert: EventBusBacklog # 积压事件 > 100 持续 5 分钟
|
||||
- alert: DatabasePoolExhausted # 活跃连接 > 90% 持续 2 分钟
|
||||
- alert: HealthCheckDegraded # /health/ready 非 ok 持续 1 分钟
|
||||
```
|
||||
2. 配置 Alertmanager 通知渠道(Webhook/邮件)
|
||||
|
||||
**验收**: 触发告警条件时 Alertmanager 发送通知
|
||||
|
||||
### Task 9: Grafana Dashboard 模板
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `docker/grafana/dashboards/hms-overview.json`
|
||||
|
||||
**步骤**:
|
||||
1. 创建 HMS Overview Dashboard,包含面板:
|
||||
- 请求速率 + 延迟分布(P50/P95/P99)
|
||||
- 错误率趋势(按 status code 分组)
|
||||
- DB 连接池使用率
|
||||
- EventBus 发布/消费速率
|
||||
- 健康检查状态
|
||||
|
||||
**验收**: Dashboard 展示实时指标
|
||||
|
||||
### Task 10: 运维文档
|
||||
|
||||
**涉及文件**:
|
||||
- 新增: `wiki/observability.md`
|
||||
|
||||
**步骤**:
|
||||
1. 记录监控端点(/health/live, /health/ready, /metrics)
|
||||
2. 记录告警规则和响应流程
|
||||
3. 记录日志查询方法(Grafana Loki)
|
||||
4. 记录 Docker 部署命令
|
||||
|
||||
**验收**: 新团队成员可通过文档独立部署和排查问题
|
||||
|
||||
---
|
||||
|
||||
## 执行原则
|
||||
|
||||
1. **条件编译** — OpenTelemetry 使用 feature gate,不启用时零开销
|
||||
2. **渐进式** — Phase 1 可独立上线(无外部依赖),Phase 2/3 需要 Docker 环境
|
||||
3. **性能优先** — 指标收集使用 `metrics` crate 的无锁实现,不影响请求延迟
|
||||
4. **端口分离** — 业务 API (3000) + Metrics (9090) 分离,避免暴露内部指标
|
||||
@@ -0,0 +1,406 @@
|
||||
# HMS 平台基座回顾与演进设计
|
||||
|
||||
> 日期: 2026-04-26 | 状态: Draft | 方法: 三专家多视角评审
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 回顾目的
|
||||
|
||||
HMS 健康管理平台经过 17 天密集开发(2026-04-10 ~ 2026-04-26),从 ERP 底座演进到包含 16 个 Rust crate、62 个前端页面、27 个小程序页面的综合医疗 SaaS 平台。本次回顾旨在:
|
||||
|
||||
- **验证基座设计** — 星形依赖拓扑、ErpModule trait、事件总线、多租户策略是否经得起实践检验
|
||||
- **评估演进路径** — 从插件开发模式到原生模块开发的决策是否正确
|
||||
- **识别缺口与风险** — 通过多专家视角发现盲点
|
||||
- **制定演进路线** — 基于 P0/P1/P2 优先级指导后续迭代
|
||||
|
||||
### 1.2 评审方法
|
||||
|
||||
采用三专家独立评审,每个专家从不同视角分析相同的诊断和建议:
|
||||
|
||||
| 专家 | 视角 | 关注点 |
|
||||
|------|------|--------|
|
||||
| 高级系统架构师 | 架构可持续性 | 模块边界、事件可靠性、技术债 |
|
||||
| 医疗信息化专家 | 临床安全与合规 | 患者安全、PIPL 合规、领域模型 |
|
||||
| 产品策略专家 | ROI 与开发节奏 | 优先级、技术债量化、路线图现实性 |
|
||||
|
||||
### 1.3 核心结论
|
||||
|
||||
**基座设计方向正确,但深度不足。** 星形依赖、trait 抽象、事件总线等基础架构经受住了实践检验。但在临床安全(危急值告警未闭环)、合规(知情同意缺失)、事件可靠性(无重放机制)方面存在需立即修复的缺口。插件系统已验证可行性但对 HMS 核心业务贡献有限,建议有条件冻结。
|
||||
|
||||
---
|
||||
|
||||
## 2. 基座设计验证
|
||||
|
||||
### 2.1 评分总览
|
||||
|
||||
| 维度 | 评分 | 说明 |
|
||||
|------|------|------|
|
||||
| 模块边界 | ★★★★ | 星形拓扑零循环依赖,trait 契约清晰 |
|
||||
| ErpModule trait | ★★★★ | 生命周期/权限/事件/健康检查统一接口 |
|
||||
| 事件总线 | ★★★☆ | 基础设施扎实(broadcast+outbox),但无重放机制,消费侧不完整 |
|
||||
| 多租户 | ★★★☆ | JWT→TenantContext 全链路贯通,但缺 RLS 兜底和集成测试 |
|
||||
| 权限体系 | ★★★★ | RBAC + 行级数据权限 + 按钮级控制 |
|
||||
| 插件系统 | ★★★☆ | CRUD 场景验证通过,医疗场景天花板明显 |
|
||||
| API 一致性 | ★★★★ | 统一 envelope、分页、OpenAPI 自动文档 |
|
||||
| 数据库迁移 | ★★★★ | 59 个迁移,幂等、可回滚、fixup 模式健康 |
|
||||
| 测试覆盖 | ★☆☆☆ | 36 后端 + 3 前端,覆盖率 < 5% |
|
||||
| 合规性 | ★☆☆☆ | 知情同意缺失,审计不完整,PIE 加密范围不足 |
|
||||
|
||||
### 2.2 星形依赖拓扑
|
||||
|
||||
```
|
||||
erp-core (L1)
|
||||
/ | \ \ \ \
|
||||
erp-auth workflow message config erp-health erp-plugin erp-ai
|
||||
\ | / / / / /
|
||||
erp-server (L3, 组装入口)
|
||||
```
|
||||
|
||||
- `erp-core`:零业务依赖,纯净基础层
|
||||
- 7 个业务 crate:各只依赖 `erp-core`,兄弟间无横向依赖
|
||||
- `erp-server`:唯一组装点,负责路由合并和模块初始化
|
||||
- **无循环依赖** — 架构师验证通过
|
||||
|
||||
### 2.3 ErpModule trait
|
||||
|
||||
当前 trait 提供统一的模块接口:
|
||||
|
||||
- **身份**:`name()` / `id()` / `version()`
|
||||
- **依赖声明**:`dependencies()` — 用于拓扑排序启动顺序
|
||||
- **生命周期**:`on_startup()` / `on_shutdown()` / `health_check()`
|
||||
- **多租户**:`on_tenant_created()` / `on_tenant_deleted()`
|
||||
- **权限自描述**:`permissions()` — 模块声明自己需要的权限码
|
||||
- **事件订阅**:`register_event_handlers()` / `as_any()`
|
||||
|
||||
**已知张力**:路由注册不在 trait 中,而是通过各模块的 inherent method (`public_routes()` / `protected_routes()`) 手动在 `main.rs` 中合并。原因是 Axum 的 `Router<S>` 泛型约束不适合 trait object。这是务实的妥协,但在添加新模块时有 boilerplate 成本。
|
||||
|
||||
### 2.4 事件总线
|
||||
|
||||
**实现机制**:`tokio::sync::broadcast` (容量 1024) + `domain_events` 表持久化(best-effort)+ Outbox relay (5秒轮询,3次重试)
|
||||
|
||||
**发布侧**(已识别的事件类型):
|
||||
|
||||
| 模块 | 事件类型数 | 示例 |
|
||||
|------|-----------|------|
|
||||
| erp-auth | 10 | `user.login`, `user.created`, `role.created` |
|
||||
| erp-workflow | 4 | `process_instance.started`, `task.completed` |
|
||||
| erp-message | 1 | `message.sent` |
|
||||
| erp-health | 13 | `patient.created`, `health_data.critical_alert`, `follow_up.overdue` |
|
||||
| erp-plugin | 2+ | `plugin.config.updated`, `plugin.trigger.*` |
|
||||
|
||||
**消费侧**(已识别的订阅者):
|
||||
|
||||
| 订阅者 | 订阅方式 | 处理的事件 |
|
||||
|--------|---------|-----------|
|
||||
| erp-message | `subscribe()` 全量 | `appointment.*`, `process_instance.*`, `task.*` |
|
||||
| erp-health | `register_handlers_with_state` | `workflow.task.completed` |
|
||||
| erp-plugin 通知 | `subscribe_filtered("plugin.trigger.*")` | 插件触发通知 |
|
||||
| outbox relay | 轮询 DB | 重发 pending 事件 |
|
||||
|
||||
**已识别缺陷**:
|
||||
1. **无重放机制** — 内存 broadcast,服务重启后未消费的事件丢失
|
||||
2. **无幂等保护** — `follow_up.overdue` 每 6 小时检查会重复发布同一条逾期事件
|
||||
3. **全量订阅** — erp-message 使用 `subscribe()` 而非 `subscribe_filtered()`,所有事件都经过消息模块
|
||||
|
||||
### 2.5 多租户
|
||||
|
||||
**已实现**:
|
||||
- JWT claims 提取 `tenant_id` → `TenantContext` 注入请求扩展
|
||||
- 所有 Entity 含 `tenant_id` 字段,BaseFields 统一
|
||||
- 所有 DomainEvent 携带 `tenant_id`
|
||||
- `on_tenant_created()` / `on_tenant_deleted()` 钩子(auth 和 health 已实现)
|
||||
- 部门级数据范围(`department_ids` 在 TenantContext 中)
|
||||
|
||||
**缺失**:
|
||||
- 无 PostgreSQL RLS policy 作为兜底层
|
||||
- 无强制 tenant_id 过滤的查询层机制 — 依赖每个 service 手动 `.filter()`
|
||||
- 当前实际只有 default_tenant,微信登录硬编码使用 `default_tenant_id`
|
||||
- 无多租户管理 API(创建/配置/迁移)
|
||||
|
||||
---
|
||||
|
||||
## 3. 演进路径回顾
|
||||
|
||||
### 3.1 时间线
|
||||
|
||||
```
|
||||
4/10-4/16 基座搭建 (Phase 1-6)
|
||||
→ core → auth → config → workflow → message
|
||||
→ 全部原生 Rust 模块,30+ 数据库表
|
||||
|
||||
4/13-4/18 WASM 插件实验
|
||||
→ 插件系统设计与实现 (Wasmtime + WIT bindgen)
|
||||
→ CRM (5实体) → Inventory (6实体) → Freelance → ITOps
|
||||
→ 证明:CRUD 密集型领域可行,沙盒隔离有效
|
||||
→ 跨插件数据引用未解决
|
||||
|
||||
4/23-4/26 HMS 分叉 — 健康模块原生开发
|
||||
→ 18+ 强类型实体 (患者/家属/医生/预约/排班/随访/咨询/体征/化验/透析/诊断/积分...)
|
||||
→ PII 加密 (AES-256-GCM)、脱敏管道
|
||||
→ AI 模块 (4 SSE 流式端点 + 3 REST 端点)
|
||||
→ 微信小程序 (27 页面)
|
||||
→ 按钮级权限控制
|
||||
```
|
||||
|
||||
### 3.2 从插件到原生的决策链
|
||||
|
||||
**原始插件愿景**(设计规格 2026-04-13):
|
||||
|
||||
- 平台模块原生,行业模块 WASM 插件
|
||||
- 插件通过 9 个 Host API 函数通信(db_insert/query/update/delete、event_publish、config_get 等)
|
||||
- 数据存 JSONB 动态表,路由自动生成
|
||||
- UI 配置驱动,通用 PluginCRUDPage 组件
|
||||
|
||||
**健康模块原生的 5 个硬限制**(设计规格 2026-04-23 §1.3):
|
||||
|
||||
| 限制 | 影响 | 不可妥协原因 |
|
||||
|------|------|-------------|
|
||||
| 20 实体上限 | 健康平台轻松超过 | 18+ 实体已是最低合理粒度 |
|
||||
| JSONB 存储 | 无强类型、无外键约束 | 医疗数据需要引用完整性和精确索引 |
|
||||
| 无自定义 API | 只有自动 CRUD | 趋势分析/统计报表/日历视图无法实现 |
|
||||
| 无文件上传 | 沙盒阻止文件系统访问 | 化验单/体检报告需要文件存储 |
|
||||
| WASM 沙盒限制 | 无 native crypto/外部 API/后台任务 | PII 加密、微信集成、定时任务全部需要 |
|
||||
|
||||
### 3.3 得失评估
|
||||
|
||||
**得 — 正确的决策:**
|
||||
|
||||
| 决策 | 收益 |
|
||||
|------|------|
|
||||
| 星形依赖拓扑 | 模块独立性强,可独立测试和替换 |
|
||||
| ErpModule 统一接口 | 新模块注册流程标准化 |
|
||||
| 事件总线 | 跨模块解耦通信的基础设施已就绪 |
|
||||
| JWT→TenantContext | 多租户全链路贯通 |
|
||||
| 健康模块原生 | 不受沙盒限制,加密/文件/后台任务全部可用 |
|
||||
| 插件实验 | 验证了平台灵活性,CRM/库存可正常使用 |
|
||||
|
||||
**失 — 需要修正的问题:**
|
||||
|
||||
| 决策 | 代价 |
|
||||
|------|------|
|
||||
| 插件系统投入过大 | 22,000 行代码(41% Rust 总量),对 HMS 核心业务贡献接近零 |
|
||||
| 积分系统混入 health | 8 实体/12+ 路由,增加合规复杂度和数据泄露面 |
|
||||
| 事件消费侧忽视 | 13 个事件只有 3 个被消费,危急告警和逾期通知空转 |
|
||||
| 测试覆盖极薄 | 36 后端 + 3 前端测试,覆盖率 < 5% |
|
||||
| 合规意识不足 | 知情同意缺失、审计不完整、PIE 加密范围不足 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 三专家评审摘要
|
||||
|
||||
### 4.1 高级系统架构师
|
||||
|
||||
**诊断准确度:7/10** — 四个张力都真实存在,但优先级和细节有偏差。
|
||||
|
||||
关键补充:
|
||||
|
||||
| 发现 | 严重程度 |
|
||||
|------|---------|
|
||||
| WIT 接口是同步调用(阻塞),WASM 运行时嵌入主进程(故障隔离不足) | 架构隐患 |
|
||||
| EventBus 内存 broadcast 无重放机制,服务重启丢事件 | P1 |
|
||||
| `follow_up.overdue` 无幂等保护,每 6h 检查重复发布 | P0 |
|
||||
| erp-message 用 `subscribe()` 全量订阅,性能隐患 | P1 |
|
||||
| RLS 不是 P0,多租户集成测试才是 | 观点 |
|
||||
| 积分系统(8 独立实体、12+ 路由)不应在 erp-health 内 | 共识 |
|
||||
| 缺监控/可观测性、数据备份策略、API 版本升级路线图 | 盲点 |
|
||||
|
||||
核心原则:**先补测试再重构,先修事件再上功能,先验证再加固。**
|
||||
|
||||
### 4.2 医疗信息化专家
|
||||
|
||||
**发现了比原始诊断更深层的临床安全风险。**
|
||||
|
||||
| 新发现 | 严重程度 |
|
||||
|--------|---------|
|
||||
| 危急值阈值全部硬编码(收缩压 180/80、心率 150/40),不可配置 | P0 |
|
||||
| `daily_monitoring` 表体征数据不经过危急值检测(合并前遗留) | P0 |
|
||||
| 过敏史更新直接覆盖,无变更历史 | P0 |
|
||||
| 知情同意完全缺失(搜索 consent/同意/授权/隐私 零结果) | P0 — PIPL 违规 |
|
||||
| 只有身份证号存储加密,姓名/过敏史/诊断/咨询内容明文 | P1 |
|
||||
| 审计日志不完整 — 只有预约状态变更记录前后值 | P1 |
|
||||
| `ip_address` 和 `user_agent` 从未被填充 | P1 |
|
||||
| 读操作(查看患者详情/化验报告)完全没有审计记录 | P1 |
|
||||
| 诊断记录 `icd_code` 只做字符串约束,无格式校验,无同行审核 | P1 |
|
||||
|
||||
合规评估:PIPL 第 29 条要求处理敏感个人信息须取得单独同意。医疗数据属于敏感个人信息。知情同意缺失是法律红线。
|
||||
|
||||
领域模型建议:积分系统(6 实体 + 2 线下活动实体)应拆分为独立 `erp-points` 或 `erp-engagement` 模块,与健康数据分离以降低合规复杂度。
|
||||
|
||||
### 4.3 产品策略专家
|
||||
|
||||
**开发节奏不可持续但不必恐慌。**
|
||||
|
||||
| 分析 | 结论 |
|
||||
|------|------|
|
||||
| 峰值 68 提交/天,fix 提交占 21.6% | 短期冲刺可以,长期人会耗竭 |
|
||||
| 41% Rust 代码在插件系统,对核心业务贡献接近零 | 最大 ROI 失衡 |
|
||||
| 单人 + AI 的"速度幻觉" | 68 提交/天 = 审查不足,积分混入 health 就是例证 |
|
||||
| 测试覆盖 < 5% | 正确水位不是 80%,而是关键路径不回退(目标 50-80 用例,3-4 天) |
|
||||
|
||||
关键风险缓解建议:
|
||||
- ADR(架构决策记录)强制化
|
||||
- 医疗安全代码双人外部 review
|
||||
- 每日提交上限 15 次
|
||||
- 每月需求裁剪
|
||||
|
||||
V2 血透路线图评估:技术储备已够(`dialysis_service` 286 行骨架在),但缺市场验证。建议先做 3-5 家目标客户调研,确认需求后再做 2 周 MVP 试运行。
|
||||
|
||||
---
|
||||
|
||||
## 5. 共识优先级
|
||||
|
||||
### 5.1 三专家加权共识矩阵
|
||||
|
||||
| 议题 | 架构师 | 医疗专家 | 产品策略 | 共识等级 |
|
||||
|------|--------|---------|---------|---------|
|
||||
| 危急值告警闭环 | P0 | P0 + 硬编码 | P0 | 三方一致 |
|
||||
| 知情同意 (PIPL) | 未涉及 | P0 | P0 | 两方一致 |
|
||||
| 审计日志补全 | 未涉及 | P1 | P0 | P0-P1 |
|
||||
| EventBus 可靠性 | P1 | 未涉及 | P0 | P0-P1 |
|
||||
| 随访逾期通知 | P0 | P0 | P0 | 三方一致 |
|
||||
| 积分系统拆分 | 应拆 | 应拆(合规) | 占 19.5% | 三方一致 |
|
||||
| RLS | 不是 P0 | P1 | P0 | 有分歧 |
|
||||
| 插件系统 | 有条件冻结 | 未涉及 | 冻结 | 两方一致 |
|
||||
| 测试覆盖 | 先补测试 | 上线前必修 | 50-80 用例 | 三方一致 |
|
||||
| V2 血透 | 未涉及 | 缺标准流程 | 先调研 | 两方一致 |
|
||||
|
||||
### 5.2 P0 — 上线前必修(估计 2-3 周)
|
||||
|
||||
| 序号 | 项 | 工作量 | 负责 crate | 说明 |
|
||||
|------|---|--------|-----------|------|
|
||||
| 1 | 危急值告警消费者 | 1 天 | erp-health + erp-message | `health_data.critical_alert` → 推送通知给责任医护 |
|
||||
| 2 | 危急值阈值可配置化 | 2 天 | erp-health | 硬编码阈值改为数据库配置,支持科室/年龄差异化 |
|
||||
| 3 | daily_monitoring 合并后告警验证 | 1 天 | erp-health | 确认合并到 vital_signs 后所有体征数据都经过告警检测 |
|
||||
| 4 | 随访逾期通知 | 1 天 | erp-health + erp-message | `follow_up.overdue` → 催办通知 + 幂等保护 |
|
||||
| 5 | 知情同意记录 | 3 天 | erp-health | 患者数据处理同意获取和记录机制 |
|
||||
| 6 | 审计日志补全 | 3 天 | erp-core + erp-health | 临床数据变更记录前后值、读操作审计、IP/UA 填充 |
|
||||
| 7 | EventBus 持久化增强 | 2 天 | erp-core | 服务重启不丢事件 + overdue 事件幂等 |
|
||||
|
||||
### 5.3 P1 — 治理(2-4 周)
|
||||
|
||||
| 序号 | 项 | 工作量 | 说明 |
|
||||
|------|---|--------|------|
|
||||
| 8 | 积分系统剥离 | 5 天 | 从 erp-health 拆分为独立 erp-engagement crate |
|
||||
| 9 | 关键路径测试 | 4 天 | 多租户隔离、患者安全路径、预约并发(50-80 用例) |
|
||||
| 10 | 插件系统冻结声明 | 0.5 天 | 保留代码,README 声明实验性,不再投入 |
|
||||
| 11 | erp-message 改用 `subscribe_filtered` | 1 天 | 减少无效事件传递 |
|
||||
| 12 | 统一事件消费模式 | 2 天 | 消除 `register_event_handlers` vs `on_startup` 双路径 |
|
||||
| 13 | 过敏史变更历史 | 1 天 | 更新时记录旧值 |
|
||||
|
||||
### 5.4 P2 — 扩展(后续迭代)
|
||||
|
||||
| 序号 | 项 | 前置条件 |
|
||||
|------|---|---------|
|
||||
| 14 | PostgreSQL RLS | P1 测试覆盖完成 |
|
||||
| 15 | 血透专科 | 3-5 家客户调研完成 |
|
||||
| 16 | OCR 化验单提取 | 血透验证后 |
|
||||
| 17 | IM 咨询 | 血透验证后 |
|
||||
| 18 | health 模块按子域重组目录 | P1 测试覆盖完成 |
|
||||
| 19 | 前端测试覆盖提升 | P1 后端测试完成 |
|
||||
| 20 | 动态菜单系统 | 现有计划可用 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险与缓解
|
||||
|
||||
### 6.1 开发模式风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 单人认知单点 | 一人理解 16 个 crate,bus factor = 1 | ADR 强制化,关键决策留文档 |
|
||||
| AI 生成"编译对但逻辑错" | 危急值阈值硬编码、积分混入 health 就是例证 | 医疗安全代码双人外部 review |
|
||||
| 速度幻觉 | 68 提交/天 = 审查不足 | 每日提交上限 15 次 |
|
||||
| AI 回音壁 | AI 不质疑需求合理性 | 每月需求裁剪,引入真实用户反馈 |
|
||||
|
||||
### 6.2 临床安全风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 危急值告警未闭环 | 危急体征值无人响应,可致患者安全事故 | P0-1:实现消费者 + 阈值可配置 |
|
||||
| 逾期随访无催办 | 患者失访,影响医疗质量指标 | P0-4:实现通知 + 幂等保护 |
|
||||
| 过敏史无变更记录 | 无法追溯过敏史变更,用药风险 | P1-13:添加变更历史 |
|
||||
| 告警阈值硬编码 | 无法适应儿科/老年科/血透科不同范围 | P0-2:数据库配置 |
|
||||
|
||||
### 6.3 合规风险
|
||||
|
||||
| 风险 | 法规依据 | 缓解措施 |
|
||||
|------|---------|---------|
|
||||
| 知情同意缺失 | PIPL 第 29 条 | P0-5:实现同意记录机制 |
|
||||
| 审计不完整 | 医疗机构信息化建设要求 | P0-6:补全审计日志 |
|
||||
| PIE 加密范围不足 | PIPL 第 51 条 | P1:扩展加密到姓名/过敏史/诊断 |
|
||||
| 数据删除权缺失 | PIPL 第 47 条 | P2:实现患者数据导出/删除 |
|
||||
|
||||
### 6.4 架构风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| EventBus 无重放 | 服务重启丢事件 | P0-7:增强持久化 |
|
||||
| 全量订阅 | 性能隐患,所有事件经消息模块 | P1-11:改用过滤订阅 |
|
||||
| 路由手动合并 | 新模块 boilerplate 成本 | 长期:ErpModule trait v2 |
|
||||
| erp-health 过大 | 18+ 实体,维护复杂度上升 | P2-18:按子域重组 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 附录
|
||||
|
||||
### 7.1 关键文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `crates/erp-core/src/module.rs` | ErpModule trait + ModuleRegistry (拓扑排序) |
|
||||
| `crates/erp-core/src/events.rs` | EventBus 实现 (broadcast + outbox) |
|
||||
| `crates/erp-core/src/types.rs` | TenantContext, BaseFields, Pagination |
|
||||
| `crates/erp-core/src/rbac.rs` | 权限/角色检查 |
|
||||
| `crates/erp-server/src/main.rs` | 服务组装和手动路由合并 |
|
||||
| `crates/erp-server/src/state.rs` | AppState + FromRef 桥接 |
|
||||
| `crates/erp-server/src/outbox.rs` | Outbox relay (5s 轮询, 3 次重试) |
|
||||
| `crates/erp-auth/src/middleware/jwt_auth.rs` | JWT 认证 + TenantContext 注入 |
|
||||
| `crates/erp-health/src/module.rs` | HealthModule (ErpModule 实现 + 后台任务) |
|
||||
| `crates/erp-health/src/event.rs` | 健康模块事件订阅 |
|
||||
| `crates/erp-health/src/crypto.rs` | AES-256-GCM 加密 |
|
||||
| `crates/erp-health/src/service/masking.rs` | PII 脱敏管道 |
|
||||
| `crates/erp-plugin/src/engine.rs` | WASM 插件引擎 |
|
||||
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | 插件系统设计规格 |
|
||||
| `docs/superpowers/specs/2026-04-23-health-management-module-design.md` | 健康模块设计规格 |
|
||||
| `docs/discussions/2026-04-18-plugin-platform-brainstorm.md` | 插件平台演进讨论 |
|
||||
|
||||
### 7.2 迁移历史时间线
|
||||
|
||||
| 日期 | 迁移范围 | 说明 |
|
||||
|------|---------|------|
|
||||
| 4/10-11 | 核心平台 | 租户、用户、凭证、角色、权限、组织、部门、岗位 |
|
||||
| 4/12 | 配置 + 工作流 | 字典、菜单、设置、编号规则 + 流程定义/实例/令牌/任务 |
|
||||
| 4/13 | 消息 + 审计 | 模板、消息、订阅 + 审计日志 |
|
||||
| 4/14 | 修复 | 唯一索引与软删除冲突、标准字段补全 |
|
||||
| 4/16 | 领域事件 | domain_events 表 |
|
||||
| 4/17 | 插件系统 | 插件表、动态表 |
|
||||
| 4/18 | 搜索 + 权限 | pg_trgm、实体注册表、数据范围 |
|
||||
| 4/19 | 关联修复 | 用户部门、CRM 修复、插件市场 |
|
||||
| 4/23 | 健康表 | 患者、微信用户、文章 |
|
||||
| 4/24 | 索引修复 | 3 个 fixup 迁移 |
|
||||
| 4/25 | 健康扩展 | 患者ID哈希、医生名、透析/化验增强、AI 表、积分 |
|
||||
| 4/26 | 业务改进 | 诊断、列重命名、daily_monitoring 合并、菜单种子 |
|
||||
|
||||
**总计:59 个迁移,17 天内。** fixup 迁移模式健康(不编辑旧迁移,单独修复)。
|
||||
|
||||
### 7.3 项目统计快照 (2026-04-26)
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| Rust crate 数 | 16 |
|
||||
| Rust 代码行 | ~57,000 |
|
||||
| 前端文件数 | 174 (TSX/TS) |
|
||||
| 前端页面 | 62 |
|
||||
| 小程序页面 | 27 |
|
||||
| 数据库迁移 | 59 |
|
||||
| 数据库表 | 30 基础 + 18 健康 + 3 AI |
|
||||
| 后端测试 | 36 |
|
||||
| 前端单元测试 | 3 |
|
||||
| Git 提交 | 237 |
|
||||
| 开发周期 | 17 天 |
|
||||
|
||||
---
|
||||
|
||||
*本文档由三专家多视角评审生成,作为 HMS 平台基座演进的参考基准。后续实施计划将基于本文档的优先级排序展开。*
|
||||
@@ -0,0 +1,714 @@
|
||||
# 架构反思实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 落地架构反思三个结论 — WASM 评估量表插件、透析模块独立、P1 事件消费者补全。
|
||||
|
||||
**Architecture:** 三条独立工作线可并行推进。WASM 插件遵循 erp-plugin-test-sample 模式;透析模块拆分参照 erp-points 拆 crate 模式;事件消费者补全遵循现有 subscribe_filtered + tokio::spawn 模式。
|
||||
|
||||
**Tech Stack:** Rust/SeaORM/Axum/WASM(wit-bindgen 0.55)
|
||||
|
||||
**Spec:** `docs/discussions/2026-04-28-architecture-retrospective.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: WASM 评估量表插件(PHQ-9)
|
||||
|
||||
### Task 1: 创建插件 crate 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-plugin-assessment/Cargo.toml`
|
||||
- Create: `crates/erp-plugin-assessment/src/lib.rs`
|
||||
- Create: `crates/erp-plugin-assessment/plugin.toml`
|
||||
|
||||
- [ ] **Step 1: 创建 Cargo.toml**
|
||||
|
||||
参照 `crates/erp-plugin-test-sample/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-plugin-assessment"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.55"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 plugin.toml**
|
||||
|
||||
```toml
|
||||
[metadata]
|
||||
id = "assessment"
|
||||
name = "评估量表"
|
||||
version = "0.1.0"
|
||||
description = "标准化医学评估量表(PHQ-9、GAD-7 等)"
|
||||
author = "HMS"
|
||||
min_platform_version = "0.1.0"
|
||||
|
||||
[[permissions]]
|
||||
code = "assessment_scale.list"
|
||||
name = "查看评估量表"
|
||||
description = "查看评估量表列表和详情"
|
||||
|
||||
[[permissions]]
|
||||
code = "assessment_scale.manage"
|
||||
name = "管理评估量表"
|
||||
description = "创建、编辑、删除评估量表"
|
||||
|
||||
[[permissions]]
|
||||
code = "assessment_response.list"
|
||||
name = "查看评估结果"
|
||||
description = "查看患者评估答卷"
|
||||
|
||||
[[permissions]]
|
||||
code = "assessment_response.manage"
|
||||
name = "管理评估结果"
|
||||
description = "提交、编辑评估答卷"
|
||||
|
||||
[[schema.entities]]
|
||||
name = "assessment_scale"
|
||||
display_name = "评估量表"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "scale_code"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "量表编码"
|
||||
unique = true
|
||||
ui_widget = "select"
|
||||
options = ["PHQ-9", "GAD-7", "SF-36", "MMSE", "ADL", "IADL"]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "title"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "量表名称"
|
||||
searchable = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "description"
|
||||
field_type = "string"
|
||||
display_name = "描述"
|
||||
ui_widget = "textarea"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "questions_json"
|
||||
field_type = "json"
|
||||
required = true
|
||||
display_name = "题目定义(JSON)"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "scoring_rules_json"
|
||||
field_type = "json"
|
||||
required = true
|
||||
display_name = "评分规则(JSON)"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "status"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "状态"
|
||||
default = "active"
|
||||
ui_widget = "select"
|
||||
options = ["active", "inactive"]
|
||||
|
||||
[[schema.entities]]
|
||||
name = "assessment_response"
|
||||
display_name = "评估答卷"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "scale_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "量表"
|
||||
ui_widget = "entity_select"
|
||||
ref_entity = "assessment_scale"
|
||||
ref_plugin = "assessment"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "patient_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "患者 ID"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "answers_json"
|
||||
field_type = "json"
|
||||
required = true
|
||||
display_name = "答案(JSON)"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "total_score"
|
||||
field_type = "integer"
|
||||
required = true
|
||||
display_name = "总分"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "severity_level"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "严重程度"
|
||||
ui_widget = "select"
|
||||
options = ["normal", "mild", "moderate", "severe"]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "assessed_by"
|
||||
field_type = "uuid"
|
||||
display_name = "评估人"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "status"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "状态"
|
||||
default = "completed"
|
||||
ui_widget = "select"
|
||||
options = ["draft", "completed", "reviewed"]
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "assessment_scale"
|
||||
foreign_key = "scale_id"
|
||||
on_delete = "restrict"
|
||||
name = "scale"
|
||||
type = "belongs_to"
|
||||
display_field = "title"
|
||||
|
||||
[[trigger_events]]
|
||||
name = "assessment_completed"
|
||||
display_name = "评估完成"
|
||||
description = "患者完成评估量表,触发评分计算和后续流程"
|
||||
entity = "assessment_response"
|
||||
on = "create"
|
||||
|
||||
[[ui.pages]]
|
||||
type = "crud"
|
||||
label = "评估量表"
|
||||
icon = "FormOutlined"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 创建 src/lib.rs**
|
||||
|
||||
```rust
|
||||
// crates/erp-plugin-assessment/src/lib.rs
|
||||
//! 评估量表插件 — 标准化医学评估(PHQ-9, GAD-7 等)
|
||||
|
||||
wit_bindgen::generate!({
|
||||
path: "../../crates/erp-plugin/src/wit/plugin.wit",
|
||||
world: "plugin-world",
|
||||
});
|
||||
|
||||
use crate::exports::erp::plugin::plugin_api::Guest;
|
||||
use crate::erp::plugin::host_api::*;
|
||||
|
||||
struct AssessmentPlugin;
|
||||
|
||||
impl Guest for AssessmentPlugin {
|
||||
fn init() -> Result<(), String> {
|
||||
log_write("info", "AssessmentPlugin initialized");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_tenant_created(tenant_id: String) -> Result<(), String> {
|
||||
log_write("info", &format!("AssessmentPlugin: tenant {} created", tenant_id));
|
||||
// 可以为新租户插入默认量表(PHQ-9、GAD-7)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(
|
||||
event_type: String,
|
||||
_event_id: String,
|
||||
_tenant_id: String,
|
||||
_payload: String,
|
||||
) -> Result<(), String> {
|
||||
log_write("debug", &format!("AssessmentPlugin received: {}", event_type));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
export!(AssessmentPlugin);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 注册到 workspace**
|
||||
|
||||
在根 `Cargo.toml` 的 `workspace.members` 中添加 `"crates/erp-plugin-assessment"`。
|
||||
|
||||
- [ ] **Step 5: 编译验证**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-plugin-assessment
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-plugin-assessment/ Cargo.toml
|
||||
git commit -m "feat(plugin): 评估量表插件骨架 — assessment_scale + assessment_response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: PHQ-9 默认量表数据 + 评分逻辑
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-plugin-assessment/src/lib.rs`(on_tenant_created 插入默认量表)
|
||||
|
||||
- [ ] **Step 1: 在 on_tenant_created 中插入 PHQ-9 默认数据**
|
||||
|
||||
PHQ-9 的 9 道题(每题 0-3 分)和评分规则:
|
||||
|
||||
```json
|
||||
// questions_json
|
||||
[
|
||||
{"id": 1, "text": "做事时提不起劲或没有兴趣", "options": [{"label": "完全不会", "score": 0}, {"label": "好几天", "score": 1}, {"label": "一半以上的天数", "score": 2}, {"label": "几乎每天", "score": 3}]},
|
||||
// ... 共 9 题
|
||||
]
|
||||
|
||||
// scoring_rules_json
|
||||
[
|
||||
{"min": 0, "max": 4, "level": "normal", "label": "无抑郁症状"},
|
||||
{"min": 5, "max": 9, "level": "mild", "label": "轻度抑郁"},
|
||||
{"min": 10, "max": 14, "level": "moderate", "label": "中度抑郁"},
|
||||
{"min": 15, "max": 19, "level": "moderate_severe", "label": "中重度抑郁"},
|
||||
{"min": 20, "max": 27, "level": "severe", "label": "重度抑郁"}
|
||||
]
|
||||
```
|
||||
|
||||
通过 `db_insert` host API 在 `on_tenant_created` 中插入。
|
||||
|
||||
- [ ] **Step 2: 编译 + 验证**
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(plugin): PHQ-9 默认量表数据 + 评分规则"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 编译 WASM + 注册到 erp-server
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-server/src/main.rs`(插件注册,如需手动加载)
|
||||
- Verify: WASM 编译输出
|
||||
|
||||
- [ ] **Step 1: 编译为 WASM Component**
|
||||
|
||||
```bash
|
||||
cd crates/erp-plugin-assessment
|
||||
cargo build --target wasm32-unknown-unknown --release
|
||||
# 或使用项目内的 WASM 编译脚本
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证插件加载**
|
||||
|
||||
启动后端,确认插件系统识别 assessment 插件,动态表创建成功。
|
||||
|
||||
- [ ] **Step 3: 通过 API 测试评估量表 CRUD**
|
||||
|
||||
```bash
|
||||
# 创建量表
|
||||
curl -X POST /api/v1/plugin/assessment/assessment_scale \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"scale_code": "PHQ-9", ...}'
|
||||
|
||||
# 提交答卷
|
||||
curl -X POST /api/v1/plugin/assessment/assessment_response \
|
||||
-d '{"scale_id": "...", "patient_id": "...", "answers_json": [...]}'
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(plugin): 评估量表 WASM 编译 + 端到端验证"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 透析模块拆分为 erp-dialysis
|
||||
|
||||
### Task 4: 创建 erp-dialysis crate 骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/erp-dialysis/Cargo.toml`
|
||||
- Create: `crates/erp-dialysis/src/{lib,module,state,error}.rs`
|
||||
- Modify: `Cargo.toml`(workspace members)
|
||||
|
||||
- [ ] **Step 1: 创建 Cargo.toml**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "erp-dialysis"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
sea-orm.workspace = true
|
||||
tokio.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
axum.workspace = true
|
||||
utoipa.workspace = true
|
||||
validator.workspace = true
|
||||
async-trait.workspace = true
|
||||
tracing.workspace = true
|
||||
rust_decimal.workspace = true
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建标准模块文件**
|
||||
|
||||
```rust
|
||||
// crates/erp-dialysis/src/lib.rs
|
||||
pub mod dto;
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod handler;
|
||||
pub mod module;
|
||||
pub mod service;
|
||||
pub mod state;
|
||||
|
||||
pub use module::DialysisModule;
|
||||
pub use state::DialysisState;
|
||||
```
|
||||
|
||||
```rust
|
||||
// crates/erp-dialysis/src/module.rs
|
||||
//! ErpModule trait 实现
|
||||
|
||||
use async_trait::async_trait;
|
||||
use erp_core::module::{ErpModule, ModuleContext, ModuleType, PermissionDescriptor};
|
||||
use erp_core::events::EventBus;
|
||||
use crate::state::DialysisState;
|
||||
|
||||
pub struct DialysisModule {
|
||||
state: DialysisState,
|
||||
}
|
||||
|
||||
impl DialysisModule {
|
||||
pub fn new(db: sea_orm::DatabaseConnection, event_bus: EventBus) -> Self {
|
||||
Self { state: DialysisState { db, event_bus } }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ErpModule for DialysisModule {
|
||||
fn name(&self) -> &str { "透析管理" }
|
||||
fn id(&self) -> &str { "erp-dialysis" }
|
||||
fn version(&self) -> &str { "0.1.0" }
|
||||
fn module_type(&self) -> ModuleType { ModuleType::Builtin }
|
||||
|
||||
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||||
vec![
|
||||
PermissionDescriptor { code: "dialysis.record.list".into(), name: "查看透析记录".into() },
|
||||
PermissionDescriptor { code: "dialysis.record.manage".into(), name: "管理透析记录".into() },
|
||||
PermissionDescriptor { code: "dialysis.prescription.list".into(), name: "查看透析处方".into() },
|
||||
PermissionDescriptor { code: "dialysis.prescription.manage".into(), name: "管理透析处方".into() },
|
||||
]
|
||||
}
|
||||
|
||||
fn on_startup(&self, ctx: &ModuleContext) {
|
||||
crate::event::register_handlers_with_state(self.state.clone());
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 注册到 workspace**
|
||||
|
||||
在根 `Cargo.toml` 的 members 中添加 `"crates/erp-dialysis"`。
|
||||
|
||||
- [ ] **Step 4: 编译验证**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-dialysis
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(dialysis): 创建 erp-dialysis crate 骨架 + ErpModule 实现"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 迁移透析 Entity + Service + Handler + DTO
|
||||
|
||||
**Files:**
|
||||
- Move: 6 个文件从 `erp-health` → `erp-dialysis`
|
||||
- Modify: `crates/erp-health/src/{entity,service,handler,dto}/mod.rs`(删除透析导出)
|
||||
- Modify: 迁移文件中的 `crate::` 引用改为 `erp_core::` 或 erp-dialysis 内部引用
|
||||
|
||||
**待迁移文件清单:**
|
||||
|
||||
| 来源 | 目标 | 行数 |
|
||||
|------|------|------|
|
||||
| `erp-health/src/entity/dialysis_record.rs` | `erp-dialysis/src/entity/` | 82 |
|
||||
| `erp-health/src/entity/dialysis_prescription.rs` | `erp-dialysis/src/entity/` | 78 |
|
||||
| `erp-health/src/service/dialysis_service.rs` | `erp-dialysis/src/service/` | 333 |
|
||||
| `erp-health/src/service/dialysis_prescription_service.rs` | `erp-dialysis/src/service/` | 274 |
|
||||
| `erp-health/src/handler/dialysis_handler.rs` | `erp-dialysis/src/handler/` | 145 |
|
||||
| `erp-health/src/handler/dialysis_prescription_handler.rs` | `erp-dialysis/src/handler/` | 120 |
|
||||
| `erp-health/src/dto/dialysis_dto.rs` | `erp-dialysis/src/dto/` | 125 |
|
||||
| `erp-health/src/dto/dialysis_prescription_dto.rs` | `erp-dialysis/src/dto/` | 107 |
|
||||
|
||||
- [ ] **Step 1: 复制文件到 erp-dialysis**
|
||||
|
||||
```bash
|
||||
# Entity
|
||||
cp crates/erp-health/src/entity/dialysis_record.rs crates/erp-dialysis/src/entity/
|
||||
cp crates/erp-health/src/entity/dialysis_prescription.rs crates/erp-dialysis/src/entity/
|
||||
# Service
|
||||
cp crates/erp-health/src/service/dialysis_service.rs crates/erp-dialysis/src/service/
|
||||
cp crates/erp-health/src/service/dialysis_prescription_service.rs crates/erp-dialysis/src/service/
|
||||
# Handler
|
||||
cp crates/erp-health/src/handler/dialysis_handler.rs crates/erp-dialysis/src/handler/
|
||||
cp crates/erp-health/src/handler/dialysis_prescription_handler.rs crates/erp-dialysis/src/handler/
|
||||
# DTO
|
||||
cp crates/erp-health/src/dto/dialysis_dto.rs crates/erp-dialysis/src/dto/
|
||||
cp crates/erp-health/src/dto/dialysis_prescription_dto.rs crates/erp-dialysis/src/dto/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新 crate 内引用**
|
||||
|
||||
全局替换:
|
||||
- `crate::state::HealthState` → `crate::state::DialysisState`
|
||||
- `crate::error::{HealthError, HealthResult}` → `crate::error::{DialysisError, DialysisResult}`
|
||||
- `crate::entity::` → 保持不变(同 crate 内)
|
||||
- `crate::dto::` → 保持不变
|
||||
- `crate::service::` → 保持不变
|
||||
|
||||
- [ ] **Step 3: 创建 error.rs**
|
||||
|
||||
```rust
|
||||
// crates/erp-dialysis/src/error.rs
|
||||
use erp_core::error::AppError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DialysisError {
|
||||
#[error("透析记录未找到: {0}")]
|
||||
RecordNotFound(uuid::Uuid),
|
||||
#[error("处方未找到: {0}")]
|
||||
PrescriptionNotFound(uuid::Uuid),
|
||||
#[error("状态转换无效: {0} → {1}")]
|
||||
InvalidStatusTransition(String, String),
|
||||
#[error("版本冲突")]
|
||||
VersionConflict,
|
||||
}
|
||||
|
||||
impl From<DialysisError> for AppError {
|
||||
fn from(e: DialysisError) -> Self { AppError::Business(e.to_string()) }
|
||||
}
|
||||
|
||||
pub type DialysisResult<T> = Result<T, DialysisError>;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 从 erp-health 删除透析代码**
|
||||
|
||||
从以下 mod.rs 中移除透析相关 `pub mod` 声明:
|
||||
- `crates/erp-health/src/entity/mod.rs`
|
||||
- `crates/erp-health/src/service/mod.rs`
|
||||
- `crates/erp-health/src/handler/mod.rs`
|
||||
- `crates/erp-health/src/dto/mod.rs`
|
||||
|
||||
- [ ] **Step 5: 在 erp-server 注册新模块**
|
||||
|
||||
在 `crates/erp-server/src/main.rs` 中:
|
||||
- 添加 `use erp_dialysis::DialysisModule;`
|
||||
- 在 registry 链中 `.register(dialysis_module)`
|
||||
- 在路由 merge 中 `.merge(erp_dialysis::DialysisModule::protected_routes())`
|
||||
|
||||
- [ ] **Step 6: 编译 + 全链路验证**
|
||||
|
||||
```bash
|
||||
cargo check --workspace
|
||||
# 启动后端,验证透析相关 API 正常
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor: 透析模块拆分为独立 erp-dialysis crate(2 Entity + 2 Service)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: P1 事件消费者补全
|
||||
|
||||
### Task 6: patient.created → 欢迎消息 + 默认随访
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-health/src/event.rs`(添加消费者)
|
||||
|
||||
- [ ] **Step 1: 在 register_handlers_with_state 中添加 patient.created 消费者**
|
||||
|
||||
```rust
|
||||
// 在 register_handlers_with_state() 中新增:
|
||||
let (mut patient_rx, _) = state.event_bus.subscribe_filtered("patient.".to_string());
|
||||
let patient_db = state.db.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match patient_rx.recv().await {
|
||||
Some(event) if event.event_type == PATIENT_CREATED => {
|
||||
if erp_core::events::is_event_processed(&patient_db, event.id, "patient_welcome").await.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| Uuid::parse_str(s).ok());
|
||||
if let Some(pid) = patient_id {
|
||||
// 1. 发布欢迎消息事件(消息模块消费后发送站内通知)
|
||||
let welcome_event = DomainEvent::new(
|
||||
"message.send",
|
||||
event.tenant_id,
|
||||
erp_core::events::build_event_payload(serde_json::json!({
|
||||
"template": "patient_welcome",
|
||||
"recipient_type": "patient",
|
||||
"recipient_id": pid,
|
||||
})),
|
||||
);
|
||||
// 2. TODO: 创建默认随访计划(后续迭代)
|
||||
tracing::info!(patient_id = %pid, "新患者欢迎流程触发");
|
||||
}
|
||||
let _ = erp_core::events::mark_event_processed(&patient_db, event.id, "patient_welcome").await;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译验证**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-health
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(health): patient.created 消费者 — 新患者欢迎消息"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: appointment.confirmed/cancelled → 通知 + 号源
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-health/src/event.rs`
|
||||
|
||||
- [ ] **Step 1: 添加 appointment 事件消费者**
|
||||
|
||||
```rust
|
||||
let (mut appt_rx, _) = state.event_bus.subscribe_filtered("appointment.".to_string());
|
||||
let appt_db = state.db.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match appt_rx.recv().await {
|
||||
Some(event) if event.event_type == "appointment.confirmed" => {
|
||||
if erp_core::events::is_event_processed(&appt_db, event.id, "appointment_notifier").await.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
// 通知医生
|
||||
let doctor_id = event.payload.get("doctor_id").and_then(|v| v.as_str());
|
||||
let patient_id = event.payload.get("patient_id").and_then(|v| v.as_str());
|
||||
if let (Some(did), Some(pid)) = (doctor_id, patient_id) {
|
||||
tracing::info!(doctor_id = did, patient_id = pid, "预约确认通知触发");
|
||||
// 发布通知事件
|
||||
}
|
||||
let _ = erp_core::events::mark_event_processed(&appt_db, event.id, "appointment_notifier").await;
|
||||
}
|
||||
Some(event) if event.event_type == "appointment.cancelled" => {
|
||||
// 释放号源 + 通知排队患者
|
||||
tracing::info!(event_id = %event.id, "预约取消,号源释放");
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译 + 提交**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-health
|
||||
git commit -m "feat(health): appointment 事件消费者 — 预约确认/取消通知"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: follow_up.overdue → 升级通知
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-health/src/event.rs`
|
||||
|
||||
- [ ] **Step 1: 添加 follow_up.overdue 消费者**
|
||||
|
||||
```rust
|
||||
// 在 register_handlers_with_state 中:
|
||||
// 注意:follow_up 事件的前缀是 "follow_up."
|
||||
let (mut fu_rx, _) = state.event_bus.subscribe_filtered("follow_up.".to_string());
|
||||
let fu_db = state.db.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match fu_rx.recv().await {
|
||||
Some(event) if event.event_type == FOLLOW_UP_OVERDUE => {
|
||||
if erp_core::events::is_event_processed(&fu_db, event.id, "follow_up_escalator").await.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let task_id = event.payload.get("task_id").and_then(|v| v.as_str());
|
||||
let assigned_to = event.payload.get("assigned_to").and_then(|v| v.as_str());
|
||||
if let (Some(tid), Some(uid)) = (task_id, assigned_to) {
|
||||
// 通知随访负责人 + 科室主管
|
||||
tracing::warn!(task_id = tid, assigned_to = uid, "随访逾期升级通知");
|
||||
}
|
||||
let _ = erp_core::events::mark_event_processed(&fu_db, event.id, "follow_up_escalator").await;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译 + 提交**
|
||||
|
||||
```bash
|
||||
cargo check -p erp-health
|
||||
git commit -m "feat(health): follow_up.overdue 消费者 — 逾期随访升级通知"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
| Chunk | Tasks | 内容 | 预估 |
|
||||
|-------|-------|------|------|
|
||||
| 1 | T1-T3 | WASM 评估量表插件(PHQ-9) | 1-2 天 |
|
||||
| 2 | T4-T5 | 透析模块拆 erp-dialysis | 1 天 |
|
||||
| 3 | T6-T8 | P1 事件消费者补全(3 个) | 0.5-1 天 |
|
||||
|
||||
**总计 8 个 Task,预估 2.5-4 天。**
|
||||
|
||||
**依赖关系:**
|
||||
- T1→T2→T3 串行(WASM 插件逐层构建)
|
||||
- T4→T5 串行(先骨架再迁移)
|
||||
- T6/T7/T8 可并行(独立消费者)
|
||||
- Chunk 1/2/3 相互独立,可完全并行
|
||||
|
||||
**与技术债计划的关系:**
|
||||
- 本计划的 Chunk 3(事件消费者)应在技术债批次 B(EventBus dead-letter)之后执行
|
||||
- Chunk 2(透析拆分)应在技术债批次 A(安全修复)之后执行,避免合并冲突
|
||||
- Chunk 1(WASM 插件)完全独立,随时可执行
|
||||
@@ -0,0 +1,346 @@
|
||||
# V1 客户演示方案设计规格
|
||||
|
||||
> 日期: 2026-05-09 | 状态: Draft v2 | 类型: 演示方案
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 为什么做这次演示
|
||||
|
||||
HMS 健康管理平台已完成核心功能开发(700+ 次提交,V2 审计 85% 完成度),进入 V1 发布阶段。需要面向潜在客户(体检中心/血透中心)进行产品演示,目标是:
|
||||
|
||||
- **打动决策者签约** — 展示业务价值,而非功能清单
|
||||
- **收集真实反馈** — 了解客户实际工作流中的痛点,指导 V2 迭代
|
||||
- **验证产品定位** — 确认「AI 驱动主动关怀引擎」的定位是否与客户需求匹配
|
||||
|
||||
### 1.2 当前系统状态
|
||||
|
||||
| 指标 | 状态 |
|
||||
|------|------|
|
||||
| 核心链路 | 11 条端到端链路已验证通过 |
|
||||
| 已知 CRITICAL | 1 个未修复(Token 刷新竞态);其余 CRITICAL(告警权限码拼写、晚间血压丢失、仪表盘 500)均已修复 |
|
||||
| 角色测试通过率 | 84.6%(R01-R05) |
|
||||
| Web 前端 | 55 路由,283 文件,最完整的端 |
|
||||
| 小程序 | 59 页面,118 文件,代码完整 |
|
||||
| AI 模块 | 已对接 Ollama qwen3:4b,SSE 分析可用 |
|
||||
|
||||
### 1.3 演示策略
|
||||
|
||||
- **质量优先** — 修完所有已知问题再发布
|
||||
- **故事线驱动** — 用一个患者的 30 天管理历程展示完整闭环
|
||||
- **单患者深度** — 而非多角色广度,降低演示事故风险
|
||||
|
||||
## 2. 演示信息
|
||||
|
||||
| 项 | 值 |
|
||||
|------|------|
|
||||
| 受众 | 机构决策层 + 医疗团队 |
|
||||
| 时长 | 30-40 分钟 |
|
||||
| 视角 | 患者旅程(张大爷的 30 天) |
|
||||
| 涉及端 | Web 管理端(主力)+ 微信小程序(辅助) |
|
||||
| 涉及角色 | 护士、AI、医生、患者、健康管理师、管理员 |
|
||||
|
||||
## 3. 准备清单
|
||||
|
||||
### 3.1 测试账号
|
||||
|
||||
| 账号 | 角色 | 密码 | 用途 |
|
||||
|------|------|------|------|
|
||||
| `admin` | 管理员 | `Admin@2026` | 场景 7 仪表盘 |
|
||||
| `doctor1` | 医生 | `Admin@2026` | 场景 3 医生审批 |
|
||||
| `nurse1` | 护士 | `Admin@2026` | 场景 1 建档 + 场景 5 告警处理 |
|
||||
| `health_mgr` | 健康管理师 | `Admin@2026` | 场景 6 随访执行 |
|
||||
| `zhang_daye` | 患者(小程序) | 微信登录 | 场景 4/5 患者端操作 |
|
||||
|
||||
### 3.2 预置测试数据
|
||||
|
||||
| 数据 | 说明 | 目的 |
|
||||
|------|------|------|
|
||||
| 患者档案(张大爷) | 张建国,65岁,男,CKD 3期 | 主角 |
|
||||
| 历史化验单 ×2 | 肌酐 88→102 μmol/L 的趋势 | AI 分析需要历史对比发现趋势 |
|
||||
| 随访模板 | "慢性肾病定期随访"模板 | 场景 3 医生一键生成随访 |
|
||||
| 告警规则 | 肌酐>120 或 收缩压>160 | 场景 5 触发告警 |
|
||||
| 健康科普文章 ×3 | CKD 饮食/运动/用药 | 场景 4 小程序内容展示 |
|
||||
|
||||
### 3.3 环境检查
|
||||
|
||||
| 检查项 | 方法 | 通过标准 |
|
||||
|--------|------|----------|
|
||||
| 后端服务 | `cargo run` | 无 panic,Swagger 可访问 |
|
||||
| Web 前端 | `pnpm dev` | 登录页正常加载 |
|
||||
| 小程序 | 微信开发者工具 | 真机预览可扫码 |
|
||||
| 数据库 | 迁移已执行 | 预置数据查询无空结果 |
|
||||
| AI 模块 | Ollama 运行中 | SSE 分析端点可返回结果 |
|
||||
| 浏览器 | Chrome 无痕模式 | 干净环境,无缓存干扰 |
|
||||
|
||||
### 3.4 风险预案
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|----------|
|
||||
| AI 分析响应慢/失败 | 预先跑一次分析,截图备用;口头说明"云端大模型更快" |
|
||||
| 小程序真机扫码失败 | 准备 15 秒录屏视频展示关键页面 |
|
||||
| 后端服务崩溃 | 演示前重启一次确保干净状态 |
|
||||
| 数据库连接断开 | 提前验证 Docker PostgreSQL 健康状态 |
|
||||
| 告警权限码 bug | 演示前验证 AlertDashboard.tsx 权限码已修复(`health.alerts.manage`) |
|
||||
| SSE 长连接断开 | 录制 30 秒 AI 分析过程视频备用 |
|
||||
| Ollama 模型未加载 | 环境检查清单加入 `ollama list` 确认 qwen3:4b 已就绪 |
|
||||
| 多角色登录冲突 | 使用多个 Chrome Profile,每个角色一个独立 Profile |
|
||||
| 演示超时 | 标注可跳过场景(场景 6 可一句话带过) |
|
||||
|
||||
### 3.5 硬件与网络要求
|
||||
|
||||
| 项 | 要求 |
|
||||
|------|------|
|
||||
| 投影仪/大屏 | 分辨率 ≥ 1920x1080 |
|
||||
| 网络 | 演示机器与服务器在同一局域网,延迟 < 10ms |
|
||||
| 浏览器 | Chrome ×2 个 Profile(Web 端两个角色并行),或双屏方案 |
|
||||
| 手机 | 安装微信,可扫小程序码(备用:开发者工具投屏) |
|
||||
| 服务器 | 后端 + PostgreSQL + Ollama 运行在同一台机器,避免网络依赖 |
|
||||
|
||||
### 3.6 角色切换指引
|
||||
|
||||
| 切换点 | 操作 | 预计耗时 |
|
||||
|--------|------|----------|
|
||||
| 场景 1→2 | nurse1 退出 → admin 登录 | 15 秒 |
|
||||
| 场景 2→3 | admin 退出 → doctor1 登录 | 15 秒 |
|
||||
| 场景 3→4 | Web → 微信开发者工具/手机 | 10 秒 |
|
||||
| 场景 4→5 | 小程序录入 → Web nurse1 告警 | 10 秒 |
|
||||
| 场景 5→6 | nurse1 退出 → health_mgr 登录 | 15 秒 |
|
||||
| 场景 6→7 | health_mgr 退出 → admin 登录 | 15 秒 |
|
||||
|
||||
**建议:** 准备 2 个 Chrome Profile(Profile A: nurse1/admin,Profile B: doctor1/health_mgr),减少登录切换。场景 4/5 用独立手机或开发者工具。总切换时间约 1-1.5 分钟。
|
||||
|
||||
## 4. 演示脚本
|
||||
|
||||
### 开场(2 分钟)
|
||||
|
||||
**话术:**
|
||||
> "体检中心最大的痛点是什么?患者体检完,就走了。没有后续管理,没有随访跟进,体检数据躺在系统里没人看。今天给大家演示 HMS 健康管理平台如何解决这个问题——用一个真实场景:张大爷来体检后,系统如何帮他做 30 天的持续健康管理。"
|
||||
|
||||
---
|
||||
|
||||
### 场景 1:张大爷来体检(Day 1 上午)— 护士视角
|
||||
|
||||
**登录:** Web 端 `nurse1` | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 登录后展示护士工作台首页 — 一眼看到今日待办
|
||||
2. 点击「患者管理」→「新建患者」
|
||||
3. 填入:张建国 / 男 / 65岁 / 手机号 / 慢性肾病3期(诊断标签)
|
||||
4. 保存 → 跳转患者详情页
|
||||
5. 在患者详情页点击「体征录入」→ 录入血压 142/88、心率 72、空腹血糖 5.8
|
||||
6. 点击「化验报告」→ 上传预置的化验单图片,显示肌酐值 102 μmol/L
|
||||
|
||||
**话术:**
|
||||
> "张大爷第一次来体检中心。以前护士拿纸质表格登记,现在 30 秒建档。体征数据和化验报告立刻进入系统。"
|
||||
|
||||
**突出能力:** 快速建档、结构化体征录入、化验单数字化
|
||||
|
||||
---
|
||||
|
||||
### 场景 2:AI 自动分析(Day 1 下午)— 系统自动触发
|
||||
|
||||
**登录:** `admin` 或任意管理端账号 | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 展示「AI 分析」页面 → 显示张大爷的分析结果
|
||||
2. 点击分析详情 → 展示 AI 输出:
|
||||
- "肌酐值 88→102 μmol/L,3 个月持续上升趋势"
|
||||
- "建议:加做肾功能全套检查,排除 CKD 进展"
|
||||
- 风险等级:中风险(黄色标签)
|
||||
3. 切到 AI 建议列表 → 展示系统自动生成的「建议加做肾功能检查」行动项
|
||||
|
||||
**话术:**
|
||||
> "护士录入完数据,系统后台自动跑 AI 分析。不需要医生手动触发。AI 发现张大爷肌酐 3 个月在涨,主动建议进一步检查。这就是我们说的「主动关怀」——不是等患者出问题才看,是系统帮你盯着。"
|
||||
|
||||
**突出能力:** AI 自动分析、趋势发现、主动建议生成
|
||||
|
||||
**重要说明:** 当前 Web 端 AI 分析触发入口有限(审计报告指出"仅历史查看有 UI,分析触发无入口")。演示前**必须**执行以下操作之一:
|
||||
- 方案 A:演示前通过 API 手动触发一次分析(`POST /api/v1/ai/analysis/...`),演示时展示已生成的结果
|
||||
- 方案 B:为演示临时添加一个「触发分析」按钮到患者详情页
|
||||
- 推荐方案 A,配合话术调整:"这是系统刚才自动生成的分析结果"
|
||||
|
||||
**预案:** AI 分析慢或失败 → 展示预置截图,口头说明"接入云端大模型后速度更快"
|
||||
|
||||
---
|
||||
|
||||
### 场景 3:医生一秒决策(Day 3)— 医生视角
|
||||
|
||||
**登录:** Web 端 `doctor1` | **时长:** ~5 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 展示医生工作台 → 待办区域显示"1 条 AI 建议待审批"
|
||||
2. 点击进入 → 查看 AI 分析详情 + 患者历史数据
|
||||
3. 点击「同意建议」→ 系统自动:
|
||||
- 生成随访任务("肾功能复查随访",2 周后到期)
|
||||
- 推送小程序消息给患者
|
||||
4. 展示随访任务列表 → 新任务已创建
|
||||
5. 点击「预约管理」→ 演示为张大爷预约复查(选医生、选时间段、确认)
|
||||
|
||||
**话术:**
|
||||
> "李医生早上打开系统,看到 AI 昨天的分析建议。以前要翻纸质报告、手动比对数据,现在 AI 已经帮你分析好了,医生只需要做一个决策:同意还是不同意。点一下,系统自动安排随访、自动通知患者。"
|
||||
|
||||
**突出能力:** AI 辅助决策、一键生成随访、自动通知患者
|
||||
|
||||
---
|
||||
|
||||
### 场景 4:张大爷在家收到提醒(Day 7)— 小程序视角
|
||||
|
||||
**操作:** 微信开发者工具或真机预览 | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 打开小程序首页 → 展示今日摘要:1 条随访待办 + 1 篇健康科普
|
||||
2. 点击「消息」Tab → 显示"您有一条新的随访任务"
|
||||
3. 点击进入随访详情 → 显示随访问卷(饮食情况、用药依从性、症状变化)
|
||||
4. 快速填写 2-3 项 → 提交
|
||||
5. 切回「健康」Tab → 展示张大爷的体征趋势图(血压曲线、肌酐趋势)
|
||||
6. 展示 AI 建议卡片:"您的血压近一周有上升趋势,建议减少盐分摄入"
|
||||
|
||||
**话术:**
|
||||
> "张大爷在家打开手机,不用打电话、不用跑医院,系统自动提醒他有随访要完成。填个问卷 2 分钟,医生那边就能看到。趋势图也让他自己看到身体变化,比口头解释直观得多。"
|
||||
|
||||
**突出能力:** 小程序主动提醒、随访问卷、趋势可视化、AI 健康建议触达
|
||||
|
||||
**预案:** 真机失败 → 播放 15 秒小程序录屏,重点展示随访提醒和趋势图
|
||||
|
||||
---
|
||||
|
||||
### 场景 5:危急值告警(Day 14)— 护士 + 系统联动
|
||||
|
||||
**操作:** 先小程序,再切 Web 端 | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. **小程序端**(快速操作):张大爷录入血压 168/95 → 提交
|
||||
2. **切到 Web 端**(`nurse1` 登录):
|
||||
- 顶部弹出告警通知 "危急值告警:张建国 收缩压 168mmHg"
|
||||
- 点击进入告警列表 → 红色高亮显示
|
||||
- 点击告警详情 → 展示:触发规则(收缩压>160)、当前值、历史趋势
|
||||
- 点击「确认」→ 状态变为"已确认"
|
||||
- 点击「处理」→ 录入处理备注:"已电话通知患者,建议立即到门诊"
|
||||
- 状态变为"已处理"
|
||||
3. **回到小程序端**:张大爷收到消息"您的血压偏高,李医生建议您尽快来院检查"
|
||||
|
||||
**话术:**
|
||||
> "张大爷在家量了个血压,168。以前这种情况没人知道,可能拖到下次复诊才发现。现在数据一传上来,护士工作站立刻弹告警。护士确认后打电话给患者,15 分钟内完成从发现到处理。这才是真正的「主动关怀」。"
|
||||
|
||||
**突出能力:** 实时告警、分级处理、跨端联动(小程序录入→Web 告警→小程序反馈)
|
||||
|
||||
---
|
||||
|
||||
### 场景 6:随访闭环(Day 21)— 健康管理师视角
|
||||
|
||||
**登录:** Web 端 `health_mgr` | **时长:** ~4 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 展示健康管理师工作台 → 随访任务列表显示"张建国 - 肾功能复查随访 - 即将到期"
|
||||
2. 点击执行随访 → 选择"电话随访"
|
||||
3. 录入随访记录:
|
||||
- 患者状态:"已完成肾功能检查,肌酐降至 98"
|
||||
- 遵医行为:"按时服药,控制饮食"
|
||||
- 下一步:"继续观察,3 个月后复查"
|
||||
4. 提交 → 随访状态变为"已完成"
|
||||
5. 展示随访历史时间线 → Day 3 创建 → Day 7 问卷 → Day 21 电话随访,完整记录
|
||||
|
||||
**话术:**
|
||||
> "30 天的管理周期里,每一步都有记录。从 AI 发现问题、医生决策、患者问卷、到健康管理师电话回访,全部可追溯。卫健委来检查,一导出就是完整的健康管理档案。"
|
||||
|
||||
**突出能力:** 随访全流程记录、可追溯、健康管理闭环
|
||||
|
||||
---
|
||||
|
||||
### 场景 7:数据说话(Day 30)— 管理员视角
|
||||
|
||||
**登录:** Web 端 `admin` | **时长:** ~3 分钟
|
||||
|
||||
**操作步骤:**
|
||||
1. 展示运营仪表盘:
|
||||
- 本月管理患者数
|
||||
- 随访完成率
|
||||
- AI 分析覆盖率
|
||||
- 告警响应平均时间
|
||||
2. 展示趋势图:患者增长曲线、随访完成率趋势
|
||||
3. 切到「内容管理」→ 展示已发布的健康科普文章(阅读量、转发量)
|
||||
4. 切到「积分商城」→ 展示患者积分排行、兑换记录
|
||||
|
||||
**话术:**
|
||||
> "张大爷的故事不是个例。系统帮你管每一个患者,而且每一步都有数据。随访完成率从手工追踪的 40% 提升到系统化管理后能做到 80% 以上。这些数据就是你们向卫健委、向患者证明管理质量的最好证据。"
|
||||
|
||||
**突出能力:** 运营数据可视化、管理质量量化、内容运营
|
||||
|
||||
**重要说明:** 单个患者(张大爷)的数据不足以支撑仪表盘的说服力。演示前**必须**预置 20-30 个背景患者数据 + 若干随访/告警记录,让仪表盘显示有意义的统计数字。在数据预置脚本中一并处理。
|
||||
|
||||
---
|
||||
|
||||
### 收尾(5 分钟)
|
||||
|
||||
**总结话术:**
|
||||
> "总结一下 HMS 带来的三个核心变化:
|
||||
> 1. **从被动到主动** — AI 帮你看数据,系统帮你盯着患者
|
||||
> 2. **从纸质到数字** — 每一步可追溯,检查随时可导出
|
||||
> 3. **从单点到闭环** — 体检不是终点,30 天持续管理才是"
|
||||
|
||||
**收集反馈(3 个问题):**
|
||||
1. "您刚才看到的流程中,哪些环节对您机构最有价值?"
|
||||
2. "有没有我们没覆盖到、但您实际工作中很重要的场景?"
|
||||
3. "您更关心 Web 管理端还是患者小程序端的能力?"
|
||||
|
||||
---
|
||||
|
||||
## 5. V1 发布前必修项
|
||||
|
||||
### 5.1 必修(阻塞发布)
|
||||
|
||||
| # | 问题 | 修复方案 | 工作量估计 |
|
||||
|---|------|----------|-----------|
|
||||
| 1 | Token 刷新并发竞态 | refresh 流程加事务 + SELECT FOR UPDATE | 0.5 天 |
|
||||
|
||||
### 5.2 建议修(提升演示体验)
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | AI 分析预置截图 | 演示前手动跑一次分析,截图备用 |
|
||||
| 2 | 小程序录屏视频 | 15 秒展示随访提醒 + 趋势图 |
|
||||
| 3 | 测试数据脚本 | 一键预置张大爷的完整数据 |
|
||||
| 4 | 演示前全链路冒烟 | 跑一遍 7 个场景确认无阻塞 |
|
||||
|
||||
## 6. 下一步演化方向(演示后收集)
|
||||
|
||||
| 方向 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| HIS 系统集成 | 场景 1 | 演示后可能被问"能不能对接我们现有 HIS" |
|
||||
| 报告导出 | 场景 6 | 卫健委检查需要标准格式报告 |
|
||||
| 多科室支持 | 客户反馈 | 当前以肾病/体检为主,其他科室扩展 |
|
||||
| 微信服务号推送 | 场景 4 | 小程序消息触达有限,服务号更灵活 |
|
||||
| 设备直连 | 场景 5 | 血压计/血糖仪 BLE 直连小程序 |
|
||||
|
||||
## 7. Q&A 异议处理
|
||||
|
||||
### 客户可能提出的问题及建议回答
|
||||
|
||||
**Q: 能不能对接我们现有的 HIS/EMR 系统?**
|
||||
> HMS 提供标准 FHIR R4 接口和 RESTful API,支持 HL7 标准数据交换。具体集成方案需要了解贵院 HIS 的品牌和版本,我们可以安排技术团队做接口评估。通常 2-4 周可以完成基础对接。
|
||||
|
||||
**Q: 患者数据安全如何保障?**
|
||||
> 数据存储采用 PII 加密(姓名/身份证/手机号等敏感字段加密存储),多租户隔离确保不同机构数据完全独立。系统支持私有化部署,数据不出院。后端使用 Rust 语言开发,天然免疫内存安全漏洞。
|
||||
|
||||
**Q: AI 分析的准确率如何?**
|
||||
> 当前 AI 模块定位是「辅助筛查」,发现异常趋势后由医生做最终决策。不是替代医生诊断,而是帮医生从海量数据中找到需要关注的患者。所有 AI 建议都需要医生审批才生效。
|
||||
|
||||
**Q: 部署方式有哪些?**
|
||||
> 支持 SaaS(按年付费,我们运维)和私有化部署(一次性 + 年维护费,部署在客户服务器)。SaaS 适合快速上线,私有化适合数据合规要求高的机构。
|
||||
|
||||
**Q: 价格怎么算?**
|
||||
> 根据机构规模(管理患者数、医护账号数)定制方案。演示后我们可以根据贵院的具体需求出一份详细报价。
|
||||
|
||||
**Q: 医护人员需要培训多久?**
|
||||
> 系统设计遵循「零培训」理念——医生工作台只展示待办,护士录入界面跟纸质表单一样直观。通常 30 分钟上手,1 天熟练。我们提供远程培训和操作手册。
|
||||
|
||||
## 8. DRY RUN 计划
|
||||
|
||||
| 阶段 | 时间 | 内容 |
|
||||
|------|------|------|
|
||||
| D-7 | 演示前 7 天 | 修完 P0 问题(Token 刷新、AI 触发入口验证) |
|
||||
| D-5 | 演示前 5 天 | 编写数据预置脚本,预置张大爷完整数据 |
|
||||
| D-3 | 演示前 3 天 | 第一次 DRY RUN:完整走 7 个场景,记录阻塞点 |
|
||||
| D-2 | 演示前 2 天 | 修复 DRY RUN 发现的问题,预置 20-30 个背景患者数据 |
|
||||
| D-1 | 演示前 1 天 | 第二次 DRY RUN(带投影/网络),确认全链路无阻塞 |
|
||||
| D-Day | 演示当天 | 提前 1 小时启动环境,30 分钟前最终冒烟 |
|
||||
@@ -0,0 +1,476 @@
|
||||
# V1 客户演示准备 — 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 修复已知 CRITICAL 问题,预置演示数据,完成 DRY RUN 验证,确保 V1 客户演示 7 个场景端到端无阻塞。
|
||||
|
||||
**Architecture:** 按依赖关系分 6 个 Task:先修 CRITICAL(Token 竞态),再验证关键链路(告警、AI),然后预置数据,最后全链路冒烟。每个 Task 独立可提交。
|
||||
|
||||
**Tech Stack:** Rust (SeaORM + Axum), TypeScript/React (Web 前端), SQL (数据预置), Taro (小程序)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-09-v1-customer-demo-plan-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Modify | `crates/erp-auth/src/service/token_service.rs:156-176` | revoke 改为原子操作 |
|
||||
| Modify | `crates/erp-auth/src/service/auth_service.rs:187-258` | refresh 流程使用原子 revoke |
|
||||
| Create | `crates/erp-server/tests/integration/auth_concurrent_tests.rs` | 并发刷新测试 |
|
||||
| Create | `scripts/demo-seed.sql` | 演示数据预置脚本 |
|
||||
| Verify | `crates/erp-health/src/service/seed.rs` | 确认告警规则覆盖演示场景 |
|
||||
| Verify | `apps/web/src/pages/health/components/LabReportsTab.tsx:36-57` | 确认 AI 触发按钮可用 |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Token 刷新竞态修复
|
||||
|
||||
### Task 1: 修复 Token 刷新并发竞态(CRITICAL)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-auth/src/service/token_service.rs` — 新增 `revoke_by_hash_atomic` 方法
|
||||
- Modify: `crates/erp-auth/src/service/auth_service.rs:193-197` — refresh 中改用原子操作
|
||||
- Create: `crates/erp-server/tests/integration/auth_concurrent_tests.rs`
|
||||
|
||||
**设计说明:** JWT claims 中没有 token 数据库 ID(`id` 列),只有 `sub`(user_id) 和 `tid`(tenant_id)。因此原子 CAS 应该使用 `token_hash` 作为匹配条件——先用 JWT 解码获取原始 token,计算 SHA-256 哈希,再用 `UPDATE WHERE token_hash = ? AND revoked_at IS NULL` 做原子操作。这样不需要修改 JWT 结构。
|
||||
|
||||
- [ ] **Step 1: 在 token_service.rs 新增 `revoke_by_hash_atomic` 方法**
|
||||
|
||||
在 `crates/erp-auth/src/service/token_service.rs` 第 176 行(`revoke_token` 方法之后)新增:
|
||||
|
||||
```rust
|
||||
/// 原子操作:通过 token_hash 验证并撤销 refresh token。
|
||||
/// 如果 token 已被撤销(rows_affected == 0),返回 AuthError::TokenRevoked。
|
||||
pub async fn revoke_by_hash_atomic(
|
||||
db: &DatabaseConnection,
|
||||
token_hash: &str,
|
||||
user_id: Uuid,
|
||||
) -> AuthResult<()> {
|
||||
use user_token::Entity as UserToken;
|
||||
let result = UserToken::update_many()
|
||||
.col_expr(
|
||||
user_token::Column::RevokedAt,
|
||||
sea_orm::sea_query::Expr::value(Some(chrono::Utc::now().naive_utc())),
|
||||
)
|
||||
.filter(user_token::Column::TokenHash.eq(token_hash))
|
||||
.filter(user_token::Column::UserId.eq(user_id))
|
||||
.filter(user_token::Column::RevokedAt.is_null())
|
||||
.exec(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(AuthError::TokenRevoked);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
需要新增导入:`use sea_orm::sea_query::Expr;`(参考 `consultation_service.rs:683` 的模式)
|
||||
|
||||
- [ ] **Step 2: 改造 auth_service.rs 的 refresh 流程**
|
||||
|
||||
在 `crates/erp-auth/src/service/auth_service.rs:193-197`,将当前的 validate + revoke 两步替换:
|
||||
|
||||
```rust
|
||||
// 旧代码(第 193-197 行):
|
||||
// let claims = TokenService::validate_refresh_token(&self.token, &self.db).await?;
|
||||
// TokenService::revoke_token(&self.db, &claims.token_id, claims.user_id).await?;
|
||||
|
||||
// 新代码:
|
||||
// 1. JWT 解码获取 claims(不查数据库)
|
||||
let claims = TokenService::decode_refresh_token(&self.token)?;
|
||||
// 2. 计算 token 的 SHA-256 哈希
|
||||
let token_hash = TokenService::hash_token(&self.token);
|
||||
// 3. 原子操作:通过 hash 验证 + 撤销(CAS)
|
||||
TokenService::revoke_by_hash_atomic(&self.db, &token_hash, claims.sub.parse()?).await?;
|
||||
// 4. 后续:查询用户角色权限(第 200-201 行,不变)
|
||||
```
|
||||
|
||||
注意:需要确认 `decode_refresh_token`(仅 JWT 解码)和 `hash_token`(SHA-256 计算)是否已是公开方法。如果 `validate_refresh_token` 内部已有这些逻辑,需要拆分为独立方法。
|
||||
|
||||
- [ ] **Step 3: 编译检查**
|
||||
|
||||
Run: `cargo check --package erp-auth`
|
||||
Expected: 编译通过,无错误
|
||||
|
||||
- [ ] **Step 4: 写并发刷新测试**
|
||||
|
||||
在 `crates/erp-server/tests/integration/auth_concurrent_tests.rs` 中:
|
||||
|
||||
```rust
|
||||
use crate::test_db::TestDb;
|
||||
use erp_auth::service::auth_service::AuthService;
|
||||
use erp_core::config::JwtConfig;
|
||||
|
||||
async fn setup_test_user(db: &TestDb) -> (Uuid, String, String) {
|
||||
// 创建测试用户,返回 (user_id, access_token, refresh_token)
|
||||
// 复用现有集成测试中的用户创建逻辑
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_rotates_token() {
|
||||
let db = TestDb::new().await;
|
||||
let (_user_id, _, refresh_token) = setup_test_user(&db).await;
|
||||
let jwt_config = JwtConfig::default();
|
||||
|
||||
let svc = AuthService::new(db.conn(), &jwt_config);
|
||||
// 第一次 refresh → 成功
|
||||
let result = svc.refresh(&refresh_token).await;
|
||||
assert!(result.is_ok(), "第一次 refresh 应成功");
|
||||
let new_tokens = result.unwrap();
|
||||
// 用旧 refresh_token 再次 refresh → 必须失败
|
||||
let result2 = svc.refresh(&refresh_token).await;
|
||||
assert!(result2.is_err(), "旧 token 必须不可用");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_refresh_token_reuse() {
|
||||
let db = TestDb::new().await;
|
||||
let (_user_id, _, refresh_token) = setup_test_user(&db).await;
|
||||
let jwt_config = JwtConfig::default();
|
||||
|
||||
let svc = AuthService::new(db.conn(), &jwt_config);
|
||||
let token_clone = refresh_token.clone();
|
||||
let svc_clone = // 需要确认 AuthService 是否可 Clone 或用 Arc
|
||||
|
||||
// 使用 tokio::spawn 并发发两个 refresh
|
||||
let handle1 = tokio::spawn(async move { svc.refresh(&refresh_token).await });
|
||||
let handle2 = tokio::spawn(async move { svc_clone.refresh(&token_clone).await });
|
||||
|
||||
let r1 = handle1.await.unwrap();
|
||||
let r2 = handle2.await.unwrap();
|
||||
|
||||
// 恰好一个成功、一个失败
|
||||
let ok_count = [&r1, &r2].iter().filter(|r| r.is_ok()).count();
|
||||
assert_eq!(ok_count, 1, "并发 refresh 中恰好一个成功,另一个失败");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行全部认证测试**
|
||||
|
||||
Run: `cargo test --package erp-auth`
|
||||
Expected: 全部通过
|
||||
|
||||
Run: `cargo test --package erp-server --test integration auth`
|
||||
Expected: 全部通过
|
||||
|
||||
Run: `cargo test --package erp-server --test integration auth_concurrent -- --nocapture`
|
||||
Expected: 两个测试 PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/erp-auth/src/service/token_service.rs \
|
||||
crates/erp-auth/src/service/auth_service.rs \
|
||||
crates/erp-server/tests/integration/auth_concurrent_tests.rs
|
||||
git commit -m "fix(auth): 修复 Token 刷新并发竞态条件
|
||||
|
||||
使用原子 CAS(UPDATE WHERE token_hash = ? AND revoked_at IS NULL)
|
||||
替代先查后改的非原子操作,防止同一 refresh token 被并发使用两次。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: 演示链路验证
|
||||
|
||||
### Task 2: 验证告警链路(场景 5 依赖)
|
||||
|
||||
**Files:**
|
||||
- Verify: `crates/erp-health/src/service/seed.rs` — 确认告警规则
|
||||
- Verify: `apps/web/src/pages/health/AlertDashboard.tsx:51` — 确认权限码
|
||||
- Verify: `crates/erp-health/src/handler/alert_handler.rs:82-115` — 确认操作端点
|
||||
|
||||
- [ ] **Step 1: 启动后端服务**
|
||||
|
||||
Run: `cd crates/erp-server && cargo run`
|
||||
Expected: 服务无 panic 启动在 localhost:3000
|
||||
|
||||
- [ ] **Step 2: 查询已有告警规则**
|
||||
|
||||
Run: `curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:3000/api/v1/health/alert-rules | jq '.data.items[] | {name, metric, operator, threshold}'`
|
||||
|
||||
Expected: 返回 10 条默认规则,包括:
|
||||
- 收缩压偏高 (>=140)
|
||||
- 收缩压危急 (>=180)
|
||||
|
||||
场景 5 需要"张大爷录入血压 168 触发告警"→ 使用已有的"收缩压危急 >=180"不够,需要调整场景 5 话术用血压 185,或添加一条 >=160 的规则。
|
||||
|
||||
- [ ] **Step 3: 手动测试告警触发**
|
||||
|
||||
```
|
||||
1. 以 nurse1 登录 Web
|
||||
2. 找到张大爷患者详情页
|
||||
3. 录入体征:收缩压 185 / 舒张压 95
|
||||
4. 切到告警仪表盘页面
|
||||
5. 确认出现告警条目
|
||||
6. 点击「确认」→ 状态变为已确认
|
||||
7. 点击「处理」→ 输入备注 → 状态变为已处理
|
||||
```
|
||||
|
||||
Expected: 全流程无 403、无 500
|
||||
|
||||
- [ ] **Step 4: 记录验证结果**
|
||||
|
||||
在文件头部注释验证结果。如果告警权限码正确(`health.alerts.manage`),记录为 ✅。
|
||||
如果发现任何问题,记录具体报错信息,新建 Task 修复。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 验证 AI 分析触发(场景 2 依赖)
|
||||
|
||||
**Files:**
|
||||
- Verify: `apps/web/src/pages/health/components/LabReportsTab.tsx:177-183`
|
||||
- Verify: `apps/web/src/api/ai/analysisSse.ts`
|
||||
|
||||
- [ ] **Step 1: 确认 Ollama 模型就绪**
|
||||
|
||||
Run: `ollama list`
|
||||
Expected: 输出包含 `qwen3:4b`
|
||||
|
||||
如果没有:`ollama pull qwen3:4b`
|
||||
|
||||
- [ ] **Step 2: 手动触发 AI 分析**
|
||||
|
||||
```
|
||||
1. 以 admin 登录 Web
|
||||
2. 进入张大爷患者详情页
|
||||
3. 切到「化验报告」Tab
|
||||
4. 找到一条化验报告
|
||||
5. 点击「AI 解读」按钮
|
||||
6. 等待 SSE 流式输出
|
||||
```
|
||||
|
||||
Expected: AI 分析结果流式显示,无 500 错误
|
||||
|
||||
如果 AI 解读按钮不存在或化验报告为空 → 使用预案(预置截图),在脚本中标注
|
||||
|
||||
- [ ] **Step 3: 预置 AI 分析截图(预案)**
|
||||
|
||||
如果 AI 分析成功:截图保存到 `docs/demo/screenshots/ai-analysis.png`
|
||||
如果 AI 分析失败:在实施计划中标注使用备用话术
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 验证 health_manager 测试账号
|
||||
|
||||
**Files:**
|
||||
- Verify: 数据库 `users` 表
|
||||
- Reference: `crates/erp-server/migration/src/m20260506_000125_restructure_menus_and_roles.rs:123-126`
|
||||
|
||||
- [ ] **Step 1: 查询 health_manager 角色是否存在**
|
||||
|
||||
Run: `docker exec erp-postgres psql -U erp -c "SELECT id, name, code FROM roles WHERE code = 'health_manager'"`
|
||||
|
||||
Expected: 返回 1 行
|
||||
|
||||
- [ ] **Step 2: 查询是否有测试用户关联此角色**
|
||||
|
||||
Run: `docker exec erp-postgres psql -U erp -c "SELECT u.username, r.code FROM users u JOIN user_roles ur ON u.id = ur.user_id JOIN roles r ON ur.role_id = r.id WHERE r.code = 'health_manager'"`
|
||||
|
||||
Expected: 返回至少 1 个用户
|
||||
|
||||
如果没有用户:需要通过 Web 管理界面创建一个 `health_mgr` 用户并分配 `health_manager` 角色
|
||||
|
||||
- [ ] **Step 3: 验证 health_manager 用户可登录**
|
||||
|
||||
用 health_manager 用户名 + `Admin@2026` 密码尝试登录 Web 端。
|
||||
Expected: 成功登录,工作台显示「任务工作台」
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: 演示数据预置
|
||||
|
||||
### Task 5: 编写演示数据预置脚本
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/demo-seed.sql`
|
||||
|
||||
脚本目标:一键预置以下数据(幂等,可重复执行):
|
||||
|
||||
- 张建国患者档案 + 2 份历史化验单(肌酐 88→102)
|
||||
- 20-30 个背景患者(让仪表盘有数据)
|
||||
- 若干随访任务/告警记录(让仪表盘统计有意义)
|
||||
- 3 篇 CKD 健康科普文章
|
||||
- 收缩压 >=160 告警规则(如 seed 中没有)
|
||||
|
||||
- [ ] **Step 1: 编写 SQL 脚本骨架**
|
||||
|
||||
在 `scripts/demo-seed.sql` 中:
|
||||
|
||||
```sql
|
||||
-- HMS V1 Demo Data Seed
|
||||
-- 用法: docker exec -i erp-postgres psql -U erp < scripts/demo-seed.sql
|
||||
-- 幂等:使用 ON CONFLICT DO NOTHING
|
||||
|
||||
-- 1. 确保租户 ID(从现有租户获取)
|
||||
-- 2. 张建国患者档案
|
||||
-- 3. 2 份历史化验单(3 个月前 肌酐 88,1 个月前 肌酐 102)
|
||||
-- 4. 20 个背景患者(随机姓名,基础体征数据)
|
||||
-- 5. 若干随访任务(不同状态:pending/completed)
|
||||
-- 6. 若干告警记录(不同状态:pending/acknowledged/resolved)
|
||||
-- 7. 3 篇 CKD 科普文章
|
||||
-- 8. 收缩压 >=160 告警规则
|
||||
```
|
||||
|
||||
注意:所有 INSERT 需包含 `tenant_id`、`created_at`、`updated_at`、`created_by`、`updated_by`、`version`、`id`(UUID v7)字段。参考现有 Entity 的字段结构。
|
||||
|
||||
- [ ] **Step 2: 编写张建国患者 + 化验单数据**
|
||||
|
||||
```sql
|
||||
-- 患者档案
|
||||
INSERT INTO patients (id, tenant_id, name, gender, birth_date, phone, ...)
|
||||
VALUES (
|
||||
'019dcd34-bc4d-72c1-8c19-77ce1f4839d6', -- 使用已知测试患者 ID
|
||||
(SELECT id FROM tenants LIMIT 1),
|
||||
'张建国', 'male', '1961-03-15', '13800138001', ...
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 化验单 1:3 个月前 肌酐 88
|
||||
INSERT INTO lab_reports (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
|
||||
-- 化验单 1 的 items:肌酐 88 μmol/L
|
||||
INSERT INTO lab_report_items (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 化验单 2:1 个月前 肌酐 102
|
||||
INSERT INTO lab_reports (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
|
||||
-- 化验单 2 的 items:肌酐 102 μmol/L
|
||||
INSERT INTO lab_report_items (...) VALUES (...) ON CONFLICT (id) DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写背景患者批量数据**
|
||||
|
||||
使用 SQL generate_series 生成 20-30 个虚拟患者:
|
||||
|
||||
```sql
|
||||
INSERT INTO patients (id, tenant_id, name, gender, birth_date, ...)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
(SELECT id FROM tenants LIMIT 1),
|
||||
'测试患者' || i,
|
||||
CASE WHEN i % 2 = 0 THEN 'male' ELSE 'female' END,
|
||||
CURRENT_DATE - (30 + (i * 37) % 50) * INTERVAL '1 year',
|
||||
...
|
||||
FROM generate_series(1, 25) AS i
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编写随访任务和告警记录**
|
||||
|
||||
为背景患者生成不同状态的随访任务和告警记录,让仪表盘统计有意义。
|
||||
|
||||
- [ ] **Step 5: 编写科普文章和告警规则**
|
||||
|
||||
```sql
|
||||
-- 3 篇 CKD 科普文章
|
||||
INSERT INTO articles (title, content, category, status, ...) VALUES
|
||||
('慢性肾病患者的饮食指南', '...', 'nutrition', 'published', ...),
|
||||
('CKD 患者运动建议', '...', 'exercise', 'published', ...),
|
||||
('慢性肾病常用药物说明', '...', 'medication', 'published', ...)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 收缩压 >=160 告警规则(如果 seed 中没有)
|
||||
INSERT INTO alert_rules (name, metric, operator, threshold, ...)
|
||||
VALUES ('收缩压偏高(演示用)', 'systolic_bp', '>=', 160, ...)
|
||||
ON CONFLICT DO NOTHING;
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 执行脚本验证**
|
||||
|
||||
Run: `docker exec -i erp-postgres psql -U erp < scripts/demo-seed.sql`
|
||||
Expected: 无错误,所有 INSERT 成功或 ON CONFLICT 跳过
|
||||
|
||||
- [ ] **Step 7: 验证数据完整性**
|
||||
|
||||
```
|
||||
1. 查询张建国患者:SELECT * FROM patients WHERE name = '张建国'
|
||||
2. 查询化验单数量:SELECT count(*) FROM lab_reports WHERE patient_id = ...
|
||||
3. 查询背景患者数:SELECT count(*) FROM patients WHERE name LIKE '测试患者%'
|
||||
4. 查询文章数:SELECT count(*) FROM articles WHERE status = 'published'
|
||||
Expected: 1 张建国 + 2 化验单 + 25 背景患者 + 3 文章
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/demo-seed.sql
|
||||
git commit -m "chore(demo): V1 演示数据预置脚本
|
||||
|
||||
一键预置张建国患者+化验单+25背景患者+随访+告警+科普文章。
|
||||
幂等设计,可重复执行。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: 全链路 DRY RUN
|
||||
|
||||
### Task 6: 端到端 DRY RUN(7 个场景)
|
||||
|
||||
**前置条件:** Task 1-5 全部完成
|
||||
|
||||
- [ ] **Step 1: 环境启动检查**
|
||||
|
||||
```bash
|
||||
# 1. PostgreSQL
|
||||
docker exec erp-postgres pg_isready
|
||||
|
||||
# 2. 后端
|
||||
curl -s http://localhost:3000/api/v1/auth/health | jq .
|
||||
|
||||
# 3. Web 前端
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:5174
|
||||
|
||||
# 4. Ollama
|
||||
ollama list | grep qwen3
|
||||
```
|
||||
|
||||
Expected: 全部 200/ready
|
||||
|
||||
- [ ] **Step 2: 场景 1 — 护士建档**
|
||||
|
||||
登录 nurse1 → 新建患者/查找张建国 → 录入体征 → 查看化验报告
|
||||
Expected: 全流程无报错
|
||||
|
||||
- [ ] **Step 3: 场景 2 — AI 分析**
|
||||
|
||||
进入张建国化验报告 → 点击 AI 解读(或展示预置结果)
|
||||
Expected: AI 输出正常或截图备用
|
||||
|
||||
- [ ] **Step 4: 场景 3 — 医生审批**
|
||||
|
||||
登录 doctor1 → 查看 AI 建议 → 同意 → 查看随访任务
|
||||
Expected: 随访任务自动生成
|
||||
|
||||
- [ ] **Step 5: 场景 4 — 小程序**
|
||||
|
||||
打开小程序(开发者工具)→ 查看消息/随访 → 填写问卷 → 查看趋势
|
||||
Expected: 页面正常渲染,数据正确
|
||||
|
||||
- [ ] **Step 6: 场景 5 — 告警**
|
||||
|
||||
小程序录入血压 185/95 → Web nurse1 查看告警 → 确认 → 处理
|
||||
Expected: 告警实时出现,可操作
|
||||
|
||||
- [ ] **Step 7: 场景 6 — 随访**
|
||||
|
||||
登录 health_manager → 查看随访任务 → 执行 → 录入记录
|
||||
Expected: 随访完成,状态更新
|
||||
|
||||
- [ ] **Step 8: 场景 7 — 仪表盘**
|
||||
|
||||
登录 admin → 查看统计仪表盘 → 查看文章 → 查看积分
|
||||
Expected: 数据有意义(非零)
|
||||
|
||||
- [ ] **Step 9: 记录 DRY RUN 结果**
|
||||
|
||||
在 `docs/qa/demo-dry-run-results.md` 中记录每个场景的结果:
|
||||
- ✅ 通过 / ❌ 失败(附具体错误)
|
||||
- 阻塞问题 → 新建 Task 修复
|
||||
- 可跳过场景标注
|
||||
|
||||
- [ ] **Step 10: Commit DRY RUN 报告**
|
||||
|
||||
```bash
|
||||
git add docs/qa/demo-dry-run-results.md
|
||||
git commit -m "docs: V1 Demo DRY RUN 结果报告"
|
||||
```
|
||||
Reference in New Issue
Block a user