package jnpf.bizcommon.onlyoffice.service.impl; import cn.hutool.core.net.URLEncodeUtil; import cn.hutool.crypto.digest.DigestUtil; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeProperties; import jnpf.constant.GlobalConst; import jnpf.exception.DataException; import jnpf.file.FileApi; import jnpf.file.FileUploadApi; import jnpf.util.UploaderUtil; import lombok.RequiredArgsConstructor; import org.dromara.x.file.storage.core.FileInfo; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.util.LinkedHashMap; import java.util.Map; /** 所有编辑产物另存至专用目录;不调用 replaceFile,不触及源文件。 */ @Service @RequiredArgsConstructor public class UploadEditStorage { private final OnlyOfficeProperties properties; private final FileApi fileApi; private final FileUploadApi fileUploadApi; public Map save(String tenantId, String leaseId, String fileName, byte[] content) { if (content == null || content.length == 0) throw new DataException("编辑产物为空"); String prefix = relativeDirectory(properties.getUploadFile().getTempDirectory(), tenantId, leaseId); String base = fileApi.getPath("annex"); if (base == null || base.isBlank()) throw new DataException("无法解析附件存储目录"); String path = base.replace('\\', '/').replaceAll("/+$", "") + "/" + prefix; FileInfo stored = fileUploadApi.uploadFile(new Content(fileName, content), path, fileName); if (stored == null || stored.getFilename() == null || stored.getFilename().isBlank()) { throw new DataException("在线编辑临时文件另存失败"); } if (stored.getFilename().contains("/") || stored.getFilename().contains("\\") || stored.getFilename().contains(",")) { throw new DataException("文件服务返回了无效的编辑产物标识"); } // 平台原生自定义上传目录以逗号编码路径;查看、下载仍走原附件接口。 String fileId = prefix.replace('/', ',') + stored.getFilename(); Map file = new LinkedHashMap<>(); file.put("fileId", fileId); file.put("contentSavedAt", System.currentTimeMillis()); file.put("name", fileName); file.put("fileSize", stored.getSize()); file.put("fileExtension", fileName.toLowerCase(java.util.Locale.ROOT).endsWith(".doc") ? "doc" : "docx"); file.put("url", UploaderUtil.uploaderFile(fileId + "#annex") + "&name=" + URLEncodeUtil.encode(fileName, GlobalConst.DEFAULT_CHARSET)); return file; } static String relativeDirectory(String directory, String tenantId, String leaseId) { if (directory == null || !directory.matches("[A-Za-z0-9][A-Za-z0-9_-]{0,63}")) { throw new DataException("在线编辑临时目录必须是单个安全目录名"); } if (tenantId == null || tenantId.isBlank() || leaseId == null || !leaseId.matches("(?:review_)?[a-f0-9]{32}")) { throw new DataException("在线编辑临时文件缺少有效归属"); } return directory + "/" + DigestUtil.sha256Hex(tenantId).substring(0, 32) + "/" + leaseId + "/"; } private static class Content implements MultipartFile { private final String fileName; private final byte[] content; Content(String fileName, byte[] content) { this.fileName = fileName; this.content = content; } @Override public String getName() { return "multipartFile"; } @Override public String getOriginalFilename() { return fileName; } @Override public String getContentType() { return fileName.toLowerCase(java.util.Locale.ROOT).endsWith(".doc") ? "application/msword" : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; } @Override public boolean isEmpty() { return content.length == 0; } @Override public long getSize() { return content.length; } @Override public byte[] getBytes() { return content; } @Override public InputStream getInputStream() { return new ByteArrayInputStream(content); } @Override public void transferTo(File dest) throws IOException { Files.write(dest.toPath(), content); } } }