369 lines
10 KiB
JavaScript
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
|
|
}
|