个人账号无法开通当面付时的支付宝 JSAPI 免签支付方案
项目:墨鱼杂货铺
目标:在无法开通支付宝“当面付”的情况下,用支付宝小程序 JSAPI 能力实现一套可对接独角数卡的易支付网关。
很多个人开发者或小微场景无法直接开通支付宝“当面付”。这时,如果账号拥有支付宝小程序能力,可以换一种思路:不走当面付扫码支付,而是让用户扫码打开小程序,并在小程序内通过官方 JSAPI 支付完成收款。
这里说的“免签”不是监听个人收款码,也不是绕过支付宝风控,而是不用“当面付”产品,改用官方的小程序 JSAPI 支付能力完成收款闭环。
背景
独角数卡这类发卡系统通常更习惯对接“易支付”协议,标准流程类似这样:
1 | 独角数卡 -> 易支付网关 -> 用户付款 -> 网关异步通知独角数卡 |
如果没有当面付权限,常见的扫码支付接口无法直接使用。但如果账号拥有支付宝小程序能力,就可以在小程序内调用 JSAPI 支付:
1 | my.tradePay({ |
本项目采用的整体思路如下:
1 | 独角数卡发起易支付订单 |
系统结构
项目分为两部分:
1 | MoYuZaHuoPu/ |
核心角色如下:
| 角色 | 职责 |
|---|---|
| 独角数卡 | 请求易支付 submit 接口 |
| Service | 模拟易支付网关,保存订单,调用支付宝 OpenAPI,接收支付宝异步通知,回调独角数卡 notify_url |
| Miniapp | 通过 out_trade_no 获取订单,获取支付宝用户授权码,调用 Service 创建交易,调用 my.tradePay 支付 |
| 支付宝 | 创建交易,完成支付,异步通知 Service |
易支付入口设计
Service 提供易支付入口:
1 | GET /api/epay/submit |
接收独角数卡传来的易支付参数:
1 | pid |
后端配置一个虚拟易支付商户:
1 | EPAY_PID=1001 |
独角数卡后台也要填写同样的 PID 和 KEY。
易支付 MD5 签名规则:
1 | 1. 去掉 sign、sign_type 和空值 |
验签通过后,Service 把订单保存到 SQLite:
1 | Order: |
随后返回一个 HTML 收银台页面。页面里生成二维码,二维码内容是支付宝小程序跳转链接,并带上订单号。
示例:
1 | MINIAPP_QR_BASE_URL=alipays://platformapi/startapp?appId=2021006154623825&page=pages/pay/index |
Service 会自动追加:
1 | query=out_trade_no%3D订单号 |
小程序支付流程
小程序支付页路径:
1 | pages/pay/index |
进入页面时读取:
1 | out_trade_no |
然后调用:
1 | GET /api/order/{out_trade_no} |
用于展示订单名称和金额。
用户点击“确认支付”时,流程如下:
- 小程序调用
my.getAuthCode - 把
auth_code和out_trade_no发给 Service - Service 用
alipay.system.oauth.token换取买家标识 - Service 调用
alipay.trade.create创建支付宝交易 - Service 返回
trade_no - 小程序调用
my.tradePay
小程序请求示例:
1 | { |
Service 返回示例:
1 | { |
小程序拉起支付:
1 | my.tradePay({ |
支付宝 OpenAPI 调用
Service 主要调用两个支付宝接口:
1 | alipay.system.oauth.token |
用 auth_code 换买家标识
小程序拿到的是 auth_code,不能直接当成买家 ID。Service 需要调用:
1 | alipay.system.oauth.token |
支付宝可能返回两类用户标识:
1 | user_id -> 创建交易时传 buyer_id |
这里踩过一个坑:一开始把 open_id 当成 buyer_id 传给支付宝,结果报错:
1 | 参数无效:userId长度不合法 |
最终修正为:
1 | 如果 OAuth 返回 user_id,则传 buyer_id |
创建支付宝交易
创建交易时调用:
1 | alipay.trade.create |
核心 biz_content:
1 | { |
或者:
1 | { |
支付宝创建成功后返回 trade_no,小程序才能调用 my.tradePay。
回调闭环
支付完成后,支付宝会请求:
1 | POST /api/pay/alipay_notify |
这个地址必须是公网可访问地址,不能是 localhost。没有正式域名时,可以使用内网穿透:
1 | ALIPAY_NOTIFY_URL=https://你的内网穿透域名/api/pay/alipay_notify |
Service 收到支付宝异步通知后:
- 使用支付宝公钥做 RSA2 验签
- 校验支付状态
- 校验金额是否一致
- 更新本地订单状态为
PAID - 读取订单里的
notify_url - 按易支付格式回调独角数卡
回调独角数卡参数示例:
1 | pid=1001 |
独角数卡返回:
1 | success |
则闭环完成。
本地测试页
为了不一开始就依赖独角数卡,Service 增加了 PC 测试页:
1 | GET /pc |
测试流程:
1 | 1. 打开 https://你的内网穿透域名/pc |
因为未上线的小程序普通扫码可能打不开,所以小程序首页也加了一个测试入口:
1 | 首页底部 -> 支付链路测试 -> 输入 out_trade_no -> 跳转支付页 |
这样可以在支付宝开发者工具里直接测试:
1 | pages/pay/index?out_trade_no=测试订单号 |
配置说明
Service 的环境变量文件:
1 | Service/.env |
模板:
1 | Service/.env.example |
核心配置:
1 | EPAY_PID=1001 |
小程序后端地址配置:
1 | Miniapp/utils/config.js |
示例:
1 | const API_BASE_URL = 'https://你的内网穿透域名/api' |
真机测试时不能使用:
1 | localhost |
因为手机访问不到电脑本机地址。
遇到的问题和解决方案
普通支付宝扫码提示“暂未找到此功能”
现象:
1 | 暂未找到此功能,请稍后再试 |
原因:
1 | 小程序还没有线上版本,普通支付宝客户端无法通过 alipays://platformapi/startapp 打开。 |
解决:
1 | 使用支付宝开发者工具的预览、真机调试或体验版。 |
小程序金额显示为 0.00
现象:
1 | 后端返回 money: "0.01",页面显示 ¥0.00 |
原因:
1 | 小程序页面只读取 total_amount、amount、pay_amount,没有读取 Service 返回的 money。 |
解决:
1 | displayAmount = money || total_amount || amount || pay_amount || 0 |
应用私钥解析失败
现象:
1 | Could not deserialize key data |
原因:
1 | 支付宝密钥工具常见应用私钥是 PKCS8: |
解决:
1 | 优先按 PRIVATE KEY 解析,失败再按 RSA PRIVATE KEY 兜底。 |
支付宝返回 GBK 编码内容导致 UTF-8 解码失败
现象:
1 | 'utf-8' codec can't decode byte ... |
原因:
1 | 支付宝网关异常时可能返回 GBK/GB18030 编码的错误页或文本。 |
解决:
1 | 解析支付宝响应时按 UTF-8、UTF-8-SIG、GB18030、GBK 依次尝试。 |
charset 参数位置导致验签失败
现象:
1 | 验签出错,请确认 charset 参数放在了 URL 查询字符串中 |
原因:
1 | 一开始用 POST body 提交参数,支付宝网关希望 charset 等公共参数出现在 URL query string 中。 |
解决:
1 | client.post(ALIPAY_GATEWAY, params=params) |
而不是:
1 | client.post(ALIPAY_GATEWAY, data=params) |
RSA2 签名字符串错误
现象:
1 | 验签出错,建议检查签名字符串或签名私钥与应用公钥是否匹配 |
支付宝返回的验签字符串中包含:
1 | sign_type=RSA2 |
原因:
1 | 代码一开始生成支付宝待签名串时排除了 sign_type。 |
解决:
1 | 支付宝请求加签只排除 sign,不排除 sign_type。 |
买家信息不能为空
现象:
1 | 买家信息不能为空 |
原因:
1 | alipay.trade.create 需要买家身份。 |
解决:
1 | 小程序调用 my.getAuthCode。 |
userId 长度不合法
现象:
1 | 参数无效:userId长度不合法 |
原因:
1 | OAuth 返回的是 open_id,却被当成 buyer_id 传给支付宝。 |
解决:
1 | user_id -> buyer_id |
卖家买家账号相同
现象:
1 | 卖家买家账号相同,不能进行交易 |
原因:
1 | 测试付款账号和收款商户账号是同一个支付宝账号或同一主体。 |
解决:
1 | 换另一个支付宝账号测试。 |
密钥检查
为了确认应用私钥和支付宝后台上传的应用公钥是否匹配,项目里增加了:
1 | Service/check_alipay_keys.py |
运行:
1 | cd Service |
它会:
1 | 1. 检查 ALIPAY_APP_ID 是否配置 |
注意:
1 | ALIPAY_APP_PRIVATE_KEY = 应用私钥,放在 Service/.env |
不要把应用公钥和支付宝公钥混淆。
独角数卡对接方式
独角数卡后台添加易支付或码支付类插件时:
1 | 商户 ID: EPAY_PID |
Service 必须公网可访问:
1 | https://你的域名/api/epay/submit |
支付宝开放平台后台的异步通知地址也应配置为:
1 | https://你的域名/api/pay/alipay_notify |
安全注意事项
- 不要提交
Service/.env - 不要把应用私钥发到聊天、工单、公开仓库
- 如果应用私钥泄露,正式上线前必须重新生成密钥对
- 使用 HTTPS 公网域名,不要使用 HTTP
- 支付宝回调必须验签
- 支付成功必须校验金额
- 独角数卡回调必须按易支付规则重新签名
- 测试订单和生产订单建议使用不同前缀
源码进度与交流
目前这套方案的源码还在继续完善中,后面会整理成相对完整、可读的版本放到 Git 仓库里。
如果你也在折腾类似的支付宝小程序 JSAPI 支付、易支付协议适配,或者独角数卡支付网关对接,可以加微信交流:
1 | Moyu-Dev16 |
最终结论
当个人账号无法开通支付宝当面付时,可以通过支付宝小程序 JSAPI 支付实现一套“类易支付网关”:
1 | 易支付入口负责接单 |
这套方案的关键不是绕过支付宝,而是把“面对面扫码支付”转成“扫码打开小程序并在小程序内支付”。
只要小程序支付能力、支付宝 OpenAPI 密钥、OAuth 授权、异步通知和易支付回调都打通,就可以对接独角数卡这类只认识易支付协议的系统。