feat: 微信支付v3真实下单实现(商户证书就绪即通)+缺证书时明确503提示;certs目录说明
This commit is contained in:
+330
-330
@@ -1,330 +1,330 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { User, Order, ShopItem } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const weChatService = require('../utils/weixin')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取商品列表(从数据库)- 公开接口
|
||||
* GET /api/payment/products
|
||||
*/
|
||||
router.get('/products', async (req, res) => {
|
||||
try {
|
||||
const items = await ShopItem.find({ status: 'active' }).sort({ sort: 1 })
|
||||
return ApiResponse.success(res, items)
|
||||
} catch (error) {
|
||||
logger.error('获取商品列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 应用用户认证中间件到需要保护的路由
|
||||
router.use(userAuthMiddleware)
|
||||
|
||||
/**
|
||||
* 创建订单
|
||||
* POST /api/payment/create-order
|
||||
*/
|
||||
router.post('/create-order', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId } = req.body
|
||||
|
||||
// 从数据库获取商品信息
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, '无效商品 ID 或商品已下架', 400)
|
||||
}
|
||||
|
||||
// 检查是否已购买(针对皮肤类一次性商品)
|
||||
if (product.type === 'skin' && user.ownedSkins?.includes(productId)) {
|
||||
return ApiResponse.error(res, '已拥有该商品', 400)
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
const orderNo = weChatService.generateOrderNo('WD')
|
||||
|
||||
// 创建订单记录
|
||||
const order = new Order({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
userId: user._id,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
status: 'pending'
|
||||
})
|
||||
await order.save()
|
||||
|
||||
// 调用微信支付统一下单
|
||||
const payParams = await weChatService.createPaymentOrder({
|
||||
openid: user.openid,
|
||||
orderNo,
|
||||
body: `宇之然-${product.name}`,
|
||||
amount: product.price
|
||||
})
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
orderNo,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
payParams
|
||||
}, '订单创建成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('创建订单失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 创建H5支付订单(微信H5支付)
|
||||
* POST /api/payment/create-h5-order
|
||||
*/
|
||||
router.post('/create-h5-order', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId, clientIp } = req.body
|
||||
|
||||
// 从数据库获取商品信息
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, '无效商品 ID 或商品已下架', 400)
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
const orderNo = weChatService.generateOrderNo('WD')
|
||||
|
||||
// 创建订单记录
|
||||
const order = new Order({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
userId: user._id,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
status: 'pending'
|
||||
})
|
||||
await order.save()
|
||||
|
||||
// 调用微信支付H5下单
|
||||
const h5PayParams = await weChatService.createH5PaymentOrder({
|
||||
orderNo,
|
||||
body: `宇之然-${product.name}`,
|
||||
amount: product.price,
|
||||
clientIp: clientIp || req.ip || req.connection.remoteAddress || '127.0.0.1'
|
||||
})
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
orderNo,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
h5Url: h5PayParams.h5Url
|
||||
}, 'H5支付订单创建成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('创建H5订单失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 支付回调
|
||||
* POST /api/payment/callback
|
||||
*/
|
||||
router.post('/callback', async (req, res) => {
|
||||
try {
|
||||
const data = req.body
|
||||
|
||||
// 验证签名
|
||||
if (!weChatService.verifyCallbackSignature(data, data.sign)) {
|
||||
logger.warn('支付签名验证失败')
|
||||
return res.status(400).send('FAIL')
|
||||
}
|
||||
|
||||
const orderNo = data.out_trade_no
|
||||
const transactionId = data.transaction_id
|
||||
|
||||
// 查询订单
|
||||
const order = await Order.findOne({ orderNo })
|
||||
if (!order) {
|
||||
logger.error(`订单不存在: ${orderNo}`)
|
||||
return res.status(400).send('FAIL')
|
||||
}
|
||||
|
||||
// 已处理过
|
||||
if (order.status === 'paid' || order.status === 'completed') {
|
||||
return res.status(200).send('SUCCESS')
|
||||
}
|
||||
|
||||
// 更新订单状态
|
||||
order.status = 'paid'
|
||||
order.transactionId = transactionId
|
||||
order.paidAt = new Date()
|
||||
await order.save()
|
||||
|
||||
// 解锁商品
|
||||
const user = await User.findOne({ openid: order.openid })
|
||||
if (user) {
|
||||
const product = await ShopItem.findOne({ itemId: order.productId })
|
||||
|
||||
if (product) {
|
||||
if (product.type === 'skin') {
|
||||
if (!user.ownedSkins.includes(order.productId)) {
|
||||
user.ownedSkins.push(order.productId)
|
||||
}
|
||||
} else if (product.type === 'noad') {
|
||||
user.isAdFree = true
|
||||
} else if (product.type === 'subscription') {
|
||||
const expiry = new Date()
|
||||
expiry.setDate(expiry.getDate() + (product.duration || 30))
|
||||
user.isSubscriber = true
|
||||
user.subscribeExpiry = expiry
|
||||
}
|
||||
await user.save()
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`支付成功: ${orderNo}`)
|
||||
return res.status(200).send('SUCCESS')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('支付回调处理失败:', error)
|
||||
return res.status(500).send('FAIL')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 验证支付结果
|
||||
* POST /api/payment/verify
|
||||
* 只有存在该用户真实已支付订单时才解锁商品,防止未支付直接解锁
|
||||
*/
|
||||
router.post('/verify', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId, orderNo } = req.body
|
||||
|
||||
if (!orderNo || !productId) {
|
||||
return ApiResponse.error(res, '缺少订单号或商品 ID', 400)
|
||||
}
|
||||
|
||||
// 校验订单是否已支付,且属于当前用户
|
||||
const order = await Order.findOne({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
status: { $in: ['paid', 'completed'] }
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return ApiResponse.error(res, '订单未支付,无法解锁商品', 400)
|
||||
}
|
||||
|
||||
// 校验订单对应商品
|
||||
if (order.productId !== productId) {
|
||||
return ApiResponse.error(res, '订单与商品不匹配', 400)
|
||||
}
|
||||
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, `未知商品: ${productId}`, 400)
|
||||
}
|
||||
|
||||
// 解锁商品(幂等)
|
||||
let alreadyUnlocked = false
|
||||
if (product.type === 'skin') {
|
||||
if (user.ownedSkins.includes(productId)) alreadyUnlocked = true
|
||||
else user.ownedSkins.push(productId)
|
||||
} else if (product.type === 'noad') {
|
||||
alreadyUnlocked = user.isAdFree
|
||||
user.isAdFree = true
|
||||
} else if (product.type === 'subscription') {
|
||||
alreadyUnlocked = user.isSubscriber
|
||||
const expiry = new Date()
|
||||
expiry.setDate(expiry.getDate() + (product.duration || 30))
|
||||
user.isSubscriber = true
|
||||
user.subscribeExpiry = expiry
|
||||
}
|
||||
|
||||
await user.save()
|
||||
|
||||
// 订单已处理过则直接返回
|
||||
if (order.status === 'completed') {
|
||||
return ApiResponse.success(res, {
|
||||
product,
|
||||
message: alreadyUnlocked ? '权益已生效' : `${product.name} 已解锁!`
|
||||
})
|
||||
}
|
||||
|
||||
// 标记订单为已完成
|
||||
order.status = 'completed'
|
||||
order.completedAt = new Date()
|
||||
await order.save()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
product,
|
||||
message: `${product.name} 已解锁!`
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('验证支付失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* 申请退款
|
||||
* POST /api/payment/refund
|
||||
*/
|
||||
router.post('/refund', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { orderNo, reason } = req.body
|
||||
|
||||
// 查询订单
|
||||
const order = await Order.findOne({ orderNo })
|
||||
if (!order) {
|
||||
return ApiResponse.error(res, '订单不存在', 404)
|
||||
}
|
||||
|
||||
// 权限校验:只能退自己的订单
|
||||
if (order.openid !== user.openid) {
|
||||
return ApiResponse.error(res, '无权操作该订单', 403)
|
||||
}
|
||||
|
||||
if (order.status !== 'paid' && order.status !== 'completed') {
|
||||
return ApiResponse.error(res, '订单状态不支持退款', 400)
|
||||
}
|
||||
|
||||
// 调用微信退款接口
|
||||
const result = await weChatService.refundOrder(
|
||||
order.transactionId,
|
||||
`REF_${order.orderNo}`,
|
||||
order.amount / 100,
|
||||
order.amount / 100,
|
||||
reason
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
// 更新订单状态
|
||||
order.status = 'refunded'
|
||||
order.refundReason = reason
|
||||
order.refundedAt = new Date()
|
||||
await order.save()
|
||||
|
||||
return ApiResponse.success(res, result, '退款申请已提交')
|
||||
} else {
|
||||
return ApiResponse.error(res, '退款申请失败', 500)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('申请退款失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { User, Order, ShopItem } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const weChatService = require('../utils/weixin')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取商品列表(从数据库)- 公开接口
|
||||
* GET /api/payment/products
|
||||
*/
|
||||
router.get('/products', async (req, res) => {
|
||||
try {
|
||||
const items = await ShopItem.find({ status: 'active' }).sort({ sort: 1 })
|
||||
return ApiResponse.success(res, items)
|
||||
} catch (error) {
|
||||
logger.error('获取商品列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 应用用户认证中间件到需要保护的路由
|
||||
router.use(userAuthMiddleware)
|
||||
|
||||
/**
|
||||
* 创建订单
|
||||
* POST /api/payment/create-order
|
||||
*/
|
||||
router.post('/create-order', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId } = req.body
|
||||
|
||||
// 从数据库获取商品信息
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, '无效商品 ID 或商品已下架', 400)
|
||||
}
|
||||
|
||||
// 检查是否已购买(针对皮肤类一次性商品)
|
||||
if (product.type === 'skin' && user.ownedSkins?.includes(productId)) {
|
||||
return ApiResponse.error(res, '已拥有该商品', 400)
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
const orderNo = weChatService.generateOrderNo('WD')
|
||||
|
||||
// 创建订单记录
|
||||
const order = new Order({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
userId: user._id,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
status: 'pending'
|
||||
})
|
||||
await order.save()
|
||||
|
||||
// 调用微信支付统一下单
|
||||
const payParams = await weChatService.createPaymentOrder({
|
||||
openid: user.openid,
|
||||
orderNo,
|
||||
body: `宇之然-${product.name}`,
|
||||
amount: product.price
|
||||
})
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
orderNo,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
payParams
|
||||
}, '订单创建成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('创建订单失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 创建H5支付订单(微信H5支付)
|
||||
* POST /api/payment/create-h5-order
|
||||
*/
|
||||
router.post('/create-h5-order', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId, clientIp } = req.body
|
||||
|
||||
// 从数据库获取商品信息
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, '无效商品 ID 或商品已下架', 400)
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
const orderNo = weChatService.generateOrderNo('WD')
|
||||
|
||||
// 创建订单记录
|
||||
const order = new Order({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
userId: user._id,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
status: 'pending'
|
||||
})
|
||||
await order.save()
|
||||
|
||||
// 调用微信支付H5下单
|
||||
const h5PayParams = await weChatService.createH5PaymentOrder({
|
||||
orderNo,
|
||||
body: `宇之然-${product.name}`,
|
||||
amount: product.price,
|
||||
clientIp: clientIp || req.ip || req.connection.remoteAddress || '127.0.0.1'
|
||||
})
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
orderNo,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
h5Url: h5PayParams.h5Url
|
||||
}, 'H5支付订单创建成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('创建H5订单失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 支付回调
|
||||
* POST /api/payment/callback
|
||||
*/
|
||||
router.post('/callback', async (req, res) => {
|
||||
try {
|
||||
const data = req.body
|
||||
|
||||
// 验证签名
|
||||
if (!weChatService.verifyCallbackSignature(data, data.sign)) {
|
||||
logger.warn('支付签名验证失败')
|
||||
return res.status(400).send('FAIL')
|
||||
}
|
||||
|
||||
const orderNo = data.out_trade_no
|
||||
const transactionId = data.transaction_id
|
||||
|
||||
// 查询订单
|
||||
const order = await Order.findOne({ orderNo })
|
||||
if (!order) {
|
||||
logger.error(`订单不存在: ${orderNo}`)
|
||||
return res.status(400).send('FAIL')
|
||||
}
|
||||
|
||||
// 已处理过
|
||||
if (order.status === 'paid' || order.status === 'completed') {
|
||||
return res.status(200).send('SUCCESS')
|
||||
}
|
||||
|
||||
// 更新订单状态
|
||||
order.status = 'paid'
|
||||
order.transactionId = transactionId
|
||||
order.paidAt = new Date()
|
||||
await order.save()
|
||||
|
||||
// 解锁商品
|
||||
const user = await User.findOne({ openid: order.openid })
|
||||
if (user) {
|
||||
const product = await ShopItem.findOne({ itemId: order.productId })
|
||||
|
||||
if (product) {
|
||||
if (product.type === 'skin') {
|
||||
if (!user.ownedSkins.includes(order.productId)) {
|
||||
user.ownedSkins.push(order.productId)
|
||||
}
|
||||
} else if (product.type === 'noad') {
|
||||
user.isAdFree = true
|
||||
} else if (product.type === 'subscription') {
|
||||
const expiry = new Date()
|
||||
expiry.setDate(expiry.getDate() + (product.duration || 30))
|
||||
user.isSubscriber = true
|
||||
user.subscribeExpiry = expiry
|
||||
}
|
||||
await user.save()
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`支付成功: ${orderNo}`)
|
||||
return res.status(200).send('SUCCESS')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('支付回调处理失败:', error)
|
||||
return res.status(500).send('FAIL')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 验证支付结果
|
||||
* POST /api/payment/verify
|
||||
* 只有存在该用户真实已支付订单时才解锁商品,防止未支付直接解锁
|
||||
*/
|
||||
router.post('/verify', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId, orderNo } = req.body
|
||||
|
||||
if (!orderNo || !productId) {
|
||||
return ApiResponse.error(res, '缺少订单号或商品 ID', 400)
|
||||
}
|
||||
|
||||
// 校验订单是否已支付,且属于当前用户
|
||||
const order = await Order.findOne({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
status: { $in: ['paid', 'completed'] }
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return ApiResponse.error(res, '订单未支付,无法解锁商品', 400)
|
||||
}
|
||||
|
||||
// 校验订单对应商品
|
||||
if (order.productId !== productId) {
|
||||
return ApiResponse.error(res, '订单与商品不匹配', 400)
|
||||
}
|
||||
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, `未知商品: ${productId}`, 400)
|
||||
}
|
||||
|
||||
// 解锁商品(幂等)
|
||||
let alreadyUnlocked = false
|
||||
if (product.type === 'skin') {
|
||||
if (user.ownedSkins.includes(productId)) alreadyUnlocked = true
|
||||
else user.ownedSkins.push(productId)
|
||||
} else if (product.type === 'noad') {
|
||||
alreadyUnlocked = user.isAdFree
|
||||
user.isAdFree = true
|
||||
} else if (product.type === 'subscription') {
|
||||
alreadyUnlocked = user.isSubscriber
|
||||
const expiry = new Date()
|
||||
expiry.setDate(expiry.getDate() + (product.duration || 30))
|
||||
user.isSubscriber = true
|
||||
user.subscribeExpiry = expiry
|
||||
}
|
||||
|
||||
await user.save()
|
||||
|
||||
// 订单已处理过则直接返回
|
||||
if (order.status === 'completed') {
|
||||
return ApiResponse.success(res, {
|
||||
product,
|
||||
message: alreadyUnlocked ? '权益已生效' : `${product.name} 已解锁!`
|
||||
})
|
||||
}
|
||||
|
||||
// 标记订单为已完成
|
||||
order.status = 'completed'
|
||||
order.completedAt = new Date()
|
||||
await order.save()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
product,
|
||||
message: `${product.name} 已解锁!`
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('验证支付失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* 申请退款
|
||||
* POST /api/payment/refund
|
||||
*/
|
||||
router.post('/refund', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { orderNo, reason } = req.body
|
||||
|
||||
// 查询订单
|
||||
const order = await Order.findOne({ orderNo })
|
||||
if (!order) {
|
||||
return ApiResponse.error(res, '订单不存在', 404)
|
||||
}
|
||||
|
||||
// 权限校验:只能退自己的订单
|
||||
if (order.openid !== user.openid) {
|
||||
return ApiResponse.error(res, '无权操作该订单', 403)
|
||||
}
|
||||
|
||||
if (order.status !== 'paid' && order.status !== 'completed') {
|
||||
return ApiResponse.error(res, '订单状态不支持退款', 400)
|
||||
}
|
||||
|
||||
// 调用微信退款接口
|
||||
const result = await weChatService.refundOrder(
|
||||
order.transactionId,
|
||||
`REF_${order.orderNo}`,
|
||||
order.amount / 100,
|
||||
order.amount / 100,
|
||||
reason
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
// 更新订单状态
|
||||
order.status = 'refunded'
|
||||
order.refundReason = reason
|
||||
order.refundedAt = new Date()
|
||||
await order.save()
|
||||
|
||||
return ApiResponse.success(res, result, '退款申请已提交')
|
||||
} else {
|
||||
return ApiResponse.error(res, '退款申请失败', 500)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('申请退款失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
+331
-285
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user