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} 之上透出启停状态。
+ *
+ * - {@code status}:0=启用 / 1=停用(sys_group.status 原值,语义=停用只影响「选」不影响「看」与「办」);
+ * - 反序列化兼容:字段只增不改,消费方(svc-hall / flow-center)经 starter 注入的 Spring ObjectMapper
+ * 解析,未知字段默认忽略——不升级消费方 SDK 也能照常工作;需要读 status 时按同名 JSON 键取即可;
+ * - 不动 BasePlatform 的 BoaGroup(平台模型加字段要发 SDK 版本,超出最小批)。
+ *
+ */
+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");
+ }
+}