Chunk 1: Token 刷新竞态修复(原子 CAS via token_hash) Chunk 2: 告警/AI/health_manager 链路验证 Chunk 3: 演示数据预置脚本(张建国 + 25 背景患者) Chunk 4: 端到端 DRY RUN(7 场景验证)
16 KiB
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 方法之后)新增:
/// 原子操作:通过 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 两步替换:
// 旧代码(第 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 中:
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
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 中:
-- 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: 编写张建国患者 + 化验单数据
-- 患者档案
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 个虚拟患者:
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: 编写科普文章和告警规则
-- 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
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: 环境启动检查
# 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 报告
git add docs/qa/demo-dry-run-results.md
git commit -m "docs: V1 Demo DRY RUN 结果报告"