This commit is contained in:
@@ -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
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user