feat: 金蝶票池发票文件下载(A801-2):按发票号镜像优先/实时回源定位,后端流式代理供前端查看下载

This commit is contained in:
刘泉佳
2026-09-27 21:17:52 +08:00
parent d94b956f26
commit a3f1e07b2f
4 changed files with 337 additions and 0 deletions
@@ -29,6 +29,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
@@ -86,6 +87,14 @@ public class InvoiceReceiptController extends BladeController {
return R.data(invoiceReceiptService.invoicePoolPage(query));
}
@GetMapping("/invoice-pool/attachment/download")
@ApiOperationSupport(order = 17)
@Operation(summary = "金蝶票池发票文件下载(A801-2 流式回源)",
description = "按发票号定位金蝶侧文件:本地镜像优先,镜像未落库实时回源 A801-1;成功返回文件流,失败返回 JSON 错误")
public void downloadPoolAttachment(@RequestParam String invoiceNo, HttpServletResponse response) {
invoiceReceiptService.downloadPoolAttachment(invoiceNo, response);
}
@GetMapping("/settlement-candidates")
@ApiOperationSupport(order = 4)
@Operation(summary = "可关联的应付正式结算单")
@@ -26,6 +26,7 @@
package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import jakarta.servlet.http.HttpServletResponse;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
@@ -58,6 +59,17 @@ public interface IInvoiceReceiptService extends BaseService<InvoiceReceipt> {
*/
KingdeeInvoicePoolPageVO invoicePoolPage(KingdeeInvoicePoolQueryDTO query);
/**
* 金蝶票池发票文件下载(A801-2 流式回源,前端查看/下载代理端点)。
* <p>按发票号定位金蝶侧文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证),
* 镜像未落库或无文件时实时回源 A801-1 取 downloadUrl;经 A801-2 下载后流式透传浏览器,
* 不落盘不进堆。写流前的业务失败以 JSON(R.fail)写入响应,前端按 Content-Type 识别提示。
*
* @param invoiceNo 发票号码
* @param response 响应对象(文件流写出口)
*/
void downloadPoolAttachment(String invoiceNo, HttpServletResponse response);
List<Map<String, Object>> settlementCandidates(String keyword, Long receiptId);
Map<String, Object> referenceInformation(String settlementIds);
@@ -28,14 +28,18 @@ package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import feign.Response;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.core.tool.utils.WebUtil;
import org.springblade.thirdparty.bfi.constant.BfiConstant;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDTO;
import org.springblade.thirdparty.bfi.pojo.dto.BfiInvoicePoolQueryDataDTO;
@@ -82,12 +86,18 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -112,6 +122,9 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
/** A801-1 rim 契约单页最大条数 */
private static final int POOL_PAGE_SIZE = 500;
/** A801-2 文件下载流式拷贝缓冲区大小 */
private static final int DOWNLOAD_BUFFER_SIZE = 8 * 1024;
private final KingdeeInvoicePoolMapper invoicePoolMapper;
private final InvoiceReceiptSettlementMapper settlementRelationMapper;
private final InvoiceReceiptRecordMapper recordMapper;
@@ -1201,6 +1214,138 @@ public class InvoiceReceiptServiceImpl extends BaseServiceImpl<InvoiceReceiptMap
return JsonUtil.toJson(attachment);
}
// ==================== 金蝶票池发票文件下载(A801-2 前端代理,见 2026-09-27 设计文档) ====================
@Override
public void downloadPoolAttachment(String invoiceNo, HttpServletResponse response) {
String trimmedInvoiceNo = trimOrNull(invoiceNo);
if (StringUtil.isBlank(trimmedInvoiceNo)) {
WebUtil.renderJson(response, R.fail("发票号不能为空"));
return;
}
try {
doDownloadPoolAttachment(trimmedInvoiceNo, response);
} catch (ServiceException exception) {
// 写流前的业务失败统一以 JSON 返回:前端按响应 Content-Type 识别提示,浏览器不落损坏文件
log.warn("金蝶票池发票文件下载失败:发票[{}]:{}", trimmedInvoiceNo, exception.getMessage());
WebUtil.renderJson(response, R.fail(exception.getMessage()));
} catch (IOException exception) {
// 传输中断类失败(客户端断开等):响应通道已不可用,仅记录日志
log.error("金蝶票池发票文件下载传输异常:发票[{}]", trimmedInvoiceNo, exception);
}
}
/**
* 下载主流程:按发票号定位金蝶侧文件 path(镜像优先、A801-1 实时回源兜底)→ A801-2 文件流
* → 先校验后写头(失败在前端可识别阶段暴露)→ 8KB 流式透传,不落盘不进堆
*/
private void doDownloadPoolAttachment(String invoiceNo, HttpServletResponse response) throws IOException {
KingdeeAttachmentSyncSupport.KingdeeAttachmentItem item = resolvePoolAttachment(invoiceNo);
if (item == null || Func.isBlank(item.key())) {
throw new ServiceException("该发票没有金蝶侧文件");
}
long start = System.currentTimeMillis();
Response bfiResponse = bfiService.downloadInvoice(item.key());
try {
String contentType = firstHeader(bfiResponse, "Content-Type");
// A801-2 失败以 JSON 体返回(如 {"status":false,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。"}),先校验再写流
if (contentType != null && contentType.contains("application/json")) {
throw new ServiceException("调用BFI文件下载失败:" + readBfiJsonMessage(bfiResponse));
}
String fileName = Func.isBlank(item.name()) ? invoiceNo + ".pdf" : item.name();
// 文件名按 RFC 5987 编码(+ 号转 %20),中文/特殊字符不乱码
String encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20");
response.setContentType(Func.isBlank(contentType) ? "application/pdf" : contentType);
String contentLength = firstHeader(bfiResponse, "Content-Length");
if (contentLength != null) {
try {
response.setContentLengthLong(Long.parseLong(contentLength));
} catch (NumberFormatException ignored) {
// 非法长度头按无长度处理,走分块传输
}
}
response.setHeader("Content-Disposition",
"attachment; filename=\"" + encodedName + "\"; filename*=UTF-8''" + encodedName);
int totalBytes = 0;
try (InputStream inputStream = bfiResponse.body().asInputStream();
OutputStream outputStream = response.getOutputStream()) {
byte[] buffer = new byte[DOWNLOAD_BUFFER_SIZE];
int read;
while ((read = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, read);
totalBytes += read;
}
outputStream.flush();
}
log.info("金蝶票池发票文件下载完成:发票[{}],文件[{}],{}字节,耗时{}ms",
invoiceNo, fileName, totalBytes, System.currentTimeMillis() - start);
} finally {
bfiResponse.close();
}
}
/**
* 定位金蝶侧发票文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证),
* 镜像未落库(新进票池尚未同步)或镜像无文件时实时回源 A801-1 按票号取 downloadUrl;
* 远程票号查询为模糊匹配,命中行需与目标票号全等防御
*/
private KingdeeAttachmentSyncSupport.KingdeeAttachmentItem resolvePoolAttachment(String invoiceNo) {
List<KingdeeInvoicePool> mirrors = invoicePoolMapper.selectList(Wrappers.<KingdeeInvoicePool>lambdaQuery()
.eq(KingdeeInvoicePool::getInvoiceNo, invoiceNo));
if (Func.isNotEmpty(mirrors)) {
Optional<KingdeeAttachmentSyncSupport.KingdeeAttachmentItem> mirrorItem = kingdeeAttachmentSyncSupport
.parseInvoicePoolFile(mirrors.get(0).getAttachmentsJson()).stream().findFirst();
if (mirrorItem.isPresent()) {
return mirrorItem.get();
}
}
KingdeeInvoicePoolQueryDTO query = new KingdeeInvoicePoolQueryDTO();
query.setInvoiceNo(invoiceNo);
List<BfiInvoicePoolVO> rows = queryPoolPage(1, POOL_PAGE_SIZE, query);
for (BfiInvoicePoolVO row : rows) {
if (row.getInvoiceNo() != null && invoiceNo.equals(row.getInvoiceNo().trim())) {
return kingdeeAttachmentSyncSupport.parseInvoicePoolFile(buildAttachmentJson(row))
.stream().findFirst().orElse(null);
}
}
return null;
}
/** 读取 A801-2 JSON 错误体提示信息(优先 message,兜底 errorCode) */
private String readBfiJsonMessage(Response response) {
try (InputStream inputStream = response.body().asInputStream()) {
Object parsed = JsonUtil.parse(new String(inputStream.readAllBytes(), StandardCharsets.UTF_8), Object.class);
if (parsed instanceof Map<?, ?> map) {
String message = firstTextValue(map, "message");
if (Func.isNotBlank(message)) {
return message;
}
String errorCode = firstTextValue(map, "errorCode");
if (Func.isNotBlank(errorCode)) {
return errorCode;
}
}
} catch (Exception exception) {
log.warn("解析BFI文件下载错误响应失败", exception);
}
return "响应体为JSON错误";
}
/** 大小写不敏感取第一个响应头值 */
private String firstHeader(Response response, String name) {
for (Map.Entry<String, Collection<String>> entry : response.headers().entrySet()) {
if (name.equalsIgnoreCase(entry.getKey()) && Func.isNotEmpty(entry.getValue())) {
return entry.getValue().iterator().next();
}
}
return null;
}
private String firstTextValue(Map<?, ?> map, String key) {
Object value = map.get(key);
return value == null ? null : String.valueOf(value);
}
/**
* 税率优先取顶层小数换算为百分数(0.13 -> 13),顶层为空回退首条明细,均无效时置 0(镜像列非空约束)
*/