refactor: 统一实体/限流AOP化/精简日志/文件归位

This commit is contained in:
Ray.Hao
2026-06-24 08:11:23 +08:00
parent 0dce433849
commit 6b6c06370f
71 changed files with 751 additions and 914 deletions

View File

@@ -0,0 +1,22 @@
package com.youlai.boot.common.annotation;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* 接口限流
* <p>标注在 Controller 方法上,基于 Redis 计数窗口实现</p>
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
int limit() default 0;
int window() default 0;
TimeUnit timeUnit() default TimeUnit.SECONDS;
String prefix() default "rate_limit:";
}

View File

@@ -1,144 +0,0 @@
package com.youlai.boot.common.aspect;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.util.IPUtils;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.system.model.entity.SysLog;
import com.youlai.boot.system.service.LogService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.time.LocalDateTime;
/**
* 日志切面
*
* @author Ray.Hao
* @since 2.10.0
*/
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class LogAspect {
private final LogService logService;
/**
* 日志注解切点
*/
@Pointcut("@annotation(logAnnotation)")
public void logPointCut(Log logAnnotation) {
}
/**
* 环绕通知:记录操作日志
*/
@Around(value = "logPointCut(logAnnotation)", argNames = "pjp,logAnnotation")
public Object around(ProceedingJoinPoint pjp, Log logAnnotation) throws Throwable {
long startTime = System.currentTimeMillis();
// 在方法执行前获取用户信息,避免 logout 等操作清除 SecurityContext 后无法获取
Long userId = SecurityUtils.getUserId();
String username = SecurityUtils.getUsername();
Object result = null;
Exception exception = null;
try {
result = pjp.proceed();
return result;
} catch (Exception e) {
exception = e;
throw e;
} finally {
long executionTime = System.currentTimeMillis() - startTime;
// fallback登录等场景在 proceed() 前未认证,需在 proceed() 后获取
if (userId == null) {
userId = SecurityUtils.getUserId();
username = SecurityUtils.getUsername();
}
try {
saveLogAsync(logAnnotation, executionTime, exception, userId, username);
} catch (Exception ex) {
log.error("保存操作日志失败", ex);
}
}
}
/**
* 异步保存日志
*/
@Async
public void saveLogAsync(Log logAnnotation, long executionTime, Exception exception, Long userId, String username) {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return;
}
HttpServletRequest request = attributes.getRequest();
// 解析 User-Agent
String userAgentStr = request.getHeader("User-Agent");
UserAgent userAgent = UserAgentUtil.parse(userAgentStr);
// 解析 IP 地区
String ip = IPUtils.getIpAddr(request);
String region = IPUtils.getRegion(ip);
String province = null;
String city = null;
if (StrUtil.isNotBlank(region)) {
String[] parts = region.split("\\|");
if (parts.length >= 3) {
province = StrUtil.blankToDefault(parts[2], null);
city = StrUtil.blankToDefault(parts[3], null);
}
}
// 构建日志实体
LogModuleEnum module = logAnnotation.module();
ActionTypeEnum actionType = logAnnotation.value();
String title = StrUtil.blankToDefault(logAnnotation.title(),
module.getLabel() + "-" + actionType.getLabel());
String content = logAnnotation.content();
SysLog logEntity = new SysLog();
logEntity.setModule(module);
logEntity.setActionType(actionType);
logEntity.setTitle(title);
logEntity.setContent(content);
logEntity.setOperatorId(userId);
logEntity.setOperatorName(username);
logEntity.setRequestUri(request.getRequestURI());
logEntity.setRequestMethod(request.getMethod());
logEntity.setIp(ip);
logEntity.setProvince(province);
logEntity.setCity(city);
logEntity.setDevice(userAgent.getOs().getName());
logEntity.setOs(userAgent.getOs().getName());
logEntity.setBrowser(userAgent.getBrowser().getName());
logEntity.setStatus(exception == null ? 1 : 0);
logEntity.setErrorMsg(exception != null ? exception.getMessage() : null);
logEntity.setExecutionTime((int) executionTime);
logEntity.setCreateTime(LocalDateTime.now());
logService.save(logEntity);
} catch (Exception e) {
log.error("保存操作日志异常: {}", e.getMessage());
}
}
}

View File

