PAYATHON 2026

如何在付款后安全地允许下载 file.zip?

支付阿杰

结论

不要直接向用户暴露 file.zip 的真实地址,也不要仅凭付款成功后的页面跳转开放下载。建议采用以下流程:

  1. 通过支付平台的服务端回调确认付款。
  2. 为订单创建随机下载令牌,并限制有效期和下载次数。
  3. 将文件存放在 Web 根目录之外。
  4. 下载时,由服务端校验订单、账号、令牌、有效期和下载次数。
  5. 校验通过后,再由应用程序或 Web 服务器返回文件。

这种方案能减少链接被猜中、长期共享或无限次下载的风险,但无法彻底阻止购买者下载后再次分发。对于价值较高的文件,还可以加入用户水印、许可证绑定或专用客户端验证。

推荐流程

1. 使用支付回调确认付款

不要相信浏览器传来的 paid=1、跳转参数或前端支付结果,因为用户可以自行构造这些请求。

支付平台应通过 Webhook 向服务器发送通知。服务器完成以下检查后,才能将订单标记为已支付:

  • 回调签名是否合法;
  • 支付状态是否成功;
  • 商户订单号是否存在;
  • 支付金额和币种是否与订单一致;
  • 支付交易号是否已经处理;
  • 回调是否属于当前商户。

支付平台可能重复发送同一通知,因此 Webhook 必须具备幂等性。

2. 创建不可预测的下载令牌

确认付款后,生成高强度随机令牌,例如:

$token = bin2hex(random_bytes(32));

不要用订单号、用户 ID、时间戳或这些值的简单哈希作为令牌,因为它们可能被猜中或枚举。

数据库中最好只保存令牌的哈希值:

$tokenHash = hash('sha256', $token);

数据表可以采用类似的结构:

CREATE TABLE download_grants (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    token_hash CHAR(64) NOT NULL UNIQUE,
    expires_at DATETIME NOT NULL,
    max_downloads INT NOT NULL DEFAULT 3,
    download_count INT NOT NULL DEFAULT 0,
    revoked_at DATETIME NULL,
    created_at DATETIME NOT NULL
);

下载地址可以使用以下形式:

https://example.com/download.php?token=<随机令牌>

令牌的有效期不宜过长。具体时长取决于业务需求,可以是数小时或数天,没有适用于所有业务的固定值。

3. 将文件移出 Web 根目录

不要把文件放在能够直接访问的位置:

/var/www/html/files/file.zip

更合适的位置是:

/srv/private-downloads/file.zip

这样,用户无法绕过下载接口直接请求静态文件。

PHP 下载接口示例

以下示例假设用户必须登录,且数据库连接 $pdo 已启用异常模式。生产环境还需要补充完整的身份认证、日志和错误处理。

<?php

declare(strict_types=1);

session_start();

if (!isset($_SESSION['user_id'])) {
    http_response_code(401);
    exit('请先登录');
}

$token = $_GET['token'] ?? '';

if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
    http_response_code(400);
    exit('无效的下载链接');
}

$userId = (int) $_SESSION['user_id'];
$tokenHash = hash('sha256', $token);

try {
    $pdo->beginTransaction();

    $stmt = $pdo->prepare(
        'SELECT dg.id, dg.download_count, dg.max_downloads
           FROM download_grants dg
           JOIN orders o ON o.id = dg.order_id
          WHERE dg.token_hash = :token_hash
            AND dg.user_id = :user_id
            AND dg.revoked_at IS NULL
            AND dg.expires_at > CURRENT_TIMESTAMP
            AND o.payment_status = :payment_status
          FOR UPDATE'
    );

    $stmt->execute([
        'token_hash' => $tokenHash,
        'user_id' => $userId,
        'payment_status' => 'paid',
    ]);

    $grant = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$grant || (int) $grant['download_count'] >= (int) $grant['max_downloads']) {
        $pdo->rollBack();
        http_response_code(403);
        exit('下载链接无效、已过期或已达到次数限制');
    }

    $update = $pdo->prepare(
        'UPDATE download_grants
            SET download_count = download_count + 1
          WHERE id = :id'
    );
    $update->execute(['id' => $grant['id']]);

    $pdo->commit();
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    http_response_code(500);
    exit('暂时无法处理下载请求');
}

$file = '/srv/private-downloads/file.zip';

if (!is_file($file) || !is_readable($file)) {
    http_response_code(404);
    exit('文件不存在');
}

header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="file.zip"');
header('Content-Length: ' . filesize($file));
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, no-store');

$handle = fopen($file, 'rb');

if ($handle === false) {
    http_response_code(500);
    exit('无法读取文件');
}

fpassthru($handle);
fclose($handle);
exit;

SELECT ... FOR UPDATE 和计数更新位于同一个事务中,可以防止用户通过并发请求绕过次数限制。不同数据库的事务和行锁行为可能有所区别,需要根据实际使用的数据库进行确认。

大文件的处理方式

由 PHP 直接读取大文件会占用应用服务器资源。使用 Nginx 时,可以在权限校验通过后返回 X-Accel-Redirect,由 Nginx 传输文件。

Nginx 配置示例:

location /protected-downloads/ {
    internal;
    alias /srv/private-downloads/;
}

PHP 校验通过后返回:

header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="file.zip"');
header('X-Accel-Redirect: /protected-downloads/file.zip');
exit;

internal 会阻止外部用户直接访问 /protected-downloads/file.zip。如果使用 Apache、对象存储或 CDN,可以采用相应的内部转发或短期签名 URL 机制。

下载次数不宜简单限制为一次

一次性下载看起来更安全,却很容易影响正常用户,例如:

  • 网络中断;
  • 浏览器或下载器自动重试;
  • 用户更换设备;
  • 服务端开始响应后传输失败;
  • 断点续传产生多个 HTTP 请求。

通常可以采用更实用的策略:

  • 要求用户登录,并校验订单归属;
  • 设置短期有效的下载令牌;
  • 允许少量重试;
  • 在订单页面提供“重新生成下载链接”功能;
  • 发现异常后允许撤销令牌;
  • 限制请求频率和并发数。

如果需要支持 Range 断点续传,不能把每个 HTTP 请求都算作一次完整下载。可以按下载会话记录,也可以采用较宽松的次数限制。

其他安全注意事项

  • 全程使用 HTTPS,防止令牌在传输过程中泄露。
  • 不要在访问日志、分析系统或第三方跳转参数中记录完整令牌。
  • 为下载页面设置合适的 Referrer-Policy,减少令牌通过 Referer 泄露的可能。
  • 发生退款或拒付后,应撤销对应的下载权限。
  • 记录同一令牌的异常 IP、设备数量和下载频率,但不要只根据 IP 判断用户身份。
  • 使用通用错误信息,避免攻击者据此判断订单号或令牌是否真实存在。
  • 为私有文件设置最小文件系统权限,只允许负责传输文件的服务账号读取。
  • 对于高价值文件,可以按订单动态加入用户标识或不可见水印,以便追踪泄露来源。

下载页面本身不是权限凭证,支付后的重定向也不能证明用户已经付款。服务器必须在每次下载请求中重新验证授权。

备注:内容仅供参考。