最近接了个线上的支付回调接口改造。改之前的情况是:前端下单按钮没有防抖,用户手抖点两下,后端就落两条订单;更麻烦的是有个第三方会对回调地址做重试,同一笔交易在数据库里出现了三次。查日志的时候发现,请求参数一模一样,只是间隔了两秒。
这类问题在 ThinkPHP 里其实有很成熟的解法,核心就三件事:验签、防重放、幂等。前两件放在中间件里统一处理,第三件靠 Redis 的原子操作加数据库唯一索引兜底。下面把完整实现拆开讲,代码基于 ThinkPHP 8,可以直接往项目里搬。
先想清楚签名要签什么
签名的目的是证明”这个请求确实是我方客户端发的,而且中途没被改过”。所以参与签名的参数必须覆盖所有会影响业务结果的字段,同时要带上两个时间维度的东西:
- timestamp:请求发起的时间戳,服务端校验它和当前时间不能相差太多,默认给 300 秒
- nonce:一次性的随机串,服务端在 Redis 里记一笔,重复出现就拒绝
签名算法用 HMAC-SHA256,不直接用 MD5。原因不在于算法强度,而是客户端一旦泄露了密钥,MD5 拼接逻辑简单,别人随便用在线工具就能撞出来;HMAC 至少能让攻击者多一道构造成本。当然,真正的安全上限取决于密钥保管,别把密钥硬编码在 App 里才是根本。
拼接规则要写死在文档里,三端实现必须完全一致。我的做法是:把除 sign 之外的参数按 ASCII 升序排列,拼成 k1=v1&k2=v2 的字符串,末尾再追加 &key=密钥,最后对整串做 HMAC-SHA256 并转大写。数组参数统一用 json_encode,并且固定 JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES 这两个选项。这一点非常关键,PHP 和 JavaScript 对中文、斜杠、浮点数的序列化结果不一样,不统一的话联调永远对不上。
签名中间件
ThinkPHP 8 的中间件入口是 handle 方法,返回 Response 就相当于短路了后续流程。签名校验失败直接返回错误,不要抛异常,因为异常会走全局异常处理,返回结构可能和接口约定不一致。
namespace appmiddleware;
use Closure;
use thinkfacadeCache;
use thinkRequest;
use thinkResponse;
class ApiSign
{
// 允许的时间偏移,单位秒
protected int $timeout = 300;
// nonce 在 Redis 中保留的时间,要比 timeout 长,避免边界时间内重放
protected int $nonceTtl = 600;
public function handle(Request $request, Closure $next): Response
{
$params = $request->param();
$sign = (string)($params['sign'] ?? '');
$ts = (int)($params['timestamp'] ?? 0);
$nonce = (string)($params['nonce'] ?? '');
$appid = (string)($params['appid'] ?? '');
if ($sign === '' || $ts === 0 || $nonce === '' || $appid === '') {
return $this->fail(40001, '缺少签名参数');
}
if (strlen($nonce) < 16 || strlen($nonce) > 64) {
return $this->fail(40002, 'nonce 长度不合法');
}
if (abs(time() - $ts) > $this->timeout) {
return $this->fail(40003, '请求已过期');
}
$secret = $this->getSecret($appid);
if ($secret === null) {
return $this->fail(40004, '未知的应用标识');
}
$expect = $this->makeSign($params, $secret);
if (!hash_equals($expect, $sign)) {
return $this->fail(40005, '签名校验失败');
}
// 验签通过后再占用 nonce,顺序不能反
$redis = Cache::store('redis')->handler();
$key = "api:nonce:{$appid}:{$ts}:{$nonce}";
$ok = $redis->set($key, 1, ['nx', 'ex' => $this->nonceTtl]);
if (!$ok) {
return $this->fail(40006, '请求重复提交');
}
$request->appid = $appid;
return $next($request);
}
protected function makeSign(array $params, string $secret): string
{
unset($params['sign']);
ksort($params, SORT_STRING);
$pieces = [];
foreach ($params as $k => $v) {
if (is_array($v) || is_object($v)) {
$v = json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} elseif (is_bool($v)) {
$v = $v ? 'true' : 'false';
} elseif ($v === null) {
$v = '';
}
$pieces[] = $k . '=' . $v;
}
$raw = implode('&', $pieces) . '&key=' . $secret;
return strtoupper(hash_hmac('sha256', $raw, $secret));
}
protected function getSecret(string $appid): ?string
{
// 真实项目从数据库或配置中心读,这里简化
$map = [
'web' => 'b1f8c0a9e2d7460c',
'mobile' => '9a3c2e5f7b014d68',
];
return $map[$appid] ?? null;
}
protected function fail(int $code, string $msg): Response
{
return json(['code' => $code, 'msg' => $msg, 'data' => null], 200);
}
}
有几个顺序上的细节值得说。第一,timestamp 和 nonce 的检查要放在验签之前,因为过期请求没必要再花一次 HMAC 计算;第二,nonce 落 Redis 要放在验签之后,否则攻击者可以用垃圾请求把 Redis 塞满,这是一种变相的拒绝服务;第三,hash_equals 必须用,普通 == 比较字符串存在时序侧信道,虽然实际利用门槛不低,但成本几乎为零,没理由不用。
Redis 的 set 带 ['nx', 'ex' => 600] 参数是 phpredis 5.x 之后的写法,等价于 SET key value NX EX 600。如果你用的是 swoole 协程版的 Redis 客户端,参数格式略有差异,查一下对应文档就行。用 Cache::store('redis')->handler() 拿到底层句柄是关键,走 Cache::set 没法保证 NX 语义。
幂等中间件单独做一层
签名和防重放解决的是”请求是不是合法的”,但解决不了”这个业务操作有没有做过”。有些场景下请求完全合法,nonce 也各不相同,比如用户改了收货地址再点支付,两次请求是不同的请求,但业务上只应该有一个订单。这种情况要靠业务维度的幂等键。
我的做法是让客户端在请求头里带一个 Idempotency-Key,通常是一次页面会话内生成的 UUID。服务端用 appid + 接口路径 + key 组合成一个 Redis 键,做 24 小时的占位。这个中间件挂在需要幂等的路由上,比如下单、退款申请、支付回调。
namespace appmiddleware;
use Closure;
use thinkfacadeCache;
use thinkRequest;
use thinkResponse;
class Idempotency
{
// 占位有效期,单位秒
protected int $ttl = 86400;
// 处理中状态的过期时间,防止进程崩溃后键一直卡着
protected int $lockTtl = 60;
public function handle(Request $request, Closure $next): Response
{
$key = (string)$request->header('Idempotency-Key', '');
if ($key === '') {
return $next($request);
}
if (!preg_match('/^[0-9a-zA-Z-_]{8,64}$/', $key)) {
return json(['code' => 40010, 'msg' => '幂等键格式不合法'], 200);
}
$appid = $request->appid ?? 'anonymous';
$path = strtolower($request->pathinfo());
$store = "idem:{$appid}:{$path}:{$key}";
$redis = Cache::store('redis')->handler();
$locked = $redis->set($store, 'processing', ['nx', 'ex' => $this->lockTtl]);
if (!$locked) {
$cached = $redis->get($store);
// 正在处理中,让客户端稍后重试
if ($cached === 'processing') {
return json(['code' => 42901, 'msg' => '请求处理中,请勿重复提交'], 200);
}
// 处理完成,直接返回上一次的结果
$body = json_decode($cached, true);
return json($body ?: ['code' => 0, 'msg' => 'ok']);
}
try {
$response = $next($request);
$content = $response->getContent();
$redis->setex($store, $this->ttl, $content);
return $response;
} catch (Throwable $e) {
// 出异常要立刻释放,否则用户重试会被挡住
$redis->del($store);
throw $e;
}
}
}
这里有两层过期时间,容易搞混。lockTtl 只有 60 秒,用来防止结账过程中出现死锁;ttl 是 24 小时,用来保存最终结果供重放。业务正常走完后,setex 会覆盖掉 processing,把整个响应体缓存进去。这样同一个 key 再次进来,直接返回上次的响应,客户端拿到的东西完全一样。
把响应体整体缓存有个前提:接口的返回里不能包含时效性数据,比如当前时间戳、签名、一次性 token。下单接口一般没事,但如果你的接口返回里带了签名或短链,得在中间件里排除掉,或者干脆只缓存业务主键,让业务层自己回查。
数据库唯一索引是最后一道闸
Redis 的 SETNX 在单节点下是原子的,但在集群或主从切换的瞬间,理论上存在极小概率的并发穿透。这不是 ThinkPHP 的问题,任何基于 Redis 的分布式锁都有这个风险。所以落库这一步必须加唯一约束,捕获重复键异常之后把已存在的记录读出来返回,而不是直接抛 500。
namespace appservice;
use appmodelOrder;
use thinkfacadeDb;
class OrderService
{
public function create(array $data): array
{
$bizNo = $data['biz_no'];
try {
Db::startTrans();
$order = Order::create([
'biz_no' => $bizNo,
'user_id' => $data['user_id'],
'amount' => $data['amount'],
'status' => Order::STATUS_PENDING,
]);
Db::commit();
return $order->toArray();
} catch (thinkdbexceptionPDOException $e) {
Db::rollback();
// 唯一索引冲突:1062 是 MySQL 的 Duplicate entry
if ((int)$e->getCode() === 23000
&& str_contains($e->getMessage(), 'Duplicate entry')) {
$exist = Order::where('biz_no', $bizNo)->find();
if ($exist) {
return $exist->toArray();
}
}
throw $e;
}
}
}
这里有个坑:ThinkPHP 8 的 PDOException 实际来自 thinkdbexceptionPDOException,getCode() 返回的是 SQLSTATE 字符串 '23000',所以要先强转成整型再比较。另外不同数据库的错误码不一样,SQLite 是 19,PostgreSQL 是 23505,如果项目要支持多库,判断逻辑得抽出来。
还有一个更隐蔽的问题:唯一索引冲突之后,事务已经回滚了,此时再去查 biz_no 对应的记录,可能查不到,因为并发的那条记录其实也还在未提交状态。正确姿势是捕获到冲突后短暂重试一次查询,或者干脆把这段查询放到事务外面再包一层。我实际项目里是重试三次,每次间隔 50 毫秒,稳妥得多。
前端怎么配合签名
后端代码写完了,前端对不齐签名是最高频的联调问题。下面这段是浏览器端的实现,用的是原生 crypto.subtle,不需要额外引入库:
async function makeSign(params, secret) {
const keys = Object.keys(params)
.filter((k) => k !== 'sign')
.sort();
const raw = keys.map((k) => {
let v = params[k];
if (v === null || v === undefined) {
v = '';
} else if (typeof v === 'object') {
v = JSON.stringify(v);
} else if (typeof v === 'boolean') {
v = v ? 'true' : 'false';
}
return `${k}=${v}`;
}).join('&') + '&key=' + secret;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const buf = await crypto.subtle.sign('HMAC', key, enc.encode(raw));
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.toUpperCase();
}
需要留意两点。一是 JSON.stringify 在 JS 端不会转义中文和斜杠,和 PHP 加了 JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES 的结果正好一致,但如果 PHP 端忘了加这两个选项,中文参数就会签不上。二是 JS 的 Number 是双精度浮点,JSON.stringify(0.1 + 0.2) 得到的是 0.30000000000000004,PHP 端 json_encode(0.30000000000000004) 也会得到同样的字符串,这一块倒是能对上,但金额类字段还是建议传字符串,从源头避免精度问题。
小程序和 uni-app 端没有 crypto.subtle,得用 crypto-js 或者 jsrsasign。签名逻辑一样,只是 API 换一下,这里不再重复。
几个必须提前定的约定
GET 请求要不要签名?我的建议是签,但只在涉及敏感数据的 GET 上签,比如查询订单详情。公开的列表接口签名意义不大,反而增加服务端每次请求的计算成本。
文件上传接口怎么办?multipart/form-data 场景下签名只覆盖普通表单字段,文件内容不参与签名。文件本身的完整性靠后续的 MD5 校验,如果业务有要求,让客户端额外传一个 file_md5 字段参与签名即可。
密钥轮换怎么处理?别指望一次性换完。给每个 appid 维护一个当前密钥和一个上一版密钥,验签时先试当前的,失败再试旧的,两个都失败才拒绝。这样前端灰度更新期间不会大面积报错。旧密钥的保留时间以客户端强制更新的时间为准,一般是两个版本周期。
调试阶段怎么绕过?不要在生产环境留后门。我的做法是中间件里判断 app()->env->get('APP_DEBUG') 且请求来自内网 IP 时跳过 nonce 检查,只保留验签。这样本地调试不受 nonce 过期影响,线上也没有风险。
压一下看看效果
写完之后用 ab 压了一轮,单机 4 核,1000 并发下接口 QPS 从改造前的 3200 掉到 2700 左右,主要损耗来自 Redis 的一次 SET 和一次 SETNX。这个代价换来的是一批重复订单的消失,完全值得。如果对性能特别敏感,可以把 nonce 的 Redis 操作改成管道或者 Lua 脚本合并,能省下几百微秒,但代码可读性会明显下降。
整个方案没有什么高深技巧,核心是把三件事分清楚:验签保证请求来源可信,nonce 加时间戳保证请求不可重放,幂等键加唯一索引保证业务只执行一次。三者各管一段,缺任何一个都会留下口子。代码量不大,但每一段都得写对顺序,不然就会像我一开始那样,在测试环境怎么试都正常,上线之后被打回原形。

