|
|
@@ -0,0 +1,652 @@
|
|
|
+---
|
|
|
+doc_id: DEV-202606-006
|
|
|
+feature_id: FEAT-202606-005-saas-tenant-isolation
|
|
|
+type: dev-progress
|
|
|
+title: HIS 多医院动态路由实施计划
|
|
|
+status: reviewing
|
|
|
+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`
|
|
|
+
|
|
|
+- [ ] **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<HisRouteDefinition> routes) {
|
|
|
+ HisRoutingProperties properties = new HisRoutingProperties();
|
|
|
+ properties.setStrict(strict);
|
|
|
+ properties.setFallbackEnabled(fallbackEnabled);
|
|
|
+ properties.setRoutes(routes);
|
|
|
+ return properties;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp \
|
|
|
+ -DskipTests=false -Dprofiles.active= \
|
|
|
+ -Dtest=HisClientRouterTest test
|
|
|
+```
|
|
|
+
|
|
|
+预期:因路由类不存在而编译失败。
|
|
|
+
|
|
|
+- [ ] **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` 必须按三个字段完整匹配,禁止单字段或通配符匹配。
|
|
|
+
|
|
|
+- [ ] **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);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **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
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 运行路由测试确认通过**
|
|
|
+
|
|
|
+运行 Step 2 命令,预期全部通过。
|
|
|
+
|
|
|
+- [ ] **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`
|
|
|
+
|
|
|
+- [ ] **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);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp \
|
|
|
+ -DskipTests=false -Dprofiles.active= \
|
|
|
+ -Dtest=HisToolInvokeServiceTest test
|
|
|
+```
|
|
|
+
|
|
|
+预期:`HisToolInvokeService` 仍直接使用单一 `HisClient`。
|
|
|
+
|
|
|
+- [ ] **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<String, Object> result = dispatch(
|
|
|
+ hisClient, toolName, inputs, traceId(request), idempotencyKey);
|
|
|
+```
|
|
|
+
|
|
|
+`dispatch`、`markMockPaid` 和 `result` 接收本次路由得到的客户端,不使用共享可变上下文。
|
|
|
+
|
|
|
+- [ ] **Step 4: 修正 Mock 标记**
|
|
|
+
|
|
|
+在 `HisClient` 增加:
|
|
|
+
|
|
|
+```java
|
|
|
+default boolean isMock() {
|
|
|
+ return false;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+现有两个 Mock 客户端覆盖为 `true`。工具响应使用 `hisClient.isMock()`,真实 Provider
|
|
|
+不得被标记为 Mock。
|
|
|
+
|
|
|
+- [ ] **Step 5: 运行 MCP 应用测试**
|
|
|
+
|
|
|
+运行 Step 2 命令,预期全部通过。
|
|
|
+
|
|
|
+- [ ] **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`
|
|
|
+
|
|
|
+- [ ] **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();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp \
|
|
|
+ -DskipTests=false -Dprofiles.active= \
|
|
|
+ -Dtest=McpToolServiceTest test
|
|
|
+```
|
|
|
+
|
|
|
+预期:不存在带 `HisRouteContext` 的重载。
|
|
|
+
|
|
|
+- [ ] **Step 3: 增加上下文重载并保留旧方法**
|
|
|
+
|
|
|
+以下方法增加末尾 `HisRouteContext context` 重载:
|
|
|
+
|
|
|
+```text
|
|
|
+queryDepartments
|
|
|
+queryDoctors
|
|
|
+querySchedules
|
|
|
+lockSlot
|
|
|
+releaseSchedule
|
|
|
+createPaymentOrder
|
|
|
+markMockPaid
|
|
|
+queryPaymentStatus
|
|
|
+searchPatient
|
|
|
+createPatient
|
|
|
+createAppointment
|
|
|
+```
|
|
|
+
|
|
|
+旧方法保持签名不变,委托给 `HisRouteContext.empty()`:
|
|
|
+
|
|
|
+```java
|
|
|
+public List<HisDepartment> queryDepartments() {
|
|
|
+ return queryDepartments(HisRouteContext.empty());
|
|
|
+}
|
|
|
+
|
|
|
+public List<HisDepartment> queryDepartments(HisRouteContext context) {
|
|
|
+ return hisClientRouter.route(context).getDepartments();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+支付、挂号响应中的 `mock` 使用实际路由客户端的 `isMock()`。
|
|
|
+
|
|
|
+- [ ] **Step 4: 验证旧方法仍使用兼容回退**
|
|
|
+
|
|
|
+```java
|
|
|
+@Test
|
|
|
+void legacyMethodUsesCompatibilityFallback() {
|
|
|
+ service.queryDepartments();
|
|
|
+ verify(router).route(HisRouteContext.empty());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 运行测试确认通过**
|
|
|
+
|
|
|
+运行 Step 2 命令,预期全部通过。
|
|
|
+
|
|
|
+- [ ] **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`
|
|
|
+
|
|
|
+- [ ] **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");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **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 非数字、参数为空或记录不存在时返回空,不回退到仅会话查询。
|
|
|
+
|
|
|
+- [ ] **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"));
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 在 Orchestrator 中一次解析上下文**
|
|
|
+
|
|
|
+每次 `execute` 开始时,通过作用域会话生成 `HisRouteContext`,后续所有 HIS 查询和写操作
|
|
|
+复用该不可变对象。会话不属于当前租户时,不得调用 HIS。
|
|
|
+
|
|
|
+- [ ] **Step 5: 对话降级查询使用命令医院上下文**
|
|
|
+
|
|
|
+`AgentChatApplicationServiceImpl` 已持有可信项目和租户,并从会话创建链得到
|
|
|
+`command.hospitalId()`。`buildFallbackDepartmentData` 和
|
|
|
+`handlePostStreamCards` 调用 `McpToolService` 时传入:
|
|
|
+
|
|
|
+```java
|
|
|
+new HisRouteContext(tenantId, projectId, command.hospitalId())
|
|
|
+```
|
|
|
+
|
|
|
+不修改 `AgentChatRequest`、Controller URL 或响应结构。
|
|
|
+
|
|
|
+- [ ] **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
|
|
|
+```
|
|
|
+
|
|
|
+预期全部通过。
|
|
|
+
|
|
|
+- [ ] **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`
|
|
|
+
|
|
|
+- [ ] **Step 1: 增加无真实医院数据的默认配置**
|
|
|
+
|
|
|
+```yaml
|
|
|
+emoon:
|
|
|
+ his:
|
|
|
+ routing:
|
|
|
+ strict: false
|
|
|
+ fallback-enabled: true
|
|
|
+ routes: []
|
|
|
+```
|
|
|
+
|
|
|
+不得提交 endpoint、credentialRef 实值或真实医院标识。
|
|
|
+
|
|
|
+- [ ] **Step 2: 更新交接文档**
|
|
|
+
|
|
|
+记录:
|
|
|
+
|
|
|
+```text
|
|
|
+已完成的路由类和调用链
|
|
|
+配置项及严格模式启用步骤
|
|
|
+未修改外部接口
|
|
|
+真实 Provider 和专线仍由下游适配
|
|
|
+自动化测试结果
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 审核 SQL 影响**
|
|
|
+
|
|
|
+本方案预期无新增 DDL/DML。在数据库 Runbook 增加明确结论:
|
|
|
+
|
|
|
+```text
|
|
|
+HIS 配置文件 + Provider SPI 不新增表,不需要执行 SQL。
|
|
|
+如果下游改为数据库动态路由,必须另开设计,不得临时建表。
|
|
|
+```
|
|
|
+
|
|
|
+不得连接数据库或执行文档中的任何 SQL。
|
|
|
+
|
|
|
+- [ ] **Step 4: 检查接口影响**
|
|
|
+
|
|
|
+执行:
|
|
|
+
|
|
|
+```bash
|
|
|
+git diff -- docs/api docs/initiatives/FEAT-202606-001-unified-entry-client/对外接口联调基准.md
|
|
|
+```
|
|
|
+
|
|
|
+预期无外部接口契约差异。如出现差异,停止提交并先更新专题接口说明。
|
|
|
+
|
|
|
+- [ ] **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.
|
|
|
+
|
|
|
+- [ ] **Step 1: MCP 全量测试**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp \
|
|
|
+ -DskipTests=false -Dprofiles.active= test
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Agent 全量测试**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent \
|
|
|
+ -DskipTests=false -Dprofiles.active= test
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Reactor 编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl emoon-openplatform -am -DskipTests compile
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 架构测试**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl emoon-admin -DskipTests=false -Dprofiles.active= \
|
|
|
+ -Dtest=AiPlatformArchitectureTest test
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 已知失败复核**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn -pl emoon-openplatform -DskipTests=false -Dprofiles.active= test
|
|
|
+```
|
|
|
+
|
|
|
+必须分别记录本次新增失败和既有失败。不得把既有
|
|
|
+`TerminalMvpAcceptanceTest`、Weaviate 配置错误描述为本次通过。
|
|
|
+
|
|
|
+- [ ] **Step 6: 差异和安全审查**
|
|
|
+
|
|
|
+检查:
|
|
|
+
|
|
|
+```text
|
|
|
+无 Controller 直连 Mapper
|
|
|
+无 Card 直连 MCP
|
|
|
+无 endpoint、credentialRef、Token、密码和私钥日志
|
|
|
+无跨医院 fallback
|
|
|
+无外部接口契约变化
|
|
|
+无 SQL 实际执行记录
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **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 交接清单,不在本地伪造完成结论。
|