Kaynağa Gözat

fix(转账): 下载回单 —— 改为后端直发字节,并修掉文件名乱码

两轮的改动,同一件事:点了「下载回单」之后,文件要真的到用户手里。

一、后端发字节(取代 window.open)

原先后端返回 download_url,前端 window.open 它就算完 —— 返回值没人查,
被弹窗拦截时静默返回 null,而 success 提示是无条件弹的。于是「后端说成功」
与「浏览器真拿到文件」之间那一段是黑的,用户看到「下载成功」却什么都没发生。

改为字节由我方源站下发:

  GET /payment/account/receipt/file?order_no=...

权限串复用 module_payment:account:receipt(与 /receipt/download 同串),
所以不需要菜单 SQL。409 表示「回单还没生成好」—— 生成是异步的,第一次
申请通常是 PROCESS,前端据此重试 3 次;其它码是终局失败,不重试。
回单状态不是 SUCCESS 就一个字节都不去取,取不到就抛。这个 409 是唯一的
机器可读判据,改成 400 会让用户为一张刚生成的回单反复点。

二、文件名乱码

java.net.http.HttpClient 按 HTTP 规范把响应头字节当 ISO-8859-1 解成 String,
所以上游在 filename="…" 里写 UTF-8 中文时,我们拿到手就已经是乱码(还夹着
C1 控制字符);GBK 则解成另一串看着像西欧字母的东西。原来原样透传、再按
UTF-8 编码回去,浏览器解出来就是乱码。

ReceiptFileFetcher.trustedFilename:纯 ASCII 或真 Unicode 原样用;含 Latin-1
高位字符则试着重解一次 UTF-8,无损才用;救不回来就返回 null,改用调用方给的
名字。宁可换个名字,也不能把乱码交给用户。fetch 因此变成 fetch(url,
fallbackName) —— 兜底名由知道这是什么文件的调用方给(回单_<单号>.pdf)。
前端零改动:它的兜底名本来就叫回单_${order_no}.pdf,两端天然一致。

测试:AccountServiceReceiptFileTest 与 ReceiptFileFetcherTest 共 28 条
(上一轮 24 条,本轮 +4),全量 840 绿。两向变异 5/5。
alphaH 3 hafta önce
ebeveyn
işleme
edeb58a74f

+ 16 - 0
frontend/src/api/module_payment/account.ts

@@ -257,6 +257,22 @@ export const AccountAPI = {
     });
   },
 
+  /**
+   * 回单文件本体 —— 走我方源站的字节流。
+   *
+   * 与 downloadReceipt 的区别:那个只回一个 download_url,由页面 window.open 去碰运气,
+   * 被浏览器拦截时是静默的(window.open 返回 null 没人查),页面照样弹「下载成功」。
+   * 这个拿的是同源响应,字节到没到是确定的。
+   */
+  downloadReceiptFile(params: { order_no: string }) {
+    return request({
+      url: `${API_PATH}/receipt/file`,
+      method: "get",
+      params,
+      responseType: "blob",
+    });
+  },
+
   queryReceipt(params: {
     enterprise_id: string;
     file_id: string;

+ 87 - 55
frontend/src/views/module_payment/account/index.vue

@@ -1854,76 +1854,108 @@ function handleViewTransferDetail(outBizNo: string) {
   transferDetailVisible.value = true;
 }
 
-function downloadFile(url: string) {
-  window.open(url, "_blank");
+/**
+ * blob 里其实是统一错误体时抛出。
+ *
+ * 后端业务异常是 HTTP 200 + `{code, msg}`(GlobalExceptionHandler 没加 @ResponseStatus),
+ * 而 request.ts 对 blob 分支直接 return、不做 code 检查 —— 不嗅探的话,用户会下载到
+ * 一个装着错误信息的 .pdf,比什么都不下载更糟。带上 code 是为了让调用方能分辨
+ * 「还没生成好(409)」与「真失败」。
+ */
+class ReceiptBlobError extends Error {
+  code?: number;
+  constructor(msg: string, code?: number) {
+    super(msg);
+    this.code = code;
+  }
+}
+
+async function toReceiptBlobError(blob: Blob): Promise<ReceiptBlobError | null> {
+  if (!(blob.type || "").toLowerCase().includes("json")) return null;
+  try {
+    const parsed = JSON.parse(await blob.text());
+    return new ReceiptBlobError(parsed?.msg || "回单下载失败", parsed?.code);
+  } catch {
+    return new ReceiptBlobError("回单下载失败");
+  }
+}
+
+/** 文件名听后端响应头的(后端给了 filename= 与 filename*=UTF-8'' 两种形式)。 */
+function receiptFilename(headers: any, order_no: string): string {
+  const cd: string = headers?.["content-disposition"] || "";
+  const star = /filename\*=UTF-8''([^;]+)/i.exec(cd);
+  if (star) {
+    try {
+      return decodeURIComponent(star[1]);
+    } catch {
+      /* 编码坏了就退回下一支 */
+    }
+  }
+  const plain = /filename="?([^";]+)"?/i.exec(cd);
+  if (plain) return plain[1];
+  return `回单_${order_no}.pdf`;
+}
+
+/**
+ * 保存回单文件。
+ *
+ * 这里原来是 `window.open(download_url, "_blank")` —— 被浏览器拦下时它静默返回 null,
+ * 而调用方没查返回值,于是「回单下载成功」照弹、文件一个没有。现在改成走我方源站取字节:
+ * 字节没到就抛,成功提示才有依据;同源响应也不存在弹窗拦截与跨域问题。
+ */
+async function saveReceiptFile(order_no: string) {
+  const res: any = await AccountAPI.downloadReceiptFile({ order_no });
+  const blob: Blob = res.data;
+  const blobError = await toReceiptBlobError(blob);
+  if (blobError) throw blobError;
+
+  const url = window.URL.createObjectURL(blob);
+  const a = document.createElement("a");
+  a.href = url;
+  a.download = receiptFilename(res.headers, order_no);
+  document.body.appendChild(a);
+  a.click();
+  document.body.removeChild(a);
+  window.URL.revokeObjectURL(url);
+}
+
+/**
+ * 回单是异步生成的:刚转完账那一下查询往往还是 PROCESS。
+ * 后端把这种情况回成 409,这里就重试几次 —— 免得用户为了一张刚生成的回单反复点。
+ * 其它错误重试没有意义,直接抛。
+ */
+async function downloadReceiptWithRetry(order_no: string) {
+  const maxAttempts = 3;
+  const retryInterval = 3000;
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    try {
+      await saveReceiptFile(order_no);
+      return;
+    } catch (error: any) {
+      if (error?.code !== 409 || attempt === maxAttempts) throw error;
+      await new Promise(resolve => setTimeout(resolve, retryInterval));
+    }
+  }
 }
 
 async function handleDownloadReceipt(row: any) {
-  const loadingText = ref("获取回单中...");
   const loadingInstance = ElMessage({
-    message: loadingText.value,
+    message: "获取回单中...",
     type: "loading",
     duration: 0,
   });
 
   try {
-    const res = await AccountAPI.downloadReceipt({
-      enterprise_id: currentEnterpriseId.value!,
-      order_no: row.order_no,
-    });
-
-    const { file_id, status, download_url, error_message } = res.data.data;
-
-    if (status === "SUCCESS" && download_url) {
-      downloadFile(download_url);
-      ElMessage.success("回单下载成功");
-    } else if (status === "FAIL") {
-      ElMessage.error(`回单生成失败: ${error_message || "未知错误"}`);
-    } else if (status === "PROCESS" || status === "INIT") {
-      await pollForReceipt(row.order_no);
-    } else {
-      ElMessage.info("回单状态未知,请稍后尝试");
-    }
+    await downloadReceiptWithRetry(row.order_no);
+    // 只有字节真的落盘才说成功
+    ElMessage.success("回单下载成功");
   } catch (error: any) {
-    ElMessage.error(error.message || "获取回单失败");
+    ElMessage.error(error?.message || "获取回单失败");
   } finally {
     loadingInstance.close();
   }
 }
 
