refactor: 统一实体/限流AOP化/精简日志/文件归位
This commit is contained in:
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = "手机号不能为空")
|
||||
@@ -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")
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
* 手机号快捷登录
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:";
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
// 验证和解析过滤器
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,11 +58,4 @@ public interface ConfigService extends IService<Config> {
|
||||
*/
|
||||
boolean refreshCache();
|
||||
|
||||
/**
|
||||
* 获取系统配置
|
||||
* @param key 配置键
|
||||
* @return 配置值
|
||||
*/
|
||||
Object getSystemConfig(String key);
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -18,6 +18,7 @@
|
||||
t1.route_name,
|
||||
t1.route_path,
|
||||
t1.component,
|
||||
t1.external_url,
|
||||
t1.icon,
|
||||
t1.sort,
|
||||
t1.visible,
|
||||
|
||||
Reference in New Issue
Block a user