部门线P0最小批:sys_group加status启停(单列UPDATE+快照驱逐+oapi透出+160幂等SQL)
build / build-push (push) Successful in 57s

This commit is contained in:
chenxing
2026-10-20 22:37:22 +08:00
parent 2b754c08ca
commit 035df02f63
9 changed files with 288 additions and 2 deletions
+29
View File
@@ -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
@@ -65,6 +65,31 @@ public class SysGroupController {
sysGroupService.delete(id);
}
/**
* 停用组(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status=1,不碰其他任何表与列。<br>
* 不判组类型(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<SysGroupVo> list(SysGroupQuery query) {
@@ -33,6 +33,14 @@ public class SysGroup extends BaseEntity {
private Integer sort;
/**
* 启停状态(2026-10-20 部门线 P0 最小批):0=启用 / 1=停用,对齐 flw_dept 枚举。<br>
* 停用只影响「选」(新建选择器过滤),不影响「看」(展示/指南页)与「办」(在途流程解析);
* 当前无消费方,语义在未来部门场景(P1)生效。翻转只允许走 {@code updateStatus} 单列 UPDATE。
*/
@Column(name = "status")
private Integer status = 0;
// 一对多反向关联中间表
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@@ -0,0 +1,25 @@
package com.yunhai.userbase.model.sysgroup;
import com.yunhai.platform.utils.model.BoaGroup;
/**
* oapi 组树出参扩展(2026-10-20 部门线 P0 最小批):在平台模型 {@link BoaGroup} 之上透出启停状态。<br>
* <ul>
* <li>{@code status}:0=启用 / 1=停用(sys_group.status 原值,语义=停用只影响「选」不影响「看」与「办」);</li>
* <li>反序列化兼容:字段只增不改,消费方(svc-hall / flow-center)经 starter 注入的 Spring ObjectMapper
* 解析,未知字段默认忽略——不升级消费方 SDK 也能照常工作;需要读 status 时按同名 JSON 键取即可;</li>
* <li>不动 BasePlatform 的 BoaGroup(平台模型加字段要发 SDK 版本,超出最小批)。</li>
* </ul>
*/
public class BoaGroupStatusVo extends BoaGroup {
private Integer status;
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
}
@@ -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<SysGroupVo> children = new ArrayList<>();
}
@@ -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<SysGroup, Integer>, JpaSpecificationExecutor<SysGroup> {
/**
* 启停状态单列 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();
@@ -28,6 +28,15 @@ public interface ISysGroupService {
void delete(Integer id);
/**
* 启停状态翻转(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status,不碰其他任何表与列。<br>
* 不判组类型(status 当前无消费方,语义只在未来部门场景生效);删除行为一字不动(沿用 deleteInternal 现行规则)。
*
* @param id 组ID
* @param disabled true=停用(status=1)/ false=启用(status=0)
*/
void changeStatus(Integer id, boolean disabled);
/**
* 开放接口专用:在学校(组)范围内删除组。<br>
* 与 {@link #delete(Integer)} 删除逻辑一致,但额外强制边界:<br>
@@ -228,6 +228,27 @@ public class SysGroupService implements ISysGroupService {
deleteInternal(id);
}
/**
* 启停状态翻转(2026-10-20 部门线 P0 最小批):严格单列 UPDATE。<br>
* ① 只走 {@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(未展开)。<br>
* 出参载体为 {@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;
}
@@ -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 最小批)单元测试。<br>
* 覆盖:① 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");
}
}