Pre Merge pull request !212 from egd/feat-fileServerStreamIO
commit
f597f8bd90
|
|
@ -2,16 +2,19 @@ package cn.iocoder.yudao.module.infra.api.file;
|
||||||
|
|
||||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
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.FileCreateReqDTO;
|
||||||
|
import cn.iocoder.yudao.module.infra.api.file.dto.FileCreateRespDTO;
|
||||||
import cn.iocoder.yudao.module.infra.enums.ApiConstants;
|
import cn.iocoder.yudao.module.infra.enums.ApiConstants;
|
||||||
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.Valid;
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
import org.springframework.cloud.openfeign.FeignClient;
|
import org.springframework.cloud.openfeign.FeignClient;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.core.io.InputStreamResource;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
@FeignClient(name = ApiConstants.NAME) // TODO 芋艿:fallbackFactory =
|
@FeignClient(name = ApiConstants.NAME) // TODO 芋艿:fallbackFactory =
|
||||||
@Tag(name = "RPC 服务 - 文件")
|
@Tag(name = "RPC 服务 - 文件")
|
||||||
|
|
@ -58,6 +61,26 @@ public interface FileApi {
|
||||||
@Operation(summary = "保存文件,并返回文件的访问路径")
|
@Operation(summary = "保存文件,并返回文件的访问路径")
|
||||||
CommonResult<String> createFile(@Valid @RequestBody FileCreateReqDTO createReqDTO);
|
CommonResult<String> 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<FileCreateRespDTO> 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<String> presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url,
|
CommonResult<String> presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url,
|
||||||
Integer expirationSeconds);
|
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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -2,11 +2,15 @@ package cn.iocoder.yudao.module.infra.api.file;
|
||||||
|
|
||||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
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.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 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 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;
|
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));
|
return success(fileService.presignGetUrl(url, expirationSeconds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommonResult<FileCreateRespDTO> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package cn.iocoder.yudao.module.infra.controller.admin.file;
|
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.io.IoUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import cn.hutool.core.util.URLUtil;
|
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.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
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 java.util.List;
|
||||||
|
|
||||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
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 = "管理后台 - 文件存储")
|
@Tag(name = "管理后台 - 文件存储")
|
||||||
@RestController
|
@RestController
|
||||||
|
|
@ -43,12 +46,13 @@ public class FileController {
|
||||||
private FileService fileService;
|
private FileService fileService;
|
||||||
|
|
||||||
@PostMapping("/upload")
|
@PostMapping("/upload")
|
||||||
@Operation(summary = "上传文件", description = "模式一:后端上传文件")
|
@Operation(summary = "上传文件", description = "模式一:后端上传文件(流式)")
|
||||||
public CommonResult<String> uploadFile(@Valid FileUploadReqVO uploadReqVO) throws Exception {
|
public CommonResult<String> uploadFile(@Valid FileUploadReqVO uploadReqVO) throws Exception {
|
||||||
MultipartFile file = uploadReqVO.getFile();
|
MultipartFile file = uploadReqVO.getFile();
|
||||||
byte[] content = IoUtil.readBytes(file.getInputStream());
|
try (InputStream in = file.getInputStream()) {
|
||||||
return success(fileService.createFile(content, file.getOriginalFilename(),
|
FileDO fileDO = fileService.createFile(in, file.getSize(), file.getOriginalFilename(), uploadReqVO.getDirectory(), file.getContentType());
|
||||||
uploadReqVO.getDirectory(), file.getContentType()));
|
return success(fileDO.getUrl());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/presigned-url")
|
@GetMapping("/presigned-url")
|
||||||
|
|
@ -90,29 +94,101 @@ public class FileController {
|
||||||
@GetMapping("/{configId}/get/**")
|
@GetMapping("/{configId}/get/**")
|
||||||
@PermitAll
|
@PermitAll
|
||||||
@TenantIgnore
|
@TenantIgnore
|
||||||
@Operation(summary = "下载文件")
|
@Operation(summary = "下载/预览文件(支持 Range/206)")
|
||||||
@Parameter(name = "configId", description = "配置编号", required = true)
|
@Parameter(name = "configId", description = "配置编号", required = true)
|
||||||
public void getFileContent(HttpServletRequest request,
|
public void getFileContent(HttpServletRequest request,
|
||||||
HttpServletResponse response,
|
HttpServletResponse response,
|
||||||
@PathVariable("configId") Long configId) throws Exception {
|
@PathVariable("configId") Long configId) throws Exception {
|
||||||
// 获取请求的路径
|
// 解析并校验路径
|
||||||
String path = StrUtil.subAfter(request.getRequestURI(), "/get/", false);
|
String path = StrUtil.subAfter(request.getRequestURI(), "/get/", false);
|
||||||
if (StrUtil.isEmpty(path)) {
|
if (StrUtil.isEmpty(path)) {
|
||||||
throw new IllegalArgumentException("结尾的 path 路径必须传递");
|
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
||||||
}
|
|
||||||
// 解码,解决中文路径的问题
|
|
||||||
// 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());
|
|
||||||
return;
|
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")
|
@GetMapping("/page")
|
||||||
|
|
@ -123,4 +199,149 @@ public class FileController {
|
||||||
return success(BeanUtils.toBean(pageResult, FileRespVO.class));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,6 @@ public class FileCreateReqVO {
|
||||||
private String type;
|
private String type;
|
||||||
|
|
||||||
@Schema(description = "文件大小", example = "2048", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "文件大小", example = "2048", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private Integer size;
|
private Long size;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public class FileRespVO {
|
||||||
private String type;
|
private String type;
|
||||||
|
|
||||||
@Schema(description = "文件大小", example = "2048", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "文件大小", example = "2048", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private Integer size;
|
private Long size;
|
||||||
|
|
||||||
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
|
||||||
|
|
@ -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<FileRespVO> convertPage(PageResult<FileDO> page);
|
||||||
|
|
||||||
|
FileCreateRespDTO convertCreateRespDTO(FileDO bean);
|
||||||
|
|
||||||
|
List<FileCreateRespDTO> convertList(List<FileDO> bean);
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,6 @@ public class FileDO extends BaseDO {
|
||||||
/**
|
/**
|
||||||
* 文件大小
|
* 文件大小
|
||||||
*/
|
*/
|
||||||
private Integer size;
|
private Long size;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package cn.iocoder.yudao.module.infra.framework.file.core.client;
|
package cn.iocoder.yudao.module.infra.framework.file.core.client;
|
||||||
|
|
||||||
|
import cn.hutool.core.io.IoUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件客户端的抽象类,提供模板方法,减少子类的冗余代码
|
* 文件客户端的抽象类,提供模板方法,减少子类的冗余代码
|
||||||
*
|
*
|
||||||
|
|
@ -66,4 +69,20 @@ public abstract class AbstractFileClient<Config extends FileClientConfig> implem
|
||||||
return StrUtil.format("{}/admin-api/infra/file/{}/get/{}", domain, getId(), path);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package cn.iocoder.yudao.module.infra.framework.file.core.client;
|
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;
|
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;
|
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 支持 ==========
|
// ========== 文件签名,目前仅 S3 支持 ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
package cn.iocoder.yudao.module.infra.framework.file.core.client.db;
|
package cn.iocoder.yudao.module.infra.framework.file.core.client.db;
|
||||||
|
|
||||||
import cn.hutool.core.collection.CollUtil;
|
import cn.hutool.core.collection.CollUtil;
|
||||||
|
import cn.hutool.core.io.IoUtil;
|
||||||
import cn.hutool.extra.spring.SpringUtil;
|
import cn.hutool.extra.spring.SpringUtil;
|
||||||
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileContentDO;
|
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.dal.mysql.file.FileContentMapper;
|
||||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
|
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.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
|
@ -36,6 +39,12 @@ public class DBFileClient extends AbstractFileClient<DBFileClientConfig> {
|
||||||
return super.formatFileUrl(config.getDomain(), path);
|
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
|
@Override
|
||||||
public void delete(String path) {
|
public void delete(String path) {
|
||||||
fileContentMapper.deleteByConfigIdAndPath(getId(), path);
|
fileContentMapper.deleteByConfigIdAndPath(getId(), path);
|
||||||
|
|
@ -52,4 +61,16 @@ public class DBFileClient extends AbstractFileClient<DBFileClientConfig> {
|
||||||
return CollUtil.getLast(list).getContent();
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import cn.hutool.extra.ftp.FtpConfig;
|
||||||
import cn.hutool.extra.ftp.FtpException;
|
import cn.hutool.extra.ftp.FtpException;
|
||||||
import cn.hutool.extra.ftp.FtpMode;
|
import cn.hutool.extra.ftp.FtpMode;
|
||||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
|
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.*;
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ftp 文件客户端
|
* Ftp 文件客户端
|
||||||
|
|
@ -59,6 +61,17 @@ public class FtpFileClient extends AbstractFileClient<FtpFileClientConfig> {
|
||||||
return super.formatFileUrl(config.getDomain(), path);
|
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
|
@Override
|
||||||
public void delete(String path) {
|
public void delete(String path) {
|
||||||
String filePath = getFilePath(path);
|
String filePath = getFilePath(path);
|
||||||
|
|
@ -77,10 +90,39 @@ public class FtpFileClient extends AbstractFileClient<FtpFileClientConfig> {
|
||||||
return out.toByteArray();
|
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) {
|
private String getFilePath(String path) {
|
||||||
return config.getBasePath() + StrUtil.SLASH + 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() {
|
private synchronized void reconnectIfTimeout() {
|
||||||
ftp.reconnectIfTimeout();
|
ftp.reconnectIfTimeout();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.FileUtil;
|
||||||
import cn.hutool.core.io.IORuntimeException;
|
import cn.hutool.core.io.IORuntimeException;
|
||||||
|
import cn.hutool.core.io.IoUtil;
|
||||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
|
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<LocalFileClientConfig> {
|
||||||
return super.formatFileUrl(config.getDomain(), path);
|
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
|
@Override
|
||||||
public void delete(String path) {
|
public void delete(String path) {
|
||||||
String filePath = getFilePath(path);
|
String filePath = getFilePath(path);
|
||||||
|
|
@ -49,6 +61,20 @@ public class LocalFileClient extends AbstractFileClient<LocalFileClientConfig> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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) {
|
private String getFilePath(String path) {
|
||||||
return config.getBasePath() + File.separator + path;
|
return config.getBasePath() + File.separator + path;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package cn.iocoder.yudao.module.infra.framework.file.core.client.s3;
|
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.io.IoUtil;
|
||||||
import cn.hutool.core.util.BooleanUtil;
|
import cn.hutool.core.util.BooleanUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
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.regions.Region;
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
import software.amazon.awssdk.services.s3.model.*;
|
||||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
|
||||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
|
||||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
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.GetObjectPresignRequest;
|
||||||
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
|
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -84,6 +86,49 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||||
return presignGetUrl(path, null);
|
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
|
@Override
|
||||||
public void delete(String path) {
|
public void delete(String path) {
|
||||||
DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
|
DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
|
||||||
|
|
@ -102,6 +147,32 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||||
return IoUtil.readBytes(client.getObject(getRequest));
|
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
|
@Override
|
||||||
public String presignPutUrl(String path) {
|
public String presignPutUrl(String path) {
|
||||||
return presigner.presignPutObject(PutObjectPresignRequest.builder()
|
return presigner.presignPutObject(PutObjectPresignRequest.builder()
|
||||||
|
|
@ -159,4 +230,21 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||||
return StrUtil.format("https://{}", config.getEndpoint());
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@ import cn.hutool.extra.ssh.Sftp;
|
||||||
import cn.iocoder.yudao.framework.common.util.io.FileUtils;
|
import cn.iocoder.yudao.framework.common.util.io.FileUtils;
|
||||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
|
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
|
||||||
import com.jcraft.jsch.JSch;
|
import com.jcraft.jsch.JSch;
|
||||||
|
import com.jcraft.jsch.SftpException;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sftp 文件客户端
|
* Sftp 文件客户端
|
||||||
|
|
@ -66,6 +68,18 @@ public class SftpFileClient extends AbstractFileClient<SftpFileClientConfig> {
|
||||||
return super.formatFileUrl(config.getDomain(), path);
|
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
|
@Override
|
||||||
public void delete(String path) {
|
public void delete(String path) {
|
||||||
String filePath = getFilePath(path);
|
String filePath = getFilePath(path);
|
||||||
|
|
@ -82,6 +96,20 @@ public class SftpFileClient extends AbstractFileClient<SftpFileClientConfig> {
|
||||||
return FileUtil.readBytes(destFile);
|
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) {
|
private String getFilePath(String path) {
|
||||||
return config.getBasePath() + File.separator + path;
|
return config.getBasePath() + File.separator + path;
|
||||||
}
|
}
|
||||||
|
|
@ -90,4 +118,20 @@ public class SftpFileClient extends AbstractFileClient<SftpFileClientConfig> {
|
||||||
sftp.reconnectIfTimeout();
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package cn.iocoder.yudao.module.infra.service.file;
|
package cn.iocoder.yudao.module.infra.service.file;
|
||||||
|
|
||||||
import cn.hutool.core.io.resource.ResourceUtil;
|
import cn.hutool.core.io.resource.ResourceUtil;
|
||||||
|
import cn.hutool.core.lang.Assert;
|
||||||
import cn.hutool.core.util.IdUtil;
|
import cn.hutool.core.util.IdUtil;
|
||||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
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.transaction.annotation.Transactional;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
@ -189,9 +191,13 @@ public class FileConfigServiceImpl implements FileConfigService {
|
||||||
public String testFileConfig(Long id) throws Exception {
|
public String testFileConfig(Long id) throws Exception {
|
||||||
// 校验存在
|
// 校验存在
|
||||||
validateFileConfigExists(id);
|
validateFileConfigExists(id);
|
||||||
// 上传文件
|
// 上传文件(流式)
|
||||||
byte[] content = ResourceUtil.readBytes("file/erweima.jpg");
|
try (InputStream in = ResourceUtil.getStream("file/erweima.jpg")) {
|
||||||
return getFileClient(id).upload(content, IdUtil.fastSimpleUUID() + ".jpg", "image/jpeg");
|
Assert.notNull(in, "测试文件不存在: file/erweima.jpg");
|
||||||
|
String name = IdUtil.fastSimpleUUID() + ".jpg";
|
||||||
|
// 大小未知就传 -1,走 chunked
|
||||||
|
return getFileClient(id).upload(in, -1, name, "image/jpeg");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
||||||
|
|
@ -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.controller.admin.file.vo.file.FilePresignedUrlRespVO;
|
||||||
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
|
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import lombok.SneakyThrows;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -36,6 +38,19 @@ public interface FileService {
|
||||||
String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
|
String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
|
||||||
String name, String directory, String type);
|
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;
|
byte[] getFileContent(Long configId, String path) throws Exception;
|
||||||
|
|
||||||
|
@SneakyThrows
|
||||||
|
InputStream openFileStream(Long configId, String path);
|
||||||
|
|
||||||
|
@SneakyThrows
|
||||||
|
Long getFileLength(Long configId, String path);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.infra.service.file;
|
||||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||||
import cn.hutool.core.io.FileUtil;
|
import cn.hutool.core.io.FileUtil;
|
||||||
import cn.hutool.core.lang.Assert;
|
import cn.hutool.core.lang.Assert;
|
||||||
|
import cn.hutool.core.util.RandomUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import cn.hutool.crypto.digest.DigestUtil;
|
import cn.hutool.crypto.digest.DigestUtil;
|
||||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||||
|
|
@ -20,6 +21,7 @@ import jakarta.annotation.Resource;
|
||||||
import lombok.SneakyThrows;
|
import lombok.SneakyThrows;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN;
|
import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN;
|
||||||
|
|
@ -88,10 +90,55 @@ public class FileServiceImpl implements FileService {
|
||||||
// 3. 保存到数据库
|
// 3. 保存到数据库
|
||||||
fileMapper.insert(new FileDO().setConfigId(client.getId())
|
fileMapper.insert(new FileDO().setConfigId(client.getId())
|
||||||
.setName(name).setPath(path).setUrl(url)
|
.setName(name).setPath(path).setUrl(url)
|
||||||
.setType(type).setSize(content.length));
|
.setType(type).setSize((long) content.length));
|
||||||
return url;
|
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
|
@VisibleForTesting
|
||||||
String generateUploadPath(String name, String directory) {
|
String generateUploadPath(String name, String directory) {
|
||||||
// 1. 生成前缀、后缀
|
// 1. 生成前缀、后缀
|
||||||
|
|
@ -198,4 +245,20 @@ public class FileServiceImpl implements FileService {
|
||||||
return client.getContent(path);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue