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;
import com.jomalls.custom.app.constant.Constants;
import com.jomalls.custom.enums.CodeEnum;
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 org.aspectj.lang.JoinPoint;
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.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.SysRoleDomainService;
import com.jomalls.custom.security.LoginUser;
......
package com.jomalls.custom.app.service.system.impl;
import com.jomalls.custom.app.service.system.SysConfigService;
import com.jomalls.custom.dal.entity.SysConfigEntity;
import com.jomalls.custom.domain.service.system.SysConfigDomainService;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* 参数配置 App Service 实现
* <p>
* 与 custom-back SysConfigService 对齐:优先从 Redis 缓存读取,缓存未命中时查 DB 并回写缓存
*
* @author Lizh
*/
......@@ -16,16 +24,70 @@ import org.springframework.stereotype.Service;
@RequiredArgsConstructor
public class SysConfigServiceImpl implements SysConfigService {
private static final String SYS_CONFIG_KEY = "sys_config:";
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
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
public boolean selectCaptchaEnabled() {
String value = selectConfigByKey("sys.account.captchaEnabled");
if (!StringUtils.hasText(value)) {
return true; // 默认开启
}
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;
import com.jomalls.custom.app.service.system.SysLogininforService;
import com.jomalls.custom.dal.entity.SysLogininforEntity;
import com.jomalls.custom.domain.service.system.SysLogininforDomainService;
import lombok.RequiredArgsConstructor;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Date;
/**
* 系统访问记录 App Service 实现
* <p>
* 异步记录登录日志,不阻塞主流程
* 与 custom-back AsyncFactory.recordLogininfor 对齐:异步记录登录日志,
* 采集 IP、浏览器、操作系统信息
*
* @author Lizh
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SysLogininforServiceImpl implements SysLogininforService {
private final SysLogininforDomainService sysLogininforDomainService;
@Autowired
private SysLogininforDomainService sysLogininforDomainService;
@Async
@Override
......@@ -34,10 +38,77 @@ public class SysLogininforServiceImpl implements SysLogininforService {
entity.setMsg(message);
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);
log.debug("记录登录日志: username={}, status={}, msg={}", username, status, message);
} catch (Exception e) {
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;
import com.jomalls.custom.app.exception.ServiceException;
import com.jomalls.custom.dal.entity.SysUserEntity;
import lombok.RequiredArgsConstructor;
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.stereotype.Component;
......@@ -16,21 +17,40 @@ import java.util.concurrent.TimeUnit;
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SysPasswordService {
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) {
// 此处不直接验证密码(由调用方验证),重点是重试锁定逻辑
// 实际由 SysLoginService 调用 matches + 记录重试
String username = user.getUserName();
Integer retryCount = getRetryCount(username);
if (retryCount == null) {
retryCount = 0;
}
if (retryCount >= maxRetryCount) {
throw new ServiceException("密码错误次数超过" + maxRetryCount + "次,账户锁定" + lockTime + "分钟");
}
}
/**
......@@ -38,11 +58,13 @@ public class SysPasswordService {
*/
public void recordLoginFailure(String username) {
if (stringRedisTemplate == null) return;
String key = PWD_ERR_CNT_KEY + username;
String key = getCacheKey(username);
Long retryCount = stringRedisTemplate.opsForValue().increment(key);
stringRedisTemplate.expire(key, LOCK_TIME_MINUTES, TimeUnit.MINUTES);
if (retryCount != null && retryCount > MAX_RETRY_COUNT) {
log.warn("用户[{}]密码错误次数超过{}次,账户锁定{}分钟", username, MAX_RETRY_COUNT, LOCK_TIME_MINUTES);
if (retryCount != null && retryCount == 1) {
stringRedisTemplate.expire(key, lockTime, TimeUnit.MINUTES);
}
if (retryCount != null && retryCount >= maxRetryCount) {
log.warn("用户[{}]密码错误次数超过{}次,账户锁定{}分钟", username, maxRetryCount, lockTime);
}
}
......@@ -51,13 +73,9 @@ public class SysPasswordService {
*/
public void checkLocked(String username) {
if (stringRedisTemplate == null) return;
String key = PWD_ERR_CNT_KEY + username;
String countStr = stringRedisTemplate.opsForValue().get(key);
if (countStr != null) {
int count = Integer.parseInt(countStr);
if (count > MAX_RETRY_COUNT) {
throw new ServiceException("账户已被锁定,请" + LOCK_TIME_MINUTES + "分钟后重试");
}
Integer retryCount = getRetryCount(username);
if (retryCount != null && retryCount >= maxRetryCount) {
throw new ServiceException("账户已被锁定,请" + lockTime + "分钟后重试");
}
}
......@@ -66,6 +84,20 @@ public class SysPasswordService {
*/
public void clearLoginRecord(String username) {
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;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
......@@ -37,7 +38,7 @@ public class SecurityConfig {
@Override
public String encode(CharSequence rawPassword) {
if (rawPassword == null || rawPassword.length() == 0) {
if (StringUtils.isBlank(rawPassword)) {
throw new IllegalArgumentException("密码不能为空");
}
return md5Hex(rawPassword.toString());
......@@ -45,8 +46,7 @@ public class SecurityConfig {
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (rawPassword == null || rawPassword.length() == 0
|| encodedPassword == null || encodedPassword.isEmpty()) {
if (StringUtils.isBlank(rawPassword) || StringUtils.isBlank(encodedPassword)) {
return false;
}
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