refactor(system/file): 重构文件管理表结构,新增计算文件大小接口

This commit is contained in:
2025-05-16 23:02:38 +08:00
parent 37027c774b
commit 798182d120
20 changed files with 314 additions and 189 deletions

View File

@@ -29,11 +29,6 @@ public class ContainerConstants {
*/ */
public static final String USER_NICKNAME = "UserNickname"; public static final String USER_NICKNAME = "UserNickname";
/**
* 文件信息
*/
public static final String FILE_INFO = "FileInfo";
/** /**
* 用户角色 ID 列表 * 用户角色 ID 列表
*/ */

View File

@@ -1,54 +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.crane4j.core.container.Container;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import top.continew.admin.common.constant.ContainerConstants;
import top.continew.admin.system.model.entity.FileDO;
import top.continew.admin.system.service.FileService;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 文件信息填充容器
*
* @author luoqiz
* @since 2025/3/12 18:11
*/
@Component
@RequiredArgsConstructor
public class FileInfoContainer implements Container<Long> {
private final FileService fileService;
@Override
public String getNamespace() {
return ContainerConstants.FILE_INFO;
}
@Override
public Map<Long, FileDO> get(Collection<Long> ids) {
List<FileDO> list = fileService.listByIds(ids);
return list.stream().collect(Collectors.toMap(FileDO::getId, Function.identity()));
}
}

View File

@@ -62,11 +62,11 @@ public class FileRecorderImpl implements FileRecorder {
// 方便文件上传完成后获取文件信息 // 方便文件上传完成后获取文件信息
fileInfo.setId(String.valueOf(file.getId())); fileInfo.setId(String.valueOf(file.getId()));
if (!URLUtils.isHttpUrl(fileInfo.getUrl())) { if (!URLUtils.isHttpUrl(fileInfo.getUrl())) {
String prefix = StrUtil.blankToDefault(storage.getDomain(), storage.getEndpoint()); String prefix = storage.getUrlPrefix();
String url = URLUtil.completeUrl(prefix, fileInfo.getUrl()); String url = URLUtil.normalize(prefix + fileInfo.getUrl(), false, true);
fileInfo.setUrl(url); fileInfo.setUrl(url);
if (StrUtil.isNotBlank(fileInfo.getThUrl())) { if (StrUtil.isNotBlank(fileInfo.getThUrl())) {
fileInfo.setThUrl(URLUtil.completeUrl(prefix, fileInfo.getThUrl())); fileInfo.setThUrl(URLUtil.normalize(prefix + fileInfo.getThUrl(), false, true));
} }
} }
return true; return true;
@@ -117,8 +117,7 @@ public class FileRecorderImpl implements FileRecorder {
.eq(FileDO::getName, StrUtil.subAfter(url, StringConstants.SLASH, true)); .eq(FileDO::getName, StrUtil.subAfter(url, StringConstants.SLASH, true));
// 非 HTTP URL 场景 // 非 HTTP URL 场景
if (!URLUtils.isHttpUrl(url)) { if (!URLUtils.isHttpUrl(url)) {
return queryWrapper.eq(FileDO::getPath, StrUtil.prependIfMissing(StrUtil return queryWrapper.eq(FileDO::getPath, StrUtil.prependIfMissing(url, StringConstants.SLASH)).one();
.subBefore(url, StringConstants.SLASH, true), StringConstants.SLASH)).one();
} }
// HTTP URL 场景 // HTTP URL 场景
List<FileDO> list = queryWrapper.list(); List<FileDO> list = queryWrapper.list();
@@ -137,8 +136,7 @@ public class FileRecorderImpl implements FileRecorder {
String urlPrefix = StrUtil.subBefore(url, StringConstants.SLASH, true); String urlPrefix = StrUtil.subBefore(url, StringConstants.SLASH, true);
// http://localhost:8000/file/ + /user/avatar => http://localhost:8000/file/user/avatar // http://localhost:8000/file/ + /user/avatar => http://localhost:8000/file/user/avatar
StorageDO storage = storageMap.get(file.getStorageId()); StorageDO storage = storageMap.get(file.getStorageId());
String prefix = StrUtil.blankToDefault(storage.getDomain(), storage.getEndpoint()); return urlPrefix.equals(URLUtil.normalize(storage.getUrlPrefix() + file.getParentPath(), false, true));
return urlPrefix.equals(URLUtil.normalize(prefix + file.getPath(), false, true));
}).findFirst().orElse(null); }).findFirst().orElse(null);
} }
} }

View File

