简介:面向.NET开发者与需要接入移动支付、在线支付的项目团队,这份材料围绕C#环境下微信、支付宝、银联支付的整合展开,涉及JSAPI、Native、H5、APP及网页支付、扫码支付等常见场景,重点覆盖统一下单、预支付交易串生成、异步回调验签、支付状态确认以及查询、退款、撤销等后续操作。资源页显示文件总数为0、主要文件类型暂无明细,压缩包约52.91MB,具体内容以实际下载为准;其价值主要在Payment.Api接口层的组织思路,即对外提供统一支付入口,内部封装三家渠道的SDK调用与差异适配,便于业务系统模块化接入。材料还提示商户密钥与用户信息的加密保护、回调接口的幂等与校验、网络及参数异常处理,以及沙箱测试和合规要求。已有1495人学习,适合希望理清三方支付接入流程、对照实现统一支付服务的中初级.NET开发者参考。
1. 一套 C# 代码同时接微信、支付宝和银联,先解决的其实是对账
做过多渠道收银台的 .NET 开发者大概都见过这种场面:财务月底要导三份账单,微信支付一份、支付宝一份、银联一份,字段名各不相同,退款还得回各自的商户后台手工点。这个方案要解决的就是这件事——用一套 C#/.NET 支付网关,把微信支付、支付宝、银联三条通道收口到同一组接口、同一张订单表、同一份对账逻辑里。它适合正在做毕业设计或课程设计的人,把“多渠道支付集成”当成一个完整系统来交;也适合手里已经有 ASP.NET Core 项目、要把单通道扩成三通道的开发者,照着改就能用。下面按需求、架构、抽象层、渠道实现、避坑、验收六段走,代码能跑,表结构能抄,验收用例能直接进答辩 PPT。
2. 总体架构与选型:三层结构、五个模块和一张订单主表
支付集成真正容易出问题的地方,从来不是单个渠道的签名算法,而是三个渠道拼在一起之后订单状态对不上。同一笔订单,微信这边回调说成功,支付宝那边还在轮询,银联的前台跳转和后台通知又先后到达两次,如果没有统一的订单主表和状态机,业务层就会被三套语义撕裂。所以这套方案的第一步不是打开 IDE 写签名方法,而是先把层级划清楚:谁负责跟渠道说话,谁负责改订单状态,谁负责记录不可篡改的原始报文。
2.1 三层结构与模块职责划分
整体是接入层、支付核心层、渠道适配层三层,依赖方向严格单向:接入层调核心层,核心层调适配层,适配层不能反向引用核心层里的业务实体。这条约束看着像教条,实际是后期加渠道时的保命线——第四个渠道接进来的时候,你只需要新增一个适配器类,订单服务一行都不用改。
| 模块 | 职责 | 明确不做的事 |
|---|---|---|
| 接入层(PaymentController) | 登录态校验、参数格式校验、给前端返回统一结构 | 不参与签名,不拼渠道报文 |
| 订单服务(OrderService) | 建单、状态流转、幂等控制、超时关单 | 不关心渠道字段叫 prepay_id 还是 trade_no |
| 渠道适配层(XxxPayChannel) | 组包、签名、发请求、验签、解析回调 | 不直接写订单表,不改业务状态 |
| 回调处理器(NotifyHandler) | 原始报文存档、调用适配器验签、驱动订单服务 | 不在 HTTP 线程里跑长事务 |
| 对账任务(ReconcileJob) | 拉取渠道账单、与本地流水比对、出差异单 | 不修改已完成订单的金额 |
五个模块里最容易被学生方案忽略的是回调处理器。很多人把验签、改订单、发通知、扣库存全塞在一个 Controller 方法里,结果渠道因为超时重发通知,业务就被执行了两遍。正确的做法是:回调进来先落一条通知日志(带渠道通知唯一标识做唯一键),验签通过后只做状态推进,耗时逻辑丢进后台队列。
2.2 技术选型:为什么倾向 HttpClientFactory 而不是直接引三个官方 SDK
运行环境选 .NET 8(LTS),Web 框架用 ASP.NET Core,ORM 按团队习惯在 EF Core 和 Dapper 之间选一个——支付场景里 SQL 大多不复杂,Dapper 写起来更直接,EF Core 的优势在后台管理页面。定时对账用 Quartz.NET 或 Hangfire,日志用 Serilog 输出结构化 JSON,缓存用 Redis 存下单频率限制和二维码短链。
关键的取舍在“要不要用官方 SDK”。三个渠道的官方 .NET SDK 更新节奏不一致,有的还在依赖 Newtonsoft.Json 的某个旧版本,和你项目里已经升上去的版本打架;有的把签名逻辑封在内部,出错时只能看到一个“签名错误”,连待签名串都打印不出来,排查起来像个黑匣子。这套方案统一走原生 HttpClient + 自己封装的签名工具类,好处是待签名串、请求头、原始响应全在手里,出了问题能逐字节比对。
提示:项目周期紧、只接一个渠道、且不需要深度定制时,用官方 SDK 完全合理,别为了架构洁癖多写两千行。是否自建的判断标准是“渠道数量 ≥ 2 且需要统一日志与对账”。
HttpClient 的注册必须走 IHttpClientFactory,不要 new HttpClient,也不要写成单例。支付网关调用属于典型的短连接高频场景,套接字耗尽(SocketException)在压测时几乎必现,这个坑吃过一次就忘不掉。配合 Polly 加一个 8 秒超时和最多两次重试,注意重试只对“查询类”接口开,下单类接口重试要格外小心,否则可能生成两笔渠道订单。
// Program.cs:按渠道注册命名客户端,超时与重试策略分开配置 builder.Services.AddHttpClient("wechat", c => { c.BaseAddress = new Uri("https://api.mch.weixin.qq.com"); c.Timeout = TimeSpan.FromSeconds(10); }) .AddPolicyHandler(Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(8))); builder.Services.AddHttpClient("alipay", c => c.Timeout = TimeSpan.FromSeconds(10)); builder.Services.AddHttpClient("unionpay", c => c.Timeout = TimeSpan.FromSeconds(10));这段配置里有两个参数值得改:Timeout是 HttpClient 层面的总超时,Polly 的TimeoutAsync是策略层面的,后者要小于前者,否则异常类型会被外层吞掉,日志里只看到一个 TaskCanceledException,分不清是网络慢还是你自己设的超时。生产环境建议把网关地址做成配置项,不要在代码里写死域名。
2.3 统一订单模型与状态机
金额字段全链路用int(单位分)或long,从数据库到接口到渠道报文一律是分,只在页面展示时除以 100 转成decimal。这不是洁癖,是血泪经验:double表示 0.1 + 0.2 得到的 0.30000000000000004,在支付场景里就是一笔对不上的账。订单号生成规则建议是“业务前缀 + yyyyMMdd + 分布式序列”,长度控制在 32 位以内,因为银联的 orderId 有长度上限,微信的 out_trade_no 只允许字母数字和_-|*。
状态机是这套方案的骨架。状态只能单向流转,任何“从已支付回退到待支付”的操作都不允许出现,退款走独立状态而不是把订单改回去。
public enum PayStatus { Created = 0, // 已建单,未调渠道 Paying = 1, // 已向渠道下单,等待回调 Paid = 2, // 支付成功 Refunding = 3, // 退款中 Refunded = 4, // 已退款 Closed = 5 // 超时关闭或用户取消 } public static class PayStatusMachine { private static readonly Dictionary<PayStatus, PayStatus[]> Allowed = new() { [PayStatus.Created] = new[] { PayStatus.Paying, PayStatus.Closed }, [PayStatus.Paying] = new[] { PayStatus.Paid, PayStatus.Closed }, [PayStatus.Paid] = new[] { PayStatus.Refunding }, [PayStatus.Refunding] = new[] { PayStatus.Refunded }, [PayStatus.Refunded] = Array.Empty<PayStatus>(), [PayStatus.Closed] = Array.Empty<PayStatus>() }; public static bool CanTransit(PayStatus from, PayStatus to) => Allowed.TryGetValue(from, out var next) && next.Contains(to); }状态判断放在内存里只是第一道闸,真正的并发安全靠数据库。更新语句必须带上前置状态和版本号:UPDATE pay_order SET status = 2, row_version = row_version + 1 WHERE order_no = ? AND status = 1 AND row_version = ?,受影响行数为 0 就说明别人已经改过了,直接返回成功即可,不要抛异常。这样即使微信连发五次通知,订单也只会被推进一次。
3. 统一支付抽象层:一套接口统合三种渠道的差异
三个渠道的差异比想象中大。微信 V3 用 JSON + 自定义 Authorization 头,支付宝表单和 JSON 混用、回调是 form 表单,银联则是纯 key-value 表单提交加证书签名。如果业务代码里出现if (channel == "wechat")这种分支,说明抽象层没做对。这一章把接口、路由、表结构、密钥管理四件事定下来,后面接第四个渠道基本是填空。
3.1 支付能力接口与参数对象定义
接口只暴露业务真正需要的四件事:下单、退款、解析回调、查单。渠道特有的字段(微信的 openid、支付宝的 buyer_id、银联的 frontUrl)不要一股脑塞进方法签名,而是放进请求对象里,谁需要谁读,用不到就忽略。
public interface IPaymentChannel { string Code { get; } // wechat / alipay / unionpay Task<PayResult> CreatePayAsync(PayRequest request, CancellationToken ct = default); Task<RefundResult> RefundAsync(RefundRequest request, CancellationToken ct = default); Task<NotifyResult> ParseNotifyAsync(HttpRequest request, CancellationToken ct = default); Task<QueryResult> QueryAsync(string orderNo, CancellationToken ct = default); } // 金额一律用分;OpenId、ClientIp 只有部分渠道会用到,用不到的实现直接忽略 public record PayRequest( string OrderNo, int AmountFen, string Subject, string? OpenId, string ClientIp, string NotifyUrl); public record PayResult(bool Success, string? PrepayId, string? PayPayload, string? ErrorCode, string? ErrorMessage); public record NotifyResult(bool Verified, string? OrderNo, string? ChannelTradeNo, int AmountFen, string RawResponse);PayPayload这个字段是设计上的一个小机关:微信 Native 返回的是二维码链接,支付宝返回的是一段自动提交的 HTML 表单,银联返回的是跳转地址,格式完全不同。适配层把它们统一塞进PayPayload交给前端,前端只判断“是链接就渲染二维码,是表单就 innerHTML 提交”,不用关心背后是哪家。NotifyResult里的AmountFen必须由适配层从渠道报文中解析出来,后续订单服务要拿它和本地金额比对,这一步是防金额篡改的关键。
3.2 渠道路由与依赖注入注册
注册用IEnumerable<IPaymentChannel>,运行时按Code建成字典,比 switch 好扩展,也比 .NET 8 的 keyed service 更容易在 .NET 6 项目里复用。新增渠道时只改 DI 注册那两行。
public sealed class PaymentChannelResolver : IPaymentChannelResolver { private readonly IReadOnlyDictionary<string, IPaymentChannel> _channels; public PaymentChannelResolver(IEnumerable<IPaymentChannel> channels) => _channels = channels.ToDictionary(c => c.Code, StringComparer.OrdinalIgnoreCase); public IPaymentChannel Resolve(string code) => _channels.TryGetValue(code, out var ch) ? ch : throw new NotSupportedException($"未接入的支付渠道: {code}"); } // Program.cs builder.Services.AddScoped<IPaymentChannel, WechatPayChannel>(); builder.Services.AddScoped<IPaymentChannel, AlipayChannel>(); builder.Services.AddScoped<IPaymentChannel, UnionPayChannel>(); builder.Services.AddSingleton<IPaymentChannelResolver, PaymentChannelResolver>();适配器注册成Scoped,因为每个渠道都要注入DbContext或仓储来写流水日志;解析器本身无状态,注册成单例即可。注意ToDictionary在遇到重复 Code 时会抛异常,这其实是好事,重复注册能在启动阶段就暴露出来,而不是等到线上下单才发现路由到了错误的渠道。
3.3 数据表设计:订单表、通知日志表与退款表
三张表足够撑起一个完整方案:订单主表、渠道通知日志表、退款表。订单主表存业务语义,通知日志表存原始报文(排障的唯一证据),退款表独立是因为退款是异步的,和订单生命周期不同步。
CREATE TABLE pay_order ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT '业务订单号,全局唯一', biz_type VARCHAR(32) NOT NULL COMMENT '业务类型,便于多业务复用同一套支付', channel VARCHAR(16) NOT NULL COMMENT 'wechat/alipay/unionpay', amount_fen INT NOT NULL COMMENT '订单金额,单位分', subject VARCHAR(128) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1支付中 2已支付 3退款中 4已退款 5已关闭', channel_trade_no VARCHAR(64) NULL COMMENT '渠道侧交易号', expire_at DATETIME NOT NULL COMMENT '支付过期时间', paid_at DATETIME NULL, row_version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_status_expire (status, expire_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE pay_notify_log ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, channel VARCHAR(16) NOT NULL, notify_id VARCHAR(64) NOT NULL COMMENT '渠道通知唯一标识,用于幂等', order_no VARCHAR(32) NULL, raw_body MEDIUMTEXT NOT NULL COMMENT '原始报文,解密后明文', verified TINYINT NOT NULL DEFAULT 0, handled TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_channel_notify (channel, notify_id), KEY idx_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;idx_status_expire这个联合索引是给关单任务用的:定时任务每分钟扫一次status = 1 AND expire_at < NOW(),没有索引的话订单量上万之后这条 SQL 会拖慢整个库。uk_channel_notify是幂等的最后一道防线,重复通知插入时触发唯一键冲突,直接捕获并返回成功即可,业务代码根本没机会重复执行。raw_body用MEDIUMTEXT而不用 JSON 字段,是因为三家报文格式不统一,存原文最省事。
3.4 密钥与证书的配置管理
三家的凭据形态不一样:微信有商户 API 证书(pem 格式的私钥 + 证书)、APIv3 密钥(32 位字符串,用于回调解密)和平台证书(用于验签回调);支付宝是应用私钥加支付宝公钥两段 PEM 文本;银联是一张带密码的 PFX 证书加银联公钥证书。这些东西一律不能进 Git 仓库,本地开发用dotnet user-secrets,线上用环境变量或密钥管理服务注入。
// 从 pem 加载商户证书;Windows 下必须导出成 PFX 再重新导入,否则私钥句柄不可用 public static X509Certificate2 LoadFromPem(string certPath, string keyPath) { var cert = X509Certificate2.CreateFromPemFile(certPath, keyPath); return new X509Certificate2( cert.Export(X509ContentType.Pfx), (string?)null, X509KeyStorageFlags.EphemeralKeySet); // 避免写入用户证书存储区 } // 银联的 PFX 带密码 public static X509Certificate2 LoadFromPfx(string path, string password) => new X509Certificate2(path, password, X509KeyStorageFlags.EphemeralKeySet);EphemeralKeySet这个标志在 Linux 容器里尤其重要,不加的话在某些基础镜像上会抛出“找不到私钥”的异常,而日志里只显示签名失败,查半天查不出来。配置类用IOptions<PaymentOptions>绑定,每条渠道一个子节点,启动时做一次非空校验(ValidateOnStart),让配置缺失在启动阶段就炸掉,而不是等第一笔订单进来才发现。证书轮换留个后路:把证书序列号一起写进配置,签名的 Authorization 头里带上序列号,换证书时新旧并行一段时间即可。
4. 三个渠道的对接实现:微信 V3、支付宝 RSA2 与银联全渠道
抽象层定完之后,每个渠道的实现其实就三件事:按规则拼待签名串、发请求、解析应答。这一章把三家的关键代码和容易记错的规则摆出来,配上业务链路串联,基本可以照着写。
4.1 微信支付 V3:签名串、下单与回调 AES-GCM 解密
微信 V3 的请求签名规则写得很死:待签名串是“请求方法\nURL(含 query)\n时间戳\n随机串\n请求报文\n”,五行,每行以\n结尾,最后一行也要有换行符。少一个换行就是签名错误,而且错误信息不会告诉你少了什么。签名用商户 API 私钥做 SHA256withRSA,结果 Base64 后拼进 Authorization 头。
public string BuildAuthorization(HttpMethod method, string urlPath, string body) { var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); var nonce = Guid.NewGuid().ToString("N"); // 32 位随机串 // 待签名串:方法\nURL\n时间戳\n随机串\n报文\n,末尾的换行不能省 var message = $"{method.Method}\n{urlPath}\n{timestamp}\n{nonce}\n{body}\n"; var signature = Sign(message, _merchantCert.GetRSAPrivateKey()!); return $"WECHATPAY2-SHA256-RSA2048 mchid=\"{_mchId}\",nonce_str=\"{nonce}\"," + $"timestamp=\"{timestamp}\",serial_no=\"{_merchantCert.SerialNumber}\",signature=\"{signature}\""; } private static string Sign(string message, RSA rsa) { var sig = rsa.SignData(Encoding.UTF8.GetBytes(message), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return Convert.ToBase64String(sig); }urlPath必须带 query string,比如/v3/pay/transactions/native是纯路径,而查单接口/v3/pay/transactions/out-trade-no/{no}?mchid={mchid}就要把问号后面一起拼进去。serial_no是商户证书的序列号,不是你随便编的编号,取cert.SerialNumber时会返回十六进制大写字符串,直接用即可。
回调处理是微信 V3 最容易卡住的一步。通知体是 JSON,resource节点里的ciphertext用 APIv3 密钥做 AES-256-GCM 加密,解密时需要associated_data、nonce、ciphertext三个参数,且密文最后 16 字节是认证标签,要单独切出来。
public string DecryptResource(string associatedData, string nonce, string ciphertextBase64) { var key = Encoding.UTF8.GetBytes(_apiV3Key); // 必须正好 32 字节 var raw = Convert.FromBase64String(ciphertextBase64); var tag = raw[^16..]; // 末 16 字节是 GCM 认证标签 var cipher = raw[..^16]; var plain = new byte[cipher.Length]; using var aes = new AesGcm(key, 16); // .NET 8 需显式传 tagSizeInBytes aes.Decrypt(Encoding.UTF8.GetBytes(nonce), cipher, tag, plain, Encoding.UTF8.GetBytes(associatedData)); return Encoding.UTF8.GetString(plain); }在 .NET 6 上AesGcm只有一个参数的构造函数,会有一个过时告警,忽略即可;升级到 .NET 8 后必须补上第二个参数,否则编译不过。解密出来的明文是{"out_trade_no":"...","transaction_id":"...","trade_state":"SUCCESS","amount":{"total":1}}这样的结构,注意total单位是分。回调应答按微信要求返回 HTTP 200 加{"code":"SUCCESS","message":"成功"},或者干脆返回 204 空响应;返回 500 或者返回一段 HTML,微信就会按策略重发,一天下来能发八次。
4.2 支付宝:RSA2 签名与异步通知验签
支付宝的规则相对温和:参数去掉sign和空值,按 key 的字典序排序,用k=v以&连接,值用原始值不做 URL 编码,然后 SHA256withRSA 签名,Base64 编码后再整体 URL 编码提交。字符集统一 UTF-8,sign_type固定RSA2(对应 SHA256withRSA,RSA是老的 SHA1,新接入不要用)。
public string Sign(IDictionary<string, string> parameters, string appPrivateKeyPem) { // 1. 剔除 sign 与空值,按 key 字典序升序拼接,值不做 URL 编码 var content = string.Join("&", parameters .Where(p => p.Key != "sign" && !string.IsNullOrEmpty(p.Value)) .OrderBy(p => p.Key, StringComparer.Ordinal) .Select(p => $"{p.Key}={p.Value}")); using var rsa = RSA.Create(); rsa.ImportFromPem(appPrivateKeyPem); var sig = rsa.SignData(Encoding.UTF8.GetBytes(content), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return Convert.ToBase64String(sig); // 提交表单前再对 sign 单独做一次 UrlEncode }验签是签名的逆运算:把回调表单里的参数(已经是 URL 解码后的值)同样排序拼接,用支付宝公钥验证sign字段。这里有个高频翻车点——很多人在验签前又对参数做了一次 UrlEncode,或者用了Request.QueryString拿到的原始编码串,结果待验签串和支付宝那边算的对不上。正确做法是用框架解析后的表单集合,取已解码的值。验签通过后返回纯文本success,不要加引号、不要返回 JSON、不要返回 HTML。返回格式不对,支付宝会持续重发通知长达 24 小时。
4.3 银联全渠道:表单参数、证书签名与应答验签
银联的参数是一堆 key-value,签名规则同样是排序拼接后签名,但它用的是 PFX 证书里的私钥,应答和后台通知则要用银联公钥证书验签。金额单位也是分,currencyCode固定156,交易类型txnType=01表示消费。
public IDictionary<string, string> BuildForm(PayOrder order) { var p = new Dictionary<string, string> { ["version"] = "5.1.0", ["encoding"] = "utf-8", ["signMethod"] = "01", // 01=RSA(SHA1),11=SHA256,按对接文档选 ["txnType"] = "01", // 01 消费 ["txnSubType"] = "01", ["bizType"] = "000201", ["channelType"] = "07", // 07 互联网 ["accessType"] = "0", ["merId"] = _merId, ["orderId"] = order.OrderNo, // 6~40 位字母数字 ["txnTime"] = order.CreatedAt.ToString("yyyyMMddHHmmss"), ["txnAmt"] = order.AmountFen.ToString(), // 单位分 ["currencyCode"] = "156", ["frontUrl"] = _frontUrl, // 前台跳回地址 ["backUrl"] = _backUrl // 后台通知地址 }; p["signature"] = SignSortedParams(p, _signCert); // 排序拼接后签名 return p; // 以 form 表单 POST 到银联网关,提交时整体 UrlEncode }银联的应答有个坑:HTTP 状态码 200 只代表请求到了,业务成功要看报文里的respCode,00才是成功。很多人只看 HTTP 状态就判定下单成功,结果拿到一个空的前台跳转地址。另外银联的前台跳转(frontUrl)和后台通知(backUrl)是两条独立链路,前台跳转只用于用户体验,绝对不能用它来改订单状态——用户关掉浏览器,跳转就没了,而后台通知才是可信来源。
4.4 从统一下单到回调落库的完整链路
一笔订单走完是这样的:前端提交下单请求,接入层校验参数并写入pay_order(状态 Created),调用订单服务把状态推到 Paying,解析器按渠道 Code 找到适配器,适配器组包签名后发请求;渠道返回预支付信息(微信的二维码链接、支付宝的表单、银联的跳转地址),原样塞进PayPayload返回前端。用户支付完成后,渠道把通知打到NotifyUrl,回调处理器先把原始报文写进pay_notify_log(唯一键挡重复),再交给适配器验签解密,拿到订单号、渠道交易号、金额,最后调订单服务做状态推进。
订单服务收到通知后做三件事:比对金额(回调金额必须等于订单金额,不等就告警并拒绝)、比对渠道(订单的 channel 必须和通知来源一致,防止跨渠道伪造)、按状态机推进(Paying → Paid)。任何一步不通过都只记日志不抛异常,因为对渠道来说处理失败就意味着重发。前端侧的体验靠轮询或 WebSocket 查询订单状态兜底,查询接口内部先查本地库,本地还是 Paying 且距上次查询超过 30 秒,才去渠道查一次单,避免把渠道查询接口当轮询接口用。
5. 避坑与排查:签名、回调、金额和应用环境这五类问题
这一章列的五个问题,基本覆盖了多渠道支付集成里 90% 的线上故障。每条按“现象 → 原因 → 解决”写,照着排查能省不少时间。
5.1 签名与验签失败
现象:请求直接被渠道拒绝,微信返回 401 和SIGN_ERROR,支付宝返回invalid-signature,银联返回respCode非 00 且提示验签失败;本地日志里只有一个错误码,看不出待签名串长什么样。
原因:绝大多数是待签名串的构造细节出错。待签名串多了空格、少了末尾换行、URL 没带 query、时间戳用了毫秒而渠道要秒、私钥格式是 PKCS#1(BEGIN RSA PRIVATE KEY)而代码按 PKCS#8(BEGIN PRIVATE KEY)加载、支付宝的值被做了二次 URL 编码。这些错误的共同点是签名结果看起来“完全随机”,靠猜是猜不出来的。
解决:在签名方法里加一行 Debug 级日志,把待签名串原文以\n可见的形式打印出来(比如替换成\\n),然后拿渠道文档给的示例参数对一遍。各渠道都提供在线验签工具,把待签名串和签名结果贴进去,能立刻判断是“串错了”还是“密钥错了”。私钥格式问题统一用RSA.ImportFromPem处理,它同时支持两种格式,比手写ImportRSAPrivateKey稳。
5.2 回调不生效或重复入账
现象:渠道后台显示“通知失败”,一直重发;或者反过来,用户只付了一次,系统里发了两份货、加了两倍积分。
原因:前者的典型原因是应答格式不对——微信要 JSON 或 204,支付宝要纯文本success,银联要返回指定的应答报文,返回一段 HTML 或者带 BOM 的字符串都会被判定为失败。后者的原因是回调处理没有幂等,通知日志表里能看到同一笔订单有多条记录,每条都触发了一次业务逻辑。
解决:应答格式严格照文档写,写完用 curl 手工打一次自己的回调地址看返回值。幂等做两层:数据库唯一键(uk_channel_notify)拦住重复插入,订单更新的WHERE status = 1拦住重复状态推进。还有一条经验:回调处理不要超过 5 秒,渠道的超时时间普遍很短,业务逻辑重就丢进队列异步做,HTTP 线程只负责验签和落库。
5.3 金额、单位与精度
现象:订单显示 1 元,渠道收到的是 1 分;或者退款时提示“金额超出可退金额”;或者对账时总是差几分钱。
原因:元与分混用。前端传的是元(9.9),后端直接乘 100 转int,浮点误差导致 9.9 * 100 = 989.9999,取整后变成 989;退款时用订单金额减已退金额,两边单位不一致就出负数。
解决:接口层就强制要求分,前端提交前自己乘 100 并取整;后端所有金额字段用int/long,序列化时不要用double;只在展示层用decimal除以 100。退款金额单独存一张表,记录每笔退款的金额,可退金额 = 订单金额 - SUM(已成功退款金额),每次退款前重新算一遍而不是读缓存。
5.4 退款与对账
现象:退款接口返回成功,但用户没收到钱;或者对账时渠道账单里有一笔成功订单,本地还是 Paying。
原因:退款是异步的,接口返回的“成功”往往只代表受理成功,真正的结果要看退款查询接口或退款回调;对账差异多半是回调丢失(网络抖动、服务重启、回调地址配错)导致的单边账。
解决:退款单独立建表,状态走“受理中 → 成功/失败”,定时任务每 5 分钟查一次未终态的退款单;对账任务每天凌晨拉前一天的渠道账单(微信和支付宝都提供对账单下载接口,银联提供对账文件),逐笔比对本地流水,差异分三类处理:本地无渠道有(补单)、本地有渠道无(挂起人工核查)、金额不一致(告警,不自动改)。对账文件里的金额单位和接口一致,都是分,别在这里又踩一次单位的坑。
5.5 证书、时间戳与环境切换
现象:本地和测试环境全通,一上生产就签名失败;或者某天早上突然全部订单失败,改代码也没用。
原因:三类。一是沙箱与生产的商户号、密钥、网关地址不同,配置没切干净;二是微信的请求签名里带时间戳,服务器时间与标准时间偏差超过 5 分钟就会被拒;三是证书到期,微信平台证书有有效期,到期前需要重新下载并替换。
解决:配置按环境分文件,生产配置里的网关地址和商户号在启动时打印一次到日志(密钥不打印),一眼能看出是不是切错了。服务器统一配 NTP 时间同步,容器环境尤其要注意宿主机时间。证书方面,把到期时间做成健康检查项,提前 30 天在监控里报警;微信平台证书支持通过接口下载,写个定时任务每天拉一次并按序列号缓存,新证书进来后新旧并存,验签时按通知头里的Wechatpay-Serial选对应证书。
6. 验收与答辩:用八条用例证明这套支付集成是可靠的
支付模块的验收不能只看“能付成功”,那只是及格线。真正能说明问题的是异常路径:重复通知、签名被篡改、金额对不上、回调丢失。下面这张表是我一般会写进验收文档的用例清单,八条跑完,答辩时被问到任何一个环节都有话讲。
| 编号 | 用例 | 操作 | 通过标准 |
|---|---|---|---|
| 1 | 三渠道正常支付 | 每个渠道各下一单并完成支付 | 订单状态变为已支付,金额、渠道交易号正确 |
| 2 | 重复回调 | 抓取回调报文,重放 3 次 | pay_notify_log有 3 条记录,订单只推进一次 |
| 3 | 签名篡改 | 修改回调报文任意一个字符后重放 | 验签失败,订单不变,记录告警日志 |
| 4 | 金额篡改 | 回调金额改成 1 分 | 拒绝处理并告警,订单保持原状态 |
| 5 | 超时关单 | 建单后不支付,等过期时间到 | 定时任务把状态置为已关闭,渠道侧同步关单 |
| 6 | 全额与部分退款 | 分别发起一次全额、一次部分退款 | 退款单状态正确,可退金额递减 |
| 7 | 对账 | 跑一次 T+1 对账任务 | 差异数为 0;人为删一条本地流水后能识别出单边账 |
| 8 | 并发回调 | 同一订单 20 个并发回调 | 数据库只有一条已支付记录,无死锁异常 |
做验收演示时有个小技巧:把pay_notify_log的raw_body字段直接投到屏幕上,让评审看到原始报文、验签结果、处理结果三列并排。这比讲十遍“我们做了幂等”都有说服力。答辩常被问的几个问题提前准备一下:为什么不用聚合支付 SDK(回答控制力和统一日志)、密钥存在哪(回答不入库、环境变量注入、启动校验)、证书怎么轮换(回答序列号路由加新旧并存)、回调丢了怎么办(回答定时对账补单)。
从那以后我每次接新渠道,上线前都强制走一遍这三步:同一笔订单重复回调打三次、把待签名串故意改一个字符、把服务器时间拨快十分钟。三步都过了,才敢让真实用户下单。这套习惯没什么技术含量,但确实省下了好几次半夜被叫起来看日志的机会。希望帮到你。
本文还有配套的精品资源,点击获取