Compare commits

...
2 Commits
Author SHA1 Message Date
chenxing d472902957 推送订阅添加批量删除功能
ci-userbase-service / build-and-push (push) Successful in 1m56s
2026-08-19 20:38:12 +08:00
chenxing f3656057d4 推送订阅添加批量删除功能 2026-08-19 20:38:00 +08:00
12 changed files with 276 additions and 8 deletions
@@ -0,0 +1,27 @@
package com.yunhai.userbase.common.context;
/**
* 开放接口(oapi)调用方来源上下文:在 oapi 请求链路中保存"触发本次数据变更的订阅方 appKey",
* 供业务层发布 {@code DataSyncEvent} 时写入事件来源,推送分发器据此回推时排除来源应用自身
* (避免应用 A 自己调 oapi 写接口后又收到自己的回声)。<br><br>
* <b>生命周期:</b>由 {@code OapiCallerAppInterceptor} 在 {@code preHandle} 写入、
* {@code afterCompletion} 强制清除(防线程池复用泄漏)。管理后台 /api/* 写接口不走 oapi 拦截器,
* 此处为 null,表示"来源非订阅方",推送不对其做来源排除(全量投递)。<br><br>
* 设计与 {@link UserContext} 同套路(ThreadLocal + remove)。
*/
public class CallerAppContext {
private static final ThreadLocal<Integer> CALLER_APP_KEY = new ThreadLocal<>();
public static void setAppKey(Integer appKey) {
CALLER_APP_KEY.set(appKey);
}
public static Integer getAppKey() {
return CALLER_APP_KEY.get();
}
public static void clear() {
CALLER_APP_KEY.remove();
}
}
@@ -12,6 +12,8 @@ public class DWebMvcConfig implements WebMvcConfigurer
{
@Autowired
private BoaUserContextInterceptor boaUserContextInterceptor;
@Autowired
private OapiCallerAppInterceptor oapiCallerAppInterceptor;
@Override
public void addCorsMappings(CorsRegistry registry)
@@ -25,6 +27,9 @@ public class DWebMvcConfig implements WebMvcConfigurer
@Override
public void addInterceptors(InterceptorRegistry registry) {
// oapi 调用方来源注入:仅命中 /oapi/**,供推送来源排除使用
registry.addInterceptor(oapiCallerAppInterceptor)
.addPathPatterns("/oapi/**");
registry.addInterceptor(boaUserContextInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**");
@@ -0,0 +1,49 @@
package com.yunhai.userbase.config;
import com.yunhai.platform.appauth.AppAuthFilter;
import com.yunhai.userbase.common.context.CallerAppContext;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* oapi 调用方来源注入拦截器:在 oapi 请求处理前,从 yh-app-auth 验签过滤器写入的
* {@link AppAuthFilter#APP_AUTH_ATTR}(调用方 appKey)解析出整型 appKey,
* 存入 {@link CallerAppContext};请求结束(无论成功/异常)在 {@code afterCompletion} 强制清除,
* 防止线程池复用导致 appKey 串号、进而误把其他订阅方排除。<br><br>
* 配合 {@code PushDispatcher} 的来源排除逻辑,实现"应用自己触发的变更不回推给自己"。
* 管理后台 /api/* 不走此拦截器 → CallerAppContext 为 null → 推送对该来源不做排除(全量投递)。
*/
@Component
public class OapiCallerAppInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
if (attr == null) {
return true;
}
String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr);
if (StringUtils.isBlank(appKeyStr)) {
return true;
}
try {
CallerAppContext.setAppKey(Integer.valueOf(appKeyStr.trim()));
} catch (NumberFormatException ignored) {
// 非法 appKey 格式:不设置来源,等同"非订阅方来源",推送全量投递
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
CallerAppContext.clear();
}
}
@@ -87,11 +87,12 @@ public class GroupController {
}
/**
* 删除组:组下存在子组或用户时拒绝删除(根节点不可删)。无返回值。
* 删除组:组下存在子组或用户时拒绝删除(根节点不可删)。<br>
* 新增 {@code schoolId} 边界:服务端强制 id 属于该校子树且不等于 schoolId,避免删学校根组/跨校删组。
*/
@PostMapping("delete/{id}")
public void delete(@PathVariable Integer id) {
sysGroupService.delete(id);
@PostMapping("delete/{id}/{schoolId}")
public void delete(@PathVariable Integer id, @PathVariable Integer schoolId) {
sysGroupService.delete(id, schoolId);
}
/**
@@ -8,6 +8,7 @@ import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
import com.yunhai.userbase.model.sysuser.SysUserMoveRequest;
import com.yunhai.userbase.model.sysuser.SysUserRequest;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
@@ -168,11 +169,20 @@ public class UserController {
* 删除用户(按组解绑,语义同管理端「从组中删除用户」):仅解除该用户与指定组的关联;<br>
* 仅当用户已无任何组关联时,才级联删除 sys_user 及其角色绑定。<br>
* 这样多校用户只会被退出指定学校,而不会被整体误删。<br>
* 若用户仅归属这一个组,调用后该用户将被真正删除(组关联清空即触发级联删)。
* 新增 {@code schoolId} 边界:服务端强制 groupId 属于该校子树且不等于 schoolId,避免跨校/整校移除。
*/
@PostMapping("delete/{id}/{groupId}")
public void delete(@PathVariable Integer id, @PathVariable Integer groupId) {
sysUserService.deleteFromGroup(id, groupId);
@PostMapping("delete/{id}/{groupId}/{schoolId}")
public void delete(@PathVariable Integer id, @PathVariable Integer groupId, @PathVariable Integer schoolId) {
sysUserService.deleteFromGroup(id, groupId, schoolId);
}
/**
* 批量删除用户-组关系:请求体为 {@code SysUserGroupDeleteItem} 数组,每条携带自身 (userId, groupId, schoolId);<br>
* 服务端逐条校验学校边界后解绑,语义与单条 delete 一致。
*/
@PostMapping("deleteUsers")
public void deleteUsers(@RequestBody List<SysUserGroupDeleteItem> items) {
sysUserService.deleteUsers(items);
}
/**
@@ -24,4 +24,7 @@ public class DataSyncEvent {
private List<Integer> userIds;
/** 组维度的关联事件(如删除用户=解除组关系)携带的组 id */
private Integer groupId;
/** 触发本次变更的 oapi 调用方 appKey;非 oapi 来源(如管理后台)为 null。
* 推送分发器据此回推时排除来源应用自身,避免收到自己的回声。 */
private Integer sourceAppKey;
}
@@ -0,0 +1,40 @@
package com.yunhai.userbase.model.sysuser;
/**
* 批量删除用户-组关系单项(与 BasePlatform 开放接口模型 {@code SysUserGroupDeleteItem} 字段对齐)。<br>
* 每条记录携带自身所属学校范围 {@code schoolId},供 {@code SysUserService.deleteUsers} 逐条校验:<br>
* groupId 必须属于 schoolId 子树且不能等于 schoolId(禁止跨校解绑 / 整校移除)。
*/
public class SysUserGroupDeleteItem {
/** 待解绑用户 ID */
private Integer userId;
/** 待解绑的组 ID(必须属于 schoolId 子树,且不能等于 schoolId) */
private Integer groupId;
/** 学校组 ID:限定该条解绑的学校范围 */
private Integer schoolId;
public Integer getUserId() {
return userId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
public Integer getGroupId() {
return groupId;
}
public void setGroupId(Integer groupId) {
this.groupId = groupId;
}
public Integer getSchoolId() {
return schoolId;
}
public void setSchoolId(Integer schoolId) {
this.schoolId = schoolId;
}
}
@@ -23,6 +23,18 @@ public interface ISysGroupService {
void delete(Integer id);
/**
* 开放接口专用:在学校(组)范围内删除组。<br>
* 与 {@link #delete(Integer)} 删除逻辑一致,但额外强制边界:<br>
* ① id 不能等于 schoolId(禁止删学校根组);<br>
* ② id 必须属于 schoolId 子树(禁止跨校删组)。<br>
* 任意一条不满足即抛异常。
*
* @param id 待删除组ID
* @param schoolId 学校组ID(限定删除范围)
*/
void delete(Integer id, Integer schoolId);
List<SysGroupVo> list(SysGroupQuery query);
/** 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。 */
@@ -1,6 +1,7 @@
package com.yunhai.userbase.service;
import com.yunhai.userbase.model.sysuser.BatchImportResult;
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
import com.yunhai.userbase.model.sysuser.SysUserQuery;
import com.yunhai.userbase.model.sysuser.SysUserRequest;
import com.yunhai.userbase.model.sysuser.SysUserVo;
@@ -46,6 +47,26 @@ public interface ISysUserService {
*/
void batchDeleteFromGroup(List<Integer> userIds, Integer groupId);
/**
* 开放接口专用:在学校(组)范围内按组解绑用户。<br>
* 与 {@link #deleteFromGroup(Integer, Integer)} 语义一致,但额外强制边界:<br>
* ① groupId 不能等于 schoolId(禁止对学校根组解绑,避免整校移除);<br>
* ② groupId 必须属于 schoolId 子树(禁止跨校解绑)。<br>
* 任意一条不满足即抛异常。
*
* @param userId 用户ID
* @param groupId 当前组ID
* @param schoolId 学校组ID(限定解绑范围)
*/
void deleteFromGroup(Integer userId, Integer groupId, Integer schoolId);
/**
* 开放接口专用:批量删除用户-组关系;每条携带自身 (userId, groupId, schoolId),逐条做上述学校边界校验后解绑。
*
* @param items 待删除项列表({@link SysUserGroupDeleteItem})
*/
void deleteUsers(List<SysUserGroupDeleteItem> items);
/**
* 启用/禁用用户。<br>
*
@@ -6,6 +6,7 @@ import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.IEnum;
import com.yunhai.userbase.event.DataSyncEvent;
import com.yunhai.userbase.common.context.CallerAppContext;
import com.yunhai.userbase.model.push.PushInterfaceType;
import org.springframework.context.ApplicationEventPublisher;
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
@@ -199,6 +200,18 @@ public class SysGroupService implements ISysGroupService {
@Override
@Transactional
public void delete(Integer id) {
deleteInternal(id);
}
@Override
@Transactional
public void delete(Integer id, Integer schoolId) {
// 开放接口边界:id 必须属于 schoolId 子树且不能等于 schoolId(禁止删学校根组/跨校删组)
assertWithinSchool(id, schoolId);
deleteInternal(id);
}
private void deleteInternal(Integer id) {
SysGroup entity = sysGroupRepository.findById(id)
.orElseThrow(() -> new DException("系统组不存在"));
// 根节点不可删除
@@ -218,6 +231,31 @@ public class SysGroupService implements ISysGroupService {
publishGroupEvent(PushInterfaceType.DELETE_GROUP, id);
}
/**
* 学校边界校验:确保待删除的组属于指定学校范围,且不是学校根组本身。
*
* @param groupId 待删除组ID
* @param schoolId 学校组ID(限定范围)
*/
private void assertWithinSchool(Integer groupId, Integer schoolId) {
if (schoolId == null) {
throw new DException("学校ID不能为空");
}
if (groupId == null) {
throw new DException("组ID不能为空");
}
if (!sysGroupRepository.existsById(schoolId)) {
throw new DException("学校分组不存在");
}
if (groupId.equals(schoolId)) {
throw new DException("禁止删除学校根组,请指定具体子组");
}
List<Integer> schoolScope = listSelfAndDescendantIds(schoolId);
if (!schoolScope.contains(groupId)) {
throw new DException("组不属于该学校范围,禁止跨校操作");
}
}
@Override
public List<SysGroupVo> list(SysGroupQuery query) {
List<SysGroup> list;
@@ -653,6 +691,7 @@ public class SysGroupService implements ISysGroupService {
DataSyncEvent e = new DataSyncEvent();
e.setType(type);
e.setGroupIds(Collections.singletonList(groupId));
e.setSourceAppKey(CallerAppContext.getAppKey());
eventPublisher.publishEvent(e);
}
@@ -663,6 +702,7 @@ public class SysGroupService implements ISysGroupService {
DataSyncEvent e = new DataSyncEvent();
e.setType(type);
e.setGroupIds(groupIds);
e.setSourceAppKey(CallerAppContext.getAppKey());
eventPublisher.publishEvent(e);
}
}
@@ -9,6 +9,7 @@ import com.yunhai.userbase.enums.UserTypeEnum;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.model.sysuser.BatchImportResult;
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
import com.yunhai.userbase.model.sysuser.SysUserQuery;
import com.yunhai.userbase.model.sysuser.SysUserRequest;
import com.yunhai.userbase.model.sysuser.SysUserVo;
@@ -19,6 +20,7 @@ import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.ISysUserService;
import com.yunhai.userbase.event.DataSyncEvent;
import com.yunhai.userbase.common.context.CallerAppContext;
import com.yunhai.userbase.model.push.PushInterfaceType;
import org.springframework.context.ApplicationEventPublisher;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
@@ -243,6 +245,53 @@ public class SysUserService implements ISysUserService {
publishUserEvent(PushInterfaceType.DELETE_USER, userIds, groupId);
}
@Override
@Transactional
public void deleteFromGroup(Integer userId, Integer groupId, Integer schoolId) {
// 开放接口边界:groupId 必须属于 schoolId 子树且不能等于 schoolId(禁止跨校/整校移除)
assertGroupWithinSchool(groupId, schoolId);
batchDeleteFromGroup(Collections.singletonList(userId), groupId);
}
@Override
@Transactional
public void deleteUsers(List<SysUserGroupDeleteItem> items) {
if (items == null || items.isEmpty()) {
throw new DException("批量删除用户-组关系列表不能为空");
}
for (SysUserGroupDeleteItem item : items) {
deleteFromGroup(item.getUserId(), item.getGroupId(), item.getSchoolId());
}
}
/**
* 学校边界校验:确保待操作的组属于指定学校范围,且不是学校根组本身。<br>
* 与 {@link #moveUserWithinSchool} 的「学校子树范围」判定一致,复用 {@code listSelfAndDescendantIds}。
*
* @param groupId 待操作组ID(解绑/删除目标)
* @param schoolId 学校组ID(限定范围)
*/
private void assertGroupWithinSchool(Integer groupId, Integer schoolId) {
if (schoolId == null) {
throw new DException("学校ID不能为空");
}
if (groupId == null) {
throw new DException("组ID不能为空");
}
if (!sysGroupRepository.existsById(schoolId)) {
throw new DException("学校分组不存在");
}
// 拿学校根组当 groupId = 把用户从整所学校移除,禁止
if (groupId.equals(schoolId)) {
throw new DException("禁止对学校根组执行该操作,请指定具体子组");
}
// groupId 必须落在 schoolId 子树(含子孙,不含学校根组自身——上面已拦截)
List<Integer> schoolScope = sysGroupService.listSelfAndDescendantIds(schoolId);
if (!schoolScope.contains(groupId)) {
throw new DException("组不属于该学校范围,禁止跨校操作");
}
}
@Override
@Transactional
public void changeStatus(Integer id, boolean disabled) {
@@ -1008,6 +1057,7 @@ public class SysUserService implements ISysUserService {
DataSyncEvent e = new DataSyncEvent();
e.setType(type);
e.setUserIds(Collections.singletonList(userId));
e.setSourceAppKey(CallerAppContext.getAppKey());
eventPublisher.publishEvent(e);
}
@@ -1018,6 +1068,7 @@ public class SysUserService implements ISysUserService {
DataSyncEvent e = new DataSyncEvent();
e.setType(type);
e.setUserIds(userIds);
e.setSourceAppKey(CallerAppContext.getAppKey());
eventPublisher.publishEvent(e);
}
@@ -1030,6 +1081,7 @@ public class SysUserService implements ISysUserService {
e.setType(type);
e.setUserIds(userIds);
e.setGroupId(groupId);
e.setSourceAppKey(CallerAppContext.getAppKey());
eventPublisher.publishEvent(e);
}
}
@@ -72,6 +72,10 @@ public class PushDispatcher {
? event.getGroupIds() : event.getUserIds();
Integer groupId = event.getGroupId();
for (ApplicationInfo app : applicationInfoRepository.findByPushEnabled(1)) {
// 来源排除:应用自己触发的变更(如 oapi 写接口)不回推给自己,避免收到自己的回声
if (event.getSourceAppKey() != null && event.getSourceAppKey().equals(app.getAppKey())) {
continue;
}
dispatchDeleteToOne(app, type, ids, groupId, "auto");
}
return;
@@ -88,6 +92,10 @@ public class PushDispatcher {
users = sysUserRepository.findAll();
}
for (ApplicationInfo app : applicationInfoRepository.findByPushEnabled(1)) {
// 来源排除:应用自己触发的变更(如 oapi 写接口)不回推给自己,避免收到自己的回声
if (event.getSourceAppKey() != null && event.getSourceAppKey().equals(app.getAppKey())) {
continue;
}
dispatchToOne(app, type, groups, users, "auto", false);
}
} catch (Exception e) {