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:
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。
| 文件 | 职责 |
|---|---|
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 |
对话降级查询医院上下文传递 |
Files:
emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/routing/HisRouteContext.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/routing/HisRouteDefinition.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/routing/HisRoutingProperties.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/routing/HisClientProvider.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/routing/HisClientRouter.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/config/HisRoutingConfiguration.javaTest:
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 2 命令,预期全部通过。
[x] Step 7: 提交路由核心
git add emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp
git commit -m "feat: 增加HIS多医院路由核心"
Files:
emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/client/HisClient.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/client/MockHisClient.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/client/SqliteMockHisClient.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/application/HisToolInvokeService.javaTest:
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);
dispatch、markMockPaid 和 result 接收本次路由得到的客户端,不使用共享可变上下文。
在 HisClient 增加:
default boolean isMock() {
return false;
}
现有两个 Mock 客户端覆盖为 true。工具响应使用 hisClient.isMock(),真实 Provider
不得被标记为 Mock。
运行 Step 2 命令,预期全部通过。
[x] Step 6: 提交标准入口路由
git add emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp
git commit -m "feat: 接入MCP工具医院路由"
Files:
emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/ai/mcp/application/McpToolService.javaTest:
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 的重载。
以下方法增加末尾 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路由上下文重载"
Files:
emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/ai/agent/application/ConversationTerminalService.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/mcp/mapper/AiConversationMapper.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/ai/agent/application/AgentActionOrchestrator.javaemoon-openplatform/src/main/java/com/emoon/openplatform/service/impl/AgentChatApplicationServiceImpl.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/test/java/com/emoon/ai/agent/application/ConversationTerminalServiceTest.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/test/java/com/emoon/ai/agent/application/AgentActionOrchestratorTest.javaTest:
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。
AgentChatApplicationServiceImpl 已持有可信项目和租户,并从会话创建链得到
command.hospitalId()。buildFallbackDepartmentData 和
handlePostStreamCards 调用 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医院路由上下文"
Files:
emoon-openplatform/src/main/resources/application.ymldocs/initiatives/FEAT-202606-005-saas-tenant-isolation/HIS多医院路由详细设计.mddocs/initiatives/FEAT-202606-005-saas-tenant-isolation/SaaS化代码改造与下游交接说明.mddocs/initiatives/FEAT-202606-005-saas-tenant-isolation/SaaS化数据库候选变更与执行清单.mdModify:
docs/initiatives/FEAT-202606-005-saas-tenant-isolation/专题索引.md
[x] Step 1: 增加无真实医院数据的默认配置
emoon:
his:
routing:
strict: false
fallback-enabled: true
routes: []
不得提交 endpoint、credentialRef 实值或真实医院标识。
记录:
已完成的路由类和调用链
配置项及严格模式启用步骤
未修改外部接口
真实 Provider 和专线仍由下游适配
自动化测试结果
本方案预期无新增 DDL/DML。在数据库 Runbook 增加明确结论:
HIS 配置文件 + Provider SPI 不新增表,不需要执行 SQL。
如果下游改为数据库动态路由,必须另开设计,不得临时建表。
不得连接数据库或执行文档中的任何 SQL。
执行:
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多医院路由交接说明"
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 配置错误描述为本次通过。
检查:
无 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多医院路由验证结果"