希曼日记

给桌面软件官网接入支付宝:从沙箱到生产,踩过的七个坑

约 9 分钟3,367 字

Scorpius 是一个剪贴板管理器,官网只卖一样东西:6 元买断的激活码,含 12 个月更新。量不大,但这件事必须做对——用户付了钱,就得拿到码;付了两次,就得能查到;付了没到账,不能让他再付一次。

这篇按真实接入过程整理:要准备什么、沙箱怎么用、Go 端怎么签、异步通知怎么验、上线怎么切,以及七个把我卡住(或差点让钱掉进黑洞)的坑。代码是简化过的骨架,域名与凭据都已替换。

先决定用哪个产品

支付宝的线上支付按终端分:原生 App 用 App 支付,手机浏览器用手机网站支付,电脑浏览器用电脑网站支付(alipay.trade.page.pay),小程序用 JSAPI。Scorpius 的买家绝大多数在电脑上打开官网买激活码,选电脑网站支付。

产品调用方式适用
电脑网站支付服务端拿表单,浏览器跳收银台PC 网页商城、软件官网
手机网站支付同上,H5 收银台手机浏览器
App 支付客户端 SDK 调起原生 App
JSAPI小程序内 my.tradePay支付宝小程序
商家扣款签约后周期扣款订阅、自动续费

Scorpius 是买断制,没有订阅,所以不涉及商家扣款;也不需要冻结资金(预授权)。

要准备的东西

这些是平台流程,官方文档都有,但顺序和「容易卡住的地方」值得单独列一遍:

要准备的东西说明
支付宝开放平台账号(已实名)企业、个体工商户、个人都能申请电脑网站支付
域名 ICP 备案备案主体必须与支付宝账号主体一致,这是签约被拒最常见的原因
一个网页应用控制台创建后拿到正式 APPID
RSA2 密钥对官方密钥工具生成,选 SHA256withRSA;上传应用公钥,平台回给支付宝公钥
应用网关填异步通知地址:https://你的API/v1/alipay/notify
电脑网站支付签约商家平台产品中心开通;费率 0.6%,新签约有 90 日(或连续 30 日交易)结算周期限制
公网 HTTPS 服务端通知要能推到你,回跳也要能打开
IP 白名单(可选)填服务器出口 IP,私钥泄漏时的最后一道闸

控制台截图我没放:菜单路径和页面改版很快,截图很容易过期误导。上面每一项的菜单在开放平台「开发 → 开发设置」和商家平台「产品中心」里都能找到;签约材料以平台页面为准。

密钥有个细节:工具生成的是 PKCS#8,沙箱/控制台给的可能是 PKCS#1,两者是同一把 RSA 密钥的不同包装。解析时两种都接受,不要自己转换或拼接 PEM 头——很多「验签失败」就是从这开始的。

沙箱先行:能用与不能用的

支付宝提供「快速沙箱」:匿名创建,直接给你 appId、应用公私钥、支付宝公钥,还有一对买卖家测试账号,不用登录开放平台。把网关指向 https://openapi-sandbox.dl.alipaydev.com/gateway.do 就能跑通下单。

沙箱有两个限制,必须在设计阶段吃进去:

  1. 沙箱不推送异步通知。它不会往公网推 notify,所以「通知到达 → 发码」这条主路径在沙箱里根本走不到。
  2. 收银台要真登录。付款是浏览器行为,脚本能把你送到收银台,但买家账号密码得人来输。

第二个限制不影响开发,第一个决定了架构:回跳(return_url)那条路必须能独立完成发码。这正好符合官方规范——同步回跳不可信,必须用异步通知或交易查询确认。所以沙箱阶段验证的是「回跳 → 主动查询 → 发码」,生产再验证「异步通知 → 发码」。

沙箱收银台长这样——商品、收款方、金额都是真的(测试环境),扫码和账户登录两种付款方式:

支付宝沙箱收银台:商品为 Scorpius 激活码,金额 6.00 元,左侧扫码支付,右侧账户登录

一次购买在系统里长什么样

先把四条时间线画清楚,后面所有设计都围绕它:

