Compare commits

...

4 Commits

Author SHA1 Message Date
iven
3e1413aebc fix(auth): 修复 Token 刷新并发竞态条件
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
使用原子 CAS(UPDATE WHERE token_hash = ? AND revoked_at IS NULL)
替代先查后改的非原子操作,防止同一 refresh token 被并发使用两次。

新增 TokenService::validate_and_revoke_atomic 方法,将 JWT 解码、
哈希匹配和 token 撤销合并为单次数据库操作。
2026-05-09 01:53:28 +08:00
iven
36f2ba381a docs: V1 演示准备实施计划(4 Chunk / 6 Task)
Chunk 1: Token 刷新竞态修复(原子 CAS via token_hash)
Chunk 2: 告警/AI/health_manager 链路验证
Chunk 3: 演示数据预置脚本(张建国 + 25 背景患者)
Chunk 4: 端到端 DRY RUN(7 场景验证)
2026-05-09 01:47:40 +08:00
iven
a3273ca581 docs: V1 客户演示方案评审修订
根据规格评审反馈补充:
- 硬件/网络要求 + 角色切换指引
- Q&A 异议处理话术(6 个常见问题)
- DRY RUN 计划(D-7 到 D-Day)
- 扩展风险预案(告警权限码、SSE、Ollama、登录冲突)
- 场景 2 AI 触发入口操作说明
- 场景 7 背景数据要求
- 统一 CRITICAL 数量和完成度口径
2026-05-09 01:35:53 +08:00
iven
f58c60599b docs: V1 客户演示方案设计规格
面向潜在客户(体检中心/血透中心)决策层+医疗团队的演示方案。
采用患者旅程视角(张大爷30天管理历程),7个场景展示完整闭环:
建档→AI分析→医生决策→患者端→告警→随访→仪表盘。
2026-05-09 01:29:31 +08:00
4 changed files with 864 additions and 6 deletions

View File

