From 72fe0f218a3c283748fde4de84a9ab099ed038ca Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 22 Oct 2026 17:14:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20application=5Finfo=20app=5Fcode=20?= =?UTF-8?q?=E5=86=99=E5=85=A5=E5=8F=A3=E5=AE=88=E5=8D=AB=EF=BC=88=E5=B7=B2?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E6=8B=92=E6=94=B9=E5=90=8D/=E6=8B=92?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=EF=BC=8C=E5=88=9D=E6=AC=A1=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E6=80=A7=E4=BF=9D=E6=8C=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ApplicationInfoRepository.java | 6 ++ .../service/impl/ApplicationInfoService.java | 16 +++- .../impl/ApplicationInfoAppCodeTest.java | 84 +++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoAppCodeTest.java diff --git a/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java index 0226dba..b78db38 100644 --- a/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java @@ -17,6 +17,12 @@ import java.util.List; * @description */ public interface ApplicationInfoRepository extends JpaRepository, JpaSpecificationExecutor { + /** 读取持久化标识快照;禁止查询前自动 flush 掉待校验的实体改名。 */ + @Query("SELECT a.appCode FROM ApplicationInfo a WHERE a.id = :id") + @org.springframework.data.jpa.repository.QueryHints( + @javax.persistence.QueryHint(name = "org.hibernate.flushMode", value = "COMMIT")) + String findPersistedAppCodeById(@Param("id") Integer id); + // 基石应用排除 = AppNature.isBaseApp(appKey<=100000 ∪ app_nature='BASE')的 SQL 形态,常量拼接、语义单点见 AppNature。 // app_nature 过渡期可空:`<> 'BASE'` 遇 NULL 求值为 NULL(非 TRUE)会整行漏掉,必须带 IS NULL 分支。 @Query("SELECT COUNT(a.id) FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 " + diff --git a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java index f3d1857..0784a4c 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java @@ -145,6 +145,10 @@ public class ApplicationInfoService implements IApplicationInfoService { */ @Override public ApplicationInfo save(ApplicationInfo info) { + if (info.getId() != null) { + requireUnchangedAppCode(applicationInfoRepository.findPersistedAppCodeById(info.getId()), + info.getAppCode()); + } ApplicationInfo saved = applicationInfoRepository.save(info); eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved)); return saved; @@ -199,6 +203,10 @@ public class ApplicationInfoService implements IApplicationInfoService { } ApplicationInfo entity = applicationInfoRepository.findById(id) .orElseThrow(() -> new RuntimeException("应用不存在: " + id)); + // 空值仍按既有部分提交语义处理;非空已登记标识不可真正改名。 + if (StringUtils.isNotBlank(request.getAppCode())) { + requireUnchangedAppCode(entity.getAppCode(), request.getAppCode()); + } if (StringUtils.isNotBlank(request.getAppCode()) && applicationInfoRepository.existsByAppCodeAndIdNot(request.getAppCode(), id)) { throw new DException("技术标识已存在"); @@ -225,6 +233,12 @@ public class ApplicationInfoService implements IApplicationInfoService { eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved)); } + private static void requireUnchangedAppCode(String persisted, String proposed) { + if (StringUtils.isNotBlank(persisted) && !Objects.equals(persisted, proposed)) { + throw new DException("已登记的应用技术标识 appCode 不可修改"); + } + } + @Override @Transactional // AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理); @@ -433,4 +447,4 @@ public class ApplicationInfoService implements IApplicationInfoService { } return result; } -} \ No newline at end of file +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoAppCodeTest.java b/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoAppCodeTest.java new file mode 100644 index 0000000..629f40b --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoAppCodeTest.java @@ -0,0 +1,84 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.model.application.ApplicationInfoRequest; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.platform.utils.exception.DException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class ApplicationInfoAppCodeTest { + private ApplicationInfoService service; + private ApplicationInfoRepository repository; + private ApplicationInfo entity; + + @BeforeEach + void setUp() { + service = new ApplicationInfoService(); + repository = mock(ApplicationInfoRepository.class); + ReflectionTestUtils.setField(service, "applicationInfoRepository", repository); + ReflectionTestUtils.setField(service, "eventPublisher", mock(ApplicationEventPublisher.class)); + entity = new ApplicationInfo(); + entity.setId(1); entity.setAppCode("svc-hall"); + when(repository.findById(1)).thenReturn(Optional.of(entity)); + when(repository.save(any(ApplicationInfo.class))).thenAnswer(invocation -> invocation.getArgument(0)); + } + + private ApplicationInfoRequest request(String code) { + ApplicationInfoRequest r = new ApplicationInfoRequest(); + r.setName("display-name"); r.setAppCode(code); + return r; + } + + @Test + void editRejectsRenameBeforeMutatingEntity() { + for (String code : new String[]{"new-name", "SVC-HALL", "svc-hall "}) { + assertThrows(DException.class, () -> service.edit(1, request(code))); + } + assertEquals("svc-hall", entity.getAppCode()); + verify(repository, never()).save(any()); + } + + @Test + void sameCodeOrOmittedCodeCanEditOtherFields() { + for (String code : new String[]{"svc-hall", null, "", " "}) { + service.edit(1, request(code)); + assertEquals("svc-hall", entity.getAppCode()); + assertEquals("display-name", entity.getName()); + } + } + + @Test + void unregisteredCodeMayBeFilledButUniquenessStillApplies() { + entity.setAppCode(null); + when(repository.existsByAppCodeAndIdNot("taken", 1)).thenReturn(true); + assertThrows(DException.class, () -> service.edit(1, request("taken"))); + service.edit(1, request("svc-hall")); + assertEquals("svc-hall", entity.getAppCode()); + } + + @Test + void genericSaveCannotRenameOrClearPersistedCode() { + when(repository.findPersistedAppCodeById(1)).thenReturn("svc-hall"); + for (String code : new String[]{"new-name", null, ""}) { + entity.setAppCode(code); + assertThrows(DException.class, () -> service.save(entity)); + } + entity.setAppCode("svc-hall"); + assertSame(entity, service.save(entity)); + } + + @Test + void genericSaveAllowsInitialRegistration() { + when(repository.findPersistedAppCodeById(1)).thenReturn(null); + assertSame(entity, service.save(entity)); + entity.setId(null); + assertSame(entity, service.save(entity)); + } +}