购买时序:下单、收银台支付、通知与回跳确认、发码

对应到数据和状态:

  • alipay_payments:下单时写入,out_trade_no、金额(分)、邮箱、status=pending;
  • orders:付款确认后才写入,(provider, external_id) 唯一;
  • codes:跟订单一起创建,status=unused,明文哈希落库、密文另存一列供「我的订单」展示;
  • 状态只有两个方向:pending → paid,不存在回退。

这里的关键取舍:订单行只在确认收款后创建。没付款的 pending 记录不占用订单号,也不会让「重复下单」变成「重复发码」。

实现:Go 没有官方 SDK,就按规范自己签

官方 SDK 只有 Java、PHP、Python、Node.js、.NET,没有 Go。协议本身不复杂,核心三件事:排序拼串、RSA2 签名、验签。

签名与验签

参数按键名 ASCII 升序,k=v 用 & 连接,值不做 URL 编码;签名用 SHA256withRSA,Base64 输出:

Go
func canonical(v url.Values, skip ...string) string {
skipSet := set(skip)
keys := sorted keys of v excluding skipSet
var parts []string
for _, k := range keys {
for _, val := range v[k] {
parts = append(parts, k+"="+val) // 注意:不是 urlencode(val)
}
}
return strings.Join(parts, "&")
}
func (c *Client) SignValues(v url.Values) string {
sum := sha256.Sum256([]byte(canonical(v, "sign"))) // sign 不参与
sig, _ := rsa.SignPKCS1v15(rand.Reader, c.priv, crypto.SHA256, sum[:])
return base64.StdEncoding.EncodeToString(sig)
}

请求签名时 sign_type=RSA2 参与签名。验异步通知时官方做法是先带 sign_type 验一次,失败再不带 sign_type 验一次,两种都兼容——这是各家 SDK 处理历史差异的方式。

时间戳必须是 yyyy-MM-dd HH:mm:ss(北京时间)。Go 里用固定时区而不是 LoadLocation,因为 distroless 镜像没有 tzdata:

Go
var beijing = time.FixedZone("UTC+8", 8*60*60)
ts := c.now().In(beijing).Format("2006-01-02 15:04:05")

page.pay 返回的是 HTML 表单,不是 URL

alipay.trade.page.pay 等价于官方 SDK 的 pageExecute(),返回一段自动提交的表单。前端必须把这段 HTML 注入页面让它自己提交;用 location.href 跳转只会看到一堆参数。

HTML
<form action="https://openapi.alipay.com/gateway.do?app_id=...&method=alipay.trade.page.pay&sign=..."
method="post">
<input type="hidden" name="biz_content" value="{&quot;out_trade_no&quot;:...}">
</form>
<script>document.forms[0].submit()</script>

biz_content 里至少要 out_trade_no、total_amount、subject、product_code=FAST_INSTANT_TRADE_PAY。

异步通知:验签、核对、幂等、回 success

通知处理顺序不能乱:

Go
func (s *Server) handleNotify(w, r) {
form := parseURLEncoded(r) // application/x-www-form-urlencoded
if !s.alipay.VerifyNotification(form) { // 1. 先验签
reply("failure"); return
}
if form.Get("app_id") != s.cfg.AppID { // 2. 核对 app_id
reply("failure"); return
}
cents, _ := alipay.ParseAmount(form.Get("total_amount"))
p := loadPending(form.Get("out_trade_no")) // 3. 找本地订单
if p == nil || cents != p.AmountCents { // 金额按「分」比对
reply("failure"); return
}
if status := form.Get("trade_status"); status != "TRADE_SUCCESS" && status != "TRADE_FINISHED" {
reply("success"); return // 4. 非终态:确认收到但什么都不做
}
s.fulfil(p) // 5. 幂等:pending→paid,唯一约束发码
reply("success") // 6. 只有这七个字符
}

几个容易写错的点:

  • 金额按分比对(0.01 ≠ 6.00),不要用浮点;
  • 只认 TRADE_SUCCESS / TRADE_FINISHED;
  • 处理完回 success(七个字符),否则支付宝会重试;确认不了的回 failure 让它重试;
  • 重试是常态:同一条通知会来多次,notify_id 相同。