-// 轮询回单状态 — 只依赖 order_no(后端 downloadReceiptByOrderNo 自动推导 enterprise_id,
-// 避免未选企业时 enterprise_id 为 undefined 拼出 /receipt/undefined/{file_id})
-async function pollForReceipt(order_no: string) {
-  const maxRetries = 3;
-  const retryInterval = 3000;
-  let retryCount = 0;
-
-  while (retryCount < maxRetries) {
-    await new Promise(resolve => setTimeout(resolve, retryInterval));
-    retryCount++;
-
-    try {
-      const res = await AccountAPI.downloadReceipt({ order_no });
-
-      const { status, download_url, error_message } = res.data.data;
-
-      if (status === "SUCCESS" && download_url) {
-        downloadFile(download_url);
-        ElMessage.success("回单下载成功");
-        return;
-      } else if (status === "FAIL") {
-        ElMessage.error(`回单生成失败: ${error_message || "未知错误"}`);
-        return;
-      }
-    } catch (error: any) {
-      ElMessage.error(`轮询失败: ${error.message}`);
-      return;
-    }
-  }
-
-  ElMessage.info("回单正在生成中,请稍后手动刷新页面重试");
-}
-
 function handleTransferDetailRefresh() {
   fetchTransferList();
 }

+ 47 - 0
java/src/main/java/com/payment/platform/module/payment/account/controller/AccountController.java

@@ -10,16 +10,19 @@ import com.payment.platform.module.payment.account.entity.AccountEntity;
 import com.payment.platform.module.payment.account.mapper.AccountMapper;
 import com.payment.platform.module.payment.account.service.AccountService;
 import com.payment.platform.module.payment.account.service.AlipayTransferService;
+import com.payment.platform.module.payment.account.service.ReceiptFileFetcher;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
 import java.io.IOException;
 import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.Map;
 
@@ -268,6 +271,50 @@ public class AccountController {
         return Result.fail(400, "order_no 不能为空");
     }
 
