diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java index ed9a38f4..4b5b609d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/KingdeeAttachmentSyncSupport.java @@ -44,9 +44,11 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.URI; +import java.net.URLDecoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; @@ -177,12 +179,14 @@ public class KingdeeAttachmentSyncSupport { throw new IllegalArgumentException("附件超过20MB大小限制(" + formatSize(item.size()) + "),请压缩后重试"); } - byte[] content = fetchContent(item); + FetchedFile fetched = fetchContent(item); + byte[] content = fetched.content(); if (content.length > KingdeeAttachmentConstant.MAX_FILE_SIZE_BYTES) { throw new IllegalArgumentException("附件超过20MB大小限制(" + formatSize((long) content.length) + "),请压缩后重试"); } - String fileName = Func.isBlank(item.name()) ? "attachment" : item.name(); + String fileName = Func.isNotBlank(fetched.fileName()) ? fetched.fileName() + : (Func.isBlank(item.name()) ? "attachment" : item.name()); BfiAttachmentUploadVO uploadVO = bfiService.uploadAttachment(entityNumber, billPkId, KingdeeAttachmentConstant.CONTROL_KEY, fileName, content); log.info("金蝶附件上传成功:业务单据[{}:{}],附件[{}],kingdeePath={}", bizType, bizId, item.name(), uploadVO.getPath()); @@ -384,15 +388,24 @@ public class KingdeeAttachmentSyncSupport { return record; } + /** + * 附件内容拉取结果(合规附件按 C103-5 响应头解析真实文件名,其余来源 fileName 为 null 沿用原名称) + * + * @param content 附件内容 + * @param fileName 解析出的文件名(可空) + */ + private record FetchedFile(byte[] content, String fileName) { + } + /** * 拉取附件内容:合规附件经 C103-5 按合规附件ID 下载(流式读取,防大文件全量进内存失控); * 星瀚侧文件经 A801-2 下载(流式读取);用户附件按 http(s) url 下载(流式读取)。调用方负责按 20MB 上限校验 */ - private byte[] fetchContent(KingdeeAttachmentItem item) throws Exception { + private FetchedFile fetchContent(KingdeeAttachmentItem item) throws Exception { if (item.hcsFdIdSource()) { Response response = hcsAttachmentSupport.downloadRaw(item.key()); try (InputStream inputStream = response.body().asInputStream()) { - return readCapped(inputStream); + return new FetchedFile(readCapped(inputStream), resolveHcsFileName(response, item.name())); } finally { response.close(); } @@ -400,7 +413,7 @@ public class KingdeeAttachmentSyncSupport { if (item.kingdeePathSource()) { Response response = bfiService.downloadInvoice(item.key()); try (InputStream inputStream = response.body().asInputStream()) { - return readCapped(inputStream); + return new FetchedFile(readCapped(inputStream), null); } finally { response.close(); } @@ -414,10 +427,88 @@ public class KingdeeAttachmentSyncSupport { throw new IllegalStateException("附件下载失败,HTTP状态码 " + response.statusCode()); } try (InputStream inputStream = response.body()) { - return readCapped(inputStream); + return new FetchedFile(readCapped(inputStream), null); } } + /** + * 解析合规附件文件名(BFI 附件上传要求带后缀名):名称已带后缀原样返回; + * 否则优先取 C103-5 响应头 Content-Disposition 文件名,其次按 Content-Type 映射扩展名追加,兜底 .pdf + * + * @param response C103-5 文件流响应 + * @param fallbackName 兜底名称(无扩展名) + * @return 带后缀名的文件名 + */ + private String resolveHcsFileName(Response response, String fallbackName) { + String name = Func.isBlank(fallbackName) ? "付款特批附件" : fallbackName.trim(); + if (name.lastIndexOf('.') > 0) { + return name; + } + String fromDisposition = fileNameFromDisposition(firstHeader(response, "Content-Disposition")); + if (Func.isNotBlank(fromDisposition) && fromDisposition.lastIndexOf('.') > 0) { + return fromDisposition; + } + return name + extensionOfContentType(firstHeader(response, "Content-Type")); + } + + /** + * 响应头 Content-Disposition 文件名提取(容忍 filename*=UTF-8''xxx 与 filename="xxx" 两种形式,异常返回 null) + */ + private String fileNameFromDisposition(String disposition) { + if (Func.isBlank(disposition)) { + return null; + } + try { + String lower = disposition.toLowerCase(); + int index = lower.indexOf("filename*="); + if (index >= 0) { + String value = disposition.substring(index + "filename*=".length()); + int quote = value.indexOf("''"); + value = quote >= 0 ? value.substring(quote + 2) : value; + return URLDecoder.decode(value.replaceAll("\"", "").trim(), StandardCharsets.UTF_8); + } + index = lower.indexOf("filename="); + if (index >= 0) { + return URLDecoder.decode(disposition.substring(index + "filename=".length()) + .replaceAll("\"", "").trim(), StandardCharsets.UTF_8); + } + } catch (Exception ignored) { + // 响应头格式异常按无文件名处理,走 Content-Type 扩展名兜底 + } + return null; + } + + /** 响应头取值(key 忽略大小写,取首个非空值) */ + private String firstHeader(Response response, String name) { + if (response.headers() == null) { + return null; + } + for (Map.Entry> header : response.headers().entrySet()) { + if (header.getKey() != null && header.getKey().equalsIgnoreCase(name) && Func.isNotEmpty(header.getValue())) { + for (String value : header.getValue()) { + if (Func.isNotBlank(value)) { + return value; + } + } + } + } + return null; + } + + /** Content-Type → 扩展名(特批结果附件常规为 pdf,未识别类型兜底 .pdf) */ + private String extensionOfContentType(String contentType) { + String type = contentType == null ? "" : contentType.split(";")[0].trim().toLowerCase(); + return switch (type) { + case "application/pdf" -> ".pdf"; + case "image/jpeg" -> ".jpg"; + case "image/png" -> ".png"; + case "image/bmp" -> ".bmp"; + case "image/gif" -> ".gif"; + case "application/ofd" -> ".ofd"; + default -> ".pdf"; + }; + } + /** 流式读取并封顶(超出软限制即中断读取,避免大文件占满内存) */ private byte[] readCapped(InputStream inputStream) throws IOException { int bufferSize = 64 * 1024;