mirror of
				https://github.com/continew-org/continew-admin.git
				synced 2025-10-31 10:57:13 +08:00 
			
		
		
		
	feat: 新增接口文档配置,支持显示 SaToken 权限码 @dom-w
This commit is contained in:
		| @@ -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)); | ||||
|     } | ||||
| } | ||||
| @@ -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/1/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; | ||||
|     } | ||||
| } | ||||
| @@ -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/6/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>"; | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user