ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

PHP微信支付V3实战:签名串、回调验签与对账兜底

PHP微信支付V3实战:签名串、回调验签与对账兜底

简介:面向具备一定 PHP 基础的开发者,这份案例围绕微信支付 V3 接口接入与调用展开,兼顾 V2 的部分常用能力,可用于理解统一下单、支付回调、订单查询、退款及安全验签等环节的实现方式。压缩包共 15 个文件,以 14 个 PHP 源码和 1 个 Markdown 说明为主,整体约 42KB,体量轻便,便于放入本地环境阅读调试。目录按配置、接口封装、支付方式与示例组织,涵盖商户参数与证书路径、核心支付类、JSAPI 与 Native 扫码支付、订单与退款操作、异步通知及日志记录等模块,适合按支付流程逐步对照学习。已有 2013 人学习,读者可借助现成封装减少签名、验签与请求发送的重复工作,理解回调验签和状态查询的配合方式,并参考示例搭建可运行的支付调用骨架,积累异常处理与日志排查思路。

1. 从一次「签名串多敲了一个换行」的联调说起:PHP 微信支付 V3 案例该落到哪些实处

某同学的毕设是给一家小型健身工作室做会员续费系统,后端 PHP 8.1 + ThinkPHP,需要在小程序里拉起支付。他找了份旧案例,把 appid、商户号、密钥填进去,跑了两天一直报签名错误,日志里只有一行看不懂的返回码。PHP 微信支付 V3 这类案例真正卡人的地方从来不是业务逻辑,而是签名串拼接、商户私钥加载、回调报文解密这三件小事。下面这套结构按需求拆解、选型、总体设计、核心实现、避坑排查、验收答辩的顺序展开,适合正在做支付模块的毕设或课设同学,也适合要在存量 PHP 项目里补一条完整支付链路的后端。主链路跑通以后,退款、查单、对账都是同一套签名机制换一个 URL 而已。

2. 需求拆解与选型:V3 接口为什么不能照搬 V2 的写法

把支付模块当成一个「需求包」来拆,第一件事是分清哪些是业务需求、哪些是接口协议强制要求。业务侧要的只有三件事:用户能付、付完系统知道、出问题能退。接口侧却塞进来一整套密码学约束,这些东西在 V2 时代是隐藏的,在 V3 里全部暴露给调用方,所以老代码直接复制粘贴基本必翻车。

2.1 V2 与 V3 的差异清单:签名、证书、加解密三处全变

V2 的心智模型是「拼接参数 + MD5/HMAC 加个 key」,密钥就是一串 32 位字符串,谁拿到都能算。V3 换成了非对称体系:请求用商户私钥做 SHA256-RSA 签名,应答和回调要用平台证书或支付公钥验签,敏感字段再用 APIv3 密钥做 AES-256-GCM 解密。三套材料互相独立,缺一个都跑不通。

对比项V2 写法V3 写法常见误用
请求认证参数排序后拼 key 做 MD5Authorization 头 + SHA256-RSA2048把签名当成参数塞进 body
密钥材料一个 API 密钥商户私钥 + 证书序列号 + APIv3 密钥三个值混用、互相填错位置
应答校验一般直接信任需验 Wechatpay-Signature 头跳过验签直接解析 JSON
回调数据XML 明文JSON + 密文 resource 字段直接读 resource 里的明文字段
金额单位分分,但类型为整数传字符串或传元

上表最后一行是血泪经验:金额传1.00这种元单位,接口不会报错,只会安静地少收一百倍或者多收一百倍,等到对账才发现。V3 的所有金额字段一律是整数分,total传100才是一元。

提示:证书序列号不是 APIv3 密钥,也不是商户号。三个值在商户平台的不同位置,配置时建议在配置文件里写清注释,否则半年后回来改代码只能靠猜。

2.2 PHP 技术选型:官方 SDK、Guzzle 自封装与框架中间件

