From 11f0f90f7aeda7b345e745d28836f4659c3a4cf5 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 3 Nov 2026 22:29:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(spec58):=20=E6=A8=A1=E5=9D=97=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=A1=A8=E5=90=8E=E7=AB=AF=E4=B8=8E=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81=E6=94=B6=E6=95=9B=EF=BC=88#60?= =?UTF-8?q?=EF=BC=89=20-=20=E6=96=B0=E5=A2=9E=20gxmu/module=20=E5=8C=85?= =?UTF-8?q?=EF=BC=9AModuleRegistry=20=E5=AE=9E=E4=BD=93/Mapper/Service?= =?UTF-8?q?=EF=BC=8ClistEnabled=20=E6=8C=89=20sort=5Fnumber=20=E5=8D=87?= =?UTF-8?q?=E5=BA=8F=EF=BC=8CtitleOf=20=E6=9C=AA=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E5=9B=9E=E9=80=80=20module=20=E5=8E=9F=E4=B8=B2=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20GET=20/api/gxmu/module-registry/list?= =?UTF-8?q?=EF=BC=88=E9=9C=80=E7=99=BB=E5=BD=95=EF=BC=89=EF=BC=8C=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E5=90=AF=E7=94=A8=E6=A8=A1=E5=9D=97=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=20-=20ReviewFlowConfigServiceImpl=20/=20ReviewListSer?= =?UTF-8?q?viceImpl=20=E7=9A=84=E6=A8=A1=E5=9D=97=E5=90=8D=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E6=94=B9=E8=AF=BB=E6=B3=A8=E5=86=8C=E8=A1=A8=EF=BC=8C?= =?UTF-8?q?=E6=9F=A5=E4=B8=8D=E5=88=B0=E5=9B=9E=E9=80=80=E5=8E=9F=E4=B8=B2?= =?UTF-8?q?=20-=20DeclareGuardServiceImpl=20=E7=9A=84=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E9=9B=86=E5=90=88=E6=94=B9=E4=B8=BA=E6=B3=A8=E5=86=8C=E8=A1=A8?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=EF=BC=8C=E5=90=AB=20enabled=3D0=EF=BC=88?= =?UTF-8?q?=E5=81=9C=E7=94=A8=E6=A8=A1=E5=9D=97=E5=AD=98=E9=87=8F=E4=BB=8D?= =?UTF-8?q?=E5=8F=AF=E6=92=A4=E5=9B=9E/=E4=BD=9C=E5=BA=9F=EF=BC=89=20-=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E6=9C=9F=E4=B8=80=E8=87=B4=E6=80=A7=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=EF=BC=9Atable=5Fname=E2=89=A0module=20=E6=88=96?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E8=A1=A8=E7=BC=BA=E5=A4=B1=E5=8D=B3=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=A4=B1=E8=B4=A5=EF=BC=8Cgxmu.module-registry.strict?= =?UTF-8?q?=3Dfalse=20=E5=8F=AF=E5=85=B3=E9=97=AD=20-=20=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=2013=20=E4=BE=8B=EF=BC=9A=E6=B3=A8=E5=86=8C=E8=A1=A8=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E4=B8=8E=E6=8E=92=E5=BA=8F=E3=80=81titleOf=20?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E3=80=81=E5=90=AF=E5=8A=A8=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E3=80=81module=20=E5=90=8D=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ModuleRegistryIntegrityChecker.java | 96 +++++++++++++++ .../controller/ModuleRegistryController.java | 39 ++++++ .../gxmu/module/entity/ModuleRegistry.java | 69 +++++++++++ .../module/mapper/ModuleRegistryMapper.java | 31 +++++ .../module/service/ModuleRegistryService.java | 58 +++++++++ .../impl/ModuleRegistryServiceImpl.java | 105 ++++++++++++++++ .../service/impl/DeclareGuardServiceImpl.java | 18 +-- .../impl/ReviewFlowConfigServiceImpl.java | 33 +++-- .../service/impl/ReviewListServiceImpl.java | 33 +++-- src/main/resources/application.yml | 5 + .../ModuleRegistryIntegrityCheckerTest.java | 110 +++++++++++++++++ .../module/ModuleRegistryServiceImplTest.java | 113 ++++++++++++++++++ .../impl/ReviewFlowConfigServiceImplTest.java | 94 +++++++++++++++ 13 files changed, 758 insertions(+), 46 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/module/controller/ModuleRegistryController.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/module/service/ModuleRegistryService.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/module/service/impl/ModuleRegistryServiceImpl.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityCheckerTest.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImplTest.java diff --git a/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java b/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java new file mode 100644 index 0000000..f5c6b8a --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/module/ModuleRegistryIntegrityChecker.java @@ -0,0 +1,96 @@ +package com.gxwebsoft.gxmu.module; + +import com.gxwebsoft.gxmu.module.entity.ModuleRegistry; +import com.gxwebsoft.gxmu.module.mapper.ModuleRegistryMapper; +import com.gxwebsoft.gxmu.module.service.ModuleRegistryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 模块注册表启动期一致性校验(spec #58 用户故事 21)。 + * + *
两件事不满足就在启动期直接失败,而不是等运行到详情页才发现空白: + *
允许 gxmu.module-registry.strict=false 关闭校验,便于无库本地启动;
+ * 生产环境必须保持默认的 true。
+ *
+ * @author Codex
+ * @since 2026-11-10
+ */
+@Component
+public class ModuleRegistryIntegrityChecker implements ApplicationRunner {
+
+ private static final Logger log = LoggerFactory.getLogger(ModuleRegistryIntegrityChecker.class);
+
+ @Value("${gxmu.module-registry.strict:true}")
+ private boolean strict;
+
+ @Resource
+ private ModuleRegistryService moduleRegistryService;
+
+ @Resource
+ private ModuleRegistryMapper moduleRegistryMapper;
+
+ @Override
+ public void run(ApplicationArguments args) {
+ check();
+ }
+
+ /**
+ * 执行校验;不一致时抛 {@link IllegalStateException} 让启动失败。
+ */
+ public void check() {
+ if (!strict) {
+ log.warn("gxmu.module-registry.strict=false,跳过模块注册表一致性校验(仅限本地无库启动)");
+ return;
+ }
+ List 前端启动时一次性拉取启用模块清单并缓存,模块下拉、详情页、导出模板都从这里驱动。
+ *
+ * @author Codex
+ * @since 2026-11-10
+ */
+@Api(tags = "模块注册表")
+@RestController
+@RequestMapping("/api/gxmu/module-registry")
+public class ModuleRegistryController extends BaseController {
+
+ @Resource
+ private ModuleRegistryService moduleRegistryService;
+
+ @ApiOperation("启用模块清单")
+ @PreAuthorize("isAuthenticated()")
+ @GetMapping("/list")
+ public ApiResult module 同时是业务表名,因此 tableName 必须与它一致,
+ * 不一致会在启动期直接失败(见 ModuleRegistryIntegrityChecker)。
+ *
+ * @author Codex
+ * @since 2026-11-10
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@ApiModel(value = "ModuleRegistry对象", description = "模块注册表")
+@TableName("gxmu_module_registry")
+public class ModuleRegistry implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(value = "id", type = IdType.AUTO)
+ private Integer id;
+
+ @ApiModelProperty(value = "模块标识,同时是业务表名")
+ private String module;
+
+ @ApiModelProperty(value = "业务表名,必须与 module 一致")
+ private String tableName;
+
+ @ApiModelProperty(value = "模块标题")
+ private String title;
+
+ @ApiModelProperty(value = "详情页配置键(moduleDetailConfig.ts)")
+ private String detailKey;
+
+ @ApiModelProperty(value = "导出模板标识,空表示走默认导出")
+ private String exportTemplate;
+
+ @ApiModelProperty(value = "是否启用:0否 1是")
+ private Integer enabled;
+
+ @ApiModelProperty(value = "排序号,小的在前")
+ private Integer sortNumber;
+
+ @ApiModelProperty(value = "是否删除, 0否, 1是")
+ @TableLogic
+ private Integer deleted;
+
+ @ApiModelProperty(value = "租户id")
+ private Integer tenantId;
+
+ @ApiModelProperty(value = "创建时间")
+ private LocalDateTime createTime;
+
+ @ApiModelProperty(value = "修改时间")
+ private LocalDateTime updateTime;
+
+ private Integer userId;
+}
diff --git a/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java b/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java
new file mode 100644
index 0000000..1615f6a
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/gxmu/module/mapper/ModuleRegistryMapper.java
@@ -0,0 +1,31 @@
+package com.gxwebsoft.gxmu.module.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 模块注册表 Mapper
+ *
+ * @author Codex
+ * @since 2026-11-10
+ */
+public interface ModuleRegistryMapper extends BaseMapper 调用方不要再维护模块名常量或标题映射,一律从这里取;
+ * 注册表里查不到的历史 module 由调用方回退 module 原串,不抛错。
+ *
+ * @author Codex
+ * @since 2026-11-10
+ */
+public interface ModuleRegistryService extends IService 口径:停用只影响入口与列表,不影响存量记录的撤回/作废能力,
+ * 所以停用模块也必须返回 true。
+ */
+ boolean containsModule(String module);
+}
diff --git a/src/main/java/com/gxwebsoft/gxmu/module/service/impl/ModuleRegistryServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/module/service/impl/ModuleRegistryServiceImpl.java
new file mode 100644
index 0000000..c498f79
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/gxmu/module/service/impl/ModuleRegistryServiceImpl.java
@@ -0,0 +1,105 @@
+package com.gxwebsoft.gxmu.module.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
+import com.gxwebsoft.gxmu.module.mapper.ModuleRegistryMapper;
+import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 模块注册表 Service 实现
+ *
+ * @author Codex
+ * @since 2026-11-10
+ */
+@Service
+public class ModuleRegistryServiceImpl extends ServiceImpl 口径:停用只影响入口与列表,不影响存量记录的撤回/作废能力;
+ * 若只认启用模块,停用模块的历史数据会失去撤回、作废与状态同步。
+ */
@Override
public boolean supports(String module) {
- return module != null && MODULES.contains(module);
+ return module != null && moduleRegistryService.containsModule(module);
}
@Override
diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java
index 6b1b92c..4d6c733 100644
--- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowConfigServiceImpl.java
@@ -12,12 +12,11 @@ import com.gxwebsoft.common.system.entity.Organization;
import com.gxwebsoft.common.system.service.OrganizationService;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
+import com.gxwebsoft.gxmu.module.service.ModuleRegistryService;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
-import java.util.Collections;
import java.util.HashMap;
-import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -31,27 +30,13 @@ import java.util.stream.Collectors;
*/
@Service
public class ReviewFlowConfigServiceImpl extends ServiceImpl 对应用户故事 21:接入新模块时「表名与 module 名不一致」要启动期直接失败,
+ * 而不是运行到详情页才发现空白;同时保留 strict=false 供无库本地启动。
+ *
+ * @author Codex
+ * @since 2026-11-10
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class ModuleRegistryIntegrityCheckerTest {
+
+ @Mock
+ private ModuleRegistryService moduleRegistryService;
+
+ @Mock
+ private ModuleRegistryMapper moduleRegistryMapper;
+
+ @InjectMocks
+ private ModuleRegistryIntegrityChecker checker;
+
+ @BeforeEach
+ void strictOn() {
+ ReflectionTestUtils.setField(checker, "strict", true);
+ }
+
+ @Test
+ void failsWhenTableNameDiffersFromModule() {
+ when(moduleRegistryService.listAll()).thenReturn(Collections.singletonList(
+ module("gxmu_new_form", "gxmu_new_form_table", 1)));
+
+ IllegalStateException error = assertThrows(IllegalStateException.class, () -> checker.check());
+
+ assertTrue(error.getMessage().contains("gxmu_new_form"), error.getMessage());
+ assertTrue(error.getMessage().contains("gxmu_new_form_table"), error.getMessage());
+ verify(moduleRegistryMapper, never()).selectExistingTableNames(any());
+ }
+
+ @Test
+ void failsWhenRegisteredTableDoesNotExist() {
+ when(moduleRegistryService.listAll()).thenReturn(Arrays.asList(
+ module("gxmu_sjqn", "gxmu_sjqn", 1),
+ module("gxmu_ghost_form", "gxmu_ghost_form", 0)));
+ when(moduleRegistryMapper.selectExistingTableNames(any()))
+ .thenReturn(Collections.singletonList("gxmu_sjqn"));
+
+ IllegalStateException error = assertThrows(IllegalStateException.class, () -> checker.check());
+
+ assertTrue(error.getMessage().contains("gxmu_ghost_form"), error.getMessage());
+ }
+
+ @Test
+ void passesWhenRegistryMatchesSchema() {
+ when(moduleRegistryService.listAll()).thenReturn(Collections.singletonList(
+ module("gxmu_sjqn", "gxmu_sjqn", 1)));
+ when(moduleRegistryMapper.selectExistingTableNames(any()))
+ .thenReturn(Collections.singletonList("gxmu_sjqn"));
+
+ assertDoesNotThrow(() -> checker.check());
+ }
+
+ @Test
+ void strictFalseSkipsValidationForLocalStartupWithoutDatabase() {
+ ReflectionTestUtils.setField(checker, "strict", false);
+ when(moduleRegistryService.listAll()).thenReturn(Collections.singletonList(
+ module("gxmu_new_form", "gxmu_new_form_table", 1)));
+
+ assertDoesNotThrow(() -> checker.check());
+ verify(moduleRegistryService, never()).listAll();
+ }
+
+ private ModuleRegistry module(String name, String tableName, int enabled) {
+ ModuleRegistry item = new ModuleRegistry();
+ item.setModule(name);
+ item.setTableName(tableName);
+ item.setTitle(name);
+ item.setDetailKey(name);
+ item.setEnabled(enabled);
+ item.setSortNumber(1);
+ item.setDeleted(0);
+ return item;
+ }
+}
diff --git a/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java
new file mode 100644
index 0000000..fa70958
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/gxmu/module/ModuleRegistryServiceImplTest.java
@@ -0,0 +1,113 @@
+package com.gxwebsoft.gxmu.module;
+
+import com.gxwebsoft.gxmu.module.entity.ModuleRegistry;
+import com.gxwebsoft.gxmu.module.mapper.ModuleRegistryMapper;
+import com.gxwebsoft.gxmu.module.service.impl.ModuleRegistryServiceImpl;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * 模块注册表服务的单元测试(mock 掉 Mapper,不启 Spring、不连库)。
+ *
+ * 注册表是模块元数据的唯一来源,这里锁住三件对外可观察的事:
+ * 启用清单按 sort_number 升序、标题查询在未注册时回退 module 原串、
+ * 模块集合包含已停用模块(停用模块的历史数据仍要能撤回/作废)。
+ *
+ * @author Codex
+ * @since 2026-11-10
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class ModuleRegistryServiceImplTest {
+
+ @Mock
+ private ModuleRegistryMapper moduleRegistryMapper;
+
+ @InjectMocks
+ private ModuleRegistryServiceImpl moduleRegistryService;
+
+ @Test
+ void listEnabledReturnsOnlyEnabledModulesOrderedBySortNumber() {
+ when(moduleRegistryMapper.selectList(any())).thenReturn(Arrays.asList(
+ module("gxmu_qmgc_form", "青马工程", 1, 8),
+ module("gxmu_sjtbzbsj", "十佳团支部书记", 0, 2),
+ module("gxmu_sjqn", "十佳青年岗位能手", 1, 1)));
+
+ List 模块名改读注册表后仍要保住一条底线:注册表里查不到的老 module
+ * 不能让列表报错或空白,必须回退 module 原串。
+ *
+ * @author Codex
+ * @since 2026-11-10
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class ReviewFlowConfigServiceImplTest {
+
+ @Mock
+ private ReviewFlowConfigMapper reviewFlowConfigMapper;
+
+ @Mock
+ private ReviewFlowService reviewFlowService;
+
+ @Mock
+ private OrganizationService organizationService;
+
+ @Mock
+ private ModuleRegistryService moduleRegistryService;
+
+ @InjectMocks
+ private ReviewFlowConfigServiceImpl reviewFlowConfigService;
+
+ @Test
+ void fillsModuleNameFromRegistry() {
+ ReviewFlowConfig config = config("gxmu_sjqn");
+ when(reviewFlowConfigMapper.selectListRel(any())).thenReturn(Collections.singletonList(config));
+ when(moduleRegistryService.titleMap())
+ .thenReturn(Collections.singletonMap("gxmu_sjqn", "十佳青年岗位能手"));
+
+ List> list() {
+ return success(moduleRegistryService.listEnabled());
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java b/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java
new file mode 100644
index 0000000..b2b9836
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/gxmu/module/entity/ModuleRegistry.java
@@ -0,0 +1,69 @@
+package com.gxwebsoft.gxmu.module.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 模块注册表:业务模块元数据的唯一来源,见 docs/adr/0006-approval-chain-convergence.md。
+ *
+ *