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(镜像列非空约束)
*/
@@ -0,0 +1,171 @@
# 金蝶附件下载(A801-2 前端下载通道)设计文档
- 日期:2026-09-27
- 范围:票池镜像发票文件面向前端的查看/下载通道(后端流式代理端点 + 前端三处入口改造);附件同步(A902)链路零改动
- 背景:2026-09-24 金蝶附件同步设计核验时确认缺口——票池镜像发票文件的 attachmentsJson 为 `{source:"piaozone", path, name}`,其中 `path` 是金蝶侧文件地址(A801-1 的 downloadUrl,非 http 直链),前端 `invoiceAttachmentUrl`/`attachmentFileUrl` 只识别 `url/link/fileUrl`,因此正式结算认领发票行、付款申请发票附件行的「查看/下载」按钮对这类文件弹「暂无可查看/下载附件」;A801-2 后端能力已具备(`IBfiClient.invoiceDownload`),但仅用于附件同步链路(A801-2 下载 → A902 回传),无面向前端的下载端点。
## 一、目标
1. 前端可在票池弹窗、正式结算认领发票行、付款申请发票附件行三处查看/下载票池发票文件,文件经后端代理 A801-2 流式返回
2. http 型附件(OSS 直链)的现有查看/下载行为完全不变,两类附件按字段特征自动分流
3. 不提供「任意 path」下载能力,防止开放代理与越权读取金蝶文件
## 二、现状与缺口(核验结论)
| 入口 | 现状 | 缺口 |
| --- | --- | --- |
| 票池弹窗 `kingdee-invoice-pool-dialog` | 仅选择发票,无附件操作列 | 无查看/下载入口 |
| 正式结算 `formal-settlement-editor.vue` 认领发票行 | 有 查看/下载 按钮,`invoiceAttachmentUrl` 只取 `url/link/fileUrl`(:2288-2305) | path 型附件取不到 url,按钮弹「暂无可查看/下载附件」 |
| 付款申请 `payment-application-form.vue` 发票附件行 | 同上(:1385-1411) | 同上 |
| 收票登记 `invoice-receipt-form.vue` | 登记附件(OSS 直链)正常 | 镜像发票文件入口由票池弹窗覆盖,本表单不改 |
行数据具备条件:票池弹窗选中行与认领行均 `...row` 展开(formal-settlement-editor.vue:2195-2216),自带 `invoiceNo` 与 `attachmentJson`,满足「按发票号定位」的入参要求(勘误说明见「实施记录」1)。
## 三、契约
### A801-2 文件下载(已有,零改动)
- `POST /ierp/kapi/v2/awrp/bosext/attachment/download`,body `{"path": "<票池 downloadUrl>"}`,Header `access_token`(复用现有 getToken/token 缓存)
- 返回文件流;客户端 `IBfiClient.invoiceDownload` / `BfiServiceImpl.downloadInvoice` 已实现(status 校验、日志齐全),本期直接复用
### 契约核验(2026-09-27,源:《业财融合系统接口文档.xlsx》页签「文件下载」「查询全票池发票数据」「附件上传到指定单据接口」)
1. **A801-2 失败响应是 JSON 而非流**:`{"data":null,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。","status":false}`;成功响应文档未给出字段定义(即原始文件流)。后端代理必须先判 Content-Type 再写流——失败时解析 `message`/`errorCode` 透传给前端,不能把 JSON 错误体当文件下发。
2. **path 形态三方一致**:A801-2 请求示例 `234e244b2c803400`、A801-1 票池响应 `downloadUrl:"234e244b2c803400"`(另有 `imageUrl`/`pdfurl`/`snapshotUrl` 同形态;`originalFileName` 为 `serialNo.pdf`)、A902 返回 `data.path:"211e6ce5c9805c00"`——均为 16 位 hex 文件 id。镜像 path 可直接作为 A801-2 入参(与现状一致),且 A902 返回的 kingdee_path 与 A801-2 的 path 大概率同命名空间(见「待确认」1)。
3. **entityNumber 映射获接口文档印证**:「附件上传到指定单据接口」页签内嵌映射表——付款申请单 `ap_payapply`、财务应付单 `ap_finapbill`、财务应收单 `ar_finarbill`、收票单 `ap_invoice`、开票申请单 `sim_original_bill`(controlKey 统一 `attachmentpanel`),与上期 `KingdeeAttachmentConstant` 完全一致,无需调整。
4. A801-1 上游为票易通(queryParam 见 open.piaozone.com),`downloadUrl` 的有效期文档未说明;A902 页签未载明单文件大小上限与频率限制——「待确认」项维持。
### 新增 TMS 下载端点
`GET /blade-transport/invoice-receipt/invoice-pool/attachment/download?invoiceNo=<发票号>`
- 鉴权:BladeX 登录态(网关 + Secure 框架),与同控制器 `/invoice-pool` 查询一致,不额外加 `@PreAuth`
- 租户隔离:`KingdeeInvoicePool extends TenantEntity`,`getById` 经 MP 租户插件自动过滤,跨租户 id 等同不存在
- 成功响应:文件流。Header:
- `Content-Type`:透传 BFI 响应头,缺失时兜底 `application/pdf`(票池发票文件均为 PDF)
- `Content-Length`:BFI 响应头携带时透传
- `Content-Disposition`:`attachment; filename="<ASCII兜底>"; filename*=UTF-8''<RFC5987编码>`,文件名取镜像 attachmentsJson 的 `name`(发票号.pdf)
- 失败响应(均在写响应头**之前**失败,走全局异常处理返回 `R` JSON):
| 场景 | 行为 |
| --- | --- |
| 镜像未命中且 A801-1 实时回源未命中(含跨租户票号,租户隔离下等同不存在) | `ServiceException("该发票没有金蝶侧文件")` |
| 镜像/实时行均无 downloadUrl(如 OFD-only 票) | 同上,`ServiceException("该发票没有金蝶侧文件")` |
| BFI 返回非 200,或响应体为 JSON 错误(如 `FILE_IS_NOT_EXIST`) | 解析 BFI `message`/`errorCode` 抛 `ServiceException`(如「调用BFI文件下载失败:附件不存在。」),错误信息透传前端 |
| 传输中断 | 已写头后无法回退,流中断即连接断开,前端 reject |
## 四、后端设计
接线(均在既有文件内新增,无新文件):
- `InvoiceReceiptController`:新增 `downloadPoolAttachment(@RequestParam String invoiceNo, HttpServletResponse response)`,与 `/invoice-pool` 同域同风格(`@GetMapping` + `@ApiOperationSupport` + `@Operation`)
- `IInvoiceReceiptService` / `InvoiceReceiptServiceImpl`:新增 `downloadPoolAttachment(String invoiceNo, HttpServletResponse response)`
流程:
```
取镜像(自动租户过滤)→ 解析 attachmentsJson 取 path/name
→ bfiService.downloadInvoice(path) ← 在写任何响应字节之前
→ 校验 status==200 且 Content-Type 非 JSON(防御 BFI 以 200 返回错误体)
→ 写响应头(Content-Type / Content-Length / Content-Disposition)
→ 8KB 缓冲流式拷贝 feign Response → ServletOutputStream,flush,finally 关闭 Response
```
设计要点:
1. **path 一律服务端解析,接口不收 path 参数(ADR-1)**:按发票号定位,解析顺序「镜像快照优先 → A801-1 实时回源兜底」——镜像快照与上传链路(A902 镜像文件取回)同源同口径(同取 `blade_kingdee_invoice_pool.attachmentsJson`);但票池弹窗行是 A801-1 实时行、未必已入镜像,故下载必须有实时回源兜底(上传只发生在已绑定镜像的收票单上,无此场景)。镜像快照过期(红冲重开)时报「附件不存在。」不自动刷新,与上传取快照口径一致,刷新机制见「待确认」2
2. **先校验后写流**:所有可预知错误发生在写头之前,保证前端拿到的是可识别的 JSON 错误而非损坏文件(ADR-3)。A801-2 失败时返回 JSON(`{"status":false,"errorCode":"FILE_IS_NOT_EXIST","message":"附件不存在。"}`,HTTP 状态文档未标注,按 200 + `status:false` 防御),解析 `message`/`errorCode` 透传给前端;成功时按 Content-Type 判定为文件流再写响应
3. **全程流式**:不落盘、不整文件进堆,与同步链路的 20MB `readCapped` 无关——下载通道**不设 20MB 上限**(ADR-4)
4. **复用 BFI 配置**:token 缓存与 Feign 超时(连 10s / 读 60s,`BfiFeignClientConfig`)不动;无事务、不落附件同步表、不记操作流水
5. **日志**:BFI 侧沿用「调用BFI接口 进项发票下载开始/结束」;业务侧补一条 info(操作人、发票号、字节数),便于审计
6. 文件名编码:RFC 5987 `filename*` + ASCII 兜底,中文发票号不乱码
## 五、前端设计
### API 与公共工具
- `src/api/payment/invoiceReceipt.js` 新增:
```js
// 金蝶票池发票文件下载(后端代理 A801-2),耗时较普通接口长,放宽到 120s
export const downloadInvoicePoolAttachment = invoiceNo =>
request({ url: `${baseUrl}/invoice-pool/attachment/download`, method: 'get', params: { invoiceNo }, responseType: 'blob', timeout: 120000 });
```
- 新增公共工具 `src/utils/kingdee-attachment.js`(三入口共用,避免复制三份):
- `isKingdeePathAttachment(attachment)`:`source === 'piaozone'`,或「有 `path` 且非 http(s) 开头」——判定为金蝶侧文件
- `downloadKingdeeAttachment(row)`:金蝶型 → 调下载 API;若响应 blob 的 `type` 含 `application/json` 则读取文本解析出 msg 后 `message.error`(兼容全局异常处理器以 200 返回 R 的情况);否则 `downloadFile(blob, name)`;URL 型 → 回退既有 `downloadFileByUrl`
- `viewKingdeeAttachment(row)`:金蝶型 → 拉取 blob 后 `URL.createObjectURL` 新窗口打开(PDF 可预览;OFD 等浏览器不支持的格式提示改用下载);URL 型 → 维持 `window.open(url)`
- 两个动作均带行级 loading 守卫(单行按钮级即可,沿用「防连点全局模式本期不实施」的口径)
### 入口改造(三处)
| 文件 | 改造点 |
| --- | --- |
| `src/components/kingdee-invoice-pool-dialog/main.vue` | 表格新增「附件」操作列:查看 / 下载(行自带 invoiceNo + attachmentJson) |
| `src/views/settlement/components/formal-settlement-editor.vue` | `viewInvoiceAttachment` / `downloadInvoiceAttachment`(:2296-2305)增加金蝶型分支,走公共工具;URL 型行为不变 |
| `src/views/payment/payment-application-form.vue` | `viewInvoiceAttachment` / `downloadInvoiceAttachment`(:1393-1411)同上 |
收票登记表单不改(镜像文件入口由票池弹窗覆盖)。
## 六、不做的事
- **已上传金蝶附件(`blade_kingdee_attachment_sync.kingdee_path`)的回源下载**:A801-2 对 A902 返回 path(如 `211e6ce5c9805c00`)的兼容性未验证,待金蝶确认后二期评估
- 批量发票文件打包 zip 下载
- 后端转存 OSS 再发直链的方案(ADR-2 否决,理由见下)
- 附件同步(A902)链路与 `KingdeeAttachmentSyncSupport` 的任何改动
## 七、待确认(金蝶侧)
1. A801-2 是否可下载 A902 返回的 `data.path`(决定二期「从金蝶恢复已上传附件」可行性)。契约核验补充依据:A902 返回 path(`211e6ce5c9805c00`)与 A801-2 path 参数示例(`234e244b2c803400`)同为 16 位 hex 文件 id,疑似同一文件命名空间,大概率兼容,需实测确认
2. 票池 `downloadUrl` 的有效期与失效表现:镜像落库后 path 是否会过期、失效时 A801-2 返回什么(已知失效类错误如 `FILE_IS_NOT_EXIST`「附件不存在。」);接口文档未载明有效期,上游为票易通(queryParam 见 open.piaozone.com),若会过期,需明确经 A801-1 刷新镜像的机制(影响下载失败时的用户提示与补救路径)
3. A801-2 频率限制(本场景为用户点击型、低频,预期无压力,确认即可);A902 页签亦未载明单文件大小上限与频率限制,上期 20MB 软限制待一并确认
## 八、验证
1. 后端:`mvn clean compile -DskipTests -pl blade-service/blade-transport -am`
2. 前端:`pnpm run typecheck` 因仓库既有问题不可用(vue-tsc 3.3.11 与 typescript@7.0.2 不兼容,与上期同因),沿用 @vue/compiler-sfc 解析 + `node --check` 校验全部改动 .vue/.js
3. 手动(用户执行):
- 票池弹窗行 查看/下载 → 得到 `发票号.pdf`,内容可打开
- 正式结算认领发票行、付款申请发票附件行:金蝶型附件 查看/下载 成功;OSS 型附件行为与改造前完全一致
- 镜像缺失 path / 发票未同步票池 → 友好提示,不产生损坏文件
- 不存在的发票号 → 「该发票没有金蝶侧文件」(镜像与 A801-1 回源均未命中;跨租户票号在租户隔离下等同不存在)
- BFI 停机/超时模拟 → 前端 JSON 错误识别并提示,浏览器不落损坏文件
- path 失效模拟(BFI 返回 `FILE_IS_NOT_EXIST`)→ 前端提示透传的「附件不存在。」,不落损坏文件
- 日志链路:`调用BFI接口 进项发票下载` → 业务侧下载 info
## 九、ADR 摘要
| # | 决策 | 备选与理由 |
| --- | --- | --- |
| ADR-1 | 按发票号定位,path 服务端解析(镜像优先 → A801-1 实时兜底) | 前端传 path 被否:等同开放代理,可遍历下载金蝶任意文件;只信镜像不够——票池弹窗是 A801-1 实时行、未必已入镜像;发票号在镜像表同租户唯一(A801-1 upsert 匹配键),天然带租户与数据边界 |
| ADR-2 | 后端流式代理,不转存 OSS | 转存需处理双存储一致性、清理策略与额外延时;票池文件为点击型低频小文件(PDF),代理足够;若日后高频/大文件再评估 |
| ADR-3 | 先校验后写流,错误返回 JSON | 先写头后失败会导致前端拿到损坏文件且无法提示;前端按响应 Content-Type 是否为 JSON 统一识别成败,不依赖 HTTP 状态码细节 |
| ADR-4 | 下载通道不设 20MB 上限 | 20MB 是 A902 上传软限制(金蝶契约),A801-2 下载无此约束,流式透传不占堆内存 |
| ADR-5 | 查看走 blob objectURL 新窗口,作为增强项 | 后端不区分 inline/attachment,减少契约面;OFD 等不可预览格式由前端降级为下载提示 |
## 十、实施记录(2026-09-27 as-built)
实施中发现的现状与设计的两处偏差,已按实际落地(勘误同上期 as-built 风格):
| # | 事项 | 设计原假设 | 实施定案 |
| --- | --- | --- | --- |
| 1 | 下载定位键 | 按票池镜像 id(`GET /invoice-pool/{poolId}/attachment/download`) | **按发票号**(`GET /invoice-pool/attachment/download?invoiceNo=`)。原因:票池弹窗行是 A801-1 实时行、`id` 恒空(`toPoolViewVO` 注释即注明);正式结算/付款详情行是关系行(`FormalSettlementInvoice`/`PaymentApplicationInvoice` 的 id 为关系行 id 而非镜像 id)。三处入口统一仅可靠携带 `invoiceNo`;票号在镜像表同租户唯一(A801-1 upsert 匹配键),按票号取**实时镜像**还自动跟随 path 变化(红冲重开)。定位顺序:镜像优先(0 远程调用)→ 镜像未落库/无文件时实时回源 A801-1 按票号取 `downloadUrl`(远程模糊匹配命中行做全等防御) |
| 2 | 失败返回方式 | 走全局异常处理返回 R JSON | 写流前的业务失败(票号空/无金蝶侧文件/BFI JSON 错误体)在服务内捕获后经 `WebUtil.renderJson(response, R.fail(msg))` 直接写 HTTP 200 + JSON。原因:blade-transport 无本地异常处理器,全局处理器行为在 jar 内不可控(可能非 200,axios 拦截器对 blob 错误体无法解析出 msg);`WebUtil.renderJson` 为本仓库既有惯例(运单批量导入校验同款)。前端 `kingdee-attachment.js` 统一按响应 Content-Type 含 `application/json` 识别失败并弹出 msg |
实施产物:
- 后端:`InvoiceReceiptController.downloadPoolAttachment`(`/invoice-receipt/invoice-pool/attachment/download`,GET,继承类级 `@PreAuth(menu="invoice_receipt")`)、`IInvoiceReceiptService.downloadPoolAttachment`、`InvoiceReceiptServiceImpl` 实现(`resolvePoolAttachment` 定位 + `doDownloadPoolAttachment` A801-2 流式透传 + `readBfiJsonMessage` 错误体解析,`bfiResponse.close()` finally 兜底;IOException(客户端断开)仅记日志)
- 前端:`api/payment/invoiceReceipt.js` 新增 `downloadInvoicePoolAttachment`(blob + 120s);新增 `utils/kingdee-attachment.js`(`isKingdeePathAttachment` 判定 + 下载/预览 + JSON 错误识别 + 静默标记防双 toast);三处入口——票池弹窗新增「附件」列(行级 `attachmentBusy` 守卫)、正式结算编辑器与付款申请表单的 查看/下载 按钮按附件类型分流(`kingdeeAttachmentBusy` 守卫,OSS 直链行为不变)
- 验证:后端 `mvn clean compile -DskipTests -pl blade-service/blade-transport -am` ✅;前端 @vue/compiler-sfc 解析 + `node --check` 全部改动文件 ✅(`pnpm run typecheck` 因仓库既有 vue-tsc/TS7 不兼容问题不可用,与上期同因)
## 术语表
| 术语 | 含义 |
| --- | --- |
| A801-2 | 星瀚「文件下载」接口,按 path 取回文件流(本期对前端暴露的下载通道) |
| 票池镜像 | `blade_kingdee_invoice_pool`,A801-1 全票池发票的本地镜像,attachmentsJson 存 `{source:"piaozone", path, name}` |
| path 型附件 | 金蝶侧文件:attachmentsJson 项有 `path` 无 http url,只能经 A801-2 取回 |
| URL 型附件 | OSS 直链附件:attachmentsJson 项有 `url/link/fileUrl`,前端可直连下载(现有行为) |
| billPkId | 星瀚单据内码(上期 A902 使用,本特性不涉及,仅口径区分) |
| 开放代理 | 接口接受任意目标地址参数并代为取回内容的安全缺陷,本设计以 id 定位规避 |