From a5ced786b42275dddbc93cf0dd3d638f34dfcb46 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 12 Aug 2026 19:16:22 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=84=E7=94=A8=E6=88=B7=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E4=BF=AE=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BoaUserContextInterceptor.java | 2 +- .../userbase/config/AppSecretConfig.java | 20 +- .../OpenApiAuthEnforceConfiguration.java | 47 +- .../config/UserBaseAppSecretProvider.java | 25 +- .../UserBaseLocalAppIdentityProvider.java | 8 + .../config/UserBaseUserDetailsProvider.java | 21 +- .../controller/api/AppSettingsController.java | 2 +- .../controller/api/SysUserController.java | 94 ++- .../controller/oapi/UserController.java | 2 +- .../com/yunhai/userbase/entity/SysUser.java | 4 + .../userbase/entity/SysUserGroupRel.java | 4 + .../ApplicationInfoCacheChangedEvent.java | 45 ++ .../model/sysuser/BatchImportResult.java | 47 ++ .../model/sysuser/ChangeGroupsRequest.java | 23 + .../userbase/model/sysuser/ExcelSysUser.java | 53 ++ .../model/sysuser/SysUserRequest.java | 2 + .../userbase/model/sysuser/SysUserVo.java | 4 + .../repository/SysUserGroupRelRepository.java | 9 + .../userbase/service/ISysUserService.java | 85 +++ .../userbase/service/SignKeyCacheService.java | 151 ---- .../service/impl/ApplicationInfoService.java | 23 +- .../service/impl/SignKeyCacheService.java | 275 +++++++ .../userbase/service/impl/SysUserService.java | 672 +++++++++++++++++- .../service/{ => impl}/UserCenterService.java | 2 +- .../userbase/task/SignKeyCacheDailyTask.java | 48 ++ src/main/resources/application.yml | 6 + .../sql/sys_user_add_email_gender.sql | 5 + .../sql/sys_user_group_rel_add_sort_no.sql | 11 + 28 files changed, 1488 insertions(+), 202 deletions(-) create mode 100644 src/main/java/com/yunhai/userbase/event/ApplicationInfoCacheChangedEvent.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/BatchImportResult.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/ChangeGroupsRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/sysuser/ExcelSysUser.java delete mode 100644 src/main/java/com/yunhai/userbase/service/SignKeyCacheService.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java rename src/main/java/com/yunhai/userbase/service/{ => impl}/UserCenterService.java (99%) create mode 100644 src/main/java/com/yunhai/userbase/task/SignKeyCacheDailyTask.java create mode 100644 src/main/resources/sql/sys_user_add_email_gender.sql create mode 100644 src/main/resources/sql/sys_user_group_rel_add_sort_no.sql diff --git a/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java b/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java index dc2beb4..c06b8e0 100644 --- a/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java +++ b/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java @@ -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; diff --git a/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java b/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java index 08dcd00..5093768 100644 --- a/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java +++ b/src/main/java/com/yunhai/userbase/config/AppSecretConfig.java @@ -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:*} 的预填充时机,且能验签所有已颁发应用。