+    /**
+     * 回单文件本体。
+     *
+     * <p>与 {@code /receipt/download} 的区别:那个只返回一个 {@code download_url},
+     * 由前端 {@code window.open} 去碰运气 —— 失败了前端不知道,照样弹「下载成功」。
+     * 这里把字节取回来,浏览器拿到的是**同源**的响应,没有弹窗拦截、没有跨域、
+     * 也不需要把能力 URL 交给页面;失败时按统一错误体 200 + code 返回,前端能照实显示。
+     *
+     * <p>权限串与 {@code /receipt/download} 相同,不新增权限项,菜单 SQL 无需变更。
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:account:receipt')")
+    @GetMapping("/receipt/file")
+    public void downloadReceiptFile(
+            @RequestParam(name = "order_no") String orderNo,
+            HttpServletResponse response) throws IOException {
+        ReceiptFileFetcher.Fetched fetched = accountService.downloadReceiptFile(orderNo);
+        String filename = fetched.filename();
+        response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
+        response.setHeader("Content-Disposition", "attachment; filename=\"" + asciiFallback(filename)
+                + "\"; filename*=UTF-8''" + rfc5987(filename));
+        response.setContentLength(fetched.content().length);
+        response.getOutputStream().write(fetched.content());
+    }
+
+    /** 非 ASCII 文件名在 {@code filename=} 那一段里没法直接放,退化成下划线;真实名字走 {@code filename*=}。 */
+    private static String asciiFallback(String filename) {
+        String s = filename.replaceAll("[^\\x20-\\x7e]", "_").replace("\"", "_");
+        return s.isBlank() ? "receipt.pdf" : s;
+    }
+
+    private static String rfc5987(String filename) {
+        StringBuilder sb = new StringBuilder();
+        for (byte b : filename.getBytes(StandardCharsets.UTF_8)) {
+            int c = b & 0xff;
+            if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
+                    || c == '.' || c == '-' || c == '_' || c == '~') {
+                sb.append((char) c);
+            } else {
+                sb.append('%').append(String.format("%02X", c));
+            }
+        }
+        return sb.toString();
+    }
+
     @PreAuthorize("@perm.hasAny('module_payment:account:receipt')")
     @GetMapping("/receipt/{enterprise_id}/{file_id}")
     public Result<?> queryReceipt(

+ 46 - 0
java/src/main/java/com/payment/platform/module/payment/account/service/AccountService.java

@@ -68,6 +68,7 @@ public class AccountService {
     private final PayBillMapper payBillMapper;
     private final F2fTradeRecordMapper f2fTradeRecordMapper;
     private final AlipayTransferService alipayTransferService;
+    private final ReceiptFileFetcher receiptFileFetcher;
 
     public List<AccountVO> queryAccounts(String enterpriseId) {
         List<AccountVO> vos = accountMapper.selectList(
@@ -524,6 +525,51 @@ public class AccountService {
         return downloadReceipt(transfer.getEnterpriseId(), orderNo);
     }
 
+    /**
+     * 取回回单文件的字节 —— 供 {@code GET /receipt/file} 直接把文件交给浏览器。
+     *
+     * <p>与 {@link #downloadReceiptByOrderNo} 的区别只在最后一步:那个把支付宝给的
+     * {@code download_url} 原样交给前端去 {@code window.open},而那次跳转失败是静默的,
+     * 用户看到的是「下载成功」却什么都没发生。这里由服务端把字节取回来 ——
+     * <b>状态不是 SUCCESS 就一个字节都不去取,取不到就抛</b>,前端的成功提示才有依据。
+     */
+    public ReceiptFileFetcher.Fetched downloadReceiptFile(String orderNo) {
+        if (orderNo == null || orderNo.isBlank()) {
+            throw new BusinessException(400, "order_no 不能为空");
+        }
+        // 复用既有链路(apply + query),企业归属一并由它从转账记录里取
+        Map<String, Object> receipt = downloadReceiptByOrderNo(orderNo);
+        String status = (String) receipt.get("status");
+        Object errorMessage = receipt.get("error_message");
+        if (!"SUCCESS".equals(status)) {
+            if ("FAIL".equals(status)) {
+                throw new BusinessException(400,
+                        "回单生成失败: " + (errorMessage == null ? "未知错误" : errorMessage));
+            }
+            // 409 不是随便挑的:回单是异步生成的,第一次申请常拿到 PROCESS。
+            // 前端必须能分辨「还没生成好,值得再点一次」与「真失败了」——
+            // 这个码就是那个判据(前端见 account/index.vue 的 downloadReceiptWithRetry)。
+            throw new BusinessException(409,
+                    "回单尚未生成完成(当前状态: " + status + "),请稍后重试");
+        }
+        String downloadUrl = (String) receipt.get("download_url");
+        if (downloadUrl == null || downloadUrl.isBlank()) {
+            throw new BusinessException(400, "回单下载地址为空,请稍后重试");
+        }
+        return receiptFileFetcher.fetch(downloadUrl, receiptFilename(orderNo));
+    }
+
+    /**
+     * 上游没给文件名、或给的名字不可信时用的名字
+     * (不可信的判据见 {@code ReceiptFileFetcher.trustedFilename})。
+     *
+     * <p>我们自己在转账列表里就是这么叫这笔回单的 —— 一次下载多张时,用户能拿它对上列表里的行。
+     * 它至少不会是一串乱码,这一点比上游名字的「原汁原味」重要。
+     */
+    private static String receiptFilename(String orderNo) {
+        return "回单_" + orderNo + ".pdf";
+    }
+
 
     private AccountVO accountVO(AccountEntity a) {
         AccountVO vo = new AccountVO();

+ 293 - 0
java/src/main/java/com/payment/platform/module/payment/account/service/ReceiptFileFetcher.java

@@ -0,0 +1,293 @@
+package com.payment.platform.module.payment.account.service;
+
+import com.payment.platform.common.exception.BusinessException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+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.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 取回回单文件本身。
+ *
+ * <p>支付宝 {@code alipay.data.bill.accountbookereceipt.query} 只给一个 {@code download_url},
+ * 原来这个链接是直接丢给浏览器 {@code window.open} 的:那一步失败是静默的(返回 null,
+ * 调用方没查),用户就会看到「回单下载成功」却什么都没发生。改由服务端取字节,
+ * 取得到才算成功,取不到就抛 —— 前端那句「成功」才有依据。
+ *
+ * <p>不用项目里的 {@code RestTemplate}:这里要的是「有超时、有 UA、跟着跳转」的一发 GET,
+ * 不是 JSON 往返。超时必须显式给 —— {@code java.net.URL.openStream()}(见
+ * {@code FileService.downloadFromUrl})没有超时,一个不响应的上游会把请求线程一直挂住。
+ */
+@Slf4j
+@Component
+public class ReceiptFileFetcher {
+
+    /** 取回的内容与文件名。文件名已做过净化,可直接放进响应头。 */
+    public record Fetched(byte[] content, String filename) {
+    }
+
+    private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
+    private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
+
+    /**
+     * 默认 UA 是 {@code Java-http-client/<版本>}。这里换成普通浏览器 UA:
+     * 回单链接是给人点的(浏览器直开),用非浏览器 UA 去取是否会撞上 CDN 的策略**未实测**,
+     * 但换成浏览器 UA 没有任何代价,也没理由让服务端以另一种身份去取同一个链接。
+     */
+    private static final String USER_AGENT =
+            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
+                    + "Chrome/120.0.0.0 Safari/537.36";
+
+    private static final String DEFAULT_FILENAME = "receipt.pdf";
+
+    private static final Map<String, String> EXT_BY_CONTENT_TYPE = Map.of(
+            "application/pdf", ".pdf",
+            "image/jpeg", ".jpg",
+            "image/png", ".png",
+            "application/zip", ".zip");
+
+    private static final Pattern RFC5987_FILENAME =
+            Pattern.compile("filename\\*\\s*=\\s*([^;]+)", Pattern.CASE_INSENSITIVE);
+    private static final Pattern PLAIN_FILENAME =
+            Pattern.compile("filename\\s*=\\s*([^;]+)", Pattern.CASE_INSENSITIVE);
+
+    /**
+     * {@code Redirect.NORMAL} 会跟着 301/302 走,但**不会**从 https 掉到 http
+     * —— 这个链接是带下载凭据的能力 URL,不该被重定向降级成明文。
+     */
+    private static final HttpClient CLIENT = HttpClient.newBuilder()
+            .connectTimeout(CONNECT_TIMEOUT)
+            .followRedirects(HttpClient.Redirect.NORMAL)
+            .build();
+
+    /**
+     * 取回 bytes;任何一步不成立都抛 {@link BusinessException},绝不返回一个「当成成功」的空壳。
+     *
+     * @param fallbackName 上游没给名字、或给的名字不可信(见 {@link #trustedFilename})时用的名字。
+     *                     由调用方给 —— 它知道这个文件是什么,比任何从响应里猜出来的名字都准。
+     */
+    public Fetched fetch(String url, String fallbackName) {
+        if (url == null || url.isBlank()) {
+            throw new BusinessException(400, "回单文件获取失败:下载地址为空");
+        }
+        HttpRequest request;
+        try {
+            request = HttpRequest.newBuilder(URI.create(url))
+                    .timeout(REQUEST_TIMEOUT)
+                    .header("User-Agent", USER_AGENT)
+                    .GET()
+                    .build();
+        } catch (IllegalArgumentException e) {
+            throw new BusinessException(400, "回单文件获取失败:下载地址不合法");
+        }
+        try {
+            HttpResponse<byte[]> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray());
+            int status = response.statusCode();
+            if (status != 200) {
+                throw new BusinessException(400, "回单文件获取失败(HTTP " + status + ")");
+            }
+            byte[] body = response.body();
+            if (body == null || body.length == 0) {
+                throw new BusinessException(400, "回单文件获取失败:内容为空");
+            }
+            String filename = filenameFrom(
+                    response.headers().firstValue("Content-Disposition").orElse(null),
+                    response.headers().firstValue("Content-Type").orElse(null),
+                    url,
+                    isBlank(fallbackName) ? DEFAULT_FILENAME : fallbackName);
+            log.info("回单文件已取回: {} 字节, 文件名={}", body.length, filename);
+            return new Fetched(body, filename);
+        } catch (IOException e) {
+            throw new BusinessException(400, "回单文件获取失败:" + e.getMessage());
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new BusinessException(400, "回单文件获取被中断");
+        }
+    }
+
+    /**
+     * 定文件名:上游 {@code Content-Disposition} → URL 最后一段 → {@code fallback};
+     * 仍没有扩展名时按 {@code Content-Type} 补一个。
+     *
+     * <p>两条上游来源都要先过 {@link #trustedFilename} —— 不可信的一律当作「没给」,
+     * 继续往下走。结果会被 {@link #sanitize} 净化:它要回填进我们自己的响应头。
+     */
+    static String filenameFrom(String contentDisposition, String contentType, String url, String fallback) {
+        String name = trustedFilename(fromContentDisposition(contentDisposition));
+        if (isBlank(name)) {
+            name = trustedFilename(fromUrl(url));
+        }
+        if (isBlank(name)) {
+            name = fallback;
+        }
+        name = sanitize(name);
+        if (isBlank(name)) {
+            name = DEFAULT_FILENAME;
+        }
+        if (name.indexOf('.') < 0) {
+            String ext = EXT_BY_CONTENT_TYPE.get(normalizeContentType(contentType));
+            if (ext != null) {
+                name = name + ext;
+            }
+        }
+        return name;
+    }
+
+    private static String fromContentDisposition(String contentDisposition) {
+        if (isBlank(contentDisposition)) {
+            return null;
+        }
+        Matcher rfc5987 = RFC5987_FILENAME.matcher(contentDisposition);
+        if (rfc5987.find()) {
+            String value = unquote(rfc5987.group(1).trim());
+            int sep = value.indexOf("''");
+            if (sep >= 0) {
+                value = value.substring(sep + 2);
+            }
+            return percentDecode(value);
+        }
+        Matcher plain = PLAIN_FILENAME.matcher(contentDisposition);
+        if (plain.find()) {
+            return unquote(plain.group(1).trim());
+        }
+        return null;
+    }
+
+    private static String fromUrl(String url) {
+        if (isBlank(url)) {
+            return null;
+        }
+        try {
+            String path = URI.create(url).getPath();
+            if (isBlank(path)) {
+                return null;
+            }
+            int slash = path.lastIndexOf('/');
+            String last = slash >= 0 ? path.substring(slash + 1) : path;
+            return isBlank(last) ? null : percentDecode(last);
+        } catch (IllegalArgumentException e) {
+            return null;
+        }
+    }
+
+    /**
+     * 上游给的名字只有在**可信**时才用,否则返回 {@code null}(当作「上游没给名字」)。
+     *
+     * <p>为什么需要这一步:{@code java.net.http.HttpClient} 按 HTTP 规范把响应头字节当
+     * ISO-8859-1 解成 String,所以上游只要在 {@code filename="…"} 里直接写 UTF-8 中文,
+     * 我们拿到手时**就已经是乱码**({@code 回单.pdf} 变成一串 Latin-1 高位字符,
+     * 其中还夹着 C1 控制字符 —— 见 {@link #trustedFilename} 的测试)。
+     * 原样透传出去、再被浏览器按 UTF-8 解一次,用户看到的就是乱码文件名。
+     * 这条机制是实测的(本地回环探针 {@code logs/_cd-probe/})。
+     *
+     * <p>于是分三种情况:
+     * <ul>
+     *   <li>纯 ASCII,或已经是真 Unicode(如 {@code filename*=} 百分号解码出来的中文)—— 原样用;</li>
+     *   <li>含 Latin-1 高位字符但能**无损**还原成 UTF-8 —— 用还原后的(上游就是这种情形);</li>
+     *   <li>还原不了(上游用了别的编码,如 GBK),或串里已经出现 {@code U+FFFD}
+     *       (某个解码步骤已经丢过字节)—— 返回 {@code null},让调用方用自己的名字。
+     *       **宁可换个名字,也不能把乱码交给用户**。</li>
+     * </ul>
+     */
+    static String trustedFilename(String raw) {
+        if (isBlank(raw)) {
+            return null;
+        }
+        if (raw.indexOf('\uFFFD') >= 0) {
+            return null;
+        }
+        boolean hasHighLatin1 = false;
+        for (int i = 0; i < raw.length(); i++) {
+            char c = raw.charAt(i);
+            if (c > 0xFF) {
+                return raw;
+            }
+            if (c >= 0x80) {
+                hasHighLatin1 = true;
+            }
+        }
+        if (!hasHighLatin1) {
+            return raw;
+        }
+        String decoded = new String(raw.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);
+        return decoded.indexOf('\uFFFD') >= 0 ? null : decoded;
+    }
+
+    private static String unquote(String value) {
+        if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
+            return value.substring(1, value.length() - 1);
+        }
+        return value;
+    }
+
+    /**
+     * RFC 5987 是百分号编码,不是表单编码 —— {@code URLDecoder} 会把 {@code +} 解成空格,
+     * 于是文件名里的加号被吃掉。先把 {@code +} 转义掉再解。
+     */
+    private static String percentDecode(String value) {
+        try {
+            return URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8);
+        } catch (IllegalArgumentException e) {
+            return value;
+        }
+    }
+
+    /**
+     * 净化文件名。两条理由,都不是洁癖:
+     * <ul>
+     *   <li>这个串会被回填进我们自己的 {@code Content-Disposition} 响应头 ——
+     *       带 CR/LF 就是响应头注入;</li>
+     *   <li>它会成为用户磁盘上的文件名 —— {@code ../} 与 {@code ..\} 两种写法都要挡,
+     *       只取最后一段路径。</li>
+     * </ul>
+     */
+    static String sanitize(String name) {
+        if (name == null) {
+            return null;
+        }
+        String s = name.trim();
+        int cut = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'));
+        if (cut >= 0) {
+            s = s.substring(cut + 1);
+        }
+        StringBuilder sb = new StringBuilder(s.length());
+        for (int i = 0; i < s.length(); i++) {
+            char c = s.charAt(i);
+            if (c < 0x20 || c == 0x7f || c == '"' || c == '\'') {
+                continue;
+            }
+            sb.append(c);
+        }
+        s = sb.toString().trim();
+        if (s.length() > 120) {
+            int dot = s.lastIndexOf('.');
+            String ext = (dot > 0 && s.length() - dot <= 12) ? s.substring(dot) : "";
+            s = s.substring(0, 120 - ext.length()) + ext;
+        }
+        return s;
+    }
+
+    private static String normalizeContentType(String contentType) {
+        if (contentType == null) {
+            return "";
+        }
+        int semi = contentType.indexOf(';');
+        String type = semi >= 0 ? contentType.substring(0, semi) : contentType;
+        return type.trim().toLowerCase(Locale.ROOT);
+    }
+
+    private static boolean isBlank(String s) {
+        return s == null || s.isBlank();
+    }
+}

+ 275 - 0
java/src/test/java/com/payment/platform/module/payment/account/service/AccountServiceReceiptFileTest.java

@@ -0,0 +1,275 @@
+package com.payment.platform.module.payment.account.service;
+
+import com.alipay.api.AlipayClient;
+import com.alipay.api.domain.AlipayDataBillAccountbookereceiptQueryModel;
+import com.alipay.api.request.AlipayDataBillAccountbookereceiptQueryRequest;
+import com.alipay.api.response.AlipayDataBillAccountbookereceiptQueryResponse;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.alipay.AlipayClientFactory;
+import com.payment.platform.module.payment.account.entity.AccountEntity;
+import com.payment.platform.module.payment.account.entity.TransferEntity;
+import com.payment.platform.module.payment.account.mapper.AccountMapper;
+import com.payment.platform.module.payment.account.mapper.DepositMapper;
+import com.payment.platform.module.payment.account.mapper.TransferMapper;
+import com.payment.platform.module.payment.account.mapper.WithdrawMapper;
+import com.payment.platform.module.payment.facetoface.mapper.F2fTradeRecordMapper;
+import com.payment.platform.module.payment.notification.mapper.PayBillMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * 「下载回单」把文件交给浏览器的后半段。
+ *
+ * <p>前端原来的做法是拿后端给的 {@code download_url} 去 {@code window.open} —— 那一步失败
+ * 是静默的(返回 null,调用方没查),用户看到的是「回单下载成功」却什么都没发生。
+ * 现在这一段搬到服务端:由服务端取回字节,前端再落盘。所以这里钉的是
+ * <b>「后端说 SUCCESS」与「真的取到字节」之间不许再有缝</b> —— 状态不是 SUCCESS 时
+ * 一个字节都不许去取,取不到就抛,让前端把失败照实显示出来。
+ */
+@ExtendWith(MockitoExtension.class)
+class AccountServiceReceiptFileTest {
+
+    private static final String ORDER_NO = "ORDER-1";
+    private static final String ENTERPRISE_ID = "ENT-1";
+    private static final String DOWNLOAD_URL = "https://files.example.com/receipt/abc.pdf";
+
+    @Mock private AccountMapper accountMapper;
+    @Mock private TransferMapper transferMapper;
+    @Mock private DepositMapper depositMapper;
+    @Mock private WithdrawMapper withdrawMapper;
+    @Mock private AlipayClientFactory alipayClientFactory;
+    @Mock private RedisTemplate<String, Object> redisTemplate;
+    @Mock private ValueOperations<String, Object> valueOperations;
+    @Mock private PayBillMapper payBillMapper;
+    @Mock private F2fTradeRecordMapper f2fTradeRecordMapper;
+    @Mock private AlipayTransferService alipayTransferService;
+    @Mock private ReceiptFileFetcher receiptFileFetcher;
+    @Mock private AlipayClient alipayClient;
+
+    private AccountService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new AccountService(accountMapper, transferMapper, depositMapper, withdrawMapper,
+                alipayClientFactory, redisTemplate, payBillMapper, f2fTradeRecordMapper,
+                alipayTransferService, receiptFileFetcher);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper 可在无 Spring 上下文的单测里构造
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TransferEntity.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), AccountEntity.class);
+    }
+
+    // ==================== 正常路径 ====================
+
+    @Test
+    void receiptReady_returnsBytesAndUpstreamFilename() throws Exception {
+        givenTransfer();
+        givenSignedAccount("AGMT-1");
+        givenCachedFileId("FILE-1");
+        givenQuery(SUCCESS_RESPONSE());
+
+        when(receiptFileFetcher.fetch(eq(DOWNLOAD_URL), anyString()))
+                .thenReturn(new ReceiptFileFetcher.Fetched(new byte[]{1, 2, 3}, "abc.pdf"));
+
+        ReceiptFileFetcher.Fetched got = service.downloadReceiptFile(ORDER_NO);
+
+        assertArrayEquals(new byte[]{1, 2, 3}, got.content());
+        assertEquals("abc.pdf", got.filename());
+    }
+
+    @Test
+    void enterpriseIdComesFromTransferRecord_notFromCaller() throws Exception {
+        givenTransfer();
+        givenSignedAccount("AGMT-1");
+        givenCachedFileId("FILE-1");
+        givenQuery(SUCCESS_RESPONSE());
+        when(receiptFileFetcher.fetch(eq(DOWNLOAD_URL), anyString()))
+                .thenReturn(new ReceiptFileFetcher.Fetched(new byte[]{1}, "abc.pdf"));
+
+        service.downloadReceiptFile(ORDER_NO);
+
+        ArgumentCaptor<AlipayDataBillAccountbookereceiptQueryRequest> cap =
+                ArgumentCaptor.forClass(AlipayDataBillAccountbookereceiptQueryRequest.class);
+        verify(alipayClient).certificateExecute(cap.capture());
+        // 协议号只能由「转账记录里的 enterprise_id」查出来 —— 查单请求里带的就是这个企业
+        assertEquals("AGMT-1",
+                ((AlipayDataBillAccountbookereceiptQueryModel) cap.getValue().getBizModel()).getAgreementNo());
+    }
+
+    /**
+     * 兜底名由调用方给,且带单号:上游文件名不可信时用它 —— 我们在转账列表里就是这么叫这笔
+     * 回单的,一次下载多张时也能对上列表里的行。
+     */
+    @Test
+    void fallbackFilenameCarriesOrderNo() throws Exception {
+        givenTransfer();
+        givenSignedAccount("AGMT-1");
+        givenCachedFileId("FILE-1");
+        givenQuery(SUCCESS_RESPONSE());
+        when(receiptFileFetcher.fetch(anyString(), anyString()))
+                .thenReturn(new ReceiptFileFetcher.Fetched(new byte[]{1}, "abc.pdf"));
+
+        service.downloadReceiptFile(ORDER_NO);
+
+        verify(receiptFileFetcher).fetch(DOWNLOAD_URL, "回单_" + ORDER_NO + ".pdf");
+    }
+
+    // ==================== 状态不是 SUCCESS:一个字节都不许取 ====================
+
+    @Test
+    void receiptStillProcessing_throws_andNeverFetchesAnything() throws Exception {
+        givenTransfer();
+        givenSignedAccount("AGMT-1");
+        givenCachedFileId("FILE-1");
+        AlipayDataBillAccountbookereceiptQueryResponse resp = new AlipayDataBillAccountbookereceiptQueryResponse();
+        resp.setCode("10000");
+        resp.setStatus("PROCESS");
+        givenQuery(resp);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.downloadReceiptFile(ORDER_NO));
+
+        assertTrue(e.getMessage().contains("PROCESS"), "提示里要带上状态,否则用户无从判断该不该重试: " + e.getMessage());
+        // 409 是前端「值得再点一次」的判据(见 account/index.vue downloadReceiptWithRetry)。
+        // 这个码要是被改成 400,前端就会把「还没生成好」当终局失败 —— 用户得自己再点,
+        // 而这正是原来那套轮询要避免的。
+        assertEquals(409, e.getCode(), "「还没生成好」必须与真失败用不同的码区分开");
+        verifyNoInteractions(receiptFileFetcher);
+    }
+
+    @Test
+    void receiptFailed_surfacesUpstreamErrorMessage_andNeverFetches() throws Exception {
+        givenTransfer();
+        givenSignedAccount("AGMT-1");
+        givenCachedFileId("FILE-1");
+        AlipayDataBillAccountbookereceiptQueryResponse resp = new AlipayDataBillAccountbookereceiptQueryResponse();
+        resp.setCode("10000");
+        resp.setStatus("FAIL");
+        resp.setErrorMessage("账单不存在");
+        givenQuery(resp);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.downloadReceiptFile(ORDER_NO));
+
+        assertTrue(e.getMessage().contains("账单不存在"), e.getMessage());
+        assertNotEquals(409, e.getCode(), "真失败不能用「重试」那个码,否则前端会白转三次");
+        verifyNoInteractions(receiptFileFetcher);
+    }
+
+    /**
+     * SUCCESS 但没给下载地址 —— 这是最容易被写成「静默当成成功」的一格:
+     * 原前端正是在这里 `if (status === "SUCCESS" && download_url)` 落空后什么都不做。
+     */
+    @Test
+    void successStatusWithoutDownloadUrl_throws_andNeverFetches() throws Exception {
+        givenTransfer();
+        givenSignedAccount("AGMT-1");
+        givenCachedFileId("FILE-1");
+        AlipayDataBillAccountbookereceiptQueryResponse resp = new AlipayDataBillAccountbookereceiptQueryResponse();
+        resp.setCode("10000");
+        resp.setStatus("SUCCESS");
+        givenQuery(resp);
+
+        assertThrows(BusinessException.class, () -> service.downloadReceiptFile(ORDER_NO));
+
+        verifyNoInteractions(receiptFileFetcher);
+    }
+
+    @Test
+    void unknownOrder_throwsWithoutTouchingUpstream() {
+        when(transferMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.downloadReceiptFile(ORDER_NO));
+
+        // 文案沿用既有链路 downloadReceiptByOrderNo(它是「查不到转账记录」的唯一出口,
+        // 这里不另写一份);断言钉的是「用户被告知没找到转账记录」,不是某一串具体措辞。
+        assertTrue(e.getMessage().contains("转账记录"), e.getMessage());
+        verifyNoInteractions(alipayClientFactory, receiptFileFetcher);
+    }
+
+    @Test
+    void blankOrderNo_throwsWithoutQuerying() {
+        assertThrows(BusinessException.class, () -> service.downloadReceiptFile("  "));
+
+        verifyNoInteractions(transferMapper, alipayClientFactory, receiptFileFetcher);
+    }
+
+    @Test
+    void transferWithoutFundOrderId_throwsBeforeCallingUpstream() {
+        TransferEntity transfer = new TransferEntity();
+        transfer.setOrderNo(ORDER_NO);
+        transfer.setEnterpriseId(ENTERPRISE_ID);
+        when(transferMapper.selectOne(any())).thenReturn(transfer);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.downloadReceiptFile(ORDER_NO));
+
+        assertTrue(e.getMessage().contains("支付宝资金单号"), e.getMessage());
+        verifyNoInteractions(alipayClientFactory, receiptFileFetcher);
+    }
+
+    // ==================== 取文件的失败要照实冒出来 ====================
+
+    @Test
+    void fetchFailure_propagatesInsteadOfReportingSuccess() throws Exception {
+        givenTransfer();
+        givenSignedAccount("AGMT-1");
+        givenCachedFileId("FILE-1");
+        givenQuery(SUCCESS_RESPONSE());
+        when(receiptFileFetcher.fetch(eq(DOWNLOAD_URL), anyString()))
+                .thenThrow(new BusinessException(400, "回单文件获取失败(HTTP 403)"));
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.downloadReceiptFile(ORDER_NO));
+
+        assertTrue(e.getMessage().contains("403"), e.getMessage());
+    }
+
+    // ==================== fixtures ====================
+
+    private void givenTransfer() {
+        TransferEntity transfer = new TransferEntity();
+        transfer.setOrderNo(ORDER_NO);
+        transfer.setEnterpriseId(ENTERPRISE_ID);
+        transfer.setFundOrderId("FUND-1");
+        when(transferMapper.selectOne(any())).thenReturn(transfer);
+    }
+
+    private void givenSignedAccount(String agreementNo) {
+        AccountEntity account = new AccountEntity();
+        account.setEnterpriseId(ENTERPRISE_ID);
+        account.setAgreementNo(agreementNo);
+        when(accountMapper.selectOne(any())).thenReturn(account);
+    }
+
+    /** 申请回单命中 Redis 缓存 ⇒ 跳过 alipay.data.bill.ereceiptagent.apply 那一次出站。 */
+    private void givenCachedFileId(String fileId) {
+        when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+        when(valueOperations.get(anyString())).thenReturn(fileId);
+    }
+
+    private AlipayDataBillAccountbookereceiptQueryResponse SUCCESS_RESPONSE() {
+        AlipayDataBillAccountbookereceiptQueryResponse resp = new AlipayDataBillAccountbookereceiptQueryResponse();
+        resp.setCode("10000");
+        resp.setStatus("SUCCESS");
+        resp.setDownloadUrl(DOWNLOAD_URL);
+        return resp;
+    }
+
+    private void givenQuery(AlipayDataBillAccountbookereceiptQueryResponse resp) throws Exception {
+        when(alipayClientFactory.getClient(ENTERPRISE_ID)).thenReturn(alipayClient);
+        when(alipayClient.certificateExecute(any(AlipayDataBillAccountbookereceiptQueryRequest.class)))
+                .thenReturn(resp);
+    }
+}

