feat(condition): SERVICE 判断收口——flw_router 增 direct_host/reg_app_key 两列(登记即持有,跨应用覆盖拒绝);新端点 POST /oapi/flow/router/reg(应用签名即归属);RouteJudgeClient 对 direct_host 行跳过服务目录解析直连(TargetSpec.fromUrl),存量手工行走目录解析零变化;契约测试与 SQL 留档

This commit is contained in:
chenxing
2026-10-12 17:28:13 +08:00
parent 4eefe078e3
commit 62aff51fed
9 changed files with 440 additions and 5 deletions
@@ -1,6 +1,7 @@
package com.yunhai.flowcenter.controller.oapi;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.service.FlowRouterAdminService;
import com.yunhai.flowcenter.service.OapiFlowService;
import com.yunhai.platform.utils.model.BasePageResult;
import lombok.RequiredArgsConstructor;
@@ -27,6 +28,7 @@ import java.util.List;
* - GET /oapi/flow/diagram/url?instanceId=|flowGuid=&mode=&schId= → data={imgUrl,expiresIn}
* - GET /oapi/flow/embed/url?instanceId=|bizType=&bizId=&mode=&schId= → data={pageUrl,expiresIn}
* - POST /oapi/flow/callback/reg?schId= → data={secret}
* - POST /oapi/flow/router/reg → data=null(判断服务自注册,归属=appKey)
* 应用内审批(2026-09 第三步,原"刻意不提供审批动作"立场由该方案修订:登录身份由调用方
* 应用按其 CAS 会话保证并以 operatorId 传入,本端按任务办理人逐字复核,审计链不断):
* - GET /oapi/flow/tasks/todo?schId=&assigneeId=&bizType=&page=&size= → data=分页待办
@@ -40,6 +42,7 @@ import java.util.List;
public class OapiFlowController {
private final OapiFlowService flowService;
private final FlowRouterAdminService routerAdminService;
@GetMapping("/list")
public List<OapiModels.FlowSummaryVo> list(@RequestParam("schId") Long schId) {
@@ -124,6 +127,16 @@ public class OapiFlowController {
return flowService.registerCallback(schId, appKey, body);
}
/**
* 判断服务登记(2026-10-12 SERVICE 收口):由 {@code yh-flow-callback-spring-boot-starter} 启动自注册调用。
* 无 schId——{@code flw_router} 是全局表;归属即验签后的 appKey(登记即持有,跨应用覆盖拒绝)。
*/
@PostMapping("/router/reg")
public void registerRouter(@RequestAttribute(value = "yh.app.auth", required = false) String appKey,
@RequestBody OapiModels.RouterRegBody body) {
routerAdminService.registerByApp(appKey, body);
}
// ===== 应用内审批(第三步):查询 + 办理 =====
/** 我的待办(oapi):assigneeId 维度分页,bizType 可选过滤 */
@@ -16,7 +16,8 @@ import java.util.Map;
* 身份即本应用 appKey(100019)——签名拦截器由 {@link YschOpenCall} 装配链注入,与回调推送同构。
* 寻址:service_key 走服务目录三级解析(targets > services > 枚举兜底);
* flw_router.base_path 非空时覆盖目录条目的 context-path(网关形态目录无前缀、实际服务有的场景),
* 漏填必 401(规范 §4.2 同款坑)。信封解析复用 SDK({code,msg,data})。
* 漏填必 401(规范 §4.2 同款坑)。<b>direct_host 非空的自注册行跳过目录解析直连</b>(2026-10-12 收口)。
* 信封解析复用 SDK({code,msg,data})。
*
* <p>重试纪律:同步 3s×3 次退避重试(500ms/1500ms),业务方义务=幂等纯读轻实现。
* 全部失败抛 {@link JudgeUnavailableException}(由调用方落 outlet=NULL 留痕并挂起)。
@@ -121,14 +122,29 @@ public class RouteJudgeClient {
throw new JudgeUnavailableException("判断服务调用失败(已重试 " + MAX_ATTEMPTS + " 次)", last);
}
/** 目标解析:目录三级 + flw_router.base_path 覆盖(空=沿用目录条目前缀),判断专用 3s 超时 */
/**
* 目标解析:
* <ul>
* <li>{@code direct_host} 非空(2026-10-12 starter 自注册行)→ {@link TargetSpec#fromUrl(String)}
* 拆基址直连,<b>跳过服务目录三级解析</b>(service_key/base_path 手工核对坑消失);</li>
* <li>否则(存量手工行):目录三级 + flw_router.base_path 覆盖(空=沿用目录条目前缀),行为逐字不变。</li>
* </ul>
* 两分支同设判断专用 3s 超时。
*/
private TargetSpec resolveTarget(FlwRouter router) {
String directHost = router.getDirectHost();
if (directHost != null && !directHost.trim().isEmpty()) {
return withJudgeTimeout(TargetSpec.fromUrl(directHost.trim()));
}
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);
return withJudgeTimeout(TargetSpec.of(resolved.getBaseHost(), basePath));
}
private static TargetSpec withJudgeTimeout(TargetSpec spec) {
spec.setConnectTimeoutMs((long) TIMEOUT_MS);
spec.setReadTimeoutMs((long) TIMEOUT_MS);
return spec;
@@ -48,4 +48,18 @@ public class FlwRouter extends BaseEntity {
/** 1 启用 / 0 停用(停用后发布校验拦截引用,存量在途办件重跑会失败挂起) */
@Column(name = "status", nullable = false)
private Integer status = 1;
/**
* 自注册直连基址 {@code scheme://authority[/ctx]}(yh-flow-callback starter 启动登记)。
* 非空时 {@code RouteJudgeClient} 跳过服务目录三级解析直连——service_key/base_path 手工核对两坑消失;
* 空=存量手工行,继续按目录寻址(老行为零改动)。
*/
@Column(name = "direct_host", length = 255)
private String directHost;
/**
* 登记应用 appKey(自注册归属:upsert 时校验,防跨应用覆盖);管理端手工行为空=无归属。
*/
@Column(name = "reg_app_key", length = 64)
private String regAppKey;
}
@@ -88,6 +88,22 @@ public final class OapiModels {
private String secret;
}
/**
* 判断服务登记请求体(POST /oapi/flow/router/reg,2026-10-12 SERVICE 收口):
* 由 yh-flow-callback-spring-boot-starter 启动时自动提交,字段与 SDK
* {@code com.yunhai.platform.openapi.flow.model.FlowRouterRegBody} 逐字对齐。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class RouterRegBody {
private String routerKey;
private String name;
/** 判断端点路径(如 /oapi/flow/route/judge) */
private String judgePath;
/** 直连基址 scheme://authority[/ctx] */
private String directHost;
}
/**
* 办件进度(结构化轨迹,2026-09-16 用户拍板:SDK 对外提供图3/图4时间线数据源)。
* steps 按 nodeId 任务到达顺序聚合(同人多任务行保留原始顺序),发起申请固定为第 0 步。
@@ -7,12 +7,14 @@ import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.entity.FlwRouter;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwRouterRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -23,6 +25,7 @@ import java.util.List;
* 条件路由管理(2026-09-24 §4.2/§4.4):flw_router 注册 CRUD(全局表,管理员维护)
* + 挂起办件的重跑路由入口。发布校验引用本表(routerKey 白名单),停用注册会拦住新发布。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FlowRouterAdminService {
@@ -69,6 +72,75 @@ public class FlowRouterAdminService {
return routerRepository.save(router);
}
/**
* 自注册登记(2026-10-12 SERVICE 收口,由 yh-flow-callback starter 启动时调用)——<b>登记即持有</b>:
* <ul>
* <li>新键:建行,{@code path=judgePath}、{@code directHost} 非空(对端据此跳过服务目录三级解析
* 直连),{@code serviceKey} 填归属 appKey(NOT NULL 列,管理端"来源"展示用),status=1;</li>
* <li>已有行:{@code regAppKey} 非空且 ≠ 调用方 → 业务错拒绝(防跨应用覆盖);为空(管理端手工行)
* → 首次自注册即认领。<b>status 不动</b>:停用状态不被自注册翻正,管理员保持控制权;</li>
* <li>幂等:同 appKey 重复登记=更新地址,无 secret 轮换风险(与 callback 注册的差异)。</li>
* </ul>
* 管理端既有 CRUD({@link #save}/{@link #setStatus})不受影响——管理员仍可改一切。
*/
@Transactional
public FlwRouter registerByApp(String appKey, OapiModels.RouterRegBody body) {
if (isBlank(appKey)) {
throw new BizException(ErrorCode.UNAUTHORIZED, "判断服务登记需应用签名(appKey 为空)");
}
if (body == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "登记体不能为空");
}
String routerKey = requireLength(body.getRouterKey(), 64, "routerKey");
String name = requireLength(body.getName(), 128, "name");
String judgePath = body.getJudgePath() == null ? "" : body.getJudgePath().trim();
if (!judgePath.startsWith("/")) {
throw new BizException(ErrorCode.PARAM_ERROR,
"judgePath 必须以 / 开头(如 /oapi/flow/route/judge)");
}
String directHost = body.getDirectHost() == null ? "" : body.getDirectHost().trim();
if (!directHost.startsWith("http://") && !directHost.startsWith("https://")) {
throw new BizException(ErrorCode.PARAM_ERROR,
"directHost 必须是 http:// 或 https:// 开头的基址:" + directHost);
}
FlwRouter router = routerRepository.findByRouterKey(routerKey).orElse(null);
if (router == null) {
FlwRouter created = new FlwRouter();
created.setRouterKey(routerKey);
created.setName(name);
created.setServiceKey(appKey);
created.setPath(judgePath);
created.setDirectHost(directHost);
created.setRegAppKey(appKey);
created.setStatus(1);
log.info("[flow-center] 判断服务自注册新增:routerKey={} appKey={} host={} path={}",
routerKey, appKey, directHost, judgePath);
return routerRepository.save(created);
}
if (!isBlank(router.getRegAppKey()) && !router.getRegAppKey().equals(appKey)) {
throw new BizException(ErrorCode.FORBIDDEN,
"routerKey 已被应用 " + router.getRegAppKey() + " 登记:" + routerKey);
}
router.setName(name);
router.setPath(judgePath);
router.setDirectHost(directHost);
router.setRegAppKey(appKey);
log.info("[flow-center] 判断服务自注册更新:routerKey={} appKey={} host={} path={} status 保持不变={}",
routerKey, appKey, directHost, judgePath, router.getStatus());
return routerRepository.save(router);
}
private static String requireLength(String raw, int max, String field) {
String value = raw == null ? "" : raw.trim();
if (value.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, field + " 必填");
}
if (value.length() > max) {
throw new BizException(ErrorCode.PARAM_ERROR, field + " 长度超 " + max + ":" + value);
}
return value;
}
/** 启用/停用(停用拦新发布引用;存量在途办件重跑会失败挂起,注意时机) */
@Transactional
public FlwRouter setStatus(Long id, boolean enabled) {