- 服务号订阅通知灰度测试
服务号模板消息能力的设计初衷,旨在帮助开发者实现及时通知,但存在一些问题,如: 1. 部分开发者在用户无预期的情况下,发送与用户无关的信息,对用户造成了骚扰。 2. 模板消息是用户触发后的通知消息,不支持营销类消息,不能满足部分业务需求。 为提升微信用户体验,我们开始灰度测试服务号订阅通知功能。 能力说明 开发者可在服务号图文消息、网页等场景设置订阅功能,用户自主订阅后,开发者可按需求下发一条对应的订阅通知。 [图片] 用户可在图文订阅通知 [图片] 用户可在网页订阅通知 灰度测试计划 服务号订阅通知功能即日上线,已认证的境内主体服务号可前往 MP 后台开通使用,详见说明。 1. 服务号订阅通知灰度测试期自2021年1月27日0:00至4月30日24:00,期间服务号模板消息可正常使用;灰度测试期结束后服务号订阅通知的策略将另行公布,届时以官方信息为准; 2. 开发者使用订阅通知功能时,需遵循运营规范,不可用奖励或其它形式强制用户订阅,不可下发与用户预期不符或违反国家法律法规的内容。具体可参考文档:《微信公众平台运营规范》 微信团队 2021年1月27日
2021-01-29 - 一文搞懂微信支付 Api-v3 规则实现(附源码)
v2 与 v3 的区别 先看看 v2 与 v3 的区别,做到心中有数不怯场:) V3 规则差异 V2 JSON 参数格式 XML POST、GET 或 DELETE 提交方式 POST AES-256-GCM加密 回调加密 无需加密 RSA 加密 敏感加密 无需加密 UTF-8 编码方式 UTF-8 非对称密钥SHA256-RSA 签名方式 MD5 或 HMAC-SHA256 微信支付Api-v3 规则 官方文档 ,此规则需要你耐心细品,重复多此细品效果更佳。 以下是我细品后,总结的实现方案,在这里就分享给大家,干货多屁话少直接贴实现。 Talk is cheap. Show me the code. 获取证书序列号 通过代码获取 这里我们使用第三方的库 x509,如你知道其它获取方法欢迎留言 [代码]const cert = x509.parseCert('cert.pem 证书绝对路径') console.log(`证书序列号:${cert.serial}`) [代码] 通过工具获取 openssl x509 -in apiclient_cert.pem -noout -serial 使用证书解析工具 https://myssl.com/cert_decode.html 构建请求头 1、构建请求签名参数 [代码] /** * 构建请求签名参数 * @param method {RequestMethod} Http 请求方式 * @param url 请求接口 /v3/certificates * @param timestamp 获取发起请求时的系统当前时间戳 * @param nonceStr 随机字符串 * @param body 请求报文主体 */ public static buildReqSignMessage(method: RequestMethod, url: string, timestamp: string, nonceStr: string, body: string): string { return method .concat('\n') .concat(url) .concat('\n') .concat(timestamp) .concat('\n') .concat(nonceStr) .concat('\n') .concat(body) .concat('\n') } [代码] 2、使用 SHA256 with RSA 算法生成签名 [代码] /** * SHA256withRSA * @param data 待加密字符 * @param privatekey 私钥key key.pem fs.readFileSync(keyPath) */ public static sha256WithRsa(data: string, privatekey: Buffer): string { return crypto .createSign('RSA-SHA256') .update(data) .sign(privatekey, 'base64') } [代码] 3、根据平台规则生成请求头 authorization [代码] /** * 获取授权认证信息 * * @param mchId 商户号 * @param serialNo 商户API证书序列号 * @param nonceStr 请求随机串 * @param timestamp 时间戳 * @param signature 签名值 * @param authType 认证类型,目前为WECHATPAY2-SHA256-RSA2048 */ public static getAuthorization(mchId: string, serialNo: string, nonceStr: string, timestamp: string, signature: string, authType: string): string { let map: Map<string, string> = new Map() map.set('mchid', mchId) map.set('serial_no', serialNo) map.set('nonce_str', nonceStr) map.set('timestamp', timestamp) map.set('signature', signature) return authType.concat(' ').concat(this.createLinkString(map, ',', false, true)) } [代码] 4、Show Time [代码]/** * 构建 v3 接口所需的 Authorization * * @param method {RequestMethod} 请求方法 * @param urlSuffix 可通过 WxApiType 来获取,URL挂载参数需要自行拼接 * @param mchId 商户Id * @param serialNo 商户 API 证书序列号 * @param key key.pem 证书 * @param body 接口请求参数 */ public static async buildAuthorization(method: RequestMethod, urlSuffix: string, mchId: string, serialNo: string, key: Buffer, body: string): Promise<string> { let timestamp: string = parseInt((Date.now() / 1000).toString()).toString() let authType: string = 'WECHATPAY2-SHA256-RSA2048' let nonceStr: string = Kits.generateStr() // 构建签名参数 let buildSignMessage: string = this.buildReqSignMessage(method, urlSuffix, timestamp, nonceStr, body) // 生成签名 let signature: string = this.sha256WithRsa(key, buildSignMessage) // 根据平台规则生成请求头 authorization return this.getAuthorization(mchId, serialNo, nonceStr, timestamp, signature, authType) } [代码] 封装网络请求 每个人都有个性,可使用的网络库也比较多(Axios、Fetch、Request 等),为了适配能适配这里做一代理封装。具体实现如下,网络请求库默认是使用的 Axios 1、抽离抽象接口 [代码]/** * @author Javen * @copyright javendev@126.com * @description 封装网络请求工具 */ export class HttpKit { private static delegate: HttpDelegate = new AxiosHttpKit() public static get getHttpDelegate(): HttpDelegate { return this.delegate } public static set setHttpDelegate(delegate: HttpDelegate) { this.delegate = delegate } } export interface HttpDelegate { httpGet(url: string, options?: any): Promise<any> httpGetToResponse(url: string, options?: any): Promise<any> httpPost(url: string, data: string, options?: any): Promise<any> httpPostToResponse(url: string, data: string, options?: any): Promise<any> httpDeleteToResponse(url: string, options?: any): Promise<any> httpPostWithCert(url: string, data: string, certFileContent: Buffer, passphrase: string): Promise<any> upload(url: string, filePath: string, params?: string): Promise<any> } [代码] 2、Axios 具体实现 [代码]/** * @author Javen * @copyright javendev@126.com * @description 使用 Axios 实现网络请求 */ import axios from 'axios' import * as fs from 'fs' import { HttpDelegate } from './HttpKit' import * as FormData from 'form-data' import * as https from 'https' import concat = require('concat-stream') export class AxiosHttpKit implements HttpDelegate { httpGet(url: string, options?: any): Promise<any> { return new Promise((resolve, reject) => { axios .get(url, options) .then(response => { if (response.status === 200) { resolve(response.data) } else { reject(`error code ${response.status}`) } }) .catch(error => { reject(error) }) }) } httpGetToResponse(url: string, options?: any): Promise<any> { return new Promise(resolve => { axios .get(url, options) .then(response => { resolve(response) }) .catch(error => { resolve(error.response) }) }) } httpPost(url: string, data: string, options?: any): Promise<any> { return new Promise((resolve, reject) => { axios .post(url, data, options) .then(response => { if (response.status === 200) { resolve(response.data) } else { reject(`error code ${response.status}`) } }) .catch(error => { reject(error) }) }) } httpPostToResponse(url: string, data: string, options?: any): Promise<any> { return new Promise(resolve => { axios .post(url, data, options) .then(response => { resolve(response) }) .catch(error => { resolve(error.response) }) }) } httpDeleteToResponse(url: string, options?: any): Promise<any> { return new Promise(resolve => { axios .delete(url, options) .then(response => { resolve(response) }) .catch(error => { resolve(error.response) }) }) } httpPostWithCert(url: string, data: string, certFileContent: Buffer, passphrase: string): Promise<any> { return new Promise((resolve, reject) => { let httpsAgent = new https.Agent({ pfx: certFileContent, passphrase }) axios .post(url, data, { httpsAgent }) .then(response => { if (response.status === 200) { resolve(response.data) } else { reject(`error code ${response.status}`) } }) .catch(error => { reject(error) }) }) } upload(url: string, filePath: string, params?: string): Promise<any> { return new Promise((resolve, reject) => { let formData = new FormData() formData.append('media', fs.createReadStream(filePath)) if (params) { formData.append('description', params) } formData.pipe( concat({ encoding: 'buffer' }, async data => { axios .post(url, data, { headers: { 'Content-Type': 'multipart/form-data' } }) .then(response => { if (response.status === 200) { resolve(response.data) } else { reject(`error code ${response.status}`) } }) .catch(error => { reject(error) }) }) ) }) } } [代码] 3、使其支持 Api-v3 接口规则 [代码]/** * 微信支付 Api-v3 get 请求 * @param urlPrefix * @param urlSuffix * @param mchId * @param serialNo * @param key * @param params */ public static async exeGet(urlPrefix: string, urlSuffix: string, mchId: string, serialNo: string, key: Buffer, params?: Map<string, string>): Promise<any> { if (params && params.size > 0) { urlSuffix = urlSuffix.concat('?').concat(this.createLinkString(params, '&', true, false)) } let authorization = await this.buildAuthorization(RequestMethod.GET, urlSuffix, mchId, serialNo, key, '') return await this.get(urlPrefix.concat(urlSuffix), authorization, serialNo) } /** * 微信支付 Api-v3 post 请求 * @param urlPrefix * @param urlSuffix * @param mchId * @param serialNo * @param key * @param data */ public static async exePost(urlPrefix: string, urlSuffix: string, mchId: string, serialNo: string, key: Buffer, data: string): Promise<any> { let authorization = await this.buildAuthorization(RequestMethod.POST, urlSuffix, mchId, serialNo, key, data) return await this.post(urlPrefix.concat(urlSuffix), data, authorization, serialNo) } /** * 微信支付 Api-v3 delete 请求 * @param urlPrefix * @param urlSuffix * @param mchId * @param serialNo * @param key */ public static async exeDelete(urlPrefix: string, urlSuffix: string, mchId: string, serialNo: string, key: Buffer): Promise<any> { let authorization = await this.buildAuthorization(RequestMethod.DELETE, urlSuffix, mchId, serialNo, key, '') return await this.delete(urlPrefix.concat(urlSuffix), authorization, serialNo) } /** * get 方法 * @param url 请求 url * @param authorization 授权信息 * @param serialNumber 证书序列号 */ public static async get(url: string, authorization: string, serialNumber?: string) { return await HttpKit.getHttpDelegate.httpGetToResponse(url, { headers: this.getHeaders(authorization, serialNumber) }) } /** * post 方法 * @param url 请求 url * @param authorization 授权信息 * @param serialNumber 证书序列号 */ public static async post(url: string, data: string, authorization: string, serialNumber?: string) { return await HttpKit.getHttpDelegate.httpPostToResponse(url, data, { headers: this.getHeaders(authorization, serialNumber) }) } /** * delete 方法 * @param url 请求 url * @param authorization 授权信息 * @param serialNumber 证书序列号 */ public static async delete(url: string, authorization: string, serialNumber?: string) { return await HttpKit.getHttpDelegate.httpDeleteToResponse(url, { headers: this.getHeaders(authorization, serialNumber) }) } /** * 获取请求头 * @param authorization 授权信息 * @param serialNumber 证书序列号 */ private static getHeaders(authorization: string, serialNumber: string): Object { let userAgent: string = 'WeChatPay-TNWX-HttpClient/%s (%s) nodejs/%s' userAgent = util.format( userAgent, '2.4.0', os .platform() .concat('/') .concat(os.release()), process.version ) return { Authorization: authorization, Accept: 'application/json', 'Content-type': 'application/json', 'Wechatpay-Serial': serialNumber, 'User-Agent': userAgent } } [代码] 如何使用? 这里以「获取平台证书」为例,来演示上面封装的系列方法如何使用 [代码]try { let result = await PayKit.exeGet( WX_DOMAIN.CHINA, // WX_API_TYPE.GET_CERTIFICATES, config.mchId, x509.parseCert(config.certPath).serial, fs.readFileSync(config.keyPath) ) console.log(`result.data:${result.data}`) // 应答报文主体 let data = JSON.stringify(result.data) // 应答状态码 console.log(`status:${result.status}`) console.log(`data:${data}`) // http 请求头 let headers = result.headers // 证书序列号 let serial = headers['wechatpay-serial'] // 应答时间戳 let timestamp = headers['wechatpay-timestamp'] // 应答随机串 let nonce = headers['wechatpay-nonce'] // 应答签名 let signature = headers['wechatpay-signature'] console.log(`serial:\n${serial}`) console.log(`timestamp:\n${timestamp}`) console.log(`nonce:\n${nonce}`) console.log(`signature:\n${signature}`) ctx.body = data } catch (error) { console.log(error) } [代码] 至此微信支付 Api-v3 规则的接口已经测试通过。 但还有其他细节如要我们继续完善,比如 验证签名、证书和回调报文解密 证书和回调报文解密 AEAD_AES_256_GCM 解密算法实现 [代码] /** * AEAD_AES_256_GCM 解密 * @param key apiKey3 * @param nonce 加密使用的随机串初始化向量 * @param associatedData 附加数据包 * @param ciphertext 密文 */ public static aes256gcmDecrypt(key: string, nonce: string, associatedData: string, ciphertext: string): string { let ciphertextBuffer = Buffer.from(ciphertext, 'base64') let authTag = ciphertextBuffer.slice(ciphertextBuffer.length - 16) let data = ciphertextBuffer.slice(0, ciphertextBuffer.length - 16) let decipherIv = crypto.createDecipheriv('aes-256-gcm', key, nonce) decipherIv.setAuthTag(Buffer.from(authTag)) decipherIv.setAAD(Buffer.from(associatedData)) let decryptStr = decipherIv.update(data, null, 'utf8') decipherIv.final() return decryptStr } [代码] 保存微信平台证书示例 [代码] // 证书和回调报文解密 let certPath = '/Users/Javen/cert/platform_cert.pem' try { let decrypt = PayKit.aes256gcmDecrypt( config.apiKey3, ctx.app.config.AEAD_AES_256_GCM.nonce, ctx.app.config.AEAD_AES_256_GCM.associated_data, ctx.app.config.AEAD_AES_256_GCM.ciphertext ) // 保存证书 fs.writeFileSync(certPath, decrypt) ctx.body = decrypt } catch (error) { console.log(error) } [代码] 验证签名 示例 [代码]// 根据序列号查证书 验证签名 let verifySignature: boolean = PayKit.verifySignature(signature, data, nonce, timestamp, fs.readFileSync(ctx.app.config.WxPayConfig.wxCertPath)) console.log(`verifySignature:${verifySignature}`) [代码] 构建应答签名参数 [代码]/** * 构建应答签名参数 * @param timestamp 应答时间戳 * @param nonceStr 应答随机串 * @param body 应答报文主体 */ public static buildRepSignMessage(timestamp: string, nonceStr: string, body: string): string { return timestamp .concat('\n') .concat(nonceStr) .concat('\n') .concat(body) .concat('\n') } [代码] 使用平台证书验证 [代码] /** * 验证签名 * @param signature 待验证的签名 * @param body 应答主体 * @param nonce 随机串 * @param timestamp 时间戳 * @param publicKey 平台公钥 */ public static verifySignature(signature: string, body: string, nonce: string, timestamp: string, publicKey: Buffer): boolean { // 构建响应体中待签名数据 let buildSignMessage: string = this.buildRepSignMessage(timestamp, nonce, body) return Kits.sha256WithRsaVerify(publicKey, signature, buildSignMessage) } [代码] [代码] /** * SHA256withRSA 验证签名 * @param publicKey 公钥key * @param signature 待验证的签名串 * @param data 需要验证的字符串 */ public static sha256WithRsaVerify(publicKey: Buffer, signature: string, data: string) { return crypto .createVerify('RSA-SHA256') .update(data) .verify(publicKey, signature, 'base64') } [代码] 完整示例代码 Egg-TNWX TNWX: TypeScript + Node.js + WeiXin 微信系开发脚手架,支持微信公众号、微信支付、微信小游戏、微信小程序、企业微信/企业号、企业微信开放平台。最最最重要的是能快速的集成至任何 Node.js 框架(Express、Nest、Egg、Koa 等) 微信支付已支持 Api-v3 以及 Api-v2 版本接口,同时支持多商户多应用,国内与境外的普通商户模式和服务商模式,v2 接口同时支持 MD5 以及 HMAC-SHA256 签名算法。 如有疑问欢迎留言或者站内私信。
2020-04-21 - 搞懂微信支付 v3 接口规则-【附Java源码】
简介 为了在保证支付安全的前提下,带给商户简单、一致且易用的开发体验,我们推出了全新的微信支付API v3。 其实还要一个主要因素是「为了符合监管的要求」。 主要是为了符合监管的要求,保证更高的安全级别。《中华人民共和国电子签名法》、《金融电子认证规范》及《非银行支付机构网络支付业务管理办法》中规定 “电子签名需要第三方认证的,由依法设立的电子认证服务提供者提供认证服务。”,所以需使用第三方 CA 来确保数字证书的唯一性、完整性及交易的不可抵赖性。 支付宝支付也是如此,从之前的「普通公钥方式」新增了 「公钥证书方式」。今天的主角是微信支付 Api v3 这里就不展开讲支付宝支付了。 微信支付 Api v3 接口规则 官方文档 v2 与 v3 的区别 V3 规则差异 V2 JSON 参数格式 XML POST、GET 或 DELETE 提交方式 POST AES-256-GCM加密 回调加密 无需加密 RSA 加密 敏感加密 无需加密 UTF-8 编码方式 UTF-8 非对称密钥SHA256-RSA 签名方式 MD5 或 HMAC-SHA256 微信支付 Api-v2 版本详细介绍请参数之前博客 微信支付,你想知道的一切都在这里 干货多,屁话少,下面直接进入主题,读完全文你将 Get 到以下知识点 如何获取证书序列号 非对称密钥 SHA256-RSA 加密与验证签名 AES-256-GCM 如何解密 API 密钥设置 请登录商户平台进入【账户中心】->【账户设置】->【API安全】->【APIv3密钥】中设置 API 密钥。 具体操作步骤请参见:什么是APIv3密钥?如何设置? 获取 API 证书 请登录商户平台进入【账户中心】->【账户设置】->【API安全】根据提示指引下载证书。 具体操作步骤请参见:什么是API证书?如何获取API证书? 按照以上步骤操作后你将获取如下内容: apiKey API 密钥 apiKey3 APIv3 密钥 mchId 商户号 apiclient_key.pem X.509 标准证书的密钥 apiclient_cert.p12 X.509 标准的证书+密钥 apiclient_cert.pem X.509 标准的证书 请求签名 如何生成签名参数?官方文档 描述得非常清楚这里就不啰嗦了。 示例代码 构造签名串 [代码] /** * 构造签名串 * * @param method {@link RequestMethod} GET,POST,PUT等 * @param url 请求接口 /v3/certificates * @param timestamp 获取发起请求时的系统当前时间戳 * @param nonceStr 随机字符串 * @param body 请求报文主体 * @return 待签名字符串 */ public static String buildSignMessage(RequestMethod method, String url, long timestamp, String nonceStr, String body) { return new StringBuilder() .append(method.toString()) .append("\n") .append(url) .append("\n") .append(timestamp) .append("\n") .append(nonceStr) .append("\n") .append(body) .append("\n") .toString(); } [代码] 构造 HTTP 头中的 Authorization [代码]/** * 构建 v3 接口所需的 Authorization * * @param method {@link RequestMethod} 请求方法 * @param urlSuffix 可通过 WxApiType 来获取,URL挂载参数需要自行拼接 * @param mchId 商户Id * @param serialNo 商户 API 证书序列号 * @param keyPath key.pem 证书路径 * @param body 接口请求参数 * @param nonceStr 随机字符库 * @param timestamp 时间戳 * @param authType 认证类型 * @return {@link String} 返回 v3 所需的 Authorization * @throws Exception 异常信息 */ public static String buildAuthorization(RequestMethod method, String urlSuffix, String mchId, String serialNo, String keyPath, String body, String nonceStr, long timestamp, String authType) throws Exception { // 构建签名参数 String buildSignMessage = PayKit.buildSignMessage(method, urlSuffix, timestamp, nonceStr, body); // 获取商户私钥 String key = PayKit.getPrivateKey(keyPath); // 生成签名 String signature = RsaKit.encryptByPrivateKey(buildSignMessage, key); // 根据平台规则生成请求头 authorization return PayKit.getAuthorization(mchId, serialNo, nonceStr, String.valueOf(timestamp), signature, authType); } /** * 获取授权认证信息 * * @param mchId 商户号 * @param serialNo 商户API证书序列号 * @param nonceStr 请求随机串 * @param timestamp 时间戳 * @param signature 签名值 * @param authType 认证类型,目前为WECHATPAY2-SHA256-RSA2048 * @return 请求头 Authorization */ public static String getAuthorization(String mchId, String serialNo, String nonceStr, String timestamp, String signature, String authType) { Map<String, String> params = new HashMap<>(5); params.put("mchid", mchId); params.put("serial_no", serialNo); params.put("nonce_str", nonceStr); params.put("timestamp", timestamp); params.put("signature", signature); return authType.concat(" ").concat(createLinkString(params, ",", false, true)); } [代码] 拼接参数 [代码] public static String createLinkString(Map<String, String> params, String connStr, boolean encode, boolean quotes) { List<String> keys = new ArrayList<String>(params.keySet()); Collections.sort(keys); StringBuilder content = new StringBuilder(); for (int i = 0; i < keys.size(); i++) { String key = keys.get(i); String value = params.get(key); // 拼接时,不包括最后一个&字符 if (i == keys.size() - 1) { if (quotes) { content.append(key).append("=").append('"').append(encode ? urlEncode(value) : value).append('"'); } else { content.append(key).append("=").append(encode ? urlEncode(value) : value); } } else { if (quotes) { content.append(key).append("=").append('"').append(encode ? urlEncode(value) : value).append('"').append(connStr); } else { content.append(key).append("=").append(encode ? urlEncode(value) : value).append(connStr); } } } return content.toString(); } [代码] 从上面示例来看我们还差两个参数 serial_no 证书序列号 signature 使用商户私钥对待签名串进行 SHA256 with RSA 签名 如何获取呢?不要着急,容我喝杯 「89年的咖啡」提提神。 获取证书序列号 通过工具获取 openssl x509 -in apiclient_cert.pem -noout -serial 使用证书解析工具 https://myssl.com/cert_decode.html 通过代码获取 [代码]// 获取证书序列号 X509Certificate certificate = PayKit.getCertificate(FileUtil.getInputStream("apiclient_cert.pem 证书路径")); System.out.println("输出证书信息:\n" + certificate.toString()); System.out.println("证书序列号:" + certificate.getSerialNumber().toString(16)); System.out.println("版本号:" + certificate.getVersion()); System.out.println("签发者:" + certificate.getIssuerDN()); System.out.println("有效起始日期:" + certificate.getNotBefore()); System.out.println("有效终止日期:" + certificate.getNotAfter()); System.out.println("主体名:" + certificate.getSubjectDN()); System.out.println("签名算法:" + certificate.getSigAlgName()); System.out.println("签名:" + certificate.getSignature().toString()); /** * 获取证书 * * @param inputStream 证书文件 * @return {@link X509Certificate} 获取证书 */ public static X509Certificate getCertificate(InputStream inputStream) { try { CertificateFactory cf = CertificateFactory.getInstance("X509"); X509Certificate cert = (X509Certificate) cf.generateCertificate(inputStream); cert.checkValidity(); return cert; } catch (CertificateExpiredException e) { throw new RuntimeException("证书已过期", e); } catch (CertificateNotYetValidException e) { throw new RuntimeException("证书尚未生效", e); } catch (CertificateException e) { throw new RuntimeException("无效的证书", e); } } [代码] SHA256 with RSA 签名 获取商户私钥 [代码] /** * 获取商户私钥 * * @param keyPath 商户私钥证书路径 * @return 商户私钥 * @throws Exception 解析 key 异常 */ public static String getPrivateKey(String keyPath) throws Exception { String originalKey = FileUtil.readUtf8String(keyPath); String privateKey = originalKey .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s+", ""); return RsaKit.getPrivateKeyStr(RsaKit.loadPrivateKey(privateKey)); } public static String getPrivateKeyStr(PrivateKey privateKey) { return Base64.encode(privateKey.getEncoded()); } /** * 从字符串中加载私钥 * * @param privateKeyStr 私钥 * @return {@link PrivateKey} * @throws Exception 异常信息 */ public static PrivateKey loadPrivateKey(String privateKeyStr) throws Exception { try { byte[] buffer = Base64.decode(privateKeyStr); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(buffer); KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); return keyFactory.generatePrivate(keySpec); } catch (NoSuchAlgorithmException e) { throw new Exception("无此算法"); } catch (InvalidKeySpecException e) { throw new Exception("私钥非法"); } catch (NullPointerException e) { throw new Exception("私钥数据为空"); } } [代码] 私钥签名 [代码]/** * 私钥签名 * * @param data 需要加密的数据 * @param privateKey 私钥 * @return 加密后的数据 * @throws Exception 异常信息 */ public static String encryptByPrivateKey(String data, String privateKey) throws Exception { PKCS8EncodedKeySpec priPkcs8 = new PKCS8EncodedKeySpec(Base64.decode(privateKey)); KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); PrivateKey priKey = keyFactory.generatePrivate(priPkcs8); java.security.Signature signature = java.security.Signature.getInstance("SHA256WithRSA"); signature.initSign(priKey); signature.update(data.getBytes(StandardCharsets.UTF_8)); byte[] signed = signature.sign(); return StrUtil.str(Base64.encode(signed)); } [代码] 至此微信支付 Api-v3 接口请求参数已封装完成。 执行请求 [代码]/** * V3 接口统一执行入口 * * @param method {@link RequestMethod} 请求方法 * @param urlPrefix 可通过 {@link WxDomain}来获取 * @param urlSuffix 可通过 {@link WxApiType} 来获取,URL挂载参数需要自行拼接 * @param mchId 商户Id * @param serialNo 商户 API 证书序列号 * @param keyPath apiclient_key.pem 证书路径 * @param body 接口请求参数 * @param nonceStr 随机字符库 * @param timestamp 时间戳 * @param authType 认证类型 * @param file 文件 * @return {@link String} 请求返回的结果 * @throws Exception 接口执行异常 */ public static Map<String, Object> v3Execution(RequestMethod method, String urlPrefix, String urlSuffix, String mchId, String serialNo, String keyPath, String body, String nonceStr, long timestamp, String authType, File file) throws Exception { // 构建 Authorization String authorization = WxPayKit.buildAuthorization(method, urlSuffix, mchId, serialNo, keyPath, body, nonceStr, timestamp, authType); if (method == RequestMethod.GET) { return doGet(urlPrefix.concat(urlSuffix), authorization, serialNo, null); } else if (method == RequestMethod.POST) { return doPost(urlPrefix.concat(urlSuffix), authorization, serialNo, body); } else if (method == RequestMethod.DELETE) { return doDelete(urlPrefix.concat(urlSuffix), authorization, serialNo, body); } else if (method == RequestMethod.UPLOAD) { return doUpload(urlPrefix.concat(urlSuffix), authorization, serialNo, body, file); } return null; } [代码] 网络请求库默认是使用的 Hutool 封装的一套 Java 工具集合来实现 GET 请求 [代码]/** * @param url 请求url * @param authorization 授权信息 * @param serialNumber 公钥证书序列号 * @param jsonData 请求参数 * @return {@link HttpResponse} 请求返回的结果 */ private HttpResponse doGet(String url, String authorization, String serialNumber, String jsonData) { return HttpRequest.post(url) .addHeaders(getHeaders(authorization, serialNumber)) .body(jsonData) .execute(); } [代码] POST 请求 [代码] /** * @param url 请求url * @param authorization 授权信息 * @param serialNumber 公钥证书序列号 * @param jsonData 请求参数 * @return {@link HttpResponse} 请求返回的结果 */ private HttpResponse doPost(String url, String authorization, String serialNumber, String jsonData) { return HttpRequest.post(url) .addHeaders(getHeaders(authorization, serialNumber)) .body(jsonData) .execute(); } [代码] DELETE 请求 [代码]/** * delete 请求 * * @param url 请求url * @param authorization 授权信息 * @param serialNumber 公钥证书序列号 * @param jsonData 请求参数 * @return {@link HttpResponse} 请求返回的结果 */ private HttpResponse doDelete(String url, String authorization, String serialNumber, String jsonData) { return HttpRequest.delete(url) .addHeaders(getHeaders(authorization, serialNumber)) .body(jsonData) .execute(); } [代码] 上传文件 [代码] /** * @param url 请求url * @param authorization 授权信息 * @param serialNumber 公钥证书序列号 * @param jsonData 请求参数 * @param file 上传的文件 * @return {@link HttpResponse} 请求返回的结果 */ private HttpResponse doUpload(String url, String authorization, String serialNumber, String jsonData, File file) { return HttpRequest.post(url) .addHeaders(getUploadHeaders(authorization, serialNumber)) .form("file", file) .form("meta", jsonData) .execute(); } [代码] 构建 Http 请求头 [代码]private Map<String, String> getBaseHeaders(String authorization) { String userAgent = String.format( "WeChatPay-IJPay-HttpClient/%s (%s) Java/%s", getClass().getPackage().getImplementationVersion(), OS, VERSION == null ? "Unknown" : VERSION); Map<String, String> headers = new HashMap<>(3); headers.put("Accept", ContentType.JSON.toString()); headers.put("Authorization", authorization); headers.put("User-Agent", userAgent); return headers; } private Map<String, String> getHeaders(String authorization, String serialNumber) { Map<String, String> headers = getBaseHeaders(authorization); headers.put("Content-Type", ContentType.JSON.toString()); if (StrUtil.isNotEmpty(serialNumber)) { headers.put("Wechatpay-Serial", serialNumber); } return headers; } private Map<String, String> getUploadHeaders(String authorization, String serialNumber) { Map<String, String> headers = getBaseHeaders(authorization); headers.put("Content-Type", "multipart/form-data;boundary=\"boundary\""); if (StrUtil.isNotEmpty(serialNumber)) { headers.put("Wechatpay-Serial", serialNumber); } return headers; } [代码] 构建 Http 请求返回值 从响应的 HttpResponse 中获取微信响应头信息、状态码以及 body [代码]/** * 构建返回参数 * * @param httpResponse {@link HttpResponse} * @return {@link Map} */ private Map<String, Object> buildResMap(HttpResponse httpResponse) { Map<String, Object> map = new HashMap<>(); String timestamp = httpResponse.header("Wechatpay-Timestamp"); String nonceStr = httpResponse.header("Wechatpay-Nonce"); String serialNo = httpResponse.header("Wechatpay-Serial"); String signature = httpResponse.header("Wechatpay-Signature"); String body = httpResponse.body(); int status = httpResponse.getStatus(); map.put("timestamp", timestamp); map.put("nonceStr", nonceStr); map.put("serialNumber", serialNo); map.put("signature", signature); map.put("body", body); map.put("status", status); return map; } [代码] 至此已完成构建请求参数,执行请求。接下来我们就要实现响应数据的解密以及响应结果的验证签名 对应的官方文档 证书和回调报文解密 签名验证 验证签名 构建签名参数 [代码]/** * 构造签名串 * * @param timestamp 应答时间戳 * @param nonceStr 应答随机串 * @param body 应答报文主体 * @return 应答待签名字符串 */ public static String buildSignMessage(String timestamp, String nonceStr, String body) { return new StringBuilder() .append(timestamp) .append("\n") .append(nonceStr) .append("\n") .append(body) .append("\n") .toString(); } [代码] 证书和回调报文解密 官方文档文末有完整的源码这里就不贴了。贴一个示例大家参数一下 [代码]try { String associatedData = "certificate"; String nonce = "80d28946a64a"; String cipherText = "DwAqW4+4TeUaOEylfKEXhw+XqGh/YTRhUmLw/tBfQ5nM9DZ9d+9aGEghycwV1jwo52vXb/t6ueBvBRHRIW5JgDRcXmTHw9IMTrIK6HxTt2qiaGTWJU9whsF+GGeQdA7gBCHZm3AJUwrzerAGW1mclXBTvXqaCl6haE7AOHJ2g4RtQThi3nxOI63/yc3WaiAlSR22GuCpy6wJBfljBq5Bx2xXDZXlF2TNbDIeodiEnJEG2m9eBWKuvKPyUPyClRXG1fdOkKnCZZ6u+ipb4IJx28n3MmhEtuc2heqqlFUbeONaRpXv6KOZmH/IdEL6nqNDP2D7cXutNVCi0TtSfC7ojnO/+PKRu3MGO2Z9q3zyZXmkWHCSms/C3ACatPUKHIK+92MxjSQDc1E/8faghTc9bDgn8cqWpVKcL3GHK+RfuYKiMcdSkUDJyMJOwEXMYNUdseQMJ3gL4pfxuQu6QrVvJ17q3ZjzkexkPNU4PNSlIBJg+KX61cyBTBumaHy/EbHiP9V2GeM729a0h5UYYJVedSo1guIGjMZ4tA3WgwQrlpp3VAMKEBLRJMcnHd4pH5YQ/4hiUlHGEHttWtnxKFwnJ6jHr3OmFLV1FiUUOZEDAqR0U1KhtGjOffnmB9tymWF8FwRNiH2Tee/cCDBaHhNtfPI5129SrlSR7bZc+h7uzz9z+1OOkNrWHzAoWEe3XVGKAywpn5HGbcL+9nsEVZRJLvV7aOxAZBkxhg8H5Fjt1ioTJL+qXgRzse1BX1iiwfCR0fzEWT9ldDTDW0Y1b3tb419MhdmTQB5FsMXYOzqp5h+Tz1FwEGsa6TJsmdjJQSNz+7qPSg5D6C2gc9/6PkysSu/6XfsWXD7cQkuZ+TJ/Xb6Q1Uu7ZB90SauA8uPQUIchW5zQ6UfK5dwMkOuEcE/141/Aw2rlDqjtsE17u1dQ6TCax/ZQTDQ2MDUaBPEaDIMPcgL7fCeijoRgovkBY92m86leZvQ+HVbxlFx5CoPhz4a81kt9XJuEYOztSIKlm7QNfW0BvSUhLmxDNCjcxqwyydtKbLzA+EBb2gG4ORiH8IOTbV0+G4S6BqetU7RrO+/nKt21nXVqXUmdkhkBakLN8FUcHygyWnVxbA7OI2RGnJJUnxqHd3kTbzD5Wxco4JIQsTOV6KtO5c960oVYUARZIP1SdQhqwELm27AktEN7kzg/ew/blnTys/eauGyw78XCROb9F1wbZBToUZ7L+8/m/2tyyyqNid+sC9fYqJoIOGfFOe6COWzTI/XPytCHwgHeUxmgk7NYfU0ukR223RPUOym6kLzSMMBKCivnNg68tbLRJHEOpQTXFBaFFHt2qpceJpJgw5sKFqx3eQnIFuyvA1i8s2zKLhULZio9hpsDJQREOcNeHVjEZazdCGnbe3Vjg7uqOoVHdE/YbNzJNQEsB3/erYJB+eGzyFwFmdAHenG5RE6FhCutjszwRiSvW9F7wvRK36gm7NnVJZkvlbGwh0UHr0pbcrOmxT81xtNSvMzT0VZNLTUX2ur3AGLwi2ej8BIC0H41nw4ToxTnwtFR1Xy55+pUiwpB7JzraA08dCXdFdtZ72Tw/dNBy5h1P7EtQYiKzXp6rndfOEWgNOsan7e1XRpCnX7xoAkdPvy40OuQ5gNbDKry5gVDEZhmEk/WRuGGaX06CG9m7NfErUsnQYrDJVjXWKYuARd9R7W0aa5nUXqz/Pjul/LAatJgWhZgFBGXhNr9iAoade/0FPpBj0QWa8SWqKYKiOqXqhfhppUq35FIa0a1Vvxcn3E38XYpVZVTDEXcEcD0RLCu/ezdOa6vRcB7hjgXFIRZQAka0aXnQxwOZwE2Rt3yWXqc+Q1ah2oOrg8Lg3ETc644X9QP4FxOtDwz/A=="; AesUtil aesUtil = new AesUtil(wxPayV3Bean.getApiKey3().getBytes(StandardCharsets.UTF_8)); // 平台证书密文解密 // encrypt_certificate 中的 associated_data nonce ciphertext String publicKey = aesUtil.decryptToString( associatedData.getBytes(StandardCharsets.UTF_8), nonce.getBytes(StandardCharsets.UTF_8), cipherText ); // 保存证书 FileWriter writer = new FileWriter(wxPayV3Bean.getPlatformCertPath()); writer.write(publicKey); // 获取平台证书序列号 X509Certificate certificate = PayKit.getCertificate(new ByteArrayInputStream(publicKey.getBytes())); return certificate.getSerialNumber().toString(16).toUpperCase(); } catch (Exception e) { e.printStackTrace(); } [代码] 验证签名 [代码]/** * 验证签名 * * @param signature 待验证的签名 * @param body 应答主体 * @param nonce 随机串 * @param timestamp 时间戳 * @param certInputStream 微信支付平台证书输入流 * @return 签名结果 * @throws Exception 异常信息 */ public static boolean verifySignature(String signature, String body, String nonce, String timestamp, InputStream certInputStream) throws Exception { String buildSignMessage = PayKit.buildSignMessage(timestamp, nonce, body); // 获取证书 X509Certificate certificate = PayKit.getCertificate(certInputStream); PublicKey publicKey = certificate.getPublicKey(); return RsaKit.checkByPublicKey(buildSignMessage, signature, publicKey); } /** * 公钥验证签名 * * @param data 需要加密的数据 * @param sign 签名 * @param publicKey 公钥 * @return 验证结果 * @throws Exception 异常信息 */ public static boolean checkByPublicKey(String data, String sign, PublicKey publicKey) throws Exception { java.security.Signature signature = java.security.Signature.getInstance("SHA256WithRSA"); signature.initVerify(publicKey); signature.update(data.getBytes(StandardCharsets.UTF_8)); return signature.verify(Base64.decode(sign.getBytes(StandardCharsets.UTF_8))); } [代码] 至此微信支付 Api-v3 接口已介绍完,如有疑问欢迎留言一起探讨。 完整示例 SpringBoot 参考资料 你真的了解 HTTPS 吗? WechatPay-API-v3
2021-03-02