tencent cloud

小程序支付实践教程

PDF
聚焦模式
字号
最后更新时间: 2025-09-23 17:29:21

Superapp 支付能力与支付 API 桥接方案

技术实现


商户 ID 是 superapp 平台上商户的唯一标识。所有 API 调用都必须携带该参数,以确认支付时的商户身份。商户 ID 由 superapp 生成,并记录在小程序平台控制台数据库中。
参考设计:superapp 管理者可通过邮件、控制台、人工客服等多种方式,让小程序开发者申请商户 ID。小程序开发者需向 superapp 提交包括但不限于银行账户信息、身份资料、经营资质等内容。审核通过后,superapp 授权运营人员会为小程序开发者提供一个字符串形式的商户 ID。
小程序开发者在获得该商户 ID 后,登录小程序平台控制台,在小程序管理 - 绑定商户账号页面 (https://your-domain/superapp/miniapp/merchant-bind) 提交绑定申请。

应用平台管理员应在应用管理 - 商户支付页面 (https://your-domain/superapp/app-management/merchant) 进行审核。

至此,商户 ID 的申请流程已全部完成。

小程序支付流程

基本原则:小程序平台 并不参与加解密及签名验证过程。小程序与 superapp 需自行完成密钥交换。小程序平台充当隔离层,将小程序与 superapp 进行隔离,双方各自遵循小程序平台所定义的标准协议进行对接。因此,小程序开发者在不同的 superapp 上线时,无需修改协议,但需更新相应平台的密钥信息。
主要流程
superapp 必须具备支付能力,需要与小程序平台提供的下单及发货通知接口进行对接,并向小程序提供获取密钥的方式。
小程序向 superapp 申请密钥,获得后方可进行数据的加解密和签名验证。
小程序开发者需对接小程序平台的下单、发起支付及接收发货通知等接口,以完成支付流程。

密钥和公私钥申请

下方的时序图展示了获取加解密材料的流程。

在完成申请流程后,您将获得以下三种密钥:
平台公钥:由商户用于验证来自平台的数据可靠性。
商户密钥对:由平台用于验证来自商户的数据可靠性。
AES 密钥:用于对发货通知中的敏感数据进行加密。由于发货通知可能包含敏感的客户信息,通过加密可确保小程序平台本身无法访问任何此类信息。

支付用例流程图



小程序支付流程共分为四个阶段:

1. 预下单生成:用户下单并获取支付交易会话标识的过程。涉及以下接口调用:
小程序前端向小程序后端发送购买请求。
通常情况下此请求应该包含商品的金额、币种、商品详情和商品数量,这些信息都会通过预下单接口传递给小程序平台后台,再传给 superapp 后台。
小程序后台请求小程序平台后台进行预下单接口
小程序后台收到前端请求的接口以后,需要校验参数的合理性,库存,折扣券,币种等各类业务逻辑信息,通过以后向小程序平台后台(openserver)发起预下单请求,请特别注意,预下单接口中:订单信息、单价、金额、数量、商户接收支付成功回调通知的地址、用户 openid 需要严格准确,参数详情请参见 接口列表
下面给出接口的报文参考
POST /v3/pay/transactions/jsapi \\
-H "Authorization: ******* " \\
-H "Content-Type:application/json" \\
-d '{
"appid": "wxd678efh567hg6787",
"description": "image Shop - Shenzhen Tengda - qq doll",
"out_trade_no": "1217752501201407033233368018",
"time_expire": "2018-06-08T10:34:56+08:00",
"attach": "custom args",
"notify_url","https://xxx.xxx.xxx/payBackx",
"amount": {
"total": 100,
"currency": "USD"
},
"payer": {
"openid": "XXXXXXXX"
},
"detail": {
"cost_price": 608800,
"goods_detail": [
{
"merchant_goods_id": "1246464644",
"wechatpay_goods_id": "1001",
"goods_name": "iPhoneX 256G",
"quantity": 1,
"unit_price": 528800
}
]
}
}'
小程序平台后端将此请求(含签名和请求体)转发至 superapp 后端。superapp 后端需实现 /v3/pay/transactions/jsapi 接口并返回预下单 ID。
superapp 返回生成的预支付订单号,并原路返回至小程序后台:
{
"prepay_id": "pid_xsxssx12w3123fefe"
}

小程序后台此时获取到 prepay_id 的参数后,还需要生成 paySign 参数,最后返回给小程序前端可以直接进行下单的参数
timeStamp,nonceStr,package,signType,paySign
字段
类型
描述
timeStamp
字符串
时间(秒)
nonceStr
字符串
随机字符串 长度10
package
字符串
固定格式:prepay_id=${prepay_id}
signType
字符串
固定格式:RSA
paySign
字符串
通过小程序 Appid、timeStamp 、nonceStr、prepayId、privateKey 计算获得,算法参考如下
// how to create paySign
const generatePaySign = function (appId, timestamp, nonceStr, prepayId, privateKey) {
const string2sign = `${appId}\\n${timestamp}\\n${nonceStr}\\n${prepayId}\\n`;
const sign = crypto.createSign('RSA-SHA256');
sign.update(string2sign);
const sBytes = sign.sign(privateKey, 'base64');
const paySign = Buffer.from(sBytes, 'base64').toString('base64');
return paySign;
}
最终返回给小程序前端的参数格式如下
const response = {
"timeStamp": "XXXXXX",
"nonceStr": "XXXXXXXX",
"package": "prepay_id=askdlfkadlsfkalsdjflakjsdf",
"signType": "RSA",
"paySign": "XXXXX",
}
2. 支付:小程序发起支付,用户完成支付授权。
小程序前端调用 wx.requestPayment 接口,传入预下单 ID 和签名信息。小程序前端代码如下:
wx.requestPayment({
timeStamp,
nonceStr,
package,
signType: "RSA",
paySign,
success: function(res){
// Pay success
},
fail: function(err){
// cancel or pay has error
}
});
SDK 会将请求转发至 superapp。
superapp 需提供 requestPayment 接口并验证预下单 ID 及签名。
验证通过后,superapp 展示支付及身份确认页面,用户完成支付。
3. 发货:确认支付完成后,通知小程序后端进入下一步业务流程。
用户完成支付后,superapp 后端需调用 小程序平台 提供的支付通知接口 PaymentNotify。
整个请求由平台私钥签名并写入请求头;请求体分为支付结果枚举和具体详情,其中详情部分使用 AES 密钥加密。
小程序平台后端会记录本次请求,并在接下来的 24 小时内重试通知小程序后端。小程序后端应正确实现 notify_url 接口的回调,直至返回正确响应,小程序后端实现的接口示例如下:
假如小程序的后台支付回调的 URL 是 /notify_payBack2,请求报文格式:
POST /notify_callback

Content-Type: application/json; charset=utf-8
Content-Length: 52
Connection: keep-alive
Keep-Alive: timeout=8
Cache-Control: no-cache, must-revalidate
X-Content-Type-Options: nosniff
Request-ID: 08F5B8C2B506102C18FDDFEEA30620BE821E28EDC405-0
Content-Language: zh-CN
Wechatpay-Nonce: d824f2e086d3c1df967785d13fcd22ef
Wechatpay-Signature: mfI1CPqvBrgcXfgXMFjdNIhBf27ACE2YyeWsWV9ZI7T7RU0vHvbQpu9Z32ogzc+k8ZC5n3kz7h70eWKjgqNdKQF0eRp8mVKlmfzMLBVHbssB9jEZEDXThOX1XFqX7s7ymia1hoHQxQagPGzkdWxtlZPZ4ZPvr1RiqkgAu6Is8MZgXXrRoBKqjmSdrP1N7uxzJ/cjfSiis9FiLjuADoqmQ1P7p2N876YPAol7Rn0+GswwAwxldbdLrmVSjfytfSBJFqTMHn4itojgxSWWN1byuckQt8hSTEv/Lg97QoeGniYP17T80pJeQyL3b+295FPHSO2AtvCgyIbKMZ0BALilAA==
Wechatpay-Timestamp: 1722850421
Wechatpay-Serial: PUB_KEY_ID_0000000000000024101100397200000006
Wechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048

{
"id": "EV-2018022511223320873",
"create_time": "2015-05-20T13:29:35+08:00",
"resource_type": "encrypt-resource",
"event_type": "TRANSACTION.SUCCESS",
"summary": "pay success",
"resource": {
"original_type": "transaction",
"algorithm": "AEAD_AES_256_GCM",
"ciphertext": "*************",
"associated_data": "",
"nonce": ""
}
}

HTTP 头 Wechatpay-Timestamp 中的应答时间戳
HTTP 头 Wechatpay-Nonce 中的应答随机串
支付的应答签名通过 HTTP 头 Wechatpay-Signature 传递
应答报文主体(Response Body),请使用原始报文主体执行验签。如果您使用了某个框架,要确保它不会篡改报文主体。对报文主体的任何篡改都会导致验证失败。
然后,请按照以下规则构造应答的验签名串。签名串共有三行,行尾以\\n 结束,包括最后一行。\\n 为换行符(ASCII 编码值为 0x0A)。若应答报文主体为空(如 HTTP 状态码为 204 No Content),最后一行仅为一个 \\n 换行符。
应答时间戳\\n应答随机串\\n应答报文主体\\n
1722850421\\n2d824f2e086d3c1df967785d13fcd22ef\\nbodyContent\\n
再进行验证签名,很多编程语言的签名验证函数支持对验签名串和签名进行签名验证。强烈建议商户调用该类函数,使用支付公钥对验签名串和签名进行 SHA256 with RSA 签名验证。
(1)下面展示使用命令行演示如何进行验签。假设我们已经获取了支付公钥并保存为 pay_pub.pem。内容如下:
$ cat pay_pub.pem2
-----BEGIN PUBLIC KEY-----
3MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4zej1cqugGQtVSY2Ah8RMCKcr2UpZ8Npo+5Ja9xpFPYkWHaF1Gjrn3d5kcwAFuHHcfdc3yxDYx6+9grvJnCA2zQzWjzVRa3BJ5LTMj6yqvhEmtvjO9D1xbFTA2m3kyjxlaIar/RYHZSslT4VmjIatW9KJCDKkwpM6x/RIWL8wwfFwgz2q3Zcrff1y72nB8p8P12ndH7GSLoY6d2Tv0OB2+We2Kyy2+QzfGXOmLp7UK/pFQjJjzhSf9jxaWJXYKIBxpGlddbRZj9PqvFPTiep8rvfKGNZF9Q6QaMYTpTp/uKQ3YvpDlyeQlYe4rRFauH3mOE6j56QlYQWivknDX9VrwIDAQAB4
-----END PUBLIC KEY-----
(2) 使用 base64 解码应答签名,将保存为文件 signature.txt 。(以下命令行均在一行)
$openssl base64 -d -A <<< \\ 'CtcbzwtQjN8rnOXItEBJ5aQFSnIXESeV28Pr2YEmf9wsDQ8Nx25ytW6FXBCAFdrr0mgqngX3AD9gNzjnNHzSGTPBSsaEkIfhPF4b8YRRTpny88tNLyprXA0GU5ID3DkZHpjFkX1hAp/D0fva2GKjGRLtvYbtUk/OLYqFuzbjt3yOBzJSKQqJsvbXILffgAmX4pKql+Ln+6UPvSCeKwznvtPaEx+9nMBmKu7Wpbqm/+2ksc0XwjD+xlvlECkCxfD/OJ4gN3IurE0fpjxIkvHDiinQmk51BI7zQD8k1znU7r/spPqB+vZjc5ep6DC5wZUpFu5vJ8MoNKjCu8wnzyCFdA==' > signature.txt
(3) 最后,验证签名,得到验签结果,请确认你的结果和文档的结果一致,如果验签结果是Verification Failure,请确认是否获取到了正确的支付公钥或者验签串是否有严格按照文档格式换行:
$ openssl dgst -sha256 -verify pay_pub.pem -signature signature.txt << EOF
1554209980
c5ac7061fccab6bf3e254dcf98995b8c
{"data":[{"serial_no":"5157F09EFDC096DE15EBE81A47057A7232F1B8E1","effective_time":"2018-03-26T11:39:50+08:00","expire_time":"2023-03-25T11:39:50+08:00","encrypt_certificate":{"algorithm":"AEAD_AES_256_GCM","nonce":"d215b0511e9c","associated_data":"certificate","ciphertext":"..."}}]}
EOF
如果结果为 verified ok 则表示验签通过
如果小程序想获取到回调通知的密文内容,需要对 resource 内容进行解密。
解密参考代码如下:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import base64
def decrypt(nonce, ciphertext, associated_data):
key = "Your32Apiv3Key"
key_bytes = str.encode(key)
nonce_bytes = str.encode(nonce)
ad_bytes = str.encode(associated_data)
data = base64.b64decode(ciphertext)
aesgcm = AESGCM(key_bytes)
return aesgcm.decrypt(nonce_bytes, data, ad_bytes)
完成上述操作以后,小程序后台可以返回如下报文,告诉小程序平台已经收到并且正确的处理了支付回调:
{code : 200}
4. 支付确认:小程序前端可进行再次确认,确保支付完成请求未被伪造。
建议做法:小程序前端接收到支付成功回调后,为确保数据真实,小程序前端可以调用小程序后端接口,验证支付是否确已正确执行。

应用集成

iOS和Android需要在应用侧实现支付代理接口
iOS
需实现 requestPayment 代理接口。应用程序应当根据后台接口返回的数据,通过 completionHandler 将成功或失败结果返回给 SDK,然后由 SDK 传递给小程序。示例代码:
- (void)requestPayment:(TMFMiniAppInfo *)app params:(NSDictionary *)params completionHandler:(MACommonCallback)completionHandler {
[TUITool makeToast:@"Prepare to pay..."];
// Request the App backend based on the params parameters passed in by the mini program
[[TCMPPDemoPayManager sharedInstance] checkPreOrder:params completionHandler:^(NSError * _Nullable err, PayResponseData * _Nullable result) {
[TUITool hideToastActivity];
if (!err) {
// The request succeeded,and the payment native page pops up
dispatch_async(dispatch_get_main_queue(), ^{
TCMPPPaymentMethodsController *payMethodVC = [[TCMPPPaymentMethodsController alloc]init];
payMethodVC.payResponseData = result;
payMethodVC.app = app;
[[[TCMPPShareMiniAppModule sharedInstance] topViewController].navigationController pushViewController:payMethodVC animated:NO];
payMethodVC.completeHandle = ^(NSDictionary * _Nullable result, NSError * _Nullable error) {
// Complete payment and call back to the mini program
completionHandler(result,error);
};
});
} else {
// The request failed, payment was not completed, and the callback was sent to the mini program
NSDictionary *retDic = @{@"retmsg":err.localizedDescription};
if (result.returnCode && result.returnMessage) {
retDic = @{@"returnCode":result.returnCode,@"returnMessage":result.returnMessage};
}
completionHandler(retDic,err);
}
}];
}
Android
APP 开发者需要实现 MiniOpenApiProxy 的代理,实现代理类中的 requestPayment 方法,并使用 AsyncResult 将支付结果返回给小程序。
实现方式如下:
@ProxyService(proxy = MiniOpenApiProxy.class)
public class MiniOpenApiProxyV2Impl extends MiniOpenApiProxy {
private static final String TAG = "MiniOpenApiProxy";

@Override
public void requestPayment(IMiniAppContext miniAppContext, JSONObject params, AsyncResult result) {
PaymentManagerV2.g().startPayment(miniAppContext, params, result);
}
}

public class PaymentManagerV2 {
private static final String TAG = "PaymentManager";
private static PaymentManagerV2 instance;
private final PayApiV2 payApi = new PayApiV2();
private PaymentRequest paymentRequest;

public static PaymentManagerV2 g() {
if (instance == null) {
instance = new PaymentManagerV2();
}
return instance;
}

public void startPayment(IMiniAppContext miniAppContext, JSONObject params, AsyncResult result) {
// do params check
if (params.has("prepayId")) {
checkOrder(miniAppContext, params, result);
} else {
JSONObject failRet = new JSONObject();
try {
failRet.put("success", false);
} catch (JSONException e) {
}
result.onReceiveResult(false, failRet);
}
}

/**
* STEP 1: check order status
*
* @param miniAppContext
* @param params
* @param result
*/
private void checkOrder(IMiniAppContext miniAppContext, JSONObject params, AsyncResult result) {
String appId = miniAppContext.getMiniAppInfo().appId;
Activity activity = miniAppContext.getAttachedActivity();
payApi.checkOrder(appId, params, new PayApiV2.PayCallBack() {
@Override
public void onSuccess(JSONObject checkRet) {
String fee = checkRet.optString("actualAmount");
double paymentValue = Double.parseDouble(fee) / 10000;
showPayTypeList(activity, paymentValue, checkRet.toString());
paymentRequest = new PaymentRequest(miniAppContext, checkRet, result, paymentValue);
Log.e("TAG", "payment request " + this.hashCode());
}

@Override
public void onFailed(String errCode, String msg) {
Log.e(TAG, "pay failed " + msg);
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(miniAppContext.getContext(), "failed", Toast.LENGTH_SHORT).show();
}
});
result.onReceiveResult(false, new JSONObject());
}
});
}

