--- doc_id: DEV-202606-006 feature_id: FEAT-202606-005-saas-tenant-isolation type: dev-progress title: HIS 多医院动态路由实施计划 status: implemented owner: 医梦研发团队 created_at: 2026-06-27 updated_at: 2026-06-27 reviewers: [] related_docs: - DDS-202606-004 - DEV-202606-005 related_modules: - emoon-openplatform - emoon-ai-agent - emoon-ai-mcp-api - emoon-ai-mcp tags: - SaaS - HIS - 实施计划 - TDD --- # HIS 多医院动态路由实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development > (recommended) or superpowers:executing-plans to implement this plan task-by-task. > Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在不修改现有 HTTP 契约、不执行数据库 SQL 的前提下,使 HIS 调用按 `tenantId + projectId + hospitalId` 精确路由到不同 Provider。 **Architecture:** `HisClientRouter` 读取 `HisRoutingProperties` 并选择 `HisClientProvider`。MCP 标准入口直接使用 `ToolCallRequest` 上下文,Agent/Card 主链路从租户作用域内会话解析医院;旧方法继续走兼容回退客户端。 **Tech Stack:** Java 17、Spring Boot 3、Spring Configuration Properties、JUnit 5、 Mockito、AssertJ、Maven。 --- ## 范围门禁 - [x] 不修改现有 HTTP URL、方法、请求体和响应结构。 - [x] 不接受前端覆盖租户或项目。 - [x] 不在配置文件写入 HIS 密钥、Token、密码和私钥。 - [x] 不执行任何 DDL/DML;如实现发现 SQL 需求,只更新数据库 Runbook。 - [x] 不改动 Workflow、RAG、Agent、Card 已有隔离语义。 - [x] 严格模式不允许跨医院回退。 ## 文件结构 | 文件 | 职责 | |---|---| | `emoon-ai-mcp/.../routing/HisRouteContext.java` | 三字段可信路由上下文 | | `emoon-ai-mcp/.../routing/HisRouteDefinition.java` | 单条配置路由 | | `emoon-ai-mcp/.../routing/HisRoutingProperties.java` | 配置绑定 | | `emoon-ai-mcp/.../routing/HisClientProvider.java` | 厂商 Provider SPI | | `emoon-ai-mcp/.../routing/HisClientRouter.java` | 精确匹配和兼容回退 | | `HisToolInvokeService.java` | MCP 标准入口路由 | | `McpToolService.java` | Agent 工具调用上下文重载 | | `ConversationTerminalService.java` | 租户作用域会话查询 | | `AgentActionOrchestrator.java` | 卡片动作医院上下文传递 | | `AgentChatApplicationServiceImpl.java` | 对话降级查询医院上下文传递 | ### Task 1: 路由核心 **Files:** - Create: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/routing/HisRouteContext.java` - Create: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/routing/HisRouteDefinition.java` - Create: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/routing/HisRoutingProperties.java` - Create: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/routing/HisClientProvider.java` - Create: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/routing/HisClientRouter.java` - Create: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/config/HisRoutingConfiguration.java` - Test: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/test/java/com/emoon/mcp/his/routing/HisClientRouterTest.java` - [x] **Step 1: 写精确匹配红灯测试** ```java @Test void routesTwoTenantProjectsToDifferentProviders() { HisRoutingProperties properties = properties(false, true, List.of( route("route-a", "tenant-a", "7", "hospital-a", "vendor-a"), route("route-b", "tenant-b", "8", "hospital-b", "vendor-b"))); when(providerA.providerType()).thenReturn("vendor-a"); when(providerB.providerType()).thenReturn("vendor-b"); when(providerA.getClient(properties.getRoutes().get(0))).thenReturn(clientA); when(providerB.getClient(properties.getRoutes().get(1))).thenReturn(clientB); HisClientRouter router = new HisClientRouter( properties, List.of(providerA, providerB), legacyClients); assertThat(router.route(new HisRouteContext("tenant-a", "7", "hospital-a"))) .isSameAs(clientA); assertThat(router.route(new HisRouteContext("tenant-b", "8", "hospital-b"))) .isSameAs(clientB); } ``` 测试辅助方法必须创建完整配置对象,不使用隐藏全局状态: ```java private HisRouteDefinition route(String routeId, String tenantId, String projectId, String hospitalId, String provider) { HisRouteDefinition route = new HisRouteDefinition(); route.setRouteId(routeId); route.setTenantId(tenantId); route.setProjectId(projectId); route.setHospitalId(hospitalId); route.setProvider(provider); route.setEnabled(true); return route; } private HisRoutingProperties properties(boolean strict, boolean fallbackEnabled, List routes) { HisRoutingProperties properties = new HisRoutingProperties(); properties.setStrict(strict); properties.setFallbackEnabled(fallbackEnabled); properties.setRoutes(routes); return properties; } ``` - [x] **Step 2: 运行测试确认失败** ```bash mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp \ -DskipTests=false -Dprofiles.active= \ -Dtest=HisClientRouterTest test ``` 预期:因路由类不存在而编译失败。 - [x] **Step 3: 实现最小路由对象和 SPI** ```java public record HisRouteContext(String tenantId, String projectId, String hospitalId) { public static HisRouteContext empty() { return new HisRouteContext(null, null, null); } public boolean complete() { return hasText(tenantId) && hasText(projectId) && hasText(hospitalId); } private static boolean hasText(String value) { return value != null && !value.isBlank(); } } public interface HisClientProvider { String providerType(); HisClient getClient(HisRouteDefinition definition); } ``` `HisClientRouter.route` 必须按三个字段完整匹配,禁止单字段或通配符匹配。 - [x] **Step 4: 补严格模式和回退测试** ```java @Test void strictModeRejectsUnknownHospitalWithoutCallingFallback() { HisRoutingProperties properties = properties(true, false, List.of()); HisClientRouter router = new HisClientRouter( properties, List.of(), legacyClients); assertThatThrownBy(() -> router.route(new HisRouteContext("tenant-a", "7", "unknown"))) .isInstanceOf(ServiceException.class) .hasMessageContaining("HIS_ROUTE_NOT_FOUND"); verifyNoInteractions(fallbackClient); } @Test void compatibilityModeUsesLegacyClientWhenRouteIsMissing() { HisRoutingProperties properties = properties(false, true, List.of()); when(legacyClients.getIfUnique()).thenReturn(fallbackClient); HisClientRouter router = new HisClientRouter( properties, List.of(), legacyClients); assertThat(router.route(HisRouteContext.empty())).isSameAs(fallbackClient); } ``` - [x] **Step 5: 实现严格模式、回退和配置校验** `HisClientRouter` 构造时建立不可变索引并校验: ```text routeId 唯一 tenantId + projectId + hospitalId 唯一 providerType 唯一 strict=true 时每条 route 都存在 Provider ``` 严格模式失败时抛出稳定错误: ```text HIS_ROUTE_CONTEXT_REQUIRED HIS_ROUTE_NOT_FOUND HIS_PROVIDER_NOT_FOUND HIS_ROUTE_DUPLICATED ``` - [x] **Step 6: 运行路由测试确认通过** 运行 Step 2 命令,预期全部通过。 - [x] **Step 7: 提交路由核心** ```bash git add emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp git commit -m "feat: 增加HIS多医院路由核心" ``` ### Task 2: MCP 标准工具入口接入路由 **Files:** - Modify: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/client/HisClient.java` - Modify: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/client/MockHisClient.java` - Modify: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/client/SqliteMockHisClient.java` - Modify: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/application/HisToolInvokeService.java` - Test: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/test/java/com/emoon/mcp/his/application/HisToolInvokeServiceTest.java` - [x] **Step 1: 写请求上下文路由红灯测试** ```java @Test void invokesHospitalClientResolvedFromRequestScope() { ToolCallRequest request = ToolCallRequest.builder() .toolName("his.queryDepartments") .tenantId("tenant-a") .projectId(7) .hospitalId("hospital-a") .inputs(Map.of()) .build(); when(router.route(new HisRouteContext("tenant-a", "7", "hospital-a"))) .thenReturn(hospitalAClient); service.invoke(request); verify(hospitalAClient).getDepartments(); verifyNoInteractions(fallbackClient); } ``` - [x] **Step 2: 运行测试确认失败** ```bash mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp \ -DskipTests=false -Dprofiles.active= \ -Dtest=HisToolInvokeServiceTest test ``` 预期:`HisToolInvokeService` 仍直接使用单一 `HisClient`。 - [x] **Step 3: 将 dispatch 改为显式客户端** ```java HisRouteContext routeContext = new HisRouteContext( request.getTenantId(), request.getProjectId() == null ? null : request.getProjectId().toString(), request.getHospitalId()); HisClient hisClient = hisClientRouter.route(routeContext); Map result = dispatch( hisClient, toolName, inputs, traceId(request), idempotencyKey); ``` `dispatch`、`markMockPaid` 和 `result` 接收本次路由得到的客户端,不使用共享可变上下文。 - [x] **Step 4: 修正 Mock 标记** 在 `HisClient` 增加: ```java default boolean isMock() { return false; } ``` 现有两个 Mock 客户端覆盖为 `true`。工具响应使用 `hisClient.isMock()`,真实 Provider 不得被标记为 Mock。 - [x] **Step 5: 运行 MCP 应用测试** 运行 Step 2 命令,预期全部通过。 - [x] **Step 6: 提交标准入口路由** ```bash git add emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp git commit -m "feat: 接入MCP工具医院路由" ``` ### Task 3: Agent 工具服务增加兼容重载 **Files:** - Modify: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/ai/mcp/application/McpToolService.java` - Test: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/test/java/com/emoon/ai/mcp/application/McpToolServiceTest.java` - [x] **Step 1: 写带上下文调用红灯测试** ```java @Test void queryDepartmentsUsesScopedHospitalClient() { HisRouteContext context = new HisRouteContext("tenant-a", "7", "hospital-a"); when(router.route(context)).thenReturn(hospitalAClient); service.queryDepartments(context); verify(hospitalAClient).getDepartments(); } ``` - [x] **Step 2: 运行测试确认失败** ```bash mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp \ -DskipTests=false -Dprofiles.active= \ -Dtest=McpToolServiceTest test ``` 预期:不存在带 `HisRouteContext` 的重载。 - [x] **Step 3: 增加上下文重载并保留旧方法** 以下方法增加末尾 `HisRouteContext context` 重载: ```text queryDepartments queryDoctors querySchedules lockSlot releaseSchedule createPaymentOrder markMockPaid queryPaymentStatus searchPatient createPatient createAppointment ``` 旧方法保持签名不变,标记为 `@Deprecated`,并委托给 `HisRouteContext.empty()`: ```java public List queryDepartments() { return queryDepartments(HisRouteContext.empty()); } public List queryDepartments(HisRouteContext context) { return hisClientRouter.route(context).getDepartments(); } ``` 支付、挂号响应中的 `mock` 使用实际路由客户端的 `isMock()`。 - [x] **Step 4: 验证旧方法仍使用兼容回退** ```java @Test void legacyMethodUsesCompatibilityFallback() { service.queryDepartments(); verify(router).route(HisRouteContext.empty()); } ``` - [x] **Step 5: 运行测试确认通过** 运行 Step 2 命令,预期全部通过。 - [x] **Step 6: 提交 Agent 工具重载** ```bash git add emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp git commit -m "feat: 补充HIS路由上下文重载" ``` ### Task 4: Agent、Card 和对话降级链传递医院上下文 **Files:** - Modify: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/ai/agent/application/ConversationTerminalService.java` - Modify: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/mcp/mapper/AiConversationMapper.java` - Modify: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/ai/agent/application/AgentActionOrchestrator.java` - Modify: `emoon-openplatform/src/main/java/com/emoon/openplatform/service/impl/AgentChatApplicationServiceImpl.java` - Test: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/test/java/com/emoon/ai/agent/application/ConversationTerminalServiceTest.java` - Test: `emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/test/java/com/emoon/ai/agent/application/AgentActionOrchestratorTest.java` - Test: `emoon-openplatform/src/test/java/com/emoon/openplatform/service/impl/AgentChatApplicationServiceImplTest.java` - [x] **Step 1: 写会话作用域查询红灯测试** ```java @Test void findByScopeRequiresConversationProjectAndTenant() { when(conversationMapper.selectByConversationAndScope( "conv-1", 7, "tenant-a")).thenReturn(new AiConversation()); service.findByScope("conv-1", "7", "tenant-a"); verify(conversationMapper).selectByConversationAndScope( "conv-1", 7, "tenant-a"); } ``` - [x] **Step 2: 实现 `findByScope`** 在 `AiConversationMapper` 新增: ```java @Select(""" SELECT * FROM ai_conversation WHERE conversation_id = #{conversationId} AND project_id = #{projectId} AND tenant_id = #{tenantId} LIMIT 1 """) AiConversation selectByConversationAndScope( @Param("conversationId") String conversationId, @Param("projectId") Integer projectId, @Param("tenantId") String tenantId); ``` `findByScope` 调用该方法。项目 ID 非数字、参数为空或记录不存在时返回空,不回退到仅会话查询。 - [x] **Step 3: 写卡片动作医院路由红灯测试** ```java @Test void scopedCardActionUsesHospitalFromTenantConversation() { AiConversation conversation = new AiConversation(); conversation.setHospitalId("hospital-a"); when(conversationService.findByScope("conv-1", "7", "tenant-a")) .thenReturn(conversation); orchestrator.execute( "select_department", payload, "conv-1", "task-1", "trace-1", "7", "tenant-a"); verify(mcpToolService).queryDoctors( "neurology", new HisRouteContext("tenant-a", "7", "hospital-a")); } ``` - [x] **Step 4: 在 Orchestrator 中一次解析上下文** 每次 `execute` 开始时,通过作用域会话生成 `HisRouteContext`,后续所有 HIS 查询和写操作 复用该不可变对象。会话不属于当前租户时,不得调用 HIS。 - [x] **Step 5: 对话降级查询使用命令医院上下文** `AgentChatApplicationServiceImpl` 已持有可信项目和租户,并从会话创建链得到 `command.hospitalId()`。`buildFallbackDepartmentData` 和 `handlePostStreamCards` 调用 `McpToolService` 时传入: ```java new HisRouteContext(tenantId, projectId, command.hospitalId()) ``` 不修改 `AgentChatRequest`、Controller URL 或响应结构。 - [x] **Step 6: 运行 Agent 和 OpenPlatform 目标测试** ```bash mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent \ -DskipTests=false -Dprofiles.active= \ -Dtest=ConversationTerminalServiceTest,AgentActionOrchestratorTest test mvn -pl emoon-openplatform \ -DskipTests=false -Dprofiles.active= \ -Dtest=AgentChatApplicationServiceImplTest test ``` 预期全部通过。 - [x] **Step 7: 提交主链路上下文** ```bash git add emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent emoon-openplatform git commit -m "feat: 贯通Agent与Card医院路由上下文" ``` ### Task 5: 配置、SQL 和交接文档 **Files:** - Modify: `emoon-openplatform/src/main/resources/application.yml` - Modify: `docs/initiatives/FEAT-202606-005-saas-tenant-isolation/HIS多医院路由详细设计.md` - Modify: `docs/initiatives/FEAT-202606-005-saas-tenant-isolation/SaaS化代码改造与下游交接说明.md` - Modify: `docs/initiatives/FEAT-202606-005-saas-tenant-isolation/SaaS化数据库候选变更与执行清单.md` - Modify: `docs/initiatives/FEAT-202606-005-saas-tenant-isolation/专题索引.md` - [x] **Step 1: 增加无真实医院数据的默认配置** ```yaml emoon: his: routing: strict: false fallback-enabled: true routes: [] ``` 不得提交 endpoint、credentialRef 实值或真实医院标识。 - [x] **Step 2: 更新交接文档** 记录: ```text 已完成的路由类和调用链 配置项及严格模式启用步骤 未修改外部接口 真实 Provider 和专线仍由下游适配 自动化测试结果 ``` - [x] **Step 3: 审核 SQL 影响** 本方案预期无新增 DDL/DML。在数据库 Runbook 增加明确结论: ```text HIS 配置文件 + Provider SPI 不新增表,不需要执行 SQL。 如果下游改为数据库动态路由,必须另开设计,不得临时建表。 ``` 不得连接数据库或执行文档中的任何 SQL。 - [x] **Step 4: 检查接口影响** 执行: ```bash git diff -- docs/api docs/initiatives/FEAT-202606-001-unified-entry-client/对外接口联调基准.md ``` 预期无外部接口契约差异。如出现差异,停止提交并先更新专题接口说明。 - [x] **Step 5: 提交配置和文档** ```bash git add emoon-openplatform/src/main/resources/application.yml \ docs/initiatives/FEAT-202606-005-saas-tenant-isolation git commit -m "docs: 更新HIS多医院路由交接说明" ``` ### Task 6: 全量验证和双轴审查 **Files:** - Modify only when verification reveals a defect directly caused by Tasks 1-5. - [x] **Step 1: MCP 全量测试** ```bash mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp \ -DskipTests=false -Dprofiles.active= test ``` - [x] **Step 2: Agent 全量测试** ```bash mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent \ -DskipTests=false -Dprofiles.active= test ``` - [x] **Step 3: Reactor 编译** ```bash mvn -pl emoon-openplatform -am -DskipTests compile ``` - [x] **Step 4: 架构测试** ```bash mvn -pl emoon-admin -DskipTests=false -Dprofiles.active= \ -Dtest=AiPlatformArchitectureTest test ``` - [x] **Step 5: 已知失败复核** ```bash mvn -pl emoon-openplatform -DskipTests=false -Dprofiles.active= test ``` 必须分别记录本次新增失败和既有失败。不得把既有 `TerminalMvpAcceptanceTest`、Weaviate 配置错误描述为本次通过。 - [x] **Step 6: 差异和安全审查** 检查: ```text 无 Controller 直连 Mapper 无 Card 直连 MCP 无 endpoint、credentialRef、Token、密码和私钥日志 无跨医院 fallback 无外部接口契约变化 无 SQL 实际执行记录 ``` - [x] **Step 7: 更新最终验证记录并提交** ```bash git diff --check git status --short git add docs/initiatives/FEAT-202606-005-saas-tenant-isolation git commit -m "docs: 记录HIS多医院路由验证结果" ``` ## 计划自审 - 设计中的精确三字段路由由 Task 1 覆盖。 - MCP 标准入口由 Task 2 覆盖。 - Agent/Card 和对话降级链由 Task 3、Task 4 覆盖。 - 接口不变、SQL 不执行和凭证安全由 Task 5 覆盖。 - 编译、测试、架构约束和既有失败记录由 Task 6 覆盖。 - Workflow、RAG、Agent、Card 的全域实库越权验证仍依赖目标数据库,继续保留在 SaaS 交接清单,不在本地伪造完成结论。