@@ -189,12 +189,9 @@ impl AuthService {
db: &sea_orm::DatabaseConnection,
jwt: &JwtConfig<'_>,
) -> AuthResult<LoginResp> {
// Validate existing refresh token
let (old_token_id, claims) =
TokenService::validate_refresh_token(refresh_token_str, db, jwt.secret).await?;
// Revoke the old token (rotation)
TokenService::revoke_token(old_token_id, claims.sub, db).await?;
// Atomically validate and revoke the old refresh token (prevents TOCTOU race)
let claims =
TokenService::validate_and_revoke_atomic(refresh_token_str, db, jwt.secret).await?;
// Fetch fresh roles and permissions
let roles: Vec<String> = TokenService::get_user_roles(claims.sub, claims.tid, db).await?;

View File

@@ -175,6 +175,45 @@ impl TokenService {
Ok(())
}
/// Atomically validate and revoke a refresh token by hash.
/// This prevents TOCTOU race conditions during concurrent refresh requests.
/// Returns the decoded claims on success, or TokenRevoked if already consumed.
pub async fn validate_and_revoke_atomic(
token: &str,
db: &DatabaseConnection,
secret: &str,
) -> AuthResult<Claims> {
let claims = Self::decode_token(token, secret)?;
if claims.token_type != "refresh" {
return Err(AuthError::Validation("不是 refresh token".to_string()));
}
let hash = sha256_hex(token);
let now = Utc::now();
let result = user_token::Entity::update_many()
.col_expr(
user_token::Column::RevokedAt,
sea_orm::sea_query::Expr::value(Some(now.naive_utc())),
)
.col_expr(
user_token::Column::UpdatedAt,
sea_orm::sea_query::Expr::value(now.naive_utc()),
)
.filter(user_token::Column::TokenHash.eq(&hash))
.filter(user_token::Column::UserId.eq(claims.sub))
.filter(user_token::Column::TenantId.eq(claims.tid))
.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(claims)
}
/// Revoke all non-revoked refresh tokens for a given user within a tenant.
pub async fn revoke_all_user_tokens(
user_id: Uuid,

View File

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

View File

@@ -0,0 +1,346 @@
# V1 客户演示方案设计规格
> 日期: 2026-05-09 | 状态: Draft v2 | 类型: 演示方案
## 1. 背景与目标
### 1.1 为什么做这次演示
HMS 健康管理平台已完成核心功能开发700+ 次提交V2 审计 85% 完成度),进入 V1 发布阶段。需要面向潜在客户(体检中心/血透中心)进行产品演示,目标是:
- **打动决策者签约** — 展示业务价值,而非功能清单
- **收集真实反馈** — 了解客户实际工作流中的痛点,指导 V2 迭代
- **验证产品定位** — 确认「AI 驱动主动关怀引擎」的定位是否与客户需求匹配
### 1.2 当前系统状态
| 指标 | 状态 |
|------|------|
| 核心链路 | 11 条端到端链路已验证通过 |
| 已知 CRITICAL | 1 个未修复Token 刷新竞态);其余 CRITICAL告警权限码拼写、晚间血压丢失、仪表盘 500均已修复 |
| 角色测试通过率 | 84.6%R01-R05 |
| Web 前端 | 55 路由283 文件,最完整的端 |
| 小程序 | 59 页面118 文件,代码完整 |
| AI 模块 | 已对接 Ollama qwen3:4bSSE 分析可用 |
### 1.3 演示策略
- **质量优先** — 修完所有已知问题再发布
- **故事线驱动** — 用一个患者的 30 天管理历程展示完整闭环
- **单患者深度** — 而非多角色广度,降低演示事故风险
## 2. 演示信息
| 项 | 值 |
|------|------|
| 受众 | 机构决策层 + 医疗团队 |
| 时长 | 30-40 分钟 |
| 视角 | 患者旅程(张大爷的 30 天) |
| 涉及端 | Web 管理端(主力)+ 微信小程序(辅助) |
| 涉及角色 | 护士、AI、医生、患者、健康管理师、管理员 |
## 3. 准备清单
### 3.1 测试账号
| 账号 | 角色 | 密码 | 用途 |
|------|------|------|------|
| `admin` | 管理员 | `Admin@2026` | 场景 7 仪表盘 |
| `doctor1` | 医生 | `Admin@2026` | 场景 3 医生审批 |
| `nurse1` | 护士 | `Admin@2026` | 场景 1 建档 + 场景 5 告警处理 |
| `health_mgr` | 健康管理师 | `Admin@2026` | 场景 6 随访执行 |
| `zhang_daye` | 患者(小程序) | 微信登录 | 场景 4/5 患者端操作 |
### 3.2 预置测试数据
| 数据 | 说明 | 目的 |
|------|------|------|
| 患者档案(张大爷) | 张建国65岁CKD 3期 | 主角 |
| 历史化验单 ×2 | 肌酐 88→102 μmol/L 的趋势 | AI 分析需要历史对比发现趋势 |
| 随访模板 | "慢性肾病定期随访"模板 | 场景 3 医生一键生成随访 |
| 告警规则 | 肌酐>120 或 收缩压>160 | 场景 5 触发告警 |
| 健康科普文章 ×3 | CKD 饮食/运动/用药 | 场景 4 小程序内容展示 |
### 3.3 环境检查
| 检查项 | 方法 | 通过标准 |
|--------|------|----------|
| 后端服务 | `cargo run` | 无 panicSwagger 可访问 |
| Web 前端 | `pnpm dev` | 登录页正常加载 |
| 小程序 | 微信开发者工具 | 真机预览可扫码 |
| 数据库 | 迁移已执行 | 预置数据查询无空结果 |
| AI 模块 | Ollama 运行中 | SSE 分析端点可返回结果 |
| 浏览器 | Chrome 无痕模式 | 干净环境,无缓存干扰 |
### 3.4 风险预案
| 风险 | 应对措施 |
|------|----------|
| AI 分析响应慢/失败 | 预先跑一次分析,截图备用;口头说明"云端大模型更快" |
| 小程序真机扫码失败 | 准备 15 秒录屏视频展示关键页面 |
| 后端服务崩溃 | 演示前重启一次确保干净状态 |
| 数据库连接断开 | 提前验证 Docker PostgreSQL 健康状态 |
| 告警权限码 bug | 演示前验证 AlertDashboard.tsx 权限码已修复(`health.alerts.manage` |
| SSE 长连接断开 | 录制 30 秒 AI 分析过程视频备用 |
| Ollama 模型未加载 | 环境检查清单加入 `ollama list` 确认 qwen3:4b 已就绪 |
| 多角色登录冲突 | 使用多个 Chrome Profile每个角色一个独立 Profile |
| 演示超时 | 标注可跳过场景(场景 6 可一句话带过) |
### 3.5 硬件与网络要求
| 项 | 要求 |
|------|------|
| 投影仪/大屏 | 分辨率 ≥ 1920x1080 |
| 网络 | 演示机器与服务器在同一局域网,延迟 < 10ms |
| 浏览器 | Chrome ×2 个 ProfileWeb 端两个角色并行),或双屏方案 |
| 手机 | 安装微信,可扫小程序码(备用:开发者工具投屏) |
| 服务器 | 后端 + PostgreSQL + Ollama 运行在同一台机器,避免网络依赖 |
### 3.6 角色切换指引
| 切换点 | 操作 | 预计耗时 |
|--------|------|----------|
| 场景 1→2 | nurse1 退出 → admin 登录 | 15 秒 |
| 场景 2→3 | admin 退出 → doctor1 登录 | 15 秒 |
| 场景 3→4 | Web → 微信开发者工具/手机 | 10 秒 |
| 场景 4→5 | 小程序录入 → Web nurse1 告警 | 10 秒 |
| 场景 5→6 | nurse1 退出 → health_mgr 登录 | 15 秒 |
| 场景 6→7 | health_mgr 退出 → admin 登录 | 15 秒 |
**建议:** 准备 2 个 Chrome ProfileProfile A: nurse1/adminProfile B: doctor1/health_mgr减少登录切换。场景 4/5 用独立手机或开发者工具。总切换时间约 1-1.5 分钟。
## 4. 演示脚本
### 开场2 分钟)
**话术:**
> "体检中心最大的痛点是什么?患者体检完,就走了。没有后续管理,没有随访跟进,体检数据躺在系统里没人看。今天给大家演示 HMS 健康管理平台如何解决这个问题——用一个真实场景:张大爷来体检后,系统如何帮他做 30 天的持续健康管理。"
---
### 场景 1张大爷来体检Day 1 上午)— 护士视角
**登录:** Web 端 `nurse1` | **时长:** ~4 分钟
**操作步骤:**
1. 登录后展示护士工作台首页 — 一眼看到今日待办
2. 点击「患者管理」→「新建患者」
3. 填入:张建国 / 男 / 65岁 / 手机号 / 慢性肾病3期诊断标签
4. 保存 → 跳转患者详情页
5. 在患者详情页点击「体征录入」→ 录入血压 142/88、心率 72、空腹血糖 5.8
6. 点击「化验报告」→ 上传预置的化验单图片,显示肌酐值 102 μmol/L
**话术:**
> "张大爷第一次来体检中心。以前护士拿纸质表格登记,现在 30 秒建档。体征数据和化验报告立刻进入系统。"
**突出能力:** 快速建档、结构化体征录入、化验单数字化
---
### 场景 2AI 自动分析Day 1 下午)— 系统自动触发
**登录:** `admin` 或任意管理端账号 | **时长:** ~4 分钟
**操作步骤:**
1. 展示「AI 分析」页面 → 显示张大爷的分析结果
2. 点击分析详情 → 展示 AI 输出:
- "肌酐值 88→102 μmol/L3 个月持续上升趋势"
- "建议:加做肾功能全套检查,排除 CKD 进展"
- 风险等级:中风险(黄色标签)
3. 切到 AI 建议列表 → 展示系统自动生成的「建议加做肾功能检查」行动项
**话术:**
> "护士录入完数据,系统后台自动跑 AI 分析。不需要医生手动触发。AI 发现张大爷肌酐 3 个月在涨,主动建议进一步检查。这就是我们说的「主动关怀」——不是等患者出问题才看,是系统帮你盯着。"
**突出能力:** AI 自动分析、趋势发现、主动建议生成
**重要说明:** 当前 Web 端 AI 分析触发入口有限(审计报告指出"仅历史查看有 UI分析触发无入口")。演示前**必须**执行以下操作之一:
- 方案 A演示前通过 API 手动触发一次分析(`POST /api/v1/ai/analysis/...`),演示时展示已生成的结果
- 方案 B为演示临时添加一个「触发分析」按钮到患者详情页
- 推荐方案 A配合话术调整"这是系统刚才自动生成的分析结果"
**预案:** AI 分析慢或失败 → 展示预置截图,口头说明"接入云端大模型后速度更快"
---
### 场景 3医生一秒决策Day 3— 医生视角
**登录:** Web 端 `doctor1` | **时长:** ~5 分钟
**操作步骤:**
1. 展示医生工作台 → 待办区域显示"1 条 AI 建议待审批"
2. 点击进入 → 查看 AI 分析详情 + 患者历史数据
3. 点击「同意建议」→ 系统自动:
- 生成随访任务("肾功能复查随访"2 周后到期)
- 推送小程序消息给患者
4. 展示随访任务列表 → 新任务已创建
5. 点击「预约管理」→ 演示为张大爷预约复查(选医生、选时间段、确认)
**话术:**
> "李医生早上打开系统,看到 AI 昨天的分析建议。以前要翻纸质报告、手动比对数据,现在 AI 已经帮你分析好了,医生只需要做一个决策:同意还是不同意。点一下,系统自动安排随访、自动通知患者。"
**突出能力:** AI 辅助决策、一键生成随访、自动通知患者
---
### 场景 4张大爷在家收到提醒Day 7— 小程序视角
**操作:** 微信开发者工具或真机预览 | **时长:** ~4 分钟
**操作步骤:**
1. 打开小程序首页 → 展示今日摘要1 条随访待办 + 1 篇健康科普
2. 点击「消息」Tab → 显示"您有一条新的随访任务"
3. 点击进入随访详情 → 显示随访问卷(饮食情况、用药依从性、症状变化)
4. 快速填写 2-3 项 → 提交
5. 切回「健康」Tab → 展示张大爷的体征趋势图(血压曲线、肌酐趋势)
6. 展示 AI 建议卡片:"您的血压近一周有上升趋势,建议减少盐分摄入"
**话术:**
> "张大爷在家打开手机,不用打电话、不用跑医院,系统自动提醒他有随访要完成。填个问卷 2 分钟,医生那边就能看到。趋势图也让他自己看到身体变化,比口头解释直观得多。"
**突出能力:** 小程序主动提醒、随访问卷、趋势可视化、AI 健康建议触达
**预案:** 真机失败 → 播放 15 秒小程序录屏,重点展示随访提醒和趋势图
---
### 场景 5危急值告警Day 14— 护士 + 系统联动
**操作:** 先小程序,再切 Web 端 | **时长:** ~4 分钟
**操作步骤:**
1. **小程序端**(快速操作):张大爷录入血压 168/95 → 提交
2. **切到 Web 端**`nurse1` 登录):
- 顶部弹出告警通知 "危急值告警:张建国 收缩压 168mmHg"
- 点击进入告警列表 → 红色高亮显示
- 点击告警详情 → 展示:触发规则(收缩压>160、当前值、历史趋势
- 点击「确认」→ 状态变为"已确认"
- 点击「处理」→ 录入处理备注:"已电话通知患者,建议立即到门诊"
- 状态变为"已处理"
3. **回到小程序端**:张大爷收到消息"您的血压偏高,李医生建议您尽快来院检查"
**话术:**
> "张大爷在家量了个血压168。以前这种情况没人知道可能拖到下次复诊才发现。现在数据一传上来护士工作站立刻弹告警。护士确认后打电话给患者15 分钟内完成从发现到处理。这才是真正的「主动关怀」。"
**突出能力:** 实时告警、分级处理、跨端联动小程序录入→Web 告警→小程序反馈)
---
### 场景 6随访闭环Day 21— 健康管理师视角
**登录:** Web 端 `health_mgr` | **时长:** ~4 分钟
**操作步骤:**
1. 展示健康管理师工作台 → 随访任务列表显示"张建国 - 肾功能复查随访 - 即将到期"
2. 点击执行随访 → 选择"电话随访"
3. 录入随访记录:
- 患者状态:"已完成肾功能检查,肌酐降至 98"
- 遵医行为:"按时服药,控制饮食"
- 下一步:"继续观察3 个月后复查"
4. 提交 → 随访状态变为"已完成"
5. 展示随访历史时间线 → Day 3 创建 → Day 7 问卷 → Day 21 电话随访,完整记录
**话术:**
> "30 天的管理周期里,每一步都有记录。从 AI 发现问题、医生决策、患者问卷、到健康管理师电话回访,全部可追溯。卫健委来检查,一导出就是完整的健康管理档案。"
**突出能力:** 随访全流程记录、可追溯、健康管理闭环
---
### 场景 7数据说话Day 30— 管理员视角
**登录:** Web 端 `admin` | **时长:** ~3 分钟
**操作步骤:**
1. 展示运营仪表盘:
- 本月管理患者数
- 随访完成率
- AI 分析覆盖率
- 告警响应平均时间
2. 展示趋势图:患者增长曲线、随访完成率趋势
3. 切到「内容管理」→ 展示已发布的健康科普文章(阅读量、转发量)
4. 切到「积分商城」→ 展示患者积分排行、兑换记录
**话术:**
> "张大爷的故事不是个例。系统帮你管每一个患者,而且每一步都有数据。随访完成率从手工追踪的 40% 提升到系统化管理后能做到 80% 以上。这些数据就是你们向卫健委、向患者证明管理质量的最好证据。"
**突出能力:** 运营数据可视化、管理质量量化、内容运营
**重要说明:** 单个患者(张大爷)的数据不足以支撑仪表盘的说服力。演示前**必须**预置 20-30 个背景患者数据 + 若干随访/告警记录,让仪表盘显示有意义的统计数字。在数据预置脚本中一并处理。
---
### 收尾5 分钟)
**总结话术:**
> "总结一下 HMS 带来的三个核心变化:
> 1. **从被动到主动** — AI 帮你看数据,系统帮你盯着患者
> 2. **从纸质到数字** — 每一步可追溯,检查随时可导出
> 3. **从单点到闭环** — 体检不是终点30 天持续管理才是"
**收集反馈3 个问题):**
1. "您刚才看到的流程中,哪些环节对您机构最有价值?"
2. "有没有我们没覆盖到、但您实际工作中很重要的场景?"
3. "您更关心 Web 管理端还是患者小程序端的能力?"
---
## 5. V1 发布前必修项
### 5.1 必修(阻塞发布)
| # | 问题 | 修复方案 | 工作量估计 |
|---|------|----------|-----------|
| 1 | Token 刷新并发竞态 | refresh 流程加事务 + SELECT FOR UPDATE | 0.5 天 |
### 5.2 建议修(提升演示体验)
| # | 问题 | 说明 |
|---|------|------|
| 1 | AI 分析预置截图 | 演示前手动跑一次分析,截图备用 |
| 2 | 小程序录屏视频 | 15 秒展示随访提醒 + 趋势图 |
| 3 | 测试数据脚本 | 一键预置张大爷的完整数据 |
| 4 | 演示前全链路冒烟 | 跑一遍 7 个场景确认无阻塞 |
## 6. 下一步演化方向(演示后收集)
| 方向 | 来源 | 说明 |
|------|------|------|
| HIS 系统集成 | 场景 1 | 演示后可能被问"能不能对接我们现有 HIS" |
| 报告导出 | 场景 6 | 卫健委检查需要标准格式报告 |
| 多科室支持 | 客户反馈 | 当前以肾病/体检为主,其他科室扩展 |
| 微信服务号推送 | 场景 4 | 小程序消息触达有限,服务号更灵活 |
| 设备直连 | 场景 5 | 血压计/血糖仪 BLE 直连小程序 |
## 7. Q&A 异议处理
### 客户可能提出的问题及建议回答
**Q: 能不能对接我们现有的 HIS/EMR 系统?**
> HMS 提供标准 FHIR R4 接口和 RESTful API支持 HL7 标准数据交换。具体集成方案需要了解贵院 HIS 的品牌和版本,我们可以安排技术团队做接口评估。通常 2-4 周可以完成基础对接。
**Q: 患者数据安全如何保障?**
> 数据存储采用 PII 加密(姓名/身份证/手机号等敏感字段加密存储),多租户隔离确保不同机构数据完全独立。系统支持私有化部署,数据不出院。后端使用 Rust 语言开发,天然免疫内存安全漏洞。
**Q: AI 分析的准确率如何?**
> 当前 AI 模块定位是「辅助筛查」,发现异常趋势后由医生做最终决策。不是替代医生诊断,而是帮医生从海量数据中找到需要关注的患者。所有 AI 建议都需要医生审批才生效。
**Q: 部署方式有哪些?**
> 支持 SaaS按年付费我们运维和私有化部署一次性 + 年维护费部署在客户服务器。SaaS 适合快速上线,私有化适合数据合规要求高的机构。
**Q: 价格怎么算?**
> 根据机构规模(管理患者数、医护账号数)定制方案。演示后我们可以根据贵院的具体需求出一份详细报价。
**Q: 医护人员需要培训多久?**
> 系统设计遵循「零培训」理念——医生工作台只展示待办,护士录入界面跟纸质表单一样直观。通常 30 分钟上手1 天熟练。我们提供远程培训和操作手册。
## 8. DRY RUN 计划
| 阶段 | 时间 | 内容 |
|------|------|------|
| D-7 | 演示前 7 天 | 修完 P0 问题Token 刷新、AI 触发入口验证) |
| D-5 | 演示前 5 天 | 编写数据预置脚本,预置张大爷完整数据 |
| D-3 | 演示前 3 天 | 第一次 DRY RUN完整走 7 个场景,记录阻塞点 |
| D-2 | 演示前 2 天 | 修复 DRY RUN 发现的问题,预置 20-30 个背景患者数据 |
| D-1 | 演示前 1 天 | 第二次 DRY RUN带投影/网络),确认全链路无阻塞 |
| D-Day | 演示当天 | 提前 1 小时启动环境30 分钟前最终冒烟 |