cc 2 weeks ago
parent
commit
1f98bb9f86

+ 6 - 6
ai-server/src/main/java/com/zsjz/ai/common/config/ReactorCaseContextConfig.java

@@ -13,24 +13,24 @@ import reactor.core.scheduler.Schedulers;
  * <p><b>要解决的问题</b>:AI 对话与 AI 研判的流式链路跑在 Reactor 上
  * ({@code AgentChatController} 返回 {@code Flux<ServerSentEvent>},
  * {@code FollowupMiddleware} / {@code InsightServiceImpl} 里有
- * {@code subscribeOn(Schedulers.boundedElastic())})。ThreadLocal 与
+ * {@code subscribeOn(Schedulers.boundedElastic())})。ScopedValue 绑定与
  * {@code RequestContextHolder} 都不跨线程,工具执行时
  * {@link CaseContextHolder} 取不到案件 ID,{@code @DS("slave")} 会直接报
  * 「请先打开案件后再操作数据」,而 77 个业务工具又不可能逐个改。
  *
  * <p><b>解法</b>:注册一个全局的 Reactor 调度钩子。
  * {@link Schedulers#onScheduleHook} 的装饰器在<b>任务被提交时</b>(即调用
- * {@code schedule()} 的那个线程上)执行,此时线程上的案件上下文仍然有效
- * (请求线程由 {@code CaseContextInterceptor} 写入)。于是把 caseId 捕获进闭包,
- * 任务真正执行时再绑定,执行完恢复。
+ * {@code schedule()} 的那个线程上)执行,此时该线程上的案件作用域绑定仍然有效
+ * (请求线程由 {@code CaseContextFilter} 包进作用域)。于是把 caseId 捕获进闭包,
+ * 任务真正执行时再用 {@code runWith} 重建作用域,执行完恢复。
  *
  * <p><b>为什么链式切换也能传递</b>:链路上第一次 {@code subscribeOn} 之后,
  * 后续 {@code publishOn} 都发生在上一个任务<b>内部</b>,此时该任务的
- * {@code runWith} 还没执行 finally 清理,ThreadLocal 依然有效,于是可以继续往下一跳传。
+ * {@code runWith} 还没退出作用域,绑定依然有效,于是可以继续往下一跳传。
  *
  * <p><b>为什么只用 {@link CaseContextHolder#get()} 而不是 {@code currentCaseId()}</b>:
  * 钩子在每次任务调度时都会跑,后者会走 sa-token 兜底(可能抛异常 + 访问 Redis),
- * 在非请求线程上代价过高。这里只读 ThreadLocal,零开销。
+ * 在非请求线程上代价过高。这里只读作用域绑定,零开销。
  */
 @Slf4j
 @Configuration

+ 1 - 10
ai-server/src/main/java/com/zsjz/ai/common/config/SaTokenWebConfig.java

@@ -2,7 +2,6 @@ package com.zsjz.ai.common.config;
 
 import cn.dev33.satoken.interceptor.SaInterceptor;
 import cn.dev33.satoken.stp.StpUtil;
-import com.zsjz.ai.common.interceptor.CaseContextInterceptor;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -18,6 +17,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
  * 不要写 {@code /js/a/auth/login}。
  *
  * <p><b>执行顺序</b>:sa-token 自身的 Filter(order = -104,负责把请求包装成 sa-token 上下文)
+ * → {@code CaseContextFilter}(order = LOWEST_PRECEDENCE - 1,把案件上下文包进请求作用域)
  * → {@code LicenseFilter}(order = Integer.MAX_VALUE)→ 本配置注册的拦截器 → Controller。
  *
  * <p>未登录时 {@code checkLogin()} 抛 {@code NotLoginException},
@@ -44,19 +44,10 @@ public class SaTokenWebConfig implements WebMvcConfigurer {
             "/error",
     };
 
-    private final CaseContextInterceptor caseContextInterceptor;
-
-    public SaTokenWebConfig(CaseContextInterceptor caseContextInterceptor) {
-        this.caseContextInterceptor = caseContextInterceptor;
-    }
-
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
         registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()).isAnnotation(false))
                 .addPathPatterns("/**")
                 .excludePathPatterns(WHITELIST);
