6 Commits

16 changed files with 253 additions and 17 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -6,7 +6,7 @@
<groupId>com.youlai</groupId>
<artifactId>youlai-boot</artifactId>
<version>4.3.0</version>
<version>4.3.1</version>
<description>基于 Java 17 + SpringBoot 4 + Spring Security 构建的权限管理系统。</description>
<parent>

View File

@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.*;
*/
@Tag(name = "01.认证中心")
@RestController
@RequestMapping("/api/v1/auth/app")
@RequestMapping("/api/v1/app/auth/")
@RequiredArgsConstructor
@Slf4j
public class AppAuthController {

View File

@@ -0,0 +1,57 @@
package com.youlai.boot.app.controller;
import com.youlai.boot.app.model.vo.AppFileInfo;
import com.youlai.boot.app.service.AppFileService;
import com.youlai.boot.common.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/**
* 文件控制层
*
* @author Ray.Hao
* @since 2022/10/16
*/
@Tag(name = "10.文件接口")
@RestController
@RequestMapping("/api/v1/app/files")
@RequiredArgsConstructor
public class AppFileController {
private final AppFileService fileService;
@PostMapping
@Operation(summary = "文件上传")
public Result<AppFileInfo> uploadFile(
@Parameter(
name = "file",
description = "表单文件对象",
required = true,
in = ParameterIn.DEFAULT,
schema = @Schema(name = "file", format = "binary")
)
@RequestPart(value = "file") MultipartFile file
) {
Assert.isTrue(!file.isEmpty(), "上传文件不能为空文件");
AppFileInfo fileInfo = fileService.uploadFile(file);
return Result.success(fileInfo);
}
@DeleteMapping
@Operation(summary = "文件删除")
@SneakyThrows
public Result<?> deleteFile(
@Parameter(description = "文件路径") @RequestParam String filePath
) {
boolean result = fileService.deleteFile(filePath);
return Result.judge(result);
}
}

View File

@@ -0,0 +1,23 @@
package com.youlai.boot.app.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 文件信息对象
*
* @author Ray.Hao
* @since 1.0.0
*/
@Schema(description = "文件对象")
@Data
public class AppFileInfo {
@Schema(description = "文件名称")
private String name;
@Schema(description = "文件URL")
private String url;
}

View File

@@ -0,0 +1,30 @@
package com.youlai.boot.app.service;
import com.youlai.boot.app.model.vo.AppFileInfo;
import org.springframework.web.multipart.MultipartFile;
/**
* 对象存储服务接口层
*
* @author haoxr
* @since 2022/11/19
*/
public interface AppFileService {
/**
* 上传文件
* @param file 表单文件对象
* @return 文件信息
*/
AppFileInfo uploadFile(MultipartFile file);
/**
* 删除文件
*
* @param filePath 文件完整URL
* @return 删除结果
*/
boolean deleteFile(String filePath);
}

View File

@@ -0,0 +1,93 @@
package com.youlai.boot.app.service.impl;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import com.youlai.boot.app.model.vo.AppFileInfo;
import com.youlai.boot.app.service.AppFileService;
import com.youlai.boot.file.service.FileService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.InputStream;
import java.time.LocalDateTime;
/**
* 本地存储服务类
*
* @author Theo
* @since 2024-12-09 17:11
*/
@Data
@Slf4j
@Component
//@ConditionalOnProperty(value = "oss.type", havingValue = "local")
//@ConfigurationProperties(prefix = "oss.local")
@RequiredArgsConstructor
public class LocalAppFileService implements AppFileService {
@Value("${oss.local.storage-path}")
private String storagePath;
/**
* 上传文件方法
*
* @param file 表单文件对象
* @return 文件信息
*/
@Override
public AppFileInfo uploadFile(MultipartFile file) {
// 获取文件名
String originalFilename = file.getOriginalFilename();
// 获取文件后缀
String suffix = FileUtil.getSuffix(originalFilename);
// 生成uuid
String fileName = IdUtil.simpleUUID()+ "." + suffix;;
// 生成文件名(日期文件夹)
String folder = DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN);
String filePrefix = storagePath.endsWith(File.separator) ? storagePath : storagePath + File.separator;
// try-with-resource 语法糖自动释放流
try (InputStream inputStream = file.getInputStream()) {
// 上传文件
FileUtil.writeFromStream(inputStream, filePrefix + folder + File.separator + fileName);
} catch (Exception e) {
log.error("文件上传失败", e);
throw new RuntimeException("文件上传失败");
}
// 获取文件访问路径,因为这里是本地存储,所以直接返回文件的相对路径,需要前端自行处理访问前缀
String fileUrl = File.separator + folder + File.separator + fileName;
AppFileInfo fileInfo = new AppFileInfo();
fileInfo.setName(originalFilename);
fileInfo.setUrl(fileUrl);
return fileInfo;
}
/**
* 删除文件
* @param filePath 文件完整URL
* @return 是否删除成功
*/
@Override
public boolean deleteFile(String filePath) {
//判断文件是否为空
if (filePath == null || filePath.isEmpty()) {
return false;
}
// 判断filepath是否为文件夹
if (FileUtil.isDirectory(storagePath + filePath)) {
// 禁止删除文件夹
return false;
}
// 删除文件
return FileUtil.del(storagePath + filePath);
}
}

View File

