feat(file): 实现流式文件上传与下载

pull/212/head
egd 2025-10-15 18:18:49 +08:00
parent 5ede07de09
commit 266753c01e
18 changed files with 751 additions and 41 deletions

View File

@ -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
}
}

View File

@ -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;
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}
}

View File

@ -28,6 +28,6 @@ public class FileCreateReqVO {
private String type;
@Schema(description = "文件大小", example = "2048", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer size;
private Long size;
}

View File

@ -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;

View File

@ -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);
}

View File

@ -52,6 +52,6 @@ public class FileDO extends BaseDO {
/**
*
*/
private Integer size;
private Long size;
}

View File

@ -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;
}
}

View File

@ -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 支持 ==========
/**

View File

@ -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;
}
}

View File

@ -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();
}

View File

@ -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;
}

View File

@ -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) Bodyv2 的 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);
}
}

View File

@ -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
);
}
}

View File

@ -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

View File

@ -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);
}

View File

@ -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);
}
}