@@ -83,7 +83,7 @@ public enum StorageTypeEnum implements BaseEnum<Integer> {
* @param req 请求参数 * @param req 请求参数
*/ */
public void pretreatment(StorageReq req) { public void pretreatment(StorageReq req) {
// 域名需要以 “/” 结尾 // 域名需要以 “/” 结尾x-file-storage 在拼接路径时都是直接 + 拼接,所以规范要求每一级都要以 “/” 结尾,且后面路径不能以 “/” 开头)
if (StrUtil.isNotBlank(req.getDomain())) { if (StrUtil.isNotBlank(req.getDomain())) {
req.setDomain(StrUtil.appendIfMissing(req.getDomain(), StringConstants.SLASH)); req.setDomain(StrUtil.appendIfMissing(req.getDomain(), StringConstants.SLASH));
} }

View File

@@ -36,6 +36,6 @@ public interface FileMapper extends BaseMapper<FileDO> {
* *
* @return 文件资源统计信息 * @return 文件资源统计信息
*/ */
@Select("SELECT type, COUNT(1) number, SUM(size) size FROM sys_file GROUP BY type") @Select("SELECT type, COUNT(1) number, SUM(size) size FROM sys_file WHERE type != 0 GROUP BY type")
List<FileStatisticsResp> statistics(); List<FileStatisticsResp> statistics();
} }

View File

@@ -60,7 +60,12 @@ public class FileDO extends BaseDO {
private Long size; private Long size;
/** /**
* 存储路径 * 上级目录
*/
private String parentPath;
/**
* 路径
*/ */
private String path; private String path;
@@ -119,10 +124,11 @@ public class FileDO extends BaseDO {
this.originalName = fileInfo.getOriginalFilename(); this.originalName = fileInfo.getOriginalFilename();
this.size = fileInfo.getSize(); this.size = fileInfo.getSize();
// 如果为空,则为 /;如果不为空,则调整格式为:/xxx // 如果为空,则为 /;如果不为空,则调整格式为:/xxx
this.path = StrUtil.isEmpty(fileInfo.getPath()) this.parentPath = StrUtil.isEmpty(fileInfo.getPath())
? StringConstants.SLASH ? StringConstants.SLASH
: StrUtil.removeSuffix(StrUtil.prependIfMissing(fileInfo : StrUtil.removeSuffix(StrUtil.prependIfMissing(fileInfo
.getPath(), StringConstants.SLASH), StringConstants.SLASH); .getPath(), StringConstants.SLASH), StringConstants.SLASH);
this.path = StrUtil.prependIfMissing(fileInfo.getUrl(), StringConstants.SLASH);
this.extension = fileInfo.getExt(); this.extension = fileInfo.getExt();
this.contentType = fileInfo.getContentType(); this.contentType = fileInfo.getContentType();
this.type = FileTypeEnum.getByExtension(this.extension); this.type = FileTypeEnum.getByExtension(this.extension);
@@ -148,15 +154,16 @@ public class FileDO extends BaseDO {
// 暂不使用,所以保持空 // 暂不使用,所以保持空
fileInfo.setBasePath(StringConstants.EMPTY); fileInfo.setBasePath(StringConstants.EMPTY);
fileInfo.setSize(this.size); fileInfo.setSize(this.size);
fileInfo.setPath(StringConstants.SLASH.equals(this.path) fileInfo.setPath(StringConstants.SLASH.equals(this.parentPath)
? StringConstants.EMPTY ? StringConstants.EMPTY
: StrUtil.appendIfMissing(StrUtil.removePrefix(this.path, StringConstants.SLASH), StringConstants.SLASH)); : StrUtil.appendIfMissing(StrUtil
.removePrefix(this.parentPath, StringConstants.SLASH), StringConstants.SLASH));
fileInfo.setExt(this.extension); fileInfo.setExt(this.extension);
fileInfo.setContentType(this.contentType); fileInfo.setContentType(this.contentType);
if (StrUtil.isNotBlank(this.metadata)) { if (StrUtil.isNotBlank(this.metadata)) {
fileInfo.setMetadata(JSONUtil.toBean(this.metadata, Map.class)); fileInfo.setMetadata(JSONUtil.toBean(this.metadata, Map.class));
} }
fileInfo.setUrl(fileInfo.getPath() + fileInfo.getFilename()); fileInfo.setUrl(StrUtil.removePrefix(this.path, StringConstants.SLASH));
// 缩略图信息 // 缩略图信息
fileInfo.setThFilename(this.thumbnailName); fileInfo.setThFilename(this.thumbnailName);
fileInfo.setThSize(this.thumbnailSize); fileInfo.setThSize(this.thumbnailSize);
@@ -166,4 +173,11 @@ public class FileDO extends BaseDO {
} }
return fileInfo; return fileInfo;
} }
public void setParentPath(String parentPath) {
this.parentPath = parentPath;
this.path = StringConstants.SLASH.equals(parentPath)
? parentPath + this.name
: parentPath + StringConstants.SLASH + this.name;
}
} }

View File

@@ -16,14 +16,20 @@
package top.continew.admin.system.model.entity; package top.continew.admin.system.model.entity;
import cn.hutool.core.lang.RegexPool;
import cn.hutool.core.util.ReUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data; import lombok.Data;
import top.continew.admin.common.enums.DisEnableStatusEnum; 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.admin.common.model.entity.BaseDO;
import top.continew.admin.system.enums.StorageTypeEnum;
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.security.crypto.annotation.FieldEncrypt; import top.continew.starter.security.crypto.annotation.FieldEncrypt;
import java.io.Serial; import java.io.Serial;
import java.net.URL;
/** /**
* 存储实体 * 存储实体
@@ -99,4 +105,28 @@ public class StorageDO extends BaseDO {
* 状态 * 状态
*/ */
private DisEnableStatusEnum status; private DisEnableStatusEnum status;
/**
* 获取 URL 前缀
* <p>
* LOCAL{@link #domain}/ <br />
* OSS域名不为空{@link #domain}/Endpoint 不是
* IPhttp(s)://{@link #bucketName}.{@link #endpoint}/;否则:{@link #endpoint}/{@link #bucketName}/
* </p>
*
* @return URL 前缀
*/
public String getUrlPrefix() {
if (StrUtil.isNotBlank(this.domain) || StorageTypeEnum.LOCAL.equals(this.type)) {
return StrUtil.appendIfMissing(this.domain, StringConstants.SLASH);
}
URL url = URLUtil.url(this.endpoint);
String host = url.getHost();
// IPMinIO 则拼接 BucketName
if (ReUtil.isMatch(RegexPool.IPV4, host) || ReUtil.isMatch(RegexPool.IPV6, host)) {
return StrUtil
.appendIfMissing(this.endpoint, StringConstants.SLASH) + this.bucketName + StringConstants.SLASH;
}
return "%s://%s.%s/".formatted(url.getProtocol(), this.bucketName, host);
}
} }

