From b4856a480dd0f16e0c194441c9523f0cc4fbc48b Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 12 Oct 2026 10:44:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(msg):=20=E6=B6=88=E6=81=AF=20jump=5Fapp=20?= =?UTF-8?q?=E6=8D=A2=E6=BA=90=20apply=20=E8=B0=83=E7=94=A8=E6=96=B9=20app?= =?UTF-8?q?=5Fcode=20=E5=BF=AB=E7=85=A7=EF=BC=8C=E6=9B=BF=E4=BB=A3=20bizTy?= =?UTF-8?q?pe=20=E5=86=92=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1/P2 联合修复(消息归属 + jump_app 值域污染): - FlwInstance 新增 apply_app_key/apply_app_code 双列快照(自动 DDL) - apply 从验签上下文(yh.app.auth)取调用方身份,by-key 一次换得 app_code (域间边界只在 apply 转换一次;查无/不可达降级 null,不阻断 apply) - UserMsgNotifier 两处 jumpApp 改源 applyAppCode——bizType 是消息分类 不是路由键;降级空时接收方按发送方回落(现状行为) - FlowDefAdminService.save appKey 必填化(2026-10-12 拍板):新建必须 显式传;升版本/直改可继承源行,源行为空必须补填(存量 19 条 NULL 随编辑收敛) - 单测:SaveTest 新增必填拦截 3 用例、BizUrlTest 换源断言+降级用例(19 绿) - E2E(8085+UserBase):100617->apply_app_code=notice 正向落库、 100018(app_code NULL)->降级成功、错签名 401,三断言全过 --- .../controller/oapi/OapiFlowController.java | 3 +- .../yunhai/flowcenter/entity/FlwInstance.java | 16 +++++ .../flowcenter/notify/UserMsgClient.java | 7 ++- .../flowcenter/notify/UserMsgNotifier.java | 13 ++-- .../service/FlowDefAdminService.java | 11 +++- .../service/FlowInstanceService.java | 9 +++ .../flowcenter/service/OapiFlowService.java | 34 +++++++++- .../flowcenter/engine/FlowEngineIT.java | 2 +- .../notify/UserMsgNotifierBizUrlTest.java | 33 +++++++++- .../service/FlowDefAdminServiceSaveTest.java | 62 +++++++++++++++---- 10 files changed, 160 insertions(+), 30 deletions(-) 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 efb7627..d2295f8 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java @@ -54,8 +54,9 @@ public class OapiFlowController { @PostMapping("/apply") public OapiModels.ApplyResultVo apply(@RequestParam("schId") Long schId, + @RequestAttribute(value = "yh.app.auth", required = false) String appKey, @RequestBody OapiModels.ApplyBody body) { - return flowService.apply(schId, body); + return flowService.apply(schId, appKey, body); } @GetMapping("/instance") diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java index 5542d37..72f1965 100644 --- a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java @@ -74,6 +74,22 @@ public class FlwInstance extends BaseEntity { @Column(name = "biz_url", length = 500) private String bizUrl; + /** + * 代发起调用方 app_key 快照(验签上下文 yh.app.auth,2026-10-12):验签域身份, + * 审计/对账用——与 flw_callback_reg/flw_def 的 app_key 同一口径。 + */ + @Column(name = "apply_app_key", length = 32) + private String applyAppKey; + + /** + * 代发起调用方 app_code 快照(apply 时按 app_key 查 UserBase /oapi/app/by-key 换得): + * 消息 jump_app 的权威来源(路由/跳转/判权域标识,projman app.name 口径如 notice/svc-hall)。 + * biz_url 页面即调用方页面,验签身份=页面属主由平台结构保证。UserBase 不可达时为空 + * →消息 jumpApp 空、回落发送方归属(现状行为),不阻断 apply。 + */ + @Column(name = "apply_app_code", length = 32) + private String applyAppCode; + /** 业务摘要 JSON 快照:办理页展示用(只收摘要,不收富文本正文,概览 §十边界) */ @Column(name = "biz_snapshot", columnDefinition = "LONGTEXT") private String bizSnapshot; diff --git a/src/main/java/com/yunhai/flowcenter/notify/UserMsgClient.java b/src/main/java/com/yunhai/flowcenter/notify/UserMsgClient.java index facedbe..00c4e6c 100644 --- a/src/main/java/com/yunhai/flowcenter/notify/UserMsgClient.java +++ b/src/main/java/com/yunhai/flowcenter/notify/UserMsgClient.java @@ -67,9 +67,10 @@ public class UserMsgClient { /** * 发送站内信(content 必填;msgNo 幂等,重发安全)。 * - * @param jumpApp 跳转页面归属应用(可空):页面属业务方(bizUrl 落地页)时填业务方应用名 - * (=instance.bizType),接收方优先按它查基址;页面属流程中心自己(/task、/instance) - * 传 null,由接收方按 bizType 回落 + * @param jumpApp 跳转页面归属应用(可空,值域=app_code 如 notice/svc-hall):页面属业务方 + * (bizUrl 落地页)时填 apply 调用方 app_code(=instance.applyAppCode,apply + * 时由验签身份按 by-key 换得),接收方优先按它查基址;页面属流程中心自己 + * (/task、/instance)传 null,由接收方按发送方 appKey 回落 */ public void sendUserMsg(Integer schId, List userIds, Integer msgType, String bizType, String bizId, String title, String content, String jumpPath, String jumpApp, diff --git a/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java b/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java index a7704de..f40a494 100644 --- a/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java +++ b/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java @@ -73,11 +73,13 @@ public class UserMsgNotifier { String content = "「" + safe(instance.getBizTitle()) + "」等待您审批" + (task.getNodeName() == null ? "" : "(" + task.getNodeName() + ")"); // 跳转路径:办件自带 bizUrl(业务方详情页,改造后为裸相对路径)优先——应用内审批免跳 - // 流程中心,页面归属=业务方(jumpApp=instance.bizType,接收方按它查基址拼绝对地址); - // 未传 bizUrl 回落流程中心办理页 /task/{taskId},页面属自己(jumpApp 留空按 bizType 回落) + // 流程中心,页面归属=apply 调用方(jumpApp=instance.applyAppCode,apply 时由验签身份 + // 按_by-key 换得的 app_code,接收方按它查基址拼绝对地址);applyAppCode 空(老数据/ + // UserBase 不可达)或未传 bizUrl 回落流程中心页(/task/{taskId}、/instance/{id}), + // 页面属自己(jumpApp 留空按发送方回落)。bizType 是消息分类,不是路由键,不再冒充。 boolean useBizUrl = instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty(); String jumpPath = useBizUrl ? instance.getBizUrl() : "/task/" + task.getId(); - String jumpApp = useBizUrl ? instance.getBizType() : null; + String jumpApp = useBizUrl ? instance.getApplyAppCode() : null; userMsgClient.sendUserMsg(instance.getSchId().intValue(), Collections.singletonList(task.getAssigneeId()), 2, "flow.task", String.valueOf(instance.getId()), title, content, @@ -143,10 +145,11 @@ public class UserMsgNotifier { String content = "「" + bizTitle + "」抄送给您" + (nodeName == null || String.valueOf(nodeName).isEmpty() ? "" : "(" + nodeName + ")"); - // 跳转与任务到达同策略:bizUrl(业务方详情页,页面归属=业务方)优先,空回落办件详情(属自己) + // 跳转与任务到达同策略:bizUrl(业务方详情页,页面归属=apply 调用方 applyAppCode)优先, + // 空回落办件详情(属自己) boolean useBizUrl = instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty(); String jumpPath = useBizUrl ? instance.getBizUrl() : "/instance/" + instance.getId(); - String jumpApp = useBizUrl ? instance.getBizType() : null; + String jumpApp = useBizUrl ? instance.getApplyAppCode() : null; userMsgClient.sendUserMsg(instance.getSchId().intValue(), Collections.singletonList((Integer) ccUserId), 1, "flow.cc", String.valueOf(instance.getId()), diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java index 6258828..ecce6da 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java @@ -75,13 +75,14 @@ public class FlowDefAdminService { * - id 非空:草稿直改;已发布/停用=复制升版本(同 flowGuid,version=max+1) * - id 空 + baseGuid 非空:在该流程线上开新版本草稿(version=max+1,flowGuid 不变)——前端"升版本" * - id 空:永远新开一条流程线(flowGuid 生成,version=1);同编码多条线合法,前端保存前有软提示 - * appKey=归属应用(筛选维度):新建/草稿直改直接落库;升版本(baseGuid/已发布行)缺省继承源线,传非空则覆盖 + * appKey=归属应用(筛选维度,2026-10-12 拍板必填):新建必须显式传;升版本/直改可继承源行, + * 但源行为空时必须显式补填——存量 NULL 线随编辑自然收敛(白名单只校验显式传入值,继承值不重校) */ @Transactional public FlwDef save(Long schId, Long id, String code, String name, String contentJson, String baseGuid, String appKey) { String cleanAppKey = (appKey == null || appKey.trim().isEmpty()) ? null : appKey.trim(); // 归属应用白名单(2026-10-10 应用设置):显式传入非空时必须 ∈ flw_app(sch, enabled=1); - // 不传(升版本继承源线)不拦。不在学校池/未开启的应用由此拦下(拍板 D2:不做存量兜底) + // 继承值不重校(既成事实,应用停用不应卡死升版本)。不在校池/未开启的应用由此拦下(拍板 D2:不做存量兜底) if (cleanAppKey != null && !appRepository.isEnabledApp(schId, cleanAppKey)) { throw new BizException(ErrorCode.PARAM_ERROR, "归属应用未开启或不存在(" + cleanAppKey + "):请在 流程管理 > 应用设置 中开启后重试"); @@ -134,6 +135,12 @@ public class FlowDefAdminService { } def.setCode(code.trim()); def.setName(name.trim()); + // 归属应用必填(2026-10-12 拍板):各分支归一后落库值不得为空——新建必须显式传; + // 升版本/直改继承源行,源行为空的存量线编辑时强制补填(19 条存量随编辑自然收敛) + if (def.getAppKey() == null || def.getAppKey().trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "归属应用必填:请选择归属应用后再保存(应用需先在 流程管理 > 应用设置 中开启)"); + } def.setContentJson(contentJson); // 定义 JSON 里的 code/name 与行字段一致性校验(给绑定回显用行字段,JSON 内为主) if (parsed.code != null && !parsed.code.trim().isEmpty() && !parsed.code.trim().equals(def.getCode())) { diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java index 0bf9726..11499ae 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java @@ -56,6 +56,10 @@ public class FlowInstanceService { public final Integer submitterId; public final String submitterName; public final String formDataJson; + /** 调用方验签身份快照(yh.app.auth;本地/无签名场景为 null) */ + public final String applyAppKey; + /** 调用方 app_code 快照(by-key 一次换得;UserBase 不可达为 null→消息回落发送方归属) */ + public final String applyAppCode; } /** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */ @@ -84,6 +88,8 @@ public class FlowInstanceService { instance.setBizUrl(cmd.bizUrl); instance.setBizSnapshot(cmd.bizSnapshot); instance.setFormDataJson(cmd.formDataJson); + instance.setApplyAppKey(cmd.applyAppKey); + instance.setApplyAppCode(cmd.applyAppCode); instance.setSubmitterId(cmd.submitterId); instance.setSubmitterName(cmd.submitterName); instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode()); @@ -106,6 +112,9 @@ public class FlowInstanceService { instance.setBizUrl(cmd.bizUrl); instance.setBizSnapshot(cmd.bizSnapshot); instance.setFormDataJson(cmd.formDataJson); + // 调用方身份快照跟随最新一次 apply 刷新(重启=新一轮代发起,调用方可能已变) + instance.setApplyAppKey(cmd.applyAppKey); + instance.setApplyAppCode(cmd.applyAppCode); instance.setSubmitterId(cmd.submitterId); instance.setSubmitterName(cmd.submitterName); instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode()); diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java index b316381..aa858a1 100644 --- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java @@ -79,6 +79,7 @@ public class OapiFlowService { private final FlowEngine flowEngine; private final ObjectMapper objectMapper; private final OpenApiUserClient userClient; + private final com.yunhai.platform.openapi.base.OpenApiAppClient appClient; private final DiagramTokenService tokenService; /** 本应用身份(回调探活补应用签名头;未装配时裸发,接收方 /oapi/* 会 401) */ @@ -101,9 +102,10 @@ public class OapiFlowService { .map(this::toSummary).orElse(null); } - /** 代发起(biz uk 幂等 + R4 重启重提,见 FlowInstanceService) */ + /** 代发起(biz uk 幂等 + R4 重启重提,见 FlowInstanceService):appKey=验签上下文调用方身份, + * 与 app_code 一起快照进办件(消息 jump_app 的权威来源,2026-10-12 起替代 bizType 冒充) */ @Transactional - public OapiModels.ApplyResultVo apply(Long schId, OapiModels.ApplyBody body) { + public OapiModels.ApplyResultVo apply(Long schId, String appKey, OapiModels.ApplyBody body) { if (schId == null || body.getFlowGuid() == null || body.getBizType() == null || body.getBizId() == null || body.getSubmitterId() == null) { throw new BizException(ErrorCode.PARAM_ERROR, @@ -114,12 +116,14 @@ public class OapiFlowService { schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode()) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程不存在或未发布:" + body.getFlowGuid())); + String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim(); FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand( schId, def, body.getBizType(), body.getBizId(), body.getBizTitle(), body.getBizUrl(), asSnapshotString(body.getBizSnapshot()), body.getSubmitterId(), body.getSubmitterName(), - asFormDataString(body.getFormData())); + asFormDataString(body.getFormData()), + cleanAppKey, resolveApplyAppCode(cleanAppKey)); FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd); OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo(); vo.setInstanceId(outcome.getInstanceId()); @@ -127,6 +131,30 @@ public class OapiFlowService { return vo; } + /** + * 调用方 app_key → app_code 一次转换(域间边界:验签域→路由域,只在 apply 做这一次)。 + * 查无/不可达降级 null——消息 jumpApp 空、归属回落发送方(现状行为),不阻断 apply; + * jump_app 值域契约(=app_code)由本方法单点保证,替代历史上用 bizType 冒充的写法。 + */ + private String resolveApplyAppCode(String appKey) { + if (appKey == null) { + return null; + } + try { + com.fasterxml.jackson.databind.JsonNode app = appClient.getAppByKey(appKey); + String code = app == null ? null : app.path("appCode").asText(null); + if (code == null || code.trim().isEmpty()) { + log.warn("[flow-center] apply 调用方 app_code 解析为空(by-key 查无):appKey={},消息归属将回落发送方", appKey); + return null; + } + return code.trim(); + } catch (Exception e) { + log.warn("[flow-center] apply 调用方 app_code 解析失败(UserBase 不可达?):appKey={}:{}", + appKey, e.toString()); + return null; + } + } + /** 按业务键查办件(对账兜底;不存在返回 null——对账方按"漂移待清理"处理) */ public OapiModels.InstanceVo instanceByBiz(Long schId, String bizType, String bizId) { FlwInstance instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId) diff --git a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java index d6c6420..55f9d3d 100644 --- a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java +++ b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java @@ -102,7 +102,7 @@ class FlowEngineIT extends FlowTestSupport { SCH, flowGuid, 1).orElseThrow(IllegalStateException::new); FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand( SCH, def, "svc_item", bizId, "张三的测试办件", "http://biz/1", "{\"k\":\"v\"}", - submitterId, submitterName, null); + submitterId, submitterName, null, "100001", "notice"); return instanceService.apply(cmd); } diff --git a/src/test/java/com/yunhai/flowcenter/notify/UserMsgNotifierBizUrlTest.java b/src/test/java/com/yunhai/flowcenter/notify/UserMsgNotifierBizUrlTest.java index 20dd583..7f4918f 100644 --- a/src/test/java/com/yunhai/flowcenter/notify/UserMsgNotifierBizUrlTest.java +++ b/src/test/java/com/yunhai/flowcenter/notify/UserMsgNotifierBizUrlTest.java @@ -20,9 +20,11 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; /** - * 待办通知 jumpPath/jumpApp 语义(自指地址优化 Phase 2):办件 bizUrl 非空=直达业务方详情页, - * 页面归属 jumpApp=instance.bizType(接收方按它查基址);空/空白=回落流程中心办理页 - * /task/{taskId},页面属自己(jumpApp=null,接收方按 bizType 回落)。 + * 待办通知 jumpPath/jumpApp 语义(自指地址优化 Phase 2;2026-10-12 换源): + * 办件 bizUrl 非空=直达业务方详情页,页面归属 jumpApp=instance.applyAppCode(apply 时由验签 + * 身份按 by-key 换得的 app_code——bizType 是消息分类,不是路由键,不再冒充); + * applyAppCode 空(存量行/UserBase 不可达降级)→ jumpApp=null,接收方按发送方回落; + * bizUrl 空/空白=回落流程中心办理页 /task/{taskId},页面属自己。 */ class UserMsgNotifierBizUrlTest { @@ -73,6 +75,8 @@ class UserMsgNotifierBizUrlTest { instance.setSchId(1L); instance.setBizTitle("张三的事项"); instance.setBizType("notice"); + instance.setApplyAppKey("100001"); + instance.setApplyAppCode("notice"); instance.setBizUrl("/list/detail?serid=27"); when(taskRepository.findById(77L)).thenReturn(Optional.of(task)); when(instanceRepository.findById(5L)).thenReturn(Optional.of(instance)); @@ -83,6 +87,28 @@ class UserMsgNotifierBizUrlTest { assertEquals("notice", userMsg.lastJumpApp); } + /** 降级兼容:applyAppCode 空(存量行/UserBase 不可达)→ jumpApp=null,接收方按发送方回落 */ + @Test + void applyAppCodeBlank_jumpAppNull_evenWithBizUrl() { + FlwTask task = new FlwTask(); + task.setId(81L); + task.setAssigneeId(101); + task.setInstanceId(9L); + FlwInstance instance = new FlwInstance(); + instance.setId(9L); + instance.setSchId(1L); + instance.setBizTitle("赵六的事项"); + instance.setBizType("svc_item"); + instance.setBizUrl("/hall/detail?id=3"); + when(taskRepository.findById(81L)).thenReturn(Optional.of(task)); + when(instanceRepository.findById(9L)).thenReturn(Optional.of(instance)); + + notifier.onFlowEvent(FlowEvent.of(FlowEvent.Type.TASK_CREATED, 9L, 81L, 101, "丁", null)); + + assertEquals("/hall/detail?id=3", userMsg.lastJumpPath); + assertNull(userMsg.lastJumpApp); + } + /** 存量兼容:业务方尚未升级仍传绝对 URL 时原样透传,jumpApp 照常按归属声明 */ @Test void bizUrlLegacyAbsolute_passedThroughWithOwner() { @@ -95,6 +121,7 @@ class UserMsgNotifierBizUrlTest { instance.setSchId(1L); instance.setBizTitle("王五的事项"); instance.setBizType("notice"); + instance.setApplyAppCode("notice"); instance.setBizUrl("http://base.yunhaixingtong.com/notice/#/list/detail?serid=27"); when(taskRepository.findById(79L)).thenReturn(Optional.of(task)); when(instanceRepository.findById(7L)).thenReturn(Optional.of(instance)); diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java index 09f9b81..492024b 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java @@ -28,6 +28,7 @@ import static org.mockito.Mockito.when; /** * save() 分派语义(2026-10-10 唯一键改 (sch,flow_guid,version) 后): * 新建=永远新线 v1(编码只是标签);升版本=复制行 maxByGuid+1;唯一键冲突转友好 BizException。 + * 2026-10-12 拍板 appKey 必填:新建必须显式传;升版本/直改可继承源行,源行为空必须补填。 * 注:@Nested 在 surefire 2.22 的 -Dtest 过滤下发现不了,保持拍平写法。 */ class FlowDefAdminServiceSaveTest { @@ -70,7 +71,7 @@ class FlowDefAdminServiceSaveTest { @Test void 新建_永远新开流程线_版本从1起() { ArgumentCaptor cap = ArgumentCaptor.forClass(FlwDef.class); - FlwDef saved = service.save(SCH, null, "work-leave", "请假", JSON, null, null); + FlwDef saved = service.save(SCH, null, "work-leave", "请假", JSON, null, "notice"); verify(defRepository).save(cap.capture()); assertEquals(cap.getValue(), saved); assertEquals(1, saved.getVersion()); @@ -81,8 +82,8 @@ class FlowDefAdminServiceSaveTest { @Test void 连续新建同编码_各得独立流程线() { - FlwDef a = service.save(SCH, null, "work-leave", "产假", JSON, null, null); - FlwDef b = service.save(SCH, null, "work-leave", "丧假", JSON, null, null); + FlwDef a = service.save(SCH, null, "work-leave", "产假", JSON, null, "notice"); + FlwDef b = service.save(SCH, null, "work-leave", "丧假", JSON, null, "notice"); assertEquals(1, a.getVersion()); assertEquals(1, b.getVersion()); assertNotEquals(a.getFlowGuid(), b.getFlowGuid()); @@ -90,8 +91,10 @@ class FlowDefAdminServiceSaveTest { @Test void baseGuid_在线上开新版本草稿_max加1且保持flowGuid() { + FlwDef base = def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()); + base.setAppKey("notice"); when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, "G")) - .thenReturn(Optional.of(def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()))); + .thenReturn(Optional.of(base)); when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(6); FlwDef saved = service.save(SCH, null, "work-leave", "请假", JSON, "G", null); @@ -104,7 +107,9 @@ class FlowDefAdminServiceSaveTest { @Test void 已发布行带id保存_复制升版本() { - when(defRepository.findById(9L)).thenReturn(Optional.of(def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()))); + FlwDef published = def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()); + published.setAppKey("notice"); + when(defRepository.findById(9L)).thenReturn(Optional.of(published)); when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4); FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", JSON, null, null); @@ -118,6 +123,7 @@ class FlowDefAdminServiceSaveTest { @Test void 草稿行带id保存_直改本行() { FlwDef draft = def(9L, "G", "work-leave", 5, FlwEnums.DefStatus.DRAFT.getCode()); + draft.setAppKey("notice"); when(defRepository.findById(9L)).thenReturn(Optional.of(draft)); FlwDef saved = service.save(SCH, 9L, "work-leave", "新名字", JSON, null, null); @@ -136,9 +142,15 @@ class FlowDefAdminServiceSaveTest { } @Test - void 新建_归属应用直接落库_空白规整为null() { - assertEquals("notice", service.save(SCH, null, "work-leave", "请假", JSON, null, "notice").getAppKey()); - assertNull(service.save(SCH, null, "work-leave", "请假", JSON, null, " ").getAppKey()); + void 新建_归属应用直接落库_空白规整生效() { + assertEquals("notice", service.save(SCH, null, "work-leave", "请假", JSON, null, " notice ").getAppKey()); + } + + @Test + void 新建_appKey不传_必填被拦() { + BizException e = assertThrows(BizException.class, + () -> service.save(SCH, null, "work-leave", "请假", JSON, null, " ")); + assertTrue(e.getMessage().contains("归属应用必填")); } @Test @@ -152,6 +164,18 @@ class FlowDefAdminServiceSaveTest { assertEquals("crm", service.save(SCH, null, "work-leave", "请假", JSON, "G", "crm").getAppKey()); } + @Test + void 升版本_源线appKey为空且不传_必填被拦() { + // 存量 NULL 线(D2 不兜底的 19 条):第一次升版本被强制补填——随编辑自然收敛 + when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, "G")) + .thenReturn(Optional.of(def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()))); + when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4); + + BizException e = assertThrows(BizException.class, + () -> service.save(SCH, null, "work-leave", "请假", JSON, "G", null)); + assertTrue(e.getMessage().contains("归属应用必填")); + } + @Test void 草稿直改_传appKey覆盖_不传保留() { FlwDef draft = def(9L, "G", "work-leave", 5, FlwEnums.DefStatus.DRAFT.getCode()); @@ -162,9 +186,21 @@ class FlowDefAdminServiceSaveTest { assertEquals("crm", service.save(SCH, 9L, "work-leave", "请假", JSON, null, "crm").getAppKey()); } + @Test + void 草稿直改_源行appKey为空且不传_必填被拦() { + FlwDef draft = def(9L, "G", "work-leave", 5, FlwEnums.DefStatus.DRAFT.getCode()); + when(defRepository.findById(9L)).thenReturn(Optional.of(draft)); + + BizException e = assertThrows(BizException.class, + () -> service.save(SCH, 9L, "work-leave", "请假", JSON, null, null)); + assertTrue(e.getMessage().contains("归属应用必填")); + } + @Test void 并发升版本撞唯一键_转友好提示而非内部服务错误() { - when(defRepository.findById(9L)).thenReturn(Optional.of(def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()))); + FlwDef published = def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()); + published.setAppKey("notice"); + when(defRepository.findById(9L)).thenReturn(Optional.of(published)); when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4); when(defRepository.save(any())).thenThrow(new DataIntegrityViolationException("dup")); @@ -183,12 +219,14 @@ class FlowDefAdminServiceSaveTest { @Test void appKey不传_白名单校验不触发() { - // 升版本继承源线(cleanAppKey=null)不查白名单——存量线升版本不受切换影响 - when(defRepository.findById(9L)).thenReturn(Optional.of(def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()))); + // 升版本继承源线(cleanAppKey=null)不查白名单——继承值是既成事实,应用停用不卡升版本 + FlwDef published = def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()); + published.setAppKey("notice"); + when(defRepository.findById(9L)).thenReturn(Optional.of(published)); when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4); when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, "G")).thenReturn(Optional.empty()); FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", JSON, null, null); org.mockito.Mockito.verify(appRepository, org.mockito.Mockito.never()).isEnabledApp(any(), any()); - assertNull(saved.getAppKey()); + assertEquals("notice", saved.getAppKey()); } }