HIS多医院路由实施计划.md 20 KB


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。


范围门禁

  • 不修改现有 HTTP URL、方法、请求体和响应结构。
  • 不接受前端覆盖租户或项目。
  • 不在配置文件写入 HIS 密钥、Token、密码和私钥。
  • 不执行任何 DDL/DML;如实现发现 SQL 需求,只更新数据库 Runbook。
  • 不改动 Workflow、RAG、Agent、Card 已有隔离语义。
  • 严格模式不允许跨医院回退。

文件结构

文件 职责
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: 写精确匹配红灯测试

    @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);
    }
    

测试辅助方法必须创建完整配置对象,不使用隐藏全局状态:

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<HisRouteDefinition> routes) {
    HisRoutingProperties properties = new HisRoutingProperties();
    properties.setStrict(strict);
    properties.setFallbackEnabled(fallbackEnabled);
    properties.setRoutes(routes);
    return properties;
}
  • [x] Step 2: 运行测试确认失败

    mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp \
    -DskipTests=false -Dprofiles.active= \
    -Dtest=HisClientRouterTest test
    

预期:因路由类不存在而编译失败。

  • [x] Step 3: 实现最小路由对象和 SPI

    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: 补严格模式和回退测试

    @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 构造时建立不可变索引并校验:

routeId 唯一
tenantId + projectId + hospitalId 唯一
providerType 唯一
strict=true 时每条 route 都存在 Provider

严格模式失败时抛出稳定错误:

HIS_ROUTE_CONTEXT_REQUIRED
HIS_ROUTE_NOT_FOUND
HIS_PROVIDER_NOT_FOUND
HIS_ROUTE_DUPLICATED
  • Step 6: 运行路由测试确认通过

运行 Step 2 命令,预期全部通过。

  • [x] Step 7: 提交路由核心

    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: 写请求上下文路由红灯测试

    @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: 运行测试确认失败

    mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp \
    -DskipTests=false -Dprofiles.active= \
    -Dtest=HisToolInvokeServiceTest test
    

预期:HisToolInvokeService 仍直接使用单一 HisClient

  • [x] Step 3: 将 dispatch 改为显式客户端

    HisRouteContext routeContext = new HisRouteContext(
    request.getTenantId(),
    request.getProjectId() == null ? null : request.getProjectId().toString(),
    request.getHospitalId());
    HisClient hisClient = hisClientRouter.route(routeContext);
    Map<String, Object> result = dispatch(
    hisClient, toolName, inputs, traceId(request), idempotencyKey);
    

dispatchmarkMockPaidresult 接收本次路由得到的客户端,不使用共享可变上下文。

  • Step 4: 修正 Mock 标记

HisClient 增加:

default boolean isMock() {
    return false;
}

现有两个 Mock 客户端覆盖为 true。工具响应使用 hisClient.isMock(),真实 Provider 不得被标记为 Mock。

  • Step 5: 运行 MCP 应用测试

运行 Step 2 命令,预期全部通过。

  • [x] Step 6: 提交标准入口路由

    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: 写带上下文调用红灯测试

    @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: 运行测试确认失败

    mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp \
    -DskipTests=false -Dprofiles.active= \
    -Dtest=McpToolServiceTest test
    

预期:不存在带 HisRouteContext 的重载。

  • Step 3: 增加上下文重载并保留旧方法

以下方法增加末尾 HisRouteContext context 重载:

queryDepartments
queryDoctors
querySchedules
lockSlot
releaseSchedule
createPaymentOrder
markMockPaid
queryPaymentStatus
searchPatient
createPatient
createAppointment

旧方法保持签名不变,标记为 @Deprecated,并委托给 HisRouteContext.empty()

public List<HisDepartment> queryDepartments() {
    return queryDepartments(HisRouteContext.empty());
}

public List<HisDepartment> queryDepartments(HisRouteContext context) {
    return hisClientRouter.route(context).getDepartments();
}

支付、挂号响应中的 mock 使用实际路由客户端的 isMock()

  • [x] Step 4: 验证旧方法仍使用兼容回退

    @Test
    void legacyMethodUsesCompatibilityFallback() {
    service.queryDepartments();
    verify(router).route(HisRouteContext.empty());
    }
    
  • [x] Step 5: 运行测试确认通过

运行 Step 2 命令,预期全部通过。

  • [x] Step 6: 提交 Agent 工具重载

    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: 写会话作用域查询红灯测试

    @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 新增:

@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: 写卡片动作医院路由红灯测试

    @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。

  • Step 5: 对话降级查询使用命令医院上下文

AgentChatApplicationServiceImpl 已持有可信项目和租户,并从会话创建链得到 command.hospitalId()buildFallbackDepartmentDatahandlePostStreamCards 调用 McpToolService 时传入:

new HisRouteContext(tenantId, projectId, command.hospitalId())

不修改 AgentChatRequest、Controller URL 或响应结构。

  • [x] Step 6: 运行 Agent 和 OpenPlatform 目标测试

    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: 提交主链路上下文

    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: 增加无真实医院数据的默认配置

    emoon:
    his:
    routing:
      strict: false
      fallback-enabled: true
      routes: []
    

不得提交 endpoint、credentialRef 实值或真实医院标识。

  • Step 2: 更新交接文档

记录:

已完成的路由类和调用链
配置项及严格模式启用步骤
未修改外部接口
真实 Provider 和专线仍由下游适配
自动化测试结果
  • Step 3: 审核 SQL 影响

本方案预期无新增 DDL/DML。在数据库 Runbook 增加明确结论:

HIS 配置文件 + Provider SPI 不新增表,不需要执行 SQL。
如果下游改为数据库动态路由,必须另开设计,不得临时建表。

不得连接数据库或执行文档中的任何 SQL。

  • Step 4: 检查接口影响

执行:

git diff -- docs/api docs/initiatives/FEAT-202606-001-unified-entry-client/对外接口联调基准.md

预期无外部接口契约差异。如出现差异,停止提交并先更新专题接口说明。

  • [x] Step 5: 提交配置和文档

    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 全量测试

    mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp \
    -DskipTests=false -Dprofiles.active= test
    
  • [x] Step 2: Agent 全量测试

    mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent \
    -DskipTests=false -Dprofiles.active= test
    
  • [x] Step 3: Reactor 编译

    mvn -pl emoon-openplatform -am -DskipTests compile
    
  • [x] Step 4: 架构测试

    mvn -pl emoon-admin -DskipTests=false -Dprofiles.active= \
    -Dtest=AiPlatformArchitectureTest test
    
  • [x] Step 5: 已知失败复核

    mvn -pl emoon-openplatform -DskipTests=false -Dprofiles.active= test
    

必须分别记录本次新增失败和既有失败。不得把既有 TerminalMvpAcceptanceTest、Weaviate 配置错误描述为本次通过。

  • Step 6: 差异和安全审查

检查:

无 Controller 直连 Mapper
无 Card 直连 MCP
无 endpoint、credentialRef、Token、密码和私钥日志
无跨医院 fallback
无外部接口契约变化
无 SQL 实际执行记录
  • [x] Step 7: 更新最终验证记录并提交

    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 交接清单,不在本地伪造完成结论。