commit 018abc6675a8fdaa93a2bd1e610d8bf08362bb6e Author: Kevin987 <2920370144@qq.com> Date: Mon Feb 2 21:24:59 2026 +0800 初始化 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28d0d57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +### Java / Maven ### +target/ +*.class +*.war +*.ear +*.jar +*.zip +*.tar.gz + +### IDEA ### +.idea/ +*.iml +*.iws +*.ipr +out/ +.shelf/ + +### Logs (很重要,高并发产生的日志别传) ### +*.log +logs/ + +### System / OS ### +.DS_Store +Thumbs.db + +### Frontend (如果你博客带前端代码) ### +node_modules/ +dist/ +.env.local \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..81d94f5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + com.blog + x-blog-backend + 1.0-SNAPSHOT + + + 17 + 17 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + com.mysql + mysql-connector-j + runtime + + + com.baomidou + mybatis-plus-spring-boot3-starter + 3.5.5 + + + org.flywaydb + flyway-mysql + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-validation + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + runtime + 0.11.5 + + + io.jsonwebtoken + jjwt-jackson + runtime + 0.11.5 + + + com.github.whvcse + easy-captcha + 1.6.2 + + + org.springframework.boot + spring-boot-starter-aop + + + com.google.guava + guava + 33.0.0-jre + + + + + + + org.springframework.boot + spring-boot-dependencies + 3.2.11 + pom + import + + + + \ No newline at end of file diff --git a/src/main/java/com/blog/App.java b/src/main/java/com/blog/App.java new file mode 100644 index 0000000..f18924c --- /dev/null +++ b/src/main/java/com/blog/App.java @@ -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); + } +} diff --git a/src/main/java/com/blog/aspect/UserAspect.java b/src/main/java/com/blog/aspect/UserAspect.java new file mode 100644 index 0000000..776e195 --- /dev/null +++ b/src/main/java/com/blog/aspect/UserAspect.java @@ -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("无效的验证码"); + } +} diff --git a/src/main/java/com/blog/common/Captcha.java b/src/main/java/com/blog/common/Captcha.java new file mode 100644 index 0000000..18f95ce --- /dev/null +++ b/src/main/java/com/blog/common/Captcha.java @@ -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; +} diff --git a/src/main/java/com/blog/common/CaptchaLimit.java b/src/main/java/com/blog/common/CaptchaLimit.java new file mode 100644 index 0000000..c2ac7c2 --- /dev/null +++ b/src/main/java/com/blog/common/CaptchaLimit.java @@ -0,0 +1,11 @@ +package com.blog.common; + +import lombok.Builder; +import lombok.Data; + +@Builder +@Data +public class CaptchaLimit { + + private long ttl; +} diff --git a/src/main/java/com/blog/common/R.java b/src/main/java/com/blog/common/R.java new file mode 100644 index 0000000..41f9f6e --- /dev/null +++ b/src/main/java/com/blog/common/R.java @@ -0,0 +1,33 @@ +package com.blog.common; + +import lombok.Data; + +@Data +public class R { + + 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 R ok(String msg) { + return new R<>(0,msg); + } + + public static R ok(String msg, T data) { + return new R<>(0,msg,data); + } + + public static R err(String msg) { + return new R<>(-1,msg); + } +} diff --git a/src/main/java/com/blog/config/AuthorizationServerConfig.java b/src/main/java/com/blog/config/AuthorizationServerConfig.java new file mode 100644 index 0000000..43b5776 --- /dev/null +++ b/src/main/java/com/blog/config/AuthorizationServerConfig.java @@ -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(); + } +} \ No newline at end of file diff --git a/src/main/java/com/blog/config/WebMvcConfig.java b/src/main/java/com/blog/config/WebMvcConfig.java new file mode 100644 index 0000000..513dc16 --- /dev/null +++ b/src/main/java/com/blog/config/WebMvcConfig.java @@ -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/**"); + } +} \ No newline at end of file diff --git a/src/main/java/com/blog/context/GlobalContext.java b/src/main/java/com/blog/context/GlobalContext.java new file mode 100644 index 0000000..9b2264c --- /dev/null +++ b/src/main/java/com/blog/context/GlobalContext.java @@ -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 captchas; + + private Map ipLimitPool; + + public GlobalContext() { + captchas = new ConcurrentHashMap<>(); + ipLimitPool = new ConcurrentHashMap<>(); + } +} diff --git a/src/main/java/com/blog/controller/GlobalExceptionHandler.java b/src/main/java/com/blog/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..8de4052 --- /dev/null +++ b/src/main/java/com/blog/controller/GlobalExceptionHandler.java @@ -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 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 handleException(Exception e) { + e.printStackTrace(); + return R.err("系统异常,请联系管理员"); + } + @ExceptionHandler(NoResourceFoundException.class) + public R handleException(NoResourceFoundException e) { + e.printStackTrace(); + return R.err("资源不存在"); + } +} diff --git a/src/main/java/com/blog/controller/UserController.java b/src/main/java/com/blog/controller/UserController.java new file mode 100644 index 0000000..d67fd9f --- /dev/null +++ b/src/main/java/com/blog/controller/UserController.java @@ -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); + } +} diff --git a/src/main/java/com/blog/dto/UserLoginDto.java b/src/main/java/com/blog/dto/UserLoginDto.java new file mode 100644 index 0000000..4930ed4 --- /dev/null +++ b/src/main/java/com/blog/dto/UserLoginDto.java @@ -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; + + +} diff --git a/src/main/java/com/blog/dto/UserRegisterDto.java b/src/main/java/com/blog/dto/UserRegisterDto.java new file mode 100644 index 0000000..feb8810 --- /dev/null +++ b/src/main/java/com/blog/dto/UserRegisterDto.java @@ -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; +} diff --git a/src/main/java/com/blog/entity/UserEntity.java b/src/main/java/com/blog/entity/UserEntity.java new file mode 100644 index 0000000..6eb1b65 --- /dev/null +++ b/src/main/java/com/blog/entity/UserEntity.java @@ -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(); + } +} diff --git a/src/main/java/com/blog/filter/TokenBucketFilter.java b/src/main/java/com/blog/filter/TokenBucketFilter.java new file mode 100644 index 0000000..1c011cd --- /dev/null +++ b/src/main/java/com/blog/filter/TokenBucketFilter.java @@ -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("系统繁忙,请稍后再试"))); + } +} diff --git a/src/main/java/com/blog/helper/UserHelper.java b/src/main/java/com/blog/helper/UserHelper.java new file mode 100644 index 0000000..d16965f --- /dev/null +++ b/src/main/java/com/blog/helper/UserHelper.java @@ -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 obtainJWT(UserEntity userEntity) { + Map 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 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 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(); + } +} diff --git a/src/main/java/com/blog/holder/GlobalContextHolder.java b/src/main/java/com/blog/holder/GlobalContextHolder.java new file mode 100644 index 0000000..e123afa --- /dev/null +++ b/src/main/java/com/blog/holder/GlobalContextHolder.java @@ -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; + } +} diff --git a/src/main/java/com/blog/initialization/GlobalContextInitialization.java b/src/main/java/com/blog/initialization/GlobalContextInitialization.java new file mode 100644 index 0000000..cd7b0ea --- /dev/null +++ b/src/main/java/com/blog/initialization/GlobalContextInitialization.java @@ -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); + } +} diff --git a/src/main/java/com/blog/interceptor/IpLimitInterceptor.java b/src/main/java/com/blog/interceptor/IpLimitInterceptor.java new file mode 100644 index 0000000..b9ea47f --- /dev/null +++ b/src/main/java/com/blog/interceptor/IpLimitInterceptor.java @@ -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 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() { +} diff --git a/src/main/java/com/blog/service/UserService.java b/src/main/java/com/blog/service/UserService.java new file mode 100644 index 0000000..4bbb4f8 --- /dev/null +++ b/src/main/java/com/blog/service/UserService.java @@ -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 login(UserLoginDto user); + + String register(UserRegisterDto userDto); + + boolean exist(String username); +} diff --git a/src/main/java/com/blog/service/impl/UserServiceImpl.java b/src/main/java/com/blog/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..f7e5ec7 --- /dev/null +++ b/src/main/java/com/blog/service/impl/UserServiceImpl.java @@ -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 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 login(UserLoginDto user) { + LambdaQueryWrapper 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 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().eq(UserEntity::getUsername, username)); + if (userEntity == null) return false; + return true; + } +} diff --git a/src/main/java/com/blog/task/GlobalCleanupTask.java b/src/main/java/com/blog/task/GlobalCleanupTask.java new file mode 100644 index 0000000..de06c0e --- /dev/null +++ b/src/main/java/com/blog/task/GlobalCleanupTask.java @@ -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 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 ipLimitPool = GlobalContextHolder.getGlobalContext().getIpLimitPool(); + ipLimitPool.forEach((k,v)->{ + if(v.getTtl()< Instant.now().getEpochSecond()){ + ipLimitPool.remove(k); + } + }); + } +} diff --git a/src/main/java/com/blog/utils/ChartsGenerator.java b/src/main/java/com/blog/utils/ChartsGenerator.java new file mode 100644 index 0000000..b45686d --- /dev/null +++ b/src/main/java/com/blog/utils/ChartsGenerator.java @@ -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(); + } +} diff --git a/src/main/java/com/blog/vaildator/UserValidator.java b/src/main/java/com/blog/vaildator/UserValidator.java new file mode 100644 index 0000000..147ae18 --- /dev/null +++ b/src/main/java/com/blog/vaildator/UserValidator.java @@ -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; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..be3145c --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,29 @@ +server: + port: 80 +spring: + application: + name: blog-backend + datasource: + url: jdbc:mysql://localhost:33400/x-bolg?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + username: root + password: g-o-n-g-q-i + driver-class-name: com.mysql.cj.jdbc.Driver + flyway: + # 是否启用 + enabled: true + # 脚本位置(默认即为 db/migration) + locations: classpath:db/migration + # 如果数据库非空且没有 flyway 表,是否自动初始化(基准线) + baseline-on-migrate: true + # 针对已有的数据库,设定版本号为 1 + baseline-version: 1 +logging: + level: + org.springframework.security: DEBUG + com.blog.mapper: DEBUG +mybatis-plus: + mapper-locations: classpath:/mapper/**.xml + configuration: + local-cache-scope: statement + cache-enabled: false + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl \ No newline at end of file diff --git a/src/main/resources/db/migration/V1__create_table.sql b/src/main/resources/db/migration/V1__create_table.sql new file mode 100644 index 0000000..c9bf1db --- /dev/null +++ b/src/main/resources/db/migration/V1__create_table.sql @@ -0,0 +1,102 @@ +-- 1. 用户表 (包含你要求的: 密码>6位、邀请码、状态、解禁日期等) +CREATE TABLE `sys_user` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `username` VARCHAR(64) NOT NULL COMMENT '用户名', + `password` VARCHAR(255) NOT NULL COMMENT '密码(BCrypt加密)', + `nickname` VARCHAR(64) NOT NULL COMMENT '用户昵称', + `email` VARCHAR(128) NOT NULL COMMENT '邮箱', + `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', + `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL', + `gender` TINYINT DEFAULT 0 COMMENT '性别: 1男, 2女, 0未知', + `enabled` TINYINT(1) DEFAULT 1 COMMENT '状态: 1启用, 0禁用', + `invite_code_id` VARCHAR(64) NOT NULL COMMENT '使用的邀请码ID', + `release_date` DATETIME DEFAULT NULL COMMENT '解禁日期', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后修改时间', + `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0-未删, 1-已删', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; + +-- 2. 角色表 +CREATE TABLE `sys_role` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(32) NOT NULL COMMENT '角色名称(如:超级管理员)', + `code` VARCHAR(32) NOT NULL COMMENT '角色标识(如:ROLE_ADMIN)', + `description` VARCHAR(255) DEFAULT NULL COMMENT '描述', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态(1:正常, 0:禁用)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `uk_code` (`code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; + +-- 3. 权限/菜单表 +CREATE TABLE `sys_menu` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `parent_id` BIGINT DEFAULT 0 COMMENT '父菜单ID', + `name` VARCHAR(64) NOT NULL COMMENT '权限名称', + `perms` VARCHAR(100) DEFAULT NULL COMMENT '权限标识(如: sys:user:add)', + `type` TINYINT NOT NULL COMMENT '类型(0:目录, 1:菜单, 2:按钮)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限菜单表'; + +-- 4. 用户-角色关联表 +CREATE TABLE `sys_user_role` ( + `user_id` BIGINT NOT NULL, + `role_id` BIGINT NOT NULL, + PRIMARY KEY (`user_id`, `role_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 5. 角色-权限关联表 +CREATE TABLE `sys_role_menu` ( + `role_id` BIGINT NOT NULL, + `menu_id` BIGINT NOT NULL, + PRIMARY KEY (`role_id`, `menu_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 6. 邀请码表 +CREATE TABLE `sys_invite_code` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `code` VARCHAR(32) NOT NULL COMMENT '唯一邀请码', + `creator_id` BIGINT DEFAULT NULL COMMENT '生成人ID', + `max_uses` INT NOT NULL DEFAULT 1 COMMENT '最大使用次数', + `used_count` INT NOT NULL DEFAULT 0 COMMENT '已使用次数', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-有效, 0-失效', + `expire_time` DATETIME DEFAULT NULL COMMENT '过期时间', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uk_code` (`code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='邀请码表'; + +-- 7. 文章表 +CREATE TABLE `sys_article` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `title` VARCHAR(255) NOT NULL COMMENT '标题', + `content` LONGTEXT NOT NULL COMMENT '内容', + `author_id` BIGINT NOT NULL COMMENT '作者ID', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-草稿, 1-发布', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` TINYINT NOT NULL DEFAULT 0, + KEY `idx_author` (`author_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表'; + +-- 初始化管理员角色 +INSERT INTO `sys_role` (`name`, `code`, `description`) +VALUES ('超级管理员', 'ROLE_ADMIN', '拥有系统所有权限'); + +-- 初始化管理员用户 (密码: admin123) +-- 注意: invite_code_id 不能为空,这里填 SYSTEM +INSERT INTO `sys_user` ( + `username`, `password`, `nickname`, `email`, `invite_code_id`, `enabled`, `is_deleted` +) VALUES ( + 'admin', + 'admin123', + '超级管理员', + 'admin@example.com', + 'SYSTEM', + 1, + 0 + ); + +-- 关联管理员与角色 (假设 ID 都是 1) +INSERT INTO `sys_user_role` (`user_id`, `role_id`) VALUES (1, 1); \ No newline at end of file