feat(system/config): 移除系统管理,新增存储配置

This commit is contained in:
2025-02-25 14:17:53 +00:00
parent 5f68e84e7d
commit 6d64f47d3e
33 changed files with 891 additions and 1260 deletions

View File

@@ -29,19 +29,6 @@
<artifactId>sms4j-spring-boot-starter</artifactId>
</dependency>
<!-- X File Storage一行代码将文件存储到本地、FTP、SFTP、WebDAV、阿里云 OSS、华为云 OBS...等其它兼容 S3 协议的存储平台) -->
<dependency>
<groupId>org.dromara.x-file-storage</groupId>
<artifactId>x-file-storage-spring</artifactId>
<version>2.2.1</version>
</dependency>
<!-- Amazon S3Amazon Simple Storage Service亚马逊简单存储服务通用存储协议 S3兼容主流云厂商对象存储 -->
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-s3</artifactId>
<version>1.12.780</version>
</dependency>
<!-- FreeMarker模板引擎 -->
<dependency>
<groupId>org.freemarker</groupId>
@@ -149,5 +136,19 @@
<groupId>top.continew</groupId>
<artifactId>continew-starter-json-jackson</artifactId>
</dependency>
<!-- ContiNew Starter 存储模块 - 本地存储 -->
<dependency>
<groupId>top.continew</groupId>
<artifactId>continew-starter-storage-local</artifactId>
</dependency>
<!-- ContiNew Starter 存储模块 - 对象存储 -->
<dependency>
<groupId>top.continew</groupId>
<artifactId>continew-starter-storage-oss</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,216 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.common.config.doc;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.hutool.core.map.MapUtil;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springdoc.core.customizers.GlobalOpenApiCustomizer;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.annotation.*;
import top.continew.starter.apidoc.autoconfigure.SpringDocExtensionProperties;
import top.continew.starter.auth.satoken.autoconfigure.SaTokenExtensionProperties;
import top.continew.starter.extension.crud.annotation.CrudRequestMapping;
import java.lang.reflect.Method;
import java.util.*;
import java.util.stream.Collectors;
/**
* 全局鉴权参数定制器
*
* @author echo
* @since 2024/12/31 13:36
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GlobalAuthenticationCustomizer implements GlobalOpenApiCustomizer {
private final SpringDocExtensionProperties properties;
private final SaTokenExtensionProperties saTokenExtensionProperties;
private final ApplicationContext context;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
/**
* 定制 OpenAPI 文档
*
* @param openApi 当前 OpenAPI 对象
*/
@Override
public void customise(OpenAPI openApi) {
if (MapUtil.isEmpty(openApi.getPaths())) {
return;
}
// 收集需要排除的路径(包括 Sa-Token 配置中的排除路径和 @SaIgnore 注解路径)
Set<String> excludedPaths = collectExcludedPaths();
// 遍历所有路径,为需要鉴权的路径添加安全认证配置
openApi.getPaths().forEach((path, pathItem) -> {
if (isPathExcluded(path, excludedPaths)) {
// 路径在排除列表中,跳过处理
return;
}
// 为路径添加安全认证参数
addAuthenticationParameters(pathItem);
});
}
/**
* 收集所有需要排除的路径
*
* @return 排除路径集合
*/
private Set<String> collectExcludedPaths() {
Set<String> excludedPaths = new HashSet<>();
excludedPaths.addAll(Arrays.asList(saTokenExtensionProperties.getSecurity().getExcludes()));
excludedPaths.addAll(resolveSaIgnorePaths());
return excludedPaths;
}
/**
* 为路径项添加认证参数
*
* @param pathItem 当前路径项
*/
private void addAuthenticationParameters(PathItem pathItem) {
Components components = properties.getComponents();
if (components == null || MapUtil.isEmpty(components.getSecuritySchemes())) {
return;
}
Map<String, SecurityScheme> securitySchemes = components.getSecuritySchemes();
List<String> schemeNames = securitySchemes.values().stream().map(SecurityScheme::getName).toList();
pathItem.readOperations().forEach(operation -> {
SecurityRequirement securityRequirement = new SecurityRequirement();
schemeNames.forEach(securityRequirement::addList);
operation.addSecurityItem(securityRequirement);
});
}
/**
* 解析所有带有 @SaIgnore 注解的路径
*
* @return 被忽略的路径集合
*/
private Set<String> resolveSaIgnorePaths() {
// 获取所有标注 @RestController 的 Bean
Map<String, Object> controllers = context.getBeansWithAnnotation(RestController.class);
Set<String> ignoredPaths = new HashSet<>();
// 遍历所有控制器,解析 @SaIgnore 注解路径
controllers.values().forEach(controllerBean -> {
Class<?> controllerClass = AopUtils.getTargetClass(controllerBean);
List<String> classPaths = getClassPaths(controllerClass);
// 类级别的 @SaIgnore 注解
if (controllerClass.isAnnotationPresent(SaIgnore.class)) {
classPaths.forEach(classPath -> ignoredPaths.add(classPath + "/**"));
}
// 方法级别的 @SaIgnore 注解
Arrays.stream(controllerClass.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(SaIgnore.class))
.forEach(method -> ignoredPaths.addAll(combinePaths(classPaths, getMethodPaths(method))));
});
return ignoredPaths;
}
/**
* 获取类上的所有路径
*
* @param controller 控制器类
* @return 类路径列表
*/
private List<String> getClassPaths(Class<?> controller) {
List<String> classPaths = new ArrayList<>();
// 处理 @RequestMapping 注解
if (controller.isAnnotationPresent(RequestMapping.class)) {
RequestMapping mapping = controller.getAnnotation(RequestMapping.class);
classPaths.addAll(Arrays.asList(mapping.value()));
}
// 处理 @CrudRequestMapping 注解
if (controller.isAnnotationPresent(CrudRequestMapping.class)) {
CrudRequestMapping mapping = controller.getAnnotation(CrudRequestMapping.class);
if (!mapping.value().isEmpty()) {
classPaths.add(mapping.value());
}
}
return classPaths;
}
/**
* 获取方法上的所有路径
*
* @param method 控制器方法
* @return 方法路径列表
*/
private List<String> getMethodPaths(Method method) {
List<String> methodPaths = new ArrayList<>();
// 检查方法上的各种映射注解
if (method.isAnnotationPresent(GetMapping.class)) {
methodPaths.addAll(Arrays.asList(method.getAnnotation(GetMapping.class).value()));
} else if (method.isAnnotationPresent(PostMapping.class)) {
methodPaths.addAll(Arrays.asList(method.getAnnotation(PostMapping.class).value()));
} else if (method.isAnnotationPresent(PutMapping.class)) {
methodPaths.addAll(Arrays.asList(method.getAnnotation(PutMapping.class).value()));
} else if (method.isAnnotationPresent(DeleteMapping.class)) {
methodPaths.addAll(Arrays.asList(method.getAnnotation(DeleteMapping.class).value()));
} else if (method.isAnnotationPresent(RequestMapping.class)) {
methodPaths.addAll(Arrays.asList(method.getAnnotation(RequestMapping.class).value()));
} else if (method.isAnnotationPresent(PatchMapping.class)) {
methodPaths.addAll(Arrays.asList(method.getAnnotation(PatchMapping.class).value()));
}
return methodPaths;
}
/**
* 组合类路径和方法路径
*
* @param classPaths 类路径列表
* @param methodPaths 方法路径列表
* @return 完整路径集合
*/
private Set<String> combinePaths(List<String> classPaths, List<String> methodPaths) {
return classPaths.stream()
.flatMap(classPath -> methodPaths.stream().map(methodPath -> classPath + methodPath))
.collect(Collectors.toSet());
}
/**
* 检查路径是否在排除列表中
*
* @param path 当前路径
* @param excludedPaths 排除路径集合,支持通配符
* @return 是否匹配排除规则
*/
private boolean isPathExcluded(String path, Set<String> excludedPaths) {
return excludedPaths.stream().anyMatch(pattern -> pathMatcher.match(pattern, path));
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.common.config.doc;
import cn.hutool.core.util.StrUtil;
import io.swagger.v3.oas.models.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springdoc.core.customizers.GlobalOperationCustomizer;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import java.util.ArrayList;
import java.util.List;
/**
* 全局描述定制器 - 处理 sa-token 的注解权限码
*
* @author echo
* @since 2025/01/24 14:59
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GlobalDescriptionCustomizer implements GlobalOperationCustomizer {
@Override
public Operation customize(Operation operation, HandlerMethod handlerMethod) {
// 将 sa-token 注解数据添加到 operation 的描述中
// 权限
List<String> noteList = new ArrayList<>(new OperationDescriptionCustomizer().getPermission(handlerMethod));
// 如果注解数据列表为空,直接返回原 operation
if (noteList.isEmpty()) {
return operation;
}
// 拼接注解数据为字符串
String noteStr = StrUtil.join("<br/>", noteList);
// 获取原描述
String originalDescription = operation.getDescription();
// 根据原描述是否为空,更新描述
String newDescription = StringUtils.isNotEmpty(originalDescription)
? originalDescription + "<br/>" + noteStr
: noteStr;
// 设置新描述
operation.setDescription(newDescription);
return operation;
}
}

View File

@@ -0,0 +1,180 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.common.config.doc;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import cn.hutool.core.text.CharSequenceUtil;
import org.springframework.web.method.HandlerMethod;
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.extension.crud.annotation.CrudApi;
import top.continew.starter.extension.crud.annotation.CrudRequestMapping;
import top.continew.starter.extension.crud.enums.Api;
import java.lang.annotation.Annotation;
import java.util.ArrayList;
import java.util.List;
/**
* Operation 描述定制器 处理 sa-token 鉴权标识符
*
* @author echo
* @since 2024/06/14 11:18
*/
public class OperationDescriptionCustomizer {
/**
* 获取 sa-token 注解信息
*
* @param handlerMethod 处理程序方法
* @return 包含权限和角色校验信息的列表
*/
public List<String> getPermission(HandlerMethod handlerMethod) {
List<String> values = new ArrayList<>();
// 获取权限校验信息
String permissionInfo = getAnnotationInfo(handlerMethod, SaCheckPermission.class, "权限校验:");
if (!permissionInfo.isEmpty()) {
values.add(permissionInfo);
}
// 获取角色校验信息
String roleInfo = getAnnotationInfo(handlerMethod, SaCheckRole.class, "角色校验:");
if (!roleInfo.isEmpty()) {
values.add(roleInfo);
}
// 处理 CrudRequestMapping 和 CrudApi 注解生成的权限信息
String crudPermissionInfo = getCrudPermissionInfo(handlerMethod);
if (!crudPermissionInfo.isEmpty()) {
values.add(crudPermissionInfo);
}
return values;
}
/**
* 获取类和方法上指定注解的信息
*
* @param handlerMethod 处理程序方法
* @param annotationClass 注解类
* @param title 信息标题
* @param <A> 注解类型
* @return 拼接好的注解信息字符串
*/
@SuppressWarnings("unchecked")
private <A extends Annotation> String getAnnotationInfo(HandlerMethod handlerMethod,
Class<A> annotationClass,
String title) {
StringBuilder infoBuilder = new StringBuilder();
// 获取类上的注解
A classAnnotation = handlerMethod.getBeanType().getAnnotation(annotationClass);
if (classAnnotation != null) {
appendAnnotationInfo(infoBuilder, "类:", classAnnotation);
}
// 获取方法上的注解
A methodAnnotation = handlerMethod.getMethodAnnotation(annotationClass);
if (methodAnnotation != null) {
appendAnnotationInfo(infoBuilder, "方法:", methodAnnotation);
}
// 如果有注解信息,添加标题
if (!infoBuilder.isEmpty()) {
infoBuilder.insert(0, "<font style=\"color:red\" class=\"light-red\">" + title + "</font></br>");
}
return infoBuilder.toString();
}
/**
* 拼接注解信息到 StringBuilder 中
*
* @param builder 用于拼接信息的 StringBuilder
* @param prefix 前缀信息,如 "类:" 或 "方法:"
* @param annotation 注解对象
*/
private void appendAnnotationInfo(StringBuilder builder, String prefix, Annotation annotation) {
String[] values = null;
SaMode mode = null;
String type = "";
String[] orRole = new String[0];
if (annotation instanceof SaCheckPermission checkPermission) {
values = checkPermission.value();
mode = checkPermission.mode();
type = checkPermission.type();
orRole = checkPermission.orRole();
} else if (annotation instanceof SaCheckRole checkRole) {
values = checkRole.value();
mode = checkRole.mode();
type = checkRole.type();
}
if (values != null && mode != null) {
builder.append("<font style=\"color:red\" class=\"light-red\">");
builder.append(prefix);
if (!type.isEmpty()) {
builder.append("(类型:").append(type).append("");
}
builder.append(getAnnotationNote(values, mode));
if (orRole.length > 0) {
builder.append(" 或 角色校验(").append(getAnnotationNote(orRole, mode)).append("");
}
builder.append("</font></br>");
}
}
/**
* 根据注解的模式拼接注解值
*
* @param values 注解的值数组
* @param mode 注解的模式AND 或 OR
* @return 拼接好的注解值字符串
*/
private String getAnnotationNote(String[] values, SaMode mode) {
if (mode.equals(SaMode.AND)) {
return String.join("", values);
} else {
return String.join("", values);
}
}
/**
* 处理 CrudRequestMapping 和 CrudApi 注解生成的权限信息
*
* @param handlerMethod 处理程序方法
* @return 拼接好的权限信息字符串
*/
private String getCrudPermissionInfo(HandlerMethod handlerMethod) {
CrudRequestMapping crudRequestMapping = handlerMethod.getBeanType().getAnnotation(CrudRequestMapping.class);
CrudApi crudApi = handlerMethod.getMethodAnnotation(CrudApi.class);
if (crudRequestMapping == null || crudApi == null) {
return "";
}
String path = crudRequestMapping.value();
String prefix = String.join(StringConstants.COLON, CharSequenceUtil.splitTrim(path, StringConstants.SLASH));
Api api = crudApi.value();
String apiName = Api.PAGE.equals(api) || Api.TREE.equals(api) ? Api.LIST.name() : api.name();
String permission = "%s:%s".formatted(prefix, apiName.toLowerCase());
return "<font style=\"color:red\" class=\"light-red\">Crud 权限校验:</font></br><font style=\"color:red\" class=\"light-red\">方法:</font><font style=\"color:red\" class=\"light-red\">" + permission + "</font>";
}
}

View File

@@ -17,15 +17,16 @@
package top.continew.admin.common.config.exception;
import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.core.util.NumberUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.servlet.NoHandlerFoundException;
import top.continew.starter.core.exception.BadRequestException;
import top.continew.starter.core.exception.BusinessException;
import top.continew.starter.web.model.R;
@@ -34,6 +35,7 @@ import top.continew.starter.web.model.R;
* 全局异常处理器
*
* @author Charles7c
* @author echo
* @since 2024/8/7 20:21
*/
@Slf4j
@@ -73,7 +75,7 @@ public class GlobalExceptionHandler {
* 拦截文件上传异常-超过上传大小限制
*/
@ExceptionHandler(MultipartException.class)
public R handleRequestTooBigException(MultipartException e, HttpServletRequest request) {
public R handleMultipartException(MultipartException e, HttpServletRequest request) {
log.error("[{}] {}", request.getMethod(), request.getRequestURI(), e);
String msg = e.getMessage();
R defaultFail = R.fail(String.valueOf(HttpStatus.BAD_REQUEST.value()), msg);
@@ -85,14 +87,33 @@ public class GlobalExceptionHandler {
if (null != cause) {
msg = msg.concat(cause.getMessage().toLowerCase());
}
if (msg.contains("size") && msg.contains("exceed")) {
sizeLimit = CharSequenceUtil.subBetween(msg, "the maximum size ", " for");
} else if (msg.contains("larger than")) {
if (msg.contains("larger than")) {
sizeLimit = CharSequenceUtil.subAfter(msg, "larger than ", true);
} else if (msg.contains("size") && msg.contains("exceed")) {
sizeLimit = CharSequenceUtil.subBetween(msg, "the maximum size ", " for");
} else {
return defaultFail;
}
String errorMsg = "请上传小于 %sKB 的文件".formatted(NumberUtil.parseLong(sizeLimit) / 1024);
return R.fail(String.valueOf(HttpStatus.BAD_REQUEST.value()), errorMsg);
return R.fail(String.valueOf(HttpStatus.BAD_REQUEST.value()), "请上传小于 %s bytes 的文件".formatted(sizeLimit));
}
/**
* 拦截请求 URL 不存在异常
*/
@ExceptionHandler(NoHandlerFoundException.class)
public R handleNoHandlerFoundException(NoHandlerFoundException e, HttpServletRequest request) {
log.error("[{}] {}", request.getMethod(), request.getRequestURI(), e);
return R.fail(String.valueOf(HttpStatus.NOT_FOUND.value()), "请求 URL '%s' 不存在".formatted(request
.getRequestURI()));
}
/**
* 拦截不支持的 HTTP 请求方法异常
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public R handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e,
HttpServletRequest request) {
log.error("[{}] {}", request.getMethod(), request.getRequestURI(), e);
return R.fail(String.valueOf(HttpStatus.METHOD_NOT_ALLOWED.value()), "请求方式 '%s' 不支持".formatted(e.getMethod()));
}
}