需求背景

项目中的文件(如送货照片、生产文件、设计源文件等)存储在云存储(阿里云OSS/腾讯云COS等),文件名使用MD5哈希值命名。用户下载时希望按照一定的命名规则生成有意义的文件名,例如:项目编号_生产文件.pdf

解决方案

采用后端代理下载方案,通过创建下载代理接口,在响应头中设置 Content-Disposition 指定自定义文件名。

方案优势

  1. 通用性强 - 适用于所有云存储(OSS、COS、七牛等)
  2. 支持中文文件名 - 正确处理中文编码
  3. 灵活命名 - 支持模板变量和自定义函数
  4. 自动回退 - 可从Attachment表自动读取原始文件名

后端实现

下载代理接口

文件路径: application/admin/controller/Ajax.php

/**
 * 代理下载文件(支持自定义文件名)
 * @param string $url 文件URL路径
 * @param string $name 自定义文件名(可选,不传则从Attachment表读取原始文件名)
 * @param string $suffix 文件后缀(可选,用于指定后缀)
 */
public function download()
{
    $url = $this->request->get('url');
    $name = $this->request->get('name', '');
    $suffix = $this->request->get('suffix', '');
    
    if (empty($url)) {
        $this->error('文件路径不能为空');
    }
    
    // 处理URL,获取完整URL
    $fullUrl = cdnurl($url, true);
    
    // 尝试从Attachment表获取原始文件名
    $attachment = null;
    if (empty($name)) {
        $attachment = \app\common\model\Attachment::where('url', $url)->find();
        if ($attachment) {
            $name = $attachment->filename;
        }
    }
    
    // 如果还是没有文件名,使用URL中的文件名
    if (empty($name)) {
        $name = basename(parse_url($fullUrl, PHP_URL_PATH));
    }
    
    // 获取文件后缀
    if (empty($suffix)) {
        if ($attachment && !empty($attachment->imagetype)) {
            $suffix = $attachment->imagetype;
        } else {
            $suffix = pathinfo($name, PATHINFO_EXTENSION);
            if (empty($suffix)) {
                $suffix = pathinfo(parse_url($fullUrl, PHP_URL_PATH), PATHINFO_EXTENSION);
            }
        }
    }
    
    // 确保文件名有后缀
    if ($suffix && !preg_match('/\.' . preg_quote($suffix, '/') . '$/i', $name)) {
        $name .= '.' . $suffix;
    }
    
    try {
        // 使用cURL获取文件内容
        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL => $fullUrl,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_TIMEOUT => 300,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_SSL_VERIFYHOST => false,
            CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
        ]);
        
        $content = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
        $error = curl_error($ch);
        curl_close($ch);
        
        if ($httpCode !== 200 || $error) {
            $this->error('文件获取失败: ' . ($error ?: 'HTTP ' . $httpCode));
        }
        
        $filesize = strlen($content);
        $encodedName = rawurlencode($name);
        
        // 设置响应头
        header('Content-Type: ' . ($contentType ?: 'application/octet-stream'));
        header('Content-Length: ' . $filesize);
        header('Content-Disposition: attachment; filename="' . $encodedName . '"; filename*=UTF-8\'\'' . $encodedName);
        header('Cache-Control: must-revalidate');
        header('Pragma: public');
        header('Expires: 0');
        
        echo $content;
        exit;
        
    } catch (\Exception $e) {
        $this->error('下载失败: ' . $e->getMessage());
    }
}

API 接口说明

参数类型必填说明
urlstring文件URL路径(相对路径)
namestring自定义文件名(不传则从Attachment表读取)
suffixstring文件后缀(用于指定后缀)

调用示例:

GET /admin/ajax/download?url=/uploads/xxx.pdf&name=自定义文件名.pdf

前端实现

格式化函数

文件路径: public/assets/js/require-table.js

/**
 * 文件下载格式化(支持自定义文件名)
 * 使用方式:
 * 1. 基本用法:formatter: Table.api.formatter.download
 *    会自动从Attachment表读取原始文件名
 * 2. 自定义文件名模板:
 *    formatter: Table.api.formatter.download,
 *    nameTemplate: '{title}_{field}', // 支持变量:{title}, {field}, {id}, {date}, {time}, {row.xxx}
 * 3. 完全自定义:
 *    formatter: Table.api.formatter.download,
 *    nameTemplate: function(value, row, index) { return '自定义文件名.pdf'; }
 */
download: function (value, row, index) {
    return Table.api.formatter.downloads.call(this, value, row, index);
},
downloads: function (value, row, index) {
    value = value == null || value.length === 0 ? '' : value.toString();
    var classname = typeof this.classname !== 'undefined' ? this.classname : 'img-sm img-center';
    var arr = value !== '' ? (value.indexOf('data:image/') === -1 ? value.split(',') : [value]) : [];
    var html = [];
    var column = this;
    var nameTemplate = typeof this.nameTemplate !== 'undefined' ? this.nameTemplate : '';
    
    $.each(arr, function (i, filePath) {
        var suffix = /[\.]?([a-zA-Z0-9]+)$/.exec(filePath);
        suffix = suffix ? suffix[1] : 'file';
        
        var iconUrl = Fast.api.fixurl("ajax/icon?suffix=" + suffix);
        var downloadUrl = Fast.api.fixurl("ajax/download") + "?url=" + encodeURIComponent(filePath);
        
        // 处理自定义文件名
        if (nameTemplate) {
            var customName = '';
            if (typeof nameTemplate === 'function') {
                customName = nameTemplate.call(column, value, row, index);
            } else {
                customName = nameTemplate
                    .replace(/\{title\}/g, row.title || '')
                    .replace(/\{field\}/g, column.field || '')
                    .replace(/\{id\}/g, row.id || '')
                    .replace(/\{date\}/g, new Date().toISOString().slice(0, 10))
                    .replace(/\{time\}/g, new Date().toISOString().slice(11, 19).replace(/:/g, ''));
                customName = customName.replace(/\{row\.([^}]+)\}/g, function(match, key) {
                    return row[key] || '';
                });
            }
            if (customName) {
                if (suffix && customName.indexOf('.' + suffix) === -1) {
                    customName += '.' + suffix;
                }
                downloadUrl += "&name=" + encodeURIComponent(customName);
            }
        }
        
        html.push('<a href="' + downloadUrl + '" target="_blank" title="点击下载"><img src="' + iconUrl + '" class="' + classname + '" width="30" height="30"></a>');
    });
    return html.join(' ');
}

使用示例

方式一:基本用法(自动获取原始文件名)

{
    field: 'file',
    title: '文件',
    formatter: Table.api.formatter.download
}

下载时会自动从 Attachment 表读取原始上传文件名。

方式二:模板变量方式

{
    field: 'production_file',
    title: '生产文件',
    formatter: Table.api.formatter.download,
    nameTemplate: '{title}_{id}_{date}'
}

支持的模板变量:

变量说明
{title}行数据的 title 字段
{field}当前列的字段名
{id}行数据的 id
{date}当前日期(YYYY-MM-DD)
{time}当前时间(HHmmss)
{row.xxx}行数据的任意字段,如 {row.project_no}

方式三:函数方式(完全自定义)

{
    field: 'design_source_file',
    title: '设计源文件',
    formatter: Table.api.formatter.download,
    nameTemplate: function(value, row, index) {
        // 生成文件名:项目编号_设计源文件.后缀
        var suffix = /[\.]?([a-zA-Z0-9]+)$/.exec(value) || ['',''];
        return (row.project_no || row.id || 'design') + '_设计源文件.' + (suffix[1] || 'pdf');
    }
}

函数参数:

参数说明
value当前字段值(文件URL)
row当前行数据对象
index行索引

实际应用案例

文件路径: public/assets/js/backend/project.js

columns: [
    // ... 其他字段
    
    {
        field: 'production_file',
        title: __('Production_files'),
        operate: false,
        formatter: Table.api.formatter.download,
        nameTemplate: function(value, row, index) {
            var suffix = /[\.]?([a-zA-Z0-9]+)$/.exec(value) || ['',''];
            return (row.project_no || row.id || 'production') + '_生产文件.' + (suffix[1] || 'pdf');
        }
    },
    {
        field: 'design_source_file',
        title: __('Design_source_file'),
        operate: false,
        formatter: Table.api.formatter.download,
        nameTemplate: function(value, row, index) {
            var suffix = /[\.]?([a-zA-Z0-9]+)$/.exec(value) || ['',''];
            return (row.project_no || row.id || 'design') + '_设计源文件.' + (suffix[1] || 'pdf');
        }
    }
]

下载效果:

  • 原始文件: /uploads/20240115/a1b2c3d4e5f6.pdf
  • 下载文件名: P20240115001_生产文件.pdf

注意事项

  1. 性能考虑: 大文件下载会经过服务器中转,对于超大文件建议使用云存储的签名URL方式
  2. 安全限制: 确保下载接口有权限验证,防止未授权访问
  3. 编码问题: 使用 rawurlencodefilename*=UTF-8'' 双重保障中文文件名兼容性
  4. 缓存优化: 对于频繁下载的文件可考虑添加缓存机制

扩展:云存储签名URL方式(可选)

如果使用阿里云OSS,可以直接在生成签名URL时指定响应头:

$ossClient->signUrl($bucket, $object, $timeout, 'GET', [
    'response-content-disposition' => 'attachment; filename="' . urlencode($filename) . '"'
]);

腾讯云COS类似:

$cosClient->getPresignedUrl($bucket, $object, '+30 minutes', [
    'ResponseContentDisposition' => 'attachment; filename="' . urlencode($filename) . '"'
]);

这种方式无需经过服务器中转,下载速度更快,但需要根据具体云服务商API调整实现。

更多推荐