diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 17dfb9b..df2fb5d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -144,16 +144,23 @@ function PrivateRoute({ children }: { children: React.ReactNode }) { if (!isAuthenticated) return ; const path = location.pathname; + + // 冻结路由检查 + if (FROZEN_ROUTES.some((frozen) => path.startsWith(frozen))) { + return ; + } + + // 首页/工作台始终放行 + if (path === '/' || path === '') return <>{children}; + const matchedPrefix = Object.keys(ROUTE_PERMISSIONS).find((prefix) => path.startsWith(prefix)); if (matchedPrefix) { const required = ROUTE_PERMISSIONS[matchedPrefix]; const hasAccess = required.some((r) => permissions.includes(r)); if (!hasAccess) return ; - } - - // 冻结路由检查 - if (FROZEN_ROUTES.some((frozen) => path.startsWith(frozen))) { - return ; + } else { + // 未在 ROUTE_PERMISSIONS 中注册的路由,默认拒绝 + return ; } return <>{children}; diff --git a/apps/web/src/api/health/points.ts b/apps/web/src/api/health/points.ts index 40f8c4a..8985190 100644 --- a/apps/web/src/api/health/points.ts +++ b/apps/web/src/api/health/points.ts @@ -384,61 +384,61 @@ export const pointsApi = { }, // Points Statistics - getStatistics: async () => { + getStatistics: async (opts?: { silent?: boolean }) => { const { data } = await client.get<{ success: boolean; data: PointsStatistics; - }>('/health/admin/points/statistics'); + }>('/health/admin/points/statistics', { skipGlobalError: opts?.silent } as any); return data.data; }, // --- Dashboard Statistics --- - getPatientStats: async (): Promise => { + getPatientStats: async (opts?: { silent?: boolean }): Promise => { const { data } = await client.get<{ success: boolean; data: PatientStatistics; - }>('/health/admin/statistics/patients'); + }>('/health/admin/statistics/patients', { skipGlobalError: opts?.silent } as any); return data.data; }, - getConsultationStats: async (): Promise => { + getConsultationStats: async (opts?: { silent?: boolean }): Promise => { const { data } = await client.get<{ success: boolean; data: ConsultationStatistics; - }>('/health/admin/statistics/consultations'); + }>('/health/admin/statistics/consultations', { skipGlobalError: opts?.silent } as any); return data.data; }, - getFollowUpStats: async (): Promise => { + getFollowUpStats: async (opts?: { silent?: boolean }): Promise => { const { data } = await client.get<{ success: boolean; data: FollowUpStatistics; - }>('/health/admin/statistics/follow-ups'); + }>('/health/admin/statistics/follow-ups', { skipGlobalError: opts?.silent } as any); return data.data; }, - getHealthDataStats: async (): Promise => { + getHealthDataStats: async (opts?: { silent?: boolean }): Promise => { const { data } = await client.get<{ success: boolean; data: HealthDataStats; - }>('/health/admin/statistics/health-data'); + }>('/health/admin/statistics/health-data', { skipGlobalError: opts?.silent } as any); return data.data; }, - getDialysisStats: async (): Promise => { + getDialysisStats: async (opts?: { silent?: boolean }): Promise => { const { data } = await client.get<{ success: boolean; data: DialysisStatistics; - }>('/health/admin/statistics/dialysis'); + }>('/health/admin/statistics/dialysis', { skipGlobalError: opts?.silent } as any); return data.data; }, - getPersonalStats: async (): Promise => { + getPersonalStats: async (opts?: { silent?: boolean }): Promise => { const { data } = await client.get<{ success: boolean; data: PersonalStats; - }>('/health/admin/statistics/personal-stats'); + }>('/health/admin/statistics/personal-stats', { skipGlobalError: opts?.silent } as any); return data.data; }, }; diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 29362c7..21b6811 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -394,7 +394,27 @@ export default function MainLayout({ children }: { children: React.ReactNode }) (async () => { try { const menus = await getMenusForUser(); - if (!cancelled) setDynamicMenus(menus); + if (!cancelled) { + // 根据用户权限过滤菜单:菜单项声明 permission 时,用户必须有对应权限 + const perms = useAuthStore.getState().permissions; + const isAdmin = useAuthStore.getState().user?.roles?.some((r: string) => r === 'admin') ?? false; + if (isAdmin) { + setDynamicMenus(menus); + } else { + const filterByPerm = (items: MenuInfo[]): MenuInfo[] => + items + .map((m) => ({ + ...m, + children: m.children ? filterByPerm(m.children) : undefined, + })) + .filter((m) => { + if (!m.permission) return true; + return perms.includes(m.permission); + }) + .filter((m) => m.menu_type === 'directory' || !m.children || m.children.length > 0 || !m.permission || perms.includes(m.permission)); + setDynamicMenus(filterByPerm(menus)); + } + } } catch { // fallback: 使用空数组,保留插件菜单 } diff --git a/apps/web/src/pages/health/FollowUpTaskList.tsx b/apps/web/src/pages/health/FollowUpTaskList.tsx index a2fed68..16c5b6d 100644 --- a/apps/web/src/pages/health/FollowUpTaskList.tsx +++ b/apps/web/src/pages/health/FollowUpTaskList.tsx @@ -39,6 +39,7 @@ const FOLLOW_UP_TYPE_FALLBACK = [ { value: 'phone', label: '电话' }, { value: 'outpatient', label: '门诊' }, { value: 'home_visit', label: '家访' }, + { value: 'visit', label: '上门' }, { value: 'online', label: '线上' }, { value: 'wechat', label: '微信' }, ]; @@ -47,6 +48,7 @@ const FOLLOW_UP_TYPE_MAP: Record = { phone: '电话', outpatient: '门诊', home_visit: '家访', + visit: '上门', online: '线上', wechat: '微信', }; diff --git a/apps/web/src/pages/health/StatisticsDashboard/useStatsData.ts b/apps/web/src/pages/health/StatisticsDashboard/useStatsData.ts index db3b491..f566649 100644 --- a/apps/web/src/pages/health/StatisticsDashboard/useStatsData.ts +++ b/apps/web/src/pages/health/StatisticsDashboard/useStatsData.ts @@ -53,12 +53,12 @@ export function useStatsData(): StatsData { }; await Promise.all([ - tryFetch(pointsApi.getPatientStats, setPatientStats, '患者'), - tryFetch(pointsApi.getConsultationStats, setConsultationStats, '咨询'), - tryFetch(pointsApi.getFollowUpStats, setFollowUpStats, '随访'), - tryFetch(pointsApi.getStatistics, setPointsStats, '积分'), - tryFetch(pointsApi.getHealthDataStats, setHealthDataStats, '健康数据'), - tryFetch(pointsApi.getDialysisStats, setDialysisStats, '透析'), + tryFetch(() => pointsApi.getPatientStats({ silent: true }), setPatientStats, '患者'), + tryFetch(() => pointsApi.getConsultationStats({ silent: true }), setConsultationStats, '咨询'), + tryFetch(() => pointsApi.getFollowUpStats({ silent: true }), setFollowUpStats, '随访'), + tryFetch(() => pointsApi.getStatistics({ silent: true }), setPointsStats, '积分'), + tryFetch(() => pointsApi.getHealthDataStats({ silent: true }), setHealthDataStats, '健康数据'), + tryFetch(() => pointsApi.getDialysisStats({ silent: true }), setDialysisStats, '透析'), tryFetch( async () => { const r = await doctorApi.list({ page: 1, page_size: 1 }); return r.total; }, setDoctorCount, diff --git a/crates/erp-health/src/handler/critical_alert_handler.rs b/crates/erp-health/src/handler/critical_alert_handler.rs index 16ecdb9..e97c84d 100644 --- a/crates/erp-health/src/handler/critical_alert_handler.rs +++ b/crates/erp-health/src/handler/critical_alert_handler.rs @@ -34,14 +34,20 @@ where let (items, total) = critical_alert_service::list_pending_alerts( &state, ctx.tenant_id, page, page_size, ) - .await?; + .await + .map_err(|e| { + tracing::error!(error = %e, tenant_id = %ctx.tenant_id, "查询危急值告警列表失败"); + e + })?; + + let total_pages = if page_size > 0 { total.div_ceil(page_size) } else { 0 }; Ok(axum::Json(ApiResponse::ok(PaginatedResponse { data: items, total, page, page_size, - total_pages: total.div_ceil(page_size.max(1)), + total_pages, }))) } diff --git a/crates/erp-health/src/service/validation.rs b/crates/erp-health/src/service/validation.rs index 433ee3d..a1e7dd8 100644 --- a/crates/erp-health/src/service/validation.rs +++ b/crates/erp-health/src/service/validation.rs @@ -82,7 +82,7 @@ pub fn validate_schedule_status(value: &str) -> HealthResult<()> { /// follow_up_task.follow_up_type pub fn validate_follow_up_type(value: &str) -> HealthResult<()> { validate_enum!(value, "follow_up_type", [ - "phone", "outpatient", "home_visit", "online", "wechat", + "phone", "outpatient", "home_visit", "visit", "online", "wechat", ]); Ok(()) } @@ -231,18 +231,18 @@ pub fn validate_alert_severity(value: &str) -> HealthResult<()> { /// alert.status pub fn validate_alert_status(value: &str) -> HealthResult<()> { validate_enum!(value, "alert_status", [ - "pending", "acknowledged", "resolved", "dismissed", + "pending", "active", "acknowledged", "resolved", "dismissed", ]); Ok(()) } -/// 告警状态转换校验: pending→acknowledged/dismissed, acknowledged→resolved/dismissed +/// 告警状态转换校验: pending/active→acknowledged/dismissed, acknowledged→resolved/dismissed pub fn validate_alert_status_transition(current: &str, next: &str) -> HealthResult<()> { if current == next { return Ok(()); } let allowed = match current { - "pending" => matches!(next, "acknowledged" | "dismissed"), + "pending" | "active" => matches!(next, "acknowledged" | "dismissed"), "acknowledged" => matches!(next, "resolved" | "dismissed"), _ => false, }; @@ -477,6 +477,8 @@ mod tests { #[test] fn alert_status_pending() { assert!(validate_alert_status("pending").is_ok()); } #[test] + fn alert_status_active() { assert!(validate_alert_status("active").is_ok()); } + #[test] fn alert_status_resolved() { assert!(validate_alert_status("resolved").is_ok()); } #[test] fn alert_status_invalid() { assert!(validate_alert_status("open").is_err()); } @@ -487,6 +489,10 @@ mod tests { #[test] fn alert_pending_to_dismissed() { assert!(validate_alert_status_transition("pending", "dismissed").is_ok()); } #[test] + fn alert_active_to_acknowledged() { assert!(validate_alert_status_transition("active", "acknowledged").is_ok()); } + #[test] + fn alert_active_to_dismissed() { assert!(validate_alert_status_transition("active", "dismissed").is_ok()); } + #[test] fn alert_pending_to_resolved_fails() { assert!(validate_alert_status_transition("pending", "resolved").is_err()); } #[test] fn alert_acknowledged_to_resolved() { assert!(validate_alert_status_transition("acknowledged", "resolved").is_ok()); } diff --git a/docs/qa/测试问题/deep-role-test-report-2026-05-07.md b/docs/qa/测试问题/deep-role-test-report-2026-05-07.md new file mode 100644 index 0000000..30eeedf --- /dev/null +++ b/docs/qa/测试问题/deep-role-test-report-2026-05-07.md @@ -0,0 +1,219 @@ +# 5 角色深度业务测试报告 — 2026-05-07 + +> 测试方法: API 深度测试(5 Agent 并行)+ 前端浏览器交互测试(Chrome DevTools MCP) +> 环境: 本地 dev(后端 localhost:3000 + 前端 localhost:5174) +> 所有测试均包含真实 CRUD 操作,非仅打开页面 + +## 1. 总览 + +| 角色 | API 测试项 | API PASS | API FAIL | API ISSUE | API 通过率 | 前端验证 | +|------|-----------|----------|----------|-----------|-----------|---------| +| R01 Admin | 48 | 41 | 2 | 5 | 85.4% | 工作台+9链路全覆盖 | +| R02 Doctor | 43 | 38 | 0 | 5 | 88.4% | 仪表盘+权限边界验证 | +| R03 Nurse | 37 | 32 | 1 | 4 | 86.5% | 随访监控台+待办验证 | +| R04 Health Manager | 42 | 31 | 2 | 9 | 73.8% | 今日任务流验证 | +| R05 Operator | 50 | 47 | 0 | 3 | 94.0% | 运营仪表盘+文章积分 | +| **合计** | **220** | **189** | **5** | **26** | **85.9%** | 4/5 角色工作台验证 | + +## 2. 新发现的 BUG(本次测试独有) + +### BUG-1: 患者性别/血型创建后丢失 [HIGH] + +**现象**: 新建患者时选择性别"男"和血型"A",保存后列表显示"-",编辑对话框回显"请选择性别/血型"。 + +**根因**: 前端新建表单的性别/血型 Select 组件值未正确映射到 POST 请求的 DTO 字段。`fill("男")` 仅设置显示文本,未触发组件内部 value 变更。 + +**影响**: 所有通过前端创建的患者性别和血型信息丢失。 + +**复现步骤**: +1. 患者管理 → 新建患者 +2. 填写姓名 + 选择性别"男" + 选择血型"A" +3. 保存 → 列表中性别显示"-" +4. 点击编辑 → 性别和血型回显为空 + +### BUG-2: 随访类型显示英文原始值 [MEDIUM] + +**现象**: 随访管理列表中,随访类型"visit"显示为英文原始值而非中文"上门"。"电话"类型正确显示中文。 + +**根因**: 前端随访类型映射字典缺少 `visit` → `上门` 的翻译。 + +### BUG-3: 随访状态筛选不生效 [MEDIUM] — 复现确认 + +**现象**: 随访管理页面状态筛选下拉选择后,UI 更新标签但列表数据不过滤。 + +**复现**: 筛选"已完成"后仍显示"逾期"记录,总数不变。 + +### BUG-4: 随访创建成功后对话框未关闭 [LOW] + +**现象**: 新建随访任务成功后(toast "随访任务创建成功"),对话框仍然显示,按钮处于 loading 状态。 + +**预期**: 创建成功后应自动关闭对话框。 + +### BUG-5: Operator 积分商品页面无数据 [MEDIUM] + +**现象**: 积分商品页面加载但显示 "No data"。可能原因:API 路径不匹配。 + +### BUG-6: Operator 登录后出现"服务器异常"toast [LOW] + +**现象**: operator_test 登录后出现"服务器异常,请稍后重试" toast 提示。仪表盘统计 API 可能返回 500。 + +### BUG-7: Nurse/Doctor 侧边栏显示无权限菜单 [MEDIUM] + +**现象**: Nurse 角色侧边栏可见"积分运营"和"内容运营"菜单,但 nurse 没有任何积分/文章权限。Doctor 角色侧边栏可见"随访模板管理"和"AI 用量"但无权限。 + +### BUG-8: 前端路由权限守卫不完整 [HIGH] — 复现确认 + +**现象**: 非 admin 角色通过 URL 直接访问受限页面,部分页面(如积分规则)可看到完整数据。 + +**本次复现**: +- Doctor 访问 `/health/points-rules` → 可见完整积分规则数据 +- Doctor 访问 `/users` → 可见用户列表 +- Operator 访问受限页面 → 页面空白(后端 403 拦截数据,但无"权限不足"提示) + +### BUG-9: 告警 seed 数据状态与状态机不匹配 [HIGH] — R02+R03 共现 + +**现象**: 告警 seed 数据 status="active",但后端状态机只认 "pending" 作为初始状态,导致所有 active 告警的 acknowledge/dismiss/resolve 操作均返回 422。 + +**影响范围**: R02 Doctor(3 项 ISSUE)+ R03 Nurse(3 项 ISSUE)= 6 个测试项受影响。 + +**修复建议**: 统一种子数据初始状态为 `pending`,或在状态机中增加 `active` 状态定义。 + +### BUG-10: critical-alerts API 返回 500 [HIGH] — R02+R03+R04 共现 + +**现象**: `GET /health/critical-alerts` 返回 500 Internal Server Error。Admin/Doctor/Nurse/HealthManager 都有此权限。 + +**影响范围**: R02(1 FAIL)、R03(1 FAIL)、R04(1 FAIL)= 3 个角色受影响。 + +**根因**: `critical_alert_service.rs` 查询逻辑可能存在 SQL 映射或字段缺失问题。 + +### BUG-11: 患者创建携带 id_number 字段时返回 500 [MEDIUM] — R02 发现 + +**现象**: `POST /health/patients` 携带 `id_number` 字段时返回 500,应返回 400/409 并提示原因。 + +## 3. API 层测试详细发现 + +### 3.1 R01 Admin(48 项,85.4%) + +**9 条链路全部可操作**:患者管理(6/6) → 随访管理(4/4) → 咨询管理(3/3) → 告警系统(2/2) → AI分析(2/3) → 内容管理(3/3) → 积分商城(3/3) → 线下活动(2/2) → 系统管理(7/9) + +**FAIL 项**: +- AI 用量统计端点 `/ai/usage` 不存在(404) +- 系统设置端点 `/settings` 不存在(404),功能分散在 `/config/*` + +**ISSUE 项**: +- 患者编辑时 phone/allergies/emergency_contact 未更新(DTO 映射问题) +- 文章状态更新 PUT 200 但 status 未变 published +- 积分/活动 API 路径不统一(查询 vs 创建路径不同) + +### 3.2 R02 Doctor(43 项,88.4%) + +**6 条链路测试**:患者诊疗(14/15) → 随访闭环(6/6) → 咨询接诊(6/6) → 告警处理(2/6) → AI分析(7/7) → 权限边界(7/7) + +**HIGH 发现**: +- `GET /health/critical-alerts` 返回 500 +- 告警 seed 数据 status="active" 与状态机不匹配,所有告警操作返回 400/422 + +**ISSUE**: +- `POST /health/patients` 携带 `id_number` 字段返回 500(应 400/409) + +**正面发现**: +- 患者管理、随访闭环、咨询接诊、AI 分析全部正常 +- 权限边界 100% 正确拦截(7/7) +- 乐观锁 version 字段正确工作 + +### 3.3 R03 Nurse(37 项,86.5%) + +**5 条链路 + 权限边界**:患者管理(4/4) → 随访执行(4/4) → 咨询查看(3/3) → 告警响应(2/6) → 日常监测(6/6) → 知情同意(3/3) → 行动收件箱(4/4) → 权限边界(12/12) + +**FAIL 项**: +- `GET /health/critical-alerts` 返回 500 + +**ISSUE 项**: +- 告警 acknowledge/dismiss/resolve 全部返回 422(active 状态不匹配) +- 同 R02 的 BUG-9 + +**正面发现**: +- 随访状态机正确:pending → in_progress → completed 流转顺畅 +- 日常监测完整链路通过:查看/创建/详情/更新 +- 知情同意完整链路通过:查看/创建/撤销 +- 权限边界 100% 正确拦截(12/12,含 AI/透析/模板/文章/健康数据写入) + +### 3.4 R04 Health Manager(42 项,73.8%) + +**6 条链路测试**:患者标签 → 随访管理 → 咨询管理 → 告警监测 → AI分析 → 诊断知情 + +**HIGH 发现**: +- `GET /health/critical-alerts` 返回 500(有权限但内部错误) +- `GET /health/admin/statistics/dashboard` 返回 500 + +**权限边界**: 6/6 全部正确返回 403 + +### 3.5 R05 Operator(50 项,94.0%) + +**后端权限拦截最佳**: 15 项边界测试全部正确 403 + +**链路结果**: 内容发布(12/13) + 积分商城(7/8) + 线下活动(2/2) + 设备告警只读(4/4) + AI用量(4/5) + +**ISSUE**: +- DELETE 文章返回 415(格式问题) +- PUT 积分规则需要 `data` 包装 +- AI 配额摘要返回 500 + +## 4. 前端角色工作台验证 + +| 角色 | 工作台类型 | 核心功能 | 状态 | +|------|-----------|---------|------| +| Admin | 系统管理仪表盘 | 6 服务状态 + 8 模块 + 用户活跃度 + 管理快捷入口 | PASS | +| Doctor | 医生工作台 | AI建议(1) + 危急告警(2) + 咨询(3) + 重点关注(3) + 快捷操作 | PASS | +| Nurse | 随访监控台 | 今日统计 + 待办(3类) + 告警列表 + 随访任务 + AI洞察 + 快捷操作 | PASS | +| Operator | 运营仪表盘 | AI运营摘要 + 数据卡片 + 积分动态 + 内容矩阵(6发布+4草稿) + 快捷操作 | PASS(含toast) | + +## 5. 安全评估 + +| 检查项 | 结果 | +|--------|------| +| 未认证请求拒绝 (401) | PASS — 全角色验证 | +| JWT Token 验证 | PASS — 过期/无效 token 正确拒绝 | +| 后端 RBAC 权限拦截 | PASS — 所有角色边界测试通过(403) | +| 乐观锁 (version) | PASS — 并发更新保护生效 | +| 软删除 | PASS — 删除操作正确标记 deleted_at | +| 多租户 tenant_id 注入 | PASS — JWT 中间件自动注入 | +| **前端路由守卫** | **FAIL** — 部分受限页面可通过 URL 绕过 | + +## 6. 跨角色共性问题 + +| 问题 | 影响角色 | 严重程度 | 根因 | +|------|---------|---------|------| +| critical-alerts 500 | R02+R03+R04 | HIGH | 后端查询逻辑错误 | +| 告警状态机不匹配 | R02+R03 | HIGH | seed 数据 active vs pending | +| 前端路由守卫不完整 | R02+R03+R05 | HIGH | 路由 meta.permissions 未比对 | +| 侧边栏菜单过度显示 | R02+R03 | MEDIUM | 菜单渲染缺权限码校验 | +| 随访状态筛选不生效 | R01+R02+R03+R04 | MEDIUM | 前端筛选逻辑失效 | + +## 7. 优先修复建议 + +| 优先级 | 问题 | 修复建议 | +|--------|------|---------| +| **P0** | 前端路由守卫不完整 (BUG-8) | 路由 beforeEach 全局守卫 + 权限码比对 | +| **P0** | 告警 seed 状态不匹配 (BUG-9) | 统一种子数据 status 为 pending | +| **P1** | critical-alerts 500 (BUG-10) | 排查 critical_alert_service 查询逻辑 | +| **P1** | 患者性别/血型丢失 (BUG-1) | 修复新建患者表单 Select 组件值绑定 | +| **P1** | 随访状态筛选不生效 (BUG-3) | 修复 FollowUpList 筛选逻辑 | +| **P1** | 侧边栏菜单过度显示 (BUG-7) | 菜单渲染增加权限码校验 | +| **P2** | 随访类型英文显示 (BUG-2) | 补充 visit → 上门 映射 | +| **P2** | 随访创建后对话框未关 (BUG-4) | 成功回调关闭 modal | +| **P2** | 积分商品无数据 (BUG-5) | 统一 API 路径 | +| **P2** | 患者创建 id_number 500 (BUG-11) | 修复 DTO 验证返回 400 | +| **P3** | Operator 服务器异常 toast (BUG-6) | 仪表盘统计 API 容错处理 | + +## 8. 测试数据创建 + +本次测试创建了以下数据(可在后续清理): +- 患者: "深度测试患者Admin"(admin 创建) +- 患者: "深度测试Doctor患者"(doctor_test 创建) +- 患者: "深度测试Nurse患者"(nurse_test 创建) +- 随访: 多个角色各创建的随访任务 +- 咨询: doctor_test 创建的咨询会话 +- 日常监测: nurse_test 创建的监测记录 +- 知情同意: nurse_test 创建的同意记录 +- 各 Agent 创建的测试数据若干(患者、随访、咨询、文章等)