@@ -71,7 +71,7 @@ public class SecurityConfig {
// 移动设备专用接口路径(需要设备签名验证,但不需要用户登录)
requestMatcherRegistry.requestMatchers("/api/v1/sn/**").permitAll();
requestMatcherRegistry.requestMatchers("/api/v1/auth/app/**").permitAll();
requestMatcherRegistry.requestMatchers("/api/v1/app/**").permitAll();
// 其他所有请求需登录后访问
requestMatcherRegistry.anyRequest().authenticated();
}

View File

@@ -3,13 +3,13 @@ package com.youlai.boot.framework.security.exception;
import org.springframework.security.core.AuthenticationException;
/**
* 验证码校验异常
* 短信验证码异常
*
* @author Ray.Hao
* @since 2025/3/1
*/
public class CaptchaValidationException extends AuthenticationException {
public CaptchaValidationException(String msg) {
public class SmsCaptchaException extends AuthenticationException {
public SmsCaptchaException(String msg) {
super(msg);
}
}
}

View File

@@ -0,0 +1,21 @@
package com.youlai.boot.framework.security.exception;
import com.youlai.boot.common.result.ResultCode;
import lombok.Getter;
/**
* Token 无效异常access_token 或 refresh_token 过期/无效)
*
* @author Ray.Hao
* @since 4.3.1
*/
@Getter
public class TokenInvalidException extends RuntimeException {
private final ResultCode resultCode;
public TokenInvalidException(ResultCode resultCode) {
super(resultCode.getMsg());
this.resultCode = resultCode;
}
}

View File

@@ -3,7 +3,7 @@ package com.youlai.boot.framework.security.provider;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.security.exception.CaptchaValidationException;
import com.youlai.boot.framework.security.exception.SmsCaptchaException;
import com.youlai.boot.framework.security.model.SmsAuthenticationToken;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.UserAuthInfo;
@@ -62,11 +62,11 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
// 参数校验
if (StrUtil.isBlank(mobile)) {
log.warn("短信验证码登录失败:手机号为空");
throw new CaptchaValidationException("手机号不能为空");
throw new SmsCaptchaException("手机号不能为空");
}
if (StrUtil.isBlank(inputVerifyCode)) {
log.warn("短信验证码登录失败:验证码为空,手机号={}", mobile);
throw new CaptchaValidationException("验证码不能为空");
throw new SmsCaptchaException("验证码不能为空");
}
// 根据手机号获取用户信息
@@ -89,12 +89,12 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
if (cachedVerifyCode == null) {
log.warn("短信验证码登录失败:验证码已过期,手机号={}", mobile);
throw new CaptchaValidationException("验证码已过期,请重新获取");
throw new SmsCaptchaException("验证码已过期,请重新获取");
}
if (!StrUtil.equals(inputVerifyCode, cachedVerifyCode)) {
log.warn("短信验证码登录失败:验证码错误,手机号={}", mobile);
throw new CaptchaValidationException("验证码错误");
throw new SmsCaptchaException("验证码错误");
}
// 验证成功后删除验证码,防止重复使用

View File

@@ -12,9 +12,9 @@ import cn.hutool.jwt.JWTUtil;
import com.youlai.boot.common.constant.JwtClaimConstants;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.RoleDataScope;
import org.apache.commons.lang3.StringUtils;
@@ -300,7 +300,7 @@ public class JwtTokenManager implements TokenManager {
public AuthenticationToken refreshToken(String refreshToken) {
boolean isValid = validateRefreshToken(refreshToken);
if (!isValid) {
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
throw new TokenInvalidException(ResultCode.REFRESH_TOKEN_INVALID);
}
Authentication authentication = parseToken(refreshToken);
int accessTokenExpiration = securityProperties.getSession().getAccessTokenTimeToLive();

View File

@@ -5,9 +5,9 @@ import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.UserSession;
import com.youlai.boot.framework.security.model.SysUserDetails;
@@ -147,7 +147,7 @@ public class RedisTokenManager implements TokenManager {
UserSession userSession = (UserSession) redisTemplate.opsForValue()
.get(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken));
if (userSession == null) {
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
throw new TokenInvalidException(ResultCode.REFRESH_TOKEN_INVALID);
}
Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId()));
// 删除旧的访问令牌记录

View File

@@ -5,6 +5,7 @@ import tools.jackson.core.JacksonException;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
import jakarta.servlet.ServletException;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
@@ -222,6 +223,17 @@ public class GlobalExceptionHandler {
return Result.failed(ResultCode.INTEGRITY_CONSTRAINT_VIOLATION);
}
/**
* 处理 Token 无效异常
* <p>
* 当 access_token 或 refresh_token 过期/无效时,返回 401。
*/
@ExceptionHandler(TokenInvalidException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public <T> Result<T> handleTokenInvalidException(TokenInvalidException e) {
return Result.failed(e.getResultCode());
}
/**
* 处理业务异常
* <p>

View File

@@ -27,7 +27,7 @@
#else
<if test="queryParams.${fieldConfig.fieldName} != null">
#end
#set ($queryType = ${fieldConfig.queryType}.name())
#set ($queryType = $fieldConfig.queryType.name())
#if($queryType == "EQ")
AND ${fieldConfig.columnName} = #{queryParams.${fieldConfig.fieldName}}
#elseif($queryType == "LIKE")