diff --git a/docs/superpowers/plans/2026-05-09-v1-demo-preparation-plan.md b/docs/superpowers/plans/2026-05-09-v1-demo-preparation-plan.md new file mode 100644 index 0000000..3d68ab4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-v1-demo-preparation-plan.md @@ -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 结果报告" +```