* 同时注册 UserBase 自身作为出站调用方的身份:固定 appKey=100000,密钥落库,不再依赖 yml - * 配置 app-key/app-secret。 + * 配置 app-key/app-secret。
+ * 注意:两个 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); } diff --git a/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java b/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java index e540691..72917c5 100644 --- a/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java +++ b/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java @@ -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; /** - * 强制开放接口必须带应用签名。
+ * 强制开放接口必须带应用签名 + UserBase 自身 CAS 认证兜底的 UserDetailsProvider。
*
* yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流), * 但 UserBase 作为用户中心的开放接口({/open/api/**})与内部密钥兜底端点({/oapi/internal/**}) * 绝不允许匿名访问。本过滤器在 {@code AppAuthFilter} 之后运行,校验其写入的请求属性: * 已验签(属性存在)则放行,否则返回 401。
+ * 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。
*
- * 顺序须晚于 {@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() { FilterRegistrationBean 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( + SignKeyInternalProperties properties) { + FilterRegistrationBean bean = new FilterRegistrationBean<>(); + bean.setFilter(new SignKeyInternalIpFilter(properties)); + bean.addUrlPatterns("/oapi/internal/*"); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 9); + return bean; + } + + /** + * UserBase 本地 CAS 登录用户提供者:按登录名/手机号查 UserBase 自身库返回 BoaUser。
+ * 加 {@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) diff --git a/src/main/java/com/yunhai/userbase/config/UserBaseAppSecretProvider.java b/src/main/java/com/yunhai/userbase/config/UserBaseAppSecretProvider.java index 46bcaed..3f07052 100644 --- a/src/main/java/com/yunhai/userbase/config/UserBaseAppSecretProvider.java +++ b/src/main/java/com/yunhai/userbase/config/UserBaseAppSecretProvider.java @@ -9,12 +9,15 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** - * UserBase 作为「应用密钥权威源」的验签密钥提供方。

+ * UserBase 作为「应用密钥权威源」的验签密钥提供方。
+ *
* 解析优先级:
* 1) 本库 application_info —— 覆盖全部「已颁发」应用,新增/重置密钥后即时生效;
* 2) 配置式受信应用 {@code yh.app-auth.trusted-apps} —— 平台自身默认密钥(如 应用中心 100616) - * 兜底,使其即便尚未落库也可验签,避免「默认 appKey 未建行」导致验签失败。

- * 带 60s 本地 TTL 缓存,避免每次验签直打库(密钥变更最长 60s 后生效)。

+ * 兜底,使其即便尚未落库也可验签,避免「默认 appKey 未建行」导致验签失败。
+ *
+ * 带 60s 本地 TTL 缓存,避免每次验签直打库(密钥变更最长 60s 后生效)。
+ *
* 这样 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()); diff --git a/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java b/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java index b027951..1c553ac 100644 --- a/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java +++ b/src/main/java/com/yunhai/userbase/config/UserBaseLocalAppIdentityProvider.java @@ -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; diff --git a/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java b/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java index 96e2a65..6de8f35 100644 --- a/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java +++ b/src/main/java/com/yunhai/userbase/config/UserBaseUserDetailsProvider.java @@ -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}。

- * 以 {@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) { diff --git a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java index 2c57439..b693763 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java @@ -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; diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java index 4dfc1d8..bb31fae 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java @@ -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 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 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 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 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); } } diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java index 7fddf7e..dacc9b4 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java @@ -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; diff --git a/src/main/java/com/yunhai/userbase/entity/SysUser.java b/src/main/java/com/yunhai/userbase/entity/SysUser.java index 1103b13..6a1c6ed 100644 --- a/src/main/java/com/yunhai/userbase/entity/SysUser.java +++ b/src/main/java/com/yunhai/userbase/entity/SysUser.java @@ -40,6 +40,10 @@ public class SysUser extends BaseEntity { private String idcard; + private String email; + + private Integer gender; + //头像 private String profilePicture; diff --git a/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java b/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java index 4e483b3..de68291 100644 --- a/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java +++ b/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java @@ -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; // 关联用户,仅查询,不维护外键 diff --git a/src/main/java/com/yunhai/userbase/event/ApplicationInfoCacheChangedEvent.java b/src/main/java/com/yunhai/userbase/event/ApplicationInfoCacheChangedEvent.java new file mode 100644 index 0000000..ac0aabf --- /dev/null +++ b/src/main/java/com/yunhai/userbase/event/ApplicationInfoCacheChangedEvent.java @@ -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; } +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/BatchImportResult.java b/src/main/java/com/yunhai/userbase/model/sysuser/BatchImportResult.java new file mode 100644 index 0000000..f479d3a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/BatchImportResult.java @@ -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 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)); + } +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/ChangeGroupsRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/ChangeGroupsRequest.java new file mode 100644 index 0000000..5c15b38 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/ChangeGroupsRequest.java @@ -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 userIds; + + /** 目标组ID列表(覆盖式) */ + @NotEmpty(message = "请至少保留一个组") + private List groupIds; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/ExcelSysUser.java b/src/main/java/com/yunhai/userbase/model/sysuser/ExcelSysUser.java new file mode 100644 index 0000000..675e952 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/sysuser/ExcelSysUser.java @@ -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; +} diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java index d94d80b..ccdf606 100644 --- a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java +++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserRequest.java @@ -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 roles; /** 启用状态:对应 StatusEnum 的 key(0=正常/未禁用,1=禁用);不传则默认启用 */ diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java index 73c0206..3dee203 100644 --- a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java +++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java @@ -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 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; diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java index 8152e53..0a53aff 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java @@ -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 findByUserId(Integer userId); + List findByUserIdIn(List userIds); + + List findByUserIdInAndGroupId(List userIds, Integer groupId); + + Optional findByUserIdAndGroupId(Integer userId, Integer groupId); + List findByGroupId(Integer groupId); void deleteByUserId(Integer userId); + + void deleteByUserIdAndGroupId(Integer userId, Integer groupId); } diff --git a/src/main/java/com/yunhai/userbase/service/ISysUserService.java b/src/main/java/com/yunhai/userbase/service/ISysUserService.java index d76f50a..ff40624 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysUserService.java @@ -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 userIds, Integer groupId); + /** * 启用/禁用用户。
* @@ -30,6 +52,14 @@ public interface ISysUserService { */ void changeStatus(Integer id, boolean disabled); + /** + * 批量启用/禁用用户。 + * + * @param userIds 用户ID列表 + * @param disabled true=禁用,false=启用 + */ + void batchChangeStatus(List userIds, boolean disabled); + /** * 分页查询组下用户。
* 依据 query.groupId 与 query.withSub 决定组范围,query.name 做多字段匹配。
@@ -39,4 +69,59 @@ public interface ISysUserService { * @return 用户分页 */ Page list(SysUserQuery query, Pageable pageable); + + /** + * 查询指定用户当前所属的所有组ID。 + * + * @param userId 用户ID + * @return 组ID列表 + */ + List findGroupIdsByUserId(Integer userId); + + /** + * 批量调整用户所属组:覆盖式更新 sys_user_group_rel。
+ * 同一批用户统一设置为目标组列表,第一个组设为主归属,其余为兼职。 + * + * @param userIds 待调整用户ID列表 + * @param groupIds 目标组ID列表 + */ + void changeGroups(List userIds, List groupIds); + + /** + * 调整用户在某组内的排序号(sort_no)。
+ * 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」列。
+ * 组名 / 组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; } diff --git a/src/main/java/com/yunhai/userbase/service/SignKeyCacheService.java b/src/main/java/com/yunhai/userbase/service/SignKeyCacheService.java deleted file mode 100644 index f5f259e..0000000 --- a/src/main/java/com/yunhai/userbase/service/SignKeyCacheService.java +++ /dev/null @@ -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 桥接服务。

- * 职责:把 {@code application_info} 表的 appKey/appSecret 同步到 Redis,供各业务服务的 - * common-sign-starter(SignKeyResolver)读取做验签——业务服务无需直连 UserBase 数据库。

- * 约定(读侧 starter 必须严格一致):
- * 1) key 前缀 {@code sign:app:},后接 appKey,例 {@code sign:app:100616};
- * 2) value 为 fastjson 序列化的 JSON 字符串:{@code {"appKey":"100616","secret":"...","enabled":true}};
- * 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。
- * 用于 Redis 数据丢失 / 不一致后的手动恢复,以及首次部署预热。 - * - * @return 实际写入的密钥条数 - */ - public int refreshAll() { - List all = applicationInfoRepository.findAll(); - Set 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 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; - } - } -} diff --git a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java index 0c6055d..0bedc80 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java @@ -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 diff --git a/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java b/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java new file mode 100644 index 0000000..3463459 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java @@ -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 桥接服务。
+ *
+ * 职责:把 {@code application_info} 表的 appKey/appSecret 同步到 Redis,供各业务服务的 + * common-sign-starter(SignKeyResolver)读取做验签——业务服务无需直连 UserBase 数据库。
+ *
+ * 约定(读侧 starter 必须严格一致):
+ * 1) key 前缀 {@code sign:app:},后接 appKey,例 {@code sign:app:100616};
+ * 2) value 为 fastjson 序列化的 JSON + * 字符串:{@code {"appKey":"100616","secret":"...","enabled":true}};
+ * 3) 不设过期时间,依赖 Redis 持久化(AOF/RDB)保证重启不丢;删除应用时直接删 key(等价于禁用)。
+ *
+ * 变更同步说明(IDEA 灰色=正常):本类的 {@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(新增 / 编辑 / 重置密钥时调用)。 + *

+ * 同步前主动失效本机的本地缓存,保证密钥变更后本机入站验签 / 出站签名 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。
+ * 用于 Redis 数据丢失 / 不一致后的手动恢复,以及首次部署预热 + 每日定时对账。 + * + * @return 实际写入的密钥条数 + */ + public int refreshAll() { + List all = applicationInfoRepository.findAll(); + Set 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 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 方案兜底:每日对账定时任务已迁移到 + * {@link com.yunhai.userbase.task.SignKeyCacheDailyTask#syncAll()}。 + * 保持 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 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 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 providers = applicationContext + .getBeansOfType(UserBaseAppSecretProvider.class); + if (providers != null && !providers.isEmpty()) { + for (UserBaseAppSecretProvider p : providers.values()) { + p.invalidateAll(); + } + } + } catch (Exception ignored) { + } + try { + Map 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; + } + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index 18a8221..5c1c3c5 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -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 userIds, Integer groupId) { + if (userIds == null || userIds.isEmpty()) { + throw new DException("待删除用户不能为空"); + } + if (groupId == null) { + throw new DException("组ID不能为空"); + } + + List 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 userIds, boolean disabled) { + if (userIds == null || userIds.isEmpty()) { + throw new DException("待设置用户不能为空"); + } + List 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 list(SysUserQuery query, Pageable pageable) { Specification spec = buildSpecification(query); - return sysUserRepository.findAll(spec, pageable).map(this::convertToVo); + Page page = sysUserRepository.findAll(spec, pageable); + // 把当前组维度的 sortNo 带到 Vo(仅在具体组上下文有意义;根节点/未选组时为 null) + if (query.getGroupId() != null) { + Map 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 findGroupIdsByUserId(Integer userId) { + return sysUserGroupRelRepository.findByUserId(userId).stream() + .map(SysUserGroupRel::getGroupId) + .collect(Collectors.toList()); + } + + @Override + @Transactional + public void changeGroups(List userIds, List groupIds) { + if (userIds == null || userIds.isEmpty()) { + throw new DException("待调整用户不能为空"); + } + if (groupIds == null || groupIds.isEmpty()) { + throw new DException("请至少保留一个组"); + } + + List users = sysUserRepository.findAllById(userIds); + if (users.size() != userIds.size()) { + throw new DException("存在无效用户"); + } + List groups = sysGroupRepository.findAllById(groupIds); + if (groups.size() != groupIds.size()) { + throw new DException("存在无效分组"); + } + + LocalDateTime now = LocalDateTime.now(); + for (Integer userId : userIds) { + sysUserGroupRelRepository.deleteByUserId(userId); + List 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 列顺序)。
+ * 导入模板生成与(后续)导出复用同一套,避免两边列顺序/文案漂移。 + */ + private static final List 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」两列,其余列为空供用户填写。
+ * 生成真实 .xlsx(POI XSSF),可被后端 BatchImport 正确解析(HTML 表格伪装 .xls 无法被 POI 解析)。 + */ + @Override + public void downloadImportTemplate(Integer groupId, HttpServletResponse response) throws IOException { + List 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」列。
+ * - 组名 / 组id:取每个用户实际所属的主组(isMain=1),与导入模板的「参考」列保持一致;
+ * - 密码:数据库存的是 BCrypt 密文、不可逆,导出留空(再导入时退化为默认 Vzuk4Dp3);
+ * - 用户性质 nature:取 type 的 key(0普通 / 1超级管理员 / 2学校管理员);
+ * - 用户身份 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 users = sysUserRepository.findAll(buildSpecification(query)); + + // 2. 批量取每个用户的主组(isMain=1),用于填充「组名 / 组id」参考列 + List userIds = users.stream().map(SysUser::getId).collect(Collectors.toList()); + Map userMainGroup = resolveUserMainGroup(userIds); + + // 3. 批量取组名 + Map groupNameMap = sysGroupRepository.findAllById(userMainGroup.values()) + .stream().collect(Collectors.toMap(SysGroup::getId, SysGroup::getName)); + + // 4. 组装导出行 + List 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 resolveUserMainGroup(List userIds) { + Map mainGroup = new HashMap<>(); + if (userIds.isEmpty()) { + return mainGroup; + } + List rels = sysUserGroupRelRepository.findByUserIdIn(userIds); + Map> byUser = rels.stream() + .collect(Collectors.groupingBy(SysUserGroupRel::getUserId)); + for (Map.Entry> 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 buildImportGroupPaths(Integer groupId) { + List all = sysGroupRepository.findAll(); + Map byId = new HashMap<>(); + Map> 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 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 siblings : childrenMap.values()) { + siblings.sort(order); + } + + List result = new ArrayList<>(); + List 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> childrenMap, List 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 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; } // ===================== 私有工具 ===================== /** * 组装用户列表查询条件:组范围(含子组)+ 关键字(账号/姓名/身份证模糊、手机号精确)+ 启用状态。
+ * 排序按「是否选中具体组」分治:
+ * - 选中具体组(groupId != null):JOIN 关联表,按组内 sort_no 正序(NULLS LAST),其次 user_id 正序; + * 含子组时同一用户可能命中多组,启用 distinct 去重(同时保证导出不去重重复行)。
+ * - 根节点 / 未选组(groupId == null):不 JOIN 关联表,直接按主键 id 正序(聚簇索引顺序,无 filesort), + * 平台总用户量不影响查询性能。 */ private Specification buildSpecification(SysUserQuery query) { return (root, cbQuery, cb) -> { List predicates = new ArrayList<>(); - // 组范围过滤:用户ID 属于目标组集合下的关联记录 if (query.getGroupId() != null) { + // 具体组上下文:JOIN 关联表以按组内 sort_no 排序 List groupIds = resolveGroupIds(query.getGroupId(), query.getWithSub()); - Subquery sub = cbQuery.subquery(Integer.class); - Root 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 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,直接作为 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 resolveUserSortNoInGroup(List userIds, Integer groupId) { + Map sortNoMap = new HashMap<>(); + if (userIds.isEmpty()) { + return sortNoMap; + } + List rels = sysUserGroupRelRepository.findByUserIdInAndGroupId(userIds, groupId); + for (SysUserGroupRel rel : rels) { + sortNoMap.put(rel.getUserId(), rel.getSortNo()); + } + return sortNoMap; + } + /** 依据 withSub 决定组范围:1=含所有子孙组,否则仅当前组。 */ private List 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 buildImportRoles(Integer nature, Integer identity) { + Set 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; + } + } } diff --git a/src/main/java/com/yunhai/userbase/service/UserCenterService.java b/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java similarity index 99% rename from src/main/java/com/yunhai/userbase/service/UserCenterService.java rename to src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java index 1bc177c..cea7c2a 100644 --- a/src/main/java/com/yunhai/userbase/service/UserCenterService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java @@ -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; diff --git a/src/main/java/com/yunhai/userbase/task/SignKeyCacheDailyTask.java b/src/main/java/com/yunhai/userbase/task/SignKeyCacheDailyTask.java new file mode 100644 index 0000000..ccaf22f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/task/SignKeyCacheDailyTask.java @@ -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; + +/** + * 签名密钥共享缓存的对账/同步定时任务。
+ *
+ * 每天凌晨 3:05 执行(让 {@link OperationLogCleanupTask} 的 3:00 先跑完,避免同一时间打库): + *
    + *
  1. 把 application_info 表全量同步到共享 Redis;
  2. + *
  3. 清理 DB 已删除、但 Redis 残留的旧应用 key;
  4. + *
  5. 清空本机 AppSecretProvider / LocalAppIdentityProvider 的本地密钥缓存, + * 避免长驻进程本地缓存和 DB 不一致。
  6. + *
+ *

+ * 注:应用日常新增/删除/重置密钥会通过 + * {@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()); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f56c5c1..a804672 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/sql/sys_user_add_email_gender.sql b/src/main/resources/sql/sys_user_add_email_gender.sql new file mode 100644 index 0000000..7217d43 --- /dev/null +++ b/src/main/resources/sql/sys_user_add_email_gender.sql @@ -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男'; diff --git a/src/main/resources/sql/sys_user_group_rel_add_sort_no.sql b/src/main/resources/sql/sys_user_group_rel_add_sort_no.sql new file mode 100644 index 0000000..ae8b64a --- /dev/null +++ b/src/main/resources/sql/sys_user_group_rel_add_sort_no.sql @@ -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);