diff --git a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java index d2295f8..5c23ee0 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java @@ -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 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 可选过滤 */ diff --git a/src/main/java/com/yunhai/flowcenter/engine/RouteJudgeClient.java b/src/main/java/com/yunhai/flowcenter/engine/RouteJudgeClient.java index cc955e4..c6c3ba7 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/RouteJudgeClient.java +++ b/src/main/java/com/yunhai/flowcenter/engine/RouteJudgeClient.java @@ -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 同款坑)。direct_host 非空的自注册行跳过目录解析直连(2026-10-12 收口)。 + * 信封解析复用 SDK({code,msg,data})。 * *

重试纪律:同步 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 超时 */ + /** + * 目标解析: + *

+ * 两分支同设判断专用 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; diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwRouter.java b/src/main/java/com/yunhai/flowcenter/entity/FlwRouter.java index cfbca31..0ae62f3 100644 --- a/src/main/java/com/yunhai/flowcenter/entity/FlwRouter.java +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwRouter.java @@ -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; } diff --git a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java index 82c48b1..058f5ca 100644 --- a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java +++ b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java @@ -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 步。 diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java index ff2e984..5c0c428 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java @@ -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 启动时调用)——登记即持有: + * + * 管理端既有 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) { diff --git a/src/main/resources/sql/20261012_add_flw_router_direct_host.sql b/src/main/resources/sql/20261012_add_flw_router_direct_host.sql new file mode 100644 index 0000000..e85db79 --- /dev/null +++ b/src/main/resources/sql/20261012_add_flw_router_direct_host.sql @@ -0,0 +1,6 @@ +-- 2026-10-12 条件路由 SERVICE 收口:starter 启动自注册所需两列 +-- ddl-auto=update 会自动加列;本脚本为生产备案(生产建议停自动 DDL 后手工执行) +-- direct_host 非空的行由 RouteJudgeClient 直连(跳过服务目录三级解析);存量手工行留 NULL 走原路 +ALTER TABLE `flw_router` + ADD COLUMN `direct_host` VARCHAR(255) NULL COMMENT '自注册直连基址 scheme://authority[/ctx];NULL=按服务目录寻址(存量手工行)' AFTER `base_path`, + ADD COLUMN `reg_app_key` VARCHAR(64) NULL COMMENT '登记应用 appKey(自注册归属,upsert 防跨应用覆盖);NULL=管理端手工行' AFTER `direct_host`; diff --git a/src/test/java/com/yunhai/flowcenter/engine/RouteJudgeClientTest.java b/src/test/java/com/yunhai/flowcenter/engine/RouteJudgeClientTest.java index 0b14a73..d83736f 100644 --- a/src/test/java/com/yunhai/flowcenter/engine/RouteJudgeClientTest.java +++ b/src/test/java/com/yunhai/flowcenter/engine/RouteJudgeClientTest.java @@ -19,14 +19,15 @@ import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * 路由判断客户端单测(2026-09-24 §4.3):重试纪律(3s×3 退避重试,Mock 出网)、 - * flw_router.base_path 对目录前缀的覆盖、信封 data.outlet 解析。 - * 引擎行为(选路/挂起/重跑)由 FlowEngineIT 覆盖。 + * flw_router.base_path 对目录前缀的覆盖、direct_host 自注册行直连(跳过目录解析,2026-10-12 收口)、 + * 信封 data.outlet 解析。引擎行为(选路/挂起/重跑)由 FlowEngineIT 覆盖。 */ class RouteJudgeClientTest { @@ -88,6 +89,55 @@ class RouteJudgeClientTest { assertEquals("/dirctx", spec.getValue().getBasePath(), "base_path 空=沿用目录条目 context-path"); } + @Test + void directHost_非空时拆基址直连_不查服务目录() { + FlwRouter r = router(""); + r.setDirectHost("http://open-gateway-service:10013/work-leave"); + Map data = new HashMap<>(); + data.put("outlet", "甲线"); + when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class))) + .thenReturn(data); + + RouteJudgeClient.JudgeResult result = client.call(r, body()); + + assertEquals("甲线", result.outlet); + ArgumentCaptor spec = ArgumentCaptor.forClass(TargetSpec.class); + verify(openCall).post(spec.capture(), anyString(), anyMap(), any(), eq(Map.class)); + assertEquals("http://open-gateway-service:10013", spec.getValue().getBaseHost(), "direct_host 拆 authority"); + assertEquals("/work-leave", spec.getValue().getBasePath(), "direct_host 的 path 段即 context-path(签名剥离面)"); + assertEquals((Long) 3000L, spec.getValue().getReadTimeoutMs(), "直连仍用判断专用 3s 超时"); + verify(openCall, never()).resolveTarget(anyString()); + } + + @Test + void directHost_无path段_basePath为空() { + FlwRouter r = router(""); + r.setDirectHost("http://work-leave:8080"); + Map data = new HashMap<>(); + data.put("outlet", "甲线"); + when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class))) + .thenReturn(data); + + client.call(r, body()); + + ArgumentCaptor spec = ArgumentCaptor.forClass(TargetSpec.class); + verify(openCall).post(spec.capture(), anyString(), anyMap(), any(), eq(Map.class)); + assertEquals("http://work-leave:8080", spec.getValue().getBaseHost()); + assertEquals("", spec.getValue().getBasePath()); + } + + @Test + void directHost为空_存量行仍走目录解析() { + Map data = new HashMap<>(); + data.put("outlet", "甲线"); + when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class))) + .thenReturn(data); + + client.call(router(""), body()); + + verify(openCall).resolveTarget("notice"); + } + @Test void transportFailure_retriesThreeTimes_thenThrows() { when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class))) diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java index ed95679..4ee2adb 100644 --- a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java +++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java @@ -3,7 +3,9 @@ package com.yunhai.flowcenter.oapi; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.flowcenter.entity.FlwDef; +import com.yunhai.flowcenter.entity.FlwRouter; import com.yunhai.flowcenter.service.DiagramTokenService; +import com.yunhai.flowcenter.service.FlowRouterAdminService; import com.yunhai.flowcenter.service.OapiFlowService; import com.yunhai.flowcenter.support.FlowTestSupport; import com.yunhai.flowcenter.support.OapiSignedClient; @@ -50,6 +52,9 @@ class OapiFlowContractIT extends FlowTestSupport { @Autowired private OapiFlowService flowService; + @Autowired + private FlowRouterAdminService routerAdminService; + @Autowired private DiagramTokenService tokenService; @@ -507,6 +512,78 @@ class OapiFlowContractIT extends FlowTestSupport { } } + /** + * 判断服务自注册契约(2026-10-12 SERVICE 收口,yh-flow-callback starter 的落点):未验签 401; + * 登记即持有(新增行带 directHost、regAppKey/serviceKey=签名 appKey);同键重复登记幂等更新 + * (status 不被翻正);校验项业务码拒绝。跨应用覆盖拒绝见 FlowRouterAdminServiceRegisterTest。 + */ + @Test + void routerReg_selfRegistration_contract() { + String reg = "{\"routerKey\":\"svc-hall.demo-judge\",\"name\":\"演示判断\"," + + "\"judgePath\":\"/oapi/flow/route/judge\"," + + "\"directHost\":\"http://open-gateway-service:10013/svc-hall\"}"; + + // 未验签(不带签名头)→ 401,不落行(本路径同受 /oapi/* 强制验签保护) + assertEquals(401, client.getRaw("/oapi/flow/router/reg").getStatusCodeValue()); + assertFalse(routerRepository.findByRouterKey("svc-hall.demo-judge").isPresent()); + + // 登记 → 行落库,归属=签名 appKey + assertEquals(0, client.post("/oapi/flow/router/reg", reg, JsonNode.class) + .getBody().get("code").asInt(), "自注册应成功"); + FlwRouter row = routerRepository.findByRouterKey("svc-hall.demo-judge").get(); + assertEquals(APP_KEY, row.getRegAppKey()); + assertEquals(APP_KEY, row.getServiceKey(), "serviceKey 填归属 appKey(管理端『来源』展示用)"); + assertEquals("http://open-gateway-service:10013/svc-hall", row.getDirectHost()); + assertEquals("/oapi/flow/route/judge", row.getPath()); + assertEquals(1, row.getStatus().intValue()); + Long id = row.getId(); + + // 管理员停用后自注册 → 仍成功但不翻正 status(控制权在管理员) + routerAdminService.setStatus(id, false); + assertEquals(0, client.post("/oapi/flow/router/reg", reg, JsonNode.class).getBody().get("code").asInt()); + assertEquals(0, routerRepository.findByRouterKey("svc-hall.demo-judge").get().getStatus().intValue(), + "停用状态不被自注册翻正"); + + // 重复登记(改名+换址)→ 同一行更新,不新增 + String again = "{\"routerKey\":\"svc-hall.demo-judge\",\"name\":\"演示判断2\"," + + "\"judgePath\":\"/oapi/flow/route/judge2\",\"directHost\":\"http://new-host:9000/svc-hall\"}"; + assertEquals(0, client.post("/oapi/flow/router/reg", again, JsonNode.class) + .getBody().get("code").asInt()); + FlwRouter updated = routerRepository.findByRouterKey("svc-hall.demo-judge").get(); + assertEquals(id, updated.getId(), "幂等 upsert,不应产生第二行"); + assertEquals("演示判断2", updated.getName()); + assertEquals("http://new-host:9000/svc-hall", updated.getDirectHost()); + assertEquals("/oapi/flow/route/judge2", updated.getPath()); + + // 校验项拒绝 + assertNotEquals(0, client.post("/oapi/flow/router/reg", + "{\"routerKey\":\"a.b\",\"name\":\"n\",\"judgePath\":\"oapi/x\",\"directHost\":\"http://h:1\"}", + JsonNode.class).getBody().get("code").asInt(), "judgePath 缺前导 / 应拒绝"); + assertNotEquals(0, client.post("/oapi/flow/router/reg", + "{\"routerKey\":\"a.b\",\"name\":\"n\",\"judgePath\":\"/oapi/x\",\"directHost\":\"h:1\"}", + JsonNode.class).getBody().get("code").asInt(), "directHost 非 http(s) 基址应拒绝"); + assertNotEquals(0, client.post("/oapi/flow/router/reg", + "{\"name\":\"n\",\"judgePath\":\"/oapi/x\",\"directHost\":\"http://h:1\"}", + JsonNode.class).getBody().get("code").asInt(), "routerKey 缺失应拒绝"); + } + + /** + * 自注册行天然满足 SERVICE 发布校验(2026-10-12 验收项):DefValidator 只认 routerKey + * 已注册且启用,与 serviceKey 形态无关(自注册行 serviceKey 填的是数值 appKey)。 + */ + @Test + void routerReg_selfRegisteredRow_passesServicePublishValidation() { + mapDept("km", 101, "甲"); + mapDept("kb", 102, "乙"); + String reg = "{\"routerKey\":\"svc-hall.cond-judge\",\"name\":\"条件判断\"," + + "\"judgePath\":\"/oapi/flow/route/judge\",\"directHost\":\"http://h:1/svc-hall\"}"; + assertEquals(0, client.post("/oapi/flow/router/reg", reg, JsonNode.class) + .getBody().get("code").asInt()); + + FlwDef def = saveAndPublishDef("t17", "自注册引用", conditionDef("svc-hall.cond-judge", "km", "kb")); + assertEquals(1, def.getVersion().intValue(), "引用自注册行的 SERVICE 定义应发布成功"); + } + /** 轮询等异步回调推送(AFTER_COMMIT + 推送池),最长约 5s */ private boolean awaitPayloadContains(List payloads, String needle) throws InterruptedException { for (int i = 0; i < 25; i++) { diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRegisterTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRegisterTest.java new file mode 100644 index 0000000..7c1f743 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRegisterTest.java @@ -0,0 +1,171 @@ +package com.yunhai.flowcenter.service; + +import com.yunhai.flowcenter.entity.FlwRouter; +import com.yunhai.flowcenter.model.OapiModels; +import com.yunhai.flowcenter.repository.FlwRouterRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 判断服务自注册登记(2026-10-12 SERVICE 收口,/oapi/flow/router/reg 的服务层): + * 登记即持有(新增行 serviceKey=归属 appKey、directHost 非空)、同 appKey 幂等更新且 status 不动、 + * 手工行首次自注册认领、跨应用覆盖拒绝、校验项与未验签(appKey 空)拒绝。 + */ +class FlowRouterAdminServiceRegisterTest { + + private static final String APP = "100021"; + + private FlwRouterRepository routerRepository; + private FlowRouterAdminService service; + + @BeforeEach + void setUp() { + routerRepository = mock(FlwRouterRepository.class); + service = new FlowRouterAdminService(routerRepository, + mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class), + mock(com.yunhai.flowcenter.repository.FlwDefRepository.class), + mock(com.yunhai.flowcenter.engine.FlowEngine.class), + new com.fasterxml.jackson.databind.ObjectMapper()); + when(routerRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + } + + private OapiModels.RouterRegBody body() { + OapiModels.RouterRegBody b = new OapiModels.RouterRegBody(); + b.setRouterKey("work-leave.days-judge"); + b.setName("请假天数分级"); + b.setJudgePath("/oapi/flow/route/judge"); + b.setDirectHost("http://open-gateway-service:10013/work-leave"); + return b; + } + + private FlwRouter existing(String regAppKey, Integer status) { + FlwRouter r = new FlwRouter(); + r.setId(7L); + r.setRouterKey("work-leave.days-judge"); + r.setName("旧名"); + r.setServiceKey("work-leave"); + r.setPath("/oapi/legacy/judge"); + r.setBasePath("/work-leave"); + r.setRegAppKey(regAppKey); + r.setStatus(status); + return r; + } + + @Test + void 新键_建行填归属并带直连基址() { + when(routerRepository.findByRouterKey(anyString())).thenReturn(Optional.empty()); + + FlwRouter saved = service.registerByApp(APP, body()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FlwRouter.class); + verify(routerRepository).save(captor.capture()); + FlwRouter row = captor.getValue(); + assertEquals("work-leave.days-judge", row.getRouterKey()); + assertEquals("请假天数分级", row.getName()); + assertEquals("/oapi/flow/route/judge", row.getPath(), "path=judgePath"); + assertEquals("http://open-gateway-service:10013/work-leave", row.getDirectHost()); + assertEquals(APP, row.getServiceKey(), "NOT NULL 列填归属 appKey(展示来源用)"); + assertEquals(APP, row.getRegAppKey()); + assertEquals(1, row.getStatus().intValue()); + assertNull(row.getBasePath(), "自注册行不填 base_path(直连不再需要目录前缀)"); + } + + @Test + void 同appKey重复登记_幂等更新地址_status不动() { + when(routerRepository.findByRouterKey("work-leave.days-judge")) + .thenReturn(Optional.of(existing(APP, 0))); + OapiModels.RouterRegBody b = body(); + b.setName("改名后的判断"); + b.setDirectHost("http://new-host:9000/work-leave"); + + service.registerByApp(APP, b); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FlwRouter.class); + verify(routerRepository).save(captor.capture()); + FlwRouter row = captor.getValue(); + assertEquals("改名后的判断", row.getName()); + assertEquals("http://new-host:9000/work-leave", row.getDirectHost()); + assertEquals("/oapi/flow/route/judge", row.getPath()); + assertEquals(0, row.getStatus().intValue(), "停用状态不被自注册翻正"); + } + + @Test + void 管理端手工行_首次自注册即认领() { + when(routerRepository.findByRouterKey("work-leave.days-judge")) + .thenReturn(Optional.of(existing(null, 1))); + + service.registerByApp(APP, body()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FlwRouter.class); + verify(routerRepository).save(captor.capture()); + assertEquals(APP, captor.getValue().getRegAppKey()); + assertEquals("http://open-gateway-service:10013/work-leave", captor.getValue().getDirectHost()); + } + + @Test + void 跨应用覆盖_拒绝并保留原行() { + when(routerRepository.findByRouterKey("work-leave.days-judge")) + .thenReturn(Optional.of(existing("100099", 1))); + + BizException e = assertThrows(BizException.class, () -> service.registerByApp(APP, body())); + + assertEquals(ErrorCode.FORBIDDEN, e.getCode()); + assertTrue(e.getMessage().contains("100099"), "错误信息要能定位占用方:" + e.getMessage()); + verify(routerRepository, never()).save(any()); + } + + @Test + void 未验签调用_appKey空_拒绝() { + assertEquals(ErrorCode.UNAUTHORIZED, + assertThrows(BizException.class, () -> service.registerByApp(null, body())).getCode()); + assertEquals(ErrorCode.UNAUTHORIZED, + assertThrows(BizException.class, () -> service.registerByApp(" ", body())).getCode()); + verify(routerRepository, never()).save(any()); + } + + @Test + void 校验项逐个拒绝() { + assertParam(reg -> reg.setRouterKey(" ")); + assertParam(reg -> reg.setRouterKey(repeat("k", 65))); + assertParam(reg -> reg.setName(" ")); + assertParam(reg -> reg.setName(repeat("名", 129))); + assertParam(reg -> reg.setJudgePath("oapi/flow/route/judge")); + assertParam(reg -> reg.setDirectHost("open-gateway-service:10013/work-leave")); + assertParam(reg -> reg.setDirectHost("ftp://host/work-leave")); + // 校验全过:一条正向兜底,防"全部断言都靠拒绝"漏掉正常路径 + when(routerRepository.findByRouterKey(anyString())).thenReturn(Optional.empty()); + service.registerByApp(APP, body()); + verify(routerRepository).save(any()); + } + + private void assertParam(java.util.function.Consumer mutate) { + OapiModels.RouterRegBody b = body(); + mutate.accept(b); + BizException e = assertThrows(BizException.class, () -> service.registerByApp(APP, b)); + assertEquals(ErrorCode.PARAM_ERROR, e.getCode()); + } + + private static String repeat(String s, int times) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < times; i++) { + sb.append(s); + } + return sb.toString(); + } +}