diff --git a/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java b/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java index bb22b817f..930edbd45 100644 --- a/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java +++ b/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java @@ -2,16 +2,19 @@ package cn.iocoder.yudao.module.infra.api.file; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.module.infra.api.file.dto.FileCreateReqDTO; +import cn.iocoder.yudao.module.infra.api.file.dto.FileCreateRespDTO; import cn.iocoder.yudao.module.infra.enums.ApiConstants; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +import java.io.InputStream; @FeignClient(name = ApiConstants.NAME) // TODO 芋艿:fallbackFactory = @Tag(name = "RPC 服务 - 文件") @@ -58,6 +61,26 @@ public interface FileApi { @Operation(summary = "保存文件,并返回文件的访问路径") CommonResult createFile(@Valid @RequestBody FileCreateReqDTO createReqDTO); + + default FileCreateRespDTO createFile(InputStream in, long size, String name, String directory, String type) { + org.springframework.core.io.Resource res = new KnownSizeInputStreamResource(in, name, size); + Long headerSize = (size >= 0 ? size : null); + return createFile(name, directory, type, headerSize, res).getCheckedData(); + } + + /** + * 流式上传文件(octet-stream),返回完整信息 + * 元数据通过 Header 传递:X-File-Name / X-File-Directory / X-File-Type / X-File-Size(可为 -1 或缺省) + */ + @PostMapping(value = PREFIX + "/createStream", consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE) + @Operation(summary = "保存文件(流式)并返回完整信息") + CommonResult createFile( + @RequestHeader("X-File-Name") String name, + @RequestHeader(value = "X-File-Directory", required = false) String directory, + @RequestHeader(value = "X-File-Type", required = false) String type, + @RequestHeader(value = "X-File-Size", required = false) Long size, + @RequestBody Resource body); + /** * 生成文件预签名地址,用于读取 * @@ -70,4 +93,30 @@ public interface FileApi { CommonResult presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url, Integer expirationSeconds); + /** + * 自定义 InputStreamResource: + * - 覆盖 contentLength(),避免默认实现去遍历读取导致耗尽流; + * - 提供 filename 以便服务端获取。 + */ + class KnownSizeInputStreamResource extends InputStreamResource { + private final String filename; + private final long size; // -1 表示未知 + + public KnownSizeInputStreamResource(InputStream inputStream, String filename, long size) { + super(inputStream); + this.filename = filename; + this.size = size; + } + + @Override + public String getFilename() { + return filename; + } + + @Override + public long contentLength() { + return size; + } // -1 => 走 chunked + } + } diff --git a/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/api/file/dto/FileCreateRespDTO.java b/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/api/file/dto/FileCreateRespDTO.java new file mode 100644 index 000000000..efe3a12a4 --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/api/file/dto/FileCreateRespDTO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.infra.api.file.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Schema(description = "RPC 服务 - 文件 Response VO,不返回 content 字段,太大") +@Data +public class FileCreateRespDTO implements Serializable { + + @Schema(description = "文件编号") + private Long id; + + @Schema(description = "文件编号",example = "1596380795168260097") + private Long groupId; + + @Schema(description = "配置编号",example = "19") + private Long configId; + + @Schema(description = "原文件名称",example = "1024.jpg") + private String name; + + @Schema(description = "文件路径",example = "6318848e882d8a7e7e82789d87608f684ee52d41966bfc8cad3ce15aad2b970e.jpg") + private String path; + + @Schema(description = "文件 URL",example = "https://www.yudao.com/yudao.jpg") + private String url; + + @Schema(description = "文件类型",example = "jpg") + private String type; + + @Schema(description = "文件大小",example = "2048") + private Long size; + + @Schema(description = "创建时间", example = "2023-10-01 12:00:00") + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java index 0e308386b..c17eb0830 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java @@ -2,11 +2,15 @@ package cn.iocoder.yudao.module.infra.api.file; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.module.infra.api.file.dto.FileCreateReqDTO; +import cn.iocoder.yudao.module.infra.api.file.dto.FileCreateRespDTO; +import cn.iocoder.yudao.module.infra.convert.file.FileConvert; +import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; import cn.iocoder.yudao.module.infra.service.file.FileService; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.RestController; - import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; @@ -28,4 +32,21 @@ public class FileApiImpl implements FileApi { return success(fileService.presignGetUrl(url, expirationSeconds)); } + @Override + public CommonResult createFile( + @RequestHeader("X-File-Name") String name, + @RequestHeader(value = "X-File-Directory", required = false) String directory, + @RequestHeader(value = "X-File-Type", required = false) String type, + @RequestHeader(value = "X-File-Size", required = false) Long size, + @RequestBody org.springframework.core.io.Resource body) { + try (java.io.InputStream in = body.getInputStream()) { + long len = (size != null ? size : -1L); + FileDO fileDO = fileService.createFile(in, len, name, directory, type); + FileCreateRespDTO resp = FileConvert.INSTANCE.convertCreateRespDTO(fileDO); + return success(resp); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java index a12ba3934..a3f2a22a6 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.infra.controller.admin.file; +import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.URLUtil; @@ -26,11 +27,13 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.nio.charset.StandardCharsets; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.util.List; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; -import static cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils.writeAttachment; @Tag(name = "管理后台 - 文件存储") @RestController @@ -43,12 +46,13 @@ public class FileController { private FileService fileService; @PostMapping("/upload") - @Operation(summary = "上传文件", description = "模式一:后端上传文件") + @Operation(summary = "上传文件", description = "模式一:后端上传文件(流式)") public CommonResult uploadFile(@Valid FileUploadReqVO uploadReqVO) throws Exception { MultipartFile file = uploadReqVO.getFile(); - byte[] content = IoUtil.readBytes(file.getInputStream()); - return success(fileService.createFile(content, file.getOriginalFilename(), - uploadReqVO.getDirectory(), file.getContentType())); + try (InputStream in = file.getInputStream()) { + FileDO fileDO = fileService.createFile(in, file.getSize(), file.getOriginalFilename(), uploadReqVO.getDirectory(), file.getContentType()); + return success(fileDO.getUrl()); + } } @GetMapping("/presigned-url") @@ -90,29 +94,101 @@ public class FileController { @GetMapping("/{configId}/get/**") @PermitAll @TenantIgnore - @Operation(summary = "下载文件") + @Operation(summary = "下载/预览文件(支持 Range/206)") @Parameter(name = "configId", description = "配置编号", required = true) public void getFileContent(HttpServletRequest request, HttpServletResponse response, @PathVariable("configId") Long configId) throws Exception { - // 获取请求的路径 + // 解析并校验路径 String path = StrUtil.subAfter(request.getRequestURI(), "/get/", false); if (StrUtil.isEmpty(path)) { - throw new IllegalArgumentException("结尾的 path 路径必须传递"); - } - // 解码,解决中文路径的问题 - // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/807/ - // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1432/ - path = URLUtil.decode(path, StandardCharsets.UTF_8, false); - - // 读取内容 - byte[] content = fileService.getFileContent(configId, path); - if (content == null) { - log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path); - response.setStatus(HttpStatus.NOT_FOUND.value()); + response.setStatus(HttpStatus.BAD_REQUEST.value()); return; } - writeAttachment(response, path, content); + // 解码,解决中文路径的问题 pulls/807/ + path = URLUtil.decode(path); + if (!isSafePathUnicode(path)) { + response.setStatus(HttpStatus.BAD_REQUEST.value()); + return; + } + + // 打开后端流(存在性校验) + try (InputStream in = fileService.openFileStream(configId, path)) { + if (in == null) { + log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path); + response.setStatus(HttpStatus.NOT_FOUND.value()); + return; + } + + // 基本信息:类型强兜底,避免变成 octet-stream + String fileName = StrUtil.subAfter(path, "/", true); + String contentType = resolveContentType(fileName); + Long total = fileService.getFileLength(configId, path); // 可能为 null(未知) + String range = request.getHeader("Range"); // 形如 bytes=START-END + + // 基础响应头 + response.setHeader("Accept-Ranges", "bytes"); + if (shouldInline(contentType)) { // 仅可预览类型才设置 inline + setInlineDisposition(response, fileName); + } + response.setContentType(contentType); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + + // HEAD 探测请求:仅回写头部 + if ("HEAD".equalsIgnoreCase(request.getMethod())) { + if (total != null) response.setHeader("Content-Length", String.valueOf(total)); + response.setStatus(HttpServletResponse.SC_OK); + return; + } + + // 多区间 Range:不支持 → 416 + if (StrUtil.isNotBlank(range) && range.contains(",") && total != null) { + response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); + response.setHeader("Content-Range", "bytes */" + total); + return; + } + + try { + // 支持 Range 且 total 已知:按 206 输出 + if (StrUtil.isNotBlank(range) && range.startsWith("bytes=") && total != null) { + long[] se = parseRange(range, total); + if (se == null) { // 无效 Range → 416 + response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); + response.setHeader("Content-Range", "bytes */" + total); + return; + } + long start = se[0], end = se[1]; + long len = end - start + 1; + + response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); + response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + total); + response.setHeader("Content-Length", String.valueOf(len)); + + skipFully(in, start); + copyNBytes(in, response.getOutputStream(), len); + response.flushBuffer(); + return; + } + + // 非 Range 或 total 未知:200 全量直传(若已知 total 则写 Content-Length) + if (total != null) { + response.setHeader("Content-Length", String.valueOf(total)); + } + IoUtil.copy(in, response.getOutputStream()); + response.flushBuffer(); + } catch (IOException e) { + // 客户端中断下载(浏览器刷新、离开页面等)不作为错误 + String cls = e.getClass().getName(); + String msg = e.getMessage(); + if ((cls != null && cls.contains("ClientAbortException")) + || (msg != null && (msg.contains("Broken pipe") || msg.contains("Connection reset")))) { + log.debug("[getFileContent] client aborted: {}", msg); + return; + } + throw e; + } + } } @GetMapping("/page") @@ -123,4 +199,149 @@ public class FileController { return success(BeanUtils.toBean(pageResult, FileRespVO.class)); } + // ================ 私有工具:安全校验 / Range 解析 / N 字节复制 / Content-Disposition / 类型兜底 ================ + /** + * 常见可在线预览的类型使用 inline,其余不设置 Content-Disposition(交给浏览器默认行为) + */ + private static boolean shouldInline(String contentType) { + if (contentType == null) return false; + return contentType.startsWith("image/") + || contentType.startsWith("video/") + || contentType.startsWith("audio/") + || contentType.startsWith("text/") + || "application/pdf".equalsIgnoreCase(contentType); + } + + /** + * 仅在需要预览时设置 inline;不要给非预览类型写 attachment + */ + private static void setInlineDisposition(HttpServletResponse resp, String fileName) { + String enc = URLUtil.encode(fileName); + resp.setHeader("Content-Disposition", + "inline; filename=\"" + enc + "\"; filename*=UTF-8''" + enc); + } + + /** + * 类型解析:先 Hutool,再按扩展名强兜底,避免落到 octet-stream 影响预览 + */ + private static String resolveContentType(String fileName) { + String ct = FileUtil.getMimeType(fileName); + if (StrUtil.isNotBlank(ct)) return ct; + + String ext = StrUtil.blankToDefault(FileUtil.extName(fileName), "").toLowerCase(); + return switch (ext) { + case "jpg", "jpeg" -> "image/jpeg"; + case "png" -> "image/png"; + case "gif" -> "image/gif"; + case "webp" -> "image/webp"; + case "svg" -> "image/svg+xml"; + case "bmp" -> "image/bmp"; + case "ico" -> "image/x-icon"; + case "pdf" -> "application/pdf"; + case "txt" -> "text/plain; charset=utf-8"; + case "css" -> "text/css; charset=utf-8"; + case "js" -> "application/javascript; charset=utf-8"; + case "mp4" -> "video/mp4"; + case "mp3" -> "audio/mpeg"; + default -> "application/octet-stream"; + }; + } + + /** + * 解析 Range: bytes=START-END / START- / -SUFFIX + */ + private static long[] parseRange(String rangeHeader, long total) { + try { + String v = StrUtil.removePrefix(rangeHeader.trim(), "bytes=").trim(); + String[] parts = v.split("-", 2); + String s0 = parts[0].trim(); + String s1 = parts.length > 1 ? parts[1].trim() : ""; + + long start, end; + if (s0.isEmpty()) { // "-500": 最后 500 字节 + long suffix = Long.parseLong(s1); + if (suffix <= 0) return null; + start = Math.max(0, total - suffix); + end = total - 1; + } else { + start = Long.parseLong(s0); + if (start < 0 || start >= total) return null; + if (s1.isEmpty()) { // "500-": 从 500 到结尾 + end = total - 1; + } else { // "500-999" + end = Long.parseLong(s1); + if (end < start) return null; + end = Math.min(end, total - 1); + } + } + return new long[]{start, end}; + } catch (Exception ignore) { + return null; + } + } + + /** + * 可靠地跳过指定字节数;若提前 EOF 则抛异常 + */ + private static void skipFully(InputStream in, long toSkip) throws IOException { + if (toSkip <= 0) return; + byte[] buffer = new byte[64 * 1024]; + long remaining = toSkip; + while (remaining > 0) { + long skipped = in.skip(remaining); + if (skipped > 0) { + remaining -= skipped; + continue; + } + // 某些流对 skip 支持不好:读一点丢弃,保证前进 + int read = in.read(buffer, 0, (int) Math.min(buffer.length, remaining)); + if (read < 0) { + throw new EOFException("Unexpected EOF while skipping " + toSkip + " bytes"); + } + remaining -= read; + } + } + + /** + * 允许任意 Unicode 字符(含中文、空格等),但禁止目录穿越、绝对路径、控制字符 + */ + private static boolean isSafePathUnicode(String raw) { + if (StrUtil.isBlank(raw)) return false; + + // 统一分隔符,拒绝绝对路径 + String p = raw.replace('\\', '/'); + if (p.startsWith("/")) return false; + + // 拒绝目录穿越和点路径(含边界场景) + if (p.equals(".") || p.equals("..")) return false; + if (p.startsWith("../") || p.endsWith("/..") || p.contains("/../") || p.contains("/./")) return false; + + // 拒绝空段与控制字符 + for (String seg : p.split("/")) { + if (seg.isEmpty() || ".".equals(seg) || "..".equals(seg)) return false; + for (int i = 0; i < seg.length(); i++) { + char c = seg.charAt(i); + if (Character.isISOControl(c) || c == '\u0000') return false; // 控制字符/空字节 + } + } + + // 可选:长度上限,避免恶意超长 + return p.length() <= 2048; + } + + /** + * 仅复制 N 字节到输出流 + */ + private static void copyNBytes(InputStream in, OutputStream out, long n) throws IOException { + byte[] buf = new byte[64 * 1024]; + long remain = n; + while (remain > 0) { + int toRead = (int) Math.min(buf.length, remain); + int read = in.read(buf, 0, toRead); + if (read < 0) break; + out.write(buf, 0, read); + remain -= read; + } + } + } diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileCreateReqVO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileCreateReqVO.java index 5daa3972e..72bcfd1de 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileCreateReqVO.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileCreateReqVO.java @@ -28,6 +28,6 @@ public class FileCreateReqVO { private String type; @Schema(description = "文件大小", example = "2048", requiredMode = Schema.RequiredMode.REQUIRED) - private Integer size; + private Long size; } diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileRespVO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileRespVO.java index a0357da15..17df49afb 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileRespVO.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileRespVO.java @@ -28,7 +28,7 @@ public class FileRespVO { private String type; @Schema(description = "文件大小", example = "2048", requiredMode = Schema.RequiredMode.REQUIRED) - private Integer size; + private Long size; @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime createTime; diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/convert/file/FileConvert.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/convert/file/FileConvert.java new file mode 100644 index 000000000..655a568e5 --- /dev/null +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/convert/file/FileConvert.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.infra.convert.file; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.infra.api.file.dto.FileCreateRespDTO; +import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileRespVO; +import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper +public interface FileConvert { + + FileConvert INSTANCE = Mappers.getMapper(FileConvert.class); + + FileRespVO convert(FileDO bean); + + PageResult convertPage(PageResult page); + + FileCreateRespDTO convertCreateRespDTO(FileDO bean); + + List convertList(List bean); +} diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileDO.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileDO.java index 5cb666ef5..721978893 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileDO.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileDO.java @@ -52,6 +52,6 @@ public class FileDO extends BaseDO { /** * 文件大小 */ - private Integer size; + private Long size; } diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/AbstractFileClient.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/AbstractFileClient.java index 3c7883b83..3f8de8f7b 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/AbstractFileClient.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/AbstractFileClient.java @@ -1,8 +1,11 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client; +import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.StrUtil; import lombok.extern.slf4j.Slf4j; +import java.io.InputStream; + /** * 文件客户端的抽象类,提供模板方法,减少子类的冗余代码 * @@ -66,4 +69,20 @@ public abstract class AbstractFileClient implem return StrUtil.format("{}/admin-api/infra/file/{}/get/{}", domain, getId(), path); } + @Override + public String upload(InputStream in, long length, String path, String type) throws Exception { + // 兼容退路:读满内存再走旧接口(后续各具体 Client 会覆盖为真正流式) + return upload(IoUtil.readBytes(in), path, type); + } + + @Override + public InputStream openStream(String path) throws Exception { + throw new UnsupportedOperationException("openStream not supported by default"); + } + + @Override + public Long getLength(String path) throws Exception { + return null; + } + } diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java index cf1cd620a..4e83c9a82 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java @@ -1,5 +1,7 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client; +import java.io.InputStream; + /** * 文件客户端 * @@ -24,6 +26,17 @@ public interface FileClient { */ String upload(byte[] content, String path, String type) throws Exception; + /** + * 上传文件(流式):优先由各 Client 覆盖实现。 + * 默认回退为读入内存再调用旧接口(兼容老实现)。 + * + * @param in 文件输入流(调用方负责关闭) + * @param length 内容长度;若无法预估,传 -1(部分存储需预估长度,建议尽量传真实长度) + * @param path 目标路径(相对路径) + * @param type Content-Type + */ + String upload(InputStream in, long length, String path, String type) throws Exception; + /** * 删除文件 * @@ -40,6 +53,18 @@ public interface FileClient { */ byte[] getContent(String path) throws Exception; + /** + * 打开对象为流:优先由各 Client 覆盖实现。 + * 默认回退为把字节包装为 ByteArrayInputStream(兼容老实现)。 + */ + InputStream openStream(String path) throws Exception; + + /** + * 获取对象长度(字节数),用于后续 Range/Content-Length。 + * 不支持可返回 null。 + */ + Long getLength(String path) throws Exception; + // ========== 文件签名,目前仅 S3 支持 ========== /** diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClient.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClient.java index 0a050d325..53522b3c6 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClient.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/db/DBFileClient.java @@ -1,11 +1,14 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client.db; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.IoUtil; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileContentDO; import cn.iocoder.yudao.module.infra.dal.mysql.file.FileContentMapper; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; +import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.util.Comparator; import java.util.List; @@ -36,6 +39,12 @@ public class DBFileClient extends AbstractFileClient { return super.formatFileUrl(config.getDomain(), path); } + @Override + public String upload(InputStream in, long size, String path, String type) throws Exception { + byte[] content = IoUtil.readBytes(in); + return upload(content, path, type); + } + @Override public void delete(String path) { fileContentMapper.deleteByConfigIdAndPath(getId(), path); @@ -52,4 +61,16 @@ public class DBFileClient extends AbstractFileClient { return CollUtil.getLast(list).getContent(); } + @Override + public InputStream openStream(String path) { + byte[] bytes = getContent(path); + return (bytes == null) ? null : new ByteArrayInputStream(bytes); + } + + @Override + public Long getLength(String path) { + // 后续改成仅查长度;当前返回 null,交由上层按“未知长度”处理 + return null; + } + } diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClient.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClient.java index 93bff27ec..c85ca0011 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClient.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/ftp/FtpFileClient.java @@ -8,9 +8,11 @@ import cn.hutool.extra.ftp.FtpConfig; import cn.hutool.extra.ftp.FtpException; import cn.hutool.extra.ftp.FtpMode; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; +import org.apache.commons.net.ftp.FTP; +import org.apache.commons.net.ftp.FTPClient; +import org.apache.commons.net.ftp.FTPFile; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; +import java.io.*; /** * Ftp 文件客户端 @@ -59,6 +61,17 @@ public class FtpFileClient extends AbstractFileClient { return super.formatFileUrl(config.getDomain(), path); } + @Override + public String upload(InputStream in, long size, String path, String type) { + String filePath = config.getBasePath() + path; + String fileName = FileUtil.getName(filePath); + String dir = StrUtil.removeSuffix(filePath, fileName); + ftp.reconnectIfTimeout(); + ftp.mkDirs(dir); + ftp.upload(dir, fileName, in); + return formatFileUrl(config.getDomain(), path); + } + @Override public void delete(String path) { String filePath = getFilePath(path); @@ -77,10 +90,39 @@ public class FtpFileClient extends AbstractFileClient { return out.toByteArray(); } + @Override + public InputStream openStream(String path) throws IOException { + String filePath = config.getBasePath() + path; + ftp.reconnectIfTimeout(); + FTPClient client = ftp.getClient(); + client.setFileType(FTP.BINARY_FILE_TYPE); + InputStream raw = client.retrieveFileStream(filePath); + if (raw == null) return null; + return new FilterInputStream(raw) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + client.completePendingCommand(); + } + } + }; + } + private String getFilePath(String path) { return config.getBasePath() + StrUtil.SLASH + path; } + @Override + public Long getLength(String path) throws IOException { + String filePath = config.getBasePath() + path; + ftp.reconnectIfTimeout(); + FTPClient client = ftp.getClient(); + FTPFile[] fs = client.listFiles(filePath); + return (fs != null && fs.length == 1) ? fs[0].getSize() : null; + } + private synchronized void reconnectIfTimeout() { ftp.reconnectIfTimeout(); } diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java index 6e5c0229b..cb3d5b3b5 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java @@ -2,9 +2,10 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client.local; import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.IORuntimeException; +import cn.hutool.core.io.IoUtil; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; -import java.io.File; +import java.io.*; /** * 本地文件客户端 @@ -30,6 +31,17 @@ public class LocalFileClient extends AbstractFileClient { return super.formatFileUrl(config.getDomain(), path); } + @Override + public String upload(InputStream in, long size, String path, String type) throws Exception { + String filePath = config.getBasePath() + path; + File target = new File(filePath); + FileUtil.mkParentDirs(target); + try (var out = new BufferedOutputStream(new FileOutputStream(target))) { + IoUtil.copy(in, out); + } + return formatFileUrl(config.getDomain(), path); + } + @Override public void delete(String path) { String filePath = getFilePath(path); @@ -49,6 +61,20 @@ public class LocalFileClient extends AbstractFileClient { } } + @Override + public InputStream openStream(String path) throws Exception { + String filePath = config.getBasePath() + path; + File file = new File(filePath); + if (!file.exists()) return null; + return new BufferedInputStream(FileUtil.getInputStream(file)); + } + + @Override + public Long getLength(String path) { + String filePath = config.getBasePath() + path; + return new File(filePath).length(); + } + private String getFilePath(String path) { return config.getBasePath() + File.separator + path; } diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java index 8e7f74611..c5b4da68c 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client.s3; +import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.StrUtil; @@ -13,15 +14,16 @@ import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Configuration; -import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.*; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import java.io.*; import java.net.URI; import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.Duration; /** @@ -84,6 +86,49 @@ public class S3FileClient extends AbstractFileClient { return presignGetUrl(path, null); } + @Override + public String upload(InputStream in, long size, String path, String type) throws Exception { + // 1) 请求头 + PutObjectRequest.Builder b = PutObjectRequest.builder() + .bucket(config.getBucket()) + .key(path); + + // Content-Type 兜底:优先入参;其次按扩展名推断;再兜底 octet-stream + String ct = type; + if (StrUtil.isBlank(ct)) { + String filename = path.substring(path.lastIndexOf('/') + 1); + ct = FileUtil.getMimeType(filename); + if (StrUtil.isBlank(ct)) ct = "application/octet-stream"; + } + b.contentType(ct); + + // 可预览类型显式 inline(避免某些代理默认 attachment) + if (shouldInline(ct)) { + String filename = path.substring(path.lastIndexOf('/') + 1); + String enc = urlEncodeUtf8(filename); + b.contentDisposition("inline; filename=\"" + enc + "\"; filename*=UTF-8''" + enc); + } + + PutObjectRequest req = b.build(); + + // 2) Body:v2 的 RequestBody.fromInputStream 需要已知长度;未知长度时本地临时落盘测长 + if (size >= 0) { + client.putObject(req, RequestBody.fromInputStream(in, size)); + } else { + File tmp = File.createTempFile("s3-upload-", ".bin"); + try (OutputStream out = new BufferedOutputStream(new FileOutputStream(tmp))) { + IoUtil.copy(in, out); + } + try (InputStream tmpIn = new FileInputStream(tmp)) { + client.putObject(req, RequestBody.fromInputStream(tmpIn, tmp.length())); + } finally { + //noinspection ResultOfMethodCallIgnored + tmp.delete(); + } + } + return formatUrl(path); + } + @Override public void delete(String path) { DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() @@ -102,6 +147,32 @@ public class S3FileClient extends AbstractFileClient { return IoUtil.readBytes(client.getObject(getRequest)); } + @Override + public InputStream openStream(String path) { + try { + // v2 直接返回 ResponseInputStream,可当作 InputStream 使用;上层会负责关闭 + return client.getObject(GetObjectRequest.builder() + .bucket(config.getBucket()) + .key(path) + .build()); + } catch (S3Exception e) { + return null; + } + } + + @Override + public Long getLength(String path) { + try { + HeadObjectResponse head = client.headObject(HeadObjectRequest.builder() + .bucket(config.getBucket()) + .key(path) + .build()); + return head.contentLength(); + } catch (S3Exception e) { + return null; + } + } + @Override public String presignPutUrl(String path) { return presigner.presignPutObject(PutObjectPresignRequest.builder() @@ -159,4 +230,21 @@ public class S3FileClient extends AbstractFileClient { return StrUtil.format("https://{}", config.getEndpoint()); } + private String formatUrl(String path) { + String domain = config.getDomain(); + return (domain == null || domain.isEmpty()) + ? ("/" + path) + : (StrUtil.removeSuffix(domain, "/") + "/" + path); + } + + private static String urlEncodeUtf8(String s) { + return URLEncoder.encode(s, StandardCharsets.UTF_8); + } + + private static boolean shouldInline(String contentType) { + if (contentType == null) return false; + String ct = contentType.toLowerCase(); + return ct.startsWith("image/") || ct.startsWith("video/") || ct.startsWith("audio/") || ct.startsWith("text/") || "application/pdf".equals(ct); + } + } diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClient.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClient.java index 788325f6c..11c8812dc 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClient.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/sftp/SftpFileClient.java @@ -9,8 +9,10 @@ import cn.hutool.extra.ssh.Sftp; import cn.iocoder.yudao.framework.common.util.io.FileUtils; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; import com.jcraft.jsch.JSch; +import com.jcraft.jsch.SftpException; import java.io.File; +import java.io.InputStream; /** * Sftp 文件客户端 @@ -66,6 +68,18 @@ public class SftpFileClient extends AbstractFileClient { return super.formatFileUrl(config.getDomain(), path); } + @Override + public String upload(InputStream in, long size, String path, String type) { + String remote = remotePath(path); + String dir = remote.substring(0, remote.lastIndexOf('/')); + + sftp.reconnectIfTimeout(); + sftp.mkDirs(dir); + // Hutool 的流式上传:目标必须为完整“文件路径” + sftp.put(in, remote, null, Sftp.Mode.OVERWRITE); + return formatFileUrl(config.getDomain(), path); + } + @Override public void delete(String path) { String filePath = getFilePath(path); @@ -82,6 +96,20 @@ public class SftpFileClient extends AbstractFileClient { return FileUtil.readBytes(destFile); } + @Override + public InputStream openStream(String path) throws SftpException { + String remote = remotePath(path); + sftp.reconnectIfTimeout(); + // 直接拿 JSch 的流(由调用方关闭) + return sftp.getClient().get(remote); + } + + @Override + public Long getLength(String path) throws SftpException { + String remote = remotePath(path); + return sftp.getClient().stat(remote).getSize(); + } + private String getFilePath(String path) { return config.getBasePath() + File.separator + path; } @@ -90,4 +118,20 @@ public class SftpFileClient extends AbstractFileClient { sftp.reconnectIfTimeout(); } + private String normalize(String p) { + if (p == null) return "/"; + String s = p.replace('\\', '/'); + if (!s.startsWith("/")) s = "/" + s; + return FileUtil.normalize(s); + } + + private String remotePath(String path) { + String base = normalize(config.getBasePath()); + String rel = normalize(path); + // 去掉重复的根斜杠拼接 + return FileUtil.normalize( + (base.endsWith("/") ? base.substring(0, base.length() - 1) : base) + rel + ); + } + } diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImpl.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImpl.java index 4e3bf8004..53a6075f8 100755 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImpl.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImpl.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.infra.service.file; import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.lang.Assert; import cn.hutool.core.util.IdUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; @@ -24,6 +25,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; +import java.io.InputStream; import java.time.Duration; import java.util.List; import java.util.Map; @@ -189,9 +191,13 @@ public class FileConfigServiceImpl implements FileConfigService { public String testFileConfig(Long id) throws Exception { // 校验存在 validateFileConfigExists(id); - // 上传文件 - byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); - return getFileClient(id).upload(content, IdUtil.fastSimpleUUID() + ".jpg", "image/jpeg"); + // 上传文件(流式) + try (InputStream in = ResourceUtil.getStream("file/erweima.jpg")) { + Assert.notNull(in, "测试文件不存在: file/erweima.jpg"); + String name = IdUtil.fastSimpleUUID() + ".jpg"; + // 大小未知就传 -1,走 chunked + return getFileClient(id).upload(in, -1, name, "image/jpeg"); + } } @Override diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java index 5e3448b0f..19da7bec8 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java @@ -6,7 +6,9 @@ import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; import jakarta.validation.constraints.NotEmpty; +import lombok.SneakyThrows; +import java.io.InputStream; import java.util.List; /** @@ -36,6 +38,19 @@ public interface FileService { String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, String name, String directory, String type); + /** + * 保存文件流,并返回文件的全信息(流式) + * + * @param in 输入流 + * @param size 文件大小,允许 -1,表示未知 + * @param name 文件名称,允许空 + * @param directory 目录,允许空 + * @param type 文件的 MIME 类型,允许空 + * @return 文件信息 + */ + @SneakyThrows + FileDO createFile(InputStream in, long size, String name, String directory, String type); + /** * 生成文件预签名地址信息,用于上传 * @@ -85,4 +100,10 @@ public interface FileService { */ byte[] getFileContent(Long configId, String path) throws Exception; + @SneakyThrows + InputStream openFileStream(Long configId, String path); + + @SneakyThrows + Long getFileLength(Long configId, String path); + } diff --git a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java index f47275d33..e2910a81d 100644 --- a/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java +++ b/yudao-module-infra/yudao-module-infra-server/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.infra.service.file; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.io.FileUtil; import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; @@ -20,6 +21,7 @@ import jakarta.annotation.Resource; import lombok.SneakyThrows; import org.springframework.stereotype.Service; +import java.io.InputStream; import java.util.List; import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; @@ -88,10 +90,55 @@ public class FileServiceImpl implements FileService { // 3. 保存到数据库 fileMapper.insert(new FileDO().setConfigId(client.getId()) .setName(name).setPath(path).setUrl(url) - .setType(type).setSize(content.length)); + .setType(type).setSize((long) content.length)); return url; } + @SneakyThrows + @Override + public FileDO createFile(InputStream in, long size, String name, String directory, String type) { + // 1) type & name 补全(不读取流) + if (StrUtil.isEmpty(type) && StrUtil.isNotEmpty(name)) { + type = FileUtil.getMimeType(name); // 基于扩展名推断,可能为 null + } + if (StrUtil.isEmpty(type)) { + type = "application/octet-stream"; + } + if (StrUtil.isEmpty(name)) { + name = "file_" + System.currentTimeMillis() + "_" + RandomUtil.randomString(6); + } + if (StrUtil.isEmpty(FileUtil.extName(name)) && StrUtil.isNotEmpty(type)) { + String extension = FileTypeUtils.getExtension(type); + if (StrUtil.isNotEmpty(extension)) { + name = name + extension; + } + } + + // 2) 生成 path & 上传(流式) + String path = generateUploadPath(name, directory); + FileClient client = fileConfigService.getMasterFileClient(); + Assert.notNull(client, "客户端(master) 不能为空"); + String url = client.upload(in, size, path, type); + + // 3) 归一化 size:未知或非法 -> 0(适配 BIGINT UNSIGNED NOT NULL DEFAULT 0) + Long finalSize = (size >= 0) ? size : null; + if (finalSize == null) { + try { + finalSize = client.getLength(path); // 能探测则回填 + } catch (Exception ignored) { + } + } + if (finalSize == null || finalSize < 0) { + finalSize = 0L; + } + + FileDO fileDO = new FileDO().setConfigId(client.getId()) + .setName(name).setPath(path).setUrl(url) + .setType(type).setSize(finalSize); + fileMapper.insert(fileDO); + return fileDO; + } + @VisibleForTesting String generateUploadPath(String name, String directory) { // 1. 生成前缀、后缀 @@ -198,4 +245,20 @@ public class FileServiceImpl implements FileService { return client.getContent(path); } + @SneakyThrows + @Override + public InputStream openFileStream(Long configId, String path) { + FileClient client = fileConfigService.getFileClient(configId); + Assert.notNull(client, "客户端({}) 不能为空", configId); + return client.openStream(path); + } + + @SneakyThrows + @Override + public Long getFileLength(Long configId, String path) { + FileClient client = fileConfigService.getFileClient(configId); + Assert.notNull(client, "客户端({}) 不能为空", configId); + return client.getLength(path); + } + }