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.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<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,
|
||||
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.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<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;
|
||||
|
||||
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<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,6 @@ public class FileCreateReqVO {
|
|||
private String type;
|
||||
|
||||
@Schema(description = "文件大小", example = "2048", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer size;
|
||||
private Long size;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
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<Config extends FileClientConfig> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 支持 ==========
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<DBFileClientConfig> {
|
|||
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<DBFileClientConfig> {
|
|||
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.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<FtpFileClientConfig> {
|
|||
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<FtpFileClientConfig> {
|
|||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LocalFileClientConfig> {
|
|||
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<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) {
|
||||
return config.getBasePath() + File.separator + path;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<S3FileClientConfig> {
|
|||
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<S3FileClientConfig> {
|
|||
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<S3FileClientConfig> {
|
|||
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.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<SftpFileClientConfig> {
|
|||
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<SftpFileClientConfig> {
|
|||
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<SftpFileClientConfig> {
|
|||
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;
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue