Files
services/controllers/payment.js
T
2026-06-03 14:15:55 +08:00

369 lines
10 KiB
JavaScript

const crypto = require('crypto')
const fetch = require('node-fetch')
const { query } = require('../config/database')
// 微信支付配置
const APPID = process.env.WECHAT_APPID
const MCH_ID = process.env.WECHAT_MCH_ID
const API_KEY = process.env.WECHAT_API_KEY
const NOTIFY_URL = process.env.WECHAT_NOTIFY_URL
const CERT_PATH = process.env.WECHAT_CERT_PATH
const KEY_PATH = process.env.WECHAT_KEY_PATH
// 微信支付API地址
const UNIFIED_ORDER_URL = 'https://api.mch.weixin.qq.com/pay/unifiedorder'
const REFUND_URL = 'https://api.mch.weixin.qq.com/secapi/pay/refund'
const ORDER_QUERY_URL = 'https://api.mch.weixin.qq.com/pay/orderquery'
/**
* 生成随机字符串
* @param {number} [length=32] - 字符串长度
* @returns {string} 随机字符串
*/
function generateNonceStr(length = 32) {
return crypto.randomBytes(length).toString('hex').slice(0, length)
}
/**
* 生成微信支付签名
* @param {Object} params - 参与签名的参数对象
* @returns {string} 签名字符串
*/
function generateSign(params) {
// 按字典序排序参数
const sortedKeys = Object.keys(params).sort()
// 拼接键值对(跳过空值和sign字段)
const stringA = sortedKeys
.filter(key => params[key] !== '' && params[key] !== undefined && params[key] !== null && key !== 'sign')
.map(key => `${key}=${params[key]}`)
.join('&')
// 拼接API密钥
const stringSignTemp = `${stringA}&key=${API_KEY}`
// MD5签名并转大写
return crypto.createHash('md5').update(stringSignTemp, 'utf8').digest('hex').toUpperCase()
}
/**
* 对象转XML
* @param {Object} obj - 需要转换的对象
* @returns {string} XML字符串
*/
function buildXML(obj) {
let xml = '<xml>'
for (const key of Object.keys(obj)) {
const val = obj[key]
if (typeof val === 'number') {
xml += `<${key}>${val}</${key}>`
} else {
xml += `<${key}><![CDATA[${val}]]></${key}>`
}
}
xml += '</xml>'
return xml
}
/**
* XML转对象
* @param {string} xml - XML字符串
* @returns {Promise<Object>} 解析后的对象
*/
function parseXML(xml) {
return new Promise((resolve, reject) => {
const obj = {}
const regex = /<(\w+)>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/\1>/g
let match
while ((match = regex.exec(xml)) !== null) {
if (match[1] !== 'xml') {
obj[match[1]] = match[2]
}
}
resolve(obj)
})
}
/**
* 创建支付订单
* 调用微信支付统一下单API(JSAPI),返回支付参数
*/
async function createPayment(ctx) {
const { orderId } = ctx.request.body
if (!orderId) {
ctx.body = { code: 400, message: '订单ID不能为空' }
return
}
// 查询订单信息
const orders = await query('SELECT * FROM orders WHERE id = ?', [orderId])
if (orders.length === 0) {
ctx.body = { code: 404, message: '订单不存在' }
return
}
const order = orders[0]
// 检查订单状态
if (order.status === 'paid') {
ctx.body = { code: 400, message: '订单已支付' }
return
}
// 检查支付配置
if (!APPID || !MCH_ID || !API_KEY || !NOTIFY_URL) {
ctx.body = { code: 500, message: '微信支付配置不完整' }
return
}
// 获取用户openid
const user = ctx.state.user
if (!user || !user.openid) {
ctx.body = { code: 400, message: '无法获取用户openid,请先绑定微信' }
return
}
// 构造统一下单参数
const nonceStr = generateNonceStr()
const outTradeNo = orderId
const totalFee = Math.round(parseFloat(order.total_price) * 100) // 金额转换为分
const body = `订单-${orderId}`
const params = {
appid: APPID,
mch_id: MCH_ID,
nonce_str: nonceStr,
body: body,
out_trade_no: outTradeNo,
total_fee: totalFee,
spbill_create_ip: ctx.ip || '127.0.0.1',
notify_url: NOTIFY_URL,
trade_type: 'JSAPI',
openid: user.openid
}
// 生成签名
params.sign = generateSign(params)
try {
// 调用微信统一下单API
const xmlData = buildXML(params)
const response = await fetch(UNIFIED_ORDER_URL, {
method: 'POST',
body: xmlData,
headers: { 'Content-Type': 'application/xml' }
})
const resultXml = await response.text()
const result = await parseXML(resultXml)
if (result.return_code !== 'SUCCESS') {
console.error('微信统一下单通信失败:', result.return_msg)
ctx.body = { code: 500, message: `微信支付通信失败: ${result.return_msg}` }
return
}
if (result.result_code !== 'SUCCESS') {
console.error('微信统一下单业务失败:', result.err_code_des)
ctx.body = { code: 500, message: `微信支付下单失败: ${result.err_code_des}` }
return
}
// 生成小程序支付参数
const prepayId = result.prepay_id
const payParams = {
appId: APPID,
timeStamp: Math.floor(Date.now() / 1000).toString(),
nonceStr: generateNonceStr(),
package: `prepay_id=${prepayId}`,
signType: 'MD5'
}
payParams.paySign = generateSign(payParams)
ctx.body = {
code: 200,
data: payParams
}
} catch (error) {
console.error('创建支付失败:', error)
ctx.body = { code: 500, message: '创建支付失败' }
}
}
/**
* 微信支付回调通知
* 验证签名,更新订单状态为paid
*/
async function paymentNotify(ctx) {
try {
const xml = ctx.request.body
const result = await parseXML(xml)
// 验证签名
const sign = result.sign
const calculatedSign = generateSign(result)
if (sign !== calculatedSign) {
console.error('微信支付回调签名验证失败')
ctx.body = '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[签名验证失败]]></return_msg></xml>'
return
}
// 检查支付结果
if (result.result_code !== 'SUCCESS') {
console.error('微信支付回调支付失败:', result.err_code_des)
ctx.body = '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[支付失败]]></return_msg></xml>'
return
}
const outTradeNo = result.out_trade_no
const transactionId = result.transaction_id
// 查询订单
const orders = await query('SELECT * FROM orders WHERE id = ?', [outTradeNo])
if (orders.length === 0) {
console.error('微信支付回调订单不存在:', outTradeNo)
ctx.body = '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[订单不存在]]></return_msg></xml>'
return
}
const order = orders[0]
// 防止重复通知
if (order.status === 'paid') {
ctx.body = '<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>'
return
}
// 验证金额(微信返回的金额单位为分)
const totalFee = parseInt(result.total_fee)
const orderTotalFee = Math.round(parseFloat(order.total_price) * 100)
if (totalFee !== orderTotalFee) {
console.error('微信支付回调金额不一致:', { totalFee, orderTotalFee })
ctx.body = '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[金额不一致]]></return_msg></xml>'
return
}
// 更新订单状态为paid
await query(
'UPDATE orders SET status = ?, transaction_id = ? WHERE id = ? AND status != ?',
['paid', transactionId, outTradeNo, 'paid']
)
console.info('微信支付成功,订单已更新:', outTradeNo)
ctx.body = '<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>'
} catch (error) {
console.error('处理微信支付回调异常:', error)
ctx.body = '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[内部错误]]></return_msg></xml>'
}
}
/**
* 申请退款
* 调用微信退款API
*/
async function refundPayment(ctx) {
const { orderId, reason } = ctx.request.body
if (!orderId) {
ctx.body = { code: 400, message: '订单ID不能为空' }
return
}
// 查询订单信息
const orders = await query('SELECT * FROM orders WHERE id = ?', [orderId])
if (orders.length === 0) {
ctx.body = { code: 404, message: '订单不存在' }
return
}
const order = orders[0]
// 检查订单状态,只有已支付的订单才能退款
if (order.status !== 'paid') {
ctx.body = { code: 400, message: '订单未支付,无法退款' }
return
}
// 检查支付配置
if (!APPID || !MCH_ID || !API_KEY) {
ctx.body = { code: 500, message: '微信支付配置不完整' }
return
}
// 构造退款参数
const nonceStr = generateNonceStr()
const outTradeNo = orderId
const outRefundNo = `RF${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`
const totalFee = Math.round(parseFloat(order.total_price) * 100)
const refundFee = totalFee // 默认全额退款
const params = {
appid: APPID,
mch_id: MCH_ID,
nonce_str: nonceStr,
out_trade_no: outTradeNo,
out_refund_no: outRefundNo,
total_fee: totalFee,
refund_fee: refundFee,
refund_desc: reason || '用户申请退款'
}
// 生成签名
params.sign = generateSign(params)
try {
// 读取证书(微信退款API需要双向证书)
let fetchOptions = {
method: 'POST',
body: buildXML(params),
headers: { 'Content-Type': 'application/xml' }
}
if (CERT_PATH && KEY_PATH) {
const fs = require('fs')
fetchOptions.agent = new (require('https').Agent)({
cert: fs.readFileSync(CERT_PATH),
key: fs.readFileSync(KEY_PATH)
})
}
const response = await fetch(REFUND_URL, fetchOptions)
const resultXml = await response.text()
const result = await parseXML(resultXml)
if (result.return_code !== 'SUCCESS') {
console.error('微信退款通信失败:', result.return_msg)
ctx.body = { code: 500, message: `微信退款通信失败: ${result.return_msg}` }
return
}
if (result.result_code !== 'SUCCESS') {
console.error('微信退款业务失败:', result.err_code_des)
ctx.body = { code: 500, message: `微信退款失败: ${result.err_code_des}` }
return
}
// 更新订单状态为refunded
await query(
'UPDATE orders SET status = ?, refund_no = ? WHERE id = ?',
['refunded', outRefundNo, orderId]
)
ctx.body = {
code: 200,
data: {
outRefundNo,
refundFee: refundFee / 100
}
}
} catch (error) {
console.error('申请退款失败:', error)
ctx.body = { code: 500, message: '申请退款失败' }
}
}
module.exports = {
createPayment,
paymentNotify,
refundPayment
}