View File

@@ -46,10 +46,10 @@ public class FileQuery implements Serializable {
private String originalName; private String originalName;
/** /**
* 存储路径 * 上级目录
*/ */
@Schema(description = "存储路径", example = "/") @Schema(description = "上级目录", example = "/")
private String path; private String parentPath;
/** /**
* 类型 * 类型

View File

@@ -46,8 +46,8 @@ public class FileReq implements Serializable {
private String originalName; private String originalName;
/** /**
* 存储路径 * 上级目录
*/ */
@Schema(description = "存储路径", example = "/") @Schema(description = "上级目录", example = "/")
private String path; private String parentPath;
} }

View File

@@ -0,0 +1,47 @@
/*
* 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.file;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
/**
* 文件夹计算大小响应参数
*
* @author Charles7c
* @since 2025/5/16 21:32
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "文件夹计算大小响应参数")
public class FileDirCalcSizeResp implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 大小(字节)
*/
@Schema(description = "大小(字节)", example = "4096")
private Long size;
}

View File

@@ -63,9 +63,15 @@ public class FileResp extends BaseDetailResp {
private String url; private String url;
/** /**
* 存储路径 * 上级目录
*/ */
@Schema(description = "上级目录", example = "/2025/2/25") @Schema(description = "上级目录", example = "/2025/2/25")
private String parentPath;
/**
* 路径
*/
@Schema(description = "路径", example = "/2025/2/25/6824afe8408da079832dcfb6.jpg")
private String path; private String path;
/** /**

View File

@@ -47,39 +47,39 @@ public interface FileService extends BaseService<FileResp, FileResp, FileQuery,
* @throws IOException / * @throws IOException /
*/ */
default FileInfo upload(MultipartFile file) throws IOException { default FileInfo upload(MultipartFile file) throws IOException {
return upload(file, getDefaultFilePath(), null); return upload(file, getDefaultParentPath(), null);
} }
/** /**
* 上传到默认存储 * 上传到默认存储
* *
* @param file 文件信息 * @param file 文件信息
* @param path 文件路径 * @param parentPath 上级目录
* @return 文件信息 * @return 文件信息
* @throws IOException / * @throws IOException /
*/ */
default FileInfo upload(MultipartFile file, String path) throws IOException { default FileInfo upload(MultipartFile file, String parentPath) throws IOException {
return upload(file, path, null); return upload(file, parentPath, null);
} }
/** /**
* 上传到指定存储 * 上传到指定存储
* *
* @param file 文件信息 * @param file 文件信息
* @param path 文件路径 * @param parentPath 上级目录
* @param storageCode 存储编码 * @param storageCode 存储编码
* @return 文件信息 * @return 文件信息
* @throws IOException / * @throws IOException /
*/ */
FileInfo upload(MultipartFile file, String path, String storageCode) throws IOException; FileInfo upload(MultipartFile file, String parentPath, String storageCode) throws IOException;
/** /**
* 根据存储 ID 列表查询 * 创建目录
* *
* @param storageIds 存储 ID 列表 * @param req 请求参数
* @return 文件数量 * @return ID
*/ */
Long countByStorageIds(List<Long> storageIds); Long createDir(FileReq req);
/** /**
* 查询文件资源统计信息 * 查询文件资源统计信息
@@ -97,23 +97,31 @@ public interface FileService extends BaseService<FileResp, FileResp, FileQuery,
FileResp check(String fileHash); FileResp check(String fileHash);
/** /**
* 创建目录 * 计算文件夹大小
* *
* @param req 请求参数 * @param id ID
* @return ID * @return 文件夹大小(字节)
*/ */
Long createDir(FileReq req); Long calcDirSize(Long id);
/** /**
* 获取默认文件路径 * 根据存储 ID 列表查询
*
* @param storageIds 存储 ID 列表
* @return 文件数量
*/
Long countByStorageIds(List<Long> storageIds);
/**
* 获取默认上级目录
* *
* <p> * <p>
* 默认文件路径yyyy/MM/dd/ * 默认上级目录yyyy/MM/dd/
* </p> * </p>
* *
* @return 默认文件路径 * @return 默认上级目录
*/ */
default String getDefaultFilePath() { default String getDefaultParentPath() {
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
return today.getYear() + StringConstants.SLASH + today.getMonthValue() + StringConstants.SLASH + today return today.getYear() + StringConstants.SLASH + today.getMonthValue() + StringConstants.SLASH + today
.getDayOfMonth() + StringConstants.SLASH; .getDayOfMonth() + StringConstants.SLASH;

View File

@@ -18,7 +18,6 @@ package top.continew.admin.system.service.impl;
import cn.hutool.core.collection.CollUtil; import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.io.file.FileNameUtil; import cn.hutool.core.io.file.FileNameUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ClassUtil; import cn.hutool.core.util.ClassUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil; import cn.hutool.core.util.URLUtil;
@@ -45,10 +44,10 @@ import top.continew.admin.system.service.StorageService;
import top.continew.starter.core.constant.StringConstants; import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.core.util.StrUtils; import top.continew.starter.core.util.StrUtils;
import top.continew.starter.core.validation.CheckUtils; 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.extension.crud.service.BaseServiceImpl;
import java.io.IOException; import java.io.IOException;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -80,11 +79,9 @@ public class FileServiceImpl extends BaseServiceImpl<FileMapper, FileDO, FileRes
fileStorageService.delete(fileInfo); fileStorageService.delete(fileInfo);
} else { } else {
// 不允许删除非空文件夹 // 不允许删除非空文件夹
String separator = StringConstants.SLASH.equals(file.getPath())
? StringConstants.EMPTY
: StringConstants.SLASH;
boolean exists = baseMapper.lambdaQuery() boolean exists = baseMapper.lambdaQuery()
.eq(FileDO::getPath, file.getPath() + separator + file.getName()) .eq(FileDO::getParentPath, file.getPath())
.eq(FileDO::getStorageId, entry.getKey())
.exists(); .exists();
CheckUtils.throwIf(exists, "文件夹 [{}] 不为空,请先删除文件夹下的内容", file.getName()); CheckUtils.throwIf(exists, "文件夹 [{}] 不为空,请先删除文件夹下的内容", file.getName());
} }
@@ -93,7 +90,7 @@ public class FileServiceImpl extends BaseServiceImpl<FileMapper, FileDO, FileRes
} }
@Override @Override
public FileInfo upload(MultipartFile file, String path, String storageCode) throws IOException { public FileInfo upload(MultipartFile file, String parentPath, String storageCode) throws IOException {
// 校验文件格式 // 校验文件格式
String extName = FileNameUtil.extName(file.getOriginalFilename()); String extName = FileNameUtil.extName(file.getOriginalFilename());
List<String> allExtensions = FileTypeEnum.getAllExtensions(); List<String> allExtensions = FileTypeEnum.getAllExtensions();
@@ -106,7 +103,7 @@ public class FileServiceImpl extends BaseServiceImpl<FileMapper, FileDO, FileRes
.setPlatform(storage.getCode()) .setPlatform(storage.getCode())
.setHashCalculatorSha256(true) .setHashCalculatorSha256(true)
.putAttr(ClassUtil.getClassName(StorageDO.class, false), storage) .putAttr(ClassUtil.getClassName(StorageDO.class, false), storage)
.setPath(this.pretreatmentPath(path)); .setPath(this.pretreatmentPath(parentPath));
// 图片文件生成缩略图 // 图片文件生成缩略图
if (FileTypeEnum.IMAGE.getExtensions().contains(extName)) { if (FileTypeEnum.IMAGE.getExtensions().contains(extName)) {
uploadPretreatment.setIgnoreThumbnailException(true, true); uploadPretreatment.setIgnoreThumbnailException(true, true);
@@ -129,17 +126,40 @@ public class FileServiceImpl extends BaseServiceImpl<FileMapper, FileDO, FileRes
} }
}); });
// 创建父级目录 // 创建父级目录
this.createDir(path, storage); this.createParentDir(parentPath, storage);
// 上传 // 上传
return uploadPretreatment.upload(); return uploadPretreatment.upload();
} }
@Override @Override
public Long countByStorageIds(List<Long> storageIds) { public Long createDir(FileReq req) {
if (CollUtil.isEmpty(storageIds)) { String parentPath = req.getParentPath();
return 0L; FileDO file = baseMapper.lambdaQuery()
.eq(FileDO::getParentPath, parentPath)
.eq(FileDO::getName, req.getOriginalName())
.eq(FileDO::getType, FileTypeEnum.DIR)
.one();
CheckUtils.throwIfNotNull(file, "文件夹已存在");
// 存储引擎需要一致
StorageDO storage = storageService.getDefaultStorage();
if (!StringConstants.SLASH.equals(parentPath)) {
FileDO parentFile = baseMapper.lambdaQuery()
.eq(FileDO::getPath, parentPath)
.eq(FileDO::getType, FileTypeEnum.DIR)
.one();
CheckUtils.throwIfNull(parentFile, "父级文件夹不存在");
CheckUtils.throwIfNotEqual(parentFile.getStorageId(), storage.getId(), "文件夹和父级文件夹存储引擎不一致");
} }
return baseMapper.lambdaQuery().in(FileDO::getStorageId, storageIds).count(); // 创建文件夹
FileDO dirFile = new FileDO();
String originalName = req.getOriginalName();
dirFile.setName(originalName);
dirFile.setOriginalName(originalName);
dirFile.setParentPath(parentPath);
dirFile.setType(FileTypeEnum.DIR);
dirFile.setStorageId(storage.getId());
baseMapper.insert(dirFile);
return dirFile.getId();
} }
@Override @Override
@@ -165,17 +185,31 @@ public class FileServiceImpl extends BaseServiceImpl<FileMapper, FileDO, FileRes
} }
@Override @Override
public Long createDir(FileReq req) { public Long calcDirSize(Long id) {
StorageDO storage = storageService.getDefaultStorage(); FileDO dirFile = super.getById(id);
FileDO file = new FileDO(); ValidationUtils.throwIfNotEqual(dirFile.getType(), FileTypeEnum.DIR, "ID 为 [{}] 的不是文件夹,不支持计算大小", id);
file.setName(req.getOriginalName()); // 查询当前文件夹下的所有子文件和子文件夹
file.setOriginalName(req.getOriginalName()); List<FileDO> children = baseMapper.lambdaQuery().eq(FileDO::getParentPath, dirFile.getPath()).list();
file.setSize(0L); if (CollUtil.isEmpty(children)) {
file.setPath(req.getPath()); return 0L;
file.setType(FileTypeEnum.DIR); }
file.setStorageId(storage.getId()); // 累加子文件大小和递归计算子文件夹大小
baseMapper.insert(file); return children.stream().mapToLong(child -> {
return file.getId(); if (FileTypeEnum.DIR.equals(child.getType())) {
// 递归计算子文件夹大小
return calcDirSize(child.getId());
} else {
return child.getSize();
}
}).sum();
}
@Override
public Long countByStorageIds(List<Long> storageIds) {
if (CollUtil.isEmpty(storageIds)) {
return 0L;
}
return baseMapper.lambdaQuery().in(FileDO::getStorageId, storageIds).count();
} }
@Override @Override
@@ -183,12 +217,14 @@ public class FileServiceImpl extends BaseServiceImpl<FileMapper, FileDO, FileRes
super.fill(obj); super.fill(obj);
if (obj instanceof FileResp fileResp) { if (obj instanceof FileResp fileResp) {
StorageDO storage = storageService.getById(fileResp.getStorageId()); StorageDO storage = storageService.getById(fileResp.getStorageId());
String prefix = StrUtil.blankToDefault(storage.getDomain(), storage.getEndpoint()); String prefix = storage.getUrlPrefix();
String path = fileResp.getPath(); String url = URLUtil.normalize(prefix + fileResp.getPath(), false, true);
String url = URLUtil.normalize(prefix + path + StringConstants.SLASH + fileResp.getName(), false, true);
fileResp.setUrl(url); fileResp.setUrl(url);
String parentPath = StringConstants.SLASH.equals(fileResp.getParentPath())
? StringConstants.EMPTY
: fileResp.getParentPath();
String thumbnailUrl = StrUtils.blankToDefault(fileResp.getThumbnailName(), url, thName -> URLUtil String thumbnailUrl = StrUtils.blankToDefault(fileResp.getThumbnailName(), url, thName -> URLUtil
.normalize(prefix + path + StringConstants.SLASH + thName, false, true)); .normalize(prefix + parentPath + StringConstants.SLASH + thName, false, true));
fileResp.setThumbnailUrl(thumbnailUrl); fileResp.setThumbnailUrl(thumbnailUrl);
fileResp.setStorageName("%s (%s)".formatted(storage.getName(), storage.getCode())); fileResp.setStorageName("%s (%s)".formatted(storage.getName(), storage.getCode()));
} }
@@ -198,7 +234,7 @@ public class FileServiceImpl extends BaseServiceImpl<FileMapper, FileDO, FileRes
* 处理路径 * 处理路径
* *
* <p> * <p>
* 1.如果 path 为空,则使用 {@link FileService#getDefaultFilePath()} 作为默认值 <br /> * 1.如果 path 为空,则使用 {@link FileService#getDefaultParentPath()} 作为默认值 <br />
* 2.如果 path 为 {@code /},则设置为空 <br /> * 2.如果 path 为 {@code /},则设置为空 <br />
* 3.如果 path 不以 {@code /} 结尾,则添加后缀 {@code /} <br /> * 3.如果 path 不以 {@code /} 结尾,则添加后缀 {@code /} <br />
* 4.如果 path 以 {@code /} 开头,则移除前缀 {@code /} <br /> * 4.如果 path 以 {@code /} 开头,则移除前缀 {@code /} <br />
@@ -210,7 +246,7 @@ public class FileServiceImpl extends BaseServiceImpl<FileMapper, FileDO, FileRes
*/ */
private String pretreatmentPath(String path) { private String pretreatmentPath(String path) {
if (StrUtil.isBlank(path)) { if (StrUtil.isBlank(path)) {
return this.getDefaultFilePath(); return this.getDefaultParentPath();
} }
if (StringConstants.SLASH.equals(path)) { if (StringConstants.SLASH.equals(path)) {
return StringConstants.EMPTY; return StringConstants.EMPTY;
@@ -219,47 +255,48 @@ public class FileServiceImpl extends BaseServiceImpl<FileMapper, FileDO, FileRes
} }
/** /**
* 创建文件夹(支持多级) * 创建上级文件夹(支持多级)
* *
* <p> * <p>
* user/avatar/ => userpath/、avatarpath/user * user/avatar/ => userpath/user、avatarpath/user/avatar
* </p> * </p>
* *
* @param dirPath 路径 * @param parentPath 上级目录
* @param storage 存储配置 * @param storage 存储配置
*/ */
private void createDir(String dirPath, StorageDO storage) { private void createParentDir(String parentPath, StorageDO storage) {
if (StrUtil.isBlank(dirPath) || StringConstants.SLASH.equals(dirPath)) { if (StrUtil.isBlank(parentPath) || StringConstants.SLASH.equals(parentPath)) {
return; return;
} }
// user/avatar/ => user/、avatarpath/user // user/avatar/ => user、avatar
String[] paths = StrUtil.split(dirPath, StringConstants.SLASH, false, true).toArray(String[]::new); String[] parentPathParts = StrUtil.split(parentPath, StringConstants.SLASH, false, true).toArray(String[]::new);
Map<String, String> pathMap = MapUtil.newHashMap(paths.length, true); String lastPath = StringConstants.SLASH;
for (int i = 0; i < paths.length; i++) { StringBuilder currentPathBuilder = new StringBuilder();
String key = paths[i]; for (int i = 0; i < parentPathParts.length; i++) {
String path = (i == 0) String parentPathPart = parentPathParts[i];
? StringConstants.SLASH if (i > 0) {
: StringConstants.SLASH + String.join(StringConstants.SLASH, Arrays.copyOfRange(paths, 0, i)); lastPath = currentPathBuilder.toString();
pathMap.put(key, path);
} }
// 创建文件夹 // /user、/user/avatar
for (Map.Entry<String, String> entry : pathMap.entrySet()) { currentPathBuilder.append(StringConstants.SLASH).append(parentPathPart);
String key = entry.getKey(); String currentPath = currentPathBuilder.toString();
String path = entry.getValue(); // 文件夹和文件存储引擎需要一致
if (!baseMapper.lambdaQuery() FileDO dirFile = baseMapper.lambdaQuery()
.eq(FileDO::getPath, path) .eq(FileDO::getPath, currentPath)
.eq(FileDO::getName, key)
.eq(FileDO::getType, FileTypeEnum.DIR) .eq(FileDO::getType, FileTypeEnum.DIR)
.exists()) { .one();
if (dirFile != null) {
CheckUtils.throwIfNotEqual(dirFile.getStorageId(), storage.getId(), "文件夹和上传文件存储引擎不一致");
continue;
}
FileDO file = new FileDO(); FileDO file = new FileDO();
file.setName(key); file.setName(parentPathPart);
file.setOriginalName(key); file.setOriginalName(parentPathPart);
file.setSize(0L); file.setPath(currentPath);
file.setPath(path); file.setParentPath(lastPath);
file.setType(FileTypeEnum.DIR); file.setType(FileTypeEnum.DIR);
file.setStorageId(storage.getId()); file.setStorageId(storage.getId());
baseMapper.insert(file); baseMapper.insert(file);
} }
} }
} }
}

View File

@@ -16,6 +16,7 @@
package top.continew.admin.system.service.impl; package top.continew.admin.system.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.map.MapUtil; import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil; import cn.hutool.core.util.URLUtil;
@@ -78,13 +79,9 @@ public class StorageServiceImpl extends BaseServiceImpl<StorageMapper, StorageDO
CheckUtils.throwIf(this.isCodeExists(code, null), "新增失败,[{}] 已存在", code); CheckUtils.throwIf(this.isCodeExists(code, null), "新增失败,[{}] 已存在", code);
// 需要独立操作来指定默认存储 // 需要独立操作来指定默认存储
req.setIsDefault(false); req.setIsDefault(false);
}
@Override
public void afterCreate(StorageReq req, StorageDO entity) {
// 加载存储引擎 // 加载存储引擎
if (DisEnableStatusEnum.ENABLE.equals(entity.getStatus())) { if (DisEnableStatusEnum.ENABLE.equals(req.getStatus())) {
this.load(entity); this.load(BeanUtil.copyProperties(req, StorageDO.class));
} }
} }
@@ -107,19 +104,16 @@ public class StorageServiceImpl extends BaseServiceImpl<StorageMapper, StorageDO
storageType.pretreatment(req); storageType.pretreatment(req);
// 卸载存储引擎 // 卸载存储引擎
this.unload(oldStorage); this.unload(oldStorage);
}
@Override
public void afterUpdate(StorageReq req, StorageDO entity) {
// 加载存储引擎 // 加载存储引擎
if (DisEnableStatusEnum.ENABLE.equals(entity.getStatus())) { if (DisEnableStatusEnum.ENABLE.equals(newStatus)) {
this.load(entity); BeanUtil.copyProperties(req, oldStorage);
this.load(oldStorage);
} }
} }
@Override @Override
public void beforeDelete(List<Long> ids) { public void beforeDelete(List<Long> ids) {
CheckUtils.throwIf(fileService.countByStorageIds(ids) > 0, "所选存储存在文件关联,请删除文件后重试"); CheckUtils.throwIf(fileService.countByStorageIds(ids) > 0, "所选存储存在文件或文件夹关联,请删除后重试");
List<StorageDO> storageList = baseMapper.lambdaQuery().in(StorageDO::getId, ids).list(); List<StorageDO> storageList = baseMapper.lambdaQuery().in(StorageDO::getId, ids).list();
storageList.forEach(storage -> { storageList.forEach(storage -> {
CheckUtils.throwIfEqual(Boolean.TRUE, storage.getIsDefault(), "[{}] 是默认存储,不允许删除", storage.getName()); CheckUtils.throwIfEqual(Boolean.TRUE, storage.getIsDefault(), "[{}] 是默认存储,不允许删除", storage.getName());
@@ -204,7 +198,7 @@ public class StorageServiceImpl extends BaseServiceImpl<StorageMapper, StorageDO
config.setSecretKey(storage.getSecretKey()); config.setSecretKey(storage.getSecretKey());
config.setEndPoint(storage.getEndpoint()); config.setEndPoint(storage.getEndpoint());
config.setBucketName(storage.getBucketName()); config.setBucketName(storage.getBucketName());
config.setDomain(storage.getDomain()); config.setDomain(StrUtil.emptyIfNull(storage.getDomain()));
fileStorageList.addAll(FileStorageServiceBuilder.buildAmazonS3FileStorage(Collections fileStorageList.addAll(FileStorageServiceBuilder.buildAmazonS3FileStorage(Collections
.singletonList(config), null)); .singletonList(config), null));
} }

View File

@@ -67,9 +67,10 @@ public class CommonController {
@Operation(summary = "上传文件", description = "上传文件") @Operation(summary = "上传文件", description = "上传文件")
@PostMapping("/file") @PostMapping("/file")
public FileUploadResp upload(@NotNull(message = "文件不能为空") MultipartFile file, String path) throws IOException { public FileUploadResp upload(@NotNull(message = "文件不能为空") MultipartFile file,
String parentPath) throws IOException {
ValidationUtils.throwIf(file::isEmpty, "文件不能为空"); ValidationUtils.throwIf(file::isEmpty, "文件不能为空");
FileInfo fileInfo = fileService.upload(file, path); FileInfo fileInfo = fileService.upload(file, parentPath);
return FileUploadResp.builder() return FileUploadResp.builder()
.id(fileInfo.getId()) .id(fileInfo.getId())
.url(fileInfo.getUrl()) .url(fileInfo.getUrl())

View File

@@ -19,19 +19,27 @@ package top.continew.admin.controller.system;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.dromara.x.file.storage.core.FileInfo;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import top.continew.admin.common.controller.BaseController; import top.continew.admin.common.controller.BaseController;
import top.continew.admin.system.model.query.FileQuery; import top.continew.admin.system.model.query.FileQuery;
import top.continew.admin.system.model.req.FileReq; import top.continew.admin.system.model.req.FileReq;
import top.continew.admin.system.model.resp.file.FileDirCalcSizeResp;
import top.continew.admin.system.model.resp.file.FileResp; import top.continew.admin.system.model.resp.file.FileResp;
import top.continew.admin.system.model.resp.file.FileStatisticsResp; import top.continew.admin.system.model.resp.file.FileStatisticsResp;
import top.continew.admin.system.model.resp.file.FileUploadResp;
import top.continew.admin.system.service.FileService; import top.continew.admin.system.service.FileService;
import top.continew.starter.core.validation.ValidationUtils;
import top.continew.starter.extension.crud.annotation.CrudRequestMapping; import top.continew.starter.extension.crud.annotation.CrudRequestMapping;
import top.continew.starter.extension.crud.enums.Api; import top.continew.starter.extension.crud.enums.Api;
import top.continew.starter.extension.crud.model.resp.IdResp; import top.continew.starter.extension.crud.model.resp.IdResp;
import top.continew.starter.log.annotation.Log; import top.continew.starter.log.annotation.Log;
import java.io.IOException;
/** /**
* 文件管理 API * 文件管理 API
* *
@@ -44,6 +52,46 @@ import top.continew.starter.log.annotation.Log;
@CrudRequestMapping(value = "/system/file", api = {Api.PAGE, Api.UPDATE, Api.DELETE}) @CrudRequestMapping(value = "/system/file", api = {Api.PAGE, Api.UPDATE, Api.DELETE})
public class FileController extends BaseController<FileService, FileResp, FileResp, FileQuery, FileReq> { public class FileController extends BaseController<FileService, FileResp, FileResp, FileQuery, FileReq> {
/**
* 上传文件
* <p>
* 公共上传文件请使用 {@link top.continew.admin.controller.common.CommonController#upload}
* </p>
*
* @param file 文件
* @param parentPath 上级目录
* @return 文件上传响应参数
* @throws IOException /
*/
@SaCheckPermission("system:file:upload")
@Operation(summary = "上传文件", description = "上传文件")
@PostMapping("/upload")
public FileUploadResp upload(@NotNull(message = "文件不能为空") MultipartFile file,
String parentPath) throws IOException {
ValidationUtils.throwIf(file::isEmpty, "文件不能为空");
FileInfo fileInfo = baseService.upload(file, parentPath);
return FileUploadResp.builder()
.id(fileInfo.getId())
.url(fileInfo.getUrl())
.thUrl(fileInfo.getThUrl())
.metadata(fileInfo.getMetadata())
.build();
}
@Operation(summary = "创建文件夹", description = "创建文件夹")
@SaCheckPermission("system:file:createDir")
@PostMapping("/dir")
public IdResp<Long> createDir(@RequestBody FileReq req) {
return new IdResp<>(baseService.createDir(req));
}
@Operation(summary = "计算文件夹大小", description = "计算文件夹大小")
@SaCheckPermission("system:file:calcDirSize")
@GetMapping("/dir/{id}/size")
public FileDirCalcSizeResp calcDirSize(@PathVariable Long id) {
return new FileDirCalcSizeResp(baseService.calcDirSize(id));
}
@Log(ignore = true) @Log(ignore = true)
@Operation(summary = "查询文件资源统计", description = "查询文件资源统计") @Operation(summary = "查询文件资源统计", description = "查询文件资源统计")
@SaCheckPermission("system:file:list") @SaCheckPermission("system:file:list")
@@ -59,10 +107,4 @@ public class FileController extends BaseController<FileService, FileResp, FileRe
public FileResp checkFile(String fileHash) { public FileResp checkFile(String fileHash) {
return baseService.check(fileHash); return baseService.check(fileHash);
} }
@Operation(summary = "创建文件夹", description = "创建文件夹")
@PostMapping("/dir")
public IdResp<Long> createDir(@RequestBody FileReq req) {
return new IdResp<>(baseService.createDir(req));
}
} }

View File

@@ -58,6 +58,8 @@ VALUES
(1114, '修改', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:update', 4, 1, 1, NOW()), (1114, '修改', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:update', 4, 1, 1, NOW()),
(1115, '删除', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:delete', 5, 1, 1, NOW()), (1115, '删除', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:delete', 5, 1, 1, NOW()),
(1116, '下载', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:download', 6, 1, 1, NOW()), (1116, '下载', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:download', 6, 1, 1, NOW()),
(1117, '创建文件夹', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:createDir', 7, 1, 1, NOW()),
(1118, '计算文件夹大小', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:calcDirSize', 8, 1, 1, NOW()),
(1130, '字典管理', 1000, 2, '/system/dict', 'SystemDict', 'system/dict/index', NULL, 'bookmark', b'0', b'0', b'0', NULL, 7, 1, 1, NOW()), (1130, '字典管理', 1000, 2, '/system/dict', 'SystemDict', 'system/dict/index', NULL, 'bookmark', b'0', b'0', b'0', NULL, 7, 1, 1, NOW()),
(1131, '列表', 1130, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:dict:list', 1, 1, 1, NOW()), (1131, '列表', 1130, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:dict:list', 1, 1, 1, NOW()),

View File

@@ -280,8 +280,9 @@ CREATE TABLE IF NOT EXISTS `sys_file` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID', `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`name` varchar(255) NOT NULL COMMENT '名称', `name` varchar(255) NOT NULL COMMENT '名称',
`original_name` varchar(255) NOT NULL COMMENT '原始名称', `original_name` varchar(255) NOT NULL COMMENT '原始名称',
`size` bigint(20) NOT NULL COMMENT '大小(字节)', `size` bigint(20) DEFAULT NULL COMMENT '大小(字节)',
`path` varchar(512) NOT NULL COMMENT '存储路径', `parent_path` varchar(512) NOT NULL DEFAULT '/' COMMENT '上级目录',
`path` varchar(512) NOT NULL COMMENT '路径',
`extension` varchar(32) DEFAULT NULL COMMENT '扩展名', `extension` varchar(32) DEFAULT NULL COMMENT '扩展名',
`content_type` varchar(255) DEFAULT NULL COMMENT '内容类型', `content_type` varchar(255) DEFAULT NULL COMMENT '内容类型',
`type` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '类型0: 目录1其他2图片3文档4视频5音频', `type` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '类型0: 目录1其他2图片3文档4视频5音频',

View File

@@ -58,6 +58,8 @@ VALUES
(1114, '修改', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:update', 4, 1, 1, NOW()), (1114, '修改', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:update', 4, 1, 1, NOW()),
(1115, '删除', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:delete', 5, 1, 1, NOW()), (1115, '删除', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:delete', 5, 1, 1, NOW()),
(1116, '下载', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:download', 6, 1, 1, NOW()), (1116, '下载', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:download', 6, 1, 1, NOW()),
(1117, '创建文件夹', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:createDir', 7, 1, 1, NOW()),
(1118, '计算文件夹大小', 1110, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:file:calcDirSize', 8, 1, 1, NOW()),
(1130, '字典管理', 1000, 2, '/system/dict', 'SystemDict', 'system/dict/index', NULL, 'bookmark', false, false, false, NULL, 7, 1, 1, NOW()), (1130, '字典管理', 1000, 2, '/system/dict', 'SystemDict', 'system/dict/index', NULL, 'bookmark', false, false, false, NULL, 7, 1, 1, NOW()),
(1131, '列表', 1130, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:dict:list', 1, 1, 1, NOW()), (1131, '列表', 1130, 3, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'system:dict:list', 1, 1, 1, NOW()),

View File

@@ -466,7 +466,8 @@ CREATE TABLE IF NOT EXISTS "sys_file" (
"id" int8 NOT NULL, "id" int8 NOT NULL,
"name" varchar(255) NOT NULL, "name" varchar(255) NOT NULL,
"original_name" varchar(255) NOT NULL, "original_name" varchar(255) NOT NULL,
"size" int8 NOT NULL, "size" int8 DEFAULT NULL,
"parent_path" varchar(512) NOT NULL DEFAULT '/',
"path" varchar(512) NOT NULL, "path" varchar(512) NOT NULL,
"extension" varchar(100) DEFAULT NULL, "extension" varchar(100) DEFAULT NULL,
"content_type" varchar(255) DEFAULT NULL, "content_type" varchar(255) DEFAULT NULL,
@@ -491,7 +492,8 @@ COMMENT ON COLUMN "sys_file"."id" IS 'ID';
COMMENT ON COLUMN "sys_file"."name" IS '名称'; COMMENT ON COLUMN "sys_file"."name" IS '名称';
COMMENT ON COLUMN "sys_file"."original_name" IS '原始名称'; COMMENT ON COLUMN "sys_file"."original_name" IS '原始名称';
COMMENT ON COLUMN "sys_file"."size" IS '大小(字节)'; COMMENT ON COLUMN "sys_file"."size" IS '大小(字节)';
COMMENT ON COLUMN "sys_file"."path" IS '存储路径'; COMMENT ON COLUMN "sys_file"."parent_path" IS '上级目录';
COMMENT ON COLUMN "sys_file"."path" IS '路径';
COMMENT ON COLUMN "sys_file"."extension" IS '扩展名'; COMMENT ON COLUMN "sys_file"."extension" IS '扩展名';
COMMENT ON COLUMN "sys_file"."content_type" IS '内容类型'; COMMENT ON COLUMN "sys_file"."content_type" IS '内容类型';
COMMENT ON COLUMN "sys_file"."type" IS '类型0: 目录1其他2图片3文档4视频5音频'; COMMENT ON COLUMN "sys_file"."type" IS '类型0: 目录1其他2图片3文档4视频5音频';