ThinkPHP 8 接口签名与幂等实战:中间件 + Redis 防重放与重复提交

2026-11-08 0 401

最近接了个线上的支付回调接口改造。改之前的情况是:前端下单按钮没有防抖,用户手抖点两下,后端就落两条订单;更麻烦的是有个第三方会对回调地址做重试,同一笔交易在数据库里出现了三次。查日志的时候发现,请求参数一模一样,只是间隔了两秒。

这类问题在 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 加时间戳保证请求不可重放,幂等键加唯一索引保证业务只执行一次。三者各管一段,缺任何一个都会留下口子。代码量不大,但每一段都得写对顺序,不然就会像我一开始那样,在测试环境怎么试都正常,上线之后被打回原形。

ThinkPHP 8 接口签名与幂等实战:中间件 + Redis 防重放与重复提交
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 接口签名与幂等实战:中间件 + Redis 防重放与重复提交 https://www.taomawang.com/server/thinkphp/3013.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务