Commit f1db6a85 by Lizh

fix: SysConfig/SysLogininfor/SysPasswordService 完全对齐 custom-back

- SysConfigServiceImpl: 新增 Redis 缓存层 + @PostConstruct 启动加载全部配置
  (与 custom-back selectConfigByKey 一致的: Redis→DB→缓存回写 流程)
- SysPasswordService: 改用 @Value 可配置 maxRetryCount/lockTime
  (与 custom-back user.password.maxRetryCount/lockTime 对齐)
- SysLogininforServiceImpl: 新增 IP/浏览器/操作系统 采集
  (与 custom-back AsyncFactory.recordLogininfor 对齐:
   X-Forwarded-For→X-Real-IP→RemoteAddr, User-Agent 解析浏览器和OS)
- SysConfigService.selectCaptchaEnabled: 默认返回 true(与 custom-back 一致)

Co-Authored-By: Claude <noreply@anthropic.com>
parent 454eae15
...@@ -5,7 +5,7 @@ import com.jomalls.custom.app.annotation.RequiresRoles; ...@@ -5,7 +5,7 @@ import com.jomalls.custom.app.annotation.RequiresRoles;
import com.jomalls.custom.app.constant.Constants; import com.jomalls.custom.app.constant.Constants;
import com.jomalls.custom.enums.CodeEnum; import com.jomalls.custom.enums.CodeEnum;
import com.jomalls.custom.app.exception.ServiceException; import com.jomalls.custom.app.exception.ServiceException;
import com.jomalls.custom.app.service.PermissionService; import com.jomalls.custom.app.service.permission.PermissionService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint; import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Aspect;
......
package com.jomalls.custom.app.service; package com.jomalls.custom.app.service.permission;
/** /**
* 权限服务接口 * 权限服务接口
......
package com.jomalls.custom.app.service.impl; package com.jomalls.custom.app.service.permission.impl;
import com.jomalls.custom.app.constant.SecurityConstants; import com.jomalls.custom.app.constant.SecurityConstants;
import com.jomalls.custom.app.service.PermissionService; import com.jomalls.custom.app.service.permission.PermissionService;
import com.jomalls.custom.domain.service.system.SysMenuDomainService; import com.jomalls.custom.domain.service.system.SysMenuDomainService;
import com.jomalls.custom.domain.service.system.SysRoleDomainService; import com.jomalls.custom.domain.service.system.SysRoleDomainService;
import com.jomalls.custom.security.LoginUser; import com.jomalls.custom.security.LoginUser;
......
package com.jomalls.custom.app.service.system.impl; package com.jomalls.custom.app.service.system.impl;
import com.jomalls.custom.app.service.system.SysConfigService; import com.jomalls.custom.app.service.system.SysConfigService;
import com.jomalls.custom.dal.entity.SysConfigEntity;
import com.jomalls.custom.domain.service.system.SysConfigDomainService; import com.jomalls.custom.domain.service.system.SysConfigDomainService;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
/** /**
* 参数配置 App Service 实现 * 参数配置 App Service 实现
* <p>
* 与 custom-back SysConfigService 对齐:优先从 Redis 缓存读取,缓存未命中时查 DB 并回写缓存
* *
* @author Lizh * @author Lizh
*/ */
...@@ -16,16 +24,70 @@ import org.springframework.stereotype.Service; ...@@ -16,16 +24,70 @@ import org.springframework.stereotype.Service;
@RequiredArgsConstructor @RequiredArgsConstructor
public class SysConfigServiceImpl implements SysConfigService { public class SysConfigServiceImpl implements SysConfigService {
private static final String SYS_CONFIG_KEY = "sys_config:";
private final SysConfigDomainService sysConfigDomainService; private final SysConfigDomainService sysConfigDomainService;
private final StringRedisTemplate stringRedisTemplate;
/**
* 启动时加载全部参数配置到 Redis 缓存
*/
@PostConstruct
public void init() {
try {
loadingConfigCache();
log.info("系统参数配置缓存加载完成");
} catch (Exception e) {
log.warn("系统参数配置缓存加载失败: {}", e.getMessage());
}
}
@Override @Override
public String selectConfigByKey(String configKey) { public String selectConfigByKey(String configKey) {
return sysConfigDomainService.selectConfigByKey(configKey); // 优先从 Redis 读取
if (stringRedisTemplate != null) {
String cachedValue = stringRedisTemplate.opsForValue().get(getCacheKey(configKey));
if (StringUtils.hasText(cachedValue)) {
return cachedValue;
}
}
// 缓存未命中,查询数据库
String configValue = sysConfigDomainService.selectConfigByKey(configKey);
if (configValue != null && stringRedisTemplate != null) {
stringRedisTemplate.opsForValue().set(getCacheKey(configKey), configValue);
}
return configValue != null ? configValue : "";
} }
@Override @Override
public boolean selectCaptchaEnabled() { public boolean selectCaptchaEnabled() {
String value = selectConfigByKey("sys.account.captchaEnabled"); String value = selectConfigByKey("sys.account.captchaEnabled");
if (!StringUtils.hasText(value)) {
return true; // 默认开启
}
return "true".equals(value); return "true".equals(value);
} }
/**
* 加载全部参数配置到 Redis 缓存
*/
private void loadingConfigCache() {
if (stringRedisTemplate == null) {
return;
}
List<SysConfigEntity> configs = sysConfigDomainService.list();
for (SysConfigEntity config : configs) {
if (StringUtils.hasText(config.getConfigKey()) && StringUtils.hasText(config.getConfigValue())) {
stringRedisTemplate.opsForValue().set(
getCacheKey(config.getConfigKey()), config.getConfigValue());
}
}
}
/**
* 获取缓存 key(与 custom-back CacheConstants.SYS_CONFIG_KEY 对齐)
*/
private String getCacheKey(String configKey) {
return SYS_CONFIG_KEY + configKey;
}
} }
...@@ -3,26 +3,30 @@ package com.jomalls.custom.app.service.system.impl; ...@@ -3,26 +3,30 @@ package com.jomalls.custom.app.service.system.impl;
import com.jomalls.custom.app.service.system.SysLogininforService; import com.jomalls.custom.app.service.system.SysLogininforService;
import com.jomalls.custom.dal.entity.SysLogininforEntity; import com.jomalls.custom.dal.entity.SysLogininforEntity;
import com.jomalls.custom.domain.service.system.SysLogininforDomainService; import com.jomalls.custom.domain.service.system.SysLogininforDomainService;
import lombok.RequiredArgsConstructor; import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Date; import java.util.Date;
/** /**
* 系统访问记录 App Service 实现 * 系统访问记录 App Service 实现
* <p> * <p>
* 异步记录登录日志,不阻塞主流程 * 与 custom-back AsyncFactory.recordLogininfor 对齐:异步记录登录日志,
* 采集 IP、浏览器、操作系统信息
* *
* @author Lizh * @author Lizh
*/ */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor
public class SysLogininforServiceImpl implements SysLogininforService { public class SysLogininforServiceImpl implements SysLogininforService {
private final SysLogininforDomainService sysLogininforDomainService; @Autowired
private SysLogininforDomainService sysLogininforDomainService;
@Async @Async
@Override @Override
...@@ -34,10 +38,77 @@ public class SysLogininforServiceImpl implements SysLogininforService { ...@@ -34,10 +38,77 @@ public class SysLogininforServiceImpl implements SysLogininforService {
entity.setMsg(message); entity.setMsg(message);
entity.setLoginTime(new Date()); entity.setLoginTime(new Date());
// 采集客户端信息(与 custom-back AsyncFactory 对齐)
try {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
entity.setIpaddr(getIpAddr(request));
// 从 User-Agent 解析浏览器和操作系统
String userAgent = request.getHeader("User-Agent");
if (userAgent != null) {
parseUserAgent(userAgent, entity);
}
}
} catch (Exception e) {
log.debug("采集客户端信息失败: {}", e.getMessage());
}
sysLogininforDomainService.save(entity); sysLogininforDomainService.save(entity);
log.debug("记录登录日志: username={}, status={}, msg={}", username, status, message); log.debug("记录登录日志: username={}, status={}, msg={}", username, status, message);
} catch (Exception e) { } catch (Exception e) {
log.warn("记录登录日志失败: {}", e.getMessage()); log.warn("记录登录日志失败: {}", e.getMessage());
} }
} }
/**
* 获取客户端 IP(与 custom-back IpUtils.getIpAddr 对齐)
*/
private String getIpAddr(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 多级代理取第一个 IP
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
/**
* 从 User-Agent 解析浏览器和操作系统
*/
private void parseUserAgent(String userAgent, SysLogininforEntity entity) {
// 浏览器解析
if (userAgent.contains("Edg/")) {
entity.setBrowser("Edge");
} else if (userAgent.contains("Chrome/") && !userAgent.contains("Edg/")) {
entity.setBrowser("Chrome");
} else if (userAgent.contains("Firefox/")) {
entity.setBrowser("Firefox");
} else if (userAgent.contains("Safari/") && !userAgent.contains("Chrome/")) {
entity.setBrowser("Safari");
} else {
entity.setBrowser("Unknown");
}
// 操作系统解析
if (userAgent.contains("Windows NT")) {
entity.setOs("Windows");
} else if (userAgent.contains("Mac OS X")) {
entity.setOs("Mac OS");
} else if (userAgent.contains("Linux") && !userAgent.contains("Android")) {
entity.setOs("Linux");
} else if (userAgent.contains("Android")) {
entity.setOs("Android");
} else if (userAgent.contains("iPhone") || userAgent.contains("iPad")) {
entity.setOs("iOS");
} else {
entity.setOs("Unknown");
}
}
} }
...@@ -2,8 +2,9 @@ package com.jomalls.custom.app.service.system.impl; ...@@ -2,8 +2,9 @@ package com.jomalls.custom.app.service.system.impl;
import com.jomalls.custom.app.exception.ServiceException; import com.jomalls.custom.app.exception.ServiceException;
import com.jomalls.custom.dal.entity.SysUserEntity; import com.jomalls.custom.dal.entity.SysUserEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
...@@ -16,21 +17,40 @@ import java.util.concurrent.TimeUnit; ...@@ -16,21 +17,40 @@ import java.util.concurrent.TimeUnit;
*/ */
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor
public class SysPasswordService { public class SysPasswordService {
private static final String PWD_ERR_CNT_KEY = "pwd_err_cnt:"; private static final String PWD_ERR_CNT_KEY = "pwd_err_cnt:";
private static final int MAX_RETRY_COUNT = 5; // 最大重试次数
private static final int LOCK_TIME_MINUTES = 10; // 锁定时间(分钟)
private final StringRedisTemplate stringRedisTemplate; @Value("${user.password.maxRetryCount:5}")
private int maxRetryCount;
@Value("${user.password.lockTime:10}")
private int lockTime;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/** /**
* 验证密码,失败时累计重试次数并锁定 * 获取缓存键名
*/
private String getCacheKey(String username) {
return PWD_ERR_CNT_KEY + username;
}
/**
* 验证密码(与 custom-back SysPasswordService.validate 对齐)
* <p>
* 登录失败时累计重试次数并锁定账户
*/ */
public void validate(SysUserEntity user, String rawPassword) { public void validate(SysUserEntity user, String rawPassword) {
// 此处不直接验证密码(由调用方验证),重点是重试锁定逻辑 String username = user.getUserName();
// 实际由 SysLoginService 调用 matches + 记录重试 Integer retryCount = getRetryCount(username);
if (retryCount == null) {
retryCount = 0;
}
if (retryCount >= maxRetryCount) {
throw new ServiceException("密码错误次数超过" + maxRetryCount + "次,账户锁定" + lockTime + "分钟");
}
} }
/** /**
...@@ -38,11 +58,13 @@ public class SysPasswordService { ...@@ -38,11 +58,13 @@ public class SysPasswordService {
*/ */
public void recordLoginFailure(String username) { public void recordLoginFailure(String username) {
if (stringRedisTemplate == null) return; if (stringRedisTemplate == null) return;
String key = PWD_ERR_CNT_KEY + username; String key = getCacheKey(username);
Long retryCount = stringRedisTemplate.opsForValue().increment(key); Long retryCount = stringRedisTemplate.opsForValue().increment(key);
stringRedisTemplate.expire(key, LOCK_TIME_MINUTES, TimeUnit.MINUTES); if (retryCount != null && retryCount == 1) {
if (retryCount != null && retryCount > MAX_RETRY_COUNT) { stringRedisTemplate.expire(key, lockTime, TimeUnit.MINUTES);
log.warn("用户[{}]密码错误次数超过{}次,账户锁定{}分钟", username, MAX_RETRY_COUNT, LOCK_TIME_MINUTES); }
if (retryCount != null && retryCount >= maxRetryCount) {
log.warn("用户[{}]密码错误次数超过{}次,账户锁定{}分钟", username, maxRetryCount, lockTime);
} }
} }
...@@ -51,13 +73,9 @@ public class SysPasswordService { ...@@ -51,13 +73,9 @@ public class SysPasswordService {
*/ */
public void checkLocked(String username) { public void checkLocked(String username) {
if (stringRedisTemplate == null) return; if (stringRedisTemplate == null) return;
String key = PWD_ERR_CNT_KEY + username; Integer retryCount = getRetryCount(username);
String countStr = stringRedisTemplate.opsForValue().get(key); if (retryCount != null && retryCount >= maxRetryCount) {
if (countStr != null) { throw new ServiceException("账户已被锁定,请" + lockTime + "分钟后重试");
int count = Integer.parseInt(countStr);
if (count > MAX_RETRY_COUNT) {
throw new ServiceException("账户已被锁定,请" + LOCK_TIME_MINUTES + "分钟后重试");
}
} }
} }
...@@ -66,6 +84,20 @@ public class SysPasswordService { ...@@ -66,6 +84,20 @@ public class SysPasswordService {
*/ */
public void clearLoginRecord(String username) { public void clearLoginRecord(String username) {
if (stringRedisTemplate == null) return; if (stringRedisTemplate == null) return;
stringRedisTemplate.delete(PWD_ERR_CNT_KEY + username); stringRedisTemplate.delete(getCacheKey(username));
}
/**
* 获取当前重试次数
*/
private Integer getRetryCount(String username) {
if (stringRedisTemplate == null) return null;
String countStr = stringRedisTemplate.opsForValue().get(getCacheKey(username));
if (countStr == null) return null;
try {
return Integer.parseInt(countStr);
} catch (NumberFormatException e) {
return null;
}
} }
} }
package com.jomalls.custom.config; package com.jomalls.custom.config;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
...@@ -37,7 +38,7 @@ public class SecurityConfig { ...@@ -37,7 +38,7 @@ public class SecurityConfig {
@Override @Override
public String encode(CharSequence rawPassword) { public String encode(CharSequence rawPassword) {
if (rawPassword == null || rawPassword.length() == 0) { if (StringUtils.isBlank(rawPassword)) {
throw new IllegalArgumentException("密码不能为空"); throw new IllegalArgumentException("密码不能为空");
} }
return md5Hex(rawPassword.toString()); return md5Hex(rawPassword.toString());
...@@ -45,8 +46,7 @@ public class SecurityConfig { ...@@ -45,8 +46,7 @@ public class SecurityConfig {
@Override @Override
public boolean matches(CharSequence rawPassword, String encodedPassword) { public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (rawPassword == null || rawPassword.length() == 0 if (StringUtils.isBlank(rawPassword) || StringUtils.isBlank(encodedPassword)) {
|| encodedPassword == null || encodedPassword.isEmpty()) {
return false; return false;
} }
return Objects.equals(md5Hex(rawPassword.toString()), encodedPassword); return Objects.equals(md5Hex(rawPassword.toString()), encodedPassword);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment