You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

通过JavaScript从流加载CSV文件时下载空文件且文件名异常排查

流式CSV报表下载异常:下载undefined.txt空文件的问题排查与修复

问题描述

我用Java后端通过StreamingResponseBody实现了流式生成CSV报表的接口,前端用fetch请求该接口,但点击下载按钮后会下载一个名为undefined.txt的空文件,调试发现问题出在fetch请求环节,请问哪里出错了?

后端核心代码

// 生成CSV的方法
public void generateIntervalReportCsv(Appendable fileOutputStream, String dateFrom, String dateTo, String searchType) throws Exception {
    try (Printer csvPrinter = new PrinterCsvAdapter(new CSVPrinter(fileOutputStream, CSVFormat.EXCEL.builder().setDelimiter(";") .setHeader("PERIOD", "STATUS", "RECORDS COUNT").build()))) {
        generateIntervalReport(csvPrinter, dateFrom, dateTo, searchType);
    }
}

// RestController接口
@GetMapping("/interval-report-file-csv")
public ResponseEntity<StreamingResponseBody> generateReport(@RequestParam String dateFrom,
                                    @RequestParam String dateTo,
                                    @RequestParam String period,
                                    HttpServletResponse response) {
    response.setHeader(
            "Content-Disposition",
            "attachment;filename=My-Report.csv");

    StreamingResponseBody stream = out -> {
        try {
            processService.generateIntervalReportCsv(new OutputStreamWriter(out), dateFrom, dateTo, period);
        } catch (Exception e) {
            throw new RuntimeException("Error - ", e);
        }
    };

    return ResponseEntity.ok(stream);
}

前端核心代码

function downloadCsvReport() {
    const minutesToAbort = 5;
    const awaiting_response_time_in_ms = 1000 * 60 * minutesToAbort;

    if (!is_downloadIntervalReport_alreadyPushed) {
        is_downloadIntervalReport_alreadyPushed = true;
        let dateFrom = document.getElementById("periodStartDate").value;
        let dateTo = document.getElementById("periodEndDate").value;
        let searchType = document.getElementById("period").value;
        let buttonStyle = document.getElementById("csvButtonId").style;
        buttonStyle.backgroundColor = "gray";
        let url = "/interval-report-file-csv" + "?dateFrom=" + dateFrom + "&dateTo=" + dateTo + "&period=" + searchType;

        downloadIntervalReport_timeOfStartRequest = new Date();
        fetch(url, {signal: AbortSignal.timeout(awaiting_response_time_in_ms)})
            .then(async (res) => {
                if (res.status >= 200 && res.status < 300) {
                    return res;
                } else {
                    throw new Error(await res.text());
                }
            })
            .then(res => {
                res.blob().then(file => {
                    let tempUrl = URL.createObjectURL(file);
                    const aTag = document.createElement("a");
                    aTag.href = tempUrl;
                    aTag.download = decodeURI(res.headers.get('Content-Disposition').split('UTF-8\'\'')[1]);
                    document.body.appendChild(aTag);
                    aTag.click();
                    URL.revokeObjectURL(tempUrl);
                    aTag.remove();
                });
            }).catch((reason) => {
                if (reason.name === "AbortError") {
                    console.log("Request aborted");
                } else {
                    console.error(reason.message);
                }
            }).finally(() => {
                is_downloadIntervalReport_alreadyPushed = false;
                buttonStyle.backgroundColor = "#d17b2e";
            });
    }
}

错误原因分析

1. 后端问题

  • Content-Disposition头未遵循RFC标准的UTF-8编码格式,前端无法正确解析文件名
  • 未设置Content-Type为text/csv,浏览器无法识别文件类型,默认使用txt后缀
  • 直接通过HttpServletResponse设置响应头,存在被Spring MVC覆盖的风险,不如通过ResponseEntity配置规范
  • OutputStreamWriter未指定编码,默认使用平台编码,可能导致内容乱码或空文件

2. 前端问题

  • 文件名解析逻辑依赖UTF-8''分割符,但后端未输出该格式的响应头,导致split结果为undefined,最终download属性为空
  • 手动拼接URL参数存在编码问题,可能导致请求参数错误,后端返回空内容
  • 嵌套的res.blob().then写法易出现异步逻辑问题,且未处理默认文件名的 fallback

修复方案

后端修复代码

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

// RestController接口优化
@GetMapping("/interval-report-file-csv")
public ResponseEntity<StreamingResponseBody> generateReport(@RequestParam String dateFrom,
                                    @RequestParam String dateTo,
                                    @RequestParam String period) {
    // 遵循RFC标准配置UTF-8编码的文件名
    String encodedFilename = URLEncoder.encode("My-Report.csv", StandardCharsets.UTF_8);
    String contentDisposition = String.format("attachment; filename*=UTF-8''%s", encodedFilename);
    
    StreamingResponseBody stream = out -> {
        try {
            // 指定UTF-8编码写入输出流,避免乱码
            try (OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)) {
                processService.generateIntervalReportCsv(writer, dateFrom, dateTo, period);
            }
        } catch (Exception e) {
            throw new RuntimeException("生成报表失败", e);
        }
    };

    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
            .header(HttpHeaders.CONTENT_TYPE, "text/csv; charset=UTF-8")
            .body(stream);
}

前端修复代码

function downloadCsvReport() {
    const minutesToAbort = 5;
    const awaiting_response_time_in_ms = 1000 * 60 * minutesToAbort;

    if (!is_downloadIntervalReport_alreadyPushed) {
        is_downloadIntervalReport_alreadyPushed = true;
        const dateFrom = document.getElementById("periodStartDate").value;
        const dateTo = document.getElementById("periodEndDate").value;
        const searchType = document.getElementById("period").value;
        const buttonStyle = document.getElementById("csvButtonId").style;
        buttonStyle.backgroundColor = "gray";
        
        // 使用URLSearchParams自动处理参数编码
        const params = new URLSearchParams({
            dateFrom,
            dateTo,
            period: searchType
        });
        const url = `/interval-report-file-csv?${params}`;

        downloadIntervalReport_timeOfStartRequest = new Date();
        fetch(url, {signal: AbortSignal.timeout(awaiting_response_time_in_ms)})
            .then(async (res) => {
                if (!res.ok) {
                    throw new Error(await res.text());
                }
                return res;
            })
            .then(async res => {
                const blob = await res.blob();
                const tempUrl = URL.createObjectURL(blob);
                const aTag = document.createElement("a");
                aTag.href = tempUrl;
                
                // 正确解析RFC标准的filename*格式响应头
                const disposition = res.headers.get('Content-Disposition');
                let filename = "My-Report.csv"; // 默认文件名
                if (disposition) {
                    const matches = disposition.match(/filename\*=UTF-8''(.*)/);
                    if (matches && matches[1]) {
                        filename = decodeURIComponent(matches[1]);
                    }
                }
                aTag.download = filename;
                
                document.body.appendChild(aTag);
                aTag.click();
                URL.revokeObjectURL(tempUrl);
                aTag.remove();
            })
            .catch((reason) => {
                if (reason.name === "AbortError") {
                    console.log("请求超时被终止");
                } else {
                    console.error("下载失败:", reason.message);
                }
            })
            .finally(() => {
                is_downloadIntervalReport_alreadyPushed = false;
                buttonStyle.backgroundColor = "#d17b2e";
            });
    }
}

内容的提问来源于stack exchange,提问作者Evgeniy Zelenskiy

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.15 10:27:08