diff --git a/sql/sys_group_add_status.sql b/sql/sys_group_add_status.sql new file mode 100644 index 0000000..20104e0 --- /dev/null +++ b/sql/sys_group_add_status.sql @@ -0,0 +1,29 @@ +-- sys_group 加启停状态列(2026-10-20 部门线 P0 最小批) +-- 目标环境:160(MySQL 8,容器网 mysql8:3306,库见 UserBase 生产连接串) +-- 幂等:INFORMATION_SCHEMA 判列存在即跳过,可重复执行;160 一律以本脚本为准(不依赖 ddl-auto) +-- 回滚:ALTER TABLE sys_group DROP COLUMN status;(存量数据全 0=启用,回滚无行为影响) + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS add_sys_group_status $$ +CREATE PROCEDURE add_sys_group_status() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_group' + AND COLUMN_NAME = 'status' + ) THEN + ALTER TABLE sys_group + ADD COLUMN status TINYINT NOT NULL DEFAULT 0 + COMMENT '0 启用 / 1 停用(停用后新建选不到,已有引用与在途流程不受影响)'; + END IF; +END $$ + +CALL add_sys_group_status() $$ +DROP PROCEDURE IF EXISTS add_sys_group_status $$ + +DELIMITER ; + +-- 执行后核对(计数入台账,业务数据不入): +-- SELECT status, COUNT(*) FROM sys_group GROUP BY status; -- 预期全部 status=0 diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java b/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java index b0f03f6..bcbee13 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java @@ -65,6 +65,31 @@ public class SysGroupController { sysGroupService.delete(id); } + /** + * 停用组(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status=1,不碰其他任何表与列。
+ * 不判组类型(status 当前无消费方);删除行为不动。范式对齐 SysUserController.Disable。 + */ + @OperationLog("停用组") + @Operation(operationId = "sysGroupDisable") + @PostMapping("Disable/{id}") + public void disable(@PathVariable Integer id) { + accessScopeService.requireGroupInScope(id); + accessScopeService.requireGroupModifiable(id); + sysGroupService.changeStatus(id, true); + } + + /** + * 启用组(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status=0。 + */ + @OperationLog("启用组") + @Operation(operationId = "sysGroupEnable") + @PostMapping("Enable/{id}") + public void enable(@PathVariable Integer id) { + accessScopeService.requireGroupInScope(id); + accessScopeService.requireGroupModifiable(id); + sysGroupService.changeStatus(id, false); + } + @Operation(operationId = "sysGroupList") @GetMapping("/List") public List list(SysGroupQuery query) { diff --git a/src/main/java/com/yunhai/userbase/entity/SysGroup.java b/src/main/java/com/yunhai/userbase/entity/SysGroup.java index fcfb5b7..b1d3ba7 100644 --- a/src/main/java/com/yunhai/userbase/entity/SysGroup.java +++ b/src/main/java/com/yunhai/userbase/entity/SysGroup.java @@ -33,6 +33,14 @@ public class SysGroup extends BaseEntity { private Integer sort; + /** + * 启停状态(2026-10-20 部门线 P0 最小批):0=启用 / 1=停用,对齐 flw_dept 枚举。
+ * 停用只影响「选」(新建选择器过滤),不影响「看」(展示/指南页)与「办」(在途流程解析); + * 当前无消费方,语义在未来部门场景(P1)生效。翻转只允许走 {@code updateStatus} 单列 UPDATE。 + */ + @Column(name = "status") + private Integer status = 0; + // 一对多反向关联中间表 @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY) diff --git a/src/main/java/com/yunhai/userbase/model/sysgroup/BoaGroupStatusVo.java b/src/main/java/com/yunhai/userbase/model/sysgroup/BoaGroupStatusVo.java new file mode 100644 index 0000000..c8ddc1e --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysgroup/BoaGroupStatusVo.java @@ -0,0 +1,25 @@ +package com.yunhai.userbase.model.sysgroup; + +import com.yunhai.platform.utils.model.BoaGroup; + +/** + * oapi 组树出参扩展(2026-10-20 部门线 P0 最小批):在平台模型 {@link BoaGroup} 之上透出启停状态。
+ * + */ +public class BoaGroupStatusVo extends BoaGroup { + + private Integer status; + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } +} diff --git a/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupVo.java b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupVo.java index 8aadbe5..071c55f 100644 --- a/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupVo.java +++ b/src/main/java/com/yunhai/userbase/model/sysgroup/SysGroupVo.java @@ -16,6 +16,8 @@ public class SysGroupVo { private String name; private Integer type; private Integer sort; + /** 启停状态(2026-10-20 部门线 P0):0=启用 / 1=停用;BeanUtils 同名拷贝,null 视为 0 由前端兜底 */ + private Integer status; /** 子组(递归);默认空列表,避免 JSON 序列化出现 null。 */ private List children = new ArrayList<>(); } diff --git a/src/main/java/com/yunhai/userbase/repository/SysGroupRepository.java b/src/main/java/com/yunhai/userbase/repository/SysGroupRepository.java index a9c2f02..295bb34 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysGroupRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysGroupRepository.java @@ -3,7 +3,9 @@ package com.yunhai.userbase.repository; import com.yunhai.userbase.entity.SysGroup; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -15,6 +17,14 @@ import java.util.Optional; */ public interface SysGroupRepository extends JpaRepository, JpaSpecificationExecutor { + /** + * 启停状态单列 UPDATE(2026-10-20 部门线 P0):@Modifying JPQL 绕过实体监听器与审计列, + * 严格只动 sys_group.status 一列(验收断言:翻转前后其余列逐字不变)。 + */ + @Modifying + @Query("UPDATE SysGroup g SET g.status = :status WHERE g.id = :id") + int updateStatus(@Param("id") Integer id, @Param("status") Integer status); + @Query("SELECT MAX(g.sort) FROM SysGroup g") Integer findMaxSort(); diff --git a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java index 52d1032..b16b241 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java @@ -28,6 +28,15 @@ public interface ISysGroupService { void delete(Integer id); + /** + * 启停状态翻转(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status,不碰其他任何表与列。
+ * 不判组类型(status 当前无消费方,语义只在未来部门场景生效);删除行为一字不动(沿用 deleteInternal 现行规则)。 + * + * @param id 组ID + * @param disabled true=停用(status=1)/ false=启用(status=0) + */ + void changeStatus(Integer id, boolean disabled); + /** * 开放接口专用:在学校(组)范围内删除组。
* 与 {@link #delete(Integer)} 删除逻辑一致,但额外强制边界:
diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index 608a284..3404f05 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -228,6 +228,27 @@ public class SysGroupService implements ISysGroupService { deleteInternal(id); } + /** + * 启停状态翻转(2026-10-20 部门线 P0 最小批):严格单列 UPDATE。
+ * ① 只走 {@link SysGroupRepository#updateStatus}(@Modifying JPQL,不触发实体监听器/审计列); + * ② 不判组类型、不动删除行为、不碰任何其他表; + * ③ 组域写口义务:发 {@link GroupChangedEvent} 驱逐 groupSnapshot 缓存—— + * oapi 组树(tree/byId/list)与 GetTree 都吃快照,不发则透出的 status 最多滞后 30min; + * 不发 DataSyncEvent(推送通道 push_enabled 默认关,status 也无推送消费方)。 + */ + @Override + @Transactional + public void changeStatus(Integer id, boolean disabled) { + if (id == null || sysGroupRepository.findById(id).orElse(null) == null) { + throw new DException("系统组不存在"); + } + int updated = sysGroupRepository.updateStatus(id, disabled ? 1 : 0); + if (updated == 0) { + throw new DException("系统组不存在"); + } + eventPublisher.publishEvent(new GroupChangedEvent(this, "changeStatus")); + } + @Override @Transactional public void delete(Integer id, Integer schoolId) { @@ -850,14 +871,20 @@ public class SysGroupService implements ISysGroupService { // ===================== 私有工具 ===================== - /** SysGroup -> BoaGroup(id/name/parentId/type/sort);children 默认 null(未展开)。 */ + /** + * SysGroup -> BoaGroup(id/name/parentId/type/sort + status);children 默认 null(未展开)。
+ * 出参载体为 {@link com.yunhai.userbase.model.sysgroup.BoaGroupStatusVo}(P0 起 oapi 组树透出启停状态; + * 返回类型保持 BoaGroup,消费方零改动,JSON 面多一个 status 键)。null 视为 0(存量行/未迁移列)。 + */ private BoaGroup convertToBoaGroup(SysGroup entity) { - BoaGroup bg = new BoaGroup(); + com.yunhai.userbase.model.sysgroup.BoaGroupStatusVo bg = + new com.yunhai.userbase.model.sysgroup.BoaGroupStatusVo(); bg.setId(entity.getId()); bg.setName(entity.getName()); bg.setParentId(entity.getParentId()); bg.setType(entity.getType() == null ? null : entity.getType().getKey()); bg.setSort(entity.getSort()); + bg.setStatus(entity.getStatus() == null ? 0 : entity.getStatus()); return bg; } diff --git a/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceStatusTest.java b/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceStatusTest.java new file mode 100644 index 0000000..ecb1241 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceStatusTest.java @@ -0,0 +1,151 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.enums.GroupTypeEnum; +import com.yunhai.userbase.event.GroupChangedEvent; +import com.yunhai.userbase.model.GroupSnapshot; +import com.yunhai.userbase.model.sysgroup.BoaGroupStatusVo; +import com.yunhai.userbase.repository.SysGroupRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Arrays; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@code SysGroupService.changeStatus}(2026-10-20 部门线 P0 最小批)单元测试。
+ * 覆盖:① status 翻转只走单列 UPDATE(不 save 不 delete,不碰其他表); + * ② 组不存在抛 DException 且零写入;③ 写后必须发 GroupChangedEvent 驱逐 groupSnapshot 缓存 + * (否则 oapi 组树透出的 status 滞后 30min);④ oapi 出参透出断言(BoaGroupStatusVo.status, + * null 存量行按 0 下发)。 + */ +class SysGroupServiceStatusTest { + + @InjectMocks + private SysGroupService sysGroupService; + + @Mock + private SysGroupRepository sysGroupRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Mock + private GroupSnapshotService groupSnapshotService; + + @BeforeEach + void setUp() { + MockitoAnnotations.initMocks(this); + } + + private SysGroup group(Integer id, Integer parentId, Integer status) { + SysGroup g = new SysGroup(); + g.setId(id); + g.setParentId(parentId); + g.setType(GroupTypeEnum.NORMAL_GROUP); + g.setStatus(status); + return g; + } + + // ===================== 启停端点:单列 UPDATE ===================== + + @Test + void disableFlipsStatusViaSingleColumnUpdateOnly() { + when(sysGroupRepository.findById(5)).thenReturn(Optional.of(group(5, 2, 0))); + when(sysGroupRepository.updateStatus(5, 1)).thenReturn(1); + + sysGroupService.changeStatus(5, true); + + verify(sysGroupRepository).updateStatus(5, 1); + // 单列 UPDATE 断言:不走实体 save(会触发审计列/监听器),不碰删除 + verify(sysGroupRepository, never()).save(any(SysGroup.class)); + verify(sysGroupRepository, never()).deleteById(anyInt()); + verify(sysGroupRepository, never()).delete(any(SysGroup.class)); + } + + @Test + void enableWritesZero() { + when(sysGroupRepository.findById(5)).thenReturn(Optional.of(group(5, 2, 1))); + when(sysGroupRepository.updateStatus(5, 0)).thenReturn(1); + + sysGroupService.changeStatus(5, false); + + verify(sysGroupRepository).updateStatus(5, 0); + verify(sysGroupRepository, never()).save(any(SysGroup.class)); + } + + @Test + void missingGroupRejectedWithoutAnyWrite() { + when(sysGroupRepository.findById(99)).thenReturn(Optional.empty()); + + assertThrows(DException.class, () -> sysGroupService.changeStatus(99, true)); + + verify(sysGroupRepository, never()).updateStatus(anyInt(), anyInt()); + verify(sysGroupRepository, never()).save(any(SysGroup.class)); + } + + @Test + void changeStatusEvictsGroupSnapshotCache() { + when(sysGroupRepository.findById(5)).thenReturn(Optional.of(group(5, 2, 0))); + when(sysGroupRepository.updateStatus(5, 1)).thenReturn(1); + + sysGroupService.changeStatus(5, true); + + // 不发 GroupChangedEvent = 快照最多 30min 不刷新,oapi 透出的 status 是旧值 + verify(eventPublisher).publishEvent(any(GroupChangedEvent.class)); + } + + // ===================== oapi 出参透出(BoaGroupStatusVo) ===================== + + @Test + void oapiGroupByIdCarriesStatus() { + SysGroup dept = group(7, 5, 1); + when(groupSnapshotService.snapshot()) + .thenReturn(new GroupSnapshot(Arrays.asList(group(1, 0, 0), group(5, 1, 0), dept))); + + BoaGroup byId = sysGroupService.getGroupById(7, null); + assertTrue(byId instanceof BoaGroupStatusVo, "oapi 组出参应为 BoaGroupStatusVo(透出 status)"); + assertEquals(1, ((BoaGroupStatusVo) byId).getStatus(), "停用组 status=1 必须透出"); + + BoaGroup enabled = sysGroupService.getGroupById(5, null); + assertEquals(0, ((BoaGroupStatusVo) enabled).getStatus(), "启用组 status=0 透出"); + } + + @Test + void legacyNullStatusServedAsZero() { + // 存量行(加列前)/未迁移列的实体 status=null → 下发按 0(启用),不透出 null + SysGroup legacy = group(9, 5, null); + when(groupSnapshotService.snapshot()) + .thenReturn(new GroupSnapshot(Arrays.asList(group(1, 0, null), legacy))); + + BoaGroup byId = sysGroupService.getGroupById(9, null); + assertEquals(0, ((BoaGroupStatusVo) byId).getStatus(), "null 存量行按 0(启用)下发"); + } + + @Test + void oapiTreeRecursivelyCarriesStatus() { + SysGroup dept = group(7, 5, 1); + when(groupSnapshotService.snapshot()) + .thenReturn(new GroupSnapshot(Arrays.asList(group(1, 0, 0), group(5, 1, 0), dept))); + + BoaGroup tree = sysGroupService.getGroups(); + BoaGroupStatusVo deptNode = (BoaGroupStatusVo) tree.getChildren().get(0).getChildren().get(0); + assertEquals(7, deptNode.getId()); + assertEquals(1, deptNode.getStatus(), "整棵树(递归子节点)都要透出 status"); + } +}