PHP 侧一般有三条路。第一条是用现成的支付 SDK,装完填配置就能下单,缺点是很多老项目还停在 PHP 7.0,SDK 依赖的 PSR 版本对不上,强行装会把整棵依赖树拖乱。第二条是 Guzzle 自封装一个轻量客户端,只实现签名、请求、验签、解密四件事,代码量大概两三百行,可控性最好。第三条是在框架里做成中间件或服务类,把签名逻辑收口到一处,业务层只调$pay->jsapi($order)。

我给毕设项目的建议是第二条路起步,理由很实际:答辩时要讲清楚签名是怎么算出来的,你自己封装的代码能一行行讲,纯调 SDK 容易被问住。等主链路稳定了再考虑要不要换 SDK。

<?php // composer.json 里只需要这两个基础依赖,避免引入整个支付 SDK // "require": { "guzzlehttp/guzzle": "^7.8", "ext-openssl": "*", "ext-json": "*" } final class WxPayClient { public function __construct( private string $mchId, // 商户号,纯数字 private string $serialNo, // 商户证书序列号,40 位十六进制大写 private string $privateKeyPath, // apiclient_key.pem 绝对路径 private string $apiV3Key, // 32 字节 APIv3 密钥,仅用于解密 private string $baseUri = 'https://api.mch.weixin.qq.com' ) {} }

这段构造参数就是整套方案的「材料清单」。mchId和serialNo会写进 Authorization 头,privateKeyPath只在本地参与签名、绝不外发,apiV3Key不参与请求签名、只参与解密,这个分工一定要在代码注释里写清楚。

2.3 支付场景边界:JSAPI、Native、小程序各要准备什么

下单接口的 URL 不同,前置条件也不同。JSAPI 和小程序必须有openid,需要先走网页授权或小程序登录拿到;Native 扫码只需要商户参数,返回一个code_url生成二维码;H5 需要额外的scene_info字段描述终端环境;App 支付要单独申请应用并配appid。

对毕设来说,最省事的组合是小程序 + JSAPI 下单,因为openid在登录环节天然就有。如果做的是 PC 端演示,用 Native 扫码更方便答辩时现场演示——把code_url转成二维码贴到页面上,老师用手机扫一下就完成支付,比在小程序里点半天直观。这里要注意:Native 的code_url有效期默认两小时,过期后必须重新下单,不能把旧链接一直挂在页面上。

还有一个容易被忽略的边界:notify_url必须是公网可访问的 HTTPS 地址,本地localhost收不到回调。开发阶段要么用内网穿透工具做临时映射,要么在联调时改用「主动查单」轮询来模拟回调,两种方式在后面的实现章节都会给出。

3. 总体架构设计:一条支付链路的六个层次与两张核心表

支付模块最怕写成「控制器里下单、回调里发货、发货失败没人管」的一坨。正确的做法是先把链路切开,每一层只做一件事,出问题时能定位到具体层。我一般按六层来划:入口层负责接收前端请求和参数校验,签名层负责所有密码学操作,业务层负责订单状态流转,通知层负责回调接收与幂等,对账层负责定时查单补偿,日志层负责留痕。

3.1 分层结构:六个层次的职责与调用方向

前端(小程序 / PC 页面) │ 下单请求 ▼ 入口层 PayController 参数校验、登录态校验、防重复提交 │ ▼ 业务层 OrderService 生成 out_trade_no、落库、状态机流转 │ ▼ 签名层 WxPayClient 构造 Authorization、发送 HTTPS 请求、应答验签解密 │ ▼ 外部接口 api.mch.weixin.qq.com │ 异步回调(HTTPS POST) ▼ 通知层 NotifyController 验签 → 解密 → 幂等 → 更新订单 → 返回成功应答 │ ▼ 对账层 CheckOrderJob 定时扫描超时未支付 / 已支付未发货订单,主动查单兜底

