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

@@ -1,11 +1,12 @@
package com.youlai.boot.auth.controller;
import com.youlai.boot.auth.model.LoginReq;
import com.youlai.boot.auth.model.form.LoginForm;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.auth.service.AuthService;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import io.swagger.v3.oas.annotations.Operation;
@@ -41,7 +42,8 @@ public class AuthController {
@Operation(summary = "账号密码登录")
@PostMapping("/login")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
public Result<AuthenticationToken> login(@RequestBody @Valid LoginReq request) {
@RateLimit(limit = 5, window = 60)
public Result<AuthenticationToken> login(@RequestBody @Valid LoginForm request) {
AuthenticationToken authenticationToken = authService.login(request.getUsername(), request.getPassword());
return Result.success(authenticationToken);
}
@@ -59,6 +61,7 @@ public class AuthController {
@Operation(summary = "发送登录短信验证码")
@PostMapping("/sms/code")
@RateLimit(limit = 1, window = 60)
public Result<Void> sendSmsCode(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile
) {

View File

@@ -1,10 +1,11 @@
package com.youlai.boot.auth.controller;
import com.youlai.boot.auth.model.WxMaBindMobileReq;
import com.youlai.boot.auth.model.WxMaPhoneLoginReq;
import com.youlai.boot.auth.model.WxMaLoginResp;
import com.youlai.boot.auth.model.form.WxMaBindMobileForm;
import com.youlai.boot.auth.model.form.WxMaPhoneLoginForm;
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
import com.youlai.boot.auth.service.WxMaAuthService;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;
@@ -49,11 +50,12 @@ public class WxMaAuthController {
@Operation(summary = "静默登录", description = "通过微信 code 登录,已绑定用户直接返回 token未绑定用户返回 openid 需绑定手机号")
@PostMapping("/silent-login")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
public Result<WxMaLoginResp> silentLogin(
@RateLimit(limit = 10, window = 60)
public Result<WxMaLoginVO> silentLogin(
@Parameter(description = "微信登录凭证wx.login 获取)", required = true, example = "0xxx")
@RequestParam String code
) {
WxMaLoginResp result = wxMaAuthService.silentLogin(code);
WxMaLoginVO result = wxMaAuthService.silentLogin(code);
return Result.success(result);
}
@@ -67,7 +69,8 @@ public class WxMaAuthController {
@Operation(summary = "手机号快捷登录", description = "同时使用微信 code 和手机号授权 code 登录,适用于企业认证小程序")
@PostMapping("/phone-login")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
public Result<AuthenticationToken> phoneLogin(@Valid @RequestBody WxMaPhoneLoginReq req) {
@RateLimit(limit = 5, window = 60)
public Result<AuthenticationToken> phoneLogin(@Valid @RequestBody WxMaPhoneLoginForm req) {
AuthenticationToken result = wxMaAuthService.phoneLogin(req.getLoginCode(), req.getPhoneCode());
return Result.success(result);
}
@@ -82,7 +85,8 @@ public class WxMaAuthController {
@Operation(summary = "绑定手机号", description = "为静默登录用户绑定手机号,绑定成功后自动登录")
@PostMapping("/bind-mobile")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
public Result<AuthenticationToken> bindMobile(@Valid @RequestBody WxMaBindMobileReq req) {
@RateLimit(limit = 3, window = 60)
public Result<AuthenticationToken> bindMobile(@Valid @RequestBody WxMaBindMobileForm req) {
AuthenticationToken result = wxMaAuthService.bindMobile(req.getOpenid(), req.getMobile(), req.getSmsCode());
return Result.success(result);
}

View File

@@ -1,19 +1,15 @@
package com.youlai.boot.auth.model;
package com.youlai.boot.auth.model.form;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
/**
* 登录请求参数
*
* @author Ray.Hao
* @since 3.0.0
* 登录表单
*/
@Schema(description = "登录请求参数")
@Data
public class LoginReq {
public class LoginForm {
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
@NotBlank(message = "用户名不能为空")
@@ -28,4 +24,4 @@ public class LoginReq {
@Schema(description = "验证码", example = "123456")
private String captchaCode;
}
}

View File

@@ -1,21 +1,18 @@
package com.youlai.boot.auth.model;
package com.youlai.boot.auth.model.form;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 微信小程序绑定手机号请求
*
* @author Ray.Hao
* @since 3.0.0
* 微信小程序绑定手机号表单
*/
@Schema(description = "微信小程序绑定手机号请求")
@Data
public class WxMaBindMobileReq {
public class WxMaBindMobileForm {
@NotBlank(message = "openid 不能为空")
@Schema(description = "微信用户唯一标识(静默登录返回)", example = "oVBkZ0aYgDMDIywRdgPW8-joxXc4")
@Schema(description = "微信用户唯一标识", example = "oVBkZ0aYgDMDIywRdgPW8-joxXc4")
private String openid;
@NotBlank(message = "手机号不能为空")

View File

@@ -1,18 +1,15 @@
package com.youlai.boot.auth.model;
package com.youlai.boot.auth.model.form;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 微信小程序手机号快捷登录请求
*
* @author Ray.Hao
* @since 3.0.0
* 微信小程序手机号快捷登录表单
*/
@Schema(description = "微信小程序手机号快捷登录请求")
@Data
public class WxMaPhoneLoginReq {
public class WxMaPhoneLoginForm {
@NotBlank(message = "微信登录凭证不能为空")
@Schema(description = "微信登录凭证wx.login 获取)", example = "0xxx")

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.auth.model;
package com.youlai.boot.auth.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
@@ -8,16 +8,13 @@ import lombok.NoArgsConstructor;
/**
* 微信小程序登录响应
*
* @author Ray.Hao
* @since 2.4.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "微信小程序登录响应")
public class WxMaLoginResp {
public class WxMaLoginVO {
@Schema(description = "是否新用户")
private Boolean isNewUser;
@@ -25,7 +22,7 @@ public class WxMaLoginResp {
@Schema(description = "是否需要绑定手机号")
private Boolean needBindMobile;
@Schema(description = "微信openid(绑定手机号时需要)")
@Schema(description = "微信openid")
private String openid;
@Schema(description = "访问令牌")
@@ -39,4 +36,4 @@ public class WxMaLoginResp {
@Schema(description = "过期时间(秒)")
private Integer expiresIn;
}
}

View File

@@ -1,6 +1,6 @@
package com.youlai.boot.auth.service;
import com.youlai.boot.auth.model.WxMaLoginResp;
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
import com.youlai.boot.framework.security.model.AuthenticationToken;
/**
@@ -21,7 +21,7 @@ public interface WxMaAuthService {
* @param code 微信登录凭证wx.login 获取)
* @return 登录结果(成功返回 token需绑定返回 openid
*/
WxMaLoginResp silentLogin(String code);
WxMaLoginVO silentLogin(String code);
/**
* 手机号快捷登录

View File

@@ -5,7 +5,7 @@ import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult;
import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.model.WxMaLoginResp;
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
import com.youlai.boot.auth.service.WxMaAuthService;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.security.exception.NeedBindMobileException;
@@ -54,14 +54,14 @@ public class WxMaAuthServiceImpl implements WxMaAuthService {
* 静默登录
*/
@Override
public WxMaLoginResp silentLogin(String code) {
public WxMaLoginVO silentLogin(String code) {
WxMaAuthenticationToken token = new WxMaAuthenticationToken(code);
try {
Authentication authentication = authenticationManager.authenticate(token);
AuthenticationToken authToken = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return WxMaLoginResp.builder()
return WxMaLoginVO.builder()
.isNewUser(false)
.needBindMobile(false)
.accessToken(authToken.getAccessToken())
@@ -70,7 +70,7 @@ public class WxMaAuthServiceImpl implements WxMaAuthService {
.expiresIn(authToken.getExpiresIn())
.build();
} catch (NeedBindMobileException e) {
return WxMaLoginResp.builder()
return WxMaLoginVO.builder()
.isNewUser(true)
.needBindMobile(true)
.openid(e.getOpenid())

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();
};
}
}

View File

@@ -22,33 +22,16 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.time.LocalDateTime;
/**
* Aliyun 对象存储服务类
*
* @author haoxr
* @since 2.3.0
*/
@Component
@ConditionalOnProperty(value = "oss.type", havingValue = "aliyun")
@ConfigurationProperties(prefix = "oss.aliyun")
@RequiredArgsConstructor
@Data
public class AliyunFileService implements FileService {
/**
* 服务Endpoint
*/
public class AliyunFileServiceImpl implements FileService {
private String endpoint;
/**
* 访问凭据
*/
private String accessKeyId;
/**
* 凭据密钥
*/
private String accessKeySecret;
/**
* 存储桶名称
*/
private String bucketName;
private OSS aliyunOssClient;
@@ -61,27 +44,18 @@ public class AliyunFileService implements FileService {
@Override
@SneakyThrows
public FileInfo uploadFile(MultipartFile file) {
// 获取文件名称
String originalFilename = file.getOriginalFilename();
// 生成文件名(日期文件夹)
String suffix = FileUtil.getSuffix(originalFilename);
String uuid = IdUtil.simpleUUID();
String fileName = DateUtil.format(LocalDateTime.now(), "yyyyMMdd") + "/" + uuid + "." + suffix;
// try-with-resource 语法糖自动释放流
try (InputStream inputStream = file.getInputStream()) {
// 设置上传文件的元信息例如Content-Type
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
// 创建PutObjectRequest对象指定Bucket名称对象名称和输入流
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, fileName, inputStream, metadata);
// 上传文件
aliyunOssClient.putObject(putObjectRequest);
} catch (Exception e) {
throw new RuntimeException("文件上传失败");
}
// 获取文件访问路径
String fileUrl = "https://" + bucketName + "." + endpoint + "/" + fileName;
FileInfo fileInfo = new FileInfo();
fileInfo.setName(originalFilename);
@@ -92,8 +66,8 @@ public class AliyunFileService implements FileService {
@Override
public boolean deleteFile(String filePath) {
Assert.notBlank(filePath, "删除文件路径不能为空");
String fileHost = "https://" + bucketName + "." + endpoint; // 文件主机域名
String fileName = filePath.substring(fileHost.length() + 1); // +1 /占一个字符截断左闭右开
String fileHost = "https://" + bucketName + "." + endpoint;
String fileName = filePath.substring(fileHost.length() + 1);
aliyunOssClient.deleteObject(bucketName, fileName);
return true;
}

View File

@@ -19,49 +19,30 @@ import java.io.File;
import java.io.InputStream;
import java.time.LocalDateTime;
/**
* 本地存储服务类
*
* @author Theo
* @since 2024-12-09 17:11
*/
@Data
@Slf4j
@Component
@ConditionalOnProperty(value = "oss.type", havingValue = "local")
@ConfigurationProperties(prefix = "oss.local")
@RequiredArgsConstructor
public class LocalFileService implements FileService {
public class LocalFileServiceImpl implements FileService {
@Value("${oss.local.storage-path}")
private String storagePath;
/**
* 上传文件方法
*
* @param file 表单文件对象
* @return 文件信息
*/
@Override
public FileInfo uploadFile(MultipartFile file) {
// 获取文件名
String originalFilename = file.getOriginalFilename();
// 获取文件后缀
String suffix = FileUtil.getSuffix(originalFilename);
// 生成uuid
String fileName = IdUtil.simpleUUID()+ "." + suffix;;
// 生成文件名(日期文件夹)
String folder = DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN);
String filePrefix = storagePath.endsWith(File.separator) ? storagePath : storagePath + File.separator;
// try-with-resource 语法糖自动释放流
try (InputStream inputStream = file.getInputStream()) {
// 上传文件
FileUtil.writeFromStream(inputStream, filePrefix + folder + File.separator + fileName);
} catch (Exception e) {
log.error("文件上传失败", e);
throw new RuntimeException("文件上传失败");
}
// 获取文件访问路径因为这里是本地存储所以直接返回文件的相对路径需要前端自行处理访问前缀
String fileUrl = File.separator + folder + File.separator + fileName;
FileInfo fileInfo = new FileInfo();
fileInfo.setName(originalFilename);
@@ -69,24 +50,14 @@ public class LocalFileService implements FileService {
return fileInfo;
}
/**
* 删除文件
* @param filePath 文件完整URL
* @return 是否删除成功
*/
@Override
public boolean deleteFile(String filePath) {
//判断文件是否为空
if (filePath == null || filePath.isEmpty()) {
return false;
}
// 判断filepath是否为文件夹
if (FileUtil.isDirectory(storagePath + filePath)) {
// 禁止删除文件夹
return false;
}
// 删除文件
return FileUtil.del(storagePath + filePath);
}
}

View File

@@ -24,79 +24,39 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.time.LocalDateTime;
/**
* MinIO 文件上传服务类
*
* @author Ray.Hao
* @since 2023/6/2
*/
@Component
@ConditionalOnProperty(value = "oss.type", havingValue = "minio")
@ConfigurationProperties(prefix = "oss.minio")
@RequiredArgsConstructor
@Data
@Slf4j
public class MinioFileService implements FileService {
public class MinioFileServiceImpl implements FileService {
/**
* 服务Endpoint
*/
private String endpoint;
/**
* 访问凭据
*/
private String accessKey;
/**
* 凭据密钥
*/
private String secretKey;
/**
* 存储桶名称
*/
private String bucketName;
/**
* 自定义域名
*/
private String customDomain;
private MinioClient minioClient;
// 依赖注入完成之后执行初始化
@PostConstruct
public void init() {
minioClient = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
// 创建存储桶(存储桶不存在)
// createBucketIfAbsent(bucketName);
}
/**
* 上传文件
*
* @param file 表单文件对象
* @return 文件信息
*/
@Override
public FileInfo uploadFile(MultipartFile file) {
// 创建存储桶(存储桶不存在)如果有搭建好的minio服务建议放在init方法中
createBucketIfAbsent(bucketName);
// 文件原生名称
String originalFilename = file.getOriginalFilename();
// 文件后缀
String suffix = FileUtil.getSuffix(originalFilename);
// 文件夹名称
String dateFolder = DateUtil.format(LocalDateTime.now(), "yyyyMMdd");
// 文件名称
String fileName = IdUtil.simpleUUID() + "." + suffix;
// try-with-resource 语法糖自动释放流
try (InputStream inputStream = file.getInputStream()) {
// 文件上传
PutObjectArgs putObjectArgs = PutObjectArgs.builder()
.bucket(bucketName)
.object(dateFolder + "/"+ fileName)
@@ -105,21 +65,16 @@ public class MinioFileService implements FileService {
.build();
minioClient.putObject(putObjectArgs);
// 返回文件路径
String fileUrl;
// 未配置自定义域名
if (StrUtil.isBlank(customDomain)) {
// 获取文件URL
GetPresignedObjectUrlArgs getPresignedObjectUrlArgs = GetPresignedObjectUrlArgs.builder()
.bucket(bucketName)
.object(dateFolder + "/"+ fileName)
.method(Method.GET)
.build();
fileUrl = minioClient.getPresignedObjectUrl(getPresignedObjectUrlArgs);
fileUrl = fileUrl.substring(0, fileUrl.indexOf("?"));
} else {
// 配置自定义文件路径域名
fileUrl = customDomain + "/"+ bucketName + "/"+ dateFolder + "/"+ fileName;
}
@@ -133,31 +88,18 @@ public class MinioFileService implements FileService {
}
}
/**
* 删除文件
*
* @param filePath 文件完整路径
* @return 是否删除成功
*/
@Override
public boolean deleteFile(String filePath) {
Assert.notBlank(filePath, "删除文件路径不能为空");
try {
String fileName;
if (StrUtil.isNotBlank(customDomain)) {
// https://oss.youlai.tech/default/20221120/test.jpg 20221120/websocket.jpg
fileName = filePath.substring(customDomain.length() + 1 + bucketName.length() + 1); // 两个/占了2个字符长度
fileName = filePath.substring(customDomain.length() + 1 + bucketName.length() + 1);
} else {
// http://localhost:9000/default/20221120/test.jpg 20221120/websocket.jpg
fileName = filePath.substring(endpoint.length() + 1 + bucketName.length() + 1);
}
RemoveObjectArgs removeObjectArgs = RemoveObjectArgs.builder()
.bucket(bucketName)
.object(fileName)
.build();
minioClient.removeObject(removeObjectArgs);
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucketName).object(fileName).build());
return true;
} catch (Exception e) {
log.error("删除文件失败", e);
@@ -165,16 +107,7 @@ public class MinioFileService implements FileService {
}
}
/**
* PUBLIC桶策略
* 如果不配置则新建的存储桶默认是PRIVATE则存储桶文件会拒绝访问 Access Denied
*
* @param bucketName 存储桶名称
* @return 存储桶策略
*/
private static String publicBucketPolicy(String bucketName) {
// AWS的S3存储桶策略 JSON 格式 https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/userguide/example-bucket-policies.html
return "{\"Version\":\"2012-10-17\","
+ "\"Statement\":[{\"Effect\":\"Allow\","
+ "\"Principal\":{\"AWS\":[\"*\"]},"
@@ -185,26 +118,12 @@ public class MinioFileService implements FileService {
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}]}";
}
/**
* 创建存储桶(存储桶不存在)
*
* @param bucketName 存储桶名称
*/
@SneakyThrows
private void createBucketIfAbsent(String bucketName) {
BucketExistsArgs bucketExistsArgs = BucketExistsArgs.builder().bucket(bucketName).build();
if (!minioClient.bucketExists(bucketExistsArgs)) {
MakeBucketArgs makeBucketArgs = MakeBucketArgs.builder().bucket(bucketName).build();
minioClient.makeBucket(makeBucketArgs);
// 设置存储桶访问权限为PUBLIC 如果不配置则新建的存储桶默认是PRIVATE则存储桶文件会拒绝访问 Access Denied
SetBucketPolicyArgs setBucketPolicyArgs = SetBucketPolicyArgs
.builder()
.bucket(bucketName)
.config(publicBucketPolicy(bucketName))
.build();
minioClient.setBucketPolicy(setBucketPolicyArgs);
if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build())) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
minioClient.setBucketPolicy(SetBucketPolicyArgs.builder()
.bucket(bucketName).config(publicBucketPolicy(bucketName)).build());
}
}
}

View File

@@ -0,0 +1,14 @@
package com.youlai.boot.framework.captcha.enums;
/**
* EasyCaptcha 验证码类型
*
* @author haoxr
* @since 2.5.1
*/
public enum CaptchaTypeEnum {
CIRCLE,
GIF,
LINE,
SHEAR
}

View File

@@ -6,7 +6,7 @@ import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.enums.CaptchaTypeEnum;
import com.youlai.boot.framework.captcha.enums.CaptchaTypeEnum;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.captcha.config.CaptchaProperties;
import com.youlai.boot.framework.captcha.exception.CaptchaException;

View File

@@ -9,11 +9,7 @@ import org.springframework.mail.javamail.JavaMailSenderImpl;
import java.util.Properties;
/**
* MailConfig 配置类,用于手动配置和注入 JavaMailSender。
* 通过读取 MailProperties 类中配置的邮件相关属性来初始化 JavaMailSender。
* <p>
* 手动注入的原因是为了避免在使用 application-dev.yml 或其他非 application.yml 配置文件时,
* IDEA 提示无法找到 JavaMailSender 的 bean。
* 邮件发送配置
*
* @author Ray
* @since 2024/8/17
@@ -28,11 +24,6 @@ public class MailConfig {
this.mailProperties = mailProperties;
}
/**
* 创建并配置 JavaMailSender bean。
*
* @return 配置好的 JavaMailSender 实例
*/
@Bean
public JavaMailSender javaMailSender() {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();

View File

@@ -4,7 +4,7 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 邮件配置类,用于接收和存储邮件相关的配置属性
* 邮件配置属性
*
* @author Ray
* @since 2024/8/17
@@ -13,75 +13,38 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
public class MailProperties {
/**
* 邮件服务器主机名或 IP 地址。
* 例如smtp.example.com
*/
/** SMTP 服务器地址 */
private String host;
/**
* 邮件服务器端口号。
* 例如587
*/
/** SMTP 端口 */
private int port;
/**
* 用于连接邮件服务器的用户名。
* 例如your_email@example.com
*/
/** 发件人账号 */
private String username;
/**
* 用于连接邮件服务器的密码。
* 该密码应安全存储,不应在代码中硬编码。
*/
/** 发件人密码 */
private String password;
/**
* 邮件发送者地址。
*/
/** 发件人地址 */
private String from;
/**
* 邮件服务器的其他属性配置。
* 这些配置通常用于进一步定制邮件发送行为。
*/
/** SMTP 扩展属性 */
private Properties properties = new Properties();
/**
* 内部类,用于封装邮件服务器的详细配置。
* 包含 SMTP 相关的配置选项。
*/
@Data
public static class Properties {
/**
* SMTP 配置选项类。
* 包含认证、加密等与 SMTP 协议相关的配置。
*/
private Smtp smtp = new Smtp();
@Data
public static class Smtp {
/**
* 是否启用 SMTP 认证。
* 如果为 `true`,则需要提供有效的用户名和密码进行认证。
*/
/** 启用 SMTP 认证 */
private boolean auth;
/**
* STARTTLS 加密配置选项。
*/
private StartTls starttls = new StartTls();
@Data
public static class StartTls {
/**
* 是否启用 STARTTLS 加密。
* 如果为 `true`,在发送邮件时将启用 STARTTLS 协议进行加密传输。
*/
/** 启用 STARTTLS */
private boolean enable;
}
}

View File

@@ -17,34 +17,22 @@ import java.util.Map;
@Data
public class AliyunSmsProperties {
/**
* 阿里云账户的Access Key ID用于API请求认证
*/
/** Access Key ID */
private String accessKeyId;
/**
*阿里云账户的Access Key Secret用于API请求认证
*/
/** Access Key Secret */
private String accessKeySecret;
/**
* 阿里云短信服务API的域名 eg: dysmsapi.aliyuncs.com
*/
/** API 域名,如 dysmsapi.aliyuncs.com */
private String domain;
/**
* 阿里云服务的区域ID如cn-shanghai
*/
/** 区域,如 cn-shanghai */
private String regionId;
/**
* 短信签名,必须是已经在阿里云短信服务中注册并通过审核的
*/
/** 短信签名 */
private String signName;
/**
* 短信模板集合
*/
/** 短信模板集合key 为模板用途(login/register)value 为模板CODE */
private Map<String, String> templates;
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.integration.wxma;
package com.youlai.boot.framework.integration.wxma.config;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.binarywang.wx.miniapp.api.impl.WxMaServiceImpl;
@@ -17,21 +17,13 @@ import org.springframework.context.annotation.Configuration;
@EnableConfigurationProperties(WxMaProperties.class)
public class WxMaConfig {
/**
* 微信小程序服务
*
* @param properties 微信小程序配置属性
* @return {@link WxMaService}
*/
@Bean
public WxMaService wxMaService(WxMaProperties properties) {
WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl();
config.setAppid(properties.getAppid());
config.setSecret(properties.getSecret());
WxMaService service = new WxMaServiceImpl();
service.setWxMaConfig(config);
return service;
}
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.integration.wxma;
package com.youlai.boot.framework.integration.wxma.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -10,14 +10,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "wx.miniapp")
public class WxMaProperties {
/**
* 小程序 AppID
*/
private String appid;
/**
* 小程序 AppSecret
*/
private String secret;
}

View File

@@ -5,7 +5,7 @@ import com.baomidou.mybatisplus.core.config.GlobalConfig;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.youlai.boot.framework.mybatis.handler.MyMetaObjectHandler;
import com.youlai.boot.framework.mybatis.handler.AutoFillMetaObjectHandler;
import com.youlai.boot.framework.mybatis.interceptor.MyDataPermissionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -43,7 +43,7 @@ public class MybatisConfig {
@Bean
public GlobalConfig globalConfig() {
GlobalConfig globalConfig = new GlobalConfig();
globalConfig.setMetaObjectHandler(new MyMetaObjectHandler());
globalConfig.setMetaObjectHandler(new AutoFillMetaObjectHandler());
return globalConfig;
}

View File

@@ -9,36 +9,19 @@ import java.time.LocalDateTime;
/**
* mybatis-plus 字段自动填充
* <p>
* 支持自动填充创建时间更新时间
* </p>
*
* @author Ray.Hao
* @since 2022/10/14
*/
@Component
@RequiredArgsConstructor
public class MyMetaObjectHandler implements MetaObjectHandler {
public class AutoFillMetaObjectHandler implements MetaObjectHandler {
/**
* 新增填充创建时间更新时间
*
* @param metaObject 元数据
*/
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime::now, LocalDateTime.class);
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
}
/**
* 更新填充更新时间
*
* @param metaObject 元数据
*/
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
}
}

View File

@@ -3,7 +3,6 @@ package com.youlai.boot.framework.security.config;
import cn.binarywang.wx.miniapp.api.WxMaService;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import cn.hutool.core.util.ArrayUtil;
import com.youlai.boot.framework.web.filter.RateLimiterFilter;
import com.youlai.boot.framework.security.filter.CaptchaValidationFilter;
import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter;
import com.youlai.boot.framework.security.handler.MyAccessDeniedHandler;
@@ -12,12 +11,12 @@ import com.youlai.boot.framework.security.provider.SmsAuthenticationProvider;
import com.youlai.boot.framework.security.provider.WxMaAuthenticationProvider;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.framework.security.service.SysUserDetailsService;
import com.youlai.boot.system.service.ConfigService;
import com.youlai.boot.system.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
@@ -52,7 +51,6 @@ public class SecurityConfig {
private final SysUserDetailsService userDetailsService;
private final CaptchaService captchaService;
private final ConfigService configService;
private final SecurityProperties securityProperties;
/**
@@ -86,8 +84,6 @@ public class SecurityConfig {
.httpBasic(AbstractHttpConfigurer::disable) // 禁用 HTTP Basic 认证,避免弹窗式登录
// 禁用 X-Frame-Options 响应头,允许页面被嵌套到 iframe 中
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
// 限流过滤器
.addFilterBefore(new RateLimiterFilter(redisTemplate, configService), UsernamePasswordAuthenticationFilter.class)
// 验证码校验过滤器
.addFilterBefore(new CaptchaValidationFilter(captchaService), UsernamePasswordAuthenticationFilter.class)
// 验证和解析过滤器

View File

@@ -5,7 +5,7 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.result.ResponseWriter;
import com.youlai.boot.framework.web.util.ResponseWriter;
import com.youlai.boot.framework.captcha.exception.CaptchaException;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import jakarta.servlet.FilterChain;

View File

@@ -3,7 +3,7 @@ package com.youlai.boot.framework.security.filter;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.result.ResponseWriter;
import com.youlai.boot.framework.web.util.ResponseWriter;
import com.youlai.boot.framework.security.token.TokenManager;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.framework.security.handler;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.result.ResponseWriter;
import com.youlai.boot.framework.web.util.ResponseWriter;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.framework.security.handler;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.result.ResponseWriter;
import com.youlai.boot.framework.web.util.ResponseWriter;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.AuthenticationException;

View File

@@ -0,0 +1,135 @@
package com.youlai.boot.framework.web.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.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.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.concurrent.Executor;
/**
* 操作日志切面
* <p>@Log 标注的方法执行后异步写入 sys_log 表</p>
*/
@Aspect
@Component
@Slf4j
public class LogAspect {
private final LogService logService;
private final Executor operationLogExecutor;
public LogAspect(
LogService logService,
@Qualifier("operationLogExecutor") Executor operationLogExecutor
) {
this.logService = logService;
this.operationLogExecutor = operationLogExecutor;
}
@Pointcut("@annotation(logAnnotation)")
public void pointcut(Log logAnnotation) {
}
@Around(value = "pointcut(logAnnotation)", argNames = "jp,logAnnotation")
public Object around(ProceedingJoinPoint jp, Log logAnnotation) throws Throwable {
long start = System.currentTimeMillis();
Long userId = SecurityUtils.getUserId();
String username = SecurityUtils.getUsername();
Throwable failure = null;
try {
return jp.proceed();
} catch (Throwable e) {
failure = e;
throw e;
} finally {
long elapsed = System.currentTimeMillis() - start;
if (userId == null) {
userId = SecurityUtils.getUserId();
username = SecurityUtils.getUsername();
}
try {
SysLog entity = buildEntity(logAnnotation, elapsed, failure, userId, username);
if (entity != null) {
operationLogExecutor.execute(() -> {
try {
logService.save(entity);
} catch (Exception e) {
log.error("保存操作日志失败 title={} uri={}", entity.getTitle(), entity.getRequestUri(), e);
}
});
}
} catch (Exception ex) {
log.error("构建操作日志失败", ex);
}
}
}
private SysLog buildEntity(Log ann, long elapsed, Throwable failure, Long userId, String username) {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) return null;
HttpServletRequest req = attrs.getRequest();
UserAgent ua = UserAgentUtil.parse(req.getHeader("User-Agent"));
String ip = IPUtils.getIpAddr(req);
IpRegion region = parseRegion(IPUtils.getRegion(ip));
LogModuleEnum module = ann.module();
ActionTypeEnum action = ann.value();
SysLog entity = new SysLog();
entity.setModule(module);
entity.setActionType(action);
entity.setTitle(StrUtil.blankToDefault(ann.title(), module.getLabel() + "-" + action.getLabel()));
entity.setContent(ann.content());
entity.setOperatorId(userId);
entity.setOperatorName(username);
entity.setRequestUri(req.getRequestURI());
entity.setRequestMethod(req.getMethod());
entity.setIp(ip);
entity.setProvince(region.province);
entity.setCity(region.city);
entity.setDevice(getDevice(ua));
entity.setOs(getOs(ua));
entity.setBrowser(getBrowser(ua));
entity.setStatus(failure == null ? 1 : 0);
entity.setErrorMsg(failure == null ? null : failure.getMessage());
entity.setExecutionTime((int) Math.min(elapsed, Integer.MAX_VALUE));
entity.setCreateTime(LocalDateTime.now());
return entity;
}
private IpRegion parseRegion(String region) {
if (StrUtil.isBlank(region)) return IpRegion.EMPTY;
String[] parts = region.split("\\|");
if (parts.length < 4) return IpRegion.EMPTY;
return new IpRegion(StrUtil.blankToDefault(parts[2], null), StrUtil.blankToDefault(parts[3], null));
}
private String getOs(UserAgent ua) { return Optional.ofNullable(ua).map(UserAgent::getOs).map(os -> os.getName()).orElse(null); }
private String getDevice(UserAgent ua) { return Optional.ofNullable(ua).map(UserAgent::getPlatform).map(p -> p.getName()).orElse(null); }
private String getBrowser(UserAgent ua) { return Optional.ofNullable(ua).map(UserAgent::getBrowser).map(b -> b.getName()).orElse(null); }
private record IpRegion(String province, String city) {
static final IpRegion EMPTY = new IpRegion(null, null);
}
}

View File

@@ -0,0 +1,69 @@
package com.youlai.boot.framework.web.aspect;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.util.IPUtils;
import com.youlai.boot.framework.web.config.RateLimitProperties;
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.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.concurrent.TimeUnit;
/**
* 接口限流切面
* <p>对 @RateLimit 标注的方法做 Redis 计数,超阈值则拒绝</p>
*/
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class RateLimitAspect {
private final RedisTemplate<String, Object> redisTemplate;
private final RateLimitProperties rateLimitProperties;
@Around("@annotation(rateLimit)")
public Object handle(ProceedingJoinPoint jp, RateLimit rateLimit) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String key = buildKey(request, rateLimit);
int limit = rateLimit.limit() > 0 ? rateLimit.limit() : rateLimitProperties.getDefaultLimit();
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1) {
int window = rateLimit.window() > 0 ? rateLimit.window() : rateLimitProperties.getDefaultWindow();
redisTemplate.expire(key, window, rateLimit.timeUnit());
}
if (count != null && count > limit) {
log.warn("限流触发 key={} count={} limit={}", key, count, limit);
throw new BusinessException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
}
return jp.proceed();
}
private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
String user = resolveUser(request);
return StrUtil.format(RedisConstants.RateLimiter.API, rateLimit.prefix(), user, request.getRequestURI());
}
private String resolveUser(HttpServletRequest request) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StrUtil.isNotBlank(header) && header.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
return DigestUtil.sha256Hex(header.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()));
}
return IPUtils.getIpAddr(request);
}
}

View File

@@ -0,0 +1,29 @@
package com.youlai.boot.framework.web.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 操作日志线程池
*/
@Configuration
public class OperationLogExecutorConfig {
@Bean
public Executor operationLogExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setThreadNamePrefix("operation-log-");
executor.setCorePoolSize(1);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(1000);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(10);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1,18 @@
package com.youlai.boot.framework.web.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 限流配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "rate-limit")
public class RateLimitProperties {
private int defaultLimit = 5;
private int defaultWindow = 1;
}

View File

@@ -1,98 +0,0 @@
package com.youlai.boot.framework.web.filter;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SystemConstants;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.util.IPUtils;
import com.youlai.boot.common.result.ResponseWriter;
import com.youlai.boot.system.service.ConfigService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
/**
* IP 限流过滤器
*
* @author Theo
* @since 2024/08/10 14:38
*/
@Slf4j
public class RateLimiterFilter extends OncePerRequestFilter {
private final RedisTemplate<String, Object> redisTemplate;
private final ConfigService configService;
private static final long DEFAULT_IP_LIMIT = 10L; // 默认 IP 限流阈值
public RateLimiterFilter(RedisTemplate<String, Object> redisTemplate, ConfigService configService) {
this.redisTemplate = redisTemplate;
this.configService = configService;
}
/**
* 判断 IP 是否触发限流
* 默认限制同一 IP 每秒最多请求 10 次,可通过系统配置调整。
* 如果系统未配置限流阈值,默认跳过限流。
*
* @param ip IP 地址
* @return 是否限流true 表示限流false 表示未限流
*/
public boolean rateLimit(String ip) {
// 限流 Redis 键
String key = StrUtil.format(RedisConstants.RateLimiter.IP, ip);
// 自增请求计数
Long count = redisTemplate.opsForValue().increment(key);
if (count == null || count == 1) {
// 第一次访问时设置过期时间为 1 秒
redisTemplate.expire(key, 1, TimeUnit.SECONDS);
}
// 获取系统配置的限流阈值
Object systemConfig = configService.getSystemConfig(SystemConstants.SYSTEM_CONFIG_IP_QPS_LIMIT_KEY);
if (systemConfig == null) {
// 系统未配置限流,跳过限流逻辑
log.warn("系统未配置限流阈值,跳过限流");
return false;
}
// 转换系统配置为限流值,默认为 10
long limit = Convert.toLong(systemConfig, DEFAULT_IP_LIMIT);
return count != null && count > limit;
}
/**
* 执行 IP 限流逻辑
* 如果 IP 请求超出限制,直接返回限流响应;否则继续执行过滤器链。
*
* @param request 请求体
* @param response 响应体
* @param filterChain 过滤器链
*/
@Override
protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response,
@NotNull FilterChain filterChain) throws ServletException, IOException {
// 获取请求的 IP 地址
String ip = IPUtils.getIpAddr(request);
// 判断是否限流
if (rateLimit(ip)) {
// 返回限流错误信息
ResponseWriter.writeError(response, ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
return;
}
// 未触发限流,继续执行过滤器链
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,63 @@
package com.youlai.boot.framework.web.util;
import cn.hutool.extra.servlet.JakartaServletUtil;
import cn.hutool.json.JSONUtil;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.common.result.ResultCode;
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处理器等场景统一写入HTTP响应</p>
*/
@Slf4j
public final class ResponseWriter {
private ResponseWriter() {
throw new UnsupportedOperationException("工具类不允许实例化");
}
public static void writeSuccess(HttpServletResponse response, Object data) {
writeResult(response, Result.success(data), HttpStatus.OK.value());
}
public static void writeSuccess(HttpServletResponse response) {
writeSuccess(response, null);
}
public static void writeError(HttpServletResponse response, ResultCode resultCode) {
writeError(response, resultCode, null);
}
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);
}
private static void writeResult(HttpServletResponse response, Result<?> result, int httpStatus) {
try {
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={}", httpStatus, e);
}
}
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();
};
}
}

View File

@@ -8,31 +8,22 @@ import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 在线用户统计定时任务
* <p>
* 定时统计并广播当前在线用户数量到所有 SSE 客户端
* 在线用户统计
* <p>定时统计并广播当前在线用户数量</p>
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class OnlineUserCountJob {
public class OnlineUserCountTask {
private final SseSessionRegistry sessionRegistry;
private final SseService sseService;
/**
* 定时统计在线用户数并广播
* <p>
* 每3分钟执行一次推送当前在线用户数量
*/
@Scheduled(cron = "0 */3 * * * ?")
public void execute() {
int onlineCount = sessionRegistry.getOnlineUserCount();
int connectionCount = sessionRegistry.getTotalConnectionCount();
log.debug("定时统计:在线用户数={}, 总连接数={}", onlineCount, connectionCount);
// 发送在线用户数量
log.debug("在线用户数={} 总连接数={}", onlineCount, connectionCount);
sseService.sendOnlineCount();
}
}

View File

@@ -13,7 +13,7 @@ import lombok.EqualsAndHashCode;
* @since 2024-07-29 11:17:26
*/
@Data
@EqualsAndHashCode(callSuper = false)
@EqualsAndHashCode(callSuper = true)
@Schema(description = "系统配置")
@TableName("sys_config")
public class Config extends BaseEntity {

View File

@@ -4,8 +4,8 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 部门实体对象
@@ -14,8 +14,8 @@ import lombok.Setter;
* @since 2024/06/23
*/
@TableName("sys_dept")
@Getter
@Setter
@Data
@EqualsAndHashCode(callSuper = true)
public class Dept extends BaseEntity {
/**

View File

@@ -11,7 +11,7 @@ import lombok.EqualsAndHashCode;
* @author Ray.Hao
* @since 2022/12/17
*/
@EqualsAndHashCode(callSuper = false)
@EqualsAndHashCode(callSuper = true)
@TableName("sys_dict")
@Data
public class Dict extends BaseEntity {

View File

@@ -11,7 +11,7 @@ import lombok.EqualsAndHashCode;
* @author Ray.Hao
* @since 2022/12/17
*/
@EqualsAndHashCode(callSuper = false)
@EqualsAndHashCode(callSuper = true)
@TableName("sys_dict_item")
@Data
public class DictItem extends BaseEntity {

View File

@@ -2,10 +2,10 @@ package com.youlai.boot.system.model.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Getter;
import lombok.Setter;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.util.Map;
/**
@@ -15,15 +15,9 @@ import java.util.Map;
* @since 2023/3/6
*/
@TableName(value = "sys_menu", autoResultMap = true)
@Getter
@Setter
public class Menu {
/**
* 菜单ID
*/
@TableId(type = IdType.AUTO)
private Long id;
@Data
@EqualsAndHashCode(callSuper = true)
public class Menu extends BaseEntity {
/**
* 父菜单ID
*/
@@ -100,14 +94,4 @@ public class Menu {
@TableField(updateStrategy = FieldStrategy.ALWAYS, typeHandler = JacksonTypeHandler.class)
private Map<String, Object> params;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

View File

@@ -4,8 +4,8 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serial;
import java.time.LocalDateTime;
@@ -16,8 +16,8 @@ import java.time.LocalDateTime;
* @author Kylin
* @since 2024-08-27 10:31
*/
@Getter
@Setter
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_notice")
public class Notice extends BaseEntity {

View File

@@ -4,8 +4,8 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 角色实体
@@ -14,8 +14,8 @@ import lombok.Setter;
* @since 2024/6/23
*/
@TableName("sys_role")
@Getter
@Setter
@Data
@EqualsAndHashCode(callSuper = true)
public class Role extends BaseEntity {
/**

View File

@@ -2,15 +2,15 @@ package com.youlai.boot.system.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 用户实体
*/
@TableName("sys_user")
@Getter
@Setter
@Data
@EqualsAndHashCode(callSuper = true)
public class SysUser extends BaseEntity {
/**

View File

@@ -1,14 +1,12 @@
package com.youlai.boot.system.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@@ -18,37 +16,21 @@ import java.time.LocalDateTime;
* @author Kylin
* @since 2024-08-28 16:56
*/
@Getter
@Setter
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_user_notice")
public class UserNotice extends BaseEntity {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 公共通知id
*/
/** 公共通知id */
private Long noticeId;
/**
* 用户id
*/
/** 用户id */
private Long userId;
/**
* 读取状态0未读1已读
*/
/** 读取状态0未读1已读 */
private Integer isRead;
/**
* 用户阅读时间
*/
/** 用户阅读时间 */
private LocalDateTime readTime;
/**
* 逻辑删除标识(0-未删除 1-已删除)
*/
/** 逻辑删除标识(0-未删除 1-已删除) */
@TableLogic(value = "0", delval = "1")
private Integer isDeleted;
}

View File

@@ -58,11 +58,4 @@ public interface ConfigService extends IService<Config> {
*/
boolean refreshCache();
/**
* 获取系统配置
* @param key 配置键
* @return 配置值
*/
Object getSystemConfig(String key);
}

View File

@@ -142,18 +142,5 @@ public class ConfigServiceImpl extends ServiceImpl<ConfigMapper, Config> impleme
return false;
}
/**
* 获取系统配置
*
* @param key 配置键
* @return 配置值
*/
@Override
public Object getSystemConfig(String key) {
if (StringUtils.isNotBlank(key)) {
return redisTemplate.opsForHash().get(RedisConstants.System.CONFIG, key);
}
return null;
}
}

View File

@@ -68,6 +68,11 @@ mybatis-plus:
# 这个配置会将执行的sql打印出来在开发或测试的时候可以用
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 接口限流
rate-limit:
default-limit: 5
default-window: 1
# 安全配置
security:
session:

View File

@@ -67,6 +67,11 @@ mybatis-plus:
# 这个配置会将执行的sql打印出来在开发或测试的时候可以用
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 接口限流
rate-limit:
default-limit: 10
default-window: 1
# 安全配置
security:
session:

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.youlai.boot.platform.codegen.mapper.GenConfigMapper">
</mapper>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.youlai.boot.platform.codegen.mapper.GenTableColumnMapper">
</mapper>

View File

@@ -18,6 +18,7 @@
t1.route_name,
t1.route_path,
t1.component,
t1.external_url,
t1.icon,
t1.sort,
t1.visible,