/**
* STEP 2: show pwd dialog
*
* @param activity
* @param money
*/
private void showPwdDialog(Activity activity, double money, ICustomPayCallback payCallback, int iconSrc,
String desc) {
CustomPayDemo.CustomPayDialog customPayDialog = new CustomPayDemo.CustomPayDialog(activity, money,
R.style.MyAlertDialog, iconSrc, desc);
customPayDialog.addPayResultListen(payCallback);
customPayDialog.show();
}

/**
* STEP 3: confirm pwd input and check pwd
*
* @param activity
* @param pwd
* @param data
* @param result
*/
private void checkPwdAndPay(Activity activity, String pwd, JSONObject data, AsyncResult result, String payModel,
String payModelId) {
if (checkPassWord(pwd)) {
requestPayment(activity, data, result, payModel, payModelId);
} else {
JSONObject ret = new JSONObject();
try {
ret.put("errMsg", "bad pwd ");
} catch (JSONException e) {
}
result.onReceiveResult(false, ret);
activity.finish();
}
}

private boolean checkPassWord(String pwd) {
return "666666".equals(pwd);
}

/**
* STEP 4: request payment
*
* @param activity
* @param data
* @param asyncResult
*/
private void requestPayment(Activity activity, JSONObject data, AsyncResult asyncResult, String payModel, String paymdelId) {
payApi.payOrder(data, new PayApiV2.PayCallBack() {
@Override
public void onSuccess(JSONObject result) {wei
String totalFee = result.optString("paymentAmount");
showPayResult(true, activity, totalFee);
JSONObject resultToJs = result.optJSONObject("data");
asyncResult.onReceiveResult(true, resultToJs);
}


@Override
public void onFailed(String errCode, String msg) {
JSONObject ret = new JSONObject();
try {
ret.put("errMsg", msg);
ret.put("errCode", errCode);
} catch (JSONException e) {
}
asyncResult.onReceiveResult(false, ret);
activity.finish();
}
}, payModel, paymdelId);
}