+ 182 - 0
java/src/test/java/com/payment/platform/module/payment/account/service/ReceiptFileFetcherTest.java

@@ -0,0 +1,182 @@
+package com.payment.platform.module.payment.account.service;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 文件名推导与净化。
+ *
+ * <p>{@code fetch()} 本身要打网络,按仓库约束(不对网络行为做自动化测试)不在这里测;
+ * 这里只钉住「上游给的字串变成什么文件名」这一段纯函数 —— 它有两处不是洁癖的理由:
+ * 结果会回填进我们自己的 {@code Content-Disposition} 响应头(带 CR/LF 就是响应头注入),
+ * 也会成为用户磁盘上的文件名({@code ../} 只取最后一段)。
+ */
+class ReceiptFileFetcherTest {
+
+    private static final String FALLBACK = "receipt.pdf";
+
+    private static String name(String contentDisposition, String contentType, String url) {
+        return ReceiptFileFetcher.filenameFrom(contentDisposition, contentType, url, FALLBACK);
+    }
+
+    // ==================== 取值顺序:Content-Disposition → URL → fallback ====================
+
+    @Test
+    void prefersFilenameFromContentDisposition() {
+        assertEquals("statement.pdf",
+                name("attachment; filename=\"statement.pdf\"", "text/plain",
+                        "https://files.example.com/download?token=abc"));
+    }
+
+    @Test
+    void contentDispositionWithoutQuotesAlsoWorks() {
+        assertEquals("statement.pdf",
+                name("attachment; filename=statement.pdf", null, "https://files.example.com/x"));
+    }
+
+    /** RFC 5987 形式(带 {@code filename*=})优先于同名参数。 */
+    @Test
+    void rfc5987WinsOverPlainFilename() {
+        assertEquals("回单.pdf",
+                name("attachment; filename=\"fallback.pdf\"; filename*=UTF-8''%E5%9B%9E%E5%8D%95.pdf",
+                        null, "https://files.example.com/x"));
+    }
+
+    /**
+     * RFC 5987 是百分号编码,不是表单编码。{@code URLDecoder} 默认把 {@code +} 解成空格 ——
+     * 名字里的加号会被静默吃成空格,所以这里单独钉一枚。
+     *
+     * <p>两枚输入缺一不可,而且**先红的那个才是判据**:合规编码({@code %2B})在两种实现下
+     * 结果相同,只有上游直接写了个裸 {@code +}(不严格合规但确实常见)才能把差异照出来 ——
+     * 第一版这里只写了 {@code %2B},变异实测「去掉转义仍全绿」,等于没钉住。
+     */
+    @Test
+    void plusInRfc5987IsNotDecodedAsSpace() {
+        assertEquals("a+b.pdf",
+                name("attachment; filename*=UTF-8''a+b.pdf", null, "https://files.example.com/x"),
+                "裸 + 要原样保留(URLDecoder 会把它解成空格)");
+        assertEquals("a+b.pdf",
+                name("attachment; filename*=UTF-8''a%2Bb.pdf", null, "https://files.example.com/x"),
+                "合规的 %2B 同样得到 +");
+    }
+
+    @Test
+    void fallsBackToLastUrlSegmentWhenNoContentDisposition() {
+        assertEquals("receipt-2026.pdf",
+                name(null, null, "https://files.example.com/bills/receipt-2026.pdf"));
+    }
+
+    /** URL 里的一段可能被百分号编码(中文文件名常见),要解回来。 */
+    @Test
+    void decodesPercentEscapesInUrlSegment() {
+        assertEquals("回单.pdf",
+                name(null, null, "https://files.example.com/bills/%E5%9B%9E%E5%8D%95.pdf"));
+    }
+
+    @Test
+    void fallsBackWhenUrlHasNoFilename() {
+        assertEquals(FALLBACK, name(null, null, "https://files.example.com/"), "URL 只有目录");
+        assertEquals(FALLBACK, name(null, null, "not a url"), "地址不合法也不该抛,退回默认名");
+        assertEquals(FALLBACK, name(null, null, null), "没有地址");
+    }
+
+    // ==================== 扩展名:上游没给就按 Content-Type 补 ====================
+
+    @Test
+    void appendsExtensionFromContentTypeWhenNameHasNone() {
+        // 回单链接常见形态:路径最后一段是个动词,真正的类型只在响应头里
+        assertEquals("download.pdf", name(null, "application/pdf", "https://files.example.com/download"));
+        assertEquals("download.pdf",
+                name(null, "application/pdf;charset=UTF-8", "https://files.example.com/download"),
+                "带参数也要认得出来");
+    }
+
+    /** 已经带扩展名时不覆盖 —— 上游说的类型可能与我们猜的不同。 */
+    @Test
+    void keepsExistingExtension() {
+        assertEquals("receipt.png", name(null, "application/pdf", "https://files.example.com/receipt.png"));
+    }
+
+    @Test
+    void unknownContentTypeLeavesNameAsIs() {
+        assertEquals("download", name(null, "application/octet-stream", "https://files.example.com/download"));
+    }
+
+    // ==================== 上游文件名的编码:乱码不是「显示问题」,是字节问题 ====================
+
+    /**
+     * 上游若在 {@code filename="..."} 里直接写 UTF-8 中文,我们拿到手时**已经是乱码**。
+     *
+     * <p>机制是实测的(本地回环探针 {@code logs/_cd-probe/}):{@code java.net.http.HttpClient}
+     * 按 HTTP 规范把响应头字节当 ISO-8859-1 解成 String,于是 {@code 回单.pdf} 的 UTF-8 字节
+     * 变成 {@code å\x9B\x9Eå\x8D\x95.pdf}。原样透传出去,浏览器再按 UTF-8 一解 —— 用户看到的
+     * 就是乱码文件名。这里的 mojibake 是**用实测到的同一套字节关系**造出来的。
+     */
+    @Test
+    void repairsUtf8FilenameMisdecodedAsLatin1() {
+        String mojibake = new String("回单.pdf".getBytes(StandardCharsets.UTF_8),
+                StandardCharsets.ISO_8859_1);
+        assertEquals("回单.pdf", name("attachment; filename=\"" + mojibake + "\"", null, null));
+    }
+
+    /**
+     * 救不回来(上游用的是别的编码,如 GBK)时**不能把乱码交给用户** —— 换调用方给的名字。
+     * 我们自己的名字不比上游的差,乱码则一定是错的。
+     */
+    @Test
+    void unrepairableFilenameFallsBackToCallerName() {
+        String gbkAsLatin1 = new String("回单.pdf".getBytes(Charset.forName("GBK")),
+                StandardCharsets.ISO_8859_1);
+        assertEquals(FALLBACK, name("attachment; filename=\"" + gbkAsLatin1 + "\"", null, null));
+    }
+
+    /**
+     * 已经在解码途中丢过字节的({@code U+FFFD})同样不可信 —— 这类串可能已经是「真 Unicode」
+     * (不触发 Latin-1 修复那条路),所以必须单独挡一次:丢掉的下标换不回来。
+     */
+    @Test
+    void replacementCharacterMakesNameUntrusted() {
+        assertEquals(FALLBACK,
+                name("attachment; filename*=UTF-8''%BB%D8%B5%A5.pdf", null, null));
+    }
+
+    // ==================== 净化 ====================
+
+    @Test
+    void stripsDirectoryTraversalFromUnquotedFilename() {
+        assertEquals("passwd", name("attachment; filename=../../etc/passwd", null, null));
+        assertEquals("passwd", name("attachment; filename=..\\..\\windows\\passwd", null, null),
+                "Windows 写法同样只取最后一段");
+    }
+
+    @Test
+    void stripsDirectoryTraversalFromUrl() {
+        assertEquals("passwd", name(null, null, "https://files.example.com/a/b/../../../etc/passwd"));
+    }
+
+    /** 这个串会回填进响应头;带 CR/LF 就是响应头注入。 */
+    @Test
+    void stripsControlCharacters() {
+        String got = name("attachment; filename=\"a\r\nX-Evil: 1.pdf\"", null, null);
+        assertFalse(got.contains("\r"), got);
+        assertFalse(got.contains("\n"), got);
+        assertFalse(got.contains("\""), got);
+        assertEquals("aX-Evil: 1.pdf", got, "只去掉控制字符与引号,其余照原样保留");
+    }
+
+    @Test
+    void truncatesOverlongNamesButKeepsExtension() {
+        String got = name("attachment; filename=\"" + "x".repeat(400) + ".pdf\"", null, null);
+        assertTrue(got.length() <= 120, "长度 " + got.length());
+        assertTrue(got.endsWith(".pdf"), got);
+    }
+
+    @Test
+    void blankEverythingFallsBackToDefault() {
+        assertEquals(FALLBACK, name("attachment; filename=\"   \"", null, "https://files.example.com/"));
+    }
+}