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,163 @@
package com.yunhai.flowcenter.engine;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.entity.FlwRouteLog;
import com.yunhai.flowcenter.entity.FlwRouter;
import com.yunhai.flowcenter.notify.UserMsgClient;
import com.yunhai.flowcenter.repository.FlwRouterRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 条件路由判断编排(2026-09-24 §4.4):查 flw_router 注册 → 组装判断契约体(variables 透传
* form_data_json)→ {@link RouteJudgeClient} 同步调用 → 结果落 flw_route_log(一次一行)。
*
* <p><b>失败策略(已定,勿改):挂起 + 管理端重跑,禁止配默认出口</b>——超时/非 2xx/
* outlet 未命中/注册缺失一律返回 null(route_log 落 outlet=NULL 行 + 告警日志与站内信),
* 节点保持在途(currentNodes 不动、办件仍 10),管理端 reroute 重新调判断。
* "校长室免审"这类判断失败默认放行是安全事故,宁挂起。
* 退回重走 CONDITION 必然重新调判断(单据可能已改):attempt 递增,留痕只增不改。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ConditionRouteService {
private final FlwRouterRepository routerRepository;
private final RouteLogStore routeLogStore;
private final RouteJudgeClient judgeClient;
private final UserMsgClient userMsgClient;
private final ObjectMapper objectMapper;
/**
* 判断并留痕。
*
* @return 命中的出口名(必属 outlets);失败返回 null(已落 outlet=NULL 留痕行 + 告警)
*/
public String decide(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node) {
List<String> outlets = outletNames(model, node);
FlwRouter router = routerRepository.findByRouterKey(node.routerKey).orElse(null);
if (router == null || router.getStatus() == null || router.getStatus() != 1) {
log.error("[flow-center] 路由判断失败:routerKey={} 未注册或已停用(挂起):instance={} node={}",
node.routerKey, instance.getId(), node.id);
return persistFailure(instance, node, outlets, "routerKey 未注册或已停用:" + node.routerKey);
}
RouteJudgeClient.JudgeBody body = new RouteJudgeClient.JudgeBody(
instance.getSchId(), instance.getId(), instance.getVersion(),
instance.getBizType(), instance.getBizId(), node.id, node.name,
node.routerKey, outlets, parseVariables(instance.getFormDataJson()));
try {
RouteJudgeClient.JudgeResult result = judgeClient.call(router, body);
if (!outlets.contains(result.outlet)) {
log.error("[flow-center] 路由判断返回出口未命中 outlets(挂起):instance={} node={} outlet={} outlets={}",
instance.getId(), node.id, result.outlet, outlets);
return persistFailure(instance, node, outlets,
"返回出口未命中 outlets:" + result.outlet + ";raw=" + result.rawResponse);
}
persist(instance, node, result.outlet, result.rawResponse);
log.info("[flow-center] 路由判断命中:instance={} node={} router={} outlet={}",
instance.getId(), node.id, node.routerKey, result.outlet);
return result.outlet;
} catch (Exception e) {
log.error("[flow-center] 路由判断调用失败(挂起):instance={} node={} router={} {}",
instance.getId(), node.id, node.routerKey, e.toString());
return persistFailure(instance, node, outlets, "调用失败:" + e);
}
}
/** CONDITION 出边名即判断契约的 outlets(发布期已保证非空唯一) */
private static List<String> outletNames(FlowDefModel model, FlowDefModel.Node node) {
List<String> names = new ArrayList<>();
for (FlowDefModel.Edge e : model.outEdges(node.id)) {
names.add(e.name);
}
return names;
}
/** form_data_json 原样透传为 variables(SDK 发起参数不加新字段;解析失败=空对象) */
private Map<String, Object> parseVariables(String formDataJson) {
if (formDataJson == null || formDataJson.trim().isEmpty()) {
return new java.util.HashMap<>();
}
try {
return objectMapper.readValue(formDataJson,
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {
});
} catch (Exception e) {
log.warn("[flow-center] form_data_json 非 JSON 对象,variables 透传空:{}", e.toString());
return new java.util.HashMap<>();
}
}
private String persistFailure(FlwInstance instance, FlowDefModel.Node node, List<String> outlets,
String error) {
int attempt = persist(instance, node, null, truncate(error));
alertSuspended(instance, node, outlets, error, attempt);
return null;
}
/** 落一行留痕(REQUIRES_NEW:外部调用已真实发生,不随外层回滚),返回本轮 attempt(告警 msgNo 用) */
private int persist(FlwInstance instance, FlowDefModel.Node node, String outlet, String raw) {
FlwRouteLog logRow = new FlwRouteLog();
logRow.setSchId(instance.getSchId());
logRow.setInstanceId(instance.getId());
logRow.setNodeId(node.id);
logRow.setNodeName(node.name);
logRow.setRouterKey(node.routerKey);
logRow.setOutlet(outlet);
logRow.setRawResponse(raw);
return routeLogStore.append(logRow).getAttempt();
}
/** 挂起告警本校管理员(复用回调挂起告警接收人配置;UserMsg 不可达只记日志,不影响挂起落定) */
private void alertSuspended(FlwInstance instance, FlowDefModel.Node node, List<String> outlets,
String error, int attempt) {
try {
List<Integer> admins = alertUserIds();
if (admins.isEmpty()) {
return;
}
userMsgClient.sendUserMsg(instance.getSchId().intValue(), admins, 1,
"flow.route", String.valueOf(instance.getId()),
"条件路由挂起告警",
"办件 " + instance.getId() + "(" + instance.getBizTitle() + ")在节点「"
+ (node.name == null || node.name.isEmpty() ? node.id : node.name)
+ "」路由判断失败已挂起,请在流程管理端重跑路由。出口候选:"
+ outlets + "。最近错误:" + truncate(error),
null, "route-hang:" + instance.getId() + ":" + node.id + ":" + attempt, null);
} catch (Exception e) {
log.warn("[flow-center] 路由挂起告警发送失败:instance={} {}", instance.getId(), e.toString());
}
}
private List<Integer> alertUserIds() {
List<Integer> userIds = new ArrayList<>();
String configured = alertUserIdsConfig;
if (configured == null || configured.trim().isEmpty()) {
return userIds;
}
for (String s : configured.split(",")) {
if (!s.trim().isEmpty()) {
userIds.add(Integer.valueOf(s.trim()));
}
}
return userIds;
}
/** 告警接收人(与回调挂起告警同源配置,@Value 注入见构造) */
@org.springframework.beans.factory.annotation.Value("${flow-center.callback.alert-user-ids:}")
private String alertUserIdsConfig;
private static String truncate(String s) {
if (s == null) {
return null;
}
return s.length() > 1000 ? s.substring(0, 1000) : s;
}
}