付款成功的邮件到了,用户点开订单页,状态写着「已发货」,点下载得到一个 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。