组用户功能修订
ci-userbase-service / build-and-push (push) Failing after 1m45s

This commit is contained in:
chenxing
2026-08-12 19:16:22 +08:00
parent f55c865ab1
commit a5ced786b4
28 changed files with 1488 additions and 202 deletions
@@ -2,7 +2,7 @@ package com.yunhai.userbase.common.interceptor;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.service.UserCenterService;
import com.yunhai.userbase.service.impl.UserCenterService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@@ -6,6 +6,7 @@ import com.yunhai.platform.appauth.LocalAppIdentityProvider;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/**
* 用户中心验签密钥源:UserBase 作为密钥权威源,优先从本库 application_info 解析调用方密钥
@@ -13,18 +14,31 @@ import org.springframework.context.annotation.Configuration;
* 显式覆盖 yh-app-auth 默认的 {@code @ConditionalOnMissingBean} 实现,使开放接口签名校验
* 不依赖 Redis 中 {@code sign:app:*} 的预填充时机,且能验签所有已颁发应用。<br><br>
* 同时注册 UserBase 自身作为出站调用方的身份:固定 appKey=100000,密钥落库,不再依赖 yml
* 配置 app-key/app-secret。
* 配置 app-key/app-secret。<br>
* <b>注意:</b>两个 Bean 都加 {@code @Primary},保证 yh-platform starter 在按类型注入时
* (如 AppSignInterceptor 需要 LocalAppIdentityProvider)不会因多个候选而报
* 「required a single bean, but 2 were found」。
*/
@Configuration
public class AppSecretConfig {
@Bean
/**
* 入站验签密钥提供方(按类型匹配 AppSecretProvider,@Primary 优先)。
* SignKeyCacheService 注入时按具体类名 UserBaseAppSecretProvider 匹配,不走这个接口类型。
*/
@Bean(name = "appSecretProvider")
@Primary
public AppSecretProvider appSecretProvider(ApplicationInfoRepository repository,
AppAuthProperties properties) {
return new UserBaseAppSecretProvider(repository, properties);
}
@Bean
/**
* 出站身份提供方(按类型匹配 LocalAppIdentityProvider,@Primary 优先)。
* AppSignInterceptor 等 yh-platform starter 内部按接口类型注入,需要明确唯一候选。
*/
@Bean(name = "localAppIdentityProvider")
@Primary
public LocalAppIdentityProvider localAppIdentityProvider(ApplicationInfoRepository repository) {
return new UserBaseLocalAppIdentityProvider(repository);
}
@@ -1,9 +1,14 @@
package com.yunhai.userbase.config;
import com.yunhai.platform.appauth.AppAuthFilter;
import com.yunhai.platform.appauth.AppAuthProperties;
import com.yunhai.platform.auth.UserDetailsProvider;
import com.yunhai.userbase.service.impl.UserCenterService;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.Ordered;
import javax.servlet.Filter;
@@ -16,31 +21,61 @@ import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 强制开放接口必须带应用签名。<br>
* 强制开放接口必须带应用签名 + UserBase 自身 CAS 认证兜底的 UserDetailsProvider。<br>
* <br>
* yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流),
* 但 UserBase 作为用户中心的开放接口({/open/api/**})与内部密钥兜底端点({/oapi/internal/**})
* 绝不允许匿名访问。本过滤器在 {@code AppAuthFilter} 之后运行,校验其写入的请求属性:
* 已验签(属性存在)则放行,否则返回 401。<br>
* 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。<br>
* <br>
* 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。
*
* 额外注册 {@code UserDetailsProvider}(@Bean @Primary,名=userDetailsProvider):
* 覆盖 yh-auth-spring-boot-starter 新版默认的 NoOpUserDetailsProvider(调用直接抛
* UnsupportedOperationException「未配置 UserDetailsProvider」)。必须「显式 @Bean
* + @Primary」
* 而不能只靠 @Component 扫描:starter 新版 AutoConfiguration 运行顺序可能早于 UserBase 组件扫描,
* 导致其 @ConditionalOnMissingBean 抢先注册 NoOp,PathBasedAuthInterceptor 注入后首次请求即抛错。
*/
@Configuration
@EnableConfigurationProperties({ SignKeyInternalProperties.class, AppAuthProperties.class })
public class OpenApiAuthEnforceConfiguration {
@Bean
public FilterRegistrationBean<RequireAppSignFilter> requireAppSignFilter() {
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new RequireAppSignFilter());
// 两个路径体系必须全部强制验签:
// 1) /open/api/* : 过渡期旧路径(客户端 404 自动回退时会用)
// 2) /oapi/* : 新统一开放前缀(覆盖 /oapi/user/、/oapi/internal/、未来 /oapi/app/
// /oapi/group/ 等)
bean.addUrlPatterns("/open/api/*", "/oapi/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
return bean;
}
/**
* 内部开放接口(/oapi/internal/*)来源 IP 白名单:
* 在验签过滤器之前先执行(HIGHEST_PRECEDENCE + 9),先卡掉公网来源再进行后续处理。
* 配置项 yh.sign-key-internal.ips / enabled 可由 Nacos 下发。
*/
@Bean
public FilterRegistrationBean<SignKeyInternalIpFilter> signKeyInternalIpFilter(
SignKeyInternalProperties properties) {
FilterRegistrationBean<SignKeyInternalIpFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new SignKeyInternalIpFilter(properties));
bean.addUrlPatterns("/oapi/internal/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 9);
return bean;
}
/**
* UserBase 本地 CAS 登录用户提供者:按登录名/手机号查 UserBase 自身库返回 BoaUser。<br>
* 加 {@code @Primary} 稳定覆盖 yh-auth 默认 NoOp 实现;bean 名固定=userDetailsProvider,
* 与 starter 默认同名,即使开启 allow-bean-definition-overriding=false 也能优先匹配。
*/
@Bean(name = "userDetailsProvider")
@Primary
public UserDetailsProvider userDetailsProvider(UserCenterService userCenterService) {
return new UserBaseUserDetailsProvider(userCenterService);
}
public static class RequireAppSignFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
@@ -9,12 +9,15 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* UserBase 作为「应用密钥权威源」的验签密钥提供方。<br><br>
* UserBase 作为「应用密钥权威源」的验签密钥提供方。<br>
* <br>
* 解析优先级:<br>
* 1) 本库 application_info —— 覆盖全部「已颁发」应用,新增/重置密钥后即时生效;<br>
* 2) 配置式受信应用 {@code yh.app-auth.trusted-apps} —— 平台自身默认密钥(如 应用中心 100616)
* 兜底,使其即便尚未落库也可验签,避免「默认 appKey 未建行」导致验签失败。<br><br>
* 带 60s 本地 TTL 缓存,避免每次验签直打库(密钥变更最长 60s 后生效)。<br><br>
* 兜底,使其即便尚未落库也可验签,避免「默认 appKey 未建行」导致验签失败。<br>
* <br>
* 带 60s 本地 TTL 缓存,避免每次验签直打库(密钥变更最长 60s 后生效)。<br>
* <br>
* 这样 UserBase 作为颁发方不依赖 Redis 预热、也不局限于 trustedApps 写死的少量密钥,
* 能够验签所有它已颁发的应用。
*/
@@ -59,6 +62,22 @@ public class UserBaseAppSecretProvider implements AppSecretProvider {
return secret;
}
/**
* 主动失效:立刻清除本地缓存中指定 appKey 的条目,密钥重置后调用 → 下一次 getSecret 直查 DB 拿新值,
* 不再受最长 60s TTL 生效窗口限制。
*/
public void invalidate(String appKey) {
if (appKey == null) {
return;
}
cache.remove(appKey);
}
/** 主动失效:立刻清空全部本地缓存(refreshAll / Redis 全量对账时调用)。 */
public void invalidateAll() {
cache.clear();
}
private static Integer parse(String appKey) {
try {
return Integer.parseInt(appKey.trim());
@@ -51,6 +51,14 @@ public class UserBaseLocalAppIdentityProvider implements LocalAppIdentityProvide
return secret;
}
/**
* 主动失效:立刻清除 UserBase 自身(appKey=100000)的本地缓存,密钥重置后调用 →
* 下一次 getAppSecret 直查 DB 拿新值,不再受最长 60s TTL 生效窗口限制。
*/
public void invalidate() {
cache.remove(USER_CENTER_APP_KEY);
}
private static final class CacheEntry {
final long ts;
final String secret;
@@ -2,22 +2,25 @@ package com.yunhai.userbase.config;
import com.yunhai.platform.auth.UserDetailsProvider;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.service.UserCenterService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.yunhai.userbase.service.impl.UserCenterService;
/**
* 平台 {@link UserDetailsProvider} 的 UserBase 本地实现:CAS 会话流解析出登录名/手机号后,
* 由本类查 UserBase 自身库,组装成基础平台统一的 {@link BoaUser}。<br><br>
* 以 {@code @Component} 注册为 Bean,覆盖 yh-auth 默认的 NoOp 实现(@ConditionalOnMissingBean)。
* 注意:UserBase 自身不引入 yh-openapi-spring-boot-starter,故 openApiUserDetailsProvider 不会被创建,
* 不会形成「用户中心回调自身」的环。
*
* 本类不再加 {@code @Component},改为由 {@link OpenApiAuthEnforceConfiguration}
* 显式以 {@code @Bean @Primary} 注册(bean 名固定为 {@code userDetailsProvider}),
* 原因:yh-auth-spring-boot-starter 新版 AutoConfiguration 处理顺序可能早于 UserBase 组件扫描,
* 导致其默认注册的 {@code NoOpUserDetailsProvider}(直接抛 UnsupportedOperationException)
* 抢先被 PathBasedAuthInterceptor 注入。显式 @Bean+@Primary 可稳定覆盖默认实现。
*/
@Component
public class UserBaseUserDetailsProvider implements UserDetailsProvider {
@Autowired
private UserCenterService userCenterService;
private final UserCenterService userCenterService;
public UserBaseUserDetailsProvider(UserCenterService userCenterService) {
this.userCenterService = userCenterService;
}
@Override
public BoaUser loadByLoginName(String loginName) {
@@ -20,7 +20,7 @@ import com.yunhai.userbase.model.appmodule.AppModuleVo;
import com.yunhai.userbase.service.IAppCategoryService;
import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.IAppModuleService;
import com.yunhai.userbase.service.SignKeyCacheService;
import com.yunhai.userbase.service.impl.SignKeyCacheService;
import com.yunhai.userbase.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
@@ -1,20 +1,31 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.BasePageQuery;
import com.yunhai.userbase.model.BasePageResult;
import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest;
import com.yunhai.userbase.model.sysuser.BatchDeleteRequest;
import com.yunhai.userbase.model.sysuser.BatchImportResult;
import com.yunhai.userbase.model.sysuser.ChangeGroupsRequest;
import com.yunhai.userbase.model.sysuser.ChangeSortNoRequest;
import com.yunhai.userbase.model.sysuser.SysUserQuery;
import com.yunhai.userbase.model.sysuser.SysUserRequest;
import com.yunhai.userbase.model.sysuser.SysUserVo;
import com.yunhai.userbase.service.ISysUserService;
import com.yunhai.userbase.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* @author chenx
@@ -31,6 +42,7 @@ public class SysUserController {
@OperationLog("新增用户")
@PostMapping("Add")
public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) {
List<String> data=new ArrayList<>();
sysUserService.add(request);
}
@@ -46,6 +58,24 @@ public class SysUserController {
sysUserService.delete(id);
}
/**
* 将用户从指定组中移除;若该用户无其它组关联则级联删除用户。
*/
@OperationLog("从组中删除用户")
@PostMapping("DeleteFromGroup/{userId}/{groupId}")
public void deleteFromGroup(@PathVariable Integer userId, @PathVariable Integer groupId) {
sysUserService.deleteFromGroup(userId, groupId);
}
/**
* 批量从指定组中移除用户;无其它组关联者级联删除用户。
*/
@OperationLog("批量从组中删除用户")
@PostMapping("BatchDelete")
public void batchDelete(@RequestBody @Validated BatchDeleteRequest request) {
sysUserService.batchDeleteFromGroup(request.getUserIds(), request.getGroupId());
}
/** 禁用用户(disabled=true) */
@OperationLog("禁用用户")
@PostMapping("Disable/{id}")
@@ -60,9 +90,63 @@ public class SysUserController {
sysUserService.changeStatus(id, false);
}
/** 批量设置用户状态 */
@OperationLog("批量设置用户状态")
@PostMapping("BatchChangeStatus")
public void batchChangeStatus(@RequestBody @Validated BatchChangeStatusRequest request) {
sysUserService.batchChangeStatus(request.getUserIds(), request.getDisabled());
}
@GetMapping("/List")
public BasePageResult<SysUserVo> list(SysUserQuery query, BasePageQuery pageQuery,
@SortDefault(sort = "id", direction = Sort.Direction.DESC) Sort sort) {
return PageUtils.toResult(sysUserService.list(query, PageUtils.toPageable(pageQuery, sort)));
public BasePageResult<SysUserVo> list(SysUserQuery query, BasePageQuery pageQuery) {
// 排序字段在关联表,无法走 Pageable.sort,统一由 Service.Specification 控制
// (根节点按 id 升序,具体组按组内 sort_no 升序)。此处不传 Sort,避免覆盖 Spec 的 orderBy。
return PageUtils.toResult(sysUserService.list(query, PageUtils.toPageable(pageQuery, null)));
}
/** 调整用户在某组内的排序号(sort_no):1~999,可空表示清除 */
@OperationLog("设置用户组内排序号")
@PostMapping("ChangeSortNo/{userId}")
public void changeSortNo(@PathVariable Integer userId,
@RequestBody @Validated ChangeSortNoRequest request) {
sysUserService.changeSortNo(userId, request.getGroupId(), request.getSortNo());
}
@OperationLog("调整组")
@PostMapping("ChangeGroups")
public void changeGroups(@RequestBody @Validated ChangeGroupsRequest request) {
sysUserService.changeGroups(request.getUserIds(), request.getGroupIds());
}
@GetMapping("Groups/{userId}")
public List<Integer> getUserGroups(@PathVariable Integer userId) {
return sysUserService.findGroupIdsByUserId(userId);
}
@OperationLog("批量导入用户")
@PostMapping("BatchImport")
public BatchImportResult batchImport(@RequestParam("file") MultipartFile file) {
return sysUserService.batchImport(file);
}
/**
* 下载用户批量导入模板(真实 .xlsx):平铺当前组及子孙组,仅填充组名(路径)与组id;其余列留空供填写。
*/
@GetMapping("DownloadTemplate")
public void downloadTemplate(@RequestParam(value = "groupId", required = false) Integer groupId,
HttpServletResponse response) throws IOException {
sysUserService.downloadImportTemplate(groupId, response);
}
/**
* 导出组下所有用户(含子组)为 Excel;与导入模板同构,末尾追加用户id列。
* 前端直接跳转此地址触发浏览器下载。
*/
@GetMapping("Export")
public void export(@RequestParam(value = "groupId", required = false) Integer groupId,
@RequestParam(value = "withSub", defaultValue = "1") Integer withSub,
@RequestParam(value = "name", required = false) String name,
HttpServletResponse response) throws IOException {
sysUserService.exportUsers(groupId, withSub, name, response);
}
}
@@ -8,7 +8,7 @@ import com.yunhai.userbase.model.groupauthz.UserPermissionAppVo;
import com.yunhai.userbase.model.groupauthz.UserPermissionCategoryVo;
import com.yunhai.userbase.model.groupauthz.UserPermissionModuleVo;
import com.yunhai.userbase.service.IGroupAuthzService;
import com.yunhai.userbase.service.UserCenterService;
import com.yunhai.userbase.service.impl.UserCenterService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
@@ -40,6 +40,10 @@ public class SysUser extends BaseEntity {
private String idcard;
private String email;
private Integer gender;
//头像
private String profilePicture;
@@ -32,6 +32,10 @@ public class SysUserGroupRel {
/** 是否主归属 1=本职主学校/部门 0=兼职兼任 */
private Integer isMain;
/** 组内排序号:越小越靠前;null 表示未设置,列表按 user_id 兜底升序 */
@Column(name = "sort_no")
private Integer sortNo;
private LocalDateTime createTime;
// 关联用户,仅查询,不维护外键
@@ -0,0 +1,45 @@
package com.yunhai.userbase.event;
import com.yunhai.userbase.entity.ApplicationInfo;
import org.springframework.context.ApplicationEvent;
/**
* 应用密钥/元数据变更事件:在 ApplicationInfoService 的 save/add/edit/delete/resetSecret 等事务内发布,
* 由 SignKeyCacheService 的 @TransactionalEventListener(phase = AFTER_COMMIT 订阅,
* 保证 DB 真正提交成功后再写共享 Redis / 清本地缓存,避免事务回滚导致的脏数据。
*/
public class ApplicationInfoCacheChangedEvent extends ApplicationEvent {
public enum Type {
UPSERT, DELETE
}
private final Type type;
private final Integer appKey;
/**
* 删除事件:appKey 必填;upsert 事件:可以传 entity 非空。
*/
private final ApplicationInfo upsertEntity;
public ApplicationInfoCacheChangedEvent(Object source, Type type, Integer appKey, ApplicationInfo upsertEntity) {
super(source);
this.type = type;
this.appKey = appKey;
this.upsertEntity = upsertEntity;
}
public static ApplicationInfoCacheChangedEvent upsert(Object source, ApplicationInfo entity) {
return new ApplicationInfoCacheChangedEvent(source, Type.UPSERT, entity.getAppKey(), entity);
}
public static ApplicationInfoCacheChangedEvent delete(Object source, Integer appKey) {
return new ApplicationInfoCacheChangedEvent(source, Type.DELETE, appKey, null);
}
public Type getType() { return type; }
public Integer getAppKey() { return appKey; }
public ApplicationInfo getUpsertEntity() { return upsertEntity; }
}
@@ -0,0 +1,47 @@
package com.yunhai.userbase.model.sysuser;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* @author chenx
* @date 2026/8/11
* @description 用户批量导入结果
*/
@Data
public class BatchImportResult {
/** 总行数(含表头) */
private int total;
/** 成功条数 */
private int success;
/** 失败条数 */
private int fail;
/** 失败明细:行号 + 原因 */
private List<FailItem> fails = new ArrayList<>();
@Data
public static class FailItem {
private int row;
private String reason;
public FailItem(int row, String reason) {
this.row = row;
this.reason = reason;
}
}
public void addSuccess() {
this.success++;
}
public void addFail(int row, String reason) {
this.fail++;
this.fails.add(new FailItem(row, reason));
}
}
@@ -0,0 +1,23 @@
package com.yunhai.userbase.model.sysuser;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* @author chenx
* @date 2026/8/11
* @description 批量调整用户所属组请求
*/
@Data
public class ChangeGroupsRequest {
/** 待调整用户ID列表 */
@NotEmpty(message = "待调整用户不能为空")
private List<Integer> userIds;
/** 目标组ID列表(覆盖式) */
@NotEmpty(message = "请至少保留一个组")
private List<Integer> groupIds;
}
@@ -0,0 +1,53 @@
package com.yunhai.userbase.model.sysuser;
import cn.afterturn.easypoi.excel.annotation.Excel;
import lombok.Data;
import java.io.Serializable;
/**
* 组用户导出 Excel 实体:与导入模板 {@code IMPORT_COLUMN_DEFS} 同构(前 12 列顺序一致),
* 末尾追加「用户id」列。导出 = 读取数据库数据填充 list,复用 EasyPoi 的 {@code @Excel} 注解生成 Excel。
*/
@Data
public class ExcelSysUser implements Serializable {
@Excel(name = "组名\n(不必填,供与id对应参考用)", orderNum = "0", width = 36)
private String groupName;
@Excel(name = "组id(必须)", orderNum = "1", width = 14)
private String groupId;
@Excel(name = "用户名(必须)", orderNum = "2", width = 20)
private String account;
@Excel(name = "密码\n(可选,\n默认Vzuk4Dp3)", orderNum = "3", width = 18)
private String password;
@Excel(name = "姓名(必须)", orderNum = "4", width = 16)
private String realName;
@Excel(name = "身份证号(可选)", orderNum = "5", width = 22)
private String idcard;
@Excel(name = "手机号(可选)", orderNum = "6", width = 16)
private String phone;
@Excel(name = "email(可选)", orderNum = "7", width = 24)
private String email;
@Excel(name = "用户性质\n(可选,默认普通用户\n组管理员2,\n普通用户0,\n导入不接受别的代码)", orderNum = "8", width = 26)
private Integer nature;
@Excel(name = "用户身份\n(1教师 2教职工 3学生 4家长)", orderNum = "9", width = 24)
private Integer identity;
@Excel(name = "性别(可选)", orderNum = "10", width = 12)
private Integer gender;
@Excel(name = "生日(yyyy-MM-dd)(可选)", orderNum = "11", width = 22)
private String birthday;
@Excel(name = "用户id", orderNum = "12", width = 14)
private Integer userId;
}
@@ -35,6 +35,8 @@ public class SysUserRequest {
private String phone;
private LocalDate birthday;
private String idcard;
private String email;
private Integer gender;
private String profilePicture;
private List<Integer> roles;
/** 启用状态:对应 StatusEnum 的 key(0=正常/未禁用,1=禁用);不传则默认启用 */
@@ -19,12 +19,16 @@ public class SysUserVo {
private String realName;
private String phone;
private String idcard;
private String email;
private Integer gender;
private String profilePicture;
private List<Integer> roles;
/** 启用状态:StatusEnum 的 key(0=正常/未禁用,1=禁用) */
private Integer disabled;
/** 用户类型:UserTypeEnum 的 key(0=普通用户,1=超级管理员,2=学校管理员) */
private Integer type;
/** 当前组维度的排序号(组内 sort_no);根节点/未选组时为 null */
private Integer sortNo;
private Boolean expired;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
import java.util.Optional;
/**
* @author chenx
@@ -19,7 +20,15 @@ public interface SysUserGroupRelRepository extends JpaRepository<SysUserGroupRel
List<SysUserGroupRel> findByUserId(Integer userId);
List<SysUserGroupRel> findByUserIdIn(List<Integer> userIds);
List<SysUserGroupRel> findByUserIdInAndGroupId(List<Integer> userIds, Integer groupId);
Optional<SysUserGroupRel> findByUserIdAndGroupId(Integer userId, Integer groupId);
List<SysUserGroupRel> findByGroupId(Integer groupId);
void deleteByUserId(Integer userId);
void deleteByUserIdAndGroupId(Integer userId, Integer groupId);
}
@@ -1,11 +1,17 @@
package com.yunhai.userbase.service;
import com.yunhai.userbase.model.sysuser.BatchImportResult;
import com.yunhai.userbase.model.sysuser.SysUserQuery;
import com.yunhai.userbase.model.sysuser.SysUserRequest;
import com.yunhai.userbase.model.sysuser.SysUserVo;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
/**
* @author chenx
* @date 2026/7/20 16:14
@@ -22,6 +28,22 @@ public interface ISysUserService {
/** 删除用户:同时清理其组用户关联。 */
void delete(Integer id);
/**
* 把用户从指定组中移除;若移除后该用户已无其它组关联,则级联删除用户本身。
*
* @param userId 用户ID
* @param groupId 当前组ID
*/
void deleteFromGroup(Integer userId, Integer groupId);
/**
* 批量把用户从指定组中移除;无其它组关联者级联删除用户本身。
*
* @param userIds 用户ID列表
* @param groupId 当前组ID
*/
void batchDeleteFromGroup(List<Integer> userIds, Integer groupId);
/**
* 启用/禁用用户。<br>
*
@@ -30,6 +52,14 @@ public interface ISysUserService {
*/
void changeStatus(Integer id, boolean disabled);
/**
* 批量启用/禁用用户。
*
* @param userIds 用户ID列表
* @param disabled true=禁用,false=启用
*/
void batchChangeStatus(List<Integer> userIds, boolean disabled);
/**
* 分页查询组下用户。<br>
* 依据 query.groupId 与 query.withSub 决定组范围,query.name 做多字段匹配。<br>
@@ -39,4 +69,59 @@ public interface ISysUserService {
* @return 用户分页
*/
Page<SysUserVo> list(SysUserQuery query, Pageable pageable);
/**
* 查询指定用户当前所属的所有组ID。
*
* @param userId 用户ID
* @return 组ID列表
*/
List<Integer> findGroupIdsByUserId(Integer userId);
/**
* 批量调整用户所属组:覆盖式更新 sys_user_group_rel。<br>
* 同一批用户统一设置为目标组列表,第一个组设为主归属,其余为兼职。
*
* @param userIds 待调整用户ID列表
* @param groupIds 目标组ID列表
*/
void changeGroups(List<Integer> userIds, List<Integer> groupIds);
/**
* 调整用户在某组内的排序号(sort_no)。<br>
* sortNo 为 null 时清除排序号,列表回退到 user_id 升序兜底。
*
* @param userId 用户ID
* @param groupId 组ID(排序号按组维度,必须落到具体组)
* @param sortNo 排序号(1~999),可空表示清除
*/
void changeSortNo(Integer userId, Integer groupId, Integer sortNo);
/**
* 批量导入用户:按模板解析 Excel,逐行校验并新增。
* 失败行继续处理,返回成功/失败统计及失败原因。
*
* @param file Excel 文件
* @return 导入结果
*/
BatchImportResult batchImport(MultipartFile file);
/**
* 下载用户批量导入模板(真实 .xlsx):平铺指定组及其所有子孙组,仅填充组名(路径)与组id。
*
* @param groupId 当前组ID(可空,空则取组织树根)
* @param response 直接写入 Excel 流
*/
void downloadImportTemplate(Integer groupId, HttpServletResponse response) throws IOException;
/**
* 导出组下所有用户(含子组)为 Excel:与导入模板同构,末尾追加「用户id」列。<br>
* 组名 / 组id 取每个用户实际所属的主组(isMain=1);密码为密文不可逆,留空。
*
* @param groupId 当前组ID(可空,空则取组织树根)
* @param withSub 是否包含子组:1=含所有子孙组;其它=仅当前组
* @param name 关键字(可选)
* @param response 直接写入 Excel 流
*/
void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException;
}
@@ -1,151 +0,0 @@
package com.yunhai.userbase.service;
import com.alibaba.fastjson.JSON;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 应用签名密钥的 Redis 桥接服务。<br><br>
* 职责:把 {@code application_info} 表的 appKey/appSecret 同步到 Redis,供各业务服务的
* common-sign-starter(SignKeyResolver)读取做验签——业务服务无需直连 UserBase 数据库。<br><br>
* 约定(读侧 starter 必须严格一致):<br>
* 1) key 前缀 {@code sign:app:},后接 appKey,例 {@code sign:app:100616};<br>
* 2) value 为 fastjson 序列化的 JSON 字符串:{@code {"appKey":"100616","secret":"...","enabled":true}};<br>
* 3) 不设过期时间,依赖 Redis 持久化(AOF/RDB)保证重启不丢;删除应用时直接删 key(等价于禁用)。
*/
@Service
public class SignKeyCacheService implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(SignKeyCacheService.class);
/** Redis key 前缀;读侧 SignKeyResolver 必须保持一致:sign:app:{appKey} */
public static final String KEY_PREFIX = "sign:app:";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private ApplicationInfoRepository applicationInfoRepository;
/**
* 同步单条应用到 Redis(新增 / 编辑 / 重置密钥时调用)。
*/
public void syncOne(ApplicationInfo info) {
if (info == null || info.getAppKey() == null || info.getAppSecret() == null) {
return;
}
String key = KEY_PREFIX + info.getAppKey();
SignAppKeyPayload payload = new SignAppKeyPayload(
String.valueOf(info.getAppKey()), info.getAppSecret(), true);
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(payload));
log.info("[SignKeyCache] sync appKey={} -> redis", info.getAppKey());
}
/**
* 从 Redis 移除某应用的密钥(删除应用时调用,等价于禁用该应用签名)。
*/
public void removeOne(Integer appKey) {
if (appKey == null) {
return;
}
stringRedisTemplate.delete(KEY_PREFIX + appKey);
log.info("[SignKeyCache] remove appKey={} from redis", appKey);
}
/**
* 全量刷新:把数据库全部应用重新写入 Redis,并清理库中已不存在的残留 key。<br>
* 用于 Redis 数据丢失 / 不一致后的手动恢复,以及首次部署预热。
*
* @return 实际写入的密钥条数
*/
public int refreshAll() {
List<ApplicationInfo> all = applicationInfoRepository.findAll();
Set<String> dbKeys = new HashSet<>();
for (ApplicationInfo info : all) {
if (info.getAppKey() == null || info.getAppSecret() == null) {
continue;
}
syncOne(info);
dbKeys.add(KEY_PREFIX + info.getAppKey());
}
// 清理残留:DB 已删除但 Redis 仍存在的 key(防止被删应用仍可验签)
Set<String> redisKeys = stringRedisTemplate.keys(KEY_PREFIX + "*");
if (redisKeys != null) {
for (String k : redisKeys) {
if (!dbKeys.contains(k)) {
stringRedisTemplate.delete(k);
log.info("[SignKeyCache] prune stale key={}", k);
}
}
}
log.info("[SignKeyCache] refreshAll done, synced={}", dbKeys.size());
return dbKeys.size();
}
/**
* 启动预热:应用启动后尽力把密钥同步到 Redis。
* Redis 不可用时仅告警,不影响应用启动(此时验签依赖后续手动刷新或 Redis 恢复)。
*/
@Override
public void run(ApplicationArguments args) {
try {
int n = refreshAll();
log.info("[SignKeyCache] startup warm-up synced={}", n);
} catch (Exception e) {
log.warn("[SignKeyCache] startup warm-up failed (redis may be unavailable): {}", e.getMessage());
}
}
/**
* Redis value 的 JSON 载体。字段名即契约,读侧 starter 的解析类需一一对应。
*/
public static class SignAppKeyPayload {
private String appKey;
private String secret;
private boolean enabled;
public SignAppKeyPayload() {
}
public SignAppKeyPayload(String appKey, String secret, boolean enabled) {
this.appKey = appKey;
this.secret = secret;
this.enabled = enabled;
}
public String getAppKey() {
return appKey;
}
public void setAppKey(String appKey) {
this.appKey = appKey;
}
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}
}
@@ -11,17 +11,18 @@ import com.yunhai.userbase.repository.SysGroupAppRelRepository;
import com.yunhai.userbase.repository.SysGroupModulePermRepository;
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.SignKeyCacheService;
import com.yunhai.platform.utils.exception.DException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
import com.yunhai.userbase.model.application.ApplicationInfoVo;
import com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.data.domain.Page;
@@ -38,7 +39,6 @@ import java.util.List;
import java.util.Objects;
import java.util.UUID;
@Service
public class ApplicationInfoService implements IApplicationInfoService {
@@ -66,6 +66,9 @@ public class ApplicationInfoService implements IApplicationInfoService {
@Autowired
private SignKeyCacheService signKeyCacheService;
@Autowired
private ApplicationEventPublisher eventPublisher;
/**
* 查询缓存:sync = true 防击穿;底层优先 Redis,Redis 故障时自动降级 Caffeine。
*/
@@ -81,7 +84,9 @@ public class ApplicationInfoService implements IApplicationInfoService {
@Override
@CachePut(value = "appInfo", key = "#info.id")
public ApplicationInfo save(ApplicationInfo info) {
return applicationInfoRepository.save(info);
ApplicationInfo saved = applicationInfoRepository.save(info);
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
return saved;
}
/**
@@ -90,7 +95,11 @@ public class ApplicationInfoService implements IApplicationInfoService {
@Override
@CacheEvict(value = "appInfo", key = "#id")
public void deleteById(Integer id) {
ApplicationInfo existing = applicationInfoRepository.findById(id).orElse(null);
applicationInfoRepository.deleteById(id);
if (existing != null && existing.getAppKey() != null) {
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.delete(this, existing.getAppKey()));
}
}
@Override
@@ -111,7 +120,7 @@ public class ApplicationInfoService implements IApplicationInfoService {
// appSecret:32 位 UUID(去除连字符)
entity.setAppSecret(UUID.randomUUID().toString().replace("-", ""));
ApplicationInfo saved = applicationInfoRepository.save(entity);
signKeyCacheService.syncOne(saved);
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
}
@Override
@@ -127,7 +136,7 @@ public class ApplicationInfoService implements IApplicationInfoService {
.orElseThrow(() -> new RuntimeException("应用不存在: " + id));
BeanUtils.copyProperties(request, entity, "id", "appKey", "appSecret");
ApplicationInfo saved = applicationInfoRepository.save(entity);
signKeyCacheService.syncOne(saved);
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
}
@Override
@@ -164,7 +173,7 @@ public class ApplicationInfoService implements IApplicationInfoService {
// 先删模块(整棵子树随 appId 一并清除),再删应用,避免残留孤儿数据
appModuleRepository.deleteByAppId(id);
applicationInfoRepository.deleteById(id);
signKeyCacheService.removeOne(appKey);
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.delete(this, appKey));
}
@Override
@@ -223,7 +232,7 @@ public class ApplicationInfoService implements IApplicationInfoService {
.orElseThrow(() -> new RuntimeException("应用不存在: " + id));
entity.setAppSecret(UUID.randomUUID().toString().replace("-", ""));
ApplicationInfo saved = applicationInfoRepository.save(entity);
signKeyCacheService.syncOne(saved);
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
}
@Override
@@ -0,0 +1,275 @@
package com.yunhai.userbase.service.impl;
import com.alibaba.fastjson.JSON;
import com.yunhai.userbase.config.UserBaseAppSecretProvider;
import com.yunhai.userbase.config.UserBaseLocalAppIdentityProvider;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 应用签名密钥的 Redis 桥接服务。<br>
* <br>
* 职责:把 {@code application_info} 表的 appKey/appSecret 同步到 Redis,供各业务服务的
* common-sign-starter(SignKeyResolver)读取做验签——业务服务无需直连 UserBase 数据库。<br>
* <br>
* 约定(读侧 starter 必须严格一致):<br>
* 1) key 前缀 {@code sign:app:},后接 appKey,例 {@code sign:app:100616};<br>
* 2) value 为 fastjson 序列化的 JSON
* 字符串:{@code {"appKey":"100616","secret":"...","enabled":true}};<br>
* 3) 不设过期时间,依赖 Redis 持久化(AOF/RDB)保证重启不丢;删除应用时直接删 key(等价于禁用)。<br>
* <br>
* <b>变更同步说明(IDEA 灰色=正常):</b>本类的 {@code onCacheChanged(...)} 由 Spring 容器在运行时通过
* 反射注册为事务监听器({@code @TransactionalEventListener}),不会在 IDE 的静态调用链中直接显示
* 引用,属正常现象。真实触发点在
* {@link com.yunhai.userbase.service.impl.ApplicationInfoService}
* 的增/改/删/重置密钥、以及
* 通用 save/deleteById 方法末尾,统一发布 {@link ApplicationInfoCacheChangedEvent}。
*/
@Service
public class SignKeyCacheService implements ApplicationRunner, ApplicationContextAware {
private static final Logger log = LoggerFactory.getLogger(SignKeyCacheService.class);
/** Redis key 前缀;读侧 SignKeyResolver 必须保持一致:sign:app:{appKey} */
public static final String KEY_PREFIX = "sign:app:";
/** UserBase 本身作为调用方(出站签名)时固定使用的基石应用 appKey,对应 100000 这条初始化记录 */
public static final String USER_BASE_APP_KEY = "100000";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private ApplicationInfoRepository applicationInfoRepository;
private ApplicationContext applicationContext;
/**
* 事务提交后的安全订阅:只有 ApplicationInfoService 对应事务真正 commit 后,
* 才会去写共享 Redis / 清本地缓存,避免事务回滚导致的 DB-Redis 数据不一致。
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCacheChanged(ApplicationInfoCacheChangedEvent event) {
if (event.getType() == ApplicationInfoCacheChangedEvent.Type.DELETE) {
removeOne(event.getAppKey());
return;
}
if (event.getUpsertEntity() != null) {
syncOne(event.getUpsertEntity());
}
}
/**
* 同步单条应用到 Redis(新增 / 编辑 / 重置密钥时调用)。
* <p>
* 同步前主动失效本机的本地缓存,保证密钥变更后本机入站验签 / 出站签名 0 窗口生效。
*/
public void syncOne(ApplicationInfo info) {
if (info == null || info.getAppKey() == null || info.getAppSecret() == null) {
return;
}
String key = KEY_PREFIX + info.getAppKey();
SignAppKeyPayload payload = new SignAppKeyPayload(
String.valueOf(info.getAppKey()), info.getAppSecret(), true);
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(payload));
invalidateLocal(info.getAppKey());
}
/**
* 从 Redis 移除某应用的密钥(删除应用时调用,等价于禁用该应用签名)。
* 删除前同步失效本机本地缓存。
*/
public void removeOne(Integer appKey) {
if (appKey == null) {
return;
}
stringRedisTemplate.delete(KEY_PREFIX + appKey);
invalidateLocal(appKey);
log.info("[SignKeyCache] remove appKey={} from redis", appKey);
}
/**
* 全量刷新:把数据库全部应用重新写入 Redis,并清理库中已不存在的残留 key。<br>
* 用于 Redis 数据丢失 / 不一致后的手动恢复,以及首次部署预热 + 每日定时对账。
*
* @return 实际写入的密钥条数
*/
public int refreshAll() {
List<ApplicationInfo> all = applicationInfoRepository.findAll();
Set<String> dbKeys = new HashSet<>();
for (ApplicationInfo info : all) {
if (info.getAppKey() == null || info.getAppSecret() == null) {
continue;
}
syncOne(info);
dbKeys.add(KEY_PREFIX + info.getAppKey());
}
// 清理残留:DB 已删除但 Redis 仍存在的 key(防止被删应用仍可验签)
Set<String> redisKeys = stringRedisTemplate.keys(KEY_PREFIX + "*");
if (redisKeys != null) {
for (String k : redisKeys) {
if (!dbKeys.contains(k)) {
stringRedisTemplate.delete(k);
log.info("[SignKeyCache] prune stale key={}", k);
}
}
}
// 全量刷新后清空本地缓存,保证之前残留的任何旧密钥在下一次 getSecret 时重新查 DB 获取新值
invalidateAllLocal();
log.info("[SignKeyCache] refreshAll done, synced={}", dbKeys.size());
return dbKeys.size();
}
/**
* 3A 方案兜底:<b>每日对账定时任务已迁移到
* {@link com.yunhai.userbase.task.SignKeyCacheDailyTask#syncAll()}。</b>
* 保持 Service 层单纯(CRUD 同步 + 启动预热),定时任务统一放在 task 包,便于运维集中查看。
*
* @deprecated 请使用 task 包下的 SignKeyCacheDailyTask.syncAll() 每日对账。
* 本方法保留仅为兼容手动调用场景。
*/
@Deprecated
public void scheduledRefreshAll() {
try {
int n = refreshAll();
log.info("[SignKeyCache] scheduled daily refreshAll done, synced={}", n);
} catch (Exception e) {
log.warn("[SignKeyCache] scheduled daily refreshAll failed: {}", e.getMessage());
}
}
/**
* 启动预热:应用启动后尽力把密钥同步到 Redis。
* Redis 不可用时仅告警,不影响应用启动(此时验签依赖后续手动刷新或 Redis 恢复)。
*/
@Override
public void run(ApplicationArguments args) {
try {
int n = refreshAll();
log.info("[SignKeyCache] startup warm-up synced={}", n);
} catch (Exception e) {
log.warn("[SignKeyCache] startup warm-up failed (redis may be unavailable): {}", e.getMessage());
}
}
/**
* 失效本机本地缓存:变更某应用密钥/删除该应用时,
* 同时失效 AppSecretProvider(入站验签读的缓存)和若其为基石应用则失效出站签名缓存。
*/
public void invalidateLocal(Integer appKey) {
if (appKey == null) {
return;
}
try {
Map<String, UserBaseAppSecretProvider> providers = applicationContext
.getBeansOfType(UserBaseAppSecretProvider.class);
if (providers != null && !providers.isEmpty()) {
for (UserBaseAppSecretProvider p : providers.values()) {
p.invalidate(String.valueOf(appKey));
}
}
} catch (Exception ignored) {
}
if (USER_BASE_APP_KEY.equals(String.valueOf(appKey))) {
try {
Map<String, UserBaseLocalAppIdentityProvider> locals = applicationContext
.getBeansOfType(UserBaseLocalAppIdentityProvider.class);
if (locals != null && !locals.isEmpty()) {
for (UserBaseLocalAppIdentityProvider p : locals.values()) {
p.invalidate();
}
}
} catch (Exception ignored) {
}
}
}
/** 全量对账/预热时,直接清空本机的所有密钥缓存,避免残留。 */
public void invalidateAllLocal() {
try {
Map<String, UserBaseAppSecretProvider> providers = applicationContext
.getBeansOfType(UserBaseAppSecretProvider.class);
if (providers != null && !providers.isEmpty()) {
for (UserBaseAppSecretProvider p : providers.values()) {
p.invalidateAll();
}
}
} catch (Exception ignored) {
}
try {
Map<String, UserBaseLocalAppIdentityProvider> locals = applicationContext
.getBeansOfType(UserBaseLocalAppIdentityProvider.class);
if (locals != null && !locals.isEmpty()) {
for (UserBaseLocalAppIdentityProvider p : locals.values()) {
p.invalidate();
}
}
} catch (Exception ignored) {
}
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
/**
* Redis value 的 JSON 载体。字段名即契约,读侧 starter 的解析类需一一对应。
*/
public static class SignAppKeyPayload {
private String appKey;
private String secret;
private boolean enabled;
public SignAppKeyPayload() {
}
public SignAppKeyPayload(String appKey, String secret, boolean enabled) {
this.appKey = appKey;
this.secret = secret;
this.enabled = enabled;
}
public String getAppKey() {
return appKey;
}
public void setAppKey(String appKey) {
this.appKey = appKey;
}
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}
}
@@ -1,12 +1,14 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.enums.IEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.enums.SysRoleEnum;
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.SysUserQuery;
import com.yunhai.userbase.model.sysuser.SysUserRequest;
import com.yunhai.userbase.model.sysuser.SysUserVo;
@@ -16,6 +18,11 @@ import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.ISysUserService;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import com.yunhai.userbase.model.sysuser.ExcelSysUser;
import com.yunhai.userbase.utils.EasyPoiUtils;
import com.yunhai.userbase.utils.ValidatorUtil;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
@@ -26,16 +33,28 @@ import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.*;
import org.springframework.web.multipart.MultipartFile;
import javax.persistence.criteria.Join;
import javax.persistence.criteria.JoinType;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import javax.persistence.criteria.Subquery;
import javax.transaction.Transactional;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletResponse;
/**
* @author chenx
@@ -73,6 +92,18 @@ public class SysUserService implements ISysUserService {
@Override
@Transactional
public void add(SysUserRequest request) {
// 单条新增走完整校验(含密码强度)
createUser(request, true);
}
/**
* 创建用户并写入组关联。
*
* @param enforcePasswordStrength 是否强制密码强度校验:
* - 单条新增 = true(前端已交互式校验);
* - 批量导入 = false(默认密码 Vzuk4Dp3 不含符号,按业务约定直接放行)。
*/
private void createUser(SysUserRequest request, boolean enforcePasswordStrength) {
if (sysUserRepository.countByAccount(request.getAccount()) > 0) {
throw new DException("账号已存在");
}
@@ -80,7 +111,9 @@ public class SysUserService implements ISysUserService {
throw new DException("所属分组不存在");
}
validateProfile(request);
String encodedPassword = validateAndEncodePassword(request.getPassword(), request.getConfirmPassword());
String encodedPassword = enforcePasswordStrength
? validateAndEncodePassword(request.getPassword(), request.getConfirmPassword())
: passwordEncoder.encode(request.getPassword());
SysUser entity = new SysUser();
entity.setAccount(request.getAccount());
@@ -89,6 +122,8 @@ public class SysUserService implements ISysUserService {
entity.setPhone(request.getPhone());
entity.setBirthday(request.getBirthday());
entity.setIdcard(request.getIdcard());
entity.setEmail(request.getEmail());
entity.setGender(request.getGender());
entity.setProfilePicture(request.getProfilePicture());
entity.setRoles(request.getRoles());
// 用户类型推导:超级管理员账号固定为 1;其余含 ADMIN(7) 角色 -> 2 学校管理员;默认 0 普通用户
@@ -126,6 +161,8 @@ public class SysUserService implements ISysUserService {
entity.setPhone(request.getPhone());
entity.setBirthday(request.getBirthday());
entity.setIdcard(request.getIdcard());
entity.setEmail(request.getEmail());
entity.setGender(request.getGender());
entity.setProfilePicture(request.getProfilePicture());
entity.setRoles(request.getRoles());
// 用户类型推导:仅当本次传入了角色时才重算,避免编辑时未传角色把 type 误重置为 0
@@ -154,6 +191,38 @@ public class SysUserService implements ISysUserService {
sysUserRepository.deleteById(id);
}
@Override
@Transactional
public void deleteFromGroup(Integer userId, Integer groupId) {
batchDeleteFromGroup(Collections.singletonList(userId), groupId);
}
@Override
@Transactional
public void batchDeleteFromGroup(List<Integer> userIds, Integer groupId) {
if (userIds == null || userIds.isEmpty()) {
throw new DException("待删除用户不能为空");
}
if (groupId == null) {
throw new DException("组ID不能为空");
}
List<SysUser> users = sysUserRepository.findAllById(userIds);
if (users.size() != userIds.size()) {
throw new DException("存在无效用户");
}
for (Integer userId : userIds) {
// 删除当前组关联
sysUserGroupRelRepository.deleteByUserIdAndGroupId(userId, groupId);
// 若用户已无其它组关联,则级联删除用户本身及其角色绑定
if (sysUserGroupRelRepository.countByUserId(userId) == 0) {
sysUserAppRoleRelRepository.deleteByUserIdBulk(userId);
sysUserRepository.deleteById(userId);
}
}
}
@Override
@Transactional
public void changeStatus(Integer id, boolean disabled) {
@@ -164,29 +233,396 @@ public class SysUserService implements ISysUserService {
sysUserRepository.save(entity);
}
@Override
@Transactional
public void batchChangeStatus(List<Integer> userIds, boolean disabled) {
if (userIds == null || userIds.isEmpty()) {
throw new DException("待设置用户不能为空");
}
List<SysUser> users = sysUserRepository.findAllById(userIds);
if (users.size() != userIds.size()) {
throw new DException("存在无效用户");
}
StatusEnum targetStatus = disabled ? StatusEnum.DISABLED : StatusEnum.NORMAL;
for (SysUser user : users) {
user.setDisabled(targetStatus);
}
sysUserRepository.saveAll(users);
}
@Override
@Transactional
public void changeSortNo(Integer userId, Integer groupId, Integer sortNo) {
if (!sysUserRepository.existsById(userId)) {
throw new DException("系统用户不存在");
}
// 排序号按组维度存储,必须落到具体的 (userId, groupId) 关联行
SysUserGroupRel rel = sysUserGroupRelRepository.findByUserIdAndGroupId(userId, groupId)
.orElseThrow(() -> new DException("用户不在该组,无法设置排序号"));
rel.setSortNo(sortNo);
sysUserGroupRelRepository.save(rel);
}
@Override
public Page<SysUserVo> list(SysUserQuery query, Pageable pageable) {
Specification<SysUser> spec = buildSpecification(query);
return sysUserRepository.findAll(spec, pageable).map(this::convertToVo);
Page<SysUser> page = sysUserRepository.findAll(spec, pageable);
// 把当前组维度的 sortNo 带到 Vo(仅在具体组上下文有意义;根节点/未选组时为 null)
if (query.getGroupId() != null) {
Map<Integer, Integer> sortNoMap = resolveUserSortNoInGroup(
page.getContent().stream().map(SysUser::getId).collect(Collectors.toList()),
query.getGroupId());
return page.map(u -> convertToVo(u, sortNoMap.get(u.getId())));
}
return page.map(u -> convertToVo(u, null));
}
@Override
public List<Integer> findGroupIdsByUserId(Integer userId) {
return sysUserGroupRelRepository.findByUserId(userId).stream()
.map(SysUserGroupRel::getGroupId)
.collect(Collectors.toList());
}
@Override
@Transactional
public void changeGroups(List<Integer> userIds, List<Integer> groupIds) {
if (userIds == null || userIds.isEmpty()) {
throw new DException("待调整用户不能为空");
}
if (groupIds == null || groupIds.isEmpty()) {
throw new DException("请至少保留一个组");
}
List<SysUser> users = sysUserRepository.findAllById(userIds);
if (users.size() != userIds.size()) {
throw new DException("存在无效用户");
}
List<SysGroup> groups = sysGroupRepository.findAllById(groupIds);
if (groups.size() != groupIds.size()) {
throw new DException("存在无效分组");
}
LocalDateTime now = LocalDateTime.now();
for (Integer userId : userIds) {
sysUserGroupRelRepository.deleteByUserId(userId);
List<SysUserGroupRel> rels = new ArrayList<>(groupIds.size());
for (int i = 0; i < groupIds.size(); i++) {
SysUserGroupRel rel = new SysUserGroupRel();
rel.setUserId(userId);
rel.setGroupId(groupIds.get(i));
rel.setIsMain(i == 0 ? IS_MAIN : 0);
rel.setCreateTime(now);
rels.add(rel);
}
sysUserGroupRelRepository.saveAll(rels);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public BatchImportResult batchImport(MultipartFile file) {
BatchImportResult result = new BatchImportResult();
if (file == null || file.isEmpty()) {
throw new DException("上传文件不能为空");
}
Workbook workbook = null;
try {
workbook = WorkbookFactory.create(file.getInputStream());
Sheet sheet = workbook.getSheetAt(0);
int lastRowNum = sheet.getLastRowNum();
for (int i = 1; i <= lastRowNum; i++) {
Row row = sheet.getRow(i);
if (row == null || isBlankRow(row)) {
continue;
}
try {
SysUserRequest request = parseImportRow(row);
createUser(request, false);
result.addSuccess();
} catch (DException e) {
result.addFail(i + 1, e.getMessage());
} catch (Exception e) {
result.addFail(i + 1, "系统异常:" + e.getMessage());
}
}
// 仅统计实际参与解析的数据行(跳过空行),使 总数 = 成功 + 失败
result.setTotal(result.getSuccess() + result.getFail());
} catch (Exception e) {
throw new DException("文件读取失败:" + e.getMessage());
} finally {
if (workbook != null) {
try {
workbook.close();
} catch (IOException ignored) {
// 关闭资源失败不阻断主流程
}
}
}
return result;
}
// ===================== 导入模板下载 =====================
/**
* 导入/导出共用的列定义(顺序即 Excel 列顺序)。<br>
* 导入模板生成与(后续)导出复用同一套,避免两边列顺序/文案漂移。
*/
private static final List<ImportColumnDef> IMPORT_COLUMN_DEFS = Arrays.asList(
new ImportColumnDef("groupName", "组名\n(不必填,供与id对应参考用)", 36, false, null),
new ImportColumnDef("groupId", "组id(必须)", 14, true, null),
new ImportColumnDef("account", "用户名(必须)", 20, true, null),
new ImportColumnDef("password", "密码\n(可选,\n默认Vzuk4Dp3)", 18, false, ""),
new ImportColumnDef("realName", "姓名(必须)", 16, true, null),
new ImportColumnDef("idcard", "身份证号(可选)", 22, false, null),
new ImportColumnDef("phone", "手机号(可选)", 16, false, null),
new ImportColumnDef("email", "email(可选)", 24, false, null),
new ImportColumnDef("nature", "用户性质\n(可选,默认普通用户\n组管理员2,\n普通用户0,\n导入不接受别的代码)", 24, false, "0"),
new ImportColumnDef("identity", "用户身份\n(1教师 2教职工 3学生 4家长)", 24, false, null),
new ImportColumnDef("gender", "性别(可选)", 12, false, null),
new ImportColumnDef("birthday", "生日(yyyy-MM-dd)(可选)", 22, false, null)
);
/** 组织树虚拟根 parentId(与 SysGroupService 约定一致)。 */
private static final int ROOT_PARENT_ID = 0;
/**
* 下载用户导入模板:以指定组(含)为根,将其自身及所有子孙组平铺为数据行,
* 仅填充「组名(路径)」与「组id」两列,其余列为空供用户填写。<br>
* 生成真实 .xlsx(POI XSSF),可被后端 BatchImport 正确解析(HTML 表格伪装 .xls 无法被 POI 解析)。
*/
@Override
public void downloadImportTemplate(Integer groupId, HttpServletResponse response) throws IOException {
List<GroupPath> groups = buildImportGroupPaths(groupId);
XSSFWorkbook workbook = new XSSFWorkbook();
try {
XSSFSheet sheet = workbook.createSheet("用户导入模板");
// 表头样式:加粗、居中、自动换行、细边框
XSSFCellStyle headerStyle = workbook.createCellStyle();
XSSFFont headerFont = workbook.createFont();
headerFont.setBold(true);
headerStyle.setFont(headerFont);
headerStyle.setWrapText(true);
headerStyle.setVerticalAlignment(VerticalAlignment.CENTER);
headerStyle.setAlignment(HorizontalAlignment.CENTER);
headerStyle.setBorderTop(BorderStyle.THIN);
headerStyle.setBorderBottom(BorderStyle.THIN);
headerStyle.setBorderLeft(BorderStyle.THIN);
headerStyle.setBorderRight(BorderStyle.THIN);
XSSFRow headerRow = sheet.createRow(0);
for (int i = 0; i < IMPORT_COLUMN_DEFS.size(); i++) {
ImportColumnDef col = IMPORT_COLUMN_DEFS.get(i);
XSSFCell cell = headerRow.createCell(i);
cell.setCellValue(col.getTitle());
cell.setCellStyle(headerStyle);
sheet.setColumnWidth(i, col.getWidth() * 256);
}
headerRow.setHeightInPoints(64);
// 数据行:每个组一行,仅填 组名(路径) + 组id
int r = 1;
for (GroupPath g : groups) {
XSSFRow row = sheet.createRow(r++);
row.createCell(IDX_GROUP_NAME).setCellValue(g.getPath());
row.createCell(IDX_GROUP_ID).setCellValue(String.valueOf(g.getId()));
}
String filename = URLEncoder.encode("用户导入模板.xlsx", "UTF-8");
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition",
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + filename);
try (OutputStream out = response.getOutputStream()) {
workbook.write(out);
}
} finally {
workbook.close();
}
}
// ===================== 导出 =====================
/**
* 导出组下所有用户(含子组)为 Excel:与导入模板 {@code IMPORT_COLUMN_DEFS} 同构,末尾追加「用户id」列。<br>
* - 组名 / 组id:取每个用户实际所属的主组(isMain=1),与导入模板的「参考」列保持一致;<br>
* - 密码:数据库存的是 BCrypt 密文、不可逆,导出留空(再导入时退化为默认 Vzuk4Dp3);<br>
* - 用户性质 nature:取 type 的 key(0普通 / 1超级管理员 / 2学校管理员);<br>
* - 用户身份 identity:取 roles 数组第一个元素(系统角色枚举 key:1管理员 2老师 3校领导 4局领导 5学生 6家长 7工作号)。
*/
@Override
public void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException {
// 1. 解析组范围(含子孙)并查询范围内全部用户(不分页)
SysUserQuery query = new SysUserQuery();
query.setGroupId(groupId);
query.setWithSub(withSub);
query.setName(name);
List<SysUser> users = sysUserRepository.findAll(buildSpecification(query));
// 2. 批量取每个用户的主组(isMain=1),用于填充「组名 / 组id」参考列
List<Integer> userIds = users.stream().map(SysUser::getId).collect(Collectors.toList());
Map<Integer, Integer> userMainGroup = resolveUserMainGroup(userIds);
// 3. 批量取组名
Map<Integer, String> groupNameMap = sysGroupRepository.findAllById(userMainGroup.values())
.stream().collect(Collectors.toMap(SysGroup::getId, SysGroup::getName));
// 4. 组装导出行
List<ExcelSysUser> rows = new ArrayList<>(users.size());
for (SysUser u : users) {
ExcelSysUser row = new ExcelSysUser();
Integer gid = userMainGroup.get(u.getId());
row.setGroupId(gid == null ? "" : String.valueOf(gid));
row.setGroupName(gid != null ? groupNameMap.get(gid) : null);
row.setAccount(u.getAccount());
row.setPassword(""); // 密文不可逆,导出留空
row.setRealName(u.getRealName());
row.setIdcard(u.getIdcard());
row.setPhone(u.getPhone());
row.setEmail(u.getEmail());
row.setNature(u.getType() == null ? null : u.getType().getKey());
row.setIdentity(u.getRoles() == null || u.getRoles().isEmpty() ? null : u.getRoles().get(0));
row.setGender(u.getGender());
row.setBirthday(u.getBirthday() == null ? "" : u.getBirthday().toString());
row.setUserId(u.getId());
rows.add(row);
}
// 5. 写出(HSSF .xls,与 EasyPoiUtils.downLoadExcel 的 .xls 兜底一致)
ExportParams exportParams = new ExportParams("组用户导出", "组用户");
exportParams.setType(ExcelType.HSSF);
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, ExcelSysUser.class, rows);
EasyPoiUtils.downLoadExcel("组用户导出", response, workbook);
}
/**
* 批量解析 用户 -> 主组(groupId) 映射:优先取 isMain=1 的组,否则取第一个关联组。
*/
private Map<Integer, Integer> resolveUserMainGroup(List<Integer> userIds) {
Map<Integer, Integer> mainGroup = new HashMap<>();
if (userIds.isEmpty()) {
return mainGroup;
}
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserIdIn(userIds);
Map<Integer, List<SysUserGroupRel>> byUser = rels.stream()
.collect(Collectors.groupingBy(SysUserGroupRel::getUserId));
for (Map.Entry<Integer, List<SysUserGroupRel>> e : byUser.entrySet()) {
Integer gid = e.getValue().stream()
.filter(r -> r.getIsMain() != null && r.getIsMain() == IS_MAIN)
.map(SysUserGroupRel::getGroupId)
.findFirst()
.orElse(e.getValue().get(0).getGroupId());
mainGroup.put(e.getKey(), gid);
}
return mainGroup;
}
/**
* 收集导入模板所需的「组路径 + 组id」列表:以指定组为根做先序 DFS,
* 路径用「/」拼接(如 上海鸿育初级中学/教师组/校长室)。
*/
private List<GroupPath> buildImportGroupPaths(Integer groupId) {
List<SysGroup> all = sysGroupRepository.findAll();
Map<Integer, SysGroup> byId = new HashMap<>();
Map<Integer, List<SysGroup>> childrenMap = new HashMap<>();
for (SysGroup g : all) {
byId.put(g.getId(), g);
Integer key = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId();
childrenMap.computeIfAbsent(key, k -> new ArrayList<>()).add(g);
}
// 同层级按 sort 升序、再按 id 升序,保证模板顺序稳定且与树展示一致
Comparator<SysGroup> order = (a, b) -> {
int sa = a.getSort() == null ? 999 : a.getSort();
int sb = b.getSort() == null ? 999 : b.getSort();
if (sa != sb) {
return Integer.compare(sa, sb);
}
return Integer.compare(a.getId(), b.getId());
};
for (List<SysGroup> siblings : childrenMap.values()) {
siblings.sort(order);
}
List<GroupPath> result = new ArrayList<>();
List<SysGroup> roots;
if (groupId != null && byId.containsKey(groupId)) {
roots = Collections.singletonList(byId.get(groupId));
} else {
roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>());
}
for (SysGroup root : roots) {
dfsGroup(root, "", childrenMap, result);
}
return result;
}
private void dfsGroup(SysGroup node, String parentPath,
Map<Integer, List<SysGroup>> childrenMap, List<GroupPath> out) {
String path = parentPath.isEmpty() ? node.getName() : parentPath + "/" + node.getName();
GroupPath gp = new GroupPath();
gp.setId(node.getId());
gp.setPath(path);
out.add(gp);
List<SysGroup> children = childrenMap.get(node.getId());
if (children != null) {
for (SysGroup child : children) {
dfsGroup(child, path, childrenMap, out);
}
}
}
/** 导入/导出列定义(轻量 DTO)。 */
@lombok.Data
private static class ImportColumnDef {
private final String key;
private final String title;
private final int width;
private final boolean required;
private final String sample;
}
/** 模板所需的组路径节点。 */
@lombok.Data
private static class GroupPath {
private Integer id;
private String path;
}
// ===================== 私有工具 =====================
/**
* 组装用户列表查询条件:组范围(含子组)+ 关键字(账号/姓名/身份证模糊、手机号精确)+ 启用状态。<br>
* 排序按「是否选中具体组」分治:<br>
* - 选中具体组(groupId != null):JOIN 关联表,按组内 sort_no 正序(NULLS LAST),其次 user_id 正序;
* 含子组时同一用户可能命中多组,启用 distinct 去重(同时保证导出不去重重复行)。<br>
* - 根节点 / 未选组(groupId == null):不 JOIN 关联表,直接按主键 id 正序(聚簇索引顺序,无 filesort),
* 平台总用户量不影响查询性能。
*/
private Specification<SysUser> buildSpecification(SysUserQuery query) {
return (root, cbQuery, cb) -> {
List<Predicate> predicates = new ArrayList<>();
// 组范围过滤:用户ID 属于目标组集合下的关联记录
if (query.getGroupId() != null) {
// 具体组上下文:JOIN 关联表以按组内 sort_no 排序
List<Integer> groupIds = resolveGroupIds(query.getGroupId(), query.getWithSub());
Subquery<Integer> sub = cbQuery.subquery(Integer.class);
Root<SysUserGroupRel> relRoot = sub.from(SysUserGroupRel.class);
sub.select(relRoot.get("userId"));
sub.where(relRoot.get("groupId").in(groupIds));
predicates.add(root.get("id").in(sub));
Join<SysUser, SysUserGroupRel> rel = root.join("userGroupRelList", JoinType.INNER);
predicates.add(rel.get("groupId").in(groupIds));
cbQuery.distinct(true);
// 排序规则:已设排序号者排最前(NULLS LAST),其次 sort_no 升序,最后 user_id 升序兜底
// 注:Hibernate 5.6 的 AST 解析器不支持把 IS NULL 表达式直接放进 ORDER BY,
// 改用 CASE WHEN sort_no IS NULL THEN 1 ELSE 0 END(产出普通整数,语义等价 NULLS LAST)。
// rel.get("sortNo") 返回 Path<Object>,直接作为 Expression<?> 传入 orderBy 即可。
cbQuery.orderBy(
cb.asc(cb.selectCase().when(cb.isNull(rel.get("sortNo")), 1).otherwise(0)),
cb.asc(rel.get("sortNo")),
cb.asc(root.get("id"))
);
} else {
// 根节点 / 未选组:无组上下文,按主键 id 升序(聚簇索引顺序,不产生 filesort)
cbQuery.orderBy(cb.asc(root.get("id")));
}
// 关键字:账号/姓名/身份证模糊,手机号精确
@@ -208,6 +644,21 @@ public class SysUserService implements ISysUserService {
};
}
/**
* 批量取「用户在某组下的排序号」,用于列表 Vo 携带当前组维度 sortNo(仅具体组上下文调用)。
*/
private Map<Integer, Integer> resolveUserSortNoInGroup(List<Integer> userIds, Integer groupId) {
Map<Integer, Integer> sortNoMap = new HashMap<>();
if (userIds.isEmpty()) {
return sortNoMap;
}
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserIdInAndGroupId(userIds, groupId);
for (SysUserGroupRel rel : rels) {
sortNoMap.put(rel.getUserId(), rel.getSortNo());
}
return sortNoMap;
}
/** 依据 withSub 决定组范围:1=含所有子孙组,否则仅当前组。 */
private List<Integer> resolveGroupIds(Integer groupId, Integer withSub) {
if (withSub != null && withSub == 1) {
@@ -243,12 +694,18 @@ public class SysUserService implements ISysUserService {
}
private SysUserVo convertToVo(SysUser entity) {
return convertToVo(entity, null);
}
private SysUserVo convertToVo(SysUser entity, Integer sortNo) {
SysUserVo vo = new SysUserVo();
BeanUtils.copyProperties(entity, vo);
// disabled 为枚举,需要单独映射为 key(与 GroupTypeEnum 的 type 处理一致)
vo.setDisabled(entity.getDisabled() == null ? null : entity.getDisabled().getKey());
// type 为枚举,同样映射为 key
vo.setType(entity.getType() == null ? null : entity.getType().getKey());
// 当前组维度的排序号;根节点 / 未选组时为 null
vo.setSortNo(sortNo);
return vo;
}
@@ -276,4 +733,191 @@ public class SysUserService implements ISysUserService {
}
return statusEnum;
}
// ===================== 批量导入私有工具 =====================
private static final int IDX_GROUP_NAME = 0;
private static final int IDX_GROUP_ID = 1;
private static final int IDX_ACCOUNT = 2;
private static final int IDX_PASSWORD = 3;
private static final int IDX_REAL_NAME = 4;
private static final int IDX_IDCARD = 5;
private static final int IDX_PHONE = 6;
private static final int IDX_EMAIL = 7;
private static final int IDX_NATURE = 8;
private static final int IDX_IDENTITY = 9;
private static final int IDX_GENDER = 10;
private static final int IDX_BIRTHDAY = 11;
/**
* 解析 Excel 一行为 SysUserRequest。
* 列顺序与前端导入模板严格一致:
* 0组名 1组id 2用户名 3密码 4姓名 5身份证 6手机 7email 8用户性质 9用户身份 10性别 11生日。
*/
private SysUserRequest parseImportRow(Row row) {
SysUserRequest request = new SysUserRequest();
Integer groupId = parseInteger(getCellString(row, IDX_GROUP_ID));
if (groupId == null) {
throw new DException("组id不能为空");
}
if (!sysGroupRepository.existsById(groupId)) {
throw new DException("组id【" + groupId + "】不存在");
}
request.setGroupId(groupId);
String account = getCellString(row, IDX_ACCOUNT);
if (StringUtils.isBlank(account)) {
throw new DException("用户名不能为空");
}
request.setAccount(account.trim());
String password = getCellString(row, IDX_PASSWORD);
if (StringUtils.isBlank(password)) {
password = "Vzuk4Dp3";
}
request.setPassword(password);
request.setConfirmPassword(password);
String realName = getCellString(row, IDX_REAL_NAME);
if (StringUtils.isBlank(realName)) {
throw new DException("姓名不能为空");
}
request.setRealName(realName.trim());
request.setIdcard(getCellString(row, IDX_IDCARD));
request.setPhone(getCellString(row, IDX_PHONE));
request.setEmail(getCellString(row, IDX_EMAIL));
Integer nature = parseInteger(getCellString(row, IDX_NATURE));
Integer identity = parseInteger(getCellString(row, IDX_IDENTITY));
request.setRoles(buildImportRoles(nature, identity));
request.setGender(parseGender(getCellString(row, IDX_GENDER)));
request.setBirthday(parseBirthday(row.getCell(IDX_BIRTHDAY)));
validateProfile(request);
return request;
}
/**
* 根据模板中的用户性质与用户身份构建 roles 列表。
* - 用户性质:0普通用户(不加管理员),2组管理员(加 ADMIN=7);其他值视为非法。
* - 用户身份:1教师->TEACHER(1),2教职工->SCHOOL_LEADER(2),3学生->STUDENT(3),4家长->PARENT(4)。
*/
private List<Integer> buildImportRoles(Integer nature, Integer identity) {
Set<Integer> roleSet = new HashSet<>();
if (nature != null) {
if (nature == 2) {
roleSet.add(SysRoleEnum.ADMIN.getKey());
} else if (nature != 0) {
throw new DException("用户性质只能为 0 或 2");
}
}
if (identity != null) {
switch (identity) {
case 1:
roleSet.add(SysRoleEnum.TEACHER.getKey());
break;
case 2:
roleSet.add(SysRoleEnum.SCHOOL_LEADER.getKey());
break;
case 3:
roleSet.add(SysRoleEnum.STUDENT.getKey());
break;
case 4:
roleSet.add(SysRoleEnum.PARENT.getKey());
break;
default:
throw new DException("用户身份只能为 1/2/3/4");
}
}
return new ArrayList<>(roleSet);
}
private Integer parseGender(String value) {
if (StringUtils.isBlank(value)) {
return null;
}
String v = value.trim();
if ("1".equals(v) || "男".equals(v)) {
return 1;
}
if ("0".equals(v) || "女".equals(v)) {
return 0;
}
throw new DException("性别只能为 0/女 或 1/男");
}
private LocalDate parseBirthday(Cell cell) {
if (cell == null) {
return null;
}
if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
return cell.getDateCellValue().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
}
String value = getCellString(cell);
if (StringUtils.isBlank(value)) {
return null;
}
try {
return LocalDate.parse(value.trim());
} catch (Exception e) {
throw new DException("生日格式错误,请使用 yyyy-MM-dd");
}
}
private boolean isBlankRow(Row row) {
for (int i = 0; i <= IDX_BIRTHDAY; i++) {
if (StringUtils.isNotBlank(getCellString(row, i))) {
return false;
}
}
return true;
}
private String getCellString(Row row, int cellIndex) {
Cell cell = row.getCell(cellIndex);
if (cell == null) {
return "";
}
return getCellString(cell);
}
private String getCellString(Cell cell) {
switch (cell.getCellType()) {
case STRING:
return StringUtils.trim(cell.getStringCellValue());
case NUMERIC:
if (DateUtil.isCellDateFormatted(cell)) {
return cell.getDateCellValue().toInstant().atZone(ZoneId.systemDefault()).toLocalDate().toString();
}
double numeric = cell.getNumericCellValue();
if (numeric == Math.floor(numeric)) {
return String.valueOf((long) numeric);
}
return String.valueOf(numeric);
case BOOLEAN:
return String.valueOf(cell.getBooleanCellValue());
case FORMULA:
try {
return String.valueOf(cell.getNumericCellValue());
} catch (Exception e) {
return cell.getStringCellValue();
}
default:
return "";
}
}
private Integer parseInteger(String value) {
if (StringUtils.isBlank(value)) {
return null;
}
try {
return Integer.valueOf(value.trim());
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -1,4 +1,4 @@
package com.yunhai.userbase.service;
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
@@ -0,0 +1,48 @@
package com.yunhai.userbase.task;
import com.yunhai.userbase.service.impl.SignKeyCacheService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 签名密钥共享缓存的对账/同步定时任务。<br>
* <br>
* 每天凌晨 3:05 执行(让 {@link OperationLogCleanupTask} 的 3:00 先跑完,避免同一时间打库):
* <ol>
* <li>把 application_info 表全量同步到共享 Redis;</li>
* <li>清理 DB 已删除、但 Redis 残留的旧应用 key;</li>
* <li>清空本机 AppSecretProvider / LocalAppIdentityProvider 的本地密钥缓存,
* 避免长驻进程本地缓存和 DB 不一致。</li>
* </ol>
* <p>
* 注:应用日常新增/删除/重置密钥会通过
* {@link com.yunhai.userbase.service.impl.ApplicationInfoService} 的事务事件
* 实时触发 {@link SignKeyCacheService} 的 syncOne/removeOne,
* 本任务仅作为「每日对账兜底」,防止 Redis 故障或人工介入导致的偏差。
*/
@Component
public class SignKeyCacheDailyTask {
private static final Logger log = LoggerFactory.getLogger(SignKeyCacheDailyTask.class);
@Autowired
private SignKeyCacheService signKeyCacheService;
/**
* 每天凌晨 3:05 执行,错开 OperationLogCleanupTask 的 3:00。
*/
@Scheduled(cron = "0 5 3 * * ?")
@Async
public void syncAll() {
try {
int n = signKeyCacheService.refreshAll();
log.info("[SignKeyCache] daily sync-all done, synced apps={}", n);
} catch (Exception e) {
log.warn("[SignKeyCache] daily sync-all failed: {}", e.getMessage());
}
}
}
+6
View File
@@ -68,6 +68,12 @@ yh:
mobi-api-pattern: /mobiapi/*
app-auth:
enabled: true
sign-key-internal:
enabled: true
ips:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.10.0/200
ADMIN_TAG_ID: 290
@@ -0,0 +1,5 @@
-- 为 sys_user 表新增 email、gender 字段,支撑批量导入模板中的邮箱与性别列
-- 执行方式:由 DBA 手动在对应环境执行,AI 不代跑
ALTER TABLE sys_user
ADD COLUMN email VARCHAR(255) NULL COMMENT '邮箱',
ADD COLUMN gender INT NULL COMMENT '性别:0女 1男';
@@ -0,0 +1,11 @@
-- 为 sys_user_group_rel 关联表新增 sort_no 字段,用于「按组维度」对用户排序
-- 默认 null(未设置)→ 列表按 user_id 升序兜底;1~999 正整数,越小越靠前。
-- 执行方式:由 DBA 在 dev / prod 各自手工执行,AI 不代跑(本地 dev 靠 ddl-auto=update 自动加列)。
-- 注意:覆盖索引 (group_id, sort_no, user_id) 让「具体组 + 排序」的 JOIN 走索引、减少回表;
-- 表达式排序 (sort_no IS NULL) 无法完全命中索引,但仍能借助 group_id 前缀快速圈定组范围。
ALTER TABLE sys_user_group_rel
ADD COLUMN sort_no INT NULL COMMENT '组内排序号,越小越靠前;null 按 user_id 兜底';
ALTER TABLE sys_user_group_rel
ADD INDEX idx_group_sort_user (group_id, sort_no, user_id);