初始化

This commit is contained in:
2026-02-02 21:24:59 +08:00
commit 018abc6675
28 changed files with 1052 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -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

93
pom.xml Normal file
View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.blog</groupId>
<artifactId>x-blog-backend</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
<version>1.6.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.0.0-jre</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.2.11</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>

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

View 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("无效的验证码");
}
}

View 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;
}

View File

@@ -0,0 +1,11 @@
package com.blog.common;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class CaptchaLimit {
private long ttl;
}

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

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

View 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/**");
}
}

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

View File

@@ -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("资源不存在");
}
}

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

View 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;
}

View 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;
}

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

View 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("系统繁忙,请稍后再试")));
}
}

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

View 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;
}
}

View File

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

View 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;
}
}

View 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> {
}

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

View 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;
}
}

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

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

View 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;
}
}

View File

@@ -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

View File

@@ -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);