@@ -12,7 +12,7 @@ public interface RedisConstants {
* 限流相关键
*/
interface RateLimiter {
String IP = "rate_limiter:ip:{}"; // IP限流示例rate_limiter:ip:192.168.1.1
String API = "{}rate_limit:{}:{}"; // 接口限流(示例:login:rate_limit:token:/api/v1/auth/login
}
/**

View File

@@ -23,10 +23,4 @@ public interface SystemConstants {
*/
String ROOT_ROLE_CODE = "ROOT";
/**
* 系统配置 IP的QPS限流的KEY
*/
String SYSTEM_CONFIG_IP_QPS_LIMIT_KEY = "IP_QPS_THRESHOLD_LIMIT";
}

View File

@@ -1,27 +0,0 @@
package com.youlai.boot.common.enums;
/**
* EasyCaptcha 验证码类型枚举
*
* @author haoxr
* @since 2.5.1
*/
public enum CaptchaTypeEnum {
/**
* 圆圈干扰验证码
*/
CIRCLE,
/**
* GIF验证码
*/
GIF,
/**
* 干扰线验证码
*/
LINE,
/**
* 扭曲干扰验证码
*/
SHEAR
}

View File

@@ -1,122 +0,0 @@
package com.youlai.boot.common.result;
import cn.hutool.extra.servlet.JakartaServletUtil;
import cn.hutool.json.JSONUtil;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import java.nio.charset.StandardCharsets;
/**
* 响应写入器
* <p>
* 用于在过滤器、Security处理器等无法使用 @RestControllerAdvice 的场景中统一写入HTTP响应。
* 支持写入成功响应和错误响应。
* 此类为工具类,所有方法均为静态方法,禁止实例化。
*
* @author Ray.Hao
* @since 2.0.0
*/
@Slf4j
public final class ResponseWriter {
/**
* 私有构造函数,防止实例化
*/
private ResponseWriter() {
throw new UnsupportedOperationException("工具类不允许实例化");
}
/**
* 写入成功响应
*
* @param response HttpServletResponse
* @param data 响应数据(可选)
*/
public static void writeSuccess(HttpServletResponse response, Object data) {
writeResult(response, Result.success(data), HttpStatus.OK.value());
}
/**
* 写入成功响应(无数据)
*
* @param response HttpServletResponse
*/
public static void writeSuccess(HttpServletResponse response) {
writeSuccess(response, null);
}
/**
* 写入错误响应
*
* @param response HttpServletResponse
* @param resultCode 响应结果码
*/
public static void writeError(HttpServletResponse response, ResultCode resultCode) {
writeError(response, resultCode, null);
}
/**
* 写入错误响应(带自定义消息)
*
* @param response HttpServletResponse
* @param resultCode 响应结果码
* @param message 自定义消息(可选,为 null 时使用 resultCode 的默认消息)
*/
public static void writeError(HttpServletResponse response, ResultCode resultCode, String message) {
Result<?> result = message == null
? Result.failed(resultCode)
: Result.failed(resultCode, message);
int httpStatus = mapHttpStatus(resultCode);
writeResult(response, result, httpStatus);
}
/**
* 写入响应结果(通用方法)
*
* @param response HttpServletResponse
* @param result 响应结果对象
* @param httpStatus HTTP状态码
*/
private static void writeResult(HttpServletResponse response, Result<?> result, int httpStatus) {
try {
// 设置HTTP状态码
response.setStatus(httpStatus);
// 设置响应编码和内容类型
response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
// 写入响应
JakartaServletUtil.write(response,
JSONUtil.toJsonStr(result),
MediaType.APPLICATION_JSON_VALUE
);
} catch (Exception e) {
log.error("写入响应时发生未知异常: httpStatus={}, result={}", httpStatus, result, e);
}
}
/**
* 根据业务结果码映射HTTP状态码
* 401: 未认证token无效/过期)
* 403: 权限不足
* 400: 其他业务错误
*
* @param resultCode 业务结果码
* @return HTTP状态码
*/
private static int mapHttpStatus(ResultCode resultCode) {
return switch (resultCode) {
case ACCESS_UNAUTHORIZED,
ACCESS_TOKEN_INVALID,
REFRESH_TOKEN_INVALID -> HttpStatus.UNAUTHORIZED.value();
case ACCESS_PERMISSION_EXCEPTION -> HttpStatus.FORBIDDEN.value();
default -> HttpStatus.BAD_REQUEST.value();
};
}
}