-        // 必须排在 SaInterceptor 之后:需要先确保已登录,才能从 Token-Session 读到案件 ID
-        registry.addInterceptor(caseContextInterceptor)
-                .addPathPatterns("/**");
     }
 }

+ 84 - 80
ai-server/src/main/java/com/zsjz/ai/common/context/CaseContextHolder.java

@@ -6,76 +6,55 @@ import lombok.extern.slf4j.Slf4j;
 import java.util.function.Supplier;
 
 /**
- * 当前线程的「案件 + 用户」上下文。
+ * 当前作用域的「案件 + 用户」上下文(JDK 25 ScopedValue,JEP 506)。
  *
- * <p><b>为什么需要它</b>:原 {@link com.zsjz.ai.common.utils.StateManager} 是 JVM 级单例,
- * 服务端多人同时使用时,A 用户开案会覆盖 B 用户的当前案件,导致串案。本类把上下文下沉到线程级:
- * <ul>
- *   <li><b>请求线程</b>:由 {@code CaseContextInterceptor} 在 preHandle 写入,afterCompletion 清理;</li>
- *   <li><b>异步线程</b>(清洗虚拟线程、{@code GlobalPool.EXC_POOL}、Reactor 的 boundedElastic):
- *       ThreadLocal 与 {@code RequestContextHolder} 都会丢失,必须由调用方用
- *       {@link #runWith(Long, Long, Runnable)} / {@link #callWith(Long, Long, Supplier)} 显式包裹。</li>
- * </ul>
+ * <p><b>为什么用 ScopedValue 替换 ThreadLocal</b>:绑定随
+ * {@code ScopedValue.where(...).run(...)} 作用域结束自动消失——
+ * 「忘清理导致池线程读到上一个请求案件」的串案风险从结构上消除;
+ * 绑定不可变、读取比 ThreadLocalMap 快,对清洗链路海量虚拟线程更友好。
+ *
+ * <p><b>请求线程</b>:由 {@code CaseContextFilter} 把整条 filter chain 包进作用域;
+ * <b>异步线程</b>(清洗虚拟线程、{@code GlobalPool.EXC_POOL}、Reactor 的 boundedElastic):
+ * 作用域绑定不会跨线程传播,必须由调用方用
+ * {@link #runWith(Long, Long, Runnable)} / {@link #callWith(Long, Long, Supplier)} 显式包裹。
  *
  * <p><b>为什么连 userId 一起放进来</b>:{@code SseService} 现在按用户维护连接,
- * 而清洗/治理链路整条跑在异步线程上 —— 池线程里既没有 ThreadLocal 也没有 sa-token 上下文,
+ * 而清洗/治理链路整条跑在异步线程上 —— 池线程里既没有作用域也没有 sa-token 上下文,
  * 只传 caseId 的话进度消息会推到 {@code ANONYMOUS_USER},用户什么都收不到。
  *
- * <p><b>解析顺序</b>:ThreadLocal → sa-token Token-Session(仅请求线程可用)。
+ * <p><b>解析顺序</b>:作用域绑定 → sa-token Token-Session(仅请求线程可用)。
  * 与 {@code StateManager.getCaseId()} 不同,解析不出来时返回 {@code null}(不是哨兵 888888)。
  */
 @Slf4j
 public final class CaseContextHolder {
 
-    /** sa-token Token-Session 中存放当前案件 ID 的键 */
+    /**
+     * sa-token Token-Session 中存放当前案件 ID 的键
+     */
     public static final String SESSION_KEY_CASE_ID = "caseId";
 
-    /** 线程上下文载体 */
-    public record Context(Long caseId, Long userId) {
-    }
-
-    private static final ThreadLocal<Context> HOLDER = new ThreadLocal<>();
+    private static final ScopedValue<Long> CASE_ID = ScopedValue.newInstance();
+    private static final ScopedValue<Long> USER_ID = ScopedValue.newInstance();
 
     private CaseContextHolder() {
     }
 
     // ------------------------------------------------------------------
-    // 写
-    // ------------------------------------------------------------------
-
-    /** 绑定案件 ID,<b>保留</b>当前线程已有的 userId */
-    public static void set(Long caseId) {
-        Context current = HOLDER.get();
-        set(caseId, current == null ? null : current.userId());
-    }
-
-    /** 绑定案件 + 用户 ID;两者都为 null 时等价于清理 */
-    public static void set(Long caseId, Long userId) {
-        if (caseId == null && userId == null) {
-            HOLDER.remove();
-        } else {
-            HOLDER.set(new Context(caseId, userId));
-        }
-    }
-
-    public static void clear() {
-        HOLDER.remove();
-    }
-
-    // ------------------------------------------------------------------
-    // 读(只读 ThreadLocal,不做兜底)
+    // 读(只读当前作用域,不做兜底)
     // ------------------------------------------------------------------
 
-    /** 本线程显式绑定的案件 ID;未绑定时返回 null */
+    /**
+     * 当前作用域绑定的案件 ID;未绑定时返回 null
+     */
     public static Long get() {
-        Context ctx = HOLDER.get();
-        return ctx == null ? null : ctx.caseId();
+        return CASE_ID.orElse(null);
     }
 
-    /** 本线程显式绑定的用户 ID;未绑定时返回 null */
+    /**
+     * 当前作用域绑定的用户 ID;未绑定时返回 null
+     */
     public static Long getUserId() {
-        Context ctx = HOLDER.get();
-        return ctx == null ? null : ctx.userId();
+        return USER_ID.orElse(null);
     }
 
     // ------------------------------------------------------------------
@@ -83,7 +62,7 @@ public final class CaseContextHolder {
     // ------------------------------------------------------------------
 
     /**
-     * 解析当前案件 ID:ThreadLocal 优先,其次从 sa-token Token-Session 兜底。
+     * 解析当前案件 ID:作用域绑定优先,其次从 sa-token Token-Session 兜底。
      *
      * @return 案件 ID;无法解析(未登录 / 未开案 / 非请求线程)时返回 {@code null}
      */
@@ -105,7 +84,7 @@ public final class CaseContextHolder {
     }
 
     /**
-     * 解析当前用户 ID:ThreadLocal 优先,其次取 sa-token 登录态。
+     * 解析当前用户 ID:作用域绑定优先,其次取 sa-token 登录态。
      *
      * @return 用户 ID;无法解析时返回 {@code null}
      */
@@ -126,55 +105,80 @@ public final class CaseContextHolder {
     // ------------------------------------------------------------------
 
     /**
-     * 在指定案件上下文中执行并返回结果(执行完毕后恢复原上下文)。
-     *
-     * @deprecated 异步链路请用 {@link #callWith(Long, Long, Supplier)} 一并带上 userId,
+     * 在指定案件上下文中执行并返回结果(作用域结束后绑定自动恢复/消失),
+     * <b>保留</b>当前作用域已有的 userId。
+     * <p>
+     * 异步链路请用 {@link #callWith(Long, Long, Supplier)} 一并带上 userId,
      * 否则进度类 SSE 消息找不到接收者。
      */
-    @Deprecated
     public static <T> T callWith(Long caseId, Supplier<T> action) {
-        Context previous = HOLDER.get();
-        try {
-            set(caseId);
-            return action.get();
-        } finally {
-            restore(previous);
-        }
+        return callWith(caseId, getUserId(), action);
     }
 
-    /** 在指定「案件 + 用户」上下文中执行并返回结果 */
+    /**
+     * 在指定「案件 + 用户」作用域中执行并返回结果
+     */
     public static <T> T callWith(Long caseId, Long userId, Supplier<T> action) {
-        Context previous = HOLDER.get();
-        try {
-            set(caseId, userId);
+        if (caseId == null && userId == null) {
             return action.get();
-        } finally {
-            restore(previous);
+        }
+        try {
+            return carrier(caseId, userId).call(action::get);
+        } catch (RuntimeException e) {
+            throw e;
+        } catch (Exception e) {
+            // Supplier.get() 不抛受检异常,理论不可达
+            throw new IllegalStateException(e);
         }
     }
 
-    /** {@link #callWith(Long, Supplier)} 的无返回值版本(已废弃,见其说明) */
-    @Deprecated
-    public static void runWith(Long caseId, Runnable action) {
-        callWith(caseId, () -> {
+    /**
+     * 在指定「案件 + 用户」作用域中执行
+     */
+    public static void runWith(Long caseId, Long userId, Runnable action) {
+        if (caseId == null && userId == null) {
             action.run();
-            return null;
-        });
+            return;
+        }
+        carrier(caseId, userId).run(action);
     }
 
-    /** 在指定「案件 + 用户」上下文中执行 */
-    public static void runWith(Long caseId, Long userId, Runnable action) {
-        callWith(caseId, userId, () -> {
+    /**
+     * 在指定「案件 + 用户」作用域中执行可能抛受检异常的动作。
+     * {@code CaseContextFilter} 包裹 {@code FilterChain.doFilter} 用——链路里要透传
+     * ServletException/IOException,而 Runnable 无法声明受检异常。
+     */
+    public static <E extends Exception> void runWithThrowing(Long caseId, Long userId,
+                                                             ThrowingRunnable<E> action) throws E {
+        if (caseId == null && userId == null) {
+            action.run();
+            return;
+        }
+        carrier(caseId, userId).call(() -> {
             action.run();
             return null;
         });
     }
 
-    private static void restore(Context previous) {
-        if (previous == null) {
-            HOLDER.remove();
-        } else {
-            HOLDER.set(previous);
+    /**
+     * 可抛受检异常的 Runnable
+     */
+    @FunctionalInterface
+    public interface ThrowingRunnable<E extends Exception> {
+        void run() throws E;
+    }
+
+    /**
+     * ScopedValue 不允许绑定 null,按非空字段逐段拼接绑定。
+     * 调用前提:caseId 与 userId 不同时为 null。
+     */
+    private static ScopedValue.Carrier carrier(Long caseId, Long userId) {
+        if (caseId == null) {
+            return ScopedValue.where(USER_ID, userId);
+        }
+        if (userId == null) {
+            return ScopedValue.where(CASE_ID, caseId);
         }
+        return ScopedValue.where(CASE_ID, caseId).where(USER_ID, userId);
     }
 }

+ 58 - 0
ai-server/src/main/java/com/zsjz/ai/common/interceptor/CaseContextFilter.java

@@ -0,0 +1,58 @@
+package com.zsjz.ai.common.interceptor;
+
+import com.zsjz.ai.common.context.CaseContextHolder;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+/**
+ * 把「当前用户打开的案件 ID」解析进 {@link CaseContextHolder} 的请求作用域。
+ *
+ * <p><b>为什么是 Filter 而不是 HandlerInterceptor</b>:ScopedValue 的绑定随
+ * {@code where(...).run(...)} 作用域结束自动消失,必须把整条请求处理包进作用域;
+ * 拦截器的 preHandle/afterCompletion 是两个独立回调,包不住 handler 执行,Filter 可以——
+ * 用 {@link CaseContextHolder#runWithThrowing} 包住 {@code chain.doFilter},
+ * 请求结束(含异常路径)绑定自动释放,不再需要手动清理,
+ * 也就不存在「线程复用读到上一个请求案件」的串案风险。
+ *
+ * <p><b>解析来源</b>:sa-token 的 Token-Session(开案时由 {@code CaseInfoService.open} 写入)。
+ * 一次请求内后续所有取 caseId 的地方都命中作用域绑定,不必反复访问 Redis / sa-token 上下文。
+ * 未登录 / 白名单路径解析为 null,等价于「无案件上下文」,不阻断请求。
+ *
+ * <p><b>注册顺序要求</b>:必须排在 sa-token 的 Filter(order = -104)之后才能读到登录态,
+ * 见 {@code SaTokenWebConfig} 的执行顺序说明。
+ *
+ * <p><b>不覆盖异步线程</b>:Agent 的流式链路会切到 {@code Schedulers.boundedElastic()},
+ * 作用域绑定在那里不存在,必须由工具调用收口用
+ * {@link CaseContextHolder#callWith(Long, java.util.function.Supplier)} 显式绑定。
+ */
+@Slf4j
+@Component
+@Order(Ordered.LOWEST_PRECEDENCE - 1)
+public class CaseContextFilter extends OncePerRequestFilter {
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+                                    FilterChain chain) throws ServletException, IOException {
+        // 未登录(白名单路径)时两者为 null,等价于「无案件上下文」,不阻断请求。
+        // userId 一并绑定:后续异步链路(清洗、治理)要靠它把 SSE 进度推给正确的人。
+        Long userId = CaseContextHolder.currentUserId();
+        Long caseId = CaseContextHolder.currentCaseId();
+        try {
+            CaseContextHolder.runWithThrowing(caseId, userId, () -> chain.doFilter(request, response));
+        } catch (IOException | ServletException e) {
+            throw e;
+        } catch (Exception e) {
+            // runWithThrowing 声明的是 Exception,链路里实际只会出现以上两种受检异常
+            throw new ServletException(e);
+        }
+    }
+}

+ 0 - 42
ai-server/src/main/java/com/zsjz/ai/common/interceptor/CaseContextInterceptor.java

@@ -1,42 +0,0 @@
-package com.zsjz.ai.common.interceptor;
-
-import com.zsjz.ai.common.context.CaseContextHolder;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Component;
-import org.springframework.web.servlet.HandlerInterceptor;
-
-/**
- * 把「当前用户打开的案件 ID」解析进 {@link CaseContextHolder} 的线程上下文。
- *
- * <p>解析来源是 sa-token 的 Token-Session(开案时由 {@code CaseInfoService.open} 写入)。
- * 这样做的收益:一次请求内后续所有取 caseId 的地方都命中 ThreadLocal,
- * 不必反复访问 Redis / sa-token 上下文。
- *
- * <p><b>注册顺序要求</b>:必须排在 sa-token 的 {@code SaInterceptor} 之后,
- * 见 {@code SaTokenWebConfig#addInterceptors}。
- *
- * <p><b>不覆盖异步线程</b>:Agent 的流式链路会切到 {@code Schedulers.boundedElastic()},
- * ThreadLocal 在那里是空的,必须由工具调用收口用
- * {@link CaseContextHolder#callWith(Long, java.util.function.Supplier)} 显式绑定。
- */
-@Slf4j
-@Component
-public class CaseContextInterceptor implements HandlerInterceptor {
-
-    @Override
-    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
-        // 未登录 / 非 Web 线程时返回 null,等价于「无案件上下文」,不阻断请求。
-        // userId 一并写入:后续异步链路(清洗、治理)要靠它把 SSE 进度推给正确的人。
-        CaseContextHolder.set(CaseContextHolder.currentCaseId(), CaseContextHolder.currentUserId());
-        return true;
-    }
-
-    @Override
-    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
-                                Object handler, Exception ex) {
-        // 线程会回到容器线程池被复用,必须清理,否则下一个请求会读到上一个用户的案件
-        CaseContextHolder.clear();
-    }
-}

+ 5 - 20
ai-server/src/main/java/com/zsjz/ai/common/utils/StateManager.java

@@ -14,7 +14,7 @@ import java.nio.file.Path;
  *
  * <p><b>改造说明</b>:本类原先是 JVM 级单例,直接持有 {@code private CaseInfo caseInfo}。
  * 服务端多人同时使用时,A 用户开案会覆盖 B 用户的当前案件,导致串案。
- * 现在把「当前案件」下沉到 {@link CaseContextHolder}(线程级 + sa-token Token-Session 兜底),
+ * 现在把「当前案件」下沉到 {@link CaseContextHolder}(请求作用域 + sa-token Token-Session 兜底),
  * 本类只保留原有方法签名作为门面 —— 因此 13 个既有调用点<b>零改动</b>。
  *
  * <p><b>注意</b>:{@link #getCaseId()} 在未开案时仍返回哨兵值 {@code 888888}(不是 null),
@@ -64,37 +64,22 @@ public class StateManager {
     }
 
     /**
-     * 绑定当前请求的案件。
+     * 绑定当前用户案件。
      *
-     * <p>同时写入 sa-token 的 Token-Session,这样同一用户后续的每个请求
-     * (由 {@code CaseContextInterceptor} 解析)都能恢复出当前案件。
+     * <p>写入 sa-token 的 Token-Session,这样同一用户后续的每个请求
+     * (由 {@code CaseContextFilter} 解析)都能恢复出当前案件。
+     * 请求线程上的本地绑定由 {@code CaseContextFilter} 的请求作用域负责,这里不再单独设置。
      */
     public void setCaseInfo(CaseInfo caseInfo) {
         Long caseId = caseInfo == null ? null : caseInfo.getId();
-        CaseContextHolder.set(caseId);
         bindCaseIdToTokenSession(caseId);
     }
 
     /** 清除当前案件(退出案件时调用) */
     public void cleanCase() {
-        CaseContextHolder.clear();
         bindCaseIdToTokenSession(null);
     }
 
-    /**
-     * 仅在当前线程绑定案件 ID,<b>不</b>写 sa-token 会话。
-     *
-     * <p>供异步线程使用:Agent 的 Reactor 线程上 sa-token 上下文不可用,
-     * 写会话会抛异常,因此拆成单独方法。
-     */
-    public void bindLocal(CaseInfo caseInfo) {
-        CaseContextHolder.set(caseInfo == null ? null : caseInfo.getId());
-    }
-
-    public void unbindLocal() {
-        CaseContextHolder.clear();
-    }
-
     /** 把案件 ID 写入当前 token 的会话;非请求线程上静默失败 */
     private void bindCaseIdToTokenSession(Long caseId) {
         try {

+ 1 - 1
ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java

@@ -316,7 +316,7 @@ public class DmService {
         if (!StateManager.instance().isCaseOpened()) {
             throw ServerException.spe("请先打开案件后再上传文件!");
         }
-        Long caseId = StateManager.instance().getCaseId().longValue();
+        Long caseId = StateManager.instance().getCaseId();
         Path dir = PathConst.WORKSPACE
                 .resolve(String.valueOf(caseId))
                 .resolve(UPLOAD_DIR_NAME)

+ 11 - 11
ai-server/src/main/java/com/zsjz/ai/module/govern/serivce/GovernService.java

@@ -141,10 +141,10 @@ public class GovernService {
      */
     public void calcTask(Integer taskProcess, Long caseId, Long userId) {
         log.info("【数据治理】开始执行数据治理任务, caseId={}, userId={}", caseId, userId);
-        // 本方法跑在 ThreadUtil 的异步线程上,案件 + 用户上下文都要显式绑定:
+        // 本方法跑在 ThreadUtil 的异步线程上,案件 + 用户上下文要用作用域显式绑定:
         // caseId 供 @DS("slave") 路由数据源,userId 供 SSE 找到接收者。
-        CaseContextHolder.set(caseId, userId);
-        try {
+        // ScopedValue 作用域结束绑定自动消失,无需手动清理。
+        CaseContextHolder.runWith(caseId, userId, () -> {
             //cacheTagsService.removeTag(StrConsts.CACHE_TAG_QING_JIAN);
             CaseDataCache.GOVERN_CONFIG.clear();
             log.info("【数据治理】清理缓存成功");
@@ -168,10 +168,7 @@ public class GovernService {
             // 完成数据治理任务
             log.info("【数据治理】开始数据治理任务完成");
             completeGovernTask();
-        } finally {
-            // 本线程来自 ThreadUtil 的公共线程池,必须清理,否则会污染下一个任务
-            CaseContextHolder.clear();
-        }
+        });
     }
 
     /**
@@ -342,10 +339,13 @@ public class GovernService {
                         calcTransRelTask,
                         calcOtherRelTask)
                 .orTimeout(30, TimeUnit.MINUTES)
-                .thenRunAsync(() -> {
-                    mergeRelEdge(context);
-                    //calcRelNode(context);
-                }, GlobalPool.EXC_POOL)
+                .thenRunAsync(() ->
+                        // 这段直接提交到 EXC_POOL,没走 createTaskWithTimeout 的绑定;
+                        // mergeRelEdge 的 INSERT 走 @DS("slave"),池线程上不绑上下文会报「请先打开案件后再操作数据」
+                        CaseContextHolder.runWith(context.caseId(), context.userId(), () -> {
+                            mergeRelEdge(context);
+                            //calcRelNode(context);
+                        }), GlobalPool.EXC_POOL)
                 .orTimeout(10, TimeUnit.MINUTES);
 
         awaitStage(stage3, "secondStage");