開場白直接引用官方文檔的吧。
為了在保證支付安全的前提下,帶給商戶簡單、一致且易用的開發(fā)體驗(yàn),我們推出了全新的微信支付 APIv3 接口。
為啥不用官方 SDK?
官方 SDK 不錯(cuò),只是依賴 Apache-httpclient,可是我連 Apache-httpclient 都不想用啊,于是就自行接入。其實(shí)官方文檔也很詳盡,只是有點(diǎn)亂(否則就沒有我寫本文的需要啦)。官方文檔如是說。
在規(guī)則說明中,你將了解到微信支付API v3的基礎(chǔ)約定,如數(shù)據(jù)格式、參數(shù)兼容性、錯(cuò)誤處理、UA說明等。我們還重點(diǎn)介紹了微信支付API v3新的認(rèn)證機(jī)制(證書/密鑰/簽名)。你可以跟隨著開發(fā)指南,使用命令行或者你熟悉的編程語言,一步一步實(shí)踐簽名生成、簽名驗(yàn)證、證書和回調(diào)報(bào)文解密和敏感信息加解密。在最后的常見問題中,我們總結(jié)了商戶接入過程遇到的各種問題。
準(zhǔn)備條件
該申請的都申請,把所需的條件準(zhǔn)備好。形成如下 Java POJO 要求的字段。
/**
* 微信支付 商戶配置
*
* @author Frank Cheung<sp42@qq.com>
*
*/
public class MerchantConfig {
/**
* 商戶號
*/
private String mchId;
/**
* 商戶證書序列號
*/
private String mchSerialNo;
/**
* V3 密鑰
*/
private String apiV3Key;
/**
* 商戶私鑰
*/
private String privateKey;
public String getMchId() {
return mchId;
}
public void setMchId(String mchId) {
this.mchId = mchId;
}
public String getMchSerialNo() {
return mchSerialNo;
}
public void setMchSerialNo(String mchSerialNo) {
this.mchSerialNo = mchSerialNo;
}
public String getApiV3Key() {
return apiV3Key;
}
public void setApiV3Key(String apiV3Key) {
this.apiV3Key = apiV3Key;
}
public String getPrivateKey() {
return privateKey;
}
public void setPrivateKey(String privateKey) {
this.privateKey = privateKey;
}
}
簽名
訪問商戶平臺的支付接口都要在 HTTP Head 加上簽名才能訪問。下圖以小程序的為例子。
如何生成簽名?下面按照文檔指引,以獲取商戶平臺證書為例子,生成簽名。
準(zhǔn)備私鑰
首先你要準(zhǔn)備好商戶 API 證書里面的私鑰(Private Key),例如我當(dāng)前讀取磁盤的證書(當(dāng)然這個(gè)到時(shí)要部署到服務(wù)器資源目錄下)。
private String privateKey = FileHelper.openAsText("C:\\Users\\frank\\Downloads\\WXCertUtil\\cert\\1623777099_20220330_cert\\apiclient_key.pem");
@Autowired
private MerchantConfig cfg;
……
cfg.setPrivateKey(privateKey);// 保存到配置
轉(zhuǎn)換為 Java 里面的 PrivateKey
對象,依靠下面的工具類 PemUtil
。
public class PemUtil {
public static PrivateKey loadPrivateKey(String privateKey) {
privateKey = privateKey.replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", "").replaceAll("\\s+", "");
try {
KeyFactory kf = KeyFactory.getInstance("RSA");
return kf.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("當(dāng)前Java環(huán)境不支持RSA", e);
} catch (InvalidKeySpecException e) {
throw new RuntimeException("無效的密鑰格式");
}
}
……
}
簽名生成器
簽名過程參見文檔,此處不再贅述。除了一般的時(shí)間戳、請求隨機(jī)串(nonce_str
)等等之外,簽名要求內(nèi)容有請求接口的 HTTP 方法、URL 和 請求報(bào)文主體,為此我們準(zhǔn)備一個(gè)簡單的 Bean。
/**
* 請求接口的 HTTP 方法、URL 和 請求報(bào)文主體
*
* @author Frank Cheung<sp42@qq.com>
*
*/
public class HttpRequestWrapper {
public String method;
public String url;
public String body;
}
我們看看調(diào)用例子。
HttpRequestWrapper r = new HttpRequestWrapper();
r.method = "GET";
r.url = "/v3/certificates";
r.body = "";
SignerMaker signer = new SignerMaker(cfg);
String token = signer.getToken(r);// 得到簽名
簽名生成器 SignerMaker
源碼如下。
import java.nio.charset.StandardCharsets;
import java.security.PrivateKey;
import com.ajaxjs.util.StrUtil;
import com.ajaxjs.util.logger.LogHelper;
import com.ajaxjs.wechat.applet.util.PemUtil;
import com.ajaxjs.wechat.applet.util.RsaCryptoUtil;
/**
* 簽名生成器
*
* @author Frank Cheung<sp42@qq.com>
*
*/
public class SignerMaker {
private static final LogHelper LOGGER = LogHelper.getLog(SignerMaker.class);
private MerchantConfig cfg;
protected final PrivateKey privateKey;
/**
* 創(chuàng)建簽名生成器
*
* @param cfg 商戶平臺的配置
*/
public SignerMaker(MerchantConfig cfg) {
this.cfg = cfg;
this.privateKey = PemUtil.loadPrivateKey(cfg.getPrivateKey());
}
/**
* 生成簽名
*
* @param request
* @return 簽名 Token
*/
public String getToken(HttpRequestWrapper request) {
String nonceStr = StrUtil.getRandomString(32);
long timestamp = System.currentTimeMillis() / 1000;
String message = buildMessage(request, nonceStr, timestamp);
LOGGER.debug("authorization message=[{0}]", message);
String signature = RsaCryptoUtil.sign(privateKey, message.getBytes(StandardCharsets.UTF_8));
// @formatter:off
String token = "mchid=\"" + cfg.getMchId() + "\","
+ "nonce_str=\"" + nonceStr + "\","
+ "timestamp=\"" + timestamp + "\","
+ "serial_no=\"" + cfg.getMchSerialNo() + "\","
+ "signature=\"" + signature + "\"";
// @formatter:on
LOGGER.debug("authorization token=[{0}]", token);
return token;
}
/**
*
* @param request
* @param nonceStr
* @param timestamp
* @return
*/
static String buildMessage(HttpRequestWrapper request, String nonceStr, long timestamp) {
// @formatter:off
return request.method + "\n"
+ request.url + "\n"
+ timestamp + "\n"
+ nonceStr + "\n"
+ request.body + "\n";
// @formatter:on
}
}
從代碼量看確實(shí)比以前簡單了。
對簽名數(shù)據(jù)進(jìn)行簽名
上述 getToken()
里面會調(diào)用 RsaCryptoUtil.sign()
,其源碼如下。
/**
* 對簽名數(shù)據(jù)進(jìn)行簽名。
*
* 使用商戶私鑰對待簽名串進(jìn)行 SHA256 with RSA 簽名,并對簽名結(jié)果進(jìn)行 Base64 編碼得到簽名值。
*
* @param message
* @return 簽名結(jié)果
*/
public static String sign(PrivateKey privateKey, byte[] message) {
try {
Signature sign = Signature.getInstance("SHA256withRSA");
sign.initSign(privateKey);
sign.update(message);
return StrUtil.base64Encode(sign.sign());
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("當(dāng)前 Java 環(huán)境不支持 SHA256withRSA", e);
} catch (SignatureException e) {
throw new RuntimeException("簽名計(jì)算失敗", e);
} catch (InvalidKeyException e) {
throw new RuntimeException("無效的私鑰", e);
}
}
測試
得到簽名 Token 后就可以放在請求頭里面測試了,如下獲取證書,這是我自己封裝的請求方法(Get.api()
)。
商戶API證書 v.s 微信支付平臺證書
事情復(fù)雜起來了,
獲取平臺證書
參見文檔、更新指引。
貌似證書生成之后就不用更新。官方推薦更新,是為了更好的安全性,如果你想省事,就壓根不做更新吧,證書有效期到三年后(好像)。
另外官方還有微信支付 APIv3 平臺證書的命令行下載工具:https://github.com/wechatpay-apiv3/CertificateDownloader
用戶登錄 & 注冊
每個(gè)用戶針對每個(gè)公眾號會產(chǎn)生一個(gè)安全的 openid;openid 只有在 appid 的作用域下可用。
流程圖如下
小程序前端設(shè)置一個(gè)登錄按鈕:
<button type="primary" open-type="getUserInfo" bindgetuserinfo="bindGetUserInfo">登錄</button>
點(diǎn)擊事件發(fā)起登錄請求
bindGetUserInfo(res: any): void {
let userInfo: any = res.detail.userInfo;
wx.login({
success(res) {
if (res.code) {
//發(fā)起網(wǎng)絡(luò)請求
wx.request({
url: 'http://127.0.0.1:8080/cp/applet/user/login?code=' + res.code,
method: 'POST',
data: userInfo,
header: { 'Content-Type': 'application/json' },
success(res) {
if (res.data.isOk) {
//獲取到用戶憑證 存儲 3rd_session
wx.setStorage({
key: "sessionId",
data: res.data.sessionId
});
} else
console.error(res)
},
fail: function (res) {
console.log(res)
}
});
}
},
fail(res) {
}
});
}
登錄控制器 AppletUserController
如下
import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ajaxjs.framework.BaseController;
import com.ajaxjs.net.http.Get;
import com.ajaxjs.sql.orm.Repository;
import com.ajaxjs.user.model.User;
import com.ajaxjs.user.model.UserConstant;
import com.ajaxjs.user.model.UserOauth;
import com.ajaxjs.user.service.UserDao;
import com.ajaxjs.user.service.UserOauthDao;
import com.ajaxjs.util.StrUtil;
import com.ajaxjs.util.filter.DataBaseFilter;
import com.ajaxjs.util.logger.LogHelper;
import com.ajaxjs.util.map.MapTool;
import com.ajaxjs.web.WebHelper;
import com.ajaxjs.wechat.applet.model.LoginSession;
import com.ajaxjs.wechat.applet.model.UserInfo;
import com.ajaxjs.wechat.applet.model.WeChatAppletConfig;
import com.ajaxjs.wechat.user.UserMgr;
/**
* 小程序用戶接口
*
* @author Frank Cheung<sp42@qq.com>
*
*/
@RestController
@RequestMapping("/applet/user")
public class AppletUserController {
private static final LogHelper LOGGER = LogHelper.getLog(AppletUserController.class);
@Autowired
private WeChatAppletConfig cfg;
private static UserDao userDao = new Repository().bind(UserDao.class);
private static UserOauthDao userOauthDao = new Repository().bind(UserOauthDao.class);
/**
* 登錄 or 注冊
*
* @param code 授權(quán)碼
* @param req
* @return
*/
@PostMapping(value = "/login", produces = MediaType.APPLICATION_JSON_VALUE + ";charset=utf-8")
@DataBaseFilter
public String login(@RequestParam(required = true) String code, HttpServletRequest req) {
LoginSession session = login(cfg, code);
User user = userDao.findUserByOauthId(session.getOpenid());
if (user == null) {
Map<String, Object> userInfo = WebHelper.getRawBodyAsJson(req);
if (userInfo != null) {
LOGGER.info("沒有會員,新注冊 " + userInfo);
user = register(userInfo, session.getOpenid());
} else
throw new IllegalArgumentException("缺少 userInfoJson 參數(shù)");
} else {
LOGGER.info("用戶已經(jīng)注冊過");
}
Map<String, Object> map = new HashMap<>();
map.put("isOk", true);
map.put("msg", "登錄成功");
map.put("sessionId", session.getSession_id());
map.put("userId", user.getId());
map.put("userName", user.getUsername());
return BaseController.toJson(map, true, false);
}
private final static String LOGIN_API = "https://api.weixin.qq.com/sns/jscode2session";
/**
* 小程序登錄
*
* @param cfg
* @param code
*/
private static LoginSession login(WeChatAppletConfig cfg, String code) {
LOGGER.info("小程序登錄");
String params = String.format("?grant_type=authorization_code&appid=%s&secret=%s&js_code=%s", cfg.getAccessKeyId(), cfg.getAccessSecret(), code);
Map<String, Object> map = Get.api(LOGIN_API + params);
LoginSession session = null;
if (map.containsKey("openid")) {
// cfg.setAccessToken(map.get("access_token").toString());
LOGGER.warning("小程序登錄成功! AccessToken [{0}]", map.containsKey("openid"));
String rndStr = StrUtil.getRandomString(8);
session = new LoginSession();
session.setOpenid(map.get("openid").toString());
session.setSession_key(map.get("session_key").toString());
session.setSession_id(rndStr);
UserMgr.SESSION.put(rndStr, session);
} else if (map.containsKey("errcode")) {
LOGGER.warning("小程序登錄失?。?Error [{0}:{1}]", map.get("errcode"), map.get("errmsg"));
throw new SecurityException(String.format("小程序登錄失敗,Error [%s]", map.get("errmsg")));
} else {
LOGGER.warning("小程序登錄失敗,未知異常 [{0}]", map);
throw new SecurityException("小程序登錄失敗,未知異常");
}
return session;
}
/**
* 注冊新用戶
*
* @param userInfoJson 用戶信息,微信后臺提供
* @param string OpenId
* @return 用戶對象
*/
private User register(Map<String, Object> userInfoJson, String openId) {
UserInfo wxUser = MapTool.map2Bean(userInfoJson, UserInfo.class);
User user = wxUser.toSystemUser();
Long userId = userDao.create(user);
UserOauth oauth = new UserOauth();
oauth.setUserId(userId);
oauth.setIdentifier(openId);
oauth.setLoginType(UserConstant.LoginType.WECHAT_APPLET);
userOauthDao.saveOpenId(oauth);
return user;
}
}
小程序過來的用戶信息有密文的,懶得使用或校驗(yàn)了。
參考:
- https://www.cnblogs.com/nosqlcoco/p/6105749.html
- https://cloud.tencent.com/developer/article/1158797
- https://blog.csdn.net/qq_41970025/article/details/90700677
下單
TODO
回調(diào)報(bào)文解密
在?支付通知 API 時(shí)候會用到,參見《?證書和回調(diào)報(bào)文解密》,解密類 AesUtil
如下:
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* 證書和回調(diào)報(bào)文解密
*
* @author Frank Cheung<sp42@qq.com>
*
*/
public class AesUtil {
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int KEY_LENGTH_BYTE = 32;
private static final int TAG_LENGTH_BIT = 128;
private final byte[] aesKey;
/**
* 解密器
*
* @param key
*/
public AesUtil(byte[] key) {
if (key.length != KEY_LENGTH_BYTE)
throw new IllegalArgumentException("無效的 ApiV3Key,長度必須為32個(gè)字節(jié)");
this.aesKey = key;
}
/**
* AEAD_AES_256_GCM 解密
*
* @param associatedData
* @param nonce
* @param ciphertext
* @return
* @throws GeneralSecurityException
*/
public String decryptToString(byte[] associatedData, byte[] nonce, String ciphertext) throws GeneralSecurityException {
try {
SecretKeySpec key = new SecretKeySpec(aesKey, "AES");
GCMParameterSpec spec = new GCMParameterSpec(TAG_LENGTH_BIT, nonce);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, key, spec);
cipher.updateAAD(associatedData);
// TODO base64 方法
return new String(cipher.doFinal(Base64.getDecoder().decode(ciphertext)), StandardCharsets.UTF_8);
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
throw new IllegalStateException(e);
} catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
throw new IllegalArgumentException(e);
}
}
}
p12 證書轉(zhuǎn)換
接手一個(gè)遺留項(xiàng)目,沒辦法獲取新的證書。獲取新的證書舊的就會作廢,因?yàn)檫@是已經(jīng)上線的項(xiàng)目。得到只有一個(gè) *.p12
的證書,但新版的 v3 支付的要求 pem
證書。咋搞?原來可以從 p12 轉(zhuǎn)換到 pem,這需要用到 openssl 命令行。提示一下,我 win 是上安裝 openssl,執(zhí)行報(bào)錯(cuò),最后在 Linux 服務(wù)器成功執(zhí)行。
# 查看所有信息
openssl pkcs12 -info -in apiclient_cert.p12 -nodes
# 導(dǎo)出證書
openssl pkcs12 -in apiclient_cert.p12 -out cert.pem -nokeys
# 導(dǎo)出秘鑰
openssl pkcs12 -in apiclient_cert.p12 -out private_key.pem -nodes -nocerts
# 查看證書序列號
openssl x509 -in cert.pem -noout -serial
過程中會讓輸入密碼,默認(rèn)就是證書對應(yīng)的商戶號。
小結(jié)
其實(shí)可以參考一下人家開源寫好的,比較成熟:https://github.com/Wechat-Group/WxJava。文章來源:http://www.zghlxwxcb.cn/news/detail-496851.html
參考文獻(xiàn)文章來源地址http://www.zghlxwxcb.cn/news/detail-496851.html
- 《微信支付分,APIv3版本接口對接過程(附代碼)》
- 《一文搞懂「微信支付 Api-v3」接口規(guī)則所有知識點(diǎn)》
- 《Spring Boot 對接微信V3支付(附源碼)》
- 微信V3APP支付2022,全網(wǎng)最新+踩坑(已實(shí)現(xiàn))
到了這里,關(guān)于實(shí)戰(zhàn)微信支付 APIv3 接口(小程序的)的文章就介紹完了。如果您還想了解更多內(nèi)容,請?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!