这个结构里最关键的一条约束是:签名层不知道业务,业务层不碰密码学。很多翻车案例都是业务代码里直接拼签名串,改一个字段忘了同步改签名,结果报签名错误却查不出原因。

对账层是「后悔药」。回调可能因为网络抖动、服务重启、异常抛出而丢失,只依赖回调的支付系统一定有漏单。定时任务每 30 秒扫一次超过 2 分钟仍未支付成功的订单,主动调查单接口拉最新状态,这条兜底逻辑比回调本身更重要。

3.2 订单与流水表设计:字段、索引与状态机

两张表够用:订单主表记录一笔交易的最终归属,流水表记录每一次状态变化,便于出问题时回溯。主表的out_trade_no必须建唯一索引,这是幂等的最后一道防线。

CREATE TABLE `pay_order` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `out_trade_no` varchar(64) NOT NULL COMMENT '商户订单号,唯一', `transaction_id` varchar(64) DEFAULT NULL COMMENT '支付侧订单号,回调后回填', `user_id` bigint unsigned NOT NULL, `amount` int unsigned NOT NULL COMMENT '金额,单位分', `subject` varchar(128) NOT NULL, `scene` varchar(16) NOT NULL DEFAULT 'jsapi' COMMENT 'jsapi/native/h5', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已关闭 3退款中 4已退款', `paid_at` datetime DEFAULT NULL, `expire_at` datetime NOT NULL COMMENT '本地过期时间,通常下单后 2 小时', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_out_trade_no` (`out_trade_no`), KEY `idx_status_expire` (`status`, `expire_at`), KEY `idx_user` (`user_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单主表';

三个索引各有用途:唯一索引保证同一订单号只能存在一条记录,idx_status_expire服务对账层的扫描,idx_user服务用户订单列表。amount用int unsigned而不是decimal,因为接口层就是整数分,存成decimal反而要做一次单位换算,多一次出错机会。

状态机同样要固定下来:0 → 1(支付成功)、0 → 2(超时关闭或用户取消)、1 → 3 → 4(退款)。任何不符合这个流转的请求都直接拒绝并打日志,比如收到了一个status=1订单的回调,说明是重复通知,直接返回成功应答但不做业务处理。

3.3 配置与密钥管理:商户私钥、APIv3 密钥、平台证书的存放与加载

密钥管理是毕设里最容易埋雷的地方。常见错误是把apiclient_key.pem提交到代码仓库、把 APIv3 密钥写死在控制器里、把私钥内容和证书序列号放反。正确做法是配置走环境变量或独立的config/pay.php,该文件加入.gitignore,仓库里只保留一份pay.php.example。

<?php // config/pay.php —— 该文件不入库,仓库里只保留 pay.php.example return [ 'mch_id' => env('WXPAY_MCH_ID'), 'serial_no' => env('WXPAY_SERIAL_NO'), 'private_key' => env('WXPAY_PRIVATE_KEY_PATH', BASE_PATH . '/cert/apiclient_key.pem'), 'api_v3_key' => env('WXPAY_API_V3_KEY'), // 必须 32 字节 'cert_path' => env('WXPAY_CERT_PATH', BASE_PATH . '/cert/apiclient_cert.pem'), 'notify_url' => env('APP_URL') . '/pay/notify', ];

加载私钥时要注意格式差异:openssl_pkey_get_private对 PKCS#8 和 PKCS#1 都能读,但如果文件首行是-----BEGIN RSA PRIVATE KEY-----,说明是 PKCS#1,某些库会不认,稳妥的做法是统一转成 PKCS#8。另一个细节是读文件失败时openssl_pkey_get_private返回false而不是抛异常,必须显式判断并抛出带路径的错误信息,否则后续openssl_sign只会静默失败。

关于平台证书和支付公钥:早期接入方式需要先调证书下载接口拿到平台证书,再按Wechatpay-Serial头去匹配;后来支持了「公钥模式」,直接在商户平台取一个公钥字符串配置到本地。毕设场景下用公钥模式更省事,少一次证书下载和定期轮换的逻辑,验签时用公钥替代证书即可,代码结构完全一致。

4. 核心实现:签名、下单、回调验签三段关键代码

实现部分的顺序建议按「先能签出去,再能收回来」来推。第一步只验证 Authorization 头能否被接受,哪怕调一个最简单的查单接口,只要能拿回 200 就说明签名对了;第二步再做下单;第三步处理回调。跳过第一步直接写业务,出了问题分不清是签名错还是参数错,这是最耗时间的排查方式。

4.1 请求签名与 Authorization 头构造

V3 的签名串是五行固定格式,字段之间用\n分隔,最后一行请求体后面也要跟一个\n。URL 部分必须包含查询字符串、但不含协议和域名,比如/v3/pay/transactions/jsapi。请求体为空时,第五行就是一个空字符串,但那个\n不能省。

<?php /** * 构造待签名串:HTTP方法\nURL\n时间戳\n随机串\n请求体\n * 五个部分缺一不可,顺序也不能变 */ function buildSignMessage(string $method, string $url, int $timestamp, string $nonce, string $body): string { return strtoupper($method) . "\n" . $url . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; } /** * 生成 Authorization 头 * @param string $url 相对路径,必须带 query,且不做 urlencode 二次处理 * @param string $body 与实际发送的字节完全一致,不能重新 json_encode */ function buildAuthorization( string $mchId, string $serialNo, string $privateKeyPath, string $method, string $url, string $body ): string { $timestamp = time(); $nonce = bin2hex(random_bytes(16)); // 32 位随机串,每次请求都不同 $message = buildSignMessage($method, $url, $timestamp, $nonce, $body); $key = openssl_pkey_get_private(file_get_contents($privateKeyPath)); if ($key === false) { throw new RuntimeException('商户私钥加载失败,检查路径与 PEM 格式'); } if (!openssl_sign($message, $signature, $key, OPENSSL_ALGO_SHA256)) { throw new RuntimeException('签名失败:' . openssl_error_string()); } return sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%d",serial_no="%s"', $mchId, $nonce, base64_encode($signature), $timestamp, $serialNo ); }

这段代码里三个参数最容易被改坏。$url必须是相对路径,带上/v3/前缀,如果有 query 参数要原样拼在后面且顺序与发出去的一致;$body必须是最终发送的那个字符串,先json_encode存成变量、再用同一个变量签名和发送,绝对不能签一次、发之前又重新编码一次,JSON_UNESCAPED_UNICODE这类选项一改,签名字节就变了;$timestamp是秒级整数,与服务器时间偏差过大会被拒。

Authorization 头里的五个字段顺序官方没有硬性要求,但signature里的 base64 结果可能包含+、/、=,拼接时不要做 URL 编码,直接放进请求头即可。

4.2 JSAPI 与 Native 下单:请求体构造与应答验签解密

下单接口返回的是一段 JSON,prepay_id是下一步拉起支付的关键。请求体里的字段名大小写敏感,out_trade_no是下划线,mchid没有下划线,appid全小写,写错一个接口直接返回参数错误。

<?php $payload = [ 'appid' => $appid, 'mchid' => $mchId, 'description' => mb_substr($order['subject'], 0, 120), // 超长会被拒 'out_trade_no' => $order['out_trade_no'], 'notify_url' => $config['notify_url'], // 必须是公网 HTTPS 'amount' => [ 'total' => (int) $order['amount'], // 单位分,整数 'currency' => 'CNY', ], 'payer' => ['openid' => $openid], // Native 场景删掉这段 'attach' => json_encode(['uid' => $order['user_id']]), ]; // 注意:后续签名和发送必须复用 $body 这个字符串本身 $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $url = '/v3/pay/transactions/jsapi'; $auth = buildAuthorization($mchId, $serialNo, $keyPath, 'POST', $url, $body); $resp = $http->post($baseUri . $url, [ 'headers' => [ 'Authorization' => $auth, 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'User-Agent' => 'php-pay-demo/1.0', ], 'body' => $body, ]);

description有长度限制,商品名太长要截断,中文按字符截而不是按字节截,用mb_substr。attach字段可以用来带回自己的业务标识,长度有限制,塞一个用户 ID 就够,不要往里面塞 JSON 大对象。Native 下单只需要把payer去掉、URL 换成/v3/pay/transactions/native,返回体里的code_url拿去生成二维码。

拿到prepay_id之后,JSAPI 还需要二次签名给前端。这一步的签名串格式与请求签名不同,是「appId\n时间戳\n随机串\nprepay_id\n」四行,用同一把商户私钥签,前端拿到paySign后调起支付面板。二次签名的结果只在当前页面有效,不要缓存复用。

应答验签这一步在毕设里经常被跳过,理由是「反正能拿到数据」。但验签是判断响应确实来自支付方的唯一手段,实现成本也就十几行:从响应头取Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial,拼成「时间戳\n随机串\n响应体\n」后用平台公钥验,失败就丢弃这次响应。

4.3 回调通知的验签、解密与幂等入库

回调是整个链路里唯一由外部主动打进来的入口,必须当成不可信输入处理。处理顺序固定为:先验签,再解密,再校验业务字段,最后才动数据库。顺序颠倒会出现「没验签就改了订单状态」的严重问题。

<?php // 1) 验签:头部四个值 + 原始请求体 $message = $headers['Wechatpay-Timestamp'] . "\n" . $headers['Wechatpay-Nonce'] . "\n" . $rawBody . "\n"; $ok = openssl_verify($message, base64_decode($headers['Wechatpay-Signature']), $publicKey, OPENSSL_ALGO_SHA256); if ($ok !== 1) { throw new RuntimeException('回调验签失败,丢弃'); } // 2) 解密 resource 里的密文 $res = json_decode($rawBody, true)['resource']; $ciphertext = base64_decode($res['ciphertext']); $plaintext = openssl_decrypt( $ciphertext, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $res['nonce'], substr($ciphertext, -16), // GCM 的认证标签固定为密文最后 16 字节 $res['associated_data'] ); if ($plaintext === false) { throw new RuntimeException('解密失败,检查 APIv3 密钥长度是否为 32 字节'); } $data = json_decode($plaintext, true);

openssl_decrypt的第六个参数是 tag,它在ciphertext的末尾 16 字节,不在头部,这个位置搞反会一直解密失败。associated_data可能是空字符串,传空串即可,不要传null。

解密出来的$data里有out_trade_no、transaction_id、trade_state、amount.total等字段。此时不要直接信任金额,正确做法是拿out_trade_no查本地订单,比对本地金额与回调金额是否一致,不一致就告警不发货。

幂等的实现依赖数据库而不是内存锁:

<?php $pdo->beginTransaction(); try { // 行锁 + 状态判断,保证同一订单只会被处理一次 $stmt = $pdo->prepare('SELECT id, status, amount FROM pay_order WHERE out_trade_no = ? FOR UPDATE'); $stmt->execute([$data['out_trade_no']]); $order = $stmt->fetch(PDO::FETCH_ASSOC); if (!$order) { throw new RuntimeException('订单不存在:' . $data['out_trade_no']); } if ((int) $order['amount'] !== (int) $data['amount']['total']) { throw new RuntimeException('金额不一致,拒绝发货'); } if ((int) $order['status'] !== 0) { // 已经是已支付或已关闭,说明是重复通知,直接返回成功 $pdo->commit(); return ['code' => 'SUCCESS']; } $upd = $pdo->prepare('UPDATE pay_order SET status = 1, transaction_id = ?, paid_at = NOW() WHERE id = ?'); $upd->execute([$data['transaction_id'], $order['id']]); // 发货逻辑放在同一事务里,或者写成可重试的任务 (new DeliveryService($pdo))->grant((int) $order['id']); $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); // 事务回滚后返回失败,让支付方按策略重试 throw $e; }

事务里做了四件事:锁行、校验金额、推进状态、执行发货。任何一步失败就回滚并抛异常,回调应答返回非成功状态码,支付方会在后续时间点重试,这条重试机制就是天然的补救通道。应答体保持{"code":"SUCCESS","message":"成功"}或直接返回 204,两者都能被接受,关键是别返回 200 带一堆业务错误码。

4.4 退款与查单:把「不确定状态」交给主动查询

退款接口是/v3/refund/domestic/refunds,需要out_refund_no(退款单号,同样要唯一)和amount.refund。退款是异步的,接口返回的status只能是PROCESSING,最终结果要么等退款回调,要么主动查退款单。毕设里通常只需要做「申请退款 + 定时查结果」两段。

查单接口是 GET,URL 带 query,签名时那个带参数的完整路径必须原样参与签名:

<?php $outTradeNo = $order['out_trade_no']; $url = '/v3/pay/transactions/out-trade-no/' . $outTradeNo . '?mchid=' . $mchId; $auth = buildAuthorization($mchId, $serialNo, $keyPath, 'GET', $url, ''); // GET 请求体为空串

对账层定时任务扫status=0 AND expire_at < NOW()的订单,逐条查单:返回SUCCESS就补一次发货并更新状态,返回NOTPAY且已过期就把订单置为关闭,返回CLOSED也置为关闭。这条兜底链路看起来冗余,但它能救回所有回调丢失的订单,是答辩时最能体现工程思维的模块。

5. 避坑与排查:联调期最容易翻车的五个点

支付接口的报错信息极其吝啬,多数时候只给你一个SIGN_ERROR或PARAM_ERROR。想在最短时间内定位,靠的不是打印整个响应,而是知道每类错误的根因集中在哪。下面五条是我在几个项目里反复见到的翻车现场,按出现频率排序。

5.1 签名错误:九成是签名串本身拼错了

现象是每次请求都返回 401 和SIGN_ERROR,换了密钥、换了证书序列号都没用。原因基本是签名串不匹配:URL 带了域名、query 参数顺序与发出去的不一致、请求体签名时用了格式化 JSON 但发送时用了紧凑 JSON、末尾少了那个\n。

解决办法是把待签名串落盘打印出来,逐字节对比。写一个调试开关,只在测试环境打开,把buildSignMessage的返回值和base64后的签名一起写进日志,再用任意一个在线 RSA 验签工具用对应公钥自验一次,能自验通过说明签名逻辑没问题,问题就在「签的内容和发的内容不是同一份」。定位到这一层,剩下就是逐字段核对。

5.2 回调重复触发导致重复发货

现象是用户明明付了一笔钱,会员天数涨了三倍。原因是回调可能因为应答超时、网络抖动被重复推送,而代码里只做了UPDATE ... SET status = 1,没有判断前置状态。

解决办法是「锁行 + 状态判断」,也就是 4.3 里那段事务逻辑:只有当前状态为待支付才推进,其余情况直接返回成功应答。另外发货动作要设计成幂等的,比如「按订单号发放一次」而不是「按次数累加」,即使状态判断被绕过也不会重复发。数据库的唯一索引是最后一道防线,能在并发场景下挡住重复插入。

5.3 平台证书序列号不匹配

现象是回调验签一直失败,日志里Wechatpay-Serial头和你配置的公钥/证书对不上。原因是平台证书有自己的有效期,换证之后序列号变了;或者用了公钥模式却还在拿证书去验。

解决办法是先把响应头里的序列号打印出来,和你本地配置的做对比。如果用的是证书模式,需要把证书下载逻辑做成定时任务,缓存所有未过期的平台证书,验签时按序列号挑对应的那把;如果用公钥模式,就没有轮换问题,配置一次即可。毕设项目强烈建议用公钥模式,能省掉一整个模块的复杂度。

5.4 金额单位与类型传错

现象是接口不报错,但对账时金额差了一百倍。原因是把元当成分传了,或者把100传成了"100"字符串,某些字段接受字符串但在校验环节会出现隐性行为差异。

解决办法是在业务层就统一「金额只以整数分存在数据库和内存里」,前端展示时再除以 100。写一个formatAmount(int $fen): string专门做展示转换,禁止在业务代码里出现* 100或/ 100这种散落各处的换算。回调里的金额一定要和本地金额比对,不一致直接告警,这条校验能拦住绝大多数金额类事故。

5.5 只能靠回调,没有主动查询兜底

现象是偶尔出现「用户说付了钱,系统还是待支付」。原因就是回调丢了,而代码完全依赖回调驱动,没有任何补偿机制。

解决办法是加一个定时任务,每分钟扫一次两分钟前创建、状态仍是待支付的订单,主动调查单接口。查单结果以接口返回为准,SUCCESS就补发货。这个任务同时承担另一个职责:把超时未支付的订单关掉,释放库存或优惠名额。写这个任务时注意控制并发,多实例部署时要加分布式锁或者用SELECT ... FOR UPDATE SKIP LOCKED分批取,否则同一订单会被多个进程同时处理。

注意:排查阶段不要在生产环境打印完整请求体和响应体,里面含openid、金额、订单号等敏感信息。日志脱敏规则要在项目初期就定好,后期补起来很麻烦。

6. 验收与答辩:用一份自检清单证明这条链路可靠

支付模块的验收不能只看「能不能付成功」,那只是最基础的一条。答辩时老师或者评审最常问的三个问题是:回调丢了怎么办、重复回调怎么办、退款的最终状态怎么确定。能把这三点讲清楚,这个模块的分量就够了。

我一般用一份自检清单逐项过,每项都要有对应的代码位置或者日志证据:

验收项判定标准观察点
请求签名正确下单接口返回 200 且有 prepay_id待签名串与发送体字节一致
应答验签生效篡改响应体后请求被拒绝验签代码有独立分支
回调验签生效伪造回调返回失败且不落库订单状态保持不变
幂等可靠同一回调连发三次只发一次货会员记录只有一条
兜底查单有效手动删除回调记录后订单仍能变已支付定时任务日志有查单记录
金额校验篡改回调金额后拒绝发货并告警告警日志有记录
超时关单过期订单状态变为已关闭定时任务每分钟执行

清单之外,演示流程也要设计。答辩现场网络不可控,直接连生产接口有风险,稳妥的做法是准备两套环境:一套连真实商户号用于证明链路通,一套用本地 mock 服务回放固定报文用于演示异常分支。伪造回调、重复回调、金额篡改这三种场景用 mock 演示,比现场碰运气靠谱得多。

还有个答辩技巧:把「签名串」这张纸准备好。当被问到 V3 的签名机制时,直接在纸上写出五行结构,标出每一行是什么、为什么末尾要换行,比口头解释有效十倍。同时准备好一句总结性的回答——「V3 的安全性来自三套独立材料的分工:私钥负责证明是我发的,公钥负责证明是你回的,APIv3 密钥负责让中间人看不到内容」,这句话能把整章的设计意图串起来。

我自己踩过最疼的一次坑,是早期版本里回调处理和订单查询用了两套不同的金额单位,测试环境全是整数所以一直没暴露,上线第一笔就少发了一个月会员。从那以后我每次做支付,都会在验收清单最前面加一条「金额单位全局自检」,用一条 SQL 把所有涉及金额的字段和接口参数列出来逐个核对,跑通才允许进入联调。希望这套结构和清单能帮到你,少走几段弯路。

本文还有配套的精品资源,点击获取

返回列表