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