feat(engine): NOTIFY 抄送 + CONDITION 条件路由 + JOIN scope 修复(两批全栈)
build / build-push (push) Successful in 47s

- NOTIFY:TYPE_NOTIFY 瞬时抄送(flw_cc 逐人留痕 + CC_SENT 事件 + 站内通知不加待办角标)、/api/flow/cc/my、轨迹 kind=CC 与 oapi progress 抄送步、示意图青框卡片
- CONDITION:flw_router 判断服务注册 + flw_route_log 决策留痕(REQUIRES_NEW,reroute 回滚不丢失败行)+ 同步判断调用(服务目录寻址 + 平台签名,3s×3 退避重试)+ 失败挂起/管理端重跑(不配默认出口)+ 可选事件 flow.route.decided
- JOIN scope:branch_key 路径前缀编码 + scope 级到齐放行 + DefValidator SPLIT/JOIN 配对与键长守卫(存量无点号键零迁移,历史直连图回退实例级口径)
- 管理端:/api/flow/admin/router/* CRUD + POST /api/flow/admin/instance/{id}/reroute
- 生产 SQL:sql/20260923_create_flw_cc.sql(含 flw_task 时间列升 DATETIME(3))、20260924_create_flw_router_and_route_log.sql
- 测试:全量 125 绿(FlowEngineIT 24,含条件/嵌套并行/退回重走用例)
This commit is contained in:
chenxing
2026-09-24 08:58:26 +08:00
parent f2126cdf32
commit d2bef20e13
34 changed files with 2472 additions and 97 deletions
@@ -0,0 +1,142 @@
package com.yunhai.flowcenter.engine;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.TargetSpec;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.flowcenter.entity.FlwRouter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 条件路由判断调用客户端(2026-09-24 §4.3):POST {目录解析 host}{basePath}{path}?schId=,
* 身份即本应用 appKey(100019)——签名拦截器由 {@link YschOpenCall} 装配链注入,与回调推送同构。
* 寻址:service_key 走服务目录三级解析(targets > services > 枚举兜底);
* flw_router.base_path 非空时覆盖目录条目的 context-path(网关形态目录无前缀、实际服务有的场景),
* 漏填必 401(规范 §4.2 同款坑)。信封解析复用 SDK({code,msg,data})。
*
* <p>重试纪律:同步 3s×3 次退避重试(500ms/1500ms),业务方义务=幂等纯读轻实现。
* 全部失败抛 {@link JudgeUnavailableException}(由调用方落 outlet=NULL 留痕并挂起)。
*/
@Slf4j
@Service
public class RouteJudgeClient {
/** 判断接口单次超时(毫秒)与重试次数/退避(设计 §4.4:总预算 3s×3 退避重试) */
static final int TIMEOUT_MS = 3_000;
static final int MAX_ATTEMPTS = 3;
private static final long[] BACKOFF_MS = {500L, 1500L};
private final YschOpenCall openCall;
private final ObjectMapper objectMapper;
public RouteJudgeClient(YschOpenCall openCall, ObjectMapper objectMapper) {
this.openCall = openCall;
this.objectMapper = objectMapper;
}
/** 判断请求体(契约冻结:query 带 schId,业务方按 outlets 返回 {outlet}) */
public static class JudgeBody {
public final Long schId;
public final Long instanceId;
public final Integer instanceVersion;
public final String bizType;
public final String bizId;
public final String nodeId;
public final String nodeName;
public final String routerKey;
public final List<String> outlets;
public final Map<String, Object> variables;
public JudgeBody(Long schId, Long instanceId, Integer instanceVersion, String bizType, String bizId,
String nodeId, String nodeName, String routerKey, List<String> outlets,
Map<String, Object> variables) {
this.schId = schId;
this.instanceId = instanceId;
this.instanceVersion = instanceVersion;
this.bizType = bizType;
this.bizId = bizId;
this.nodeId = nodeId;
this.nodeName = nodeName;
this.routerKey = routerKey;
this.outlets = outlets;
this.variables = variables;
}
}
/** 判断结果:出口名 + 原始返回(data 节点序列化,审计落 flw_route_log.raw_response) */
public static class JudgeResult {
public final String outlet;
public final String rawResponse;
public JudgeResult(String outlet, String rawResponse) {
this.outlet = outlet;
this.rawResponse = rawResponse;
}
}
/** 判断服务不可用(重试耗尽/网络/信封错误);outlet 未命中由调用方判定,不走本异常 */
public static class JudgeUnavailableException extends RuntimeException {
public JudgeUnavailableException(String message, Throwable cause) {
super(message, cause);
}
}
@SuppressWarnings("unchecked")
public JudgeResult call(FlwRouter router, JudgeBody body) {
TargetSpec spec = resolveTarget(router);
Map<String, String> query = new HashMap<>();
query.put("schId", String.valueOf(body.schId));
RuntimeException last = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
Map<String, Object> data = openCall.post(spec, router.getPath(), query, body, Map.class);
String raw = objectMapper.writeValueAsString(data);
Object outlet = data == null ? null : data.get("outlet");
if (outlet == null || String.valueOf(outlet).trim().isEmpty()) {
throw new IllegalStateException("判断接口未返回 outlet:" + raw);
}
return new JudgeResult(String.valueOf(outlet).trim(), raw);
} catch (RuntimeException e) {
last = e;
log.warn("[flow-center] 路由判断调用失败(第 {}/{} 次):router={} {}",
attempt, MAX_ATTEMPTS, router.getRouterKey(), e.toString());
if (attempt < MAX_ATTEMPTS) {
sleep(BACKOFF_MS[attempt - 1]);
}
} catch (Exception e) {
last = new IllegalStateException(e.toString(), e);
log.warn("[flow-center] 路由判断调用异常(第 {}/{} 次):router={} {}",
attempt, MAX_ATTEMPTS, router.getRouterKey(), e.toString());
if (attempt < MAX_ATTEMPTS) {
sleep(BACKOFF_MS[attempt - 1]);
}
}
}
throw new JudgeUnavailableException("判断服务调用失败(已重试 " + MAX_ATTEMPTS + " 次)", last);
}
/** 目标解析:目录三级 + flw_router.base_path 覆盖(空=沿用目录条目前缀),判断专用 3s 超时 */
private TargetSpec resolveTarget(FlwRouter router) {
TargetSpec resolved = openCall.resolveTarget(router.getServiceKey());
String basePath = resolved.getBasePath();
if (router.getBasePath() != null && !router.getBasePath().trim().isEmpty()) {
basePath = router.getBasePath().trim();
}
TargetSpec spec = TargetSpec.of(resolved.getBaseHost(), basePath);
spec.setConnectTimeoutMs((long) TIMEOUT_MS);
spec.setReadTimeoutMs((long) TIMEOUT_MS);
return spec;
}
private static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}