feat(app): application_info app_code 写入口守卫(已登记拒改名/拒清空,初次登记唯一性保持)
build / build-push (push) Successful in 1m26s

This commit is contained in:
chenxing
2026-10-22 17:14:27 +08:00
parent b1b769a1b8
commit 72fe0f218a
3 changed files with 105 additions and 1 deletions
@@ -17,6 +17,12 @@ import java.util.List;
* @description
*/
public interface ApplicationInfoRepository extends JpaRepository<ApplicationInfo, Integer>, JpaSpecificationExecutor<ApplicationInfo> {
/** 读取持久化标识快照;禁止查询前自动 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 " +
@@ -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;
}
}
}
@@ -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));
}
}