private void showPayResult(boolean success, Activity activity, String total) {
Intent intent = new Intent(activity, PaymentResultActivity.class);
intent.putExtra("success", success);
intent.putExtra("total", total);
intent.putExtra("isV2", true);
activity.startActivity(intent);
activity.finish();
}

private void showPayTypeList(Activity activity, double total, String rawData) {
Intent intent = new Intent(activity, PaymentMethodActivity.class);
intent.putExtra("totalFee", total);
intent.putExtra("rawData", rawData);
activity.startActivity(intent);
}

public void showPwdConfirm(Activity activity, int iconSrc, String desc, String model, String modelId) {
if (null != paymentRequest) {
AsyncResult result = paymentRequest.result;
JSONObject checkRet = paymentRequest.params;
try {
checkRet.put("appId", paymentRequest.miniAppContext.getMiniAppInfo().appId);
} catch (JSONException | NullPointerException e) {
}
showPwdDialog(activity, paymentRequest.payValue, (retCode, msg, dialogInterface) -> {
if (retCode == 1) {
String pwd = ((CustomPayDemo.CustomPayDialog) dialogInterface).getInputText();
Log.e(TAG, "onDismiss isComplete=" + pwd);
if (!TextUtils.isEmpty(pwd)) {
checkPwdAndPay(activity, pwd, checkRet, result, model, modelId);
} else {
Log.e(TAG, "empty pwd ~");
JSONObject ret = new JSONObject();
try {
ret.put("errMsg", "empty pws");
} catch (JSONException e) {
}
result.onReceiveResult(false, ret);
activity.finish();
}
paymentRequest = null;
}
}, iconSrc, desc);
}
}

public void notifyPaymentCancel() {
if (null != paymentRequest) {
JSONObject ret = new JSONObject();
try {
ret.put("errMsg", "cancel");
} catch (JSONException e) {
}
paymentRequest.result.onReceiveResult(false, ret);
paymentRequest = null;
}
}

private static class PaymentRequest {
public IMiniAppContext miniAppContext;
public JSONObject params;
public AsyncResult result;
public double payValue = 0f;

public PaymentRequest(IMiniAppContext miniAppContext, JSONObject params, AsyncResult result, double payValue) {
this.miniAppContext = miniAppContext;
this.params = params;
this.result = result;
this.payValue = payValue;

}
}
}


控制台操作流程

在完成上述流程中的事项后,当小程序或小游戏入驻 superapp 时,superapp 和小程序/小游戏需要在控制台完成商户号 ID 绑定申请和审批的流程。

应用开启支付开关和审批请参见:
小程序申请绑定商户号请参见:






帮助和支持

本页内容是否解决了您的问题?

填写满意度调查问卷,共创更好文档体验。

文档反馈