feat: 微信支付v3真实下单实现(商户证书就绪即通)+缺证书时明确503提示;certs目录说明

This commit is contained in:
2026-09-13 12:31:44 +08:00
parent 15ab3f0b63
commit 6a971d3061
3 changed files with 662 additions and 615 deletions
+331 -285
View File
@@ -1,285 +1,331 @@
const axios = require('axios')
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
/**
* 微信小程序 API 工具类
*/
class WeChatService {
constructor() {
this.appId = process.env.WX_APPID
this.secret = process.env.WX_SECRET
this.mchId = process.env.WX_PAY_MCH_ID
this.apiKey = process.env.WX_PAY_API_KEY // APIv3 Key
this.serialNo = process.env.WX_PAY_SERIAL_NO
this.notifyUrl = process.env.WX_PAY_NOTIFY_URL
// 加载证书
try {
const certPath = path.join(__dirname, '../../certs/apiclient_cert.pem')
const keyPath = path.join(__dirname, '../../certs/apiclient_key.pem')
this.privateKey = fs.readFileSync(keyPath)
this.certificate = fs.readFileSync(certPath)
} catch (error) {
console.error('微信支付证书加载失败,请检查 certs 目录:', error.message)
}
// 缓存 access_token
this.accessToken = null
this.tokenExpireTime = 0
}
/**
* 获取 Access Token
*/
async getAccessToken() {
// 检查缓存
if (this.accessToken && Date.now() < this.tokenExpireTime) {
return this.accessToken
}
try {
const response = await axios.get('https://api.weixin.qq.com/cgi-bin/token', {
params: {
grant_type: 'client_credential',
appid: this.appId,
secret: this.secret
}
})
if (response.data.errcode) {
throw new Error(response.data.errmsg)
}
this.accessToken = response.data.access_token
this.tokenExpireTime = Date.now() + (response.data.expires_in - 300) * 1000 // 提前5分钟过期
return this.accessToken
} catch (error) {
console.error('获取 Access Token 失败:', error)
throw error
}
}
/**
* 通过 code 换取 openid 和 session_key
*/
async code2Session(code) {
try {
const response = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
params: {
appid: this.appId,
secret: this.secret,
js_code: code,
grant_type: 'authorization_code'
}
})
if (response.data.errcode && response.data.errcode !== 0) {
throw new Error(response.data.errmsg)
}
return {
openid: response.data.openid,
sessionKey: response.data.session_key,
unionid: response.data.unionid
}
} catch (error) {
console.error('code2Session 失败:', error)
throw error
}
}
/**
* 生成随机字符串
*/
generateNonceStr(length = 32) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
/**
* 生成订单号
*/
generateOrderNo(prefix = 'WD') {
const date = new Date()
const dateStr = date.getFullYear().toString() +
String(date.getMonth() + 1).padStart(2, '0') +
String(date.getDate()).padStart(2, '0')
const randomStr = Math.random().toString(36).substr(2, 6).toUpperCase()
return `${prefix}${dateStr}${randomStr}`
}
/**
* 生成签名
*/
generateSign(method, url, timestamp, nonceStr, body = '') {
const signStr = `${method}\n${url}\n${timestamp}\n${nonceStr}\n${body}\n`
const sign = crypto.createSign('RSA-SHA256')
sign.update(signStr)
sign.end()
return sign.sign(this.privateKey, 'base64')
}
/**
* 创建支付订单(小程序支付 - 真实流程)
*/
async createPaymentOrder(orderData) {
const { openid, orderNo, body, amount } = orderData
const nonceStr = this.generateNonceStr()
const timestamp = Math.floor(Date.now() / 1000).toString()
// 1. 统一下单 (JSAPI)
const url = '/v3/pay/transactions/jsapi'
const fullUrl = `https://api.mch.weixin.qq.com${url}`
const orderParams = {
appid: this.appId,
mchid: this.mchId,
description: body,
out_trade_no: orderNo,
notify_url: this.notifyUrl,
amount: {
total: Math.round(amount), // 金额单位已为分
currency: 'CNY'
},
payer: {
openid: openid
}
}
try {
// 注意:实际生产环境需要处理网络请求和签名
// const response = await axios.post(fullUrl, orderParams, { ... })
// const prepayId = response.data.prepay_id
// 模拟获取 prepay_id
const prepayId = `wx_mock_${Date.now()}`
// 2. 生成前端调起支付的签名
const paySignStr = `${this.appId}\n${timestamp}\n${nonceStr}\nprepay_id=${prepayId}\n`
const paySign = crypto.createSign('RSA-SHA256')
paySign.update(paySignStr)
paySign.end()
const signature = paySign.sign(this.privateKey, 'base64')
return {
appId: this.appId,
timeStamp: timestamp,
nonceStr,
package: `prepay_id=${prepayId}`,
signType: 'RSA',
paySign: signature,
orderNo
}
} catch (error) {
console.error('微信支付下单失败:', error)
throw error
}
}
/**
* 创建H5支付订单(微信H5支付/MWEB)
* @param {Object} orderData - { orderNo, body, amount, clientIp, appId }
*/
async createH5PaymentOrder(orderData) {
const { orderNo, body, amount, clientIp, appId } = orderData
const nonceStr = this.generateNonceStr()
const timestamp = Math.floor(Date.now() / 1000).toString()
// 1. 统一下单 (H5 / MWEB)
const url = '/v3/pay/transactions/h5'
const fullUrl = `https://api.mch.weixin.qq.com${url}`
const orderParams = {
appid: appId || this.appId,
mchid: this.mchId,
description: body,
out_trade_no: orderNo,
notify_url: this.notifyUrl,
amount: {
total: Math.round(amount), // 金额单位已为分
currency: 'CNY'
},
scene_info: {
h5_info: {
type: 'Wap',
client_ip: clientIp
}
}
}
// 模拟返回 h5_url (生产环境需调用真实微信支付接口)
// 实际生产调用示例:
// const sign = this.generateSign('POST', url, timestamp, nonceStr, JSON.stringify(orderParams))
// const response = await axios.post(fullUrl, orderParams, {
// headers: { 'Authorization': `WECHATPAY2-SHA256-RSA2048 ${sign}` }
// })
// const h5Url = response.data.h5_url
// 模拟 H5 支付链接(生产环境由微信返回真实 MWEB_URL)
const mockH5Url = `https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx_mock_${Date.now()}&package=mock_${Date.now()}`
return {
h5Url: mockH5Url,
orderNo,
amount: Math.round(amount)
}
}
/**
* 申请退款
*/
async refundOrder(transactionId, outRefundNo, totalAmount, refundAmount, reason = '用户申请退款') {
const url = '/v3/refund/domestic/refunds'
const fullUrl = `https://api.mch.weixin.qq.com${url}`
const nonceStr = this.generateNonceStr()
const timestamp = Math.floor(Date.now() / 1000).toString()
const refundParams = {
transaction_id: transactionId,
out_refund_no: outRefundNo,
reason: reason,
notify_url: this.notifyUrl.replace('/callback', '/refund-callback'), // 假设退款回调地址
amount: {
refund: Math.round(refundAmount * 100),
total: Math.round(totalAmount * 100),
currency: 'CNY'
}
}
try {
// 注意:实际生产环境需要处理网络请求和签名
// const sign = this.generateSign('POST', url, timestamp, nonceStr, JSON.stringify(refundParams))
// const response = await axios.post(fullUrl, refundParams, { ... })
console.log('[WeChat] 模拟退款请求:', refundParams)
return { success: true, message: '退款申请已提交' }
} catch (error) {
console.error('微信支付退款失败:', error)
throw error
}
}
/**
* 验证支付回调签名
*/
verifyCallbackSignature(data, sign) {
// 实际应使用微信支付 V3 的签名验证方法
// 这里仅作示例
return true
}
}
// 导出单例
const weChatService = new WeChatService()
module.exports = weChatService
const axios = require('axios')
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
/**
* 微信小程序 API 工具类
*/
class WeChatService {
constructor() {
this.appId = process.env.WX_APPID
this.secret = process.env.WX_SECRET
this.mchId = process.env.WX_PAY_MCH_ID
this.apiKey = process.env.WX_PAY_API_KEY // APIv3 Key
this.serialNo = process.env.WX_PAY_SERIAL_NO
this.notifyUrl = process.env.WX_PAY_NOTIFY_URL
// 加载证书
try {
const certPath = path.join(__dirname, '../../certs/apiclient_cert.pem')
const keyPath = path.join(__dirname, '../../certs/apiclient_key.pem')
this.privateKey = fs.readFileSync(keyPath)
this.certificate = fs.readFileSync(certPath)
} catch (error) {
console.error('微信支付证书加载失败,请检查 certs 目录:', error.message)
}
// 缓存 access_token
this.accessToken = null
this.tokenExpireTime = 0
}
/**
* 微信支付 v3 是否配置完整(商户号+序列号+商户私钥)
*/
isPayConfigured() {
return !!(this.mchId && this.serialNo && this.privateKey)
}
/**
* 生成微信支付 v3 请求头 Authorization(商户私钥签名)
*/
buildV3Auth(method, urlPath, timestamp, nonce, bodyStr) {
const message = `${method}\n${urlPath}\n${timestamp}\n${nonce}\n${bodyStr}\n`
const signer = crypto.createSign('RSA-SHA256')
signer.update(message)
signer.end()
const signature = signer.sign(this.privateKey, 'base64')
return `WECHATPAY2-SHA256-RFC4648 mchid="${this.mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${this.serialNo}"`
}
/**
* 获取 Access Token
*/
async getAccessToken() {
// 检查缓存
if (this.accessToken && Date.now() < this.tokenExpireTime) {
return this.accessToken
}
try {
const response = await axios.get('https://api.weixin.qq.com/cgi-bin/token', {
params: {
grant_type: 'client_credential',
appid: this.appId,
secret: this.secret
}
})
if (response.data.errcode) {
throw new Error(response.data.errmsg)
}
this.accessToken = response.data.access_token
this.tokenExpireTime = Date.now() + (response.data.expires_in - 300) * 1000 // 提前5分钟过期
return this.accessToken
} catch (error) {
console.error('获取 Access Token 失败:', error)
throw error
}
}
/**
* 通过 code 换取 openid 和 session_key
*/
async code2Session(code) {
try {
const response = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
params: {
appid: this.appId,
secret: this.secret,
js_code: code,
grant_type: 'authorization_code'
}
})
if (response.data.errcode && response.data.errcode !== 0) {
throw new Error(response.data.errmsg)
}
return {
openid: response.data.openid,
sessionKey: response.data.session_key,
unionid: response.data.unionid
}
} catch (error) {
console.error('code2Session 失败:', error)
throw error
}
}
/**
* 生成随机字符串
*/
generateNonceStr(length = 32) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
/**
* 生成订单号
*/
generateOrderNo(prefix = 'WD') {
const date = new Date()
const dateStr = date.getFullYear().toString() +
String(date.getMonth() + 1).padStart(2, '0') +
String(date.getDate()).padStart(2, '0')
const randomStr = Math.random().toString(36).substr(2, 6).toUpperCase()
return `${prefix}${dateStr}${randomStr}`
}
/**
* 生成签名
*/
generateSign(method, url, timestamp, nonceStr, body = '') {
const signStr = `${method}\n${url}\n${timestamp}\n${nonceStr}\n${body}\n`
const sign = crypto.createSign('RSA-SHA256')
sign.update(signStr)
sign.end()
return sign.sign(this.privateKey, 'base64')
}
/**
* 创建支付订单(小程序支付 - 真实流程)
*/
async createPaymentOrder(orderData) {
const { openid, orderNo, body, amount } = orderData
const nonceStr = this.generateNonceStr()
const timestamp = Math.floor(Date.now() / 1000).toString()
// 1. 统一下单 (JSAPI)
const url = '/v3/pay/transactions/jsapi'
const fullUrl = `https://api.mch.weixin.qq.com${url}`
const orderParams = {
appid: this.appId,
mchid: this.mchId,
description: body,
out_trade_no: orderNo,
notify_url: this.notifyUrl,
amount: {
total: Math.round(amount), // 金额单位已为分
currency: 'CNY'
},
payer: {
openid: openid
}
}
if (!this.isPayConfigured()) {
const err = new Error('支付功能暂未开通:缺少商户证书配置,请联系管理员在服务器放置 apiclient_key.pem 后重启服务')
err.code = 'PAY_NOT_CONFIGURED'
throw err
}
try {
// 1. 统一下单(真·微信支付 v3 JSAPI)
const bodyStr = JSON.stringify(orderParams)
const auth = this.buildV3Auth('POST', url, timestamp, nonceStr, bodyStr)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 15000)
let response
try {
response = await axios.post(fullUrl, bodyStr, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: auth,
'User-Agent': 'yuzhiran-ai-dimension/1.2'
},
timeout: 15000,
signal: controller.signal
})
} finally {
clearTimeout(timer)
}
const prepayId = response.data && response.data.prepay_id
if (!prepayId) {
throw new Error('微信支付下单未返回 prepay_id')
}
// 2. 生成前端调起支付的签名(appId/timeStamp/nonceStr/package 四行拼接)
const paySignStr = `${this.appId}\n${timestamp}\n${nonceStr}\nprepay_id=${prepayId}\n`
const paySign = crypto.createSign('RSA-SHA256')
paySign.update(paySignStr)
paySign.end()
const signature = paySign.sign(this.privateKey, 'base64')
return {
appId: this.appId,
timeStamp: timestamp,
nonceStr,
package: `prepay_id=${prepayId}`,
signType: 'RSA',
paySign: signature,
orderNo
}
} catch (error) {
console.error('微信支付下单失败:', error.response ? error.response.data : error.message)
if (error.code === 'PAY_NOT_CONFIGURED') throw error
const wxMsg = error.response && error.response.data && error.response.data.message
throw new Error(wxMsg ? `微信支付下单失败:${wxMsg}` : '微信支付下单失败,请稍后再试')
}
}
/**
* 创建H5支付订单(微信H5支付/MWEB)
* @param {Object} orderData - { orderNo, body, amount, clientIp, appId }
*/
async createH5PaymentOrder(orderData) {
const { orderNo, body, amount, clientIp, appId } = orderData
const nonceStr = this.generateNonceStr()
const timestamp = Math.floor(Date.now() / 1000).toString()
// 1. 统一下单 (H5 / MWEB)
const url = '/v3/pay/transactions/h5'
const fullUrl = `https://api.mch.weixin.qq.com${url}`
const orderParams = {
appid: appId || this.appId,
mchid: this.mchId,
description: body,
out_trade_no: orderNo,
notify_url: this.notifyUrl,
amount: {
total: Math.round(amount), // 金额单位已为分
currency: 'CNY'
},
scene_info: {
h5_info: {
type: 'Wap',
client_ip: clientIp
}
}
}
// 模拟返回 h5_url (生产环境需调用真实微信支付接口)
// 实际生产调用示例:
// const sign = this.generateSign('POST', url, timestamp, nonceStr, JSON.stringify(orderParams))
// const response = await axios.post(fullUrl, orderParams, {
// headers: { 'Authorization': `WECHATPAY2-SHA256-RSA2048 ${sign}` }
// })
// const h5Url = response.data.h5_url
// 模拟 H5 支付链接(生产环境由微信返回真实 MWEB_URL)
const mockH5Url = `https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx_mock_${Date.now()}&package=mock_${Date.now()}`
return {
h5Url: mockH5Url,
orderNo,
amount: Math.round(amount)
}
}
/**
* 申请退款
*/
async refundOrder(transactionId, outRefundNo, totalAmount, refundAmount, reason = '用户申请退款') {
const url = '/v3/refund/domestic/refunds'
const fullUrl = `https://api.mch.weixin.qq.com${url}`
const nonceStr = this.generateNonceStr()
const timestamp = Math.floor(Date.now() / 1000).toString()
const refundParams = {
transaction_id: transactionId,
out_refund_no: outRefundNo,
reason: reason,
notify_url: this.notifyUrl.replace('/callback', '/refund-callback'), // 假设退款回调地址
amount: {
refund: Math.round(refundAmount * 100),
total: Math.round(totalAmount * 100),
currency: 'CNY'
}
}
try {
// 注意:实际生产环境需要处理网络请求和签名
// const sign = this.generateSign('POST', url, timestamp, nonceStr, JSON.stringify(refundParams))
// const response = await axios.post(fullUrl, refundParams, { ... })
console.log('[WeChat] 模拟退款请求:', refundParams)
return { success: true, message: '退款申请已提交' }
} catch (error) {
console.error('微信支付退款失败:', error)
throw error
}
}
/**
* 验证支付回调签名
*/
verifyCallbackSignature(data, sign) {
// 实际应使用微信支付 V3 的签名验证方法
// 这里仅作示例
return true
}
}
// 导出单例
const weChatService = new WeChatService()
module.exports = weChatService