虚拟商品怎么交付:下载链接、授权码、有效期,从下单到拿到货的完整链路怎么设计

简介: 付款成功的邮件到了,用户点开订单页,状态写着「已发货」,点下载得到一个 403。后台一看日志:文件确实在,只是链接里那个过期时间戳是两小时前生成的,而页面一直在渲染同一个旧链接。用户刷新了几次,还是同一个结果。 虚拟商品的交付链路里,「已发货」是最容易被做成一个布尔字段的东西——置成 1 就算完事。真实链路要回答的是另外几个问题:用户此刻能不能拿到、拿到的东西对不对、过期之后怎么再来一次、重复的回调会不会发两份。

付款成功的邮件到了,用户点开订单页,状态写着「已发货」,点下载得到一个 403。后台一看日志:文件确实在,只是链接里那个过期时间戳是两小时前生成的,而页面一直在渲染同一个旧链接。用户刷新了几次,还是同一个结果。

虚拟商品的交付链路里,「已发货」是最容易被做成一个布尔字段的东西——置成 1 就算完事。真实链路要回答的是另外几个问题:用户此刻能不能拿到、拿到的东西对不对、过期之后怎么再来一次、重复的回调会不会发两份。

把「交付完成」定义清楚

先给交付定一个可判定的标准:用户在订单页点开能拿到文件,或者客户端激活后能拿到有效的授权。后台把状态改成「已发货」不算交付完成,支付渠道回了 success 也不算交付完成。

围绕这个标准拆两层状态:

order:       created ──paid──▶ fulfilling ──▶ fulfilled
                                 └──▶ refunded / canceled

entitlement: pending ──issue──▶ active ──┬──▶ expired
                                          └──▶ revoked

订单表示「钱的事」,权益表示「货的事」。一个订单可以有多条权益(买了三个 SKU 就三条),权益才是下载和激活时真正要校验的东西。退款时把权益置 revoked,不用去改文件,也不用去追已经发出去的授权码——校验时读的是权益状态。

幂等键用 order_id + sku_id,在权益表上建唯一索引。支付渠道重发回调、用户重复点「我已完成支付」,都不会多出一条权益。

下载链接:私有存储加短期签名

两种常见做法都不合适:把文件放在 web 目录下,靠文件名猜不到来保护;或者生成一个永久链接写进邮件。前者一旦被转发就收不回来,后者在换域名、换存储之后全部失效。

做法是把文件放到 web 根目录之外(或对象存储的私有桶),对外只发带签名的临时地址。

// 签名密钥只从环境变量读,不写进代码和仓库
function dl_signing_key(): string {
   
    $k = getenv( 'DL_SIGNING_KEY' );
    if ( ! $k ) {
   
        throw new RuntimeException( 'DL_SIGNING_KEY 未配置' );
    }
    return $k;
}

function issue_download_url( int $orderId, int $itemId, int $ttl = 600 ): string {
   
    $exp     = time() + $ttl;
    $payload = $orderId . '|' . $itemId . '|' . $exp;
    $sig     = hash_hmac( 'sha256', $payload, dl_signing_key() );

    return '/download.php?' . http_build_query( [
        'o' => $orderId,
        'i' => $itemId,
        'e' => $exp,
        's' => $sig,
    ] );
}

function verify_download( array $q ): bool {
   
    $exp = (int) ( $q['e'] ?? 0 );
    if ( $exp < time() ) {
   
        return false;
    }
    $payload = (int) $q['o'] . '|' . (int) $q['i'] . '|' . $exp;
    return hash_equals( hash_hmac( 'sha256', $payload, dl_signing_key() ), (string) ( $q['s'] ?? '' ) );
}

download.php 里的校验顺序是固定的,别跳步:

if ( ! verify_download( $_GET ) ) {
    http_response_code( 403 ); exit; }

$stmt = $db->prepare(
    'SELECT f.file_key, f.file_name FROM entitlement e
       JOIN goods_file f ON f.sku_id = e.sku_id
     WHERE e.order_id = ? AND e.sku_id = ? AND e.status = "active"'
);
$stmt->execute( [ (int) $_GET['o'], (int) $_GET['i'] ] );
$row = $stmt->fetch();

if ( ! $row ) {
    http_response_code( 403 ); exit; }

$path = '/srv/private/goods/' . $row['file_key'];   // 只从库里取文件名,不拼用户输入
header( 'Content-Type: application/octet-stream' );
header( 'Content-Disposition: attachment; filename="' . basename( $row['file_name'] ) . '"' );
header( 'X-Content-Type-Options: nosniff' );
readfile( $path );

