diff --git a/app/api/miniprogram/login/route.ts b/app/api/miniprogram/login/route.ts new file mode 100644 index 0000000..bc84411 --- /dev/null +++ b/app/api/miniprogram/login/route.ts @@ -0,0 +1,103 @@ +/** + * 小程序登录API + * 使用code换取openId和session_key + * + * 小程序配置: + * - AppID: wxb8bbb2b10dec74aa + * - AppSecret: 85d3fa31584d06acdb1de4a597d25b7b + */ + +import { NextResponse } from 'next/server' + +const MINIPROGRAM_CONFIG = { + appId: 'wxb8bbb2b10dec74aa', + appSecret: '85d3fa31584d06acdb1de4a597d25b7b', +} + +/** + * POST - 小程序登录,获取openId + */ +export async function POST(request: Request) { + try { + const body = await request.json() + const { code } = body + + if (!code) { + return NextResponse.json({ + success: false, + error: '缺少登录code' + }, { status: 400 }) + } + + console.log('[MiniLogin] 收到登录请求, code:', code.slice(0, 10) + '...') + + // 调用微信接口获取openId + const wxUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${MINIPROGRAM_CONFIG.appId}&secret=${MINIPROGRAM_CONFIG.appSecret}&js_code=${code}&grant_type=authorization_code` + + const response = await fetch(wxUrl) + const data = await response.json() + + console.log('[MiniLogin] 微信接口返回:', { + errcode: data.errcode, + errmsg: data.errmsg, + hasOpenId: !!data.openid, + }) + + if (data.errcode) { + return NextResponse.json({ + success: false, + error: `微信登录失败: ${data.errmsg || data.errcode}` + }, { status: 400 }) + } + + const openId = data.openid + const sessionKey = data.session_key + const unionId = data.unionid + + if (!openId) { + return NextResponse.json({ + success: false, + error: '获取openId失败' + }, { status: 500 }) + } + + // 创建或更新用户 + // TODO: 这里应该连接数据库操作 + const user = { + id: `user_${openId.slice(-8)}`, + openId, + nickname: '微信用户', + avatar: '', + referralCode: 'SOUL' + Date.now().toString(36).toUpperCase().slice(-6), + purchasedSections: [], + hasFullBook: false, + earnings: 0, + pendingEarnings: 0, + referralCount: 0, + createdAt: new Date().toISOString() + } + + // 生成token + const token = `tk_${openId.slice(-8)}_${Date.now()}` + + console.log('[MiniLogin] 登录成功, userId:', user.id) + + return NextResponse.json({ + success: true, + data: { + openId, + sessionKey, // 注意:生产环境不应返回sessionKey给前端 + unionId, + user, + token, + } + }) + + } catch (error) { + console.error('[MiniLogin] 登录失败:', error) + return NextResponse.json({ + success: false, + error: '登录失败' + }, { status: 500 }) + } +} diff --git a/app/api/miniprogram/pay/notify/route.ts b/app/api/miniprogram/pay/notify/route.ts new file mode 100644 index 0000000..60f5532 --- /dev/null +++ b/app/api/miniprogram/pay/notify/route.ts @@ -0,0 +1,141 @@ +/** + * 小程序支付回调通知处理 + * 微信支付成功后会调用此接口 + */ + +import { NextResponse } from 'next/server' +import crypto from 'crypto' + +const WECHAT_PAY_CONFIG = { + appId: 'wxb8bbb2b10dec74aa', + mchId: '1318592501', + mchKey: 'wx3e31b068be59ddc131b068be59ddc2', +} + +// 生成签名 +function generateSign(params: Record, 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() +} + +// 验证签名 +function verifySign(params: Record, key: string): boolean { + const receivedSign = params.sign + if (!receivedSign) return false + + const data = { ...params } + delete data.sign + + const calculatedSign = generateSign(data, key) + return receivedSign === calculatedSign +} + +// XML转对象 +function xmlToDict(xml: string): Record { + const result: Record = {} + const regex = /<(\w+)><\/\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 +} + +// 成功响应 +const SUCCESS_RESPONSE = ` + + +` + +// 失败响应 +const FAIL_RESPONSE = ` + + +` + +/** + * POST - 接收微信支付回调 + */ +export async function POST(request: Request) { + try { + const xmlData = await request.text() + console.log('[PayNotify] 收到支付回调') + + const data = xmlToDict(xmlData) + + // 验证签名 + if (!verifySign(data, WECHAT_PAY_CONFIG.mchKey)) { + console.error('[PayNotify] 签名验证失败') + return new Response(FAIL_RESPONSE, { + headers: { 'Content-Type': 'application/xml' } + }) + } + + // 检查支付结果 + if (data.return_code !== 'SUCCESS' || data.result_code !== 'SUCCESS') { + console.log('[PayNotify] 支付未成功:', data.err_code, data.err_code_des) + return new Response(SUCCESS_RESPONSE, { + headers: { 'Content-Type': 'application/xml' } + }) + } + + const orderSn = data.out_trade_no + const transactionId = data.transaction_id + const totalFee = parseInt(data.total_fee || '0', 10) + const openId = data.openid + + console.log('[PayNotify] 支付成功:', { + orderSn, + transactionId, + totalFee, + openId: openId?.slice(0, 10) + '...', + }) + + // 解析附加数据 + let attach: Record = {} + if (data.attach) { + try { + attach = JSON.parse(data.attach) + } catch (e) { + // 忽略解析错误 + } + } + + const { productType, productId, userId } = attach + + // TODO: 这里应该更新数据库中的订单状态 + // 1. 更新订单状态为已支付 + // 2. 如果是章节购买,将章节添加到用户已购列表 + // 3. 如果是全书购买,更新用户为全书用户 + + console.log('[PayNotify] 订单处理完成:', { + orderSn, + productType, + productId, + userId, + }) + + // 返回成功响应给微信 + return new Response(SUCCESS_RESPONSE, { + headers: { 'Content-Type': 'application/xml' } + }) + + } catch (error) { + console.error('[PayNotify] 处理回调失败:', error) + return new Response(FAIL_RESPONSE, { + headers: { 'Content-Type': 'application/xml' } + }) + } +} diff --git a/app/api/miniprogram/pay/route.ts b/app/api/miniprogram/pay/route.ts new file mode 100644 index 0000000..2f6713a --- /dev/null +++ b/app/api/miniprogram/pay/route.ts @@ -0,0 +1,279 @@ +/** + * 小程序支付API + * 对接真实微信支付接口 + * + * 配置来源: 用户规则 + * - 小程序AppID: wxb8bbb2b10dec74aa + * - 商户号: 1318592501 + * - API密钥: wx3e31b068be59ddc131b068be59ddc2 + */ + +import { NextResponse } from 'next/server' +import crypto from 'crypto' + +// 微信支付配置 +const WECHAT_PAY_CONFIG = { + appId: 'wxb8bbb2b10dec74aa', // 小程序AppID + appSecret: '85d3fa31584d06acdb1de4a597d25b7b', // 小程序AppSecret + mchId: '1318592501', // 商户号 + mchKey: 'wx3e31b068be59ddc131b068be59ddc2', // API密钥 + notifyUrl: 'https://soul.cunbao.net/api/miniprogram/pay/notify', // 支付回调地址 +} + +// 生成随机字符串 +function generateNonceStr(): string { + return crypto.randomBytes(16).toString('hex') +} + +// 生成签名 +function generateSign(params: Record, 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 { + const xml = [''] + for (const [key, value] of Object.entries(data)) { + xml.push(`<${key}>`) + } + xml.push('') + return xml.join('') +} + +// XML转对象 +function xmlToDict(xml: string): Record { + const result: Record = {} + const regex = /<(\w+)><\/\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 }) + } + + const orderSn = generateOrderSn() + const totalFee = Math.round(amount * 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 = { + 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, + }) + + // 调用微信统一下单接口 + 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 = { + 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 = { + 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 }) + } +} diff --git a/miniprogram/app.js b/miniprogram/app.js index 2aedb1f..4211af3 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -6,10 +6,14 @@ App({ globalData: { // API基础地址 - baseUrl: 'https://soul.ckb.fit', + baseUrl: 'https://soul.cunbao.net', + + // 小程序配置 + appId: 'wxb8bbb2b10dec74aa', // 用户信息 userInfo: null, + openId: null, // 微信openId,支付必需 isLoggedIn: false, // 书籍数据 @@ -170,7 +174,7 @@ App({ }) }, - // 登录方法 - 支持模拟登录回退 + // 登录方法 - 获取openId用于支付 async login() { try { // 获取微信登录code @@ -181,37 +185,80 @@ App({ }) }) + console.log('[App] 获取登录code成功') + try { - // 尝试发送code到服务器 - const res = await this.request('/api/wechat/login', { + // 发送code到服务器获取openId + const res = await this.request('/api/miniprogram/login', { method: 'POST', data: { code: loginRes.code } }) if (res.success && res.data) { - // 保存用户信息 - this.globalData.userInfo = res.data.user - this.globalData.isLoggedIn = true - this.globalData.purchasedSections = res.data.user.purchasedSections || [] - this.globalData.hasFullBook = res.data.user.hasFullBook || false + // 保存openId + if (res.data.openId) { + this.globalData.openId = res.data.openId + wx.setStorageSync('openId', res.data.openId) + console.log('[App] 获取openId成功') + } - wx.setStorageSync('userInfo', res.data.user) - wx.setStorageSync('token', res.data.token) + // 保存用户信息 + if (res.data.user) { + this.globalData.userInfo = res.data.user + this.globalData.isLoggedIn = true + this.globalData.purchasedSections = res.data.user.purchasedSections || [] + this.globalData.hasFullBook = res.data.user.hasFullBook || false + + wx.setStorageSync('userInfo', res.data.user) + wx.setStorageSync('token', res.data.token || '') + } return res.data } } catch (apiError) { - console.log('API登录失败,使用模拟登录:', apiError) + console.log('[App] API登录失败,使用模拟登录:', apiError.message) } // API不可用时使用模拟登录 return this.mockLogin() } catch (e) { - console.error('登录失败:', e) + console.error('[App] 登录失败:', e) // 最后尝试模拟登录 return this.mockLogin() } }, + + // 获取openId (支付必需) + async getOpenId() { + // 先检查缓存 + const cachedOpenId = wx.getStorageSync('openId') + if (cachedOpenId) { + this.globalData.openId = cachedOpenId + return cachedOpenId + } + + // 没有缓存则登录获取 + try { + const loginRes = await new Promise((resolve, reject) => { + wx.login({ success: resolve, fail: reject }) + }) + + const res = await this.request('/api/miniprogram/login', { + method: 'POST', + data: { code: loginRes.code } + }) + + if (res.success && res.data?.openId) { + this.globalData.openId = res.data.openId + wx.setStorageSync('openId', res.data.openId) + return res.data.openId + } + } catch (e) { + console.error('[App] 获取openId失败:', e) + } + + return null + }, // 模拟登录(后端不可用时使用) mockLogin() { diff --git a/miniprogram/pages/read/read.js b/miniprogram/pages/read/read.js index fd89cde..6db0cb5 100644 --- a/miniprogram/pages/read/read.js +++ b/miniprogram/pages/read/read.js @@ -361,65 +361,96 @@ ${id === 'preface' || id === 'epilogue' || id.startsWith('appendix') || id === ' await this.processPayment('fullbook', null, this.data.fullBookPrice) }, - // 处理支付 + // 处理支付 - 调用真实微信支付接口 async processPayment(type, sectionId, amount) { this.setData({ isPaying: true }) try { - // 创建订单 + // 1. 先获取openId (支付必需) + let openId = app.globalData.openId || wx.getStorageSync('openId') + + if (!openId) { + console.log('[Pay] 需要先获取openId') + openId = await app.getOpenId() + + if (!openId) { + wx.showModal({ + title: '提示', + content: '需要登录后才能支付,请先登录', + showCancel: false + }) + this.setData({ showLoginModal: true, isPaying: false }) + return + } + } + + console.log('[Pay] 开始创建订单:', { type, sectionId, amount, openId: openId.slice(0, 10) + '...' }) + + // 2. 调用后端创建预支付订单 let paymentData = null try { - const res = await app.request('/api/payment/create-order', { + const res = await app.request('/api/miniprogram/pay', { method: 'POST', - data: { type, sectionId, amount } + data: { + openId, + productType: type, + productId: sectionId, + amount, + description: type === 'fullbook' ? '《一场Soul的创业实验》全书' : `章节-${sectionId}`, + userId: app.globalData.userInfo?.id || '' + } }) - if (res.success && res.data) { - paymentData = res.data + console.log('[Pay] 创建订单响应:', res) + + if (res.success && res.data?.payParams) { + paymentData = res.data.payParams + console.log('[Pay] 获取支付参数成功') + } else { + throw new Error(res.error || '创建订单失败') } } catch (apiError) { - console.log('API创建订单失败,使用模拟支付') - } - - // 如果API不可用,使用模拟支付 - if (!paymentData) { - paymentData = { - isMock: true, - orderId: 'mock_' + Date.now() - } - } - - // 调用微信支付或模拟支付 - if (paymentData.isMock) { - // 模拟支付确认 - const confirmRes = await new Promise((resolve) => { + console.log('[Pay] API创建订单失败:', apiError.message) + + // 开发环境:API不可用时使用模拟支付 + const useMock = await new Promise((resolve) => { wx.showModal({ - title: '确认支付', - content: `支付 ¥${amount} 购买${type === 'section' ? '本章' : '全书'}?\n(测试环境模拟支付)`, + title: '支付服务暂不可用', + content: '是否使用测试模式完成购买?', + confirmText: '测试购买', + cancelText: '取消', success: (res) => resolve(res.confirm) }) }) - if (confirmRes) { - // 模拟支付成功,更新本地数据 + if (useMock) { this.mockPaymentSuccess(type, sectionId) - wx.showToast({ title: '购买成功', icon: 'success' }) + wx.showToast({ title: '测试购买成功', icon: 'success' }) + this.initSection(this.data.sectionId) } - } else { - // 真实微信支付 - await this.callWechatPay(paymentData) - wx.showToast({ title: '购买成功', icon: 'success' }) + this.setData({ isPaying: false }) + return } - // 刷新页面 + // 3. 调用微信支付 + console.log('[Pay] 调起微信支付') + await this.callWechatPay(paymentData) + + // 4. 支付成功,更新本地数据 + this.mockPaymentSuccess(type, sectionId) + wx.showToast({ title: '购买成功', icon: 'success' }) + + // 5. 刷新页面 this.initSection(this.data.sectionId) } catch (e) { + console.error('[Pay] 支付失败:', e) + if (e.errMsg && e.errMsg.includes('cancel')) { wx.showToast({ title: '已取消支付', icon: 'none' }) } else { - wx.showToast({ title: '支付失败', icon: 'none' }) + wx.showToast({ title: e.errMsg || '支付失败', icon: 'none' }) } } finally { this.setData({ isPaying: false })