feat(def): 流程定义唯一键改按流程线管 + 归属应用筛选
build / build-push (push) Successful in 8m31s

- 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 分派语义
This commit is contained in:
chenxing
2026-10-10 14:34:25 +08:00
parent 7c92e953ec
commit a1f99fd7e0
6 changed files with 248 additions and 38 deletions
@@ -54,8 +54,15 @@ public class FlowAdminController {
@GetMapping("/def/list") @GetMapping("/def/list")
public List<FlwDef> defList(BoaUser user, public List<FlwDef> defList(BoaUser user,
@RequestParam(value = "status", required = false) Integer status) { @RequestParam(value = "status", required = false) Integer status,
return defAdminService.list(adminGuard.requireAdmin(user, null), status); @RequestParam(value = "appKey", required = false) String appKey) {
return defAdminService.list(adminGuard.requireAdmin(user, null), status, appKey);
}
/** 应用清单("按应用筛选"下拉):本校 flw_def 已标注 ∪ 已注册回调的应用 */
@GetMapping("/def/appkeys")
public List<String> defAppKeys(BoaUser user) {
return defAdminService.distinctAppKeys(adminGuard.requireAdmin(user, null));
} }
@GetMapping("/def/get") @GetMapping("/def/get")
@@ -70,7 +77,8 @@ public class FlowAdminController {
Long id = body.get("id") == null ? null : Long.valueOf(String.valueOf(body.get("id"))); Long id = body.get("id") == null ? null : Long.valueOf(String.valueOf(body.get("id")));
return defAdminService.save(schId, id, return defAdminService.save(schId, id,
(String) body.get("code"), (String) body.get("name"), (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}") @PostMapping("/def/publish/{id}")
@@ -20,8 +20,8 @@ import java.time.LocalDateTime;
@Setter @Setter
@Entity @Entity
@Table(name = "flw_def", @Table(name = "flw_def",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_def_sch_code_ver", uniqueConstraints = @UniqueConstraint(name = "uk_flw_def_guid_ver",
columnNames = {"sch_id", "code", "version"}), columnNames = {"sch_id", "flow_guid", "version"}),
indexes = @Index(name = "idx_flw_def_guid", columnList = "sch_id, flow_guid, status, version")) indexes = @Index(name = "idx_flw_def_guid", columnList = "sch_id, flow_guid, status, version"))
public class FlwDef extends BaseEntity { public class FlwDef extends BaseEntity {
@@ -33,7 +33,10 @@ public class FlwDef extends BaseEntity {
@Column(name = "flow_guid", nullable = false, length = 32) @Column(name = "flow_guid", nullable = false, length = 32)
private String flowGuid; private String flowGuid;
/** 流程编码(本应用内部管理键) */ /**
* 流程编码(管理端分类/检索标签,可重名)。2026-10-10 起唯一键改为 (sch_id, flow_guid, version),
* 编码不再承担唯一性——多条流程线(如同为请假的产假/丧假)可共用一个编码。
*/
@Column(name = "code", nullable = false, length = 64) @Column(name = "code", nullable = false, length = 64)
private String code; private String code;
@@ -41,6 +44,10 @@ public class FlwDef extends BaseEntity {
@Column(name = "name", nullable = false, length = 128) @Column(name = "name", nullable = false, length = 128)
private String name; private String name;
/** 归属应用(子应用 appKey,可空=未分配;管理端"按应用筛选"维度,2026-10-10) */
@Column(name = "app_key", length = 64)
private String appKey;
/** 版本,发布后不可改,改定义=升版本 */ /** 版本,发布后不可改,改定义=升版本 */
@Column(name = "version", nullable = false) @Column(name = "version", nullable = false)
private Integer version; private Integer version;
@@ -2,6 +2,8 @@ package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwCallbackReg; import com.yunhai.flowcenter.entity.FlwCallbackReg;
import org.springframework.data.jpa.repository.JpaRepository; 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.List;
import java.util.Optional; import java.util.Optional;
@@ -14,4 +16,8 @@ public interface FlwCallbackRegRepository extends JpaRepository<FlwCallbackReg,
List<FlwCallbackReg> findBySchIdAndFlowGuidAndStatus(Long schId, String flowGuid, Integer status); List<FlwCallbackReg> findBySchIdAndFlowGuidAndStatus(Long schId, String flowGuid, Integer status);
List<FlwCallbackReg> findBySchIdOrderByUpdateTimeDesc(Long schId); List<FlwCallbackReg> findBySchIdOrderByUpdateTimeDesc(Long schId);
/** "按应用筛选"下拉数据源之一:本校注册过回调的应用 */
@Query("select distinct r.appKey from FlwCallbackReg r where r.schId = :schId")
List<String> findDistinctAppKeysBySchId(@Param("schId") Long schId);
} }
@@ -18,16 +18,12 @@ public interface FlwDefRepository extends JpaRepository<FlwDef, Long> {
/** 任意状态(回调注册只要求流程线存在) */ /** 任意状态(回调注册只要求流程线存在) */
Optional<FlwDef> findFirstBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid); Optional<FlwDef> findFirstBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid);
/** 同名草稿(保存新建时复用,避免 uk 冲突) */
Optional<FlwDef> 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") @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); int maxVersionByGuid(@Param("schId") Long schId, @Param("flowGuid") String flowGuid);
Optional<FlwDef> 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<String> findDistinctAppKeys(@Param("schId") Long schId);
List<FlwDef> findBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid); List<FlwDef> findBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid);
@@ -12,12 +12,15 @@ import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.TreeSet;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -32,15 +35,28 @@ public class FlowDefAdminService {
private final FlwDefRepository defRepository; private final FlwDefRepository defRepository;
private final FlwAssigneeMapRepository mapRepository; private final FlwAssigneeMapRepository mapRepository;
private final com.yunhai.flowcenter.repository.FlwRouterRepository routerRepository; private final com.yunhai.flowcenter.repository.FlwRouterRepository routerRepository;
private final com.yunhai.flowcenter.repository.FlwCallbackRegRepository regRepository;
private final FlowEngine flowEngine; private final FlowEngine flowEngine;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/** 定义列表:status 空=全量(各流程线各版本都展示,管理端"版本历史"内聚在行操作里) */ /** 定义列表:status/appKey 可选过滤(各流程线各版本都展示;appKey 精确匹配,2026-10-10 按应用筛选) */
public List<FlwDef> list(Long schId, Integer status) { public List<FlwDef> list(Long schId, Integer status, String appKey) {
if (status != null) { List<FlwDef> rows = status != null
return defRepository.findBySchIdAndStatusOrderByUpdateTimeDesc(schId, status); ? 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<String> distinctAppKeys(Long schId) {
TreeSet<String> 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 非空:草稿直改;已发布/停用=复制升版本(同 flowGuid,version=max+1)
* - id 空 + baseGuid 非空:在该流程线上开新版本草稿(version=max+1,flowGuid 不变)——前端"升版本" * - id 空 + baseGuid 非空:在该流程线上开新版本草稿(version=max+1,flowGuid 不变)——前端"升版本"
* - id 空 + 同名草稿存在:复用该草稿直改 * - id 空:永远新开一条流程线(flowGuid 生成,version=1);同编码多条线合法,前端保存前有软提示
* - id 空 + 全新 code:新流程线(flowGuid 生成,version=1) * appKey=归属应用(筛选维度):新建/草稿直改直接落库;升版本(baseGuid/已发布行)缺省继承源线,传非空则覆盖
*/ */
@Transactional @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 先解析再落库(语法错误不进库) // JSON 先解析再落库(语法错误不进库)
FlowDefModel parsed = FlowDefModel.parse(objectMapper, contentJson); FlowDefModel parsed = FlowDefModel.parse(objectMapper, contentJson);
if (code == null || code.trim().isEmpty() || name == null || name.trim().isEmpty()) { if (code == null || code.trim().isEmpty() || name == null || name.trim().isEmpty()) {
@@ -69,8 +86,8 @@ public class FlowDefAdminService {
} }
FlwDef def; FlwDef def;
if (id == null && baseGuid != null && !baseGuid.trim().isEmpty()) { if (id == null && baseGuid != null && !baseGuid.trim().isEmpty()) {
// 在既有流程线上开新版本草稿 // 在既有流程线上开新版本草稿(appKey 缺省继承源线)
defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, baseGuid.trim()) FlwDef base = defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, baseGuid.trim())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "baseGuid 流程线不存在")); .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "baseGuid 流程线不存在"));
def = new FlwDef(); def = new FlwDef();
def.setSchId(schId); def.setSchId(schId);
@@ -78,25 +95,24 @@ public class FlowDefAdminService {
def.setCode(code.trim()); def.setCode(code.trim());
def.setVersion(defRepository.maxVersionByGuid(schId, baseGuid.trim()) + 1); def.setVersion(defRepository.maxVersionByGuid(schId, baseGuid.trim()) + 1);
def.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); def.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
def.setAppKey(cleanAppKey != null ? cleanAppKey : base.getAppKey());
} else if (id == null) { } else if (id == null) {
FlwDef draft = defRepository.findFirstBySchIdAndCodeAndStatus( // 新建:永远新开一条流程线,版本从 1 起(不再做同编码草稿复用)
schId, code.trim(), FlwEnums.DefStatus.DRAFT.getCode()).orElse(null);
if (draft != null) {
// 同名草稿复用直改
def = draft;
} else {
def = new FlwDef(); def = new FlwDef();
def.setSchId(schId); def.setSchId(schId);
def.setFlowGuid(UUID.randomUUID().toString().replace("-", "").toLowerCase()); def.setFlowGuid(UUID.randomUUID().toString().replace("-", "").toLowerCase());
def.setCode(code.trim()); def.setCode(code.trim());
def.setVersion(defRepository.maxVersionByCode(schId, code.trim()) + 1); def.setVersion(1);
def.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); def.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
} def.setAppKey(cleanAppKey);
} else { } else {
def = requireDef(schId, id); def = requireDef(schId, id);
if (!def.getCode().equals(code.trim())) { if (!def.getCode().equals(code.trim())) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程编码不可修改"); throw new BizException(ErrorCode.PARAM_ERROR, "流程编码不可修改");
} }
if (cleanAppKey != null) {
def.setAppKey(cleanAppKey);
}
if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) { if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) {
// 升版本:复制行,保持 flowGuid // 升版本:复制行,保持 flowGuid
FlwDef next = new FlwDef(); FlwDef next = new FlwDef();
@@ -105,6 +121,7 @@ public class FlowDefAdminService {
next.setCode(def.getCode()); next.setCode(def.getCode());
next.setVersion(defRepository.maxVersionByGuid(schId, def.getFlowGuid()) + 1); next.setVersion(defRepository.maxVersionByGuid(schId, def.getFlowGuid()) + 1);
next.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); next.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
next.setAppKey(cleanAppKey != null ? cleanAppKey : def.getAppKey());
def = next; def = next;
} }
} }
@@ -118,7 +135,12 @@ public class FlowDefAdminService {
if (parsed.name != null && !parsed.name.trim().isEmpty()) { if (parsed.name != null && !parsed.name.trim().isEmpty()) {
def.setName(parsed.name.trim()); def.setName(parsed.name.trim());
} }
try {
return defRepository.save(def); return defRepository.save(def);
} catch (DataIntegrityViolationException e) {
// 撞唯一键 (sch,flow_guid,version):并发升版本同一线时可能撞号,转可理解提示(原样抛出=笼统"内部服务错误")
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试");
}
} }
/** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布 */ /** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布 */
@@ -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<FlwDef> 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("保存冲突"));
}
}