Browse Source

docs: 制定HIS多医院路由实施计划

WangKang 5 days ago
parent
commit
e201f6a561

+ 652 - 0
docs/initiatives/FEAT-202606-005-saas-tenant-isolation/HIS多医院路由实施计划.md

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

+ 14 - 0
docs/initiatives/FEAT-202606-005-saas-tenant-isolation/HIS多医院路由详细设计.md

@@ -44,6 +44,16 @@ tags:
 - 管理后台动态维护路由。
 - 数据库 SQL 执行和真实双医院验收。
 
+## 本次改造原则
+
+1. 所有医院统一按租户和项目隔离。HIS 路由不得削弱 Workflow、RAG、Agent、Card、
+   会话、任务、设备和文件已有的租户边界。
+2. 默认不修改现有 HTTP URL、方法、请求体和响应结构。内部方法优先增加兼容重载;
+   如实现中确需修改外部接口,必须先同步更新本专题接口影响说明。
+3. 所有涉及的 DDL 和 DML 只写入
+   [SaaS化数据库候选变更与执行清单.md](SaaS化数据库候选变更与执行清单.md),
+   本阶段不连接目标数据库、不执行迁移 SQL。
+
 ## 总体方案
 
 ```mermaid
@@ -149,6 +159,10 @@ public interface HisClientProvider {
 
 Provider 可自行缓存客户端。Router 不持有或打印真实凭证。
 
+`HisClient` 增加默认方法 `isMock()`,默认返回 `false`;现有
+`MockHisClient`、`SqliteMockHisClient` 返回 `true`。工具响应中的 `mock` 标记必须取自
+实际路由客户端,真实 Provider 不得继续返回 `mock=true`。
+
 ## 调用链
 
 ### MCP 标准工具入口

+ 1 - 0
docs/initiatives/FEAT-202606-005-saas-tenant-isolation/专题索引.md

@@ -39,6 +39,7 @@ tags:
 |---|---|---|---|
 | 需求录入 | [需求录入.md](需求录入.md) | approved | 记录目标、边界和交付方式 |
 | 详细设计 | [HIS多医院路由详细设计.md](HIS多医院路由详细设计.md) | reviewing | P0 多医院 HIS 路由、Provider SPI 和安全约束 |
+| 实施计划 | [HIS多医院路由实施计划.md](HIS多医院路由实施计划.md) | reviewing | P0 多医院 HIS 路由 TDD 实施步骤 |
 | 开发进度 | [SaaS化代码改造与下游交接说明.md](SaaS化代码改造与下游交接说明.md) | reviewing | 代码改动、验证结果、遗留项和联调步骤 |
 | Runbook | [SaaS化数据库候选变更与执行清单.md](SaaS化数据库候选变更与执行清单.md) | reviewing | 数据库盘点、候选 SQL、执行顺序和校验 |