Files
soul-yongping/app/api/miniprogram/pay/route.ts

438 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 小程序支付API
* 对接真实微信支付接口
*
* 配置来源: 用户规则
* - 小程序AppID: wxb8bbb2b10dec74aa
* - 商户号: 1318592501
* - API密钥: wx3e31b068be59ddc131b068be59ddc2
*/
import { NextResponse } from 'next/server'
import crypto from 'crypto'
import { query, getConfig } from '@/lib/db'
// 微信支付配置 - 2026-01-25 更新
// 小程序支付绑定状态: 审核中申请单ID: 201554696918
const WECHAT_PAY_CONFIG = {
appId: 'wxb8bbb2b10dec74aa', // 小程序AppID
appSecret: '3c1fb1f63e6e052222bbcead9d07fe0c', // 小程序AppSecret已更新
mchId: '1318592501', // 商户号
mchKey: 'wx3e31b068be59ddc131b068be59ddc2', // API密钥(v2)
notifyUrl: 'https://soul.quwanzhi.com/api/miniprogram/pay/notify', // 支付回调地址
}
// 生成随机字符串
function generateNonceStr(): string {
return crypto.randomBytes(16).toString('hex')
}
// 生成签名
function generateSign(params: Record<string, string>, key: string): string {
const sortedKeys = Object.keys(params).sort()
const signString = sortedKeys
.filter((k) => params[k] && k !== 'sign')
.map((k) => `${k}=${params[k]}`)
.join('&')
const signWithKey = `${signString}&key=${key}`
return crypto.createHash('md5').update(signWithKey, 'utf8').digest('hex').toUpperCase()
}
// 对象转XML
function dictToXml(data: Record<string, string>): string {
const xml = ['<xml>']
for (const [key, value] of Object.entries(data)) {
xml.push(`<${key}><![CDATA[${value}]]></${key}>`)
}
xml.push('</xml>')
return xml.join('')
}
// XML转对象
function xmlToDict(xml: string): Record<string, string> {
const result: Record<string, string> = {}
const regex = /<(\w+)><!\[CDATA\[(.*?)\]\]><\/\1>/g
let match
while ((match = regex.exec(xml)) !== null) {
result[match[1]] = match[2]
}
const simpleRegex = /<(\w+)>([^<]*)<\/\1>/g
while ((match = simpleRegex.exec(xml)) !== null) {
if (!result[match[1]]) {
result[match[1]] = match[2]
}
}
return result
}
// 生成订单号
function generateOrderSn(): string {
const now = new Date()
const timestamp = now.getFullYear().toString() +
(now.getMonth() + 1).toString().padStart(2, '0') +
now.getDate().toString().padStart(2, '0') +
now.getHours().toString().padStart(2, '0') +
now.getMinutes().toString().padStart(2, '0') +
now.getSeconds().toString().padStart(2, '0')
const random = Math.floor(Math.random() * 1000000).toString().padStart(6, '0')
return `MP${timestamp}${random}`
}
/**
* POST - 创建小程序支付订单
*/
export async function POST(request: Request) {
try {
const body = await request.json()
const { openId, productType, productId, amount, description } = body
if (!openId) {
return NextResponse.json({
success: false,
error: '缺少openId参数请先登录'
}, { status: 400 })
}
if (!amount || amount <= 0) {
return NextResponse.json({
success: false,
error: '支付金额无效'
}, { status: 400 })
}
// === 根据推广配置计算好友优惠后的实际支付金额 ===
let finalAmount = amount
try {
// 读取推广/分销配置,获取好友优惠比例(如 5 表示 5%
const referralConfig = await getConfig('referral_config')
const userDiscount = referralConfig?.userDiscount ? Number(referralConfig.userDiscount) : 0
// 若存在有效的推荐码且配置了优惠比例,则给好友打折
if (userDiscount > 0 && body.referralCode) {
const discountRate = userDiscount / 100
const discounted = amount * (1 - discountRate)
// 保证至少 0.01 元,并保留两位小数
finalAmount = Math.max(0.01, Math.round(discounted * 100) / 100)
console.log('[MiniPay] 应用好友优惠:', {
originalAmount: amount,
discountPercent: userDiscount,
finalAmount,
referralCode: body.referralCode,
})
}
} catch (e) {
console.warn('[MiniPay] 读取 referral_config.userDiscount 失败,使用原价金额:', e)
finalAmount = amount
}
const orderSn = generateOrderSn()
const totalFee = Math.round(finalAmount * 100) // 转换为分(单位分)
const goodsBody = description || (productType === 'fullbook' ? '《一场Soul的创业实验》全书' : `章节购买-${productId}`)
// 获取客户端IP
const forwarded = request.headers.get('x-forwarded-for')
const clientIp = forwarded ? forwarded.split(',')[0] : '127.0.0.1'
// 构建统一下单参数
const params: Record<string, string> = {
appid: WECHAT_PAY_CONFIG.appId,
mch_id: WECHAT_PAY_CONFIG.mchId,
nonce_str: generateNonceStr(),
body: goodsBody.slice(0, 128),
out_trade_no: orderSn,
total_fee: totalFee.toString(),
spbill_create_ip: clientIp,
notify_url: WECHAT_PAY_CONFIG.notifyUrl,
trade_type: 'JSAPI',
openid: openId,
attach: JSON.stringify({ productType, productId, userId: body.userId || '' }),
}
// 生成签名
params.sign = generateSign(params, WECHAT_PAY_CONFIG.mchKey)
console.log('[MiniPay] 创建订单:', {
orderSn,
totalFee,
openId: openId.slice(0, 10) + '...',
productType,
productId,
})
// === ✅ 1. 先插入订单到数据库(无论支付是否成功,都要有订单记录) ===
const userId = body.userId || openId // 优先使用 userId否则用 openId
let orderCreated = false
// 查询当前用户的有效推荐人(用于订单归属与分销)
let referrerId: string | null = null
try {
const bindings = await query(`
SELECT referrer_id
FROM referral_bindings
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
ORDER BY binding_date DESC
LIMIT 1
`, [userId]) as any[]
if (bindings.length > 0) {
referrerId = bindings[0].referrer_id || null
console.log('[MiniPay] 订单归属推荐人(绑定):', referrerId)
}
// 若绑定未查到且前端传了邀请码,按邀请码解析推荐人
if (!referrerId && body.referralCode) {
const refUsers = await query(`
SELECT id FROM users WHERE referral_code = ? LIMIT 1
`, [String(body.referralCode).trim()]) as any[]
if (refUsers.length > 0) {
referrerId = refUsers[0].id
console.log('[MiniPay] 订单归属推荐人(邀请码):', referrerId)
}
}
} catch (e) {
console.warn('[MiniPay] 查询推荐人失败,继续创建订单:', e)
}
// 下单时使用的邀请码:优先用请求体,否则用推荐人当前邀请码(便于订单记录对账)
let orderReferralCode: string | null = body.referralCode ? String(body.referralCode).trim() || null : null
if (!orderReferralCode && referrerId) {
try {
const refRows = (await query(`SELECT referral_code FROM users WHERE id = ? LIMIT 1`, [referrerId]) as any[])
if (refRows.length > 0 && refRows[0].referral_code) {
orderReferralCode = refRows[0].referral_code
}
} catch (_) { /* 忽略 */ }
}
try {
// 检查是否已有相同产品的已支付订单
const existingOrders = await query(`
SELECT id FROM orders
WHERE user_id = ?
AND product_type = ?
AND product_id = ?
AND status = 'paid'
LIMIT 1
`, [userId, productType, productId || 'fullbook']) as any[]
if (existingOrders.length > 0) {
console.log('[MiniPay] ⚠️ 用户已购买该产品,但仍创建订单:', {
userId,
productType,
productId
})
}
// 插入订单(含 referrer_id、referral_code便于分销归属与对账
try {
await query(`
INSERT INTO orders (
id, order_sn, user_id, open_id,
product_type, product_id, amount, description,
status, transaction_id, referrer_id, referral_code, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`, [
orderSn, orderSn, userId, openId,
productType, productId || 'fullbook', finalAmount, goodsBody,
'created', null, referrerId, orderReferralCode
])
} catch (insertErr: any) {
// 兼容:若表尚无 referrer_id 或 referral_code 列
const msg = (insertErr as any)?.message || ''
const code = (insertErr as any)?.code || ''
if (msg.includes('referrer_id') || msg.includes('referral_code') || code === 'ER_BAD_FIELD_ERROR') {
try {
await query(`
INSERT INTO orders (
id, order_sn, user_id, open_id,
product_type, product_id, amount, description,
status, transaction_id, referrer_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`, [
orderSn, orderSn, userId, openId,
productType, productId || 'fullbook', finalAmount, goodsBody,
'created', null, referrerId
])
console.log('[MiniPay] 订单已插入(未含 referral_code请执行 scripts/add_orders_referral_code.py)')
} catch (e2: any) {
if (e2?.message?.includes('referrer_id') || e2?.code === 'ER_BAD_FIELD_ERROR') {
await query(`
INSERT INTO orders (
id, order_sn, user_id, open_id,
product_type, product_id, amount, description,
status, transaction_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`, [
orderSn, orderSn, userId, openId,
productType, productId || 'fullbook', finalAmount, goodsBody,
'created', null
])
console.log('[MiniPay] 订单已插入(未含 referrer_id/referral_code请执行迁移脚本)')
} else {
throw e2
}
}
} else {
throw insertErr
}
}
orderCreated = true
console.log('[MiniPay] ✅ 订单已插入数据库:', {
orderSn,
userId,
productType,
productId,
originalAmount: amount,
finalAmount,
})
} catch (dbError) {
console.error('[MiniPay] ❌ 插入订单失败:', dbError)
// 订单创建失败,但不中断支付流程
// 理由:微信支付成功后仍可以通过回调补记订单
}
// 调用微信统一下单接口
const xmlData = dictToXml(params)
const response = await fetch('https://api.mch.weixin.qq.com/pay/unifiedorder', {
method: 'POST',
headers: { 'Content-Type': 'application/xml' },
body: xmlData,
})
const responseText = await response.text()
const result = xmlToDict(responseText)
console.log('[MiniPay] 微信响应:', {
return_code: result.return_code,
result_code: result.result_code,
err_code: result.err_code,
err_code_des: result.err_code_des,
})
// 检查返回结果
if (result.return_code !== 'SUCCESS') {
return NextResponse.json({
success: false,
error: `微信支付请求失败: ${result.return_msg || '未知错误'}`
}, { status: 500 })
}
if (result.result_code !== 'SUCCESS') {
return NextResponse.json({
success: false,
error: `微信支付失败: ${result.err_code_des || result.err_code || '未知错误'}`
}, { status: 500 })
}
// 构建小程序支付参数
const prepayId = result.prepay_id
if (!prepayId) {
return NextResponse.json({
success: false,
error: '微信支付返回数据异常'
}, { status: 500 })
}
const timestamp = Math.floor(Date.now() / 1000).toString()
const nonceStr = generateNonceStr()
const payParams: Record<string, string> = {
appId: WECHAT_PAY_CONFIG.appId,
timeStamp: timestamp,
nonceStr,
package: `prepay_id=${prepayId}`,
signType: 'MD5',
}
payParams.paySign = generateSign(payParams, WECHAT_PAY_CONFIG.mchKey)
console.log('[MiniPay] 支付参数生成成功:', { orderSn, prepayId: prepayId.slice(0, 20) + '...' })
return NextResponse.json({
success: true,
data: {
orderSn,
prepayId,
payParams: {
timeStamp: timestamp,
nonceStr,
package: `prepay_id=${prepayId}`,
signType: 'MD5',
paySign: payParams.paySign,
}
}
})
} catch (error) {
console.error('[MiniPay] 创建订单失败:', error)
return NextResponse.json({
success: false,
error: '创建支付订单失败'
}, { status: 500 })
}
}
/**
* GET - 查询订单状态
*/
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const orderSn = searchParams.get('orderSn')
if (!orderSn) {
return NextResponse.json({
success: false,
error: '缺少订单号'
}, { status: 400 })
}
try {
const params: Record<string, string> = {
appid: WECHAT_PAY_CONFIG.appId,
mch_id: WECHAT_PAY_CONFIG.mchId,
out_trade_no: orderSn,
nonce_str: generateNonceStr(),
}
params.sign = generateSign(params, WECHAT_PAY_CONFIG.mchKey)
const xmlData = dictToXml(params)
const response = await fetch('https://api.mch.weixin.qq.com/pay/orderquery', {
method: 'POST',
headers: { 'Content-Type': 'application/xml' },
body: xmlData,
})
const responseText = await response.text()
const result = xmlToDict(responseText)
if (result.return_code !== 'SUCCESS' || result.result_code !== 'SUCCESS') {
return NextResponse.json({
success: true,
data: { status: 'unknown', orderSn }
})
}
const tradeState = result.trade_state
let status = 'paying'
if (tradeState === 'SUCCESS') status = 'paid'
else if (['CLOSED', 'REVOKED', 'PAYERROR'].includes(tradeState)) status = 'failed'
else if (tradeState === 'REFUND') status = 'refunded'
return NextResponse.json({
success: true,
data: {
status,
orderSn,
transactionId: result.transaction_id,
totalFee: parseInt(result.total_fee || '0', 10),
}
})
} catch (error) {
console.error('[MiniPay] 查询订单失败:', error)
return NextResponse.json({
success: false,
error: '查询订单失败'
}, { status: 500 })
}
}