From 6a971d30610af994f3a51f1a0550361e3672dfe7 Mon Sep 17 00:00:00 2001 From: txai-dev Date: Sun, 13 Sep 2026 12:31:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BE=AE=E4=BF=A1=E6=94=AF=E4=BB=98v3?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E4=B8=8B=E5=8D=95=E5=AE=9E=E7=8E=B0=EF=BC=88?= =?UTF-8?q?=E5=95=86=E6=88=B7=E8=AF=81=E4=B9=A6=E5=B0=B1=E7=BB=AA=E5=8D=B3?= =?UTF-8?q?=E9=80=9A=EF=BC=89+=E7=BC=BA=E8=AF=81=E4=B9=A6=E6=97=B6?= =?UTF-8?q?=E6=98=8E=E7=A1=AE503=E6=8F=90=E7=A4=BA=EF=BC=9Bcerts=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + server/src/routes/payment.js | 660 +++++++++++++++++------------------ server/src/utils/weixin.js | 616 +++++++++++++++++--------------- 3 files changed, 662 insertions(+), 615 deletions(-) diff --git a/.gitignore b/.gitignore index f2587c3..bfc2631 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ client/src/.share-done admin/node_modules/ admin/dist/ server/data/ +!server/data/certs/README.md diff --git a/server/src/routes/payment.js b/server/src/routes/payment.js index 7388be7..8182bb4 100755 --- a/server/src/routes/payment.js +++ b/server/src/routes/payment.js @@ -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 diff --git a/server/src/utils/weixin.js b/server/src/utils/weixin.js index 92977c1..9492269 100755 --- a/server/src/utils/weixin.js +++ b/server/src/utils/weixin.js @@ -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