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