diff --git a/continew-common/pom.xml b/continew-common/pom.xml index d9ef7094..a6ce2e87 100644 --- a/continew-common/pom.xml +++ b/continew-common/pom.xml @@ -29,6 +29,19 @@ sms4j-spring-boot-starter + + + org.dromara.x-file-storage + x-file-storage-spring + 2.2.1 + + + + com.amazonaws + aws-java-sdk-s3 + 1.12.780 + + org.freemarker @@ -136,19 +149,5 @@ top.continew continew-starter-json-jackson - - - - - top.continew - continew-starter-storage-local - - - - - - top.continew - continew-starter-storage-oss - \ No newline at end of file diff --git a/continew-common/src/main/java/top/continew/admin/common/config/doc/GlobalAuthenticationCustomizer.java b/continew-common/src/main/java/top/continew/admin/common/config/doc/GlobalAuthenticationCustomizer.java deleted file mode 100644 index 5464f36a..00000000 --- a/continew-common/src/main/java/top/continew/admin/common/config/doc/GlobalAuthenticationCustomizer.java +++ /dev/null @@ -1,216 +0,0 @@ -/* - * 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 excludedPaths = collectExcludedPaths(); - - // 遍历所有路径,为需要鉴权的路径添加安全认证配置 - openApi.getPaths().forEach((path, pathItem) -> { - if (isPathExcluded(path, excludedPaths)) { - // 路径在排除列表中,跳过处理 - return; - } - // 为路径添加安全认证参数 - addAuthenticationParameters(pathItem); - }); - } - - /** - * 收集所有需要排除的路径 - * - * @return 排除路径集合 - */ - private Set collectExcludedPaths() { - Set 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 securitySchemes = components.getSecuritySchemes(); - List 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 resolveSaIgnorePaths() { - // 获取所有标注 @RestController 的 Bean - Map controllers = context.getBeansWithAnnotation(RestController.class); - Set ignoredPaths = new HashSet<>(); - - // 遍历所有控制器,解析 @SaIgnore 注解路径 - controllers.values().forEach(controllerBean -> { - Class controllerClass = AopUtils.getTargetClass(controllerBean); - List 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 getClassPaths(Class controller) { - List 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 getMethodPaths(Method method) { - List 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 combinePaths(List classPaths, List 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 excludedPaths) { - return excludedPaths.stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); - } -} diff --git a/continew-common/src/main/java/top/continew/admin/common/config/doc/GlobalDescriptionCustomizer.java b/continew-common/src/main/java/top/continew/admin/common/config/doc/GlobalDescriptionCustomizer.java deleted file mode 100644 index 52d14a01..00000000 --- a/continew-common/src/main/java/top/continew/admin/common/config/doc/GlobalDescriptionCustomizer.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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 noteList = new ArrayList<>(new OperationDescriptionCustomizer().getPermission(handlerMethod)); - - // 如果注解数据列表为空,直接返回原 operation - if (noteList.isEmpty()) { - return operation; - } - // 拼接注解数据为字符串 - String noteStr = StrUtil.join("
", noteList); - // 获取原描述 - String originalDescription = operation.getDescription(); - // 根据原描述是否为空,更新描述 - String newDescription = StringUtils.isNotEmpty(originalDescription) - ? originalDescription + "
" + noteStr - : noteStr; - - // 设置新描述 - operation.setDescription(newDescription); - return operation; - } -} diff --git a/continew-common/src/main/java/top/continew/admin/common/config/doc/OperationDescriptionCustomizer.java b/continew-common/src/main/java/top/continew/admin/common/config/doc/OperationDescriptionCustomizer.java deleted file mode 100644 index 7625640c..00000000 --- a/continew-common/src/main/java/top/continew/admin/common/config/doc/OperationDescriptionCustomizer.java +++ /dev/null @@ -1,180 +0,0 @@ -/* - * 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 getPermission(HandlerMethod handlerMethod) { - List 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 注解类型 - * @return 拼接好的注解信息字符串 - */ - @SuppressWarnings("unchecked") - private String getAnnotationInfo(HandlerMethod handlerMethod, - Class 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, "" + title + "
"); - } - - 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(""); - 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("
"); - } - } - - /** - * 根据注解的模式拼接注解值 - * - * @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 "Crud 权限校验:
方法:" + permission + ""; - } -} diff --git a/continew-common/src/main/java/top/continew/admin/common/config/exception/GlobalExceptionHandler.java b/continew-common/src/main/java/top/continew/admin/common/config/exception/GlobalExceptionHandler.java index 567b8b58..f2f7562d 100644 --- a/continew-common/src/main/java/top/continew/admin/common/config/exception/GlobalExceptionHandler.java +++ b/continew-common/src/main/java/top/continew/admin/common/config/exception/GlobalExceptionHandler.java @@ -17,16 +17,15 @@ 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; @@ -35,7 +34,6 @@ import top.continew.starter.web.model.R; * 全局异常处理器 * * @author Charles7c - * @author echo * @since 2024/8/7 20:21 */ @Slf4j @@ -75,7 +73,7 @@ public class GlobalExceptionHandler { * 拦截文件上传异常-超过上传大小限制 */ @ExceptionHandler(MultipartException.class) - public R handleMultipartException(MultipartException e, HttpServletRequest request) { + public R handleRequestTooBigException(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); @@ -87,33 +85,14 @@ public class GlobalExceptionHandler { if (null != cause) { msg = msg.concat(cause.getMessage().toLowerCase()); } - if (msg.contains("larger than")) { - sizeLimit = CharSequenceUtil.subAfter(msg, "larger than ", true); - } else if (msg.contains("size") && msg.contains("exceed")) { + if (msg.contains("size") && msg.contains("exceed")) { sizeLimit = CharSequenceUtil.subBetween(msg, "the maximum size ", " for"); + } else if (msg.contains("larger than")) { + sizeLimit = CharSequenceUtil.subAfter(msg, "larger than ", true); } else { return defaultFail; } - 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())); + String errorMsg = "请上传小于 %sKB 的文件".formatted(NumberUtil.parseLong(sizeLimit) / 1024); + return R.fail(String.valueOf(HttpStatus.BAD_REQUEST.value()), errorMsg); } } \ No newline at end of file diff --git a/continew-module-system/src/main/java/top/continew/admin/auth/handler/AccountLoginHandler.java b/continew-module-system/src/main/java/top/continew/admin/auth/handler/AccountLoginHandler.java index f586f958..0a677d7c 100644 --- a/continew-module-system/src/main/java/top/continew/admin/auth/handler/AccountLoginHandler.java +++ b/continew-module-system/src/main/java/top/continew/admin/auth/handler/AccountLoginHandler.java @@ -110,8 +110,7 @@ public class AccountLoginHandler extends AbstractLoginHandler { .getClientIP(request)); int lockMinutes = optionService.getValueByCode2Int(PasswordPolicyEnum.PASSWORD_ERROR_LOCK_MINUTES.name()); Integer currentErrorCount = ObjectUtil.defaultIfNull(RedisUtils.get(key), 0); - CheckUtils.throwIf(currentErrorCount >= maxErrorCount, PasswordPolicyEnum.PASSWORD_ERROR_LOCK_MINUTES.getMsg() - .formatted(lockMinutes)); + CheckUtils.throwIf(currentErrorCount >= maxErrorCount, "账号锁定 {} 分钟,请稍后再试", lockMinutes); // 登录成功清除计数 if (!isError) { RedisUtils.delete(key); @@ -120,7 +119,6 @@ public class AccountLoginHandler extends AbstractLoginHandler { // 登录失败递增计数 currentErrorCount++; RedisUtils.set(key, currentErrorCount, Duration.ofMinutes(lockMinutes)); - CheckUtils.throwIf(currentErrorCount >= maxErrorCount, PasswordPolicyEnum.PASSWORD_ERROR_LOCK_COUNT.getMsg() - .formatted(maxErrorCount, lockMinutes)); + CheckUtils.throwIf(currentErrorCount >= maxErrorCount, "密码错误已达 {} 次,账号锁定 {} 分钟", maxErrorCount, lockMinutes); } } \ No newline at end of file diff --git a/continew-module-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java b/continew-module-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java new file mode 100644 index 00000000..b15681d7 --- /dev/null +++ b/continew-module-system/src/main/java/top/continew/admin/system/config/file/FileRecorderImpl.java @@ -0,0 +1,119 @@ +/* + * 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.system.config.file; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.ClassUtil; +import cn.hutool.core.util.EscapeUtil; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.x.file.storage.core.FileInfo; +import org.dromara.x.file.storage.core.recorder.FileRecorder; +import org.dromara.x.file.storage.core.upload.FilePartInfo; +import org.springframework.stereotype.Component; +import top.continew.admin.common.context.UserContextHolder; +import top.continew.admin.system.enums.FileTypeEnum; +import top.continew.admin.system.mapper.FileMapper; +import top.continew.admin.system.mapper.StorageMapper; +import top.continew.admin.system.model.entity.FileDO; +import top.continew.admin.system.model.entity.StorageDO; +import top.continew.starter.core.constant.StringConstants; + +import java.util.Optional; + +/** + * 文件记录实现类 + * + * @author Charles7c + * @since 2023/12/24 22:31 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class FileRecorderImpl implements FileRecorder { + + private final FileMapper fileMapper; + private final StorageMapper storageMapper; + + @Override + public boolean save(FileInfo fileInfo) { + FileDO file = new FileDO(); + String originalFilename = EscapeUtil.unescape(fileInfo.getOriginalFilename()); + file.setName(StrUtil.contains(originalFilename, StringConstants.DOT) + ? StrUtil.subBefore(originalFilename, StringConstants.DOT, true) + : originalFilename); + file.setUrl(fileInfo.getUrl()); + file.setSize(fileInfo.getSize()); + file.setExtension(fileInfo.getExt()); + file.setType(FileTypeEnum.getByExtension(file.getExtension())); + file.setThumbnailUrl(fileInfo.getThUrl()); + file.setThumbnailSize(fileInfo.getThSize()); + StorageDO storage = (StorageDO)fileInfo.getAttr().get(ClassUtil.getClassName(StorageDO.class, false)); + file.setStorageId(storage.getId()); + file.setCreateTime(DateUtil.toLocalDateTime(fileInfo.getCreateTime())); + file.setUpdateUser(UserContextHolder.getUserId()); + file.setUpdateTime(file.getCreateTime()); + fileMapper.insert(file); + return true; + } + + @Override + public FileInfo getByUrl(String url) { + FileDO file = this.getFileByUrl(url); + if (null == file) { + return null; + } + StorageDO storageDO = storageMapper.lambdaQuery().eq(StorageDO::getId, file.getStorageId()).one(); + return file.toFileInfo(storageDO); + } + + @Override + public boolean delete(String url) { + FileDO file = this.getFileByUrl(url); + return fileMapper.lambdaUpdate().eq(FileDO::getUrl, file.getUrl()).remove(); + } + + @Override + public void update(FileInfo fileInfo) { + /* 不使用分片功能则无需重写 */ + } + + @Override + public void saveFilePart(FilePartInfo filePartInfo) { + /* 不使用分片功能则无需重写 */ + } + + @Override + public void deleteFilePartByUploadId(String s) { + /* 不使用分片功能则无需重写 */ + } + + /** + * 根据 URL 查询文件 + * + * @param url URL + * @return 文件信息 + */ + private FileDO getFileByUrl(String url) { + Optional fileOptional = fileMapper.lambdaQuery().eq(FileDO::getUrl, url).oneOpt(); + return fileOptional.orElseGet(() -> fileMapper.lambdaQuery() + .likeLeft(FileDO::getUrl, StrUtil.subAfter(url, StringConstants.SLASH, true)) + .oneOpt() + .orElse(null)); + } +} \ No newline at end of file diff --git a/continew-module-system/src/main/java/top/continew/admin/system/config/file/FileStorageConfigLoader.java b/continew-module-system/src/main/java/top/continew/admin/system/config/file/FileStorageConfigLoader.java index 77108645..6e0d5b4e 100644 --- a/continew-module-system/src/main/java/top/continew/admin/system/config/file/FileStorageConfigLoader.java +++ b/continew-module-system/src/main/java/top/continew/admin/system/config/file/FileStorageConfigLoader.java @@ -16,24 +16,25 @@ package top.continew.admin.system.config.file; +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; -import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; -import top.continew.admin.system.enums.OptionCategoryEnum; -import top.continew.admin.system.mapper.FileMapper; -import top.continew.admin.system.service.OptionService; -import top.continew.starter.storage.dao.StorageDao; +import top.continew.admin.common.enums.DisEnableStatusEnum; +import top.continew.admin.system.model.query.StorageQuery; +import top.continew.admin.system.model.req.StorageReq; +import top.continew.admin.system.model.resp.StorageResp; +import top.continew.admin.system.service.StorageService; -import java.util.Map; +import java.util.List; /** * 文件存储配置加载器 * * @author Charles7c - * @author echo * @since 2023/12/24 22:31 */ @Slf4j @@ -41,22 +42,16 @@ import java.util.Map; @RequiredArgsConstructor public class FileStorageConfigLoader implements ApplicationRunner { - private final OptionService optionService; - private final FileStorageInit fileStorageInit; + private final StorageService storageService; @Override public void run(ApplicationArguments args) { - // 查询存储配置 - Map map = optionService.getByCategory(OptionCategoryEnum.STORAGE); - // 加载存储配置 - fileStorageInit.load(map); - } - - /** - * 存储持久层接口本地实现类 - */ - @Bean - public StorageDao storageDao(FileMapper fileMapper) { - return new StorageDaoImpl(fileMapper); + StorageQuery query = new StorageQuery(); + query.setStatus(DisEnableStatusEnum.ENABLE); + List storageList = storageService.list(query, null); + if (CollUtil.isEmpty(storageList)) { + return; + } + storageList.forEach(s -> storageService.load(BeanUtil.copyProperties(s, StorageReq.class))); } } diff --git a/continew-module-system/src/main/java/top/continew/admin/system/config/file/FileStorageInit.java b/continew-module-system/src/main/java/top/continew/admin/system/config/file/FileStorageInit.java deleted file mode 100644 index 030896aa..00000000 --- a/continew-module-system/src/main/java/top/continew/admin/system/config/file/FileStorageInit.java +++ /dev/null @@ -1,146 +0,0 @@ -/* - * 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.system.config.file; - -import cn.hutool.core.map.MapUtil; -import cn.hutool.extra.spring.SpringUtil; -import org.springframework.stereotype.Component; -import top.continew.admin.system.enums.StorageTypeEnum; -import top.continew.starter.cache.redisson.util.RedisUtils; -import top.continew.starter.storage.client.LocalClient; -import top.continew.starter.storage.client.OssClient; -import top.continew.starter.storage.constant.StorageConstant; -import top.continew.starter.storage.dao.StorageDao; -import top.continew.starter.storage.manger.StorageManager; -import top.continew.starter.storage.model.req.StorageProperties; -import top.continew.starter.storage.strategy.LocalStorageStrategy; -import top.continew.starter.storage.strategy.OssStorageStrategy; -import top.continew.starter.storage.util.StorageUtils; -import top.continew.starter.web.util.SpringWebUtils; - -import java.util.Map; - -/** - * 文件存储初始化 - * - * @author echo - * @author Charles7c - * @since 2024/12/20 11:10 - */ -@Component -public class FileStorageInit { - - /** - * 加载文件存储 - * - * @param map 存储配置 - */ - public void load(Map map) { - StorageManager.unload(StorageTypeEnum.OSS.name()); - StorageManager.unload(StorageTypeEnum.LOCAL.name()); - // 获取默认存储值并缓存 - String storageDefault = cacheDefaultStorage(map); - if (StorageTypeEnum.LOCAL.name().equals(storageDefault)) { - // 获取本地终端地址 和桶地址 - String localEndpoint = map.get("STORAGE_LOCAL_ENDPOINT"); - String localBucket = map.get("STORAGE_LOCAL_BUCKET"); - // 构建并加载本地存储配置 - StorageProperties localProperties = buildStorageProperties(StorageTypeEnum.LOCAL - .name(), localBucket, storageDefault, localEndpoint); - // 本地静态资源映射 - SpringWebUtils.registerResourceHandler(MapUtil.of(StorageUtils.createUriWithProtocol(localEndpoint) - .getPath(), localBucket)); - StorageManager.load(localProperties - .getCode(), new LocalStorageStrategy(new LocalClient(localProperties), SpringUtil - .getBean(StorageDao.class))); - } else if (StorageTypeEnum.OSS.name().equals(storageDefault)) { - // 构建并加载对象存储配置 - StorageProperties ossProperties = buildStorageProperties(StorageTypeEnum.OSS.name(), map - .get("STORAGE_OSS_BUCKET"), storageDefault, map.get("STORAGE_OSS_ACCESS_KEY"), map - .get("STORAGE_OSS_SECRET_KEY"), map.get("STORAGE_OSS_ENDPOINT"), map.get("STORAGE_OSS_REGION")); - StorageManager.load(ossProperties.getCode(), new OssStorageStrategy(new OssClient(ossProperties), SpringUtil - .getBean(StorageDao.class))); - } - } - - /** - * 卸载文件存储 - * - * @param code 存储编码 - */ - public void unLoad(String code) { - StorageManager.unload(code); - } - - /** - * 将默认存储值放入缓存 - * - * @param map 存储配置 - * @return {@link String } - */ - private String cacheDefaultStorage(Map map) { - String storageDefault = MapUtil.getStr(map, "STORAGE_DEFAULT"); - RedisUtils.set(StorageConstant.DEFAULT_KEY, storageDefault); - return storageDefault; - } - - /** - * 构建本地存储配置属性 - * - * @param code 存储码 - * @param bucketName 桶名称 - * @param defaultCode 默认存储码 - * @return {@link StorageProperties } - */ - private StorageProperties buildStorageProperties(String code, - String bucketName, - String defaultCode, - String endpoint) { - StorageProperties properties = new StorageProperties(); - properties.setCode(code); - properties.setBucketName(bucketName); - properties.setEndpoint(endpoint); - properties.setIsDefault(code.equals(defaultCode)); - return properties; - } - - /** - * 构建对象存储配置属性 - * - * @param code 存储码 - * @param bucketName 桶名称 - * @param defaultCode 默认存储码 - * @param accessKey 访问密钥 - * @param secretKey 秘密密钥 - * @param endpoint 端点 - * @param region 区域 - * @return {@link StorageProperties } - */ - private StorageProperties buildStorageProperties(String code, - String bucketName, - String defaultCode, - String accessKey, - String secretKey, - String endpoint, - String region) { - StorageProperties properties = buildStorageProperties(code, bucketName, defaultCode, endpoint); - properties.setAccessKey(accessKey); - properties.setSecretKey(secretKey); - properties.setRegion(region); - return properties; - } -} diff --git a/continew-module-system/src/main/java/top/continew/admin/system/config/file/StorageDaoImpl.java b/continew-module-system/src/main/java/top/continew/admin/system/config/file/StorageDaoImpl.java deleted file mode 100644 index 2c87ffbd..00000000 --- a/continew-module-system/src/main/java/top/continew/admin/system/config/file/StorageDaoImpl.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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.system.config.file; - -import cn.hutool.core.util.EscapeUtil; -import cn.hutool.core.util.StrUtil; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import top.continew.admin.common.context.UserContextHolder; -import top.continew.admin.system.enums.FileTypeEnum; -import top.continew.admin.system.mapper.FileMapper; -import top.continew.admin.system.model.entity.FileDO; -import top.continew.starter.core.constant.StringConstants; -import top.continew.starter.storage.dao.StorageDao; -import top.continew.starter.storage.model.resp.UploadResp; - -/** - * 存储持久层接口本地实现类 - * - * @author Charles7c - * @author echo - * @since 2023/12/24 22:31 - */ -@Slf4j -@RequiredArgsConstructor -public class StorageDaoImpl implements StorageDao { - - private final FileMapper fileMapper; - - @Override - public void add(UploadResp uploadResp) { - FileDO file = new FileDO(); - file.setStorageCode(uploadResp.getCode()); - String originalFilename = EscapeUtil.unescape(uploadResp.getOriginalFilename()); - file.setName(StrUtil.contains(originalFilename, StringConstants.DOT) - ? StrUtil.subBefore(originalFilename, StringConstants.DOT, true) - : originalFilename); - file.setUrl(uploadResp.getUrl()); - file.setPath(uploadResp.getBasePath()); - file.setSize(uploadResp.getSize()); - file.setThumbnailUrl(uploadResp.getThumbnailUrl()); - file.setThumbnailSize(uploadResp.getThumbnailSize()); - file.setExtension(uploadResp.getExt()); - file.setType(FileTypeEnum.getByExtension(file.getExtension())); - file.setETag(uploadResp.geteTag()); - file.setBucketName(uploadResp.getBucketName()); - file.setCreateTime(uploadResp.getCreateTime()); - file.setUpdateUser(UserContextHolder.getUserId()); - file.setUpdateTime(file.getCreateTime()); - fileMapper.insert(file); - } -} \ No newline at end of file diff --git a/continew-module-system/src/main/java/top/continew/admin/system/enums/OptionCategoryEnum.java b/continew-module-system/src/main/java/top/continew/admin/system/enums/OptionCategoryEnum.java index 681d9feb..2533d112 100644 --- a/continew-module-system/src/main/java/top/continew/admin/system/enums/OptionCategoryEnum.java +++ b/continew-module-system/src/main/java/top/continew/admin/system/enums/OptionCategoryEnum.java @@ -43,9 +43,4 @@ public enum OptionCategoryEnum { * 登录配置 */ LOGIN, - - /** - * 存储配置 - */ - STORAGE,; } diff --git a/continew-module-system/src/main/java/top/continew/admin/system/enums/PasswordPolicyEnum.java b/continew-module-system/src/main/java/top/continew/admin/system/enums/PasswordPolicyEnum.java index 7a90c350..cde757e5 100644 --- a/continew-module-system/src/main/java/top/continew/admin/system/enums/PasswordPolicyEnum.java +++ b/continew-module-system/src/main/java/top/continew/admin/system/enums/PasswordPolicyEnum.java @@ -45,14 +45,14 @@ import java.util.Map; public enum PasswordPolicyEnum { /** - * 密码错误锁定阈值 + * 登录密码错误锁定账号的次数 */ - PASSWORD_ERROR_LOCK_COUNT("密码错误锁定阈值取值范围为 %d-%d", SysConstants.NO, 10, "密码错误已达 %d 次,账号锁定 %d 分钟"), + PASSWORD_ERROR_LOCK_COUNT("登录密码错误锁定账号的次数取值范围为 %d-%d", SysConstants.NO, 10, null), /** - * 账号锁定时长(分钟) + * 登录密码错误锁定账号的时间(min) */ - PASSWORD_ERROR_LOCK_MINUTES("账号锁定时长取值范围为 %d-%d 分钟", 1, 1440, "账号锁定 %d 分钟,请稍后再试"), + PASSWORD_ERROR_LOCK_MINUTES("登录密码错误锁定账号的时间取值范围为 %d-%d 分钟", 1, 1440, null), /** * 密码有效期(天) @@ -60,9 +60,9 @@ public enum PasswordPolicyEnum { PASSWORD_EXPIRATION_DAYS("密码有效期取值范围为 %d-%d 天", SysConstants.NO, 999, null), /** - * 密码到期提醒(天) + * 密码到期提前提示(天) */ - PASSWORD_EXPIRATION_WARNING_DAYS("密码到期提醒取值范围为 %d-%d 天", SysConstants.NO, 998, null) { + PASSWORD_EXPIRATION_WARNING_DAYS("密码到期提前提示取值范围为 %d-%d 天", SysConstants.NO, 998, null) { @Override public void validateRange(int value, Map policyMap) { if (CollUtil.isEmpty(policyMap)) { @@ -73,7 +73,7 @@ public enum PasswordPolicyEnum { .get(PASSWORD_EXPIRATION_DAYS.name())), SpringUtil.getBean(OptionService.class) .getValueByCode2Int(PASSWORD_EXPIRATION_DAYS.name())); if (passwordExpirationDays > SysConstants.NO) { - ValidationUtils.throwIf(value >= passwordExpirationDays, "密码到期提醒时间应小于密码有效期"); + ValidationUtils.throwIf(value >= passwordExpirationDays, "密码到期前的提示时间应小于密码有效期"); return; } super.validateRange(value, policyMap); @@ -113,9 +113,9 @@ public enum PasswordPolicyEnum { }, /** - * 密码是否允许包含用户名 + * 密码是否允许包含正反序账号名 */ - PASSWORD_ALLOW_CONTAIN_USERNAME("密码是否允许包含用户名取值只能为是(%d)或否(%d)", SysConstants.NO, SysConstants.YES, "密码不允许包含正反序用户名") { + PASSWORD_ALLOW_CONTAIN_USERNAME("密码是否允许包含正反序账号名取值只能为是(%d)或否(%d)", SysConstants.NO, SysConstants.YES, "密码不允许包含正反序账号名") { @Override public void validateRange(int value, Map policyMap) { ValidationUtils.throwIf(value != SysConstants.YES && value != SysConstants.NO, this.getDescription() @@ -133,9 +133,9 @@ public enum PasswordPolicyEnum { }, /** - * 历史密码重复校验次数 + * 密码重复使用次数 */ - PASSWORD_REPETITION_TIMES("历史密码重复校验次数取值范围为 %d-%d", 3, 32, "新密码不得与历史前 %d 次密码重复") { + PASSWORD_REPETITION_TIMES("密码重复使用规则取值范围为 %d-%d", 3, 32, "新密码不得与历史前 %d 次密码重复") { @Override public void validate(String password, int value, UserDO user) { UserPasswordHistoryService userPasswordHistoryService = SpringUtil diff --git a/continew-module-system/src/main/java/top/continew/admin/system/enums/StorageTypeEnum.java b/continew-module-system/src/main/java/top/continew/admin/system/enums/StorageTypeEnum.java index 1dfaed59..45a3c8c6 100644 --- a/continew-module-system/src/main/java/top/continew/admin/system/enums/StorageTypeEnum.java +++ b/continew-module-system/src/main/java/top/continew/admin/system/enums/StorageTypeEnum.java @@ -31,9 +31,9 @@ import top.continew.starter.core.enums.BaseEnum; public enum StorageTypeEnum implements BaseEnum { /** - * 对象存储 + * 兼容S3协议存储 */ - OSS(1, "对象存储"), + S3(1, "兼容S3协议存储"), /** * 本地存储 diff --git a/continew-module-system/src/main/java/top/continew/admin/system/mapper/StorageMapper.java b/continew-module-system/src/main/java/top/continew/admin/system/mapper/StorageMapper.java new file mode 100644 index 00000000..06d8c5f6 --- /dev/null +++ b/continew-module-system/src/main/java/top/continew/admin/system/mapper/StorageMapper.java @@ -0,0 +1,29 @@ +/* + * 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.system.mapper; + +import top.continew.admin.system.model.entity.StorageDO; +import top.continew.starter.data.mp.base.BaseMapper; + +/** + * 存储 Mapper + * + * @author Charles7c + * @since 2023/12/26 22:09 + */ +public interface StorageMapper extends BaseMapper { +} \ No newline at end of file diff --git a/continew-module-system/src/main/java/top/continew/admin/system/model/entity/FileDO.java b/continew-module-system/src/main/java/top/continew/admin/system/model/entity/FileDO.java index b15a0105..95ce396a 100644 --- a/continew-module-system/src/main/java/top/continew/admin/system/model/entity/FileDO.java +++ b/continew-module-system/src/main/java/top/continew/admin/system/model/entity/FileDO.java @@ -16,12 +16,19 @@ package top.continew.admin.system.model.entity; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; +import lombok.SneakyThrows; +import org.dromara.x.file.storage.core.FileInfo; import top.continew.admin.system.enums.FileTypeEnum; +import top.continew.admin.system.enums.StorageTypeEnum; +import top.continew.starter.core.constant.StringConstants; +import top.continew.starter.core.util.StrUtils; import top.continew.admin.common.model.entity.BaseDO; import java.io.Serial; +import java.net.URL; /** * 文件实体 @@ -72,23 +79,64 @@ public class FileDO extends BaseDO { private String thumbnailUrl; /** - * 存储code + * 存储 ID */ - private String storageCode; + private Long storageId; /** - * 基础路径 + * 转换为 X-File-Storage 文件信息对象 + * + * @param storageDO 存储桶信息 + * @return X-File-Storage 文件信息对象 */ - private String path; + public FileInfo toFileInfo(StorageDO storageDO) { + FileInfo fileInfo = new FileInfo(); + fileInfo.setUrl(this.url); + fileInfo.setSize(this.size); + fileInfo.setFilename(StrUtil.contains(this.url, StringConstants.SLASH) + ? StrUtil.subAfter(this.url, StringConstants.SLASH, true) + : this.url); + fileInfo.setOriginalFilename(StrUtils + .blankToDefault(this.extension, this.name, ex -> this.name + StringConstants.DOT + ex)); + fileInfo.setBasePath(StringConstants.EMPTY); + // 优化 path 处理 + fileInfo.setPath(extractRelativePath(this.url, storageDO)); + + fileInfo.setExt(this.extension); + fileInfo.setPlatform(storageDO.getCode()); + fileInfo.setThUrl(this.thumbnailUrl); + fileInfo.setThFilename(StrUtil.contains(this.thumbnailUrl, StringConstants.SLASH) + ? StrUtil.subAfter(this.thumbnailUrl, StringConstants.SLASH, true) + : this.thumbnailUrl); + fileInfo.setThSize(this.thumbnailSize); + return fileInfo; + } /** - * 存储桶 + * 将文件路径处理成资源路径 + * 例如: + * http://domain.cn/bucketName/2024/11/27/6746ec3b2907f0de80afdd70.png => 2024/11/27/ + * http://bucketName.domain.cn/2024/11/27/6746ec3b2907f0de80afdd70.png => 2024/11/27/ + * + * @param url 文件路径 + * @param storageDO 存储桶信息 + * @return */ - private String bucketName; - - /** - * 文件标识 - */ - private String eTag; + @SneakyThrows + private static String extractRelativePath(String url, StorageDO storageDO) { + url = StrUtil.subBefore(url, StringConstants.SLASH, true) + StringConstants.SLASH; + if (storageDO.getType().equals(StorageTypeEnum.LOCAL)) { + return url; + } + // 提取 URL 中的路径部分 + String fullPath = new URL(url).getPath(); + // 移除开头的斜杠 + String relativePath = fullPath.startsWith(StringConstants.SLASH) ? fullPath.substring(1) : fullPath; + // 如果路径以 bucketName 开头,则移除 bucketName 例如: bucketName/2024/11/27/ -> 2024/11/27/ + if (relativePath.startsWith(storageDO.getBucketName())) { + return StrUtil.split(relativePath, storageDO.getBucketName()).get(1); + } + return relativePath; + } } diff --git a/continew-module-system/src/main/java/top/continew/admin/system/model/entity/StorageDO.java b/continew-module-system/src/main/java/top/continew/admin/system/model/entity/StorageDO.java new file mode 100644 index 00000000..e6109c43 --- /dev/null +++ b/continew-module-system/src/main/java/top/continew/admin/system/model/entity/StorageDO.java @@ -0,0 +1,102 @@ +/* + * 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.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import top.continew.admin.common.enums.DisEnableStatusEnum; +import top.continew.admin.system.enums.StorageTypeEnum; +import top.continew.admin.common.model.entity.BaseDO; +import top.continew.starter.security.crypto.annotation.FieldEncrypt; + +import java.io.Serial; + +/** + * 存储实体 + * + * @author Charles7c + * @since 2023/12/26 22:09 + */ +@Data +@TableName("sys_storage") +public class StorageDO extends BaseDO { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 名称 + */ + private String name; + + /** + * 编码 + */ + private String code; + + /** + * 类型 + */ + private StorageTypeEnum type; + + /** + * Access Key(访问密钥) + */ + @FieldEncrypt + private String accessKey; + + /** + * Secret Key(私有密钥) + */ + @FieldEncrypt + private String secretKey; + + /** + * Endpoint(终端节点) + */ + private String endpoint; + + /** + * 桶名称 + */ + private String bucketName; + + /** + * 域名 + */ + private String domain; + + /** + * 描述 + */ + private String description; + + /** + * 是否为默认存储 + */ + private Boolean isDefault; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态 + */ + private DisEnableStatusEnum status; +} \ No newline at end of file diff --git a/continew-module-system/src/main/java/top/continew/admin/system/model/query/StorageQuery.java b/continew-module-system/src/main/java/top/continew/admin/system/model/query/StorageQuery.java new file mode 100644 index 00000000..7dd6f592 --- /dev/null +++ b/continew-module-system/src/main/java/top/continew/admin/system/model/query/StorageQuery.java @@ -0,0 +1,53 @@ +/* + * 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.system.model.query; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import top.continew.admin.common.enums.DisEnableStatusEnum; +import top.continew.starter.data.core.annotation.Query; +import top.continew.starter.data.core.enums.QueryType; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 存储查询条件 + * + * @author Charles7c + * @since 2023/12/26 22:09 + */ +@Data +@Schema(description = "存储查询条件") +public class StorageQuery implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 关键词 + */ + @Schema(description = "关键词", example = "本地存储") + @Query(columns = {"name", "code", "description"}, type = QueryType.LIKE) + private String description; + + /** + * 状态 + */ + @Schema(description = "状态", example = "1") + private DisEnableStatusEnum status; +} \ No newline at end of file diff --git a/continew-module-system/src/main/java/top/continew/admin/system/model/req/StorageReq.java b/continew-module-system/src/main/java/top/continew/admin/system/model/req/StorageReq.java new file mode 100644 index 00000000..9a529cac --- /dev/null +++ b/continew-module-system/src/main/java/top/continew/admin/system/model/req/StorageReq.java @@ -0,0 +1,134 @@ +/* + * 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.system.model.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import org.hibernate.validator.constraints.Length; +import top.continew.admin.common.constant.RegexConstants; +import top.continew.admin.common.enums.DisEnableStatusEnum; +import top.continew.admin.system.enums.StorageTypeEnum; +import top.continew.admin.system.validation.ValidationGroup; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 存储请求参数 + * + * @author Charles7c + * @since 2023/12/26 22:09 + */ +@Data +@Schema(description = "存储请求参数") +public class StorageReq implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 名称 + */ + @Schema(description = "名称", example = "存储1") + @NotBlank(message = "名称不能为空") + @Length(max = 100, message = "名称长度不能超过 {max} 个字符") + private String name; + + /** + * 编码 + */ + @Schema(description = "编码", example = "local") + @NotBlank(message = "编码不能为空") + @Pattern(regexp = RegexConstants.GENERAL_CODE, message = "编码长度为 2-30 个字符,支持大小写字母、数字、下划线,以字母开头") + private String code; + + /** + * 类型 + */ + @Schema(description = "类型", example = "2") + @NotNull(message = "类型非法") + private StorageTypeEnum type; + + /** + * 访问密钥 + */ + @Schema(description = "访问密钥", example = "") + @Length(max = 255, message = "访问密钥长度不能超过 {max} 个字符") + @NotBlank(message = "访问密钥不能为空", groups = ValidationGroup.Storage.S3.class) + private String accessKey; + + /** + * 私有密钥 + */ + @Schema(description = "私有密钥", example = "") + @NotBlank(message = "私有密钥不能为空", groups = ValidationGroup.Storage.S3.class) + private String secretKey; + + /** + * 终端节点 + */ + @Schema(description = "终端节点", example = "") + @Length(max = 255, message = "终端节点长度不能超过 {max} 个字符") + @NotBlank(message = "终端节点不能为空", groups = ValidationGroup.Storage.S3.class) + private String endpoint; + + /** + * 桶名称 + */ + @Schema(description = "桶名称", example = "C:/continew-admin/data/file/") + @Length(max = 255, message = "桶名称长度不能超过 {max} 个字符") + @NotBlank(message = "桶名称不能为空", groups = ValidationGroup.Storage.S3.class) + @NotBlank(message = "存储路径不能为空", groups = ValidationGroup.Storage.Local.class) + private String bucketName; + + /** + * 域名 + */ + @Schema(description = "域名", example = "http://localhost:8000/file") + @Length(max = 255, message = "域名长度不能超过 {max} 个字符") + @NotBlank(message = "域名不能为空") + private String domain; + + /** + * 排序 + */ + @Schema(description = "排序", example = "1") + private Integer sort; + + /** + * 描述 + */ + @Schema(description = "描述", example = "存储描述") + @Length(max = 200, message = "描述长度不能超过 {max} 个字符") + private String description; + + /** + * 是否为默认存储 + */ + @Schema(description = "是否为默认存储", example = "true") + @NotNull(message = "是否为默认存储不能为空") + private Boolean isDefault; + + /** + * 状态 + */ + @Schema(description = "状态", example = "1") + private DisEnableStatusEnum status; +} \ No newline at end of file diff --git a/continew-module-system/src/main/java/top/continew/admin/system/model/resp/StorageResp.java b/continew-module-system/src/main/java/top/continew/admin/system/model/resp/StorageResp.java new file mode 100644 index 00000000..439b15bf --- /dev/null +++ b/continew-module-system/src/main/java/top/continew/admin/system/model/resp/StorageResp.java @@ -0,0 +1,119 @@ +/* + * 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.system.model.resp; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import top.continew.admin.common.model.resp.BaseDetailResp; +import top.continew.admin.common.enums.DisEnableStatusEnum; +import top.continew.admin.system.enums.StorageTypeEnum; +import top.continew.starter.security.mask.annotation.JsonMask; + +import java.io.Serial; + +/** + * 存储响应信息 + * + * @author Charles7c + * @since 2023/12/26 22:09 + */ +@Data +@Schema(description = "存储响应信息") +public class StorageResp extends BaseDetailResp { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 名称 + */ + @Schema(description = "名称", example = "存储1") + private String name; + + /** + * 编码 + */ + @Schema(description = "编码", example = "local") + private String code; + + /** + * 状态 + */ + @Schema(description = "状态", example = "1") + private DisEnableStatusEnum status; + + /** + * 类型 + */ + @Schema(description = "类型", example = "2") + private StorageTypeEnum type; + + /** + * 访问密钥 + */ + @Schema(description = "访问密钥", example = "") + private String accessKey; + + /** + * 私有密钥 + */ + @Schema(description = "私有密钥", example = "") + @JsonMask(left = 4, right = 3) + private String secretKey; + + /** + * 终端节点 + */ + @Schema(description = "终端节点", example = "") + private String endpoint; + + /** + * 桶名称 + */ + @Schema(description = "桶名称", example = "C:/continew-admin/data/file/") + private String bucketName; + + /** + * 域名 + */ + @Schema(description = "域名", example = "http://localhost:8000/file") + private String domain; + + /** + * 描述 + */ + @Schema(description = "描述", example = "存储描述") + private String description; + + /** + * 是否为默认存储 + */ + @Schema(description = "是否为默认存储", example = "true") + private Boolean isDefault; + + /** + * 排序 + */ + @Schema(description = "排序", example = "1") + private Integer sort; + + @Override + public Boolean getDisabled() { + return this.getIsDefault(); + } + +} \ No newline at end of file diff --git a/continew-module-system/src/main/java/top/continew/admin/system/service/FileService.java b/continew-module-system/src/main/java/top/continew/admin/system/service/FileService.java index 5e650ed1..a87cbfde 100644 --- a/continew-module-system/src/main/java/top/continew/admin/system/service/FileService.java +++ b/continew-module-system/src/main/java/top/continew/admin/system/service/FileService.java @@ -16,13 +16,13 @@ package top.continew.admin.system.service; +import org.dromara.x.file.storage.core.FileInfo; import org.springframework.web.multipart.MultipartFile; import top.continew.admin.system.model.entity.FileDO; import top.continew.admin.system.model.query.FileQuery; import top.continew.admin.system.model.req.FileReq; import top.continew.admin.system.model.resp.FileResp; import top.continew.admin.system.model.resp.FileStatisticsResp; -import top.continew.admin.system.model.resp.FileUploadResp; import top.continew.starter.data.mp.service.IService; import top.continew.starter.extension.crud.service.BaseService; @@ -42,7 +42,7 @@ public interface FileService extends BaseService, IService { + + /** + * 查询默认存储 + * + * @return 存储信息 + */ + StorageDO getDefaultStorage(); + + /** + * 根据编码查询 + * + * @param code 编码 + * @return 存储信息 + */ + StorageDO getByCode(String code); + + /** + * 加载存储 + * + * @param req 存储信息 + */ + void load(StorageReq req); + + /** + * 卸载存储 + * + * @param req 存储信息 + */ + void unload(StorageReq req); +} \ No newline at end of file diff --git a/continew-module-system/src/main/java/top/continew/admin/system/service/impl/FileServiceImpl.java b/continew-module-system/src/main/java/top/continew/admin/system/service/impl/FileServiceImpl.java index 46340e3a..42419d25 100644 --- a/continew-module-system/src/main/java/top/continew/admin/system/service/impl/FileServiceImpl.java +++ b/continew-module-system/src/main/java/top/continew/admin/system/service/impl/FileServiceImpl.java @@ -17,27 +17,39 @@ package top.continew.admin.system.service.impl; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.file.FileNameUtil; +import cn.hutool.core.util.ClassUtil; import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.URLUtil; +import jakarta.annotation.Resource; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.dromara.x.file.storage.core.FileInfo; +import org.dromara.x.file.storage.core.FileStorageService; +import org.dromara.x.file.storage.core.ProgressListener; +import org.dromara.x.file.storage.core.upload.UploadPretreatment; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import top.continew.admin.system.enums.FileTypeEnum; import top.continew.admin.system.mapper.FileMapper; import top.continew.admin.system.model.entity.FileDO; +import top.continew.admin.system.model.entity.StorageDO; import top.continew.admin.system.model.query.FileQuery; import top.continew.admin.system.model.req.FileReq; import top.continew.admin.system.model.resp.FileResp; import top.continew.admin.system.model.resp.FileStatisticsResp; -import top.continew.admin.system.model.resp.FileUploadResp; import top.continew.admin.system.service.FileService; -import top.continew.starter.core.exception.BusinessException; +import top.continew.admin.system.service.StorageService; +import top.continew.starter.core.constant.StringConstants; +import top.continew.starter.core.util.StrUtils; +import top.continew.starter.core.util.URLUtils; +import top.continew.starter.core.validation.CheckUtils; import top.continew.starter.extension.crud.service.BaseServiceImpl; -import top.continew.starter.storage.manger.StorageManager; -import top.continew.starter.storage.model.resp.UploadResp; -import top.continew.starter.storage.strategy.StorageStrategy; -import java.io.IOException; +import java.time.LocalDate; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; /** * 文件业务实现 @@ -50,31 +62,65 @@ import java.util.List; @RequiredArgsConstructor public class FileServiceImpl extends BaseServiceImpl implements FileService { + private final FileStorageService fileStorageService; + @Resource + private StorageService storageService; + @Override protected void beforeDelete(List ids) { List fileList = baseMapper.lambdaQuery().in(FileDO::getId, ids).list(); - fileList.forEach(file -> { - StorageStrategy instance = StorageManager.instance(file.getStorageCode()); - instance.delete(file.getBucketName(), file.getPath()); - }); + Map> fileListGroup = fileList.stream().collect(Collectors.groupingBy(FileDO::getStorageId)); + for (Map.Entry> entry : fileListGroup.entrySet()) { + StorageDO storage = storageService.getById(entry.getKey()); + for (FileDO file : entry.getValue()) { + FileInfo fileInfo = file.toFileInfo(storage); + fileStorageService.delete(fileInfo); + } + } } @Override - public FileUploadResp upload(MultipartFile file, String storageCode) { - StorageStrategy instance; + public FileInfo upload(MultipartFile file, String storageCode) { + StorageDO storage; if (StrUtil.isBlank(storageCode)) { - instance = StorageManager.instance(); + storage = storageService.getDefaultStorage(); + CheckUtils.throwIfNull(storage, "请先指定默认存储"); } else { - instance = StorageManager.instance(storageCode); + storage = storageService.getByCode(storageCode); + CheckUtils.throwIfNotExists(storage, "StorageDO", "Code", storageCode); } - UploadResp uploadResp; - try { - uploadResp = instance.upload(file.getOriginalFilename(), null, file.getInputStream(), file - .getContentType(), true); - } catch (IOException e) { - throw new BusinessException("文件上传失败", e); + LocalDate today = LocalDate.now(); + String path = today.getYear() + StringConstants.SLASH + today.getMonthValue() + StringConstants.SLASH + today + .getDayOfMonth() + StringConstants.SLASH; + UploadPretreatment uploadPretreatment = fileStorageService.of(file) + .setPlatform(storage.getCode()) + .putAttr(ClassUtil.getClassName(StorageDO.class, false), storage) + .setPath(path); + // 图片文件生成缩略图 + if (FileTypeEnum.IMAGE.getExtensions().contains(FileNameUtil.extName(file.getOriginalFilename()))) { + uploadPretreatment.thumbnail(img -> img.size(100, 100)); } - return FileUploadResp.builder().url(uploadResp.getUrl()).build(); + uploadPretreatment.setProgressMonitor(new ProgressListener() { + @Override + public void start() { + log.info("开始上传"); + } + + @Override + public void progress(long progressSize, Long allSize) { + log.info("已上传 [{}],总大小 [{}]", progressSize, allSize); + } + + @Override + public void finish() { + log.info("上传结束"); + } + }); + // 处理本地存储文件 URL + FileInfo fileInfo = uploadPretreatment.upload(); + String domain = StrUtil.appendIfMissing(storage.getDomain(), StringConstants.SLASH); + fileInfo.setUrl(URLUtil.normalize(domain + fileInfo.getPath() + fileInfo.getFilename())); + return fileInfo; } @Override @@ -82,7 +128,7 @@ public class FileServiceImpl extends BaseServiceImpl URLUtil + .normalize(prefix + thUrl)); + fileResp.setThumbnailUrl(thumbnailUrl); + fileResp.setStorageName("%s (%s)".formatted(storage.getName(), storage.getCode())); + } + } } \ No newline at end of file diff --git a/continew-module-system/src/main/java/top/continew/admin/system/service/impl/OptionServiceImpl.java b/continew-module-system/src/main/java/top/continew/admin/system/service/impl/OptionServiceImpl.java index 14ffe9b3..3ac3c472 100644 --- a/continew-module-system/src/main/java/top/continew/admin/system/service/impl/OptionServiceImpl.java +++ b/continew-module-system/src/main/java/top/continew/admin/system/service/impl/OptionServiceImpl.java @@ -26,7 +26,6 @@ import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWra import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import top.continew.admin.common.constant.CacheConstants; -import top.continew.admin.system.config.file.FileStorageInit; import top.continew.admin.system.enums.OptionCategoryEnum; import top.continew.admin.system.enums.PasswordPolicyEnum; import top.continew.admin.system.mapper.OptionMapper; @@ -58,7 +57,6 @@ import java.util.stream.Collectors; public class OptionServiceImpl implements OptionService { private final OptionMapper baseMapper; - private final FileStorageInit fileStorageInit; @Override public List list(OptionQuery query) { @@ -100,7 +98,6 @@ public class OptionServiceImpl implements OptionService { PasswordPolicyEnum passwordPolicy = PasswordPolicyEnum.valueOf(code); passwordPolicy.validateRange(Integer.parseInt(value), passwordPolicyOptionMap); } - storageReload(options); RedisUtils.deleteByPattern(CacheConstants.OPTION_KEY_PREFIX + StringConstants.ASTERISK); baseMapper.updateById(BeanUtil.copyToList(options, OptionDO.class)); } @@ -141,18 +138,4 @@ public class OptionServiceImpl implements OptionService { RedisUtils.set(CacheConstants.OPTION_KEY_PREFIX + code, value); return mapper.apply(value); } - - /** - * 存储重新加载 - * - * @param options 选项 - */ - private void storageReload(List options) { - Map storage = options.stream() - .filter(option -> option.getCode() != null && option.getCode().startsWith("STORAGE_")) - .collect(Collectors.toMap(OptionReq::getCode, OptionReq::getValue)); - if (ObjectUtil.isNotEmpty(storage)) { - fileStorageInit.load(storage); - } - } } \ No newline at end of file diff --git a/continew-module-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java b/continew-module-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java new file mode 100644 index 00000000..3aa01447 --- /dev/null +++ b/continew-module-system/src/main/java/top/continew/admin/system/service/impl/StorageServiceImpl.java @@ -0,0 +1,209 @@ +/* + * 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.system.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.URLUtil; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import org.dromara.x.file.storage.core.FileStorageProperties; +import org.dromara.x.file.storage.core.FileStorageService; +import org.dromara.x.file.storage.core.FileStorageServiceBuilder; +import org.dromara.x.file.storage.core.platform.FileStorage; +import org.springframework.stereotype.Service; +import top.continew.admin.common.enums.DisEnableStatusEnum; +import top.continew.admin.common.util.SecureUtils; +import top.continew.admin.system.enums.StorageTypeEnum; +import top.continew.admin.system.mapper.StorageMapper; +import top.continew.admin.system.model.entity.StorageDO; +import top.continew.admin.system.model.query.StorageQuery; +import top.continew.admin.system.model.req.StorageReq; +import top.continew.admin.system.model.resp.StorageResp; +import top.continew.admin.system.service.FileService; +import top.continew.admin.system.service.StorageService; +import top.continew.admin.system.validation.ValidationGroup; +import top.continew.starter.core.constant.StringConstants; +import top.continew.starter.core.util.ExceptionUtils; +import top.continew.starter.core.util.URLUtils; +import top.continew.starter.core.validation.CheckUtils; +import top.continew.starter.core.validation.ValidationUtils; +import top.continew.starter.extension.crud.service.BaseServiceImpl; +import top.continew.starter.web.util.SpringWebUtils; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * 存储业务实现 + * + * @author Charles7c + * @since 2023/12/26 22:09 + */ +@Service +@RequiredArgsConstructor +public class StorageServiceImpl extends BaseServiceImpl implements StorageService { + + private final FileStorageService fileStorageService; + @Resource + private FileService fileService; + + @Override + public void beforeAdd(StorageReq req) { + this.decodeSecretKey(req, null); + CheckUtils.throwIf(Boolean.TRUE.equals(req.getIsDefault()) && this.isDefaultExists(null), "请先取消原有默认存储"); + String code = req.getCode(); + CheckUtils.throwIf(this.isCodeExists(code, null), "新增失败,[{}] 已存在", code); + this.load(req); + } + + @Override + public void beforeUpdate(StorageReq req, Long id) { + StorageDO oldStorage = super.getById(id); + CheckUtils.throwIfNotEqual(req.getCode(), oldStorage.getCode(), "不允许修改存储编码"); + CheckUtils.throwIfNotEqual(req.getType(), oldStorage.getType(), "不允许修改存储类型"); + DisEnableStatusEnum newStatus = req.getStatus(); + CheckUtils.throwIf(Boolean.TRUE.equals(oldStorage.getIsDefault()) && DisEnableStatusEnum.DISABLE + .equals(newStatus), "[{}] 是默认存储,不允许禁用", oldStorage.getName()); + this.decodeSecretKey(req, oldStorage); + DisEnableStatusEnum oldStatus = oldStorage.getStatus(); + if (Boolean.TRUE.equals(req.getIsDefault())) { + CheckUtils.throwIf(this.isDefaultExists(id), "请先取消原有默认存储"); + CheckUtils.throwIf(!DisEnableStatusEnum.ENABLE.equals(oldStatus) && !DisEnableStatusEnum.ENABLE + .equals(newStatus), "请先启用该存储"); + } + // 先卸载 + if (DisEnableStatusEnum.ENABLE.equals(oldStatus)) { + this.unload(BeanUtil.copyProperties(oldStorage, StorageReq.class)); + } + // 再加载 + if (DisEnableStatusEnum.ENABLE.equals(newStatus)) { + this.load(req); + } + } + + @Override + public void beforeDelete(List ids) { + CheckUtils.throwIf(fileService.countByStorageIds(ids) > 0, "所选存储存在文件关联,请删除文件后重试"); + List storageList = baseMapper.lambdaQuery().in(StorageDO::getId, ids).list(); + storageList.forEach(s -> { + CheckUtils.throwIfEqual(Boolean.TRUE, s.getIsDefault(), "[{}] 是默认存储,不允许禁用", s.getName()); + // 卸载启用状态的存储 + if (DisEnableStatusEnum.ENABLE.equals(s.getStatus())) { + this.unload(BeanUtil.copyProperties(s, StorageReq.class)); + } + }); + } + + @Override + public StorageDO getDefaultStorage() { + return baseMapper.lambdaQuery().eq(StorageDO::getIsDefault, true).one(); + } + + @Override + public StorageDO getByCode(String code) { + return baseMapper.lambdaQuery().eq(StorageDO::getCode, code).one(); + } + + @Override + public void load(StorageReq req) { + CopyOnWriteArrayList fileStorageList = fileStorageService.getFileStorageList(); + String domain = req.getDomain(); + ValidationUtils.throwIf(!URLUtils.isHttpUrl(domain), "域名格式错误"); + String bucketName = req.getBucketName(); + StorageTypeEnum type = req.getType(); + if (StorageTypeEnum.LOCAL.equals(type)) { + ValidationUtils.validate(req, ValidationGroup.Storage.Local.class); + req.setBucketName(StrUtil.appendIfMissing(bucketName + .replace(StringConstants.BACKSLASH, StringConstants.SLASH), StringConstants.SLASH)); + FileStorageProperties.LocalPlusConfig config = new FileStorageProperties.LocalPlusConfig(); + config.setPlatform(req.getCode()); + config.setStoragePath(bucketName); + fileStorageList.addAll(FileStorageServiceBuilder.buildLocalPlusFileStorage(Collections + .singletonList(config))); + SpringWebUtils.registerResourceHandler(MapUtil.of(URLUtil.url(req.getDomain()).getPath(), bucketName)); + } else if (StorageTypeEnum.S3.equals(type)) { + ValidationUtils.validate(req, ValidationGroup.Storage.S3.class); + FileStorageProperties.AmazonS3Config config = new FileStorageProperties.AmazonS3Config(); + config.setPlatform(req.getCode()); + config.setAccessKey(req.getAccessKey()); + config.setSecretKey(req.getSecretKey()); + config.setEndPoint(req.getEndpoint()); + config.setBucketName(bucketName); + config.setDomain(domain); + fileStorageList.addAll(FileStorageServiceBuilder.buildAmazonS3FileStorage(Collections + .singletonList(config), null)); + } + } + + @Override + public void unload(StorageReq req) { + CopyOnWriteArrayList fileStorageList = fileStorageService.getFileStorageList(); + FileStorage fileStorage = fileStorageService.getFileStorage(req.getCode()); + fileStorageList.remove(fileStorage); + fileStorage.close(); + SpringWebUtils.deRegisterResourceHandler(MapUtil.of(URLUtil.url(req.getDomain()).getPath(), req + .getBucketName())); + } + + /** + * 解密 SecretKey + * + * @param req 请求参数 + * @param storage 存储信息 + */ + private void decodeSecretKey(StorageReq req, StorageDO storage) { + if (!StorageTypeEnum.S3.equals(req.getType())) { + return; + } + // 修改时,如果 SecretKey 不修改,需要手动修正 + String newSecretKey = req.getSecretKey(); + boolean isSecretKeyNotUpdate = StrUtil.isBlank(newSecretKey) || newSecretKey.contains(StringConstants.ASTERISK); + if (null != storage && isSecretKeyNotUpdate) { + req.setSecretKey(storage.getSecretKey()); + return; + } + // 新增时或修改了 SecretKey + String secretKey = ExceptionUtils.exToNull(() -> SecureUtils.decryptByRsaPrivateKey(newSecretKey)); + ValidationUtils.throwIfNull(secretKey, "私有密钥解密失败"); + ValidationUtils.throwIf(secretKey.length() > 255, "私有密钥长度不能超过 255 个字符"); + req.setSecretKey(secretKey); + } + + /** + * 默认存储是否存在 + * + * @param id ID + * @return 是否存在 + */ + private boolean isDefaultExists(Long id) { + return baseMapper.lambdaQuery().eq(StorageDO::getIsDefault, true).ne(null != id, StorageDO::getId, id).exists(); + } + + /** + * 编码是否存在 + * + * @param code 编码 + * @param id ID + * @return 是否存在 + */ + private boolean isCodeExists(String code, Long id) { + return baseMapper.lambdaQuery().eq(StorageDO::getCode, code).ne(null != id, StorageDO::getId, id).exists(); + } +} \ No newline at end of file diff --git a/continew-module-system/src/main/java/top/continew/admin/system/validation/ValidationGroup.java b/continew-module-system/src/main/java/top/continew/admin/system/validation/ValidationGroup.java new file mode 100644 index 00000000..072b006f --- /dev/null +++ b/continew-module-system/src/main/java/top/continew/admin/system/validation/ValidationGroup.java @@ -0,0 +1,45 @@ +/* + * 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.system.validation; + +import jakarta.validation.groups.Default; + +/** + * 分组校验 + * + * @author Charles7c + * @since 2024/7/3 22:01 + */ +public interface ValidationGroup extends Default { + + /** + * 分组校验-增删改查 + */ + interface Storage extends ValidationGroup { + /** + * 本地存储 + */ + interface Local extends Storage { + } + + /** + * 兼容S3协议存储 + */ + interface S3 extends Storage { + } + } +} \ No newline at end of file diff --git a/continew-webapi/src/main/java/top/continew/admin/ContiNewAdminApplication.java b/continew-webapi/src/main/java/top/continew/admin/ContiNewAdminApplication.java index 17d9ffa1..5c7fa8f5 100644 --- a/continew-webapi/src/main/java/top/continew/admin/ContiNewAdminApplication.java +++ b/continew-webapi/src/main/java/top/continew/admin/ContiNewAdminApplication.java @@ -25,6 +25,7 @@ import com.github.xiaoymin.knife4j.spring.configuration.Knife4jProperties; import io.swagger.v3.oas.annotations.Hidden; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.dromara.x.file.storage.spring.EnableFileStorage; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.SpringApplication; @@ -44,6 +45,7 @@ import top.continew.starter.web.model.R; * @since 2022/12/8 23:15 */ @Slf4j +@EnableFileStorage @EnableMethodCache(basePackages = "top.continew.admin") @EnableGlobalResponse @EnableCrudRestController diff --git a/continew-webapi/src/main/java/top/continew/admin/controller/common/CommonController.java b/continew-webapi/src/main/java/top/continew/admin/controller/common/CommonController.java index 81a9293b..07301ef8 100644 --- a/continew-webapi/src/main/java/top/continew/admin/controller/common/CommonController.java +++ b/continew-webapi/src/main/java/top/continew/admin/controller/common/CommonController.java @@ -26,6 +26,7 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; +import org.dromara.x.file.storage.core.FileInfo; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -65,9 +66,9 @@ public class CommonController { @Operation(summary = "上传文件", description = "上传文件") @PostMapping("/file") - public FileUploadResp upload(@NotNull(message = "文件不能为空") @RequestPart("file") MultipartFile file) { + public FileUploadResp upload(@NotNull(message = "文件不能为空") MultipartFile file) { ValidationUtils.throwIf(file::isEmpty, "文件不能为空"); - FileUploadResp fileInfo = fileService.upload(file); + FileInfo fileInfo = fileService.upload(file); return FileUploadResp.builder().url(fileInfo.getUrl()).build(); } diff --git a/continew-webapi/src/main/java/top/continew/admin/controller/schedule/DemoEnvironmentJob.java b/continew-webapi/src/main/java/top/continew/admin/controller/schedule/DemoEnvironmentJob.java index 45876c27..6c5dd4f9 100644 --- a/continew-webapi/src/main/java/top/continew/admin/controller/schedule/DemoEnvironmentJob.java +++ b/continew-webapi/src/main/java/top/continew/admin/controller/schedule/DemoEnvironmentJob.java @@ -47,6 +47,7 @@ public class DemoEnvironmentJob { private final DictItemMapper dictItemMapper; private final DictMapper dictMapper; + private final StorageMapper storageMapper; private final NoticeMapper noticeMapper; private final MessageMapper messageMapper; private final MessageUserMapper messageUserMapper; @@ -83,6 +84,8 @@ public class DemoEnvironmentJob { this.log(dictItemCount, "字典项"); Long dictCount = dictMapper.lambdaQuery().gt(DictDO::getId, DELETE_FLAG).count(); this.log(dictCount, "字典"); + Long storageCount = storageMapper.lambdaQuery().gt(StorageDO::getId, DELETE_FLAG).count(); + this.log(storageCount, "存储"); Long noticeCount = noticeMapper.lambdaQuery().gt(NoticeDO::getId, DELETE_FLAG).count(); this.log(noticeCount, "公告"); Long messageCount = messageMapper.lambdaQuery().count(); @@ -108,6 +111,9 @@ public class DemoEnvironmentJob { this.clean(dictCount, "字典", CacheConstants.DICT_KEY_PREFIX, () -> dictMapper.lambdaUpdate() .gt(DictDO::getId, DELETE_FLAG) .remove()); + this.clean(storageCount, "存储", null, () -> storageMapper.lambdaUpdate() + .gt(StorageDO::getId, DELETE_FLAG) + .remove()); this.clean(noticeCount, "公告", null, () -> noticeMapper.lambdaUpdate() .gt(NoticeDO::getId, DELETE_FLAG) .remove()); diff --git a/continew-webapi/src/main/java/top/continew/admin/controller/system/StorageController.java b/continew-webapi/src/main/java/top/continew/admin/controller/system/StorageController.java new file mode 100644 index 00000000..e6d5f3c4 --- /dev/null +++ b/continew-webapi/src/main/java/top/continew/admin/controller/system/StorageController.java @@ -0,0 +1,39 @@ +/* + * 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.controller.system; + +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RestController; +import top.continew.admin.common.controller.BaseController; +import top.continew.admin.system.model.query.StorageQuery; +import top.continew.admin.system.model.req.StorageReq; +import top.continew.admin.system.model.resp.StorageResp; +import top.continew.admin.system.service.StorageService; +import top.continew.starter.extension.crud.annotation.CrudRequestMapping; +import top.continew.starter.extension.crud.enums.Api; + +/** + * 存储管理 API + * + * @author Charles7c + * @since 2023/12/26 22:09 + */ +@Tag(name = "存储管理 API") +@RestController +@CrudRequestMapping(value = "/system/storage", api = {Api.PAGE, Api.DETAIL, Api.ADD, Api.UPDATE, Api.DELETE}) +public class StorageController extends BaseController { +} \ No newline at end of file diff --git a/continew-webapi/src/main/resources/db/changelog/mysql/main_data.sql b/continew-webapi/src/main/resources/db/changelog/mysql/main_data.sql index 700f1052..98167bf7 100644 --- a/continew-webapi/src/main/resources/db/changelog/mysql/main_data.sql +++ b/continew-webapi/src/main/resources/db/changelog/mysql/main_data.sql @@ -71,6 +71,13 @@ VALUES (1105, '删除', 1100, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:delete', 5, 1, 1, NOW()), (1106, '下载', 1100, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:download', 6, 1, 1, NOW()), +(1110, '存储管理', 1000, 2, '/system/storage', 'SystemStorage', 'system/storage/index', NULL, 'storage', b'0', b'0', b'0', NULL, 8, 1, 1, NOW()), +(1111, '列表', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:storage:list', 1, 1, 1, NOW()), +(1112, '详情', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:storage:detail', 2, 1, 1, NOW()), +(1113, '新增', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:storage:add', 3, 1, 1, NOW()), +(1114, '修改', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:storage:update', 4, 1, 1, NOW()), +(1115, '删除', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:storage:delete', 5, 1, 1, NOW()), + ( 1180, '客户端管理', 1000, 2, '/system/client', 'SystemClient', 'system/client/index', NULL, 'mobile', b'0', b'0', b'0', NULL, 9, 1, 1, NOW()), (1181, '列表', 1180, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:client:list', 1, 1, 1, NOW()), (1182, '详情', 1180, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:client:detail', 2, 1, 1, NOW()), @@ -154,37 +161,28 @@ VALUES INSERT INTO `sys_option` (`id`, `category`, `name`, `code`, `value`, `default_value`, `description`) VALUES -(1, 'SITE', '网站名称', 'SITE_TITLE', NULL, 'ContiNew Admin', '显示在浏览器标题栏和登录界面的系统名称'), -(2, 'SITE', '网站描述', 'SITE_DESCRIPTION', NULL, '持续迭代优化的前后端分离中后台管理系统框架', '用于 SEO 的网站元描述'), -(3, 'SITE', '版权声明', 'SITE_COPYRIGHT', NULL, 'Copyright © 2022 - present ContiNew Admin 版权所有', '显示在页面底部的版权声明文本'), -(4, 'SITE', '网站域名', 'SITE_DOMAIN', NULL, 'https://admin.continew.top', '系统主域名,用于生成绝对链接和 CORS 配置'), -(5, 'SITE', '网站备案号', 'SITE_BEIAN', NULL, NULL, '工信部 ICP 备案编号(如:京ICP备12345678号)'), -(6, 'SITE', '网站图标', 'SITE_FAVICON', NULL, '/favicon.ico', '浏览器标签页显示的网站图标(建议 .ico 格式)'), -(7, 'SITE', '网站LOGO', 'SITE_LOGO', NULL, '/logo.svg', '显示在登录页面和系统导航栏的网站图标(建议 .svg 格式)'), -(10, 'PASSWORD', '密码错误锁定阈值', 'PASSWORD_ERROR_LOCK_COUNT', NULL, '5', '连续登录失败次数达到该值将锁定账号(0-10次,0表示禁用锁定)'), -(11, 'PASSWORD', '账号锁定时长(分钟)', 'PASSWORD_ERROR_LOCK_MINUTES', NULL, '5', '账号锁定后自动解锁的时间(1-1440分钟,即24小时)'), -(12, 'PASSWORD', '密码有效期(天)', 'PASSWORD_EXPIRATION_DAYS', NULL, '0', '密码强制修改周期(0-999天,0表示永不过期)'), -(13, 'PASSWORD', '密码到期提醒(天)', 'PASSWORD_EXPIRATION_WARNING_DAYS', NULL, '0', '密码过期前的提前提醒天数(0表示不提醒)'), -(14, 'PASSWORD', '历史密码重复校验次数', 'PASSWORD_REPETITION_TIMES', NULL, '3', '禁止使用最近 N 次的历史密码(3-32次)'), -(15, 'PASSWORD', '密码最小长度', 'PASSWORD_MIN_LENGTH', NULL, '8', '密码最小字符长度要求(8-32个字符)'), -(16, 'PASSWORD', '是否允许密码包含用户名', 'PASSWORD_ALLOW_CONTAIN_USERNAME', NULL, '1', '是否允许密码包含正序或倒序的用户名字符'), -(17, 'PASSWORD', '密码是否必须包含特殊字符', 'PASSWORD_REQUIRE_SYMBOLS', NULL, '0', '是否要求密码必须包含特殊字符(如:!@#$%)'), -(20, 'MAIL', '邮件协议', 'MAIL_PROTOCOL', NULL, 'smtp', '邮件发送协议类型'), -(21, 'MAIL', '服务器地址', 'MAIL_HOST', NULL, 'smtp.126.com', '邮件服务器地址'), -(22, 'MAIL', '服务器端口', 'MAIL_PORT', NULL, '465', '邮件服务器连接端口'), -(23, 'MAIL', '邮箱账号', 'MAIL_USERNAME', NULL, 'charles7c@126.com', '发件人邮箱地址'), -(24, 'MAIL', '邮箱密码', 'MAIL_PASSWORD', NULL, NULL, '服务授权密码/客户端专用密码'), -(25, 'MAIL', '启用SSL加密', 'MAIL_SSL_ENABLED', NULL, '1', '是否启用SSL/TLS加密连接'), -(26, 'MAIL', 'SSL端口号', 'MAIL_SSL_PORT', NULL, '465', 'SSL加密连接的备用端口(通常与主端口一致)'), -(30, 'STORAGE', '默认存储类型', 'STORAGE_DEFAULT', NULL, 'LOCAL', '系统文件存储方式(LOCAL:本地存储;OSS:对象存储)'), -(31, 'STORAGE', '本地存储路径', 'STORAGE_LOCAL_BUCKET', NULL, 'C:/continew-admin/data/file/', '本地存储目录绝对路径(需以斜杠结尾,如:/data/uploads/)'), -(32, 'STORAGE', '本地资源访问地址', 'STORAGE_LOCAL_ENDPOINT', NULL, 'localhost:8000/file', '通过 URL 访问本地文件的映射地址'), -(33, 'STORAGE', 'Access Key', 'STORAGE_OSS_ACCESS_KEY', NULL, NULL, '对象存储访问密钥'), -(34, 'STORAGE', 'Secret Key', 'STORAGE_OSS_SECRET_KEY', NULL, NULL, '对象存储私有密钥'), -(35, 'STORAGE', '对象存储桶名称', 'STORAGE_OSS_BUCKET', NULL, 'continew', '对象存储 Bucket 名称(需预先创建)'), -(36, 'STORAGE', '对象存储终端节点', 'STORAGE_OSS_ENDPOINT', NULL, NULL, '对象存储访问地址'), -(37, 'STORAGE', '对象存储区域代码', 'STORAGE_OSS_REGION', NULL, 'cn-hangzhou', '对象存储数据中心区域标识(如:cn-hangzhou)'), -(40, 'LOGIN', '是否启用验证码', 'LOGIN_CAPTCHA_ENABLED', NULL, '1', NULL); +(1, 'SITE', '系统标题', 'SITE_TITLE', NULL, 'ContiNew Admin', '用于显示登录页面的系统标题。'), +(2, 'SITE', '系统描述', 'SITE_DESCRIPTION', NULL, '持续迭代优化的前后端分离中后台管理系统框架', NULL), +(3, 'SITE', '版权信息', 'SITE_COPYRIGHT', NULL, 'Copyright © 2022 - present ContiNew Admin 版权所有', '用于显示登录页面的底部版权信息。'), +(4, 'SITE', '备案号', 'SITE_BEIAN', NULL, NULL, 'ICP备案号'), +(5, 'SITE', 'favicon', 'SITE_FAVICON', NULL, '/favicon.ico', '用于显示浏览器地址栏的系统LOGO。'), +(6, 'SITE', '系统LOGO', 'SITE_LOGO', NULL, '/logo.svg', '用于显示登录页面的系统LOGO。'), +(7, 'PASSWORD', '登录密码错误锁定账号的次数', 'PASSWORD_ERROR_LOCK_COUNT', NULL, '5', '取值范围为 0-10(0 表示不锁定)。'), +(8, 'PASSWORD', '登录密码错误锁定账号的时间(min)', 'PASSWORD_ERROR_LOCK_MINUTES', NULL, '5', '取值范围为 1-1440(一天)。'), +(9, 'PASSWORD', '密码有效期(天)', 'PASSWORD_EXPIRATION_DAYS', NULL, '0', '取值范围为 0-999(0 表示永久有效)。'), +(10, 'PASSWORD', '密码到期提前提示(天)', 'PASSWORD_EXPIRATION_WARNING_DAYS', NULL, '0', '密码到期 N 天前进行提示(0 表示不提示)。'), +(11, 'PASSWORD', '密码重复使用次数', 'PASSWORD_REPETITION_TIMES', NULL, '3', '不允许使用最近 N 次密码,取值范围为 3-32。'), +(12, 'PASSWORD', '密码最小长度', 'PASSWORD_MIN_LENGTH', NULL, '8', '取值范围为 8-32。'), +(13, 'PASSWORD', '密码是否允许包含正反序账号名', 'PASSWORD_ALLOW_CONTAIN_USERNAME', NULL, '1', NULL), +(14, 'PASSWORD', '密码是否必须包含特殊字符', 'PASSWORD_REQUIRE_SYMBOLS', NULL, '0', NULL), +(15, 'MAIL', '发送协议', 'MAIL_PROTOCOL', NULL, 'smtp', NULL), +(16, 'MAIL', '服务器地址', 'MAIL_HOST', NULL, 'smtp.126.com', NULL), +(17, 'MAIL', '服务器端口', 'MAIL_PORT', NULL, '465', NULL), +(18, 'MAIL', '用户名', 'MAIL_USERNAME', NULL, 'charles7c@126.com', NULL), +(19, 'MAIL', '密码', 'MAIL_PASSWORD', NULL, NULL, NULL), +(20, 'MAIL', '是否启用SSL', 'MAIL_SSL_ENABLED', NULL, '1', NULL), +(21, 'MAIL', 'SSL端口', 'MAIL_SSL_PORT', NULL, '465', NULL), +(22, 'LOGIN', '是否启用验证码', 'LOGIN_CAPTCHA_ENABLED', NULL, '1', '是否启用验证码(1:是;0:否)'); -- 初始化默认字典 INSERT INTO `sys_dict` @@ -242,6 +240,13 @@ VALUES -- 初始化默认角色和部门关联数据 INSERT INTO `sys_role_dept` (`role_id`, `dept_id`) VALUES (547888897925840927, 547887852587843593); +-- 初始化默认存储 +INSERT INTO `sys_storage` +(`id`, `name`, `code`, `type`, `access_key`, `secret_key`, `endpoint`, `bucket_name`, `domain`, `description`, `is_default`, `sort`, `status`, `create_user`, `create_time`) +VALUES +(1, '开发环境', 'local_dev', 2, NULL, NULL, NULL, 'C:/continew-admin/data/file/', 'http://localhost:8000/file', '本地存储', b'1', 1, 1, 1, NOW()), +(2, '生产环境', 'local_prod', 2, NULL, NULL, NULL, '../data/file/', 'http://api.continew.top/file', '本地存储', b'0', 2, 2, 1, NOW()); + -- 初始化客户端数据 INSERT INTO `sys_client` (`id`, `client_id`, `client_key`, `client_secret`, `auth_type`, `client_type`, `active_timeout`, `timeout`, `status`, `create_user`, `create_time`) diff --git a/continew-webapi/src/main/resources/db/changelog/mysql/main_table.sql b/continew-webapi/src/main/resources/db/changelog/mysql/main_table.sql index 7f5d55e2..7e89ce1b 100644 --- a/continew-webapi/src/main/resources/db/changelog/mysql/main_table.sql +++ b/continew-webapi/src/main/resources/db/changelog/mysql/main_table.sql @@ -191,7 +191,7 @@ CREATE TABLE IF NOT EXISTS `sys_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', `trace_id` varchar(255) DEFAULT NULL COMMENT '链路ID', `description` varchar(255) NOT NULL COMMENT '日志描述', - `module` varchar(100) NOT NULL COMMENT '所属模块', + `module` varchar(50) NOT NULL COMMENT '所属模块', `request_url` varchar(512) NOT NULL COMMENT '请求URL', `request_method` varchar(10) NOT NULL COMMENT '请求方式', `request_headers` text DEFAULT NULL COMMENT '请求头', @@ -252,19 +252,40 @@ CREATE TABLE IF NOT EXISTS `sys_notice` ( INDEX `idx_update_user`(`update_user`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公告表'; +CREATE TABLE IF NOT EXISTS `sys_storage` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', + `name` varchar(100) NOT NULL COMMENT '名称', + `code` varchar(30) NOT NULL COMMENT '编码', + `type` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '类型(1:兼容S3协议存储;2:本地存储)', + `access_key` varchar(255) DEFAULT NULL COMMENT 'Access Key(访问密钥)', + `secret_key` varchar(255) DEFAULT NULL COMMENT 'Secret Key(私有密钥)', + `endpoint` varchar(255) DEFAULT NULL COMMENT 'Endpoint(终端节点)', + `bucket_name` varchar(255) DEFAULT NULL COMMENT '桶名称', + `domain` varchar(255) NOT NULL DEFAULT '' COMMENT '域名', + `description` varchar(200) DEFAULT NULL COMMENT '描述', + `is_default` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否为默认存储', + `sort` int NOT NULL DEFAULT 999 COMMENT '排序', + `status` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态(1:启用;2:禁用)', + `create_user` bigint(20) NOT NULL COMMENT '创建人', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_user` bigint(20) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_code`(`code`), + INDEX `idx_create_user`(`create_user`), + INDEX `idx_update_user`(`update_user`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='存储表'; + CREATE TABLE IF NOT EXISTS `sys_file` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', `name` varchar(255) NOT NULL COMMENT '名称', `size` bigint(20) NOT NULL COMMENT '大小(字节)', `url` varchar(512) NOT NULL COMMENT 'URL', `extension` varchar(100) DEFAULT NULL COMMENT '扩展名', - `e_tag` varchar(100) DEFAULT NULL COMMENT '文件唯一标识', `thumbnail_size` bigint(20) DEFAULT NULL COMMENT '缩略图大小(字节)', `thumbnail_url` varchar(512) DEFAULT NULL COMMENT '缩略图URL', `type` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '类型(1:其他;2:图片;3:文档;4:视频;5:音频)', - `storage_code` varchar(255) DEFAULT NULL COMMENT '存储唯一标识', - `bucket_name` varchar(255) DEFAULT NULL COMMENT '存储桶名称', - `path` varchar(512) DEFAULT NULL COMMENT '基础路径', + `storage_id` bigint(20) NOT NULL COMMENT '存储ID', `create_user` bigint(20) NOT NULL COMMENT '创建人', `create_time` datetime NOT NULL COMMENT '创建时间', `update_user` bigint(20) NOT NULL COMMENT '修改人', @@ -272,7 +293,6 @@ CREATE TABLE IF NOT EXISTS `sys_file` ( PRIMARY KEY (`id`), INDEX `idx_url`(`url`), INDEX `idx_type`(`type`), - INDEX `idx_storage_code`(`storage_code`), INDEX `idx_create_user`(`create_user`), INDEX `idx_update_user`(`update_user`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件表'; diff --git a/continew-webapi/src/main/resources/db/changelog/postgresql/main_data.sql b/continew-webapi/src/main/resources/db/changelog/postgresql/main_data.sql index d1950b2c..47185859 100644 --- a/continew-webapi/src/main/resources/db/changelog/postgresql/main_data.sql +++ b/continew-webapi/src/main/resources/db/changelog/postgresql/main_data.sql @@ -71,6 +71,13 @@ VALUES (1105, '删除', 1100, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:delete', 5, 1, 1, NOW()), (1106, '下载', 1100, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:download', 6, 1, 1, NOW()), +(1110, '存储管理', 1000, 2, '/system/storage', 'SystemStorage', 'system/storage/index', NULL, 'storage', false, false, false, NULL, 8, 1, 1, NOW()), +(1111, '列表', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:storage:list', 1, 1, 1, NOW()), +(1112, '详情', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:storage:detail', 2, 1, 1, NOW()), +(1113, '新增', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:storage:add', 3, 1, 1, NOW()), +(1114, '修改', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:storage:update', 4, 1, 1, NOW()), +(1115, '删除', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:storage:delete', 5, 1, 1, NOW()), + ( 1180, '客户端管理', 1000, 2, '/system/client', 'SystemClient', 'system/client/index', NULL, 'mobile', false, false, false, NULL, 9, 1, 1, NOW()), (1181, '列表', 1180, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:client:list', 1, 1, 1, NOW()), (1182, '详情', 1180, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:client:detail', 2, 1, 1, NOW()), @@ -154,37 +161,28 @@ VALUES INSERT INTO "sys_option" ("id", "category", "name", "code", "value", "default_value", "description") VALUES -(1, 'SITE', '网站名称', 'SITE_TITLE', NULL, 'ContiNew Admin', '显示在浏览器标题栏和登录界面的系统名称'), -(2, 'SITE', '网站描述', 'SITE_DESCRIPTION', NULL, '持续迭代优化的前后端分离中后台管理系统框架', '用于 SEO 的网站元描述'), -(3, 'SITE', '版权声明', 'SITE_COPYRIGHT', NULL, 'Copyright © 2022 - present ContiNew Admin 版权所有', '显示在页面底部的版权声明文本'), -(4, 'SITE', '网站域名', 'SITE_DOMAIN', NULL, 'https://admin.continew.top', '系统主域名,用于生成绝对链接和 CORS 配置'), -(5, 'SITE', '网站备案号', 'SITE_BEIAN', NULL, NULL, '工信部 ICP 备案编号(如:京ICP备12345678号)'), -(6, 'SITE', '网站图标', 'SITE_FAVICON', NULL, '/favicon.ico', '浏览器标签页显示的网站图标(建议 .ico 格式)'), -(7, 'SITE', '网站LOGO', 'SITE_LOGO', NULL, '/logo.svg', '显示在登录页面和系统导航栏的网站图标(建议 .svg 格式)'), -(10, 'PASSWORD', '密码错误锁定阈值', 'PASSWORD_ERROR_LOCK_COUNT', NULL, '5', '连续登录失败次数达到该值将锁定账号(0-10次,0表示禁用锁定)'), -(11, 'PASSWORD', '账号锁定时长(分钟)', 'PASSWORD_ERROR_LOCK_MINUTES', NULL, '5', '账号锁定后自动解锁的时间(1-1440分钟,即24小时)'), -(12, 'PASSWORD', '密码有效期(天)', 'PASSWORD_EXPIRATION_DAYS', NULL, '0', '密码强制修改周期(0-999天,0表示永不过期)'), -(13, 'PASSWORD', '密码到期提醒(天)', 'PASSWORD_EXPIRATION_WARNING_DAYS', NULL, '0', '密码过期前的提前提醒天数(0表示不提醒)'), -(14, 'PASSWORD', '历史密码重复校验次数', 'PASSWORD_REPETITION_TIMES', NULL, '3', '禁止使用最近 N 次的历史密码(3-32次)'), -(15, 'PASSWORD', '密码最小长度', 'PASSWORD_MIN_LENGTH', NULL, '8', '密码最小字符长度要求(8-32个字符)'), -(16, 'PASSWORD', '是否允许密码包含用户名', 'PASSWORD_ALLOW_CONTAIN_USERNAME', NULL, '1', '是否允许密码包含正序或倒序的用户名字符'), -(17, 'PASSWORD', '密码是否必须包含特殊字符', 'PASSWORD_REQUIRE_SYMBOLS', NULL, '0', '是否要求密码必须包含特殊字符(如:!@#$%)'), -(20, 'MAIL', '邮件协议', 'MAIL_PROTOCOL', NULL, 'smtp', '邮件发送协议类型'), -(21, 'MAIL', '服务器地址', 'MAIL_HOST', NULL, 'smtp.126.com', '邮件服务器地址'), -(22, 'MAIL', '服务器端口', 'MAIL_PORT', NULL, '465', '邮件服务器连接端口'), -(23, 'MAIL', '邮箱账号', 'MAIL_USERNAME', NULL, 'charles7c@126.com', '发件人邮箱地址'), -(24, 'MAIL', '邮箱密码', 'MAIL_PASSWORD', NULL, NULL, '服务授权密码/客户端专用密码'), -(25, 'MAIL', '启用SSL加密', 'MAIL_SSL_ENABLED', NULL, '1', '是否启用SSL/TLS加密连接'), -(26, 'MAIL', 'SSL端口号', 'MAIL_SSL_PORT', NULL, '465', 'SSL加密连接的备用端口(通常与主端口一致)'), -(30, 'STORAGE', '默认存储类型', 'STORAGE_DEFAULT', NULL, 'LOCAL', '系统文件存储方式(LOCAL:本地存储;OSS:对象存储)'), -(31, 'STORAGE', '本地存储路径', 'STORAGE_LOCAL_BUCKET', NULL, 'C:/continew-admin/data/file/', '本地存储目录绝对路径(需以斜杠结尾,如:/data/uploads/)'), -(32, 'STORAGE', '本地资源访问地址', 'STORAGE_LOCAL_ENDPOINT', NULL, 'localhost:8000/file', '通过 URL 访问本地文件的映射地址'), -(33, 'STORAGE', 'Access Key', 'STORAGE_OSS_ACCESS_KEY', NULL, NULL, '对象存储访问密钥'), -(34, 'STORAGE', 'Secret Key', 'STORAGE_OSS_SECRET_KEY', NULL, NULL, '对象存储私有密钥'), -(35, 'STORAGE', '对象存储桶名称', 'STORAGE_OSS_BUCKET', NULL, 'continew', '对象存储 Bucket 名称(需预先创建)'), -(36, 'STORAGE', '对象存储终端节点', 'STORAGE_OSS_ENDPOINT', NULL, NULL, '对象存储访问地址'), -(37, 'STORAGE', '对象存储区域代码', 'STORAGE_OSS_REGION', NULL, 'cn-hangzhou', '对象存储数据中心区域标识(如:cn-hangzhou)'), -(40, 'LOGIN', '是否启用验证码', 'LOGIN_CAPTCHA_ENABLED', NULL, '1', NULL); +(1, 'SITE', '系统标题', 'SITE_TITLE', NULL, 'ContiNew Admin', '用于显示登录页面的系统标题。'), +(2, 'SITE', '系统描述', 'SITE_DESCRIPTION', NULL, '持续迭代优化的前后端分离中后台管理系统框架', NULL), +(3, 'SITE', '版权信息', 'SITE_COPYRIGHT', NULL, 'Copyright © 2022 - present ContiNew Admin 版权所有', '用于显示登录页面的底部版权信息。'), +(4, 'SITE', '备案号', 'SITE_BEIAN', NULL, '津ICP备2022005864号-3', 'ICP备案号'), +(5, 'SITE', 'favicon', 'SITE_FAVICON', NULL, '/favicon.ico', '用于显示浏览器地址栏的系统LOGO。'), +(6, 'SITE', '系统LOGO', 'SITE_LOGO', NULL, '/logo.svg', '用于显示登录页面的系统LOGO。'), +(7, 'PASSWORD', '登录密码错误锁定账号的次数', 'PASSWORD_ERROR_LOCK_COUNT', NULL, '5', '取值范围为 0-10(0 表示不锁定)。'), +(8, 'PASSWORD', '登录密码错误锁定账号的时间(min)', 'PASSWORD_ERROR_LOCK_MINUTES', NULL, '5', '取值范围为 1-1440(一天)。'), +(9, 'PASSWORD', '密码有效期(天)', 'PASSWORD_EXPIRATION_DAYS', NULL, '0', '取值范围为 0-999(0 表示永久有效)。'), +(10, 'PASSWORD', '密码到期提前提示(天)', 'PASSWORD_EXPIRATION_WARNING_DAYS', NULL, '0', '密码到期 N 天前进行提示(0 表示不提示)。'), +(11, 'PASSWORD', '密码重复使用次数', 'PASSWORD_REPETITION_TIMES', NULL, '3', '不允许使用最近 N 次密码,取值范围为 3-32。'), +(12, 'PASSWORD', '密码最小长度', 'PASSWORD_MIN_LENGTH', NULL, '8', '取值范围为 8-32。'), +(13, 'PASSWORD', '密码是否允许包含正反序账号名', 'PASSWORD_ALLOW_CONTAIN_USERNAME', NULL, '1', NULL), +(14, 'PASSWORD', '密码是否必须包含特殊字符', 'PASSWORD_REQUIRE_SYMBOLS', NULL, '0', NULL), +(15, 'MAIL', '发送协议', 'MAIL_PROTOCOL', NULL, 'smtp', NULL), +(16, 'MAIL', '服务器地址', 'MAIL_HOST', NULL, 'smtp.126.com', NULL), +(17, 'MAIL', '服务器端口', 'MAIL_PORT', NULL, '465', NULL), +(18, 'MAIL', '用户名', 'MAIL_USERNAME', NULL, 'charles7c@126.com', NULL), +(19, 'MAIL', '密码', 'MAIL_PASSWORD', NULL, NULL, NULL), +(20, 'MAIL', '是否启用SSL', 'MAIL_SSL_ENABLED', NULL, '1', NULL), +(21, 'MAIL', 'SSL端口', 'MAIL_SSL_PORT', NULL, '465', NULL), +(22, 'LOGIN', '是否启用验证码', 'LOGIN_CAPTCHA_ENABLED', NULL, '1', '是否启用验证码(1:是;0:否)'); -- 初始化默认字典 INSERT INTO "sys_dict" @@ -242,6 +240,13 @@ VALUES -- 初始化默认角色和部门关联数据 INSERT INTO "sys_role_dept" ("role_id", "dept_id") VALUES (547888897925840927, 547887852587843593); +-- 初始化默认存储 +INSERT INTO "sys_storage" +("id", "name", "code", "type", "access_key", "secret_key", "endpoint", "bucket_name", "domain", "description", "is_default", "sort", "status", "create_user", "create_time") +VALUES +(1, '开发环境', 'local_dev', 2, NULL, NULL, NULL, 'C:/continew-admin/data/file/', 'http://localhost:8000/file', '本地存储', true, 1, 1, 1, NOW()), +(2, '生产环境', 'local_prod', 2, NULL, NULL, NULL, '../data/file/', 'http://api.continew.top/file', '本地存储', false, 2, 2, 1, NOW()); + -- 初始化客户端数据 INSERT INTO "sys_client" ("id", "client_id", "client_key", "client_secret", "auth_type", "client_type", "active_timeout", "timeout", "status", "create_user", "create_time") diff --git a/continew-webapi/src/main/resources/db/changelog/postgresql/main_table.sql b/continew-webapi/src/main/resources/db/changelog/postgresql/main_table.sql index cf7ad13a..246cbb51 100644 --- a/continew-webapi/src/main/resources/db/changelog/postgresql/main_table.sql +++ b/continew-webapi/src/main/resources/db/changelog/postgresql/main_table.sql @@ -312,7 +312,7 @@ CREATE TABLE IF NOT EXISTS "sys_log" ( "id" int8 NOT NULL, "trace_id" varchar(255) DEFAULT NULL, "description" varchar(255) NOT NULL, - "module" varchar(100) NOT NULL, + "module" varchar(50) NOT NULL, "request_url" varchar(512) NOT NULL, "request_method" varchar(10) NOT NULL, "request_headers" text DEFAULT NULL, @@ -420,6 +420,47 @@ COMMENT ON COLUMN "sys_notice"."update_user" IS '修改人'; COMMENT ON COLUMN "sys_notice"."update_time" IS '修改时间'; COMMENT ON TABLE "sys_notice" IS '公告表'; +CREATE TABLE IF NOT EXISTS "sys_storage" ( + "id" int8 NOT NULL, + "name" varchar(100) NOT NULL, + "code" varchar(30) NOT NULL, + "type" int2 NOT NULL DEFAULT 1, + "access_key" varchar(255) DEFAULT NULL, + "secret_key" varchar(255) DEFAULT NULL, + "endpoint" varchar(255) DEFAULT NULL, + "bucket_name" varchar(255) DEFAULT NULL, + "domain" varchar(255) NOT NULL DEFAULT '', + "description" varchar(200) DEFAULT NULL, + "is_default" bool NOT NULL DEFAULT false, + "sort" int4 NOT NULL DEFAULT 999, + "status" int2 NOT NULL DEFAULT 1, + "create_user" int8 NOT NULL, + "create_time" timestamp NOT NULL, + "update_user" int8 DEFAULT NULL, + "update_time" timestamp DEFAULT NULL, + PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "uk_storage_code" ON "sys_storage" ("code"); +CREATE INDEX "idx_storage_create_user" ON "sys_storage" ("create_user"); +CREATE INDEX "idx_storage_update_user" ON "sys_storage" ("update_user"); +COMMENT ON COLUMN "sys_storage"."id" IS 'ID'; +COMMENT ON COLUMN "sys_storage"."name" IS '名称'; +COMMENT ON COLUMN "sys_storage"."code" IS '编码'; +COMMENT ON COLUMN "sys_storage"."type" IS '类型(1:兼容S3协议存储;2:本地存储)'; +COMMENT ON COLUMN "sys_storage"."access_key" IS 'Access Key(访问密钥)'; +COMMENT ON COLUMN "sys_storage"."secret_key" IS 'Secret Key(私有密钥)'; +COMMENT ON COLUMN "sys_storage"."endpoint" IS 'Endpoint(终端节点)'; +COMMENT ON COLUMN "sys_storage"."bucket_name" IS '桶名称'; +COMMENT ON COLUMN "sys_storage"."domain" IS '域名'; +COMMENT ON COLUMN "sys_storage"."description" IS '描述'; +COMMENT ON COLUMN "sys_storage"."is_default" IS '是否为默认存储'; +COMMENT ON COLUMN "sys_storage"."sort" IS '排序'; +COMMENT ON COLUMN "sys_storage"."status" IS '状态(1:启用;2:禁用)'; +COMMENT ON COLUMN "sys_storage"."create_user" IS '创建人'; +COMMENT ON COLUMN "sys_storage"."create_time" IS '创建时间'; +COMMENT ON COLUMN "sys_storage"."update_user" IS '修改人'; +COMMENT ON COLUMN "sys_storage"."update_time" IS '修改时间'; +COMMENT ON TABLE "sys_storage" IS '存储表'; CREATE TABLE IF NOT EXISTS "sys_file" ( "id" int8 NOT NULL, @@ -430,19 +471,15 @@ CREATE TABLE IF NOT EXISTS "sys_file" ( "thumbnail_size" int8 DEFAULT NULL, "thumbnail_url" varchar(512) DEFAULT NULL, "type" int2 NOT NULL DEFAULT 1, + "storage_id" int8 NOT NULL, "create_user" int8 NOT NULL, "create_time" timestamp NOT NULL, "update_user" int8 NOT NULL, "update_time" timestamp NOT NULL, - "e_tag" varchar(100) DEFAULT NULL, - "storage_code" varchar(255) DEFAULT NULL, - "bucket_name" varchar(255) DEFAULT NULL, - "path" varchar(512) DEFAULT NULL, PRIMARY KEY ("id") ); CREATE INDEX "idx_file_url" ON "sys_file" ("url"); CREATE INDEX "idx_file_type" ON "sys_file" ("type"); -CREATE INDEX "idx_file_storage_code" ON "sys_file" ("storage_code"); CREATE INDEX "idx_file_create_user" ON "sys_file" ("create_user"); CREATE INDEX "idx_file_update_user" ON "sys_file" ("update_user"); COMMENT ON COLUMN "sys_file"."id" IS 'ID'; @@ -453,14 +490,11 @@ COMMENT ON COLUMN "sys_file"."extension" IS '扩展名'; COMMENT ON COLUMN "sys_file"."thumbnail_size" IS '缩略图大小(字节)'; COMMENT ON COLUMN "sys_file"."thumbnail_url" IS '缩略图URL'; COMMENT ON COLUMN "sys_file"."type" IS '类型(1:其他;2:图片;3:文档;4:视频;5:音频)'; +COMMENT ON COLUMN "sys_file"."storage_id" IS '存储ID'; COMMENT ON COLUMN "sys_file"."create_user" IS '创建人'; COMMENT ON COLUMN "sys_file"."create_time" IS '创建时间'; COMMENT ON COLUMN "sys_file"."update_user" IS '修改人'; COMMENT ON COLUMN "sys_file"."update_time" IS '修改时间'; -COMMENT ON COLUMN "sys_file"."e_tag" IS '文件唯一标识'; -COMMENT ON COLUMN "sys_file"."storage_code" IS '存储唯一标识'; -COMMENT ON COLUMN "sys_file"."bucket_name" IS '存储桶名称'; -COMMENT ON COLUMN "sys_file"."path" IS '基础路径'; COMMENT ON TABLE "sys_file" IS '文件表'; CREATE TABLE IF NOT EXISTS "sys_client" (