初始化
This commit is contained in:
18
src/main/java/com/blog/App.java
Normal file
18
src/main/java/com/blog/App.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.blog;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@MapperScan("com.blog.mapper")
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
public class App {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(App.class, args);
|
||||
}
|
||||
}
|
||||
45
src/main/java/com/blog/aspect/UserAspect.java
Normal file
45
src/main/java/com/blog/aspect/UserAspect.java
Normal file
@@ -0,0 +1,45 @@
|
||||
package com.blog.aspect;
|
||||
|
||||
import com.blog.common.Captcha;
|
||||
import com.blog.common.R;
|
||||
import com.blog.dto.UserRegisterDto;
|
||||
import com.blog.holder.GlobalContextHolder;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
public class UserAspect {
|
||||
|
||||
@Around("execution(* com.blog.controller.UserController.register(..))")
|
||||
public Object registerBefore(ProceedingJoinPoint joinPoint) {
|
||||
Object[] args = joinPoint.getArgs();
|
||||
for (Object arg : args) {
|
||||
if(arg instanceof UserRegisterDto){
|
||||
UserRegisterDto userRegisterDto = (UserRegisterDto)arg;
|
||||
Captcha captcha = GlobalContextHolder.getGlobalContext().getCaptchas().get(userRegisterDto.getKey());
|
||||
if(captcha != null){
|
||||
if(captcha.getTtl()< Instant.now().getEpochSecond()){
|
||||
return R.err("验证码已过期");
|
||||
}
|
||||
if(!captcha.getText().equals(userRegisterDto.getVerificationCode())){
|
||||
return R.err("无效的验证码");
|
||||
}
|
||||
try {
|
||||
GlobalContextHolder.getGlobalContext().getCaptchas().remove(userRegisterDto.getKey());
|
||||
Object result = joinPoint.proceed();
|
||||
return result;
|
||||
} catch (Throwable e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
return R.err("无效的验证码");
|
||||
}
|
||||
}
|
||||
return R.err("无效的验证码");
|
||||
}
|
||||
}
|
||||
15
src/main/java/com/blog/common/Captcha.java
Normal file
15
src/main/java/com/blog/common/Captcha.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.blog.common;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class Captcha {
|
||||
|
||||
private String text;
|
||||
|
||||
private long ttl;
|
||||
}
|
||||
11
src/main/java/com/blog/common/CaptchaLimit.java
Normal file
11
src/main/java/com/blog/common/CaptchaLimit.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.blog.common;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class CaptchaLimit {
|
||||
|
||||
private long ttl;
|
||||
}
|
||||
33
src/main/java/com/blog/common/R.java
Normal file
33
src/main/java/com/blog/common/R.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.blog.common;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class R<T> {
|
||||
|
||||
private int code;
|
||||
private String msg;
|
||||
private T data;
|
||||
R(int code, String msg){
|
||||
this.code = code;
|
||||
this.msg = msg;
|
||||
}
|
||||
R(int code, String msg, T data){
|
||||
this.code = code;
|
||||
this.msg = msg;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
|
||||
public static <T> R<T> ok(String msg) {
|
||||
return new R<>(0,msg);
|
||||
}
|
||||
|
||||
public static <T> R<T> ok(String msg, T data) {
|
||||
return new R<>(0,msg,data);
|
||||
}
|
||||
|
||||
public static <T> R<T> err(String msg) {
|
||||
return new R<>(-1,msg);
|
||||
}
|
||||
}
|
||||
72
src/main/java/com/blog/config/AuthorizationServerConfig.java
Normal file
72
src/main/java/com/blog/config/AuthorizationServerConfig.java
Normal file
@@ -0,0 +1,72 @@
|
||||
package com.blog.config;
|
||||
|
||||
import com.blog.common.R;
|
||||
import com.blog.filter.TokenBucketFilter;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.RequestCacheConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.context.SecurityContextHolderFilter;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class AuthorizationServerConfig {
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private TokenBucketFilter tokenBucketFilter;
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
// 默认使用 BCrypt,它自带随机盐机制
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.addFilterBefore(tokenBucketFilter, SecurityContextHolderFilter.class)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/user/login",
|
||||
"/user/captcha/**",
|
||||
"/user/exist/**",
|
||||
"/user/register").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
.httpBasic(AbstractHttpConfigurer::disable)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
.requestCache(RequestCacheConfigurer::disable)
|
||||
.exceptionHandling(exceptions -> exceptions
|
||||
// 1. 处理未认证 (401)
|
||||
.authenticationEntryPoint((request, response, authException) -> {
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.setStatus(401);
|
||||
String json = objectMapper.writeValueAsString(R.err("无效的 token"));
|
||||
response.getWriter().write(json);
|
||||
})
|
||||
// 2. 处理权限不足 (403)
|
||||
.accessDeniedHandler((request, response, accessDeniedException) -> {
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.setStatus(403);
|
||||
String json = objectMapper.writeValueAsString(R.err("权限不足"));
|
||||
response.getWriter().write(json);
|
||||
})
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
24
src/main/java/com/blog/config/WebMvcConfig.java
Normal file
24
src/main/java/com/blog/config/WebMvcConfig.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.blog.config;
|
||||
|
||||
import com.blog.interceptor.IpLimitInterceptor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Autowired
|
||||
private IpLimitInterceptor ipLimitInterceptor;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// 注册拦截器
|
||||
registry.addInterceptor(ipLimitInterceptor)
|
||||
// 1. 添加拦截路径(需要限流的接口)
|
||||
.addPathPatterns("/user/captcha/**")
|
||||
// 2. 排除不拦截的路径(如静态资源)
|
||||
.excludePathPatterns("/static/**", "/assets/**");
|
||||
}
|
||||
}
|
||||
27
src/main/java/com/blog/context/GlobalContext.java
Normal file
27
src/main/java/com/blog/context/GlobalContext.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.blog.context;
|
||||
|
||||
import com.blog.common.Captcha;
|
||||
import com.blog.common.CaptchaLimit;
|
||||
import com.google.common.util.concurrent.RateLimiter;
|
||||
import lombok.Data;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
@Data
|
||||
public class GlobalContext {
|
||||
|
||||
// 全局限流:每秒允许 10 个请求
|
||||
private final RateLimiter globalLimiter = RateLimiter.create(100.0);
|
||||
|
||||
private Map<String, Captcha> captchas;
|
||||
|
||||
private Map<String, CaptchaLimit> ipLimitPool;
|
||||
|
||||
public GlobalContext() {
|
||||
captchas = new ConcurrentHashMap<>();
|
||||
ipLimitPool = new ConcurrentHashMap<>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.blog.controller;
|
||||
|
||||
import com.blog.common.R;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public R<String> handleValidationException(MethodArgumentNotValidException e) {
|
||||
e.printStackTrace();
|
||||
// 1. 从异常对象中拿到所有的错误字段信息
|
||||
BindingResult bindingResult = e.getBindingResult();
|
||||
|
||||
// 2. 提取第一条错误信息(或者拼接所有错误)
|
||||
String defaultMessage = bindingResult.getFieldError().getDefaultMessage();
|
||||
|
||||
// 3. 返回你自定义的错误格式
|
||||
return R.err(defaultMessage != null ? defaultMessage : "参数校验失败");
|
||||
}
|
||||
@ExceptionHandler(Exception.class)
|
||||
public R<String> handleException(Exception e) {
|
||||
e.printStackTrace();
|
||||
return R.err("系统异常,请联系管理员");
|
||||
}
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public R<String> handleException(NoResourceFoundException e) {
|
||||
e.printStackTrace();
|
||||
return R.err("资源不存在");
|
||||
}
|
||||
}
|
||||
65
src/main/java/com/blog/controller/UserController.java
Normal file
65
src/main/java/com/blog/controller/UserController.java
Normal file
@@ -0,0 +1,65 @@
|
||||
package com.blog.controller;
|
||||
|
||||
import com.blog.common.Captcha;
|
||||
import com.blog.common.R;
|
||||
import com.blog.dto.UserLoginDto;
|
||||
import com.blog.dto.UserRegisterDto;
|
||||
import com.blog.holder.GlobalContextHolder;
|
||||
import com.blog.service.UserService;
|
||||
import com.wf.captcha.GifCaptcha;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/user")
|
||||
public class UserController {
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@PostMapping("/register")
|
||||
public R register(@RequestBody @Valid UserRegisterDto userDto) {
|
||||
userService.register(userDto);
|
||||
return login(userDto);
|
||||
}
|
||||
|
||||
@GetMapping("/exist/{username}")
|
||||
public R exist(@PathVariable("username") String username) {
|
||||
boolean exist = userService.exist(username);
|
||||
if (exist) {
|
||||
return R.ok("用户已存在");
|
||||
}
|
||||
return R.err("用户不存在");
|
||||
}
|
||||
|
||||
@GetMapping("/test")
|
||||
public R test() {
|
||||
return R.ok("test");
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public R login(@Valid @RequestBody UserLoginDto user) {
|
||||
Object res = userService.login(user);
|
||||
if (res instanceof String) {
|
||||
return R.err(res.toString());
|
||||
}
|
||||
return R.ok("登录成功", res);
|
||||
}
|
||||
|
||||
@GetMapping("/captcha/{captchaId}")
|
||||
public R captcha(@PathVariable("captchaId") String captchaId) throws Exception {
|
||||
if (captchaId.isBlank() || captchaId.length() > 36) {
|
||||
return R.err("无效的 captchaId");
|
||||
}
|
||||
// 生成 GIF 类型的验证码 (三个参数:宽、高、位数)
|
||||
GifCaptcha specCaptcha = new GifCaptcha(130, 48, 4);
|
||||
// 获取验证码文本
|
||||
String text = specCaptcha.text().toLowerCase();
|
||||
GlobalContextHolder.getGlobalContext().getCaptchas().put(captchaId, Captcha.builder().text(text).ttl(Instant.now().getEpochSecond() + 60).build());
|
||||
String captchaBase64 = specCaptcha.toBase64();
|
||||
return R.ok("获取成功", captchaBase64);
|
||||
}
|
||||
}
|
||||
18
src/main/java/com/blog/dto/UserLoginDto.java
Normal file
18
src/main/java/com/blog/dto/UserLoginDto.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.blog.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UserLoginDto {
|
||||
@NotBlank(message = "用户名不能为空")
|
||||
@Size(min = 4, max = 20, message = "用户名长度必须在4-20位之间")
|
||||
private String username;
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Size(min = 6, max = 32, message = "密码长度必须在 6 到 32 位之间")
|
||||
private String password;
|
||||
|
||||
|
||||
}
|
||||
22
src/main/java/com/blog/dto/UserRegisterDto.java
Normal file
22
src/main/java/com/blog/dto/UserRegisterDto.java
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.blog.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UserRegisterDto extends UserLoginDto {
|
||||
|
||||
@NotBlank(message = "邮箱不能为空")
|
||||
@Pattern(regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", message = "邮箱格式不正确")
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "邀请码不能为空")
|
||||
private String inviteCode;
|
||||
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
private String verificationCode;
|
||||
|
||||
@NotBlank(message = "key 不能为空")
|
||||
private String key;
|
||||
}
|
||||
42
src/main/java/com/blog/entity/UserEntity.java
Normal file
42
src/main/java/com/blog/entity/UserEntity.java
Normal file
@@ -0,0 +1,42 @@
|
||||
package com.blog.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.blog.dto.UserRegisterDto;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
@TableName("sys_user")
|
||||
public class UserEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String username;
|
||||
private String password;
|
||||
private Integer enabled;
|
||||
private Integer gender;
|
||||
private String inviteCodeId;
|
||||
private String nickname;
|
||||
private String email;
|
||||
private String phone;
|
||||
private String avatar;
|
||||
private LocalDateTime releaseDate;
|
||||
private LocalDateTime createTime;
|
||||
private LocalDateTime updateTime;
|
||||
@TableLogic // 开启 MP 的逻辑删除支持
|
||||
private Integer isDeleted;
|
||||
|
||||
public static UserEntity castFromRegisterDto(UserRegisterDto userDto, String password) {
|
||||
return UserEntity.builder()
|
||||
.username(userDto.getUsername())
|
||||
.password(password)
|
||||
.email(userDto.getEmail())
|
||||
.inviteCodeId(userDto.getInviteCode())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
45
src/main/java/com/blog/filter/TokenBucketFilter.java
Normal file
45
src/main/java/com/blog/filter/TokenBucketFilter.java
Normal file
@@ -0,0 +1,45 @@
|
||||
package com.blog.filter;
|
||||
|
||||
import com.blog.common.R;
|
||||
import com.blog.holder.GlobalContextHolder;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.NonNull;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class TokenBucketFilter extends OncePerRequestFilter {
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
// 1. 尝试获取令牌 (tryAcquire 是非阻塞的)
|
||||
// 使用 if 判断,符合你的高性能流控思维
|
||||
if (GlobalContextHolder.getGlobalContext().getGlobalLimiter().tryAcquire()) {
|
||||
// 2. 拿到令牌,放行
|
||||
filterChain.doFilter(request, response);
|
||||
} else {
|
||||
// 3. 没拿到令牌,直接拒绝
|
||||
handleRateLimitError(response);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleRateLimitError(HttpServletResponse response) throws IOException {
|
||||
response.setStatus(429);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
// 使用内置 JSON 或直接写字符串
|
||||
response.getWriter().write(objectMapper.writeValueAsString(R.err("系统繁忙,请稍后再试")));
|
||||
}
|
||||
}
|
||||
51
src/main/java/com/blog/helper/UserHelper.java
Normal file
51
src/main/java/com/blog/helper/UserHelper.java
Normal file
@@ -0,0 +1,51 @@
|
||||
package com.blog.helper;
|
||||
|
||||
import com.blog.entity.UserEntity;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.SignatureAlgorithm;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.Key;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class UserHelper {
|
||||
public Map<String, String> obtainJWT(UserEntity userEntity) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("userId", userEntity.getId());
|
||||
claims.put("username", userEntity.getUsername());
|
||||
claims.put("nickname", userEntity.getNickname());
|
||||
|
||||
String accessToken = createToken(claims, 2*60);
|
||||
|
||||
// 3. 签发 Refresh Token (有效期长,如 7 天)
|
||||
// 提示:Refresh Token 通常可以只存一个 userId,减少体积
|
||||
String refreshToken = createToken(Map.of("userId", userEntity.getId()), 1 * 24 * 60);
|
||||
|
||||
Map<String, String> tokens = Map.of(
|
||||
"accessToken", accessToken,
|
||||
"refreshToken", refreshToken,
|
||||
"tokenType", "Bearer"
|
||||
);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// 密钥至少 32 个字符
|
||||
private static final String SECRET = "your-super-secret-key-at-least-32-chars-long";
|
||||
private static final Key KEY = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
public static String createToken(Map<String, Object> claims, int minutes) {
|
||||
long now = System.currentTimeMillis();
|
||||
return Jwts.builder()
|
||||
.setClaims(claims)
|
||||
.setIssuedAt(new Date(now))
|
||||
.setExpiration(new Date(now + (long) minutes * 60 * 1000))
|
||||
.signWith(KEY, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
}
|
||||
14
src/main/java/com/blog/holder/GlobalContextHolder.java
Normal file
14
src/main/java/com/blog/holder/GlobalContextHolder.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.blog.holder;
|
||||
|
||||
import com.blog.context.GlobalContext;
|
||||
|
||||
public class GlobalContextHolder {
|
||||
|
||||
private static GlobalContext globalContext;
|
||||
public static GlobalContext getGlobalContext() {
|
||||
return globalContext;
|
||||
}
|
||||
public static void setGlobalContext(GlobalContext globalContext) {
|
||||
GlobalContextHolder.globalContext = globalContext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.blog.initialization;
|
||||
|
||||
import com.blog.context.GlobalContext;
|
||||
import com.blog.holder.GlobalContextHolder;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class GlobalContextInitialization {
|
||||
|
||||
@PostConstruct
|
||||
public void initGlobalContext(){
|
||||
GlobalContext globalContext = new GlobalContext();
|
||||
GlobalContextHolder.setGlobalContext(globalContext);
|
||||
}
|
||||
}
|
||||
56
src/main/java/com/blog/interceptor/IpLimitInterceptor.java
Normal file
56
src/main/java/com/blog/interceptor/IpLimitInterceptor.java
Normal file
@@ -0,0 +1,56 @@
|
||||
package com.blog.interceptor;
|
||||
|
||||
import com.blog.common.CaptchaLimit;
|
||||
import com.blog.common.R;
|
||||
import com.blog.holder.GlobalContextHolder;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.NonNull;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class IpLimitInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) throws IOException {
|
||||
String ip = getClientIp(request);
|
||||
long nowSed = Instant.now().getEpochSecond();
|
||||
// 直接从全局上下文中操作 ipLimitPool
|
||||
Map<String, CaptchaLimit> pool = GlobalContextHolder.getGlobalContext().getIpLimitPool();
|
||||
CaptchaLimit limit = pool.get(ip);
|
||||
|
||||
// --- if 判断控制流 ---
|
||||
if (limit != null && limit.getTtl()>nowSed) {
|
||||
// 拒绝请求,不抛异常,直接返回 false
|
||||
handleBlocked(response);
|
||||
return false;
|
||||
}
|
||||
// --- 允许访问:更新上下文 ---
|
||||
// 使用简单的对象封装或直接存 Long 均可
|
||||
if (limit == null||limit.getTtl()<nowSed) {
|
||||
pool.put(ip, CaptchaLimit.builder().ttl(nowSed +30).build());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void handleBlocked(HttpServletResponse response) throws IOException {
|
||||
response.setStatus(429);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write(objectMapper.writeValueAsString(R.err("30秒内请勿重复操作")));
|
||||
}
|
||||
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Real-IP");
|
||||
return (ip == null) ? request.getRemoteAddr() : ip;
|
||||
}
|
||||
}
|
||||
9
src/main/java/com/blog/mapper/UserMapper.java
Normal file
9
src/main/java/com/blog/mapper/UserMapper.java
Normal file
@@ -0,0 +1,9 @@
|
||||
package com.blog.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.blog.entity.UserEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface UserMapper extends BaseMapper<UserEntity> {
|
||||
}
|
||||
14
src/main/java/com/blog/service/UserService.java
Normal file
14
src/main/java/com/blog/service/UserService.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.blog.service;
|
||||
|
||||
import com.blog.dto.UserLoginDto;
|
||||
import com.blog.dto.UserRegisterDto;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
public interface UserService {
|
||||
|
||||
<T> T login(UserLoginDto user);
|
||||
|
||||
String register(UserRegisterDto userDto);
|
||||
|
||||
boolean exist(String username);
|
||||
}
|
||||
76
src/main/java/com/blog/service/impl/UserServiceImpl.java
Normal file
76
src/main/java/com/blog/service/impl/UserServiceImpl.java
Normal file
@@ -0,0 +1,76 @@
|
||||
package com.blog.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.blog.dto.UserLoginDto;
|
||||
import com.blog.dto.UserRegisterDto;
|
||||
import com.blog.entity.UserEntity;
|
||||
import com.blog.helper.UserHelper;
|
||||
import com.blog.mapper.UserMapper;
|
||||
import com.blog.service.UserService;
|
||||
import com.blog.utils.ChartsGenerator;
|
||||
import com.blog.vaildator.UserValidator;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
|
||||
@Service
|
||||
public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> implements UserService {
|
||||
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
|
||||
@Autowired
|
||||
private UserHelper userHelper;
|
||||
|
||||
@Autowired
|
||||
private UserValidator userValidator;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Autowired
|
||||
private TransactionTemplate transactionTemplate;
|
||||
|
||||
|
||||
@Override
|
||||
public <T> T login(UserLoginDto user) {
|
||||
LambdaQueryWrapper<UserEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
|
||||
lambdaQueryWrapper.eq(UserEntity::getUsername, user.getUsername());
|
||||
UserEntity userEntity = userMapper.selectOne(lambdaQueryWrapper);
|
||||
String errMsg = userValidator.validateUser(user,userEntity);
|
||||
if(errMsg!=null) return (T) errMsg;
|
||||
return (T) userHelper.obtainJWT(userEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String register(UserRegisterDto userDto) {
|
||||
LambdaQueryWrapper<UserEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
|
||||
lambdaQueryWrapper.eq(UserEntity::getUsername, userDto.getUsername());
|
||||
UserEntity userEntity = userMapper.selectOne(lambdaQueryWrapper);
|
||||
String errMsg = userValidator.validateRegister(userEntity);
|
||||
if (errMsg != null) return errMsg;
|
||||
UserEntity newUser = UserEntity.castFromRegisterDto(userDto,passwordEncoder.encode(userDto.getPassword()));
|
||||
newUser.setNickname("游客"+ ChartsGenerator.generateCode());
|
||||
transactionTemplate.execute(status -> {
|
||||
try {
|
||||
userMapper.insert(newUser);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
status.setRollbackOnly(); // 显式标记回滚
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exist(String username) {
|
||||
UserEntity userEntity = userMapper.selectOne(new LambdaQueryWrapper<UserEntity>().eq(UserEntity::getUsername, username));
|
||||
if (userEntity == null) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
37
src/main/java/com/blog/task/GlobalCleanupTask.java
Normal file
37
src/main/java/com/blog/task/GlobalCleanupTask.java
Normal file
@@ -0,0 +1,37 @@
|
||||
package com.blog.task;
|
||||
|
||||
import com.blog.common.Captcha;
|
||||
import com.blog.common.CaptchaLimit;
|
||||
import com.blog.holder.GlobalContextHolder;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class GlobalCleanupTask {
|
||||
|
||||
@Async
|
||||
@Scheduled(fixedRate = 60000)
|
||||
public void purgeCaptcha() {
|
||||
Map<String, Captcha> captchas = GlobalContextHolder.getGlobalContext().getCaptchas();
|
||||
captchas.forEach((k,v)->{
|
||||
if(v.getTtl()< Instant.now().getEpochSecond()){
|
||||
captchas.remove(k);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Async
|
||||
@Scheduled(fixedRate = 60000)
|
||||
public void purgeIpLimit() {
|
||||
Map<String, CaptchaLimit> ipLimitPool = GlobalContextHolder.getGlobalContext().getIpLimitPool();
|
||||
ipLimitPool.forEach((k,v)->{
|
||||
if(v.getTtl()< Instant.now().getEpochSecond()){
|
||||
ipLimitPool.remove(k);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
18
src/main/java/com/blog/utils/ChartsGenerator.java
Normal file
18
src/main/java/com/blog/utils/ChartsGenerator.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.blog.utils;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
public class ChartsGenerator {
|
||||
|
||||
private static final String CHAR_POOL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
public static String generateCode() {
|
||||
Random random = new Random();
|
||||
StringBuilder sb = new StringBuilder(6);
|
||||
for (int i = 0; i < 6; i++) {
|
||||
int index = random.nextInt(CHAR_POOL.length());
|
||||
sb.append(CHAR_POOL.charAt(index));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
36
src/main/java/com/blog/vaildator/UserValidator.java
Normal file
36
src/main/java/com/blog/vaildator/UserValidator.java
Normal file
@@ -0,0 +1,36 @@
|
||||
package com.blog.vaildator;
|
||||
import com.blog.dto.UserLoginDto;
|
||||
import com.blog.entity.UserEntity;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Component
|
||||
public class UserValidator {
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
public String validateUser(UserLoginDto user, UserEntity userEntity) {
|
||||
if (userEntity == null) {
|
||||
return "用户名不存在";
|
||||
}
|
||||
boolean isMatch = passwordEncoder.matches(user.getPassword(), userEntity.getPassword());
|
||||
if (!isMatch) {
|
||||
return "密码错误";
|
||||
}
|
||||
if (userEntity.getEnabled() == 0) return "账号已被禁用";
|
||||
if (userEntity.getReleaseDate() != null && userEntity.getReleaseDate().isAfter(LocalDateTime.now())) {
|
||||
return ("账号封禁中,解禁日期:" + userEntity.getReleaseDate());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String validateRegister(UserEntity userEntity) {
|
||||
if(userEntity!=null){
|
||||
return "用户名已存在";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user