回跳不可信,trade.query 兜底

回跳页拿到的是浏览器地址栏参数,任何人都能伪造。正确姿势是回跳页只负责触发一次服务端查询:

Go
func (s *Server) handleReturn(w, r) {
out := r.URL.Query().Get("out_trade_no")
res, err := s.alipay.TradeQuery(ctx, out) // 以查询结果为准
if err != nil || !res.Paid() {
returnPage("尚未支付") // 不猜、不推断
}
s.fulfil(s.pending(out)) // 与通知共用同一条幂等路径
returnPage("支付成功")
}

trade.query 的响应也带签名。官方签名对象是响应里 alipay_trade_query_response 这个节点的原始 JSON 字节,不是重新序列化的对象——所以要从原始响应里截出那段子串来验,而不是 json.Marshal 一遍再验(顺序、空格都会变)。

上线后第一笔真实订单,买家看到的是这个页面:

支付成功页:支付成功!激活码已按订单信息发放。

七个坑

1. 私钥抄错一个字符

沙箱给的是长 Base64 私钥,我复制进配置文件时少了一个 v(第 967 位)。症状很具体:illegal base64 data at input byte 1589,长度 1591,除不尽 4。

排查方式值得推荐:用同一把密钥的材料反向校验。把 PKCS#8 私钥解码、导出 PKCS#1、和抄录的字符串逐字节比对,一眼看出丢的是哪个字符。别靠肉眼找。

顺带记住:看到 invalid-signature 先别怀疑私钥格式。最常见的原因是请求参数不完整(比如 return_url 没传),OpenSSL / Go 都完整支持 PKCS#1,看到 ERR_OSSL_UNSUPPORTED 先检查配置项,别急着转格式。

2. 私钥差点进了 Helm values

第一版我把生产私钥直接写进了 values.yaml 的 api.env。这个文件进 Git、由 Argo CD 从 GitLab 同步,等于私钥随仓库分发。

发现得早:还没提交、没推送,私钥只在工作区。修法走 External Secrets:

YAML
api:
env:
ALIPAY_APP_ID: "2021..." # 这些不是机密
ALIPAY_ALIPAY_PUBLIC_KEY: "MIIB..."
secretEnv:
ALIPAY_PRIVATE_KEY: alipay-private-key # 环境变量名: OpenBao 属性名

但即便如此,那把私钥已经在对话里出现过——结论是必须轮换。还没上线、没有订单,换一套密钥成本为零,别心疼。

3. source 一个 JSON,引号被吃掉了

本地开发脚本 dev.sh 会 source .dev/alipay.env,其中有一行:

Bash
ALIPAY_PLAN_PRICES={"buyout_12m":600}

source 之后 JSON 变成 {buyout_12m:600}——引号被 shell 吃掉,解析报 invalid character 'b'。用单引号包住整个值即可:

Bash
ALIPAY_PLAN_PRICES='{"buyout_12m":600}'

4. 启动顺序:APP_ID 有、私钥没有 = CrashLoop

代码逻辑是「配置了 APP_ID 才启用支付」。上线时我推了 values,但 OpenBao 里还没有私钥——环境变量 ALIPAY_APP_ID 存在、ALIPAY_PRIVATE_KEY 缺失,服务启动报错退出,Pod 反复 CrashLoop。

正确顺序:先把私钥写进 OpenBao,确认 Secret 里有值,再发版。启动日志打一行 JSON("alipay":true),比翻代码快。

5. 沙箱验证不了异步通知,生产首单才见真章

沙箱不推通知,所以「通知到达」只能到生产验证。首单在日志里看到这一行才敢说通了:

Text
alipay payment confirmed via notify

上线后第一笔真实订单,必须去日志里确认这行,而不是只看回跳页显示成功——回跳成功只证明 trade query 通了。

6. 买家不填邮箱,激活码明文永久丢失

数据库只存激活码的 SHA-256,明文只在发码那一刻存在。首单买家(测试时的我自己)没填邮箱,于是发码函数拿到明文、发现没有收件人、记一行日志、丢弃。

