This commit is contained in:
@@ -11,17 +11,18 @@ import com.yunhai.userbase.repository.SysGroupAppRelRepository;
|
||||
import com.yunhai.userbase.repository.SysGroupModulePermRepository;
|
||||
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
|
||||
import com.yunhai.userbase.service.IApplicationInfoService;
|
||||
import com.yunhai.userbase.service.SignKeyCacheService;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.CachePut;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoVo;
|
||||
import com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -38,7 +39,6 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
|
||||
@Service
|
||||
public class ApplicationInfoService implements IApplicationInfoService {
|
||||
|
||||
@@ -66,6 +66,9 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
||||
@Autowired
|
||||
private SignKeyCacheService signKeyCacheService;
|
||||
|
||||
@Autowired
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
/**
|
||||
* 查询缓存:sync = true 防击穿;底层优先 Redis,Redis 故障时自动降级 Caffeine。
|
||||
*/
|
||||
@@ -81,7 +84,9 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
||||
@Override
|
||||
@CachePut(value = "appInfo", key = "#info.id")
|
||||
public ApplicationInfo save(ApplicationInfo info) {
|
||||
return applicationInfoRepository.save(info);
|
||||
ApplicationInfo saved = applicationInfoRepository.save(info);
|
||||
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +95,11 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
||||
@Override
|
||||
@CacheEvict(value = "appInfo", key = "#id")
|
||||
public void deleteById(Integer id) {
|
||||
ApplicationInfo existing = applicationInfoRepository.findById(id).orElse(null);
|
||||
applicationInfoRepository.deleteById(id);
|
||||
if (existing != null && existing.getAppKey() != null) {
|
||||
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.delete(this, existing.getAppKey()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -111,7 +120,7 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
||||
// appSecret:32 位 UUID(去除连字符)
|
||||
entity.setAppSecret(UUID.randomUUID().toString().replace("-", ""));
|
||||
ApplicationInfo saved = applicationInfoRepository.save(entity);
|
||||
signKeyCacheService.syncOne(saved);
|
||||
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -127,7 +136,7 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
||||
.orElseThrow(() -> new RuntimeException("应用不存在: " + id));
|
||||
BeanUtils.copyProperties(request, entity, "id", "appKey", "appSecret");
|
||||
ApplicationInfo saved = applicationInfoRepository.save(entity);
|
||||
signKeyCacheService.syncOne(saved);
|
||||
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -164,7 +173,7 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
||||
// 先删模块(整棵子树随 appId 一并清除),再删应用,避免残留孤儿数据
|
||||
appModuleRepository.deleteByAppId(id);
|
||||
applicationInfoRepository.deleteById(id);
|
||||
signKeyCacheService.removeOne(appKey);
|
||||
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.delete(this, appKey));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -223,7 +232,7 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
||||
.orElseThrow(() -> new RuntimeException("应用不存在: " + id));
|
||||
entity.setAppSecret(UUID.randomUUID().toString().replace("-", ""));
|
||||
ApplicationInfo saved = applicationInfoRepository.save(entity);
|
||||
signKeyCacheService.syncOne(saved);
|
||||
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.yunhai.userbase.config.UserBaseAppSecretProvider;
|
||||
import com.yunhai.userbase.config.UserBaseLocalAppIdentityProvider;
|
||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||
import com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent;
|
||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 应用签名密钥的 Redis 桥接服务。<br>
|
||||
* <br>
|
||||
* 职责:把 {@code application_info} 表的 appKey/appSecret 同步到 Redis,供各业务服务的
|
||||
* common-sign-starter(SignKeyResolver)读取做验签——业务服务无需直连 UserBase 数据库。<br>
|
||||
* <br>
|
||||
* 约定(读侧 starter 必须严格一致):<br>
|
||||
* 1) key 前缀 {@code sign:app:},后接 appKey,例 {@code sign:app:100616};<br>
|
||||
* 2) value 为 fastjson 序列化的 JSON
|
||||
* 字符串:{@code {"appKey":"100616","secret":"...","enabled":true}};<br>
|
||||
* 3) 不设过期时间,依赖 Redis 持久化(AOF/RDB)保证重启不丢;删除应用时直接删 key(等价于禁用)。<br>
|
||||
* <br>
|
||||
* <b>变更同步说明(IDEA 灰色=正常):</b>本类的 {@code onCacheChanged(...)} 由 Spring 容器在运行时通过
|
||||
* 反射注册为事务监听器({@code @TransactionalEventListener}),不会在 IDE 的静态调用链中直接显示
|
||||
* 引用,属正常现象。真实触发点在
|
||||
* {@link com.yunhai.userbase.service.impl.ApplicationInfoService}
|
||||
* 的增/改/删/重置密钥、以及
|
||||
* 通用 save/deleteById 方法末尾,统一发布 {@link ApplicationInfoCacheChangedEvent}。
|
||||
*/
|
||||
@Service
|
||||
public class SignKeyCacheService implements ApplicationRunner, ApplicationContextAware {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SignKeyCacheService.class);
|
||||
|
||||
/** Redis key 前缀;读侧 SignKeyResolver 必须保持一致:sign:app:{appKey} */
|
||||
public static final String KEY_PREFIX = "sign:app:";
|
||||
|
||||
/** UserBase 本身作为调用方(出站签名)时固定使用的基石应用 appKey,对应 100000 这条初始化记录 */
|
||||
public static final String USER_BASE_APP_KEY = "100000";
|
||||
|
||||
@Autowired
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@Autowired
|
||||
private ApplicationInfoRepository applicationInfoRepository;
|
||||
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
/**
|
||||
* 事务提交后的安全订阅:只有 ApplicationInfoService 对应事务真正 commit 后,
|
||||
* 才会去写共享 Redis / 清本地缓存,避免事务回滚导致的 DB-Redis 数据不一致。
|
||||
*/
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
public void onCacheChanged(ApplicationInfoCacheChangedEvent event) {
|
||||
if (event.getType() == ApplicationInfoCacheChangedEvent.Type.DELETE) {
|
||||
removeOne(event.getAppKey());
|
||||
return;
|
||||
}
|
||||
if (event.getUpsertEntity() != null) {
|
||||
syncOne(event.getUpsertEntity());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单条应用到 Redis(新增 / 编辑 / 重置密钥时调用)。
|
||||
* <p>
|
||||
* 同步前主动失效本机的本地缓存,保证密钥变更后本机入站验签 / 出站签名 0 窗口生效。
|
||||
*/
|
||||
public void syncOne(ApplicationInfo info) {
|
||||
if (info == null || info.getAppKey() == null || info.getAppSecret() == null) {
|
||||
return;
|
||||
}
|
||||
String key = KEY_PREFIX + info.getAppKey();
|
||||
SignAppKeyPayload payload = new SignAppKeyPayload(
|
||||
String.valueOf(info.getAppKey()), info.getAppSecret(), true);
|
||||
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(payload));
|
||||
invalidateLocal(info.getAppKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Redis 移除某应用的密钥(删除应用时调用,等价于禁用该应用签名)。
|
||||
* 删除前同步失效本机本地缓存。
|
||||
*/
|
||||
public void removeOne(Integer appKey) {
|
||||
if (appKey == null) {
|
||||
return;
|
||||
}
|
||||
stringRedisTemplate.delete(KEY_PREFIX + appKey);
|
||||
invalidateLocal(appKey);
|
||||
log.info("[SignKeyCache] remove appKey={} from redis", appKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量刷新:把数据库全部应用重新写入 Redis,并清理库中已不存在的残留 key。<br>
|
||||
* 用于 Redis 数据丢失 / 不一致后的手动恢复,以及首次部署预热 + 每日定时对账。
|
||||
*
|
||||
* @return 实际写入的密钥条数
|
||||
*/
|
||||
public int refreshAll() {
|
||||
List<ApplicationInfo> all = applicationInfoRepository.findAll();
|
||||
Set<String> dbKeys = new HashSet<>();
|
||||
for (ApplicationInfo info : all) {
|
||||
if (info.getAppKey() == null || info.getAppSecret() == null) {
|
||||
continue;
|
||||
}
|
||||
syncOne(info);
|
||||
dbKeys.add(KEY_PREFIX + info.getAppKey());
|
||||
}
|
||||
// 清理残留:DB 已删除但 Redis 仍存在的 key(防止被删应用仍可验签)
|
||||
Set<String> redisKeys = stringRedisTemplate.keys(KEY_PREFIX + "*");
|
||||
if (redisKeys != null) {
|
||||
for (String k : redisKeys) {
|
||||
if (!dbKeys.contains(k)) {
|
||||
stringRedisTemplate.delete(k);
|
||||
log.info("[SignKeyCache] prune stale key={}", k);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 全量刷新后清空本地缓存,保证之前残留的任何旧密钥在下一次 getSecret 时重新查 DB 获取新值
|
||||
invalidateAllLocal();
|
||||
log.info("[SignKeyCache] refreshAll done, synced={}", dbKeys.size());
|
||||
return dbKeys.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 3A 方案兜底:<b>每日对账定时任务已迁移到
|
||||
* {@link com.yunhai.userbase.task.SignKeyCacheDailyTask#syncAll()}。</b>
|
||||
* 保持 Service 层单纯(CRUD 同步 + 启动预热),定时任务统一放在 task 包,便于运维集中查看。
|
||||
*
|
||||
* @deprecated 请使用 task 包下的 SignKeyCacheDailyTask.syncAll() 每日对账。
|
||||
* 本方法保留仅为兼容手动调用场景。
|
||||
*/
|
||||
@Deprecated
|
||||
public void scheduledRefreshAll() {
|
||||
try {
|
||||
int n = refreshAll();
|
||||
log.info("[SignKeyCache] scheduled daily refreshAll done, synced={}", n);
|
||||
} catch (Exception e) {
|
||||
log.warn("[SignKeyCache] scheduled daily refreshAll failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动预热:应用启动后尽力把密钥同步到 Redis。
|
||||
* Redis 不可用时仅告警,不影响应用启动(此时验签依赖后续手动刷新或 Redis 恢复)。
|
||||
*/
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
try {
|
||||
int n = refreshAll();
|
||||
log.info("[SignKeyCache] startup warm-up synced={}", n);
|
||||
} catch (Exception e) {
|
||||
log.warn("[SignKeyCache] startup warm-up failed (redis may be unavailable): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 失效本机本地缓存:变更某应用密钥/删除该应用时,
|
||||
* 同时失效 AppSecretProvider(入站验签读的缓存)和若其为基石应用则失效出站签名缓存。
|
||||
*/
|
||||
public void invalidateLocal(Integer appKey) {
|
||||
if (appKey == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Map<String, UserBaseAppSecretProvider> providers = applicationContext
|
||||
.getBeansOfType(UserBaseAppSecretProvider.class);
|
||||
if (providers != null && !providers.isEmpty()) {
|
||||
for (UserBaseAppSecretProvider p : providers.values()) {
|
||||
p.invalidate(String.valueOf(appKey));
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
if (USER_BASE_APP_KEY.equals(String.valueOf(appKey))) {
|
||||
try {
|
||||
Map<String, UserBaseLocalAppIdentityProvider> locals = applicationContext
|
||||
.getBeansOfType(UserBaseLocalAppIdentityProvider.class);
|
||||
if (locals != null && !locals.isEmpty()) {
|
||||
for (UserBaseLocalAppIdentityProvider p : locals.values()) {
|
||||
p.invalidate();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 全量对账/预热时,直接清空本机的所有密钥缓存,避免残留。 */
|
||||
public void invalidateAllLocal() {
|
||||
try {
|
||||
Map<String, UserBaseAppSecretProvider> providers = applicationContext
|
||||
.getBeansOfType(UserBaseAppSecretProvider.class);
|
||||
if (providers != null && !providers.isEmpty()) {
|
||||
for (UserBaseAppSecretProvider p : providers.values()) {
|
||||
p.invalidateAll();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
Map<String, UserBaseLocalAppIdentityProvider> locals = applicationContext
|
||||
.getBeansOfType(UserBaseLocalAppIdentityProvider.class);
|
||||
if (locals != null && !locals.isEmpty()) {
|
||||
for (UserBaseLocalAppIdentityProvider p : locals.values()) {
|
||||
p.invalidate();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
|
||||
this.applicationContext = applicationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis value 的 JSON 载体。字段名即契约,读侧 starter 的解析类需一一对应。
|
||||
*/
|
||||
public static class SignAppKeyPayload {
|
||||
private String appKey;
|
||||
private String secret;
|
||||
private boolean enabled;
|
||||
|
||||
public SignAppKeyPayload() {
|
||||
}
|
||||
|
||||
public SignAppKeyPayload(String appKey, String secret, boolean enabled) {
|
||||
this.appKey = appKey;
|
||||
this.secret = secret;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getAppKey() {
|
||||
return appKey;
|
||||
}
|
||||
|
||||
public void setAppKey(String appKey) {
|
||||
this.appKey = appKey;
|
||||
}
|
||||
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void setSecret(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.userbase.entity.SysGroup;
|
||||
import com.yunhai.userbase.enums.IEnum;
|
||||
import com.yunhai.userbase.enums.StatusEnum;
|
||||
import com.yunhai.userbase.enums.SysRoleEnum;
|
||||
import com.yunhai.userbase.enums.UserTypeEnum;
|
||||
import com.yunhai.userbase.entity.SysUser;
|
||||
import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||
import com.yunhai.userbase.model.sysuser.BatchImportResult;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserQuery;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserVo;
|
||||
@@ -16,6 +18,11 @@ import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRepository;
|
||||
import com.yunhai.userbase.service.ISysGroupService;
|
||||
import com.yunhai.userbase.service.ISysUserService;
|
||||
import cn.afterturn.easypoi.excel.ExcelExportUtil;
|
||||
import cn.afterturn.easypoi.excel.entity.ExportParams;
|
||||
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
|
||||
import com.yunhai.userbase.model.sysuser.ExcelSysUser;
|
||||
import com.yunhai.userbase.utils.EasyPoiUtils;
|
||||
import com.yunhai.userbase.utils.ValidatorUtil;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
@@ -26,16 +33,28 @@ import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
import org.apache.poi.xssf.usermodel.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.persistence.criteria.Join;
|
||||
import javax.persistence.criteria.JoinType;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
import javax.persistence.criteria.Subquery;
|
||||
import javax.transaction.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.time.ZoneId;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
@@ -73,6 +92,18 @@ public class SysUserService implements ISysUserService {
|
||||
@Override
|
||||
@Transactional
|
||||
public void add(SysUserRequest request) {
|
||||
// 单条新增走完整校验(含密码强度)
|
||||
createUser(request, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户并写入组关联。
|
||||
*
|
||||
* @param enforcePasswordStrength 是否强制密码强度校验:
|
||||
* - 单条新增 = true(前端已交互式校验);
|
||||
* - 批量导入 = false(默认密码 Vzuk4Dp3 不含符号,按业务约定直接放行)。
|
||||
*/
|
||||
private void createUser(SysUserRequest request, boolean enforcePasswordStrength) {
|
||||
if (sysUserRepository.countByAccount(request.getAccount()) > 0) {
|
||||
throw new DException("账号已存在");
|
||||
}
|
||||
@@ -80,7 +111,9 @@ public class SysUserService implements ISysUserService {
|
||||
throw new DException("所属分组不存在");
|
||||
}
|
||||
validateProfile(request);
|
||||
String encodedPassword = validateAndEncodePassword(request.getPassword(), request.getConfirmPassword());
|
||||
String encodedPassword = enforcePasswordStrength
|
||||
? validateAndEncodePassword(request.getPassword(), request.getConfirmPassword())
|
||||
: passwordEncoder.encode(request.getPassword());
|
||||
|
||||
SysUser entity = new SysUser();
|
||||
entity.setAccount(request.getAccount());
|
||||
@@ -89,6 +122,8 @@ public class SysUserService implements ISysUserService {
|
||||
entity.setPhone(request.getPhone());
|
||||
entity.setBirthday(request.getBirthday());
|
||||
entity.setIdcard(request.getIdcard());
|
||||
entity.setEmail(request.getEmail());
|
||||
entity.setGender(request.getGender());
|
||||
entity.setProfilePicture(request.getProfilePicture());
|
||||
entity.setRoles(request.getRoles());
|
||||
// 用户类型推导:超级管理员账号固定为 1;其余含 ADMIN(7) 角色 -> 2 学校管理员;默认 0 普通用户
|
||||
@@ -126,6 +161,8 @@ public class SysUserService implements ISysUserService {
|
||||
entity.setPhone(request.getPhone());
|
||||
entity.setBirthday(request.getBirthday());
|
||||
entity.setIdcard(request.getIdcard());
|
||||
entity.setEmail(request.getEmail());
|
||||
entity.setGender(request.getGender());
|
||||
entity.setProfilePicture(request.getProfilePicture());
|
||||
entity.setRoles(request.getRoles());
|
||||
// 用户类型推导:仅当本次传入了角色时才重算,避免编辑时未传角色把 type 误重置为 0
|
||||
@@ -154,6 +191,38 @@ public class SysUserService implements ISysUserService {
|
||||
sysUserRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteFromGroup(Integer userId, Integer groupId) {
|
||||
batchDeleteFromGroup(Collections.singletonList(userId), groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void batchDeleteFromGroup(List<Integer> userIds, Integer groupId) {
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
throw new DException("待删除用户不能为空");
|
||||
}
|
||||
if (groupId == null) {
|
||||
throw new DException("组ID不能为空");
|
||||
}
|
||||
|
||||
List<SysUser> users = sysUserRepository.findAllById(userIds);
|
||||
if (users.size() != userIds.size()) {
|
||||
throw new DException("存在无效用户");
|
||||
}
|
||||
|
||||
for (Integer userId : userIds) {
|
||||
// 删除当前组关联
|
||||
sysUserGroupRelRepository.deleteByUserIdAndGroupId(userId, groupId);
|
||||
// 若用户已无其它组关联,则级联删除用户本身及其角色绑定
|
||||
if (sysUserGroupRelRepository.countByUserId(userId) == 0) {
|
||||
sysUserAppRoleRelRepository.deleteByUserIdBulk(userId);
|
||||
sysUserRepository.deleteById(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void changeStatus(Integer id, boolean disabled) {
|
||||
@@ -164,29 +233,396 @@ public class SysUserService implements ISysUserService {
|
||||
sysUserRepository.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void batchChangeStatus(List<Integer> userIds, boolean disabled) {
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
throw new DException("待设置用户不能为空");
|
||||
}
|
||||
List<SysUser> users = sysUserRepository.findAllById(userIds);
|
||||
if (users.size() != userIds.size()) {
|
||||
throw new DException("存在无效用户");
|
||||
}
|
||||
StatusEnum targetStatus = disabled ? StatusEnum.DISABLED : StatusEnum.NORMAL;
|
||||
for (SysUser user : users) {
|
||||
user.setDisabled(targetStatus);
|
||||
}
|
||||
sysUserRepository.saveAll(users);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void changeSortNo(Integer userId, Integer groupId, Integer sortNo) {
|
||||
if (!sysUserRepository.existsById(userId)) {
|
||||
throw new DException("系统用户不存在");
|
||||
}
|
||||
// 排序号按组维度存储,必须落到具体的 (userId, groupId) 关联行
|
||||
SysUserGroupRel rel = sysUserGroupRelRepository.findByUserIdAndGroupId(userId, groupId)
|
||||
.orElseThrow(() -> new DException("用户不在该组,无法设置排序号"));
|
||||
rel.setSortNo(sortNo);
|
||||
sysUserGroupRelRepository.save(rel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<SysUserVo> list(SysUserQuery query, Pageable pageable) {
|
||||
Specification<SysUser> spec = buildSpecification(query);
|
||||
return sysUserRepository.findAll(spec, pageable).map(this::convertToVo);
|
||||
Page<SysUser> page = sysUserRepository.findAll(spec, pageable);
|
||||
// 把当前组维度的 sortNo 带到 Vo(仅在具体组上下文有意义;根节点/未选组时为 null)
|
||||
if (query.getGroupId() != null) {
|
||||
Map<Integer, Integer> sortNoMap = resolveUserSortNoInGroup(
|
||||
page.getContent().stream().map(SysUser::getId).collect(Collectors.toList()),
|
||||
query.getGroupId());
|
||||
return page.map(u -> convertToVo(u, sortNoMap.get(u.getId())));
|
||||
}
|
||||
return page.map(u -> convertToVo(u, null));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Integer> findGroupIdsByUserId(Integer userId) {
|
||||
return sysUserGroupRelRepository.findByUserId(userId).stream()
|
||||
.map(SysUserGroupRel::getGroupId)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void changeGroups(List<Integer> userIds, List<Integer> groupIds) {
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
throw new DException("待调整用户不能为空");
|
||||
}
|
||||
if (groupIds == null || groupIds.isEmpty()) {
|
||||
throw new DException("请至少保留一个组");
|
||||
}
|
||||
|
||||
List<SysUser> users = sysUserRepository.findAllById(userIds);
|
||||
if (users.size() != userIds.size()) {
|
||||
throw new DException("存在无效用户");
|
||||
}
|
||||
List<SysGroup> groups = sysGroupRepository.findAllById(groupIds);
|
||||
if (groups.size() != groupIds.size()) {
|
||||
throw new DException("存在无效分组");
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (Integer userId : userIds) {
|
||||
sysUserGroupRelRepository.deleteByUserId(userId);
|
||||
List<SysUserGroupRel> rels = new ArrayList<>(groupIds.size());
|
||||
for (int i = 0; i < groupIds.size(); i++) {
|
||||
SysUserGroupRel rel = new SysUserGroupRel();
|
||||
rel.setUserId(userId);
|
||||
rel.setGroupId(groupIds.get(i));
|
||||
rel.setIsMain(i == 0 ? IS_MAIN : 0);
|
||||
rel.setCreateTime(now);
|
||||
rels.add(rel);
|
||||
}
|
||||
sysUserGroupRelRepository.saveAll(rels);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public BatchImportResult batchImport(MultipartFile file) {
|
||||
BatchImportResult result = new BatchImportResult();
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new DException("上传文件不能为空");
|
||||
}
|
||||
|
||||
Workbook workbook = null;
|
||||
try {
|
||||
workbook = WorkbookFactory.create(file.getInputStream());
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
int lastRowNum = sheet.getLastRowNum();
|
||||
|
||||
for (int i = 1; i <= lastRowNum; i++) {
|
||||
Row row = sheet.getRow(i);
|
||||
if (row == null || isBlankRow(row)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
SysUserRequest request = parseImportRow(row);
|
||||
createUser(request, false);
|
||||
result.addSuccess();
|
||||
} catch (DException e) {
|
||||
result.addFail(i + 1, e.getMessage());
|
||||
} catch (Exception e) {
|
||||
result.addFail(i + 1, "系统异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
// 仅统计实际参与解析的数据行(跳过空行),使 总数 = 成功 + 失败
|
||||
result.setTotal(result.getSuccess() + result.getFail());
|
||||
} catch (Exception e) {
|
||||
throw new DException("文件读取失败:" + e.getMessage());
|
||||
} finally {
|
||||
if (workbook != null) {
|
||||
try {
|
||||
workbook.close();
|
||||
} catch (IOException ignored) {
|
||||
// 关闭资源失败不阻断主流程
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===================== 导入模板下载 =====================
|
||||
|
||||
/**
|
||||
* 导入/导出共用的列定义(顺序即 Excel 列顺序)。<br>
|
||||
* 导入模板生成与(后续)导出复用同一套,避免两边列顺序/文案漂移。
|
||||
*/
|
||||
private static final List<ImportColumnDef> IMPORT_COLUMN_DEFS = Arrays.asList(
|
||||
new ImportColumnDef("groupName", "组名\n(不必填,供与id对应参考用)", 36, false, null),
|
||||
new ImportColumnDef("groupId", "组id(必须)", 14, true, null),
|
||||
new ImportColumnDef("account", "用户名(必须)", 20, true, null),
|
||||
new ImportColumnDef("password", "密码\n(可选,\n默认Vzuk4Dp3)", 18, false, ""),
|
||||
new ImportColumnDef("realName", "姓名(必须)", 16, true, null),
|
||||
new ImportColumnDef("idcard", "身份证号(可选)", 22, false, null),
|
||||
new ImportColumnDef("phone", "手机号(可选)", 16, false, null),
|
||||
new ImportColumnDef("email", "email(可选)", 24, false, null),
|
||||
new ImportColumnDef("nature", "用户性质\n(可选,默认普通用户\n组管理员2,\n普通用户0,\n导入不接受别的代码)", 24, false, "0"),
|
||||
new ImportColumnDef("identity", "用户身份\n(1教师 2教职工 3学生 4家长)", 24, false, null),
|
||||
new ImportColumnDef("gender", "性别(可选)", 12, false, null),
|
||||
new ImportColumnDef("birthday", "生日(yyyy-MM-dd)(可选)", 22, false, null)
|
||||
);
|
||||
|
||||
/** 组织树虚拟根 parentId(与 SysGroupService 约定一致)。 */
|
||||
private static final int ROOT_PARENT_ID = 0;
|
||||
|
||||
/**
|
||||
* 下载用户导入模板:以指定组(含)为根,将其自身及所有子孙组平铺为数据行,
|
||||
* 仅填充「组名(路径)」与「组id」两列,其余列为空供用户填写。<br>
|
||||
* 生成真实 .xlsx(POI XSSF),可被后端 BatchImport 正确解析(HTML 表格伪装 .xls 无法被 POI 解析)。
|
||||
*/
|
||||
@Override
|
||||
public void downloadImportTemplate(Integer groupId, HttpServletResponse response) throws IOException {
|
||||
List<GroupPath> groups = buildImportGroupPaths(groupId);
|
||||
|
||||
XSSFWorkbook workbook = new XSSFWorkbook();
|
||||
try {
|
||||
XSSFSheet sheet = workbook.createSheet("用户导入模板");
|
||||
|
||||
// 表头样式:加粗、居中、自动换行、细边框
|
||||
XSSFCellStyle headerStyle = workbook.createCellStyle();
|
||||
XSSFFont headerFont = workbook.createFont();
|
||||
headerFont.setBold(true);
|
||||
headerStyle.setFont(headerFont);
|
||||
headerStyle.setWrapText(true);
|
||||
headerStyle.setVerticalAlignment(VerticalAlignment.CENTER);
|
||||
headerStyle.setAlignment(HorizontalAlignment.CENTER);
|
||||
headerStyle.setBorderTop(BorderStyle.THIN);
|
||||
headerStyle.setBorderBottom(BorderStyle.THIN);
|
||||
headerStyle.setBorderLeft(BorderStyle.THIN);
|
||||
headerStyle.setBorderRight(BorderStyle.THIN);
|
||||
|
||||
XSSFRow headerRow = sheet.createRow(0);
|
||||
for (int i = 0; i < IMPORT_COLUMN_DEFS.size(); i++) {
|
||||
ImportColumnDef col = IMPORT_COLUMN_DEFS.get(i);
|
||||
XSSFCell cell = headerRow.createCell(i);
|
||||
cell.setCellValue(col.getTitle());
|
||||
cell.setCellStyle(headerStyle);
|
||||
sheet.setColumnWidth(i, col.getWidth() * 256);
|
||||
}
|
||||
headerRow.setHeightInPoints(64);
|
||||
|
||||
// 数据行:每个组一行,仅填 组名(路径) + 组id
|
||||
int r = 1;
|
||||
for (GroupPath g : groups) {
|
||||
XSSFRow row = sheet.createRow(r++);
|
||||
row.createCell(IDX_GROUP_NAME).setCellValue(g.getPath());
|
||||
row.createCell(IDX_GROUP_ID).setCellValue(String.valueOf(g.getId()));
|
||||
}
|
||||
|
||||
String filename = URLEncoder.encode("用户导入模板.xlsx", "UTF-8");
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
response.setHeader("Content-Disposition",
|
||||
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + filename);
|
||||
try (OutputStream out = response.getOutputStream()) {
|
||||
workbook.write(out);
|
||||
}
|
||||
} finally {
|
||||
workbook.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 导出 =====================
|
||||
|
||||
/**
|
||||
* 导出组下所有用户(含子组)为 Excel:与导入模板 {@code IMPORT_COLUMN_DEFS} 同构,末尾追加「用户id」列。<br>
|
||||
* - 组名 / 组id:取每个用户实际所属的主组(isMain=1),与导入模板的「参考」列保持一致;<br>
|
||||
* - 密码:数据库存的是 BCrypt 密文、不可逆,导出留空(再导入时退化为默认 Vzuk4Dp3);<br>
|
||||
* - 用户性质 nature:取 type 的 key(0普通 / 1超级管理员 / 2学校管理员);<br>
|
||||
* - 用户身份 identity:取 roles 数组第一个元素(系统角色枚举 key:1管理员 2老师 3校领导 4局领导 5学生 6家长 7工作号)。
|
||||
*/
|
||||
@Override
|
||||
public void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException {
|
||||
// 1. 解析组范围(含子孙)并查询范围内全部用户(不分页)
|
||||
SysUserQuery query = new SysUserQuery();
|
||||
query.setGroupId(groupId);
|
||||
query.setWithSub(withSub);
|
||||
query.setName(name);
|
||||
List<SysUser> users = sysUserRepository.findAll(buildSpecification(query));
|
||||
|
||||
// 2. 批量取每个用户的主组(isMain=1),用于填充「组名 / 组id」参考列
|
||||
List<Integer> userIds = users.stream().map(SysUser::getId).collect(Collectors.toList());
|
||||
Map<Integer, Integer> userMainGroup = resolveUserMainGroup(userIds);
|
||||
|
||||
// 3. 批量取组名
|
||||
Map<Integer, String> groupNameMap = sysGroupRepository.findAllById(userMainGroup.values())
|
||||
.stream().collect(Collectors.toMap(SysGroup::getId, SysGroup::getName));
|
||||
|
||||
// 4. 组装导出行
|
||||
List<ExcelSysUser> rows = new ArrayList<>(users.size());
|
||||
for (SysUser u : users) {
|
||||
ExcelSysUser row = new ExcelSysUser();
|
||||
Integer gid = userMainGroup.get(u.getId());
|
||||
row.setGroupId(gid == null ? "" : String.valueOf(gid));
|
||||
row.setGroupName(gid != null ? groupNameMap.get(gid) : null);
|
||||
row.setAccount(u.getAccount());
|
||||
row.setPassword(""); // 密文不可逆,导出留空
|
||||
row.setRealName(u.getRealName());
|
||||
row.setIdcard(u.getIdcard());
|
||||
row.setPhone(u.getPhone());
|
||||
row.setEmail(u.getEmail());
|
||||
row.setNature(u.getType() == null ? null : u.getType().getKey());
|
||||
row.setIdentity(u.getRoles() == null || u.getRoles().isEmpty() ? null : u.getRoles().get(0));
|
||||
row.setGender(u.getGender());
|
||||
row.setBirthday(u.getBirthday() == null ? "" : u.getBirthday().toString());
|
||||
row.setUserId(u.getId());
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
// 5. 写出(HSSF .xls,与 EasyPoiUtils.downLoadExcel 的 .xls 兜底一致)
|
||||
ExportParams exportParams = new ExportParams("组用户导出", "组用户");
|
||||
exportParams.setType(ExcelType.HSSF);
|
||||
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, ExcelSysUser.class, rows);
|
||||
EasyPoiUtils.downLoadExcel("组用户导出", response, workbook);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量解析 用户 -> 主组(groupId) 映射:优先取 isMain=1 的组,否则取第一个关联组。
|
||||
*/
|
||||
private Map<Integer, Integer> resolveUserMainGroup(List<Integer> userIds) {
|
||||
Map<Integer, Integer> mainGroup = new HashMap<>();
|
||||
if (userIds.isEmpty()) {
|
||||
return mainGroup;
|
||||
}
|
||||
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserIdIn(userIds);
|
||||
Map<Integer, List<SysUserGroupRel>> byUser = rels.stream()
|
||||
.collect(Collectors.groupingBy(SysUserGroupRel::getUserId));
|
||||
for (Map.Entry<Integer, List<SysUserGroupRel>> e : byUser.entrySet()) {
|
||||
Integer gid = e.getValue().stream()
|
||||
.filter(r -> r.getIsMain() != null && r.getIsMain() == IS_MAIN)
|
||||
.map(SysUserGroupRel::getGroupId)
|
||||
.findFirst()
|
||||
.orElse(e.getValue().get(0).getGroupId());
|
||||
mainGroup.put(e.getKey(), gid);
|
||||
}
|
||||
return mainGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集导入模板所需的「组路径 + 组id」列表:以指定组为根做先序 DFS,
|
||||
* 路径用「/」拼接(如 上海鸿育初级中学/教师组/校长室)。
|
||||
*/
|
||||
private List<GroupPath> buildImportGroupPaths(Integer groupId) {
|
||||
List<SysGroup> all = sysGroupRepository.findAll();
|
||||
Map<Integer, SysGroup> byId = new HashMap<>();
|
||||
Map<Integer, List<SysGroup>> childrenMap = new HashMap<>();
|
||||
for (SysGroup g : all) {
|
||||
byId.put(g.getId(), g);
|
||||
Integer key = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId();
|
||||
childrenMap.computeIfAbsent(key, k -> new ArrayList<>()).add(g);
|
||||
}
|
||||
// 同层级按 sort 升序、再按 id 升序,保证模板顺序稳定且与树展示一致
|
||||
Comparator<SysGroup> order = (a, b) -> {
|
||||
int sa = a.getSort() == null ? 999 : a.getSort();
|
||||
int sb = b.getSort() == null ? 999 : b.getSort();
|
||||
if (sa != sb) {
|
||||
return Integer.compare(sa, sb);
|
||||
}
|
||||
return Integer.compare(a.getId(), b.getId());
|
||||
};
|
||||
for (List<SysGroup> siblings : childrenMap.values()) {
|
||||
siblings.sort(order);
|
||||
}
|
||||
|
||||
List<GroupPath> result = new ArrayList<>();
|
||||
List<SysGroup> roots;
|
||||
if (groupId != null && byId.containsKey(groupId)) {
|
||||
roots = Collections.singletonList(byId.get(groupId));
|
||||
} else {
|
||||
roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>());
|
||||
}
|
||||
for (SysGroup root : roots) {
|
||||
dfsGroup(root, "", childrenMap, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void dfsGroup(SysGroup node, String parentPath,
|
||||
Map<Integer, List<SysGroup>> childrenMap, List<GroupPath> out) {
|
||||
String path = parentPath.isEmpty() ? node.getName() : parentPath + "/" + node.getName();
|
||||
GroupPath gp = new GroupPath();
|
||||
gp.setId(node.getId());
|
||||
gp.setPath(path);
|
||||
out.add(gp);
|
||||
List<SysGroup> children = childrenMap.get(node.getId());
|
||||
if (children != null) {
|
||||
for (SysGroup child : children) {
|
||||
dfsGroup(child, path, childrenMap, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 导入/导出列定义(轻量 DTO)。 */
|
||||
@lombok.Data
|
||||
private static class ImportColumnDef {
|
||||
private final String key;
|
||||
private final String title;
|
||||
private final int width;
|
||||
private final boolean required;
|
||||
private final String sample;
|
||||
}
|
||||
|
||||
/** 模板所需的组路径节点。 */
|
||||
@lombok.Data
|
||||
private static class GroupPath {
|
||||
private Integer id;
|
||||
private String path;
|
||||
}
|
||||
|
||||
// ===================== 私有工具 =====================
|
||||
|
||||
/**
|
||||
* 组装用户列表查询条件:组范围(含子组)+ 关键字(账号/姓名/身份证模糊、手机号精确)+ 启用状态。<br>
|
||||
* 排序按「是否选中具体组」分治:<br>
|
||||
* - 选中具体组(groupId != null):JOIN 关联表,按组内 sort_no 正序(NULLS LAST),其次 user_id 正序;
|
||||
* 含子组时同一用户可能命中多组,启用 distinct 去重(同时保证导出不去重重复行)。<br>
|
||||
* - 根节点 / 未选组(groupId == null):不 JOIN 关联表,直接按主键 id 正序(聚簇索引顺序,无 filesort),
|
||||
* 平台总用户量不影响查询性能。
|
||||
*/
|
||||
private Specification<SysUser> buildSpecification(SysUserQuery query) {
|
||||
return (root, cbQuery, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
// 组范围过滤:用户ID 属于目标组集合下的关联记录
|
||||
if (query.getGroupId() != null) {
|
||||
// 具体组上下文:JOIN 关联表以按组内 sort_no 排序
|
||||
List<Integer> groupIds = resolveGroupIds(query.getGroupId(), query.getWithSub());
|
||||
Subquery<Integer> sub = cbQuery.subquery(Integer.class);
|
||||
Root<SysUserGroupRel> relRoot = sub.from(SysUserGroupRel.class);
|
||||
sub.select(relRoot.get("userId"));
|
||||
sub.where(relRoot.get("groupId").in(groupIds));
|
||||
predicates.add(root.get("id").in(sub));
|
||||
Join<SysUser, SysUserGroupRel> rel = root.join("userGroupRelList", JoinType.INNER);
|
||||
predicates.add(rel.get("groupId").in(groupIds));
|
||||
cbQuery.distinct(true);
|
||||
// 排序规则:已设排序号者排最前(NULLS LAST),其次 sort_no 升序,最后 user_id 升序兜底
|
||||
// 注:Hibernate 5.6 的 AST 解析器不支持把 IS NULL 表达式直接放进 ORDER BY,
|
||||
// 改用 CASE WHEN sort_no IS NULL THEN 1 ELSE 0 END(产出普通整数,语义等价 NULLS LAST)。
|
||||
// rel.get("sortNo") 返回 Path<Object>,直接作为 Expression<?> 传入 orderBy 即可。
|
||||
cbQuery.orderBy(
|
||||
cb.asc(cb.selectCase().when(cb.isNull(rel.get("sortNo")), 1).otherwise(0)),
|
||||
cb.asc(rel.get("sortNo")),
|
||||
cb.asc(root.get("id"))
|
||||
);
|
||||
} else {
|
||||
// 根节点 / 未选组:无组上下文,按主键 id 升序(聚簇索引顺序,不产生 filesort)
|
||||
cbQuery.orderBy(cb.asc(root.get("id")));
|
||||
}
|
||||
|
||||
// 关键字:账号/姓名/身份证模糊,手机号精确
|
||||
@@ -208,6 +644,21 @@ public class SysUserService implements ISysUserService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量取「用户在某组下的排序号」,用于列表 Vo 携带当前组维度 sortNo(仅具体组上下文调用)。
|
||||
*/
|
||||
private Map<Integer, Integer> resolveUserSortNoInGroup(List<Integer> userIds, Integer groupId) {
|
||||
Map<Integer, Integer> sortNoMap = new HashMap<>();
|
||||
if (userIds.isEmpty()) {
|
||||
return sortNoMap;
|
||||
}
|
||||
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserIdInAndGroupId(userIds, groupId);
|
||||
for (SysUserGroupRel rel : rels) {
|
||||
sortNoMap.put(rel.getUserId(), rel.getSortNo());
|
||||
}
|
||||
return sortNoMap;
|
||||
}
|
||||
|
||||
/** 依据 withSub 决定组范围:1=含所有子孙组,否则仅当前组。 */
|
||||
private List<Integer> resolveGroupIds(Integer groupId, Integer withSub) {
|
||||
if (withSub != null && withSub == 1) {
|
||||
@@ -243,12 +694,18 @@ public class SysUserService implements ISysUserService {
|
||||
}
|
||||
|
||||
private SysUserVo convertToVo(SysUser entity) {
|
||||
return convertToVo(entity, null);
|
||||
}
|
||||
|
||||
private SysUserVo convertToVo(SysUser entity, Integer sortNo) {
|
||||
SysUserVo vo = new SysUserVo();
|
||||
BeanUtils.copyProperties(entity, vo);
|
||||
// disabled 为枚举,需要单独映射为 key(与 GroupTypeEnum 的 type 处理一致)
|
||||
vo.setDisabled(entity.getDisabled() == null ? null : entity.getDisabled().getKey());
|
||||
// type 为枚举,同样映射为 key
|
||||
vo.setType(entity.getType() == null ? null : entity.getType().getKey());
|
||||
// 当前组维度的排序号;根节点 / 未选组时为 null
|
||||
vo.setSortNo(sortNo);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -276,4 +733,191 @@ public class SysUserService implements ISysUserService {
|
||||
}
|
||||
return statusEnum;
|
||||
}
|
||||
|
||||
// ===================== 批量导入私有工具 =====================
|
||||
|
||||
private static final int IDX_GROUP_NAME = 0;
|
||||
private static final int IDX_GROUP_ID = 1;
|
||||
private static final int IDX_ACCOUNT = 2;
|
||||
private static final int IDX_PASSWORD = 3;
|
||||
private static final int IDX_REAL_NAME = 4;
|
||||
private static final int IDX_IDCARD = 5;
|
||||
private static final int IDX_PHONE = 6;
|
||||
private static final int IDX_EMAIL = 7;
|
||||
private static final int IDX_NATURE = 8;
|
||||
private static final int IDX_IDENTITY = 9;
|
||||
private static final int IDX_GENDER = 10;
|
||||
private static final int IDX_BIRTHDAY = 11;
|
||||
|
||||
/**
|
||||
* 解析 Excel 一行为 SysUserRequest。
|
||||
* 列顺序与前端导入模板严格一致:
|
||||
* 0组名 1组id 2用户名 3密码 4姓名 5身份证 6手机 7email 8用户性质 9用户身份 10性别 11生日。
|
||||
*/
|
||||
private SysUserRequest parseImportRow(Row row) {
|
||||
SysUserRequest request = new SysUserRequest();
|
||||
|
||||
Integer groupId = parseInteger(getCellString(row, IDX_GROUP_ID));
|
||||
if (groupId == null) {
|
||||
throw new DException("组id不能为空");
|
||||
}
|
||||
if (!sysGroupRepository.existsById(groupId)) {
|
||||
throw new DException("组id【" + groupId + "】不存在");
|
||||
}
|
||||
request.setGroupId(groupId);
|
||||
|
||||
String account = getCellString(row, IDX_ACCOUNT);
|
||||
if (StringUtils.isBlank(account)) {
|
||||
throw new DException("用户名不能为空");
|
||||
}
|
||||
request.setAccount(account.trim());
|
||||
|
||||
String password = getCellString(row, IDX_PASSWORD);
|
||||
if (StringUtils.isBlank(password)) {
|
||||
password = "Vzuk4Dp3";
|
||||
}
|
||||
request.setPassword(password);
|
||||
request.setConfirmPassword(password);
|
||||
|
||||
String realName = getCellString(row, IDX_REAL_NAME);
|
||||
if (StringUtils.isBlank(realName)) {
|
||||
throw new DException("姓名不能为空");
|
||||
}
|
||||
request.setRealName(realName.trim());
|
||||
|
||||
request.setIdcard(getCellString(row, IDX_IDCARD));
|
||||
request.setPhone(getCellString(row, IDX_PHONE));
|
||||
request.setEmail(getCellString(row, IDX_EMAIL));
|
||||
|
||||
Integer nature = parseInteger(getCellString(row, IDX_NATURE));
|
||||
Integer identity = parseInteger(getCellString(row, IDX_IDENTITY));
|
||||
request.setRoles(buildImportRoles(nature, identity));
|
||||
|
||||
request.setGender(parseGender(getCellString(row, IDX_GENDER)));
|
||||
request.setBirthday(parseBirthday(row.getCell(IDX_BIRTHDAY)));
|
||||
|
||||
validateProfile(request);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模板中的用户性质与用户身份构建 roles 列表。
|
||||
* - 用户性质:0普通用户(不加管理员),2组管理员(加 ADMIN=7);其他值视为非法。
|
||||
* - 用户身份:1教师->TEACHER(1),2教职工->SCHOOL_LEADER(2),3学生->STUDENT(3),4家长->PARENT(4)。
|
||||
*/
|
||||
private List<Integer> buildImportRoles(Integer nature, Integer identity) {
|
||||
Set<Integer> roleSet = new HashSet<>();
|
||||
if (nature != null) {
|
||||
if (nature == 2) {
|
||||
roleSet.add(SysRoleEnum.ADMIN.getKey());
|
||||
} else if (nature != 0) {
|
||||
throw new DException("用户性质只能为 0 或 2");
|
||||
}
|
||||
}
|
||||
if (identity != null) {
|
||||
switch (identity) {
|
||||
case 1:
|
||||
roleSet.add(SysRoleEnum.TEACHER.getKey());
|
||||
break;
|
||||
case 2:
|
||||
roleSet.add(SysRoleEnum.SCHOOL_LEADER.getKey());
|
||||
break;
|
||||
case 3:
|
||||
roleSet.add(SysRoleEnum.STUDENT.getKey());
|
||||
break;
|
||||
case 4:
|
||||
roleSet.add(SysRoleEnum.PARENT.getKey());
|
||||
break;
|
||||
default:
|
||||
throw new DException("用户身份只能为 1/2/3/4");
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(roleSet);
|
||||
}
|
||||
|
||||
private Integer parseGender(String value) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
String v = value.trim();
|
||||
if ("1".equals(v) || "男".equals(v)) {
|
||||
return 1;
|
||||
}
|
||||
if ("0".equals(v) || "女".equals(v)) {
|
||||
return 0;
|
||||
}
|
||||
throw new DException("性别只能为 0/女 或 1/男");
|
||||
}
|
||||
|
||||
private LocalDate parseBirthday(Cell cell) {
|
||||
if (cell == null) {
|
||||
return null;
|
||||
}
|
||||
if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
|
||||
return cell.getDateCellValue().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
}
|
||||
String value = getCellString(cell);
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value.trim());
|
||||
} catch (Exception e) {
|
||||
throw new DException("生日格式错误,请使用 yyyy-MM-dd");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBlankRow(Row row) {
|
||||
for (int i = 0; i <= IDX_BIRTHDAY; i++) {
|
||||
if (StringUtils.isNotBlank(getCellString(row, i))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String getCellString(Row row, int cellIndex) {
|
||||
Cell cell = row.getCell(cellIndex);
|
||||
if (cell == null) {
|
||||
return "";
|
||||
}
|
||||
return getCellString(cell);
|
||||
}
|
||||
|
||||
private String getCellString(Cell cell) {
|
||||
switch (cell.getCellType()) {
|
||||
case STRING:
|
||||
return StringUtils.trim(cell.getStringCellValue());
|
||||
case NUMERIC:
|
||||
if (DateUtil.isCellDateFormatted(cell)) {
|
||||
return cell.getDateCellValue().toInstant().atZone(ZoneId.systemDefault()).toLocalDate().toString();
|
||||
}
|
||||
double numeric = cell.getNumericCellValue();
|
||||
if (numeric == Math.floor(numeric)) {
|
||||
return String.valueOf((long) numeric);
|
||||
}
|
||||
return String.valueOf(numeric);
|
||||
case BOOLEAN:
|
||||
return String.valueOf(cell.getBooleanCellValue());
|
||||
case FORMULA:
|
||||
try {
|
||||
return String.valueOf(cell.getNumericCellValue());
|
||||
} catch (Exception e) {
|
||||
return cell.getStringCellValue();
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private Integer parseInteger(String value) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userbase.entity.SysGroup;
|
||||
import com.yunhai.userbase.entity.SysUser;
|
||||
import com.yunhai.userbase.entity.SysUserGroupRel;
|
||||
import com.yunhai.userbase.enums.GroupTypeEnum;
|
||||
import com.yunhai.userbase.enums.StatusEnum;
|
||||
import com.yunhai.userbase.repository.SysGroupRepository;
|
||||
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
|
||||
import com.yunhai.userbase.repository.SysUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 用户中心核心服务:把 UserBase 自身的 sys_user / sys_group 组装成基础平台统一的
|
||||
* {@link BoaUser}(含部门/学校/教育局多级组织)。<br><br>
|
||||
* 这是「UserBase 由子应用升级为用户中心」的关键:平台侧 {@code UserDetailsProvider}
|
||||
* ({@code UserBaseUserDetailsProvider})与开放接口({@code OpenBaseController})都复用本服务,
|
||||
* 保证 CAS 会话流与签名开放流返回的 BoaUser 结构完全一致。
|
||||
*/
|
||||
@Service
|
||||
public class UserCenterService {
|
||||
|
||||
/** 虚拟根节点 parentId(与 SysGroupService 约定一致)。 */
|
||||
private static final int ROOT_PARENT_ID = 0;
|
||||
|
||||
@Autowired
|
||||
private SysUserRepository sysUserRepository;
|
||||
@Autowired
|
||||
private SysUserGroupRelRepository sysUserGroupRelRepository;
|
||||
@Autowired
|
||||
private SysGroupRepository sysGroupRepository;
|
||||
|
||||
public BoaUser getUserInfoWithDeptByAccount(String account) {
|
||||
if (account == null) {
|
||||
return null;
|
||||
}
|
||||
return resolve(sysUserRepository.findByAccount(account.trim()));
|
||||
}
|
||||
|
||||
public BoaUser getUserInfoWithDeptByPhone(String phone) {
|
||||
if (phone == null) {
|
||||
return null;
|
||||
}
|
||||
return resolve(sysUserRepository.findByPhone(phone.trim()));
|
||||
}
|
||||
|
||||
public BoaUser getUserInfoWithDeptById(Integer id) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
return resolve(sysUserRepository.findById(id).orElse(null));
|
||||
}
|
||||
|
||||
private BoaUser resolve(SysUser user) {
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
BoaUser boa = new BoaUser();
|
||||
boa.setId(user.getId());
|
||||
boa.setAccount(user.getAccount());
|
||||
boa.setName(user.getRealName());
|
||||
boa.setIdcard(user.getIdcard());
|
||||
boa.setPhone(user.getPhone());
|
||||
boa.setProfilePicture(user.getProfilePicture());
|
||||
// 用户表无 email / gender 列,置 null
|
||||
boa.setEmail(null);
|
||||
boa.setGender(null);
|
||||
boa.setType(user.getType() != null ? user.getType().getKey() : null);
|
||||
// 状态语义对齐:本表 disabled(NORMAL=0 正常 / DISABLED=1 禁用) -> BoaUser(1 正常 / 0 禁用)
|
||||
boa.setStatus(user.getDisabled() == StatusEnum.NORMAL ? 1 : 0);
|
||||
boa.setRoleIds(user.getRoles());
|
||||
|
||||
List<BoaGroup> groups = buildGroups(user.getId());
|
||||
boa.setGroups(groups);
|
||||
deriveOrg(groups, boa);
|
||||
return boa;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装用户组织链:取主归属组(isMain=1,否则首个关联),沿 parentId 向上回溯到根,
|
||||
* 顺序为「主组 -> ... -> 根方向」(近 -> 远)。
|
||||
*/
|
||||
private List<BoaGroup> buildGroups(Integer userId) {
|
||||
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserId(userId);
|
||||
if (rels == null || rels.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
SysUserGroupRel mainRel = rels.stream()
|
||||
.filter(r -> r.getIsMain() != null && r.getIsMain() == 1)
|
||||
.findFirst()
|
||||
.orElse(rels.get(0));
|
||||
Integer groupId = mainRel.getGroupId();
|
||||
if (groupId == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
List<BoaGroup> chain = new ArrayList<>();
|
||||
Integer cursor = groupId;
|
||||
Set<Integer> seen = new HashSet<>();
|
||||
while (cursor != null && cursor != ROOT_PARENT_ID && seen.add(cursor)) {
|
||||
SysGroup g = sysGroupRepository.findById(cursor).orElse(null);
|
||||
if (g == null) {
|
||||
break;
|
||||
}
|
||||
BoaGroup bg = new BoaGroup();
|
||||
bg.setId(g.getId());
|
||||
bg.setName(g.getName());
|
||||
bg.setParentId(g.getParentId());
|
||||
bg.setType(g.getType() != null ? g.getType().getKey() : null);
|
||||
chain.add(bg);
|
||||
cursor = g.getParentId();
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/** 从组织链推导 school / bureau / dept。 */
|
||||
private void deriveOrg(List<BoaGroup> groups, BoaUser boa) {
|
||||
if (groups == null || groups.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
BoaGroup school = null;
|
||||
BoaGroup bureau = null;
|
||||
for (BoaGroup g : groups) {
|
||||
if (school == null && Objects.equals(g.getType(), GroupTypeEnum.SCHOOL_GROUP.getKey())) {
|
||||
school = g;
|
||||
}
|
||||
if (bureau == null && Objects.equals(g.getType(), GroupTypeEnum.AREA_GROUP.getKey())) {
|
||||
bureau = g;
|
||||
}
|
||||
}
|
||||
boa.setSchool(school);
|
||||
boa.setBureau(bureau);
|
||||
// 部门:主组(链的近端第一个);若主组本身为学校,则部门即学校
|
||||
boa.setDept(groups.get(0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user