From a1f99fd7e0a1eff7f5a773a2b60a64694b33f64c Mon Sep 17 00:00:00 2001 From: chenxing Date: Sat, 10 Oct 2026 14:34:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(def):=20=E6=B5=81=E7=A8=8B=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E5=94=AF=E4=B8=80=E9=94=AE=E6=94=B9=E6=8C=89=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E7=BA=BF=E7=AE=A1=20+=20=E5=BD=92=E5=B1=9E=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flw_def 唯一键 (sch,code,ver)→(sch,flow_guid,ver)(uk_flw_def_guid_ver,生产/本地已手工切索引):升版本 maxByGuid+1 与唯一键同口径,同编码多条流程线(work-leave 三条线实锤追尾)不再死锁 - 新建永远新开流程线 v1,删除同编码草稿复用;撞唯一键转"保存冲突"友好提示 - 新增 flw_def.app_key 归属应用列:save 全链路透传(升版本缺省继承源线、传非空覆盖);/admin/def/list 支持 appKey 过滤;新增 /admin/def/appkeys 应用清单(flw_def 已标注 ∪ 已注册回调) - FlowDefAdminServiceSaveTest 10 用例锁 save 分派语义 --- .../controller/api/FlowAdminController.java | 14 +- .../com/yunhai/flowcenter/entity/FlwDef.java | 13 +- .../repository/FlwCallbackRegRepository.java | 6 + .../repository/FlwDefRepository.java | 10 +- .../service/FlowDefAdminService.java | 72 +++++--- .../service/FlowDefAdminServiceSaveTest.java | 171 ++++++++++++++++++ 6 files changed, 248 insertions(+), 38 deletions(-) create mode 100644 src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java index 10c6480..6fa3a46 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java @@ -54,8 +54,15 @@ public class FlowAdminController { @GetMapping("/def/list") public List defList(BoaUser user, - @RequestParam(value = "status", required = false) Integer status) { - return defAdminService.list(adminGuard.requireAdmin(user, null), status); + @RequestParam(value = "status", required = false) Integer status, + @RequestParam(value = "appKey", required = false) String appKey) { + return defAdminService.list(adminGuard.requireAdmin(user, null), status, appKey); + } + + /** 应用清单("按应用筛选"下拉):本校 flw_def 已标注 ∪ 已注册回调的应用 */ + @GetMapping("/def/appkeys") + public List defAppKeys(BoaUser user) { + return defAdminService.distinctAppKeys(adminGuard.requireAdmin(user, null)); } @GetMapping("/def/get") @@ -70,7 +77,8 @@ public class FlowAdminController { Long id = body.get("id") == null ? null : Long.valueOf(String.valueOf(body.get("id"))); return defAdminService.save(schId, id, (String) body.get("code"), (String) body.get("name"), - (String) body.get("contentJson"), (String) body.get("baseGuid")); + (String) body.get("contentJson"), (String) body.get("baseGuid"), + (String) body.get("appKey")); } @PostMapping("/def/publish/{id}") diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java b/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java index 871fc5c..13ac96f 100644 --- a/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java @@ -20,8 +20,8 @@ import java.time.LocalDateTime; @Setter @Entity @Table(name = "flw_def", - uniqueConstraints = @UniqueConstraint(name = "uk_flw_def_sch_code_ver", - columnNames = {"sch_id", "code", "version"}), + uniqueConstraints = @UniqueConstraint(name = "uk_flw_def_guid_ver", + columnNames = {"sch_id", "flow_guid", "version"}), indexes = @Index(name = "idx_flw_def_guid", columnList = "sch_id, flow_guid, status, version")) public class FlwDef extends BaseEntity { @@ -33,7 +33,10 @@ public class FlwDef extends BaseEntity { @Column(name = "flow_guid", nullable = false, length = 32) private String flowGuid; - /** 流程编码(本应用内部管理键) */ + /** + * 流程编码(管理端分类/检索标签,可重名)。2026-10-10 起唯一键改为 (sch_id, flow_guid, version), + * 编码不再承担唯一性——多条流程线(如同为请假的产假/丧假)可共用一个编码。 + */ @Column(name = "code", nullable = false, length = 64) private String code; @@ -41,6 +44,10 @@ public class FlwDef extends BaseEntity { @Column(name = "name", nullable = false, length = 128) private String name; + /** 归属应用(子应用 appKey,可空=未分配;管理端"按应用筛选"维度,2026-10-10) */ + @Column(name = "app_key", length = 64) + private String appKey; + /** 版本,发布后不可改,改定义=升版本 */ @Column(name = "version", nullable = false) private Integer version; diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackRegRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackRegRepository.java index dda097a..fc6b5ef 100644 --- a/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackRegRepository.java +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackRegRepository.java @@ -2,6 +2,8 @@ package com.yunhai.flowcenter.repository; import com.yunhai.flowcenter.entity.FlwCallbackReg; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -14,4 +16,8 @@ public interface FlwCallbackRegRepository extends JpaRepository findBySchIdAndFlowGuidAndStatus(Long schId, String flowGuid, Integer status); List findBySchIdOrderByUpdateTimeDesc(Long schId); + + /** "按应用筛选"下拉数据源之一:本校注册过回调的应用 */ + @Query("select distinct r.appKey from FlwCallbackReg r where r.schId = :schId") + List findDistinctAppKeysBySchId(@Param("schId") Long schId); } diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java index 01b8633..521a266 100644 --- a/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java @@ -18,16 +18,12 @@ public interface FlwDefRepository extends JpaRepository { /** 任意状态(回调注册只要求流程线存在) */ Optional findFirstBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid); - /** 同名草稿(保存新建时复用,避免 uk 冲突) */ - Optional findFirstBySchIdAndCodeAndStatus(Long schId, String code, Integer status); - - @Query("select coalesce(max(d.version), 0) from FlwDef d where d.schId = :schId and d.code = :code") - int maxVersionByCode(@Param("schId") Long schId, @Param("code") String code); - @Query("select coalesce(max(d.version), 0) from FlwDef d where d.schId = :schId and d.flowGuid = :flowGuid") int maxVersionByGuid(@Param("schId") Long schId, @Param("flowGuid") String flowGuid); - Optional findFirstBySchIdAndCodeOrderByVersionDesc(Long schId, String code); + /** "按应用筛选"下拉数据源之一:本校 flw_def 已标注的应用 */ + @Query("select distinct d.appKey from FlwDef d where d.schId = :schId and d.appKey is not null") + List findDistinctAppKeys(@Param("schId") Long schId); List findBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid); diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java index 9c0711d..43d7446 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java @@ -12,12 +12,15 @@ import com.yunhai.flowcenter.repository.FlwDefRepository; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Set; +import java.util.TreeSet; import java.util.UUID; import java.util.stream.Collectors; @@ -32,15 +35,28 @@ public class FlowDefAdminService { private final FlwDefRepository defRepository; private final FlwAssigneeMapRepository mapRepository; private final com.yunhai.flowcenter.repository.FlwRouterRepository routerRepository; + private final com.yunhai.flowcenter.repository.FlwCallbackRegRepository regRepository; private final FlowEngine flowEngine; private final ObjectMapper objectMapper; - /** 定义列表:status 空=全量(各流程线各版本都展示,管理端"版本历史"内聚在行操作里) */ - public List list(Long schId, Integer status) { - if (status != null) { - return defRepository.findBySchIdAndStatusOrderByUpdateTimeDesc(schId, status); + /** 定义列表:status/appKey 可选过滤(各流程线各版本都展示;appKey 精确匹配,2026-10-10 按应用筛选) */ + public List list(Long schId, Integer status, String appKey) { + List rows = status != null + ? defRepository.findBySchIdAndStatusOrderByUpdateTimeDesc(schId, status) + : defRepository.findBySchIdOrderByUpdateTimeDesc(schId); + String key = appKey == null ? null : appKey.trim(); + if (key != null && !key.isEmpty()) { + rows = rows.stream().filter(d -> key.equals(d.getAppKey())).collect(Collectors.toList()); } - return defRepository.findBySchIdOrderByUpdateTimeDesc(schId); + return rows; + } + + /** 应用清单("按应用筛选"下拉数据源):本校 flw_def 已标注 ∪ 已注册回调的应用,去重排序 */ + public List distinctAppKeys(Long schId) { + TreeSet keys = new TreeSet<>(); + keys.addAll(defRepository.findDistinctAppKeys(schId)); + keys.addAll(regRepository.findDistinctAppKeysBySchId(schId)); + return new ArrayList<>(keys); } /** 全量列表(管理端首页:各流程线最新版本+状态聚合展示简化为全量返回) */ @@ -54,14 +70,15 @@ public class FlowDefAdminService { } /** - * 保存(新建语义按 baseGuid/同名草稿分派,避免 uk(sch,code,version) 冲突): + * 保存(2026-10-10 起唯一键为 uk(sch,flow_guid,version),编码降级为可重名的分类标签): * - id 非空:草稿直改;已发布/停用=复制升版本(同 flowGuid,version=max+1) * - id 空 + baseGuid 非空:在该流程线上开新版本草稿(version=max+1,flowGuid 不变)——前端"升版本" - * - id 空 + 同名草稿存在:复用该草稿直改 - * - id 空 + 全新 code:新流程线(flowGuid 生成,version=1) + * - id 空:永远新开一条流程线(flowGuid 生成,version=1);同编码多条线合法,前端保存前有软提示 + * appKey=归属应用(筛选维度):新建/草稿直改直接落库;升版本(baseGuid/已发布行)缺省继承源线,传非空则覆盖 */ @Transactional - public FlwDef save(Long schId, Long id, String code, String name, String contentJson, String baseGuid) { + 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(); // JSON 先解析再落库(语法错误不进库) FlowDefModel parsed = FlowDefModel.parse(objectMapper, contentJson); if (code == null || code.trim().isEmpty() || name == null || name.trim().isEmpty()) { @@ -69,8 +86,8 @@ public class FlowDefAdminService { } FlwDef def; if (id == null && baseGuid != null && !baseGuid.trim().isEmpty()) { - // 在既有流程线上开新版本草稿 - defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, baseGuid.trim()) + // 在既有流程线上开新版本草稿(appKey 缺省继承源线) + FlwDef base = defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, baseGuid.trim()) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "baseGuid 流程线不存在")); def = new FlwDef(); def.setSchId(schId); @@ -78,25 +95,24 @@ public class FlowDefAdminService { def.setCode(code.trim()); def.setVersion(defRepository.maxVersionByGuid(schId, baseGuid.trim()) + 1); def.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); + def.setAppKey(cleanAppKey != null ? cleanAppKey : base.getAppKey()); } else if (id == null) { - FlwDef draft = defRepository.findFirstBySchIdAndCodeAndStatus( - schId, code.trim(), FlwEnums.DefStatus.DRAFT.getCode()).orElse(null); - if (draft != null) { - // 同名草稿复用直改 - def = draft; - } else { - def = new FlwDef(); - def.setSchId(schId); - def.setFlowGuid(UUID.randomUUID().toString().replace("-", "").toLowerCase()); - def.setCode(code.trim()); - def.setVersion(defRepository.maxVersionByCode(schId, code.trim()) + 1); - def.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); - } + // 新建:永远新开一条流程线,版本从 1 起(不再做同编码草稿复用) + def = new FlwDef(); + def.setSchId(schId); + def.setFlowGuid(UUID.randomUUID().toString().replace("-", "").toLowerCase()); + def.setCode(code.trim()); + def.setVersion(1); + def.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); + def.setAppKey(cleanAppKey); } else { def = requireDef(schId, id); if (!def.getCode().equals(code.trim())) { throw new BizException(ErrorCode.PARAM_ERROR, "流程编码不可修改"); } + if (cleanAppKey != null) { + def.setAppKey(cleanAppKey); + } if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) { // 升版本:复制行,保持 flowGuid FlwDef next = new FlwDef(); @@ -105,6 +121,7 @@ public class FlowDefAdminService { next.setCode(def.getCode()); next.setVersion(defRepository.maxVersionByGuid(schId, def.getFlowGuid()) + 1); next.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); + next.setAppKey(cleanAppKey != null ? cleanAppKey : def.getAppKey()); def = next; } } @@ -118,7 +135,12 @@ public class FlowDefAdminService { if (parsed.name != null && !parsed.name.trim().isEmpty()) { def.setName(parsed.name.trim()); } - return defRepository.save(def); + try { + return defRepository.save(def); + } catch (DataIntegrityViolationException e) { + // 撞唯一键 (sch,flow_guid,version):并发升版本同一线时可能撞号,转可理解提示(原样抛出=笼统"内部服务错误") + throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试"); + } } /** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布 */ diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java new file mode 100644 index 0000000..95e84cf --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java @@ -0,0 +1,171 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.FlowEngine; +import com.yunhai.flowcenter.entity.FlwDef; +import com.yunhai.flowcenter.enums.FlwEnums; +import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository; +import com.yunhai.flowcenter.repository.FlwDefRepository; +import com.yunhai.flowcenter.repository.FlwRouterRepository; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +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.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * save() 分派语义(2026-10-10 唯一键改 (sch,flow_guid,version) 后): + * 新建=永远新线 v1(编码只是标签);升版本=复制行 maxByGuid+1;唯一键冲突转友好 BizException。 + * 注:@Nested 在 surefire 2.22 的 -Dtest 过滤下发现不了,保持拍平写法。 + */ +class FlowDefAdminServiceSaveTest { + + private static final Long SCH = 3L; + private static final String JSON = "{\"nodes\":[],\"edges\":[]}"; + + private FlwDefRepository defRepository; + private FlowDefAdminService service; + + @BeforeEach + void setUp() { + defRepository = mock(FlwDefRepository.class); + service = new FlowDefAdminService(defRepository, + mock(FlwAssigneeMapRepository.class), + mock(FlwRouterRepository.class), + mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class), + mock(FlowEngine.class), + new ObjectMapper()); + when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + } + + private FlwDef def(long id, String flowGuid, String code, int version, int status) { + FlwDef d = new FlwDef(); + d.setId(id); + d.setSchId(SCH); + d.setFlowGuid(flowGuid); + d.setCode(code); + d.setVersion(version); + d.setStatus(status); + d.setContentJson(JSON); + return d; + } + + @Test + void 新建_永远新开流程线_版本从1起() { + ArgumentCaptor cap = ArgumentCaptor.forClass(FlwDef.class); + FlwDef saved = service.save(SCH, null, "work-leave", "请假", JSON, null, null); + verify(defRepository).save(cap.capture()); + assertEquals(cap.getValue(), saved); + assertEquals(1, saved.getVersion()); + assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), saved.getStatus()); + assertEquals("work-leave", saved.getCode()); + assertEquals(32, saved.getFlowGuid().length()); + } + + @Test + void 连续新建同编码_各得独立流程线() { + FlwDef a = service.save(SCH, null, "work-leave", "产假", JSON, null, null); + FlwDef b = service.save(SCH, null, "work-leave", "丧假", JSON, null, null); + assertEquals(1, a.getVersion()); + assertEquals(1, b.getVersion()); + assertNotEquals(a.getFlowGuid(), b.getFlowGuid()); + } + + @Test + void baseGuid_在线上开新版本草稿_max加1且保持flowGuid() { + when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, "G")) + .thenReturn(Optional.of(def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()))); + when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(6); + + FlwDef saved = service.save(SCH, null, "work-leave", "请假", JSON, "G", null); + + assertEquals("G", saved.getFlowGuid()); + assertEquals(7, saved.getVersion()); + assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), saved.getStatus()); + assertNull(saved.getId()); + } + + @Test + void 已发布行带id保存_复制升版本() { + when(defRepository.findById(9L)).thenReturn(Optional.of(def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()))); + when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4); + + FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", JSON, null, null); + + assertEquals("G", saved.getFlowGuid()); + assertEquals(5, saved.getVersion()); + assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), saved.getStatus()); + assertNull(saved.getId()); + } + + @Test + void 草稿行带id保存_直改本行() { + FlwDef draft = def(9L, "G", "work-leave", 5, FlwEnums.DefStatus.DRAFT.getCode()); + when(defRepository.findById(9L)).thenReturn(Optional.of(draft)); + + FlwDef saved = service.save(SCH, 9L, "work-leave", "新名字", JSON, null, null); + + assertSame(draft, saved); + assertEquals("新名字", saved.getName()); + } + + @Test + void baseGuid流程线不存在_点名报错() { + when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, "GX")) + .thenReturn(Optional.empty()); + BizException e = assertThrows(BizException.class, + () -> service.save(SCH, null, "work-leave", "请假", JSON, "GX", null)); + assertTrue(e.getMessage().contains("baseGuid")); + } + + @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()); + } + + @Test + void 升版本_appKey缺省继承源线_传非空则覆盖() { + FlwDef base = def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()); + base.setAppKey("notice"); + when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, "G")).thenReturn(Optional.of(base)); + when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4); + + assertEquals("notice", service.save(SCH, null, "work-leave", "请假", JSON, "G", null).getAppKey()); + assertEquals("crm", service.save(SCH, null, "work-leave", "请假", JSON, "G", "crm").getAppKey()); + } + + @Test + void 草稿直改_传appKey覆盖_不传保留() { + FlwDef draft = def(9L, "G", "work-leave", 5, FlwEnums.DefStatus.DRAFT.getCode()); + draft.setAppKey("notice"); + when(defRepository.findById(9L)).thenReturn(Optional.of(draft)); + + assertEquals("notice", service.save(SCH, 9L, "work-leave", "请假", JSON, null, null).getAppKey()); + assertEquals("crm", service.save(SCH, 9L, "work-leave", "请假", JSON, null, "crm").getAppKey()); + } + + @Test + void 并发升版本撞唯一键_转友好提示而非内部服务错误() { + when(defRepository.findById(9L)).thenReturn(Optional.of(def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()))); + when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4); + when(defRepository.save(any())).thenThrow(new DataIntegrityViolationException("dup")); + + BizException e = assertThrows(BizException.class, + () -> service.save(SCH, 9L, "work-leave", "请假", JSON, null, null)); + assertTrue(e.getMessage().contains("保存冲突")); + } +}