这个码再也发不出来。最后只能后台补发一个新码,把原码作废。

修复是两层:服务端邮箱必填,缺了直接 400;前端加醒目的「必填」标记,点击支付时为空就弹窗拦下来。

官网购买卡片:邮箱输入框带「必填」标记,下方是支付宝支付按钮

点击支付但邮箱为空时弹出的对话框:先留个邮箱,激活码才能送达

这是「用户会忽略你的小字提示」的典型例子:第一版把提示放在按钮下方一行灰字,我自己测试都会漏看,何况买家。

7. 官网改版把购买按钮弄丢了

这个坑最冤。产品页从静态 HTML 迁到 React 站时,购买卡片只搬了文案,按钮没搬过去——页面上有价格、有权益列表、有「首批 100 个」的说明,就是没有下单入口。

接口是通的、沙箱也是通的,但用户点不到。上线前整站走查才发现。

教训很朴素:迁移页面时,功能要跟着页面一起回归。后来把「购买按钮能进入收银台」固化成一条自动化测试(真实浏览器点按钮,断言出现支付宝收银台域名),改版先跑这条。

8. 表单页面没声明编码,中文参数被重新编码导致验签失败

这是写本文时复现出来的:把 page.pay 的表单放在一个 Content-Type: text/html 没有 charset 的页面里打开,浏览器按默认编码提交,biz_content 里的中文(商品名)字节变了,网关算出来的签名串和我们对不上,直接回 invalid-signature。

页面上能看到网关贴出的验签串里商品名变成了 &#65533;(替换字符)——这就是编码错位的信号。

结论:承载支付表单的页面必须声明 UTF-8(<meta charset="utf-8"> 或响应头 text/html; charset=utf-8),并且不要在表单提交前对参数做任何二次编码。

第二次交付:没邮箱的人怎么拿到码

第一版做完,产品上出现一个问题:有用户用第三方登录(手机号、社交账号),Casdoor 里根本没有邮箱;还有人不愿意留邮箱。强制邮箱会掉转化。

于是加了渐进式登录:不登录也能买(走邮箱),登录后订单绑定 Casdoor 用户,激活码直接进「我的订单」。

Casdoor 登录页:用户名密码、验证码、WebAuthn、Face ID 多种方式

两个细节值得说:

激活码要能重现。数据库原来只存哈希,登录用户想看码也看不到。加了一列 code_enc,用 AES-256-GCM 加密存明文,密钥在 OpenBao(CODE_ENC_KEY)。这样订单页能解密展示、也能「重发到邮箱」,而数据库单独泄露拿不到码。

发码有两条通道:匿名 → 邮箱必填;登录 → 订单页(账号有邮箱时同时发邮件)。两条路共用同一个幂等发码函数,谁先到都只发一个码。

上线清单

真正切生产那天,按这个顺序走,基本不会翻车:

  1. 正式 APPID、应用私钥、支付宝公钥写进 OpenBao(不进 Git、不进日志)
  2. 删掉 ALIPAY_GATEWAY 环境变量——代码默认就是生产网关
  3. 确认 PUBLIC_URL 是 HTTPS,通知/回跳由它拼接
  4. 发版,日志里确认 "alipay":true
  5. /v1/pubkey 与发版前一致(确认没有误触发许可证私钥重新生成)
  6. 真实买一笔 6 元:回跳页确认发码、邮件到达
  7. 日志确认 confirmed via notify
  8. 测试订单在商家平台退款走一遍(0.6% 手续费不退)

复盘

这次接入真正花时间的不是写代码,而是三件事:

  • 把「钱」的逻辑想清楚:金额谁定、状态谁翻转、重复通知怎么办;
  • 在沙箱的限制下先把主路径跑通,别等生产才发现回跳路径是空心的;
  • 把用户会忽略的提示做成拦得住人的交互,邮箱那一条最典型。

代码最终落在 Go 标准库上(签名、验签、表单、查询),没有引入第三方 SDK;整套链路有单元测试和一个真实浏览器端到端测试兜底。6 元的生意也值得认真做——用户付的是信任,不是 6 块钱。