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