部门线P0最小批:sys_group加status启停(单列UPDATE+快照驱逐+oapi透出+160幂等SQL)
build / build-push (push) Successful in 57s
build / build-push (push) Successful in 57s
This commit is contained in:
@@ -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);
|
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")
|
@Operation(operationId = "sysGroupList")
|
||||||
@GetMapping("/List")
|
@GetMapping("/List")
|
||||||
public List<SysGroupVo> list(SysGroupQuery query) {
|
public List<SysGroupVo> list(SysGroupQuery query) {
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ public class SysGroup extends BaseEntity {
|
|||||||
|
|
||||||
private Integer sort;
|
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)
|
@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 String name;
|
||||||
private Integer type;
|
private Integer type;
|
||||||
private Integer sort;
|
private Integer sort;
|
||||||
|
/** 启停状态(2026-10-20 部门线 P0):0=启用 / 1=停用;BeanUtils 同名拷贝,null 视为 0 由前端兜底 */
|
||||||
|
private Integer status;
|
||||||
/** 子组(递归);默认空列表,避免 JSON 序列化出现 null。 */
|
/** 子组(递归);默认空列表,避免 JSON 序列化出现 null。 */
|
||||||
private List<SysGroupVo> children = new ArrayList<>();
|
private List<SysGroupVo> children = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package com.yunhai.userbase.repository;
|
|||||||
import com.yunhai.userbase.entity.SysGroup;
|
import com.yunhai.userbase.entity.SysGroup;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
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.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -15,6 +17,14 @@ import java.util.Optional;
|
|||||||
*/
|
*/
|
||||||
public interface SysGroupRepository extends JpaRepository<SysGroup, Integer>, JpaSpecificationExecutor<SysGroup> {
|
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")
|
@Query("SELECT MAX(g.sort) FROM SysGroup g")
|
||||||
Integer findMaxSort();
|
Integer findMaxSort();
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ public interface ISysGroupService {
|
|||||||
|
|
||||||
void delete(Integer id);
|
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>
|
* 开放接口专用:在学校(组)范围内删除组。<br>
|
||||||
* 与 {@link #delete(Integer)} 删除逻辑一致,但额外强制边界:<br>
|
* 与 {@link #delete(Integer)} 删除逻辑一致,但额外强制边界:<br>
|
||||||
|
|||||||
@@ -228,6 +228,27 @@ public class SysGroupService implements ISysGroupService {
|
|||||||
deleteInternal(id);
|
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
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Integer id, Integer schoolId) {
|
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) {
|
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.setId(entity.getId());
|
||||||
bg.setName(entity.getName());
|
bg.setName(entity.getName());
|
||||||
bg.setParentId(entity.getParentId());
|
bg.setParentId(entity.getParentId());
|
||||||
bg.setType(entity.getType() == null ? null : entity.getType().getKey());
|
bg.setType(entity.getType() == null ? null : entity.getType().getKey());
|
||||||
bg.setSort(entity.getSort());
|
bg.setSort(entity.getSort());
|
||||||
|
bg.setStatus(entity.getStatus() == null ? 0 : entity.getStatus());
|
||||||
return bg;
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user