From a3f1e07b2f86f6c3bedb56e4c88ff95fe24c0fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B3=89=E4=BD=B3?= <1107186916@qq.com> Date: Sun, 27 Sep 2026 21:17:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=91=E8=9D=B6=E7=A5=A8=E6=B1=A0?= =?UTF-8?q?=E5=8F=91=E7=A5=A8=E6=96=87=E4=BB=B6=E4=B8=8B=E8=BD=BD=EF=BC=88?= =?UTF-8?q?A801-2=EF=BC=89=EF=BC=9A=E6=8C=89=E5=8F=91=E7=A5=A8=E5=8F=B7?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E4=BC=98=E5=85=88/=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E5=9B=9E=E6=BA=90=E5=AE=9A=E4=BD=8D=EF=BC=8C=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E4=BB=A3=E7=90=86=E4=BE=9B=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/InvoiceReceiptController.java | 9 + .../service/IInvoiceReceiptService.java | 12 ++ .../impl/InvoiceReceiptServiceImpl.java | 145 +++++++++++++++ ...26-09-27-kingdee-attachment-download-design.md | 171 ++++++++++++++++++ 4 files changed, 337 insertions(+) create mode 100644 doc/docs/业务流程/2026-09-27-kingdee-attachment-download-design.md diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java index b481275d..24cf3584 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/InvoiceReceiptController.java @@ -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 = "可关联的应付正式结算单") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java index 7931fde2..7220003e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IInvoiceReceiptService.java @@ -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 { */ KingdeeInvoicePoolPageVO invoicePoolPage(KingdeeInvoicePoolQueryDTO query); + /** + * 金蝶票池发票文件下载(A801-2 流式回源,前端查看/下载代理端点)。 + *

按发票号定位金蝶侧文件:本地镜像优先(票号同租户唯一,租户隔离由 MP 插件保证), + * 镜像未落库或无文件时实时回源 A801-1 取 downloadUrl;经 A801-2 下载后流式透传浏览器, + * 不落盘不进堆。写流前的业务失败以 JSON(R.fail)写入响应,前端按 Content-Type 识别提示。 + * + * @param invoiceNo 发票号码 + * @param response 响应对象(文件流写出口) + */ + void downloadPoolAttachment(String invoiceNo, HttpServletResponse response); + List> settlementCandidates(String keyword, Long receiptId); Map referenceInformation(String settlementIds); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java index 8e2b793e..7f23f70a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java @@ -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 mirrors = invoicePoolMapper.selectList(Wrappers.lambdaQuery() + .eq(KingdeeInvoicePool::getInvoiceNo, invoiceNo)); + if (Func.isNotEmpty(mirrors)) { + Optional mirrorItem = kingdeeAttachmentSyncSupport + .parseInvoicePoolFile(mirrors.get(0).getAttachmentsJson()).stream().findFirst(); + if (mirrorItem.isPresent()) { + return mirrorItem.get(); + } + } + KingdeeInvoicePoolQueryDTO query = new KingdeeInvoicePoolQueryDTO(); + query.setInvoiceNo(invoiceNo); + List 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> 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(镜像列非空约束) */ diff --git a/doc/docs/业务流程/2026-09-27-kingdee-attachment-download-design.md b/doc/docs/业务流程/2026-09-27-kingdee-attachment-download-design.md new file mode 100644 index 00000000..611fa719 --- /dev/null +++ b/doc/docs/业务流程/2026-09-27-kingdee-attachment-download-design.md @@ -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=""; filename*=UTF-8''`,文件名取镜像 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 定位规避 |