对象存储-初始化

This commit is contained in:
2026-02-05 16:27:23 +08:00
parent 018abc6675
commit ab574032c0
40 changed files with 935 additions and 237 deletions

10
pom.xml
View File

@@ -15,6 +15,16 @@
</properties> </properties>
<dependencies> <dependencies>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>

View File

@@ -0,0 +1,35 @@
package com.blog.aspect;
import com.blog.common.User;
import com.blog.common.constant.UserConstant;
import com.blog.holder.RequestImplicitContextHolder;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class CommonAspect {
@Order(100)
@Around("execution(* com.blog.controller.*.*(..))"+
"&& !within(com.blog.controller.GlobalExceptionHandler)")
public Object buildRequestImplicitContext(ProceedingJoinPoint joinPoint){
try {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if(principal!=null && !principal.equals(UserConstant.ANONYMOUS_USER) && principal instanceof User){
User currentUser = (User) principal;
RequestImplicitContextHolder.setUserId(currentUser.getUserId());
RequestImplicitContextHolder.setUsername(currentUser.getUsername());
}
return joinPoint.proceed();
} catch (Throwable e) {
throw new RuntimeException(e);
} finally {
RequestImplicitContextHolder.clear();
}
}
}

View File

@@ -2,11 +2,13 @@ package com.blog.aspect;
import com.blog.common.Captcha; import com.blog.common.Captcha;
import com.blog.common.R; import com.blog.common.R;
import com.blog.dto.UserLoginDto;
import com.blog.dto.UserRegisterDto; import com.blog.dto.UserRegisterDto;
import com.blog.holder.GlobalContextHolder; import com.blog.holder.GlobalContextHolder;
import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.Instant; import java.time.Instant;
@@ -15,7 +17,9 @@ import java.time.Instant;
@Component @Component
public class UserAspect { public class UserAspect {
@Around("execution(* com.blog.controller.UserController.register(..))") @Order(101)
@Around("execution(* com.blog.controller.UserController.register(..))"+
"|| execution(* com.blog.controller.UserController.login(..))")
public Object registerBefore(ProceedingJoinPoint joinPoint) { public Object registerBefore(ProceedingJoinPoint joinPoint) {
Object[] args = joinPoint.getArgs(); Object[] args = joinPoint.getArgs();
for (Object arg : args) { for (Object arg : args) {
@@ -26,7 +30,27 @@ public class UserAspect {
if(captcha.getTtl()< Instant.now().getEpochSecond()){ if(captcha.getTtl()< Instant.now().getEpochSecond()){
return R.err("验证码已过期"); return R.err("验证码已过期");
} }
if(!captcha.getText().equals(userRegisterDto.getVerificationCode())){ if(!captcha.getText().equalsIgnoreCase(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("无效的验证码");
}
if(arg instanceof UserLoginDto){
UserLoginDto userRegisterDto = (UserLoginDto)arg;
Captcha captcha = GlobalContextHolder.getGlobalContext().getCaptchas().get(userRegisterDto.getKey());
if(captcha != null){
if(captcha.getTtl()< Instant.now().getEpochSecond()){
return R.err("验证码已过期");
}
if(!captcha.getText().equalsIgnoreCase(userRegisterDto.getVerificationCode())){
return R.err("无效的验证码"); return R.err("无效的验证码");
} }
try { try {

View File

@@ -0,0 +1,12 @@
package com.blog.common;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class User {
private Long userId;
private String username;
}

View File

@@ -0,0 +1,12 @@
package com.blog.common;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class UserSession {
private Long userId;
private Long ttl;
}

View File

@@ -0,0 +1,27 @@
package com.blog.common.constant;
public class UserConstant {
// 刷新头
public static final String REFRESH_TOKEN = "refresh_token";
// 匿名用户
public static final String ANONYMOUS_USER = "anonymousUser";
// 权限头
public static final String AUTHORIZATION = "Authorization";
// 权限类型
public static final String BEARER = "Bearer";
// 用户id
public static final String USER_ID = "userId";
public static final String NICKNAME = "nickname";
public static final String ACCESS_TOKENS = "accessToken";
public static final String REFRESH_TOKENS = "refreshToken";
public static final String TOKEN_TYPES = "tokenType";
public static final String USERNAME = "username";
}

View File

@@ -0,0 +1,7 @@
package com.blog.common.constant.message.error;
public class UserErrorMessage {
public static final String INVALID_REFRESH_TOKEN = "无效的 refresh_token";
public static final String INVALID_USER = "无效的用户";
}

View File

@@ -0,0 +1,6 @@
package com.blog.common.constant.message.success;
public class SpaceSuccessMessage {
public static final String MY_SPACE = "获取空间成功";
}

View File

@@ -0,0 +1,7 @@
package com.blog.common.constant.message.success;
public class UserSuccessMessage {
public static final String REFRESH_TOKEN_SUCCESS = "刷新令牌成功";
public static final String GET_ARTICLES_SUCCESS = "获取文章成功";
}

View File

@@ -1,6 +1,7 @@
package com.blog.config; package com.blog.config;
import com.blog.common.R; import com.blog.common.R;
import com.blog.filter.JwtAuthenticationFilter;
import com.blog.filter.TokenBucketFilter; import com.blog.filter.TokenBucketFilter;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -14,6 +15,7 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.context.SecurityContextHolderFilter; import org.springframework.security.web.context.SecurityContextHolderFilter;
@Configuration @Configuration
@@ -26,6 +28,9 @@ public class AuthorizationServerConfig {
@Autowired @Autowired
private TokenBucketFilter tokenBucketFilter; private TokenBucketFilter tokenBucketFilter;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
// 默认使用 BCrypt它自带随机盐机制 // 默认使用 BCrypt它自带随机盐机制
@@ -36,11 +41,16 @@ public class AuthorizationServerConfig {
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
.addFilterBefore(tokenBucketFilter, SecurityContextHolderFilter.class) .addFilterBefore(tokenBucketFilter, SecurityContextHolderFilter.class)
.addFilterBefore(jwtAuthenticationFilter, AuthorizationFilter.class)
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/user/login", .requestMatchers("/user/login",
"/user/captcha/**", "/user/captcha/**",
"/user/exist/**", "/user/exist/**",
"/user/register").permitAll() "/user/register",
"/user/refreshToken",
"/swagger-ui/**",
"/v3/api-docs/**",
"/doc").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.formLogin(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable)

View File

@@ -0,0 +1,34 @@
package com.blog.config;
import io.minio.MinioClient;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@Data
public class MinioConfig {
@Value("${minio.endpoint}")
private String endpoint;
@Value("${minio.accessKey}")
private String accessKey;
@Value("${minio.secretKey}")
private String secretKey;
@Value("${minio.bucketName}")
private String bucketName;
@Value("${minio.defaultBucketName}")
private String defaultBucketName;
@Value("${minio.defaultPublicBucketName}")
private String defaultPublicBucketName;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
}

View File

@@ -0,0 +1,19 @@
package com.blog.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 如果配置多个插件, 切记分页最后添加
// 如果有多数据源可以不配具体类型, 否则都建议配上具体的 DbType
return interceptor;
}
}

View File

@@ -0,0 +1,25 @@
package com.blog.config;
import com.blog.pipeline.RegisterPipeline;
import com.blog.processor.user.PathbuildProcessor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.Arrays;
@Configuration
public class PipelineConfig {
@Autowired
private TransactionTemplate transactionTemplate;
@Bean
public RegisterPipeline registerPipeline(PathbuildProcessor pathbuildProcessor) {
return RegisterPipeline.builder()
.transactionTemplate(transactionTemplate)
.processList(Arrays.asList(pathbuildProcessor))
.build();
}
}

View File

@@ -0,0 +1,21 @@
package com.blog.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI blogOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Blog 系统 API 文档")
.description("基于 Spring Boot 3 的博客管理系统接口")
.version("v1.0.0")
.license(new License().name("Apache 2.0")));
}
}

View File

@@ -2,6 +2,7 @@ package com.blog.context;
import com.blog.common.Captcha; import com.blog.common.Captcha;
import com.blog.common.CaptchaLimit; import com.blog.common.CaptchaLimit;
import com.blog.common.UserSession;
import com.google.common.util.concurrent.RateLimiter; import com.google.common.util.concurrent.RateLimiter;
import lombok.Data; import lombok.Data;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -16,12 +17,18 @@ public class GlobalContext {
// 全局限流:每秒允许 10 个请求 // 全局限流:每秒允许 10 个请求
private final RateLimiter globalLimiter = RateLimiter.create(100.0); private final RateLimiter globalLimiter = RateLimiter.create(100.0);
private Map<String, Captcha> captchas; // 验证码池
private final Map<String, Captcha> captchas;
private Map<String, CaptchaLimit> ipLimitPool; // ip池
private final Map<String, CaptchaLimit> ipLimitPool;
// userId映射map
private final Map<String, UserSession> userSessionMap;
public GlobalContext() { public GlobalContext() {
captchas = new ConcurrentHashMap<>(); captchas = new ConcurrentHashMap<>();
ipLimitPool = new ConcurrentHashMap<>(); ipLimitPool = new ConcurrentHashMap<>();
userSessionMap = new ConcurrentHashMap<>();
} }
} }

View File

@@ -0,0 +1,18 @@
package com.blog.context;
import lombok.Builder;
import lombok.Data;
import java.util.Map;
@Data
@Builder
public class RequestContext {
Map<String, Object> params;
public Object getReqValue(String key) {
return params.get(key);
}
}

View File

@@ -0,0 +1,27 @@
package com.blog.context;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class RequestImplicitContext {
private String traceId;
private Long userId;
private String username;
private Map<String,Object> resContextMap;
private Map<String,Object> reqContextMap;
private List<String> errorMessages;
private List<String> warningMessages;
private Object data;
}

View File

@@ -0,0 +1,16 @@
package com.blog.controller;
import com.blog.service.ArticleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/article")
public class ArticleController {
@Autowired
private ArticleService articleService;
}

View File

@@ -0,0 +1,33 @@
package com.blog.controller;
import com.blog.common.R;
import com.blog.common.constant.message.success.SpaceSuccessMessage;
import com.blog.holder.RequestImplicitContextHolder;
import com.blog.service.FileService;
import com.blog.service.PathService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/space")
public class SpaceController {
@Autowired
private FileService fileService;
@Autowired
private PathService pathService;
@PreAuthorize("isAuthenticated()")
@GetMapping("")
public R<Object> mySpace() {
if (pathService.listPath(RequestImplicitContextHolder.getUserId())) {
return R.ok(SpaceSuccessMessage.MY_SPACE,RequestImplicitContextHolder.getData());
}
return R.err(RequestImplicitContextHolder.popErrorMessage());
}
}

View File

@@ -1,18 +1,29 @@
package com.blog.controller; package com.blog.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.blog.common.Captcha; import com.blog.common.Captcha;
import com.blog.common.R; import com.blog.common.R;
import com.blog.common.constant.UserConstant;
import com.blog.common.constant.message.success.UserSuccessMessage;
import com.blog.context.RequestContext;
import com.blog.dto.UserLoginDto; import com.blog.dto.UserLoginDto;
import com.blog.dto.UserRegisterDto; import com.blog.dto.UserRegisterDto;
import com.blog.holder.GlobalContextHolder; import com.blog.holder.GlobalContextHolder;
import com.blog.holder.RequestImplicitContextHolder;
import com.blog.service.ArticleService;
import com.blog.service.UserService; import com.blog.service.UserService;
import com.wf.captcha.GifCaptcha; import com.wf.captcha.GifCaptcha;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.Instant; import java.time.Instant;
import java.util.Map;
@Tag(name = "用户管理", description = "用户相关接口")
@RestController @RestController
@RequestMapping("/user") @RequestMapping("/user")
public class UserController { public class UserController {
@@ -20,12 +31,37 @@ public class UserController {
@Autowired @Autowired
private UserService userService; private UserService userService;
@Autowired
private ArticleService articleService;
@Operation(summary = "注册", description = "注册新用户")
@PostMapping("/register") @PostMapping("/register")
public R register(@RequestBody @Valid UserRegisterDto userDto) { public<T> T register(@RequestBody @Valid UserRegisterDto userDto) {
userService.register(userDto); String errMsg = userService.register(userDto);
return login(userDto); if(errMsg != null){
return (T) R.err(errMsg);
}
if(RequestImplicitContextHolder.errorCount()>0){
return (T) R.err(RequestImplicitContextHolder.popErrorMessage());
}
return (T) login(userDto);
}
@Operation(summary = "我的文章", description = "获取当前用户的文章")
@PreAuthorize("isAuthenticated()")
@GetMapping("/article")
public R myarticles(@RequestParam("current") int currentPage, @RequestParam("size") int pageSize) {
if(pageSize==0||pageSize>20){
pageSize=20;
}
Long userId = RequestImplicitContextHolder.getUserId();
if (articleService.getarticles(userId,Page.of(currentPage,pageSize))){
return R.ok(UserSuccessMessage.GET_ARTICLES_SUCCESS,RequestImplicitContextHolder.getData());
}
return R.err(RequestImplicitContextHolder.popErrorMessage());
} }
@Operation(summary = "用户名是否存在", description = "判断用户是否存在")
@GetMapping("/exist/{username}") @GetMapping("/exist/{username}")
public R exist(@PathVariable("username") String username) { public R exist(@PathVariable("username") String username) {
boolean exist = userService.exist(username); boolean exist = userService.exist(username);
@@ -35,11 +71,24 @@ public class UserController {
return R.err("用户不存在"); return R.err("用户不存在");
} }
@PreAuthorize("isAuthenticated()")
@GetMapping("/test") @GetMapping("/test")
public R test() { public R test() {
return R.ok("test"); return R.ok("test");
} }
@Operation(summary = "刷新令牌", description = "刷新令牌")
@PostMapping("/refreshToken")
public R refreshToken(@RequestBody String refreshToken) {
RequestContext requestContext = RequestContext.builder().params(Map.of(UserConstant.REFRESH_TOKEN, refreshToken)).build();
if (userService.refreshToken(requestContext)) {
return R.ok(UserSuccessMessage.REFRESH_TOKEN_SUCCESS,RequestImplicitContextHolder.getData());
}
return R.err(RequestImplicitContextHolder.popErrorMessage());
}
@Operation(summary = "登录", description = "登录")
@PostMapping("/login") @PostMapping("/login")
public R login(@Valid @RequestBody UserLoginDto user) { public R login(@Valid @RequestBody UserLoginDto user) {
Object res = userService.login(user); Object res = userService.login(user);
@@ -49,6 +98,7 @@ public class UserController {
return R.ok("登录成功", res); return R.ok("登录成功", res);
} }
@Operation(summary = "获取验证码", description = "获取验证码")
@GetMapping("/captcha/{captchaId}") @GetMapping("/captcha/{captchaId}")
public R captcha(@PathVariable("captchaId") String captchaId) throws Exception { public R captcha(@PathVariable("captchaId") String captchaId) throws Exception {
if (captchaId.isBlank() || captchaId.length() > 36) { if (captchaId.isBlank() || captchaId.length() > 36) {

View File

@@ -13,6 +13,9 @@ public class UserLoginDto {
@NotBlank(message = "密码不能为空") @NotBlank(message = "密码不能为空")
@Size(min = 6, max = 32, message = "密码长度必须在 6 到 32 位之间") @Size(min = 6, max = 32, message = "密码长度必须在 6 到 32 位之间")
private String password; private String password;
@NotBlank(message = "验证码不能为空")
private String verificationCode;
@NotBlank(message = "key 不能为空")
private String key;
} }

View File

@@ -14,9 +14,4 @@ public class UserRegisterDto extends UserLoginDto {
@NotBlank(message = "邀请码不能为空") @NotBlank(message = "邀请码不能为空")
private String inviteCode; private String inviteCode;
@NotBlank(message = "验证码不能为空")
private String verificationCode;
@NotBlank(message = "key 不能为空")
private String key;
} }

View File

@@ -1,42 +0,0 @@
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,64 @@
package com.blog.filter;
import com.blog.common.User;
import com.blog.common.UserSession;
import com.blog.common.constant.UserConstant;
import com.blog.helper.UserHelper;
import com.blog.holder.GlobalContextHolder;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.ArrayList;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private UserHelper userHelper; // 假设你有一个处理 JWT 的工具类
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 1. 从 Header 中获取 Authorization
String authHeader = request.getHeader(UserConstant.AUTHORIZATION);
// 2. 校验格式 (匹配你前端传来的 tokenType + accessToken)
if (authHeader == null || !authHeader.startsWith(UserConstant.BEARER+" ")) {
filterChain.doFilter(request, response);
return;
}
// 3. 截取真正的 Token 字符串
String jwt = authHeader.substring(7);
try {
// 4. 解析 JWT (此处可结合你的令牌桶或缓存逻辑)
if (userHelper.validateToken(jwt)) {
Claims claims = userHelper.parseToken(jwt);
UserSession session = GlobalContextHolder.getGlobalContext().getUserSessionMap().get(claims.get(UserConstant.USER_ID).toString());
// 5. 构建认证对象并存入上下文
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(User.builder().userId(session.getUserId()).username(claims.get(UserConstant.USERNAME).toString()).build(), null, new ArrayList<>());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
// Token 过期或非法,清理上下文
SecurityContextHolder.clearContext();
}
// 6. 继续执行后面的过滤器
filterChain.doFilter(request, response);
}
}

View File

@@ -41,5 +41,6 @@ public class TokenBucketFilter extends OncePerRequestFilter {
response.setContentType("application/json;charset=UTF-8"); response.setContentType("application/json;charset=UTF-8");
// 使用内置 JSON 或直接写字符串 // 使用内置 JSON 或直接写字符串
response.getWriter().write(objectMapper.writeValueAsString(R.err("系统繁忙,请稍后再试"))); response.getWriter().write(objectMapper.writeValueAsString(R.err("系统繁忙,请稍后再试")));
response.getWriter().flush();
} }
} }

View File

@@ -1,35 +1,42 @@
package com.blog.helper; package com.blog.helper;
import com.blog.common.UserSession;
import com.blog.common.constant.UserConstant;
import com.blog.entity.UserEntity; import com.blog.entity.UserEntity;
import com.blog.holder.GlobalContextHolder;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.Key; import java.security.Key;
import java.time.Instant;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID;
@Service @Component
public class UserHelper { public class UserHelper {
public Map<String, String> obtainJWT(UserEntity userEntity) { public Map<String, String> obtainJWT(UserEntity userEntity) {
String userId = UUID.randomUUID().toString().replaceAll("-", "");
GlobalContextHolder.getGlobalContext().getUserSessionMap().put(userId, UserSession.builder().userId(userEntity.getId()).ttl(Instant.now().getEpochSecond()+(2*60*60)).build());
Map<String, Object> claims = new HashMap<>(); Map<String, Object> claims = new HashMap<>();
claims.put("userId", userEntity.getId()); claims.put(UserConstant.USER_ID, userId);
claims.put("username", userEntity.getUsername()); claims.put(UserConstant.USERNAME, userEntity.getUsername());
claims.put("nickname", userEntity.getNickname());
String accessToken = createToken(claims, 2*60); String accessToken = createToken(claims, 2*60);
// 3. 签发 Refresh Token (有效期长,如 7 天) // 3. 签发 Refresh Token (有效期长,如 7 天)
// 提示Refresh Token 通常可以只存一个 userId减少体积 // 提示Refresh Token 通常可以只存一个 userId减少体积
String refreshToken = createToken(Map.of("userId", userEntity.getId()), 1 * 24 * 60); String refreshToken = createToken(Map.of(UserConstant.USERNAME, userEntity.getUsername()), 6 * 60);
Map<String, String> tokens = Map.of( Map<String, String> tokens = Map.of(
"accessToken", accessToken, UserConstant.ACCESS_TOKENS, accessToken,
"refreshToken", refreshToken, UserConstant.REFRESH_TOKENS, refreshToken,
"tokenType", "Bearer" UserConstant.TOKEN_TYPES, UserConstant.BEARER
); );
return tokens; return tokens;
@@ -48,4 +55,24 @@ public class UserHelper {
.signWith(KEY, SignatureAlgorithm.HS256) .signWith(KEY, SignatureAlgorithm.HS256)
.compact(); .compact();
} }
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(KEY)
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 校验 Token 是否有效
*/
public boolean validateToken(String token) {
try {
Claims claims = parseToken(token);
return !claims.getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
} }

View File

@@ -0,0 +1,82 @@
package com.blog.holder;
import com.blog.context.RequestImplicitContext;
import java.util.ArrayList;
import java.util.concurrent.ConcurrentHashMap;
public class RequestImplicitContextHolder {
private static final ThreadLocal<RequestImplicitContext> CONTEXT = ThreadLocal.withInitial(() -> RequestImplicitContext.builder()
.resContextMap(new ConcurrentHashMap<>())
.resContextMap(new ConcurrentHashMap<>())
.errorMessages(new ArrayList<>())
.warningMessages(new ArrayList<>())
.build());
public static void setReq(String key, Object value) {
CONTEXT.get().getReqContextMap().put(key, value);
}
public static void pushErrorMessage(String errorMessage) {
CONTEXT.get().getErrorMessages().add(errorMessage);
}
public static void pushWarningMessage(String warningMessage) {
CONTEXT.get().getWarningMessages().add(warningMessage);
}
public static void setData(Object data) {
CONTEXT.get().setData(data);
}
public static Object getData() {
return CONTEXT.get().getData();
}
public static String popErrorMessage() {
return CONTEXT.get().getErrorMessages().get(0);
}
public static int errorCount() {
return CONTEXT.get().getErrorMessages().size();
}
public static int warningCount() {
return CONTEXT.get().getWarningMessages().size();
}
public static void setUsername(String username) {
CONTEXT.get().setUsername(username);
}
public static String getUsername() {
return CONTEXT.get().getUsername();
}
/**
* 获取数据
*/
public static Object getReq(String key) {
return CONTEXT.get().getReqContextMap().get(key);
}
public static void setRes(String key, Object value) {
CONTEXT.get().getResContextMap().put(key, value);
}
/**
* 获取数据
*/
public static Object getRes(String key) {
return CONTEXT.get().getResContextMap().get(key);
}
public static Long getUserId() {
return CONTEXT.get().getUserId();
}
public static void setUserId(Long userId) {
CONTEXT.get().setUserId(userId);
}
/**
* 彻底清除,防止线程池内存泄漏(核心步骤)
*/
public static void clear() {
CONTEXT.remove();
}
}

View File

@@ -1,4 +1,4 @@
package com.blog.initialization; package com.blog.initializer;
import com.blog.context.GlobalContext; import com.blog.context.GlobalContext;
import com.blog.holder.GlobalContextHolder; import com.blog.holder.GlobalContextHolder;
@@ -6,7 +6,7 @@ import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Component @Component
public class GlobalContextInitialization { public class GlobalContextInitializer {
@PostConstruct @PostConstruct
public void initGlobalContext(){ public void initGlobalContext(){

View File

@@ -0,0 +1,78 @@
package com.blog.initializer;
import com.blog.config.MinioConfig;
import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.SetBucketPolicyArgs;
import io.minio.errors.*;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class MinioInitializer {
private final List<String> buckets;
@Autowired
private MinioClient minioClient;
@Autowired
private MinioConfig minioConfig;
public MinioInitializer() {
buckets = new ArrayList<>();
}
@PostConstruct
public void init() {
buckets.add(minioConfig.getBucketName());
buckets.add(minioConfig.getDefaultBucketName());
buckets.add(minioConfig.getDefaultPublicBucketName());
try {
for (String bucket : buckets) {
boolean exists;
exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
if(!exists) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
}
}
setBucketPublicReadOnly(minioConfig.getDefaultPublicBucketName());
} catch (Exception e) {
throw new RuntimeException("MinIO 桶初始化失败", e);
}
}
public void setBucketPublicReadOnly(String bucketName) {
// 构造 JSON 策略字符串
// 注意Resource 部分必须包含 "arn:aws:s3:::桶名" 和 "arn:aws:s3:::桶名/*"
String policy = "{\n" +
" \"Version\": \"2012-10-17\",\n" +
" \"Statement\": [\n" +
" {\n" +
" \"Effect\": \"Allow\",\n" +
" \"Principal\": {\"AWS\": [\"*\"]},\n" +
" \"Action\": [\"s3:GetBucketLocation\", \"s3:GetObject\"],\n" +
" \"Resource\": [\"arn:aws:s3:::" + bucketName + "\", \"arn:aws:s3:::" + bucketName + "/*\"]\n" +
" }\n" +
" ]\n" +
"}";
try {
minioClient.setBucketPolicy(
SetBucketPolicyArgs.builder()
.bucket(bucketName)
.config(policy)
.build()
);
System.out.println("桶策略已更新:公开只读");
} catch (Exception e) {
throw new RuntimeException("配置桶策略失败", e);
}
}
}

View File

@@ -38,7 +38,7 @@ public class IpLimitInterceptor implements HandlerInterceptor {
// --- 允许访问:更新上下文 --- // --- 允许访问:更新上下文 ---
// 使用简单的对象封装或直接存 Long 均可 // 使用简单的对象封装或直接存 Long 均可
if (limit == null||limit.getTtl()<nowSed) { if (limit == null||limit.getTtl()<nowSed) {
pool.put(ip, CaptchaLimit.builder().ttl(nowSed +30).build()); pool.put(ip, CaptchaLimit.builder().ttl(nowSed +5).build());
} }
return true; return true;
} }
@@ -46,7 +46,8 @@ public class IpLimitInterceptor implements HandlerInterceptor {
private void handleBlocked(HttpServletResponse response) throws IOException { private void handleBlocked(HttpServletResponse response) throws IOException {
response.setStatus(429); response.setStatus(429);
response.setContentType("application/json;charset=UTF-8"); response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(R.err("30秒内请勿重复操作"))); response.getWriter().write(objectMapper.writeValueAsString(R.err("5秒内请勿重复操作")));
response.getWriter().flush();
} }
private String getClientIp(HttpServletRequest request) { private String getClientIp(HttpServletRequest request) {

View File

@@ -1,9 +0,0 @@
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,40 @@
package com.blog.pipeline;
import com.blog.dto.UserRegisterDto;
import com.blog.processor.Processor;
import lombok.Builder;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
@Builder
public class RegisterPipeline {
private TransactionTemplate transactionTemplate;
private List<Processor<UserRegisterDto>> processList;
public boolean start(UserRegisterDto userRegisterDto) {
AtomicBoolean result = new AtomicBoolean(false);
transactionTemplate.execute(status -> {
try {
for (Processor<UserRegisterDto> processor : processList) {
boolean processed = processor.process(userRegisterDto);
if (!processed) {
status.setRollbackOnly();
result.set(false);
return false;
}
}
result.set(true);
return true;
}catch (Exception e){
e.printStackTrace();
result.set(false);
return false;
}
});
return result.get();
}
}

View File

@@ -0,0 +1,8 @@
package com.blog.processor;
public interface Processor<T> {
boolean process(T t);
boolean rollback(T t);
}

View File

@@ -0,0 +1,58 @@
package com.blog.processor.user;
import com.blog.config.MinioConfig;
import com.blog.dto.UserRegisterDto;
import com.blog.entity.PathEntity;
import com.blog.holder.RequestImplicitContextHolder;
import com.blog.mapper.PathMapper;
import com.blog.processor.Processor;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.errors.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
@Component
public class PathbuildProcessor implements Processor<UserRegisterDto> {
@Autowired
private MinioClient minioClient;
@Autowired
private PathMapper pathMapper;
@Autowired
private MinioConfig minioConfig;
@Override
public boolean process(UserRegisterDto userRegisterDto) {
PathEntity pathEntity = PathEntity.builder()
.userId(RequestImplicitContextHolder.getUserId())
.pathName(userRegisterDto.getUsername())
.build();
pathMapper.insert(pathEntity);
try {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(userRegisterDto.getUsername())
.stream(new ByteArrayInputStream(new byte[] {}), 0, -1)
.build()
);
} catch (Exception e) {
RequestImplicitContextHolder.pushErrorMessage(e.getMessage());
throw new RuntimeException(e);
}
return true;
}
@Override
public boolean rollback(UserRegisterDto userRegisterDto) {
return false;
}
}

View File

@@ -1,14 +0,0 @@
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

@@ -1,76 +0,0 @@
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

@@ -2,6 +2,7 @@ package com.blog.task;
import com.blog.common.Captcha; import com.blog.common.Captcha;
import com.blog.common.CaptchaLimit; import com.blog.common.CaptchaLimit;
import com.blog.common.UserSession;
import com.blog.holder.GlobalContextHolder; import com.blog.holder.GlobalContextHolder;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
@@ -34,4 +35,15 @@ public class GlobalCleanupTask {
} }
}); });
} }
@Async
@Scheduled(fixedRate = 60000)
public void purgeOnline() {
Map<String, UserSession> ipLimitPool = GlobalContextHolder.getGlobalContext().getUserSessionMap();
ipLimitPool.forEach((k,v)->{
if(v.getTtl()< Instant.now().getEpochSecond()){
ipLimitPool.remove(k);
}
});
}
} }

View File

@@ -1,15 +1,18 @@
package com.blog.vaildator; package com.blog.validator;
import com.blog.dto.UserLoginDto; import com.blog.dto.UserLoginDto;
import com.blog.entity.InviteCodeEntity;
import com.blog.entity.UserEntity; import com.blog.entity.UserEntity;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.LocalDateTime; import java.time.Instant;
@Component @Component
public class UserValidator { public class UserValidator {
@Autowired @Autowired
private PasswordEncoder passwordEncoder; private PasswordEncoder passwordEncoder;
public String validateUser(UserLoginDto user, UserEntity userEntity) { public String validateUser(UserLoginDto user, UserEntity userEntity) {
@@ -21,16 +24,26 @@ public class UserValidator {
return "密码错误"; return "密码错误";
} }
if (userEntity.getEnabled() == 0) return "账号已被禁用"; if (userEntity.getEnabled() == 0) return "账号已被禁用";
if (userEntity.getReleaseDate() != null && userEntity.getReleaseDate().isAfter(LocalDateTime.now())) { if (userEntity.getReleaseDate() != null && userEntity.getReleaseDate().toInstant().isAfter(Instant.now())) {
return ("账号封禁中,解禁日期:" + userEntity.getReleaseDate()); return ("账号封禁中,解禁日期:" + userEntity.getReleaseDate());
} }
return null; return null;
} }
public String validateRegister(UserEntity userEntity) { public String validateRegister(UserEntity userEntity, InviteCodeEntity inviteCodeEntity) {
if(userEntity!=null){ if(userEntity!=null){
return "用户名已存在"; return "用户名已存在";
} }
if(inviteCodeEntity==null){
return "无效的邀请码";
}
if(inviteCodeEntity.getExpireTime()!=null&&inviteCodeEntity.getExpireTime().toInstant().isBefore(Instant.now())){
return "邀请码已过期";
}
if(inviteCodeEntity.getMaxUses()<=inviteCodeEntity.getUsedCount()){
return "邀请码使用次数已用尽";
}
return null; return null;
} }
} }

View File

@@ -21,9 +21,20 @@ logging:
level: level:
org.springframework.security: DEBUG org.springframework.security: DEBUG
com.blog.mapper: DEBUG com.blog.mapper: DEBUG
mybatis-plus: mybatis-plus:
mapper-locations: classpath:/mapper/**.xml mapper-locations: classpath:/mapper/**.xml
configuration: configuration:
local-cache-scope: statement local-cache-scope: statement
cache-enabled: false cache-enabled: false
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
springdoc:
swagger-ui:
path: /doc
minio:
endpoint: http://127.0.0.1:9000
accessKey: minioadmin
secretKey: minioadmin
bucketName: blog-files
defaultBucketName: private
defaultPublicBucketName: public

View File

@@ -1,85 +1,126 @@
-- 1. 用户表 (包含你要求的: 密码>6位、邀请码、状态、解禁日期等) -- 1. 用户表 (包含你要求的: 密码>6位、邀请码、状态、解禁日期等)
CREATE TABLE `sys_user` ( CREATE TABLE `sys_user` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`username` VARCHAR(64) NOT NULL COMMENT '用户名', `username` VARCHAR(64) NOT NULL COMMENT '用户名',
`password` VARCHAR(255) NOT NULL COMMENT '密码(BCrypt加密)', `password` VARCHAR(255) NOT NULL COMMENT '密码(BCrypt加密)',
`nickname` VARCHAR(64) NOT NULL COMMENT '用户昵称', `nickname` VARCHAR(64) NOT NULL COMMENT '用户昵称',
`email` VARCHAR(128) NOT NULL COMMENT '邮箱', `email` VARCHAR(128) NOT NULL COMMENT '邮箱',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL', `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
`gender` TINYINT DEFAULT 0 COMMENT '性别: 1男, 2女, 0未知', `gender` TINYINT DEFAULT 0 COMMENT '性别: 1男, 2女, 0未知',
`enabled` TINYINT(1) DEFAULT 1 COMMENT '状态: 1启用, 0禁用', `enabled` TINYINT(1) DEFAULT 1 COMMENT '状态: 1启用, 0禁用',
`invite_code_id` VARCHAR(64) NOT NULL COMMENT '使用的邀请码ID', `invite_code_id` BIGINT NOT NULL COMMENT '使用的邀请码ID',
`release_date` DATETIME DEFAULT NULL COMMENT '解禁日期', `release_date` DATETIME DEFAULT NULL COMMENT '解禁日期',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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-已删', `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记0-未删, 1-已删',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`) UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 2. 角色表 -- 2. 角色表
CREATE TABLE `sys_role` ( CREATE TABLE `sys_role` (
`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(32) NOT NULL COMMENT '角色名称(如:超级管理员)', `name` VARCHAR(32) NOT NULL COMMENT '角色名称(如:超级管理员)',
`code` VARCHAR(32) NOT NULL COMMENT '角色标识(如:ROLE_ADMIN)', `code` VARCHAR(32) NOT NULL COMMENT '角色标识(如:ROLE_ADMIN)',
`description` VARCHAR(255) DEFAULT NULL COMMENT '描述', `description` VARCHAR(255) DEFAULT NULL COMMENT '描述',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态(1:正常, 0:禁用)', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态(1:正常, 0:禁用)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_code` (`code`) UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
-- 3. 权限/菜单表 -- 3. 权限/菜单表
CREATE TABLE `sys_menu` ( CREATE TABLE `sys_menu` (
`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`parent_id` BIGINT DEFAULT 0 COMMENT '父菜单ID', `parent_id` BIGINT DEFAULT 0 COMMENT '父菜单ID',
`name` VARCHAR(64) NOT NULL COMMENT '权限名称', `name` VARCHAR(64) NOT NULL COMMENT '权限名称',
`perms` VARCHAR(100) DEFAULT NULL COMMENT '权限标识(如: sys:user:add)', `perms` VARCHAR(100) DEFAULT NULL COMMENT '权限标识(如: sys:user:add)',
`type` TINYINT NOT NULL COMMENT '类型(0:目录, 1:菜单, 2:按钮)', `type` TINYINT NOT NULL COMMENT '类型(0:目录, 1:菜单, 2:按钮)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限菜单表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限菜单表';
-- 4. 用户-角色关联表 -- 4. 用户-角色关联表
CREATE TABLE `sys_user_role` ( CREATE TABLE `sys_user_role` (
`user_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL,
`role_id` BIGINT NOT NULL, `role_id` BIGINT NOT NULL,
PRIMARY KEY (`user_id`, `role_id`) PRIMARY KEY (`user_id`, `role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 5. 角色-权限关联表 -- 5. 角色-权限关联表
CREATE TABLE `sys_role_menu` ( CREATE TABLE `sys_role_menu` (
`role_id` BIGINT NOT NULL, `role_id` BIGINT NOT NULL,
`menu_id` BIGINT NOT NULL, `menu_id` BIGINT NOT NULL,
PRIMARY KEY (`role_id`, `menu_id`) PRIMARY KEY (`role_id`, `menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 6. 邀请码表 -- 6. 邀请码表
CREATE TABLE `sys_invite_code` ( CREATE TABLE `sys_invite_code` (
`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`code` VARCHAR(32) NOT NULL COMMENT '唯一邀请码', `code` VARCHAR(32) NOT NULL COMMENT '唯一邀请码',
`creator_id` BIGINT DEFAULT NULL COMMENT '生成人ID', `creator_id` BIGINT DEFAULT NULL COMMENT '生成人ID',
`max_uses` INT NOT NULL DEFAULT 1 COMMENT '最大使用次数', `max_uses` INT NOT NULL DEFAULT 1 COMMENT '最大使用次数',
`used_count` INT NOT NULL DEFAULT 0 COMMENT '已使用次数', `used_count` INT NOT NULL DEFAULT 0 COMMENT '已使用次数',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-有效, 0-失效', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-有效, 0-失效',
`expire_time` DATETIME DEFAULT NULL COMMENT '过期时间', `expire_time` DATETIME DEFAULT NULL COMMENT '过期时间',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_code` (`code`) UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='邀请码表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='邀请码表';
-- 7. 文章表 -- 7. 文章表
CREATE TABLE `sys_article` ( CREATE TABLE `sys_article` (
`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`title` VARCHAR(255) NOT NULL COMMENT '标题', `title` VARCHAR(255) NOT NULL COMMENT '标题',
`content` LONGTEXT NOT NULL COMMENT '内容', `content` LONGTEXT NOT NULL COMMENT '内容',
`author_id` BIGINT NOT NULL COMMENT '作者ID', `author_id` BIGINT NOT NULL COMMENT '作者ID',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-草稿, 1-发布', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-草稿, 1-发布, 2下架, 3删除',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `views` BIGINT DEFAULT 0 COMMENT '浏览数',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `collection_count` BIGINT DEFAULT 0 COMMENT '收藏数',
`is_deleted` TINYINT NOT NULL DEFAULT 0, `like_count` BIGINT DEFAULT 0 COMMENT '点赞数',
KEY `idx_author` (`author_id`) `downvote_count` BIGINT DEFAULT 0 COMMENT '点踩数',
`release_date` DATETIME COMMENT '发布日期',
`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='文章表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表';
-- 8. 文件元数据映射表
CREATE TABLE `sys_file` (
`id` bigint NOT NULL COMMENT '主键ID',
`alias_name` varchar(255) COMMENT '别名',
`file_name` varchar(255) NOT NULL COMMENT '文件原始名称',
`storage_name` varchar(255) NOT NULL COMMENT 'MinIO存储对象名通常是UUID+后缀)',
`bucket_name` varchar(64) DEFAULT 'public' COMMENT '存储桶名称',
`file_size` bigint DEFAULT NULL COMMENT '文件大小(bytes)',
`file_type` varchar(128) DEFAULT NULL COMMENT '文件MIME类型',
`file_url` varchar(500) DEFAULT NULL COMMENT '访问地址(临时或持久)',
`user_id` BIGINT DEFAULT NULL COMMENT '上传者ID',
`trace_id` varchar(64) DEFAULT NULL COMMENT '关联的请求链路ID',
`is_deleted` tinyint(1) DEFAULT '0' COMMENT '逻辑删除',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
PRIMARY KEY (`id`),
INDEX `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件元数据映射表';
-- 9. 文件目录映射表
CREATE TABLE `sys_path` (
`id` bigint NOT NULL COMMENT '主键ID',
`path_name` varchar(512) not null COMMENT '目录名',
`user_id` BIGINT not null COMMENT '上传者ID',
`parent_id` bigint COMMENT '父目录ID',
`is_deleted` tinyint(1) DEFAULT '0' COMMENT '逻辑删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件目录映射表';
-- 10. 目录文件映射表
CREATE TABLE `sys_path_file`
(
`path_id` bigint NOT NULL COMMENT '目录ID',
`file_id` bigint NOT NULL COMMENT '文件ID',
PRIMARY KEY (`path_id`, `file_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='目录文件映射表';
-- 初始化管理员角色 -- 初始化管理员角色
INSERT INTO `sys_role` (`name`, `code`, `description`) INSERT INTO `sys_role` (`name`, `code`, `description`)
VALUES ('超级管理员', 'ROLE_ADMIN', '拥有系统所有权限'); VALUES ('超级管理员', 'ROLE_ADMIN', '拥有系统所有权限');
@@ -87,16 +128,21 @@ VALUES ('超级管理员', 'ROLE_ADMIN', '拥有系统所有权限');
-- 初始化管理员用户 (密码: admin123) -- 初始化管理员用户 (密码: admin123)
-- 注意: invite_code_id 不能为空,这里填 SYSTEM -- 注意: invite_code_id 不能为空,这里填 SYSTEM
INSERT INTO `sys_user` ( INSERT INTO `sys_user` (
`username`, `password`, `nickname`, `email`, `invite_code_id`, `enabled`, `is_deleted` `username`, `password`, `nickname`, `email`, `invite_code_id`, `enabled`, `is_deleted`
) VALUES ( ) VALUES (
'admin', 'admin',
'admin123', '$2a$10$HoxAaokfbkslPrrBKkKDdOw7eBV/q.5Rj4P81d1h37y92i2Jd/.VW',
'超级管理员', '超级管理员',
'admin@example.com', '2920370144@qq.com',
'SYSTEM', 0,
1, 1,
0 0
); );
-- 关联管理员与角色 (假设 ID 都是 1) -- 关联管理员与角色 (假设 ID 都是 1)
INSERT INTO `sys_user_role` (`user_id`, `role_id`) VALUES (1, 1); INSERT INTO `sys_user_role` (`user_id`, `role_id`) VALUES (1, 1);
INSERT INTO `sys_invite_code`
(`code`, `creator_id`, `max_uses`, `used_count`, `status`, `expire_time`)
VALUES
('WELCOME2026', 1, 99, 0, 1, NULL);