几个容易忽略的点:

  • TTL 短一点没关系。十分钟过期就够用,用户要重新下载就回订单页再点一次,重新签发。这样「链接过期」不会变成客服工单,因为页面上的按钮永远生成新链接。
  • 别把 IP 写死进签名。移动网络切个基站就换 IP,用户会莫名其妙拿到 403。要防转发,用下载次数上限加并发限制,再配合水印。
  • 水印比拦截有效。PDF 在生成时打上订单号和下单邮箱,图片走动态水印接口,泄露之后能追溯来源,比事后猜是谁转发的实用。
  • 目录穿越:file_key 从数据库读,不要用 $_GET['file'] 拼路径,也别忘 basename()。

授权码:发放只是开头,绑定和换机才是成本

卡密类商品的麻烦从来不在生成,而在用户换电脑、重装系统、同事帮忙激活这些场景。

生成用 random_bytes,转成去掉易混字符的 Base32,按 5 位分组,手抄和电话报读都不会错:

function new_license_key(): string {
   
    // A–Z 去掉 I、O,加上 2–9(不要 0、1),正好 32 个字符
    $alphabet = implode( '', array_diff( array_merge( range( 'A', 'Z' ), range( 2, 9 ) ), [ 'I', 'O' ] ) );
    $raw      = random_bytes( 16 );
    $out      = '';
    for ( $i = 0; $i < 16; $i++ ) {
   
        $out .= $alphabet[ ord( $raw[ $i ] ) % 32 ];
        if ( $i % 5 === 4 && $i < 15 ) $out .= '-';
    }
    return $out;
}

存储上有个取舍要提前定:只存哈希就只能校验,用户中心里看不到原码;要在后台展示原码,就得加密存储,并且展示接口单独鉴权。两边都想要又都做一半,最后往往两边都不成立。

激活接口写成一条条件插入,把「名额还有没有」交给数据库判断,比先查再插安全:

INSERT INTO license_activation (license_id, device_hash, activated_at, last_seen)
SELECT l.id, :device_hash, NOW(), NOW()
  FROM license l
 WHERE l.id = :license_id
   AND l.status = 'active'
   AND ( SELECT COUNT(*) FROM license_activation a WHERE a.license_id = l.id ) < l.max_devices
ON DUPLICATE KEY UPDATE last_seen = NOW();

受影响行数是 0 的时候,要再查一次区分原因:权益不存在、已封禁、名额满。情况不同就返回不同错误码,客户端才能给出不同的话术——一律返回「激活失败」,用户只会来问客服。

设备标识用客户端生成的 UUID 存本地,再加一层硬件指纹的哈希;服务端只存哈希。换机走自助解绑,限制频率(比如 30 天一次),每次记审计。封禁之前先限流:同一授权码短时间内在多个地区激活,先拒绝并告警,人工确认后再封,直接封会把正常出差的用户一起封掉。

有效期:字段只是开始

有效期至少要存三个字段:starts_at、expires_at、grace_hours,都用 UTC。不要存「剩余天数」,那个值一存下来就开始过期。

起点三选一,并且写进商品页和条款:付款时间、首次激活时间、首次交付时间。订阅类按周期另算。选哪个都有道理,含糊其辞才是问题——用户和客服对「一年」的理解不一致,纠纷就来了。

到期前提醒做三段:T-30 天、T-7 天、T-1 天,邮件里带一键续期链接,落回订单页而不是首页。到期后权益怎么处理,也要提前写死:只读、降级、数据保留多少天。到期当天才告诉用户「数据将被删除」,是投诉高发区。

校验时以服务端时间为准,客户端时钟只用于界面提示。留一个宽限期,用来处理时区和支付延迟。

回调:先落库,再处理,条件更新

支付回调的三个老问题——重复、乱序、延迟——都能用同一套办法压住:回调只做两件事,落事件表、返回 200;真正的发货走异步任务。

-- 事件表唯一键 = 渠道流水号,重复回调直接忽略
INSERT IGNORE INTO payment_event (channel_txn_id, order_id, payload, created_at)
VALUES (:txn, :order_id, :payload, NOW());

-- 状态迁移用条件更新,避免乱序把已退款改回已支付
UPDATE orders SET status = 'paid', paid_at = NOW()
 WHERE id = :order_id AND status = 'created';

库存扣减同理,条件更新比锁表简单:

UPDATE stock SET used = used + 1 WHERE sku = :sku AND used < total;
-- 受影响行数 0:缺货,转人工或退款,不要静默失败

回调延迟时,用户往往比回调先到。订单页给一个「我已完成支付」的按钮,服务端主动调渠道的查单接口,查到就补一次发货,比让用户干等强。

用户在哪查:订单页是唯一真源

权益存在服务端,前端不需要缓存。刷新、换设备、清 cookie,回订单页都还在。

邮件里不要直接塞下载地址,塞一个带一次性 token 的深链,点开之后换登录态再跳订单页。邮件被转发出去,别人也拿不到东西。

订单页要显示:交付类型(下载/授权码/账号)、当前状态、有效期、重新签发按钮、补发或申诉入口。用户在别处看到的任何信息,都应该是这一页的子集。

跑批对账,别等用户来报

  • 每分钟:paid 超过 5 分钟仍是 fulfilling 的订单,报警
  • 每小时:fulfilled 但三十分钟内零下载、零激活的,抽查
  • 每天:revoked 之后仍有下载请求的,看是不是缓存或签名未失效
  • 每笔下载和激活留 request_id、IP 哈希、UA 哈希,出问题能还原时间线

欧盟数字内容的撤回权那块,结算页需要用户明确同意并确认放弃撤回权,勾选时间、文案版本一起存下来,别只存一个勾没勾。AumDigital 处理的是 WooCommerce 这一段的结账和撤回权记录,交付本身的状态机还是按上面的方式落:aumcreate.com/plugins/aumdigital。

相关文章
|
云安全 安全 网络安全
|
15天前
|
JSON 程序员 数据格式
最新版Notepad++下载、安装、配置保姆级教程(附官网安装包)
Notepad++ 是一款免费开源、轻量快速的 Windows 文本编辑器,支持多标签、语法高亮、代码折叠与宏录制,体积小、启动快、资源占用低,兼容 x64/x32/ARM 架构,是程序员和文字工作者的高效利器。
3650 0
|
8天前
|
JavaScript 安全 前端开发
Nodejs Brotli decoder 代码执行漏洞 (CVE-2026-111497)10629
Node.js发布安全更新修复Brotli解码器高危RCE漏洞(CVE-2026-111497),建议受影响版本用户立即升级。
2046 2
|
14天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
2459 46
|
11天前
|
Web App开发 人工智能 前端开发
2026 年 AI 浏览器怎么选?Ego Lite、Tabbit、Chrome DevTools MCP 深度对比,开发者到底该用哪个?
2026年AI浏览器选型指南:Ego Lite专注多Agent协同(Mac优先),Tabbit国产首选,原生支持Windows与Codex控制;Chrome DevTools MCP非浏览器而是开发者利器,深度集成Console/Network/Performance调试能力,让AI真正“看懂并验证”前端代码。
1472 3
|
9天前
|
开发者 索引
DeepSeekHarness大肥鱼自带插件市场了?加个地址就能用
Open Plugin Market 是一个开放插件索引平台,收录800+优质DSH插件,支持分类浏览、智能推荐与随机发现。无需安装市场,粘贴索引地址即可一键接入,网页版直访 dshopen.org,助力小开发者被看见。(239字)
1957 1
DeepSeekHarness大肥鱼自带插件市场了?加个地址就能用
|
7天前
|
人工智能 Linux API
Magpie, 轻量本地AI网关!免费Token用不完!
Magpie 是开源AI编程模型调度网关,上线一月获1.8万Star。它通过本地高性能网关统一托管多平台API密钥、账号与免费额度,实现跨Agent的模型秒级切换、静默额度轮换与资源复用,显著降低碎片化管理成本。(239字)
Magpie, 轻量本地AI网关!免费Token用不完!
|
8天前
|
安全 应用服务中间件 Go
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
PHP2Shell 双 RCE 已遭在野利用:WordPress、Drupal 全在射程内,nginx 其实另有洞。拆成因与修复顺序
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
|
4天前
|
人工智能 安全 API
2026 年阿里云双 11|百炼大模型 API 指南:免费 Token 领取、API Key 生成、代码调用实战教程
对于零基础的AI开发者、技术爱好者和小型开发团队来说,低成本、低门槛调用主流大模型接口,是开启AI项目原型开发的必经之路。百炼大模型服务平台汇集了百余款主流大模型,面向新用户提供大规模免费推理额度,不需要预先充值,即可完成文本生成、逻辑推理、代码编写、多模态解析等各类开发验证工作。很多新手在初次接触平台时,常常遇到不清楚免费额度规则、不知道如何生成API‑Key、环境配置错误、调用接口持续报错等一系列问题。
604 1

热门文章

最新文章