import Router from '@koa/router'; import { Context } from 'koa'; import * as PaymentService from './payment.service'; import { BadRequestError } from '../../middleware/errorHandler'; import { authMiddleware } from '../../middleware/auth'; import { prisma } from '../../models'; import { safeParseInt } from '../../utils/safe-parse'; const router = new Router(); // 创建支付订单 router.post('/create', authMiddleware, async (ctx: Context) => { const userId = safeParseInt(ctx.state.user.userId); const { planId, paymentMethod, period = 'monthly', returnUrl } = ctx.request.body as { planId: number; paymentMethod: 'alipay' | 'wechat' | 'mock'; period?: 'monthly' | 'yearly'; returnUrl?: string; }; if (!planId) { throw new BadRequestError('请选择套餐'); } if (!['alipay', 'wechat', 'mock'].includes(paymentMethod)) { throw new BadRequestError('请选择支付方式'); } const result = await PaymentService.createPaymentOrder(userId, planId, paymentMethod, period, returnUrl); ctx.body = { code: 0, message: '订单创建成功', data: result }; }); // 创建 Token 包支付订单 router.post('/token-packs/create', authMiddleware, async (ctx: Context) => { const userId = safeParseInt(ctx.state.user.userId); const { quantity, paymentMethod, returnUrl } = ctx.request.body as { quantity: number; paymentMethod: 'alipay' | 'wechat' | 'mock'; returnUrl?: string; }; if (!quantity || quantity < 1) { throw new BadRequestError('购买数量至少为1'); } if (!['alipay', 'wechat', 'mock'].includes(paymentMethod)) { throw new BadRequestError('请选择支付方式'); } const result = await PaymentService.createTokenPackOrder(userId, quantity, paymentMethod, returnUrl); ctx.body = { code: 0, message: '订单创建成功', data: result }; }); // 模拟支付(仅开发环境) router.post('/mock', authMiddleware, async (ctx: Context) => { if (process.env.NODE_ENV === 'production') { throw new BadRequestError('生产环境不可用'); } const userId = safeParseInt(ctx.state.user.userId); const { orderNo } = ctx.request.body as { orderNo: string }; if (!orderNo) { throw new BadRequestError('订单号不能为空'); } const result = await PaymentService.mockPaymentSuccess(orderNo, userId); ctx.body = { code: 0, message: result.message, data: result }; }); // 支付宝异步通知回调 router.post('/alipay/notify', async (ctx: Context) => { const params = ctx.request.body as Record; console.log('[Alipay Notify] 收到异步通知:', params); // 验证签名 const signVerified = PaymentService.verifyAlipaySign(params); if (!signVerified) { console.error('[Alipay Notify] 签名验证失败'); ctx.status = 400; ctx.body = 'fail'; return; } const { out_trade_no, trade_status, trade_no } = params; try { if (trade_status === 'TRADE_SUCCESS' || trade_status === 'TRADE_FINISHED') { // 支付成功 await PaymentService.handlePaymentCallback(out_trade_no, trade_no, 'success'); console.log('[Alipay Notify] 订单支付成功:', out_trade_no); ctx.body = 'success'; } else if (trade_status === 'WAIT_BUYER_PAY') { // 等待买家付款 console.log('[Alipay Notify] 等待买家付款:', out_trade_no); ctx.body = 'success'; } else { // 其他状态视为失败 await PaymentService.handlePaymentCallback(out_trade_no, trade_no, 'failed'); console.log('[Alipay Notify] 订单支付失败:', out_trade_no, trade_status); ctx.body = 'success'; } } catch (error) { console.error('[Alipay Notify] 处理回调失败:', error); ctx.status = 500; ctx.body = 'fail'; } }); // 支付宝同步回调(用户从支付宝页面返回) router.get('/alipay/return', async (ctx: Context) => { const params = ctx.query as Record; console.log('[Alipay Return] 收到同步返回:', params); // 验证签名 const signVerified = PaymentService.verifyAlipaySign(params); if (!signVerified) { console.error('[Alipay Return] 签名验证失败'); // 重定向到支付结果页面(失败) const failUrl = `${process.env.ALIPAY_RETURN_URL || '/#/pages/payment-result/index'}?status=failed&error_msg=签名验证失败`; ctx.redirect(failUrl); return; } const { out_trade_no, trade_status } = params; // 根据支付状态重定向 if (trade_status === 'TRADE_SUCCESS' || trade_status === 'TRADE_FINISHED') { // 支付成功 const successUrl = `${process.env.ALIPAY_RETURN_URL || '/#/pages/payment-result/index'}?status=success&orderNo=${out_trade_no}&trade_status=${trade_status}`; ctx.redirect(successUrl); } else { // 支付未完成 const failUrl = `${process.env.ALIPAY_RETURN_URL || '/#/pages/payment-result/index'}?status=failed&orderNo=${out_trade_no}`; ctx.redirect(failUrl); } }); // 微信支付回调(V3 加密格式) router.post('/wechat/notify', async (ctx: Context) => { try { const body = ctx.request.body as any; console.log('[WeChat Notify] 收到回调:', JSON.stringify(body)); // WeChat V3 回调是加密的,需要先解密 resource.ciphertext const eventType = body.event_type; const decrypted = PaymentService.decryptWechatNotify(body); if (!decrypted) { console.error('[WeChat Notify] 回调解密失败,返回 500'); ctx.status = 500; ctx.body = { code: 'FAIL', message: '解密失败' }; return; } const { out_trade_no, transaction_id, trade_state } = decrypted; if (!out_trade_no) { console.error('[WeChat Notify] 解密后缺少 out_trade_no:', JSON.stringify(decrypted)); ctx.status = 400; ctx.body = { code: 'FAIL', message: '缺少订单号' }; return; } if (trade_state === 'SUCCESS') { await PaymentService.handlePaymentCallback(out_trade_no, transaction_id, 'success'); console.log('[WeChat Notify] 订单支付成功:', out_trade_no); } else { await PaymentService.handlePaymentCallback(out_trade_no, transaction_id, 'failed'); console.log('[WeChat Notify] 订单支付失败:', out_trade_no, trade_state); } ctx.body = { code: 'SUCCESS', message: '成功' }; } catch (error) { console.error('[WeChat Notify] 处理回调失败:', error); ctx.status = 500; ctx.body = { code: 'FAIL', message: '处理失败' }; } }); // 查询微信支付订单状态 router.get('/wechat/query/:orderNo', authMiddleware, async (ctx: Context) => { const { orderNo } = ctx.params; const result = await PaymentService.queryWechatOrder(orderNo); if (!result) { ctx.body = { code: -1, message: '未配置微信支付或查询失败', data: null }; return; } ctx.body = { code: 0, message: 'success', data: result }; }); // 获取订单列表 router.get('/orders', authMiddleware, async (ctx: Context) => { const userId = safeParseInt(ctx.state.user.userId); const { page = '1', pageSize = '20' } = ctx.query as { page?: string; pageSize?: string }; const result = await PaymentService.getOrderList( userId, Number(page) || 1, Number(pageSize) || 20 ); ctx.body = { code: 0, message: 'success', data: result }; }); // 获取订单详情(含主动同步微信支付状态) router.get('/orders/:orderNo', authMiddleware, async (ctx: Context) => { const userId = safeParseInt(ctx.state.user.userId); const { orderNo } = ctx.params; const result = await PaymentService.getOrderDetailWithSync(orderNo, userId); ctx.body = { code: 0, message: 'success', data: result }; }); // 生成支付宝扫码支付二维码 router.post('/alipay/qrcode', authMiddleware, async (ctx: Context) => { const userId = parseInt(ctx.state.user.userId); const { planId } = ctx.request.body as { planId: number }; if (!planId) { throw new BadRequestError('请选择套餐'); } // 获取套餐信息 const plan = await prisma.subscriptionPlan.findUnique({ where: { id: planId } }); if (!plan) { throw new BadRequestError('套餐不存在'); } // 生成订单 const orderNo = PaymentService.generateOrderNo(); const amount = Number(plan.priceMonthly); // 创建订单记录 await prisma.order.create({ data: { userId, orderNo, planId, productType: 'monthly', amount, status: 'pending', paymentMethod: 'alipay', } }); // 生成支付二维码链接 const qrcodeUrl = await PaymentService.generateAlipayQrcode(orderNo, amount, plan.name); ctx.body = { code: 0, message: 'success', data: { orderNo, amount, planName: plan.name, qrcode: qrcodeUrl } }; }); // ==================== 微信 JSAPI 支付(公众号内支付) ==================== // 🔍 微信支付配置诊断接口(一键排查所有常见配置问题) router.get('/wechat/diagnose', async (ctx: Context) => { const issues: string[] = []; const checks: Record = {}; const fs = require('fs'); const path = require('path'); // 1. 检查环境变量 const publicAppId = process.env.WECHAT_PUBLIC_APP_ID; const appId = process.env.WECHAT_APP_ID; const mchId = process.env.WECHAT_MCH_ID; const serialNo = process.env.WECHAT_SERIAL_NO; const apiv3Key = process.env.WECHAT_APIV3_KEY; const publicSecret = process.env.WECHAT_PUBLIC_APP_SECRET; const notifyUrl = process.env.WECHAT_NOTIFY_URL; checks['WECHAT_PUBLIC_APP_ID'] = publicAppId ? '✅' : '❌ 缺少(JSAPI必须用公众号appId)'; checks['WECHAT_MCH_ID'] = mchId ? '✅' : '❌ 缺少商户号'; checks['WECHAT_SERIAL_NO'] = serialNo ? '✅' : '❌ 缺少证书序列号'; checks['WECHAT_APIV3_KEY'] = apiv3Key ? '✅' : '❌ 缺少APIv3密钥'; checks['WECHAT_PUBLIC_APP_SECRET'] = publicSecret ? '✅' : '❌ 缺少(OAuth需要)'; checks['WECHAT_NOTIFY_URL'] = notifyUrl ? '✅' : '❌ 缺少回调URL'; // 2. 检查证书文件 const certDir = path.join(__dirname, '../../../cert/wx'); checks['证书目录'] = fs.existsSync(certDir) ? '✅' : '❌ 目录不存在'; if (fs.existsSync(certDir)) { checks['apiclient_key.pem'] = fs.existsSync(path.join(certDir, 'apiclient_key.pem')) ? '✅' : '❌ 缺失'; checks['pub_key.pem'] = fs.existsSync(path.join(certDir, 'pub_key.pem')) ? '✅' : '❌ 缺失(可能需要)'; } // 3. 汇总问题 if (!publicAppId) issues.push('缺少 WECHAT_PUBLIC_APP_ID(JSAPI支付必须用公众号appId)'); if (!mchId) issues.push('缺少 WECHAT_MCH_ID'); if (!apiv3Key) issues.push('缺少 WECHAT_APIV3_KEY'); if (!serialNo) issues.push('缺少 WECHAT_SERIAL_NO'); if (!publicSecret) issues.push('缺少 WECHAT_PUBLIC_APP_SECRET(OAuth授权获取openid必须)'); // 4. 检查关键平台配置(需要人工确认的) const domain = process.env.BASE_URL || `https://${ctx.request.hostname}`; issues.push(`🔔 请人工确认:微信商户平台 → 产品中心 → 开发配置 → JSAPI 支付 → 支付授权目录 → 添加 "${domain}/"`); issues.push(`🔔 请人工确认:公众号后台 → 设置与开发 → 功能设置 → 网页授权域名 → 添加 "${new URL(domain).hostname}"`); ctx.body = { code: 0, message: issues.length ? `发现 ${issues.length} 个需要注意的项` : '配置正常', data: { checks, issues, tips: [ '1. JSAPI 支付目录必须精确匹配页面 URL 前缀,建议直接配域名根目录', '2. 网页授权域名只需填域名(不带 http://),需上传验证文件到服务器', '3. IP白名单:微信商户平台 → 账户安全 → API安全 → 添加服务器出口IP', '4. timeStamp 必须是字符串类型(本代码已修复)', ] } }; }); // 生成微信 OAuth 授权链接(前端跳转到微信获取 code) router.get('/wechat/oauth-url', async (ctx: Context) => { let { redirect } = ctx.query as { redirect?: string }; // 修复HTML实体编码问题(Nginx或Koa可能把 / 编码成 /) if (redirect) { redirect = redirect.replace(//|//gi, '/').replace(/:|:/gi, ':'); } // OAuth授权必须使用公众号appId const appId = process.env.WECHAT_PUBLIC_APP_ID || process.env.WECHAT_APP_ID; if (!appId) { ctx.body = { code: -1, message: '未配置微信公众号', data: null }; return; } // 构造 OAuth 授权链接 // scope=snsapi_base 静默授权,不需要用户确认,但只能获取 openid const baseUrl = redirect || (process.env.BASE_URL || 'https://book.rrbrr.com'); const redirectUri = encodeURIComponent(baseUrl); const state = Math.random().toString(36).substring(2, 10); const oauthUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base&state=${state}#wechat_redirect`; console.log('[WeChat OAuth] 构造授权链接:', { appId, baseUrl, redirectUri: decodeURIComponent(redirectUri), oauthUrl }); ctx.body = { code: 0, message: 'success', data: { oauthUrl, state } }; }); // 通过 OAuth code 获取 openid router.post('/wechat/openid', async (ctx: Context) => { const { code } = ctx.request.body as { code: string }; if (!code) { ctx.body = { code: -1, message: '授权码不能为空', data: null }; return; } const openid = await PaymentService.getWechatOpenid(code); if (!openid) { ctx.body = { code: -1, message: '获取用户信息失败', data: null }; return; } ctx.body = { code: 0, message: 'success', data: { openid } }; }); // 获取微信 JS-SDK 签名(用于 wx.config) router.get('/wechat/jssdk-sign', async (ctx: Context) => { const { url } = ctx.query as { url?: string }; if (!url) { ctx.body = { code: -1, message: '缺少url参数', data: null }; return; } const signature = await PaymentService.generateJsSdkSignature(url); if (!signature) { ctx.body = { code: -1, message: '生成签名失败', data: null }; return; } ctx.body = { code: 0, message: 'success', data: signature }; }); // 创建微信 JSAPI 支付订单(公众号内支付) router.post('/wechat/jsapi', authMiddleware, async (ctx: Context) => { const userId = safeParseInt(ctx.state.user.userId); const rawBody = ctx.request.body as any; // ===== DEBUG: 完整请求日志 ===== console.log('[JSAPI-DEBUG] ========== 收到JSAPI支付请求 =========='); console.log('[JSAPI-DEBUG] userId:', userId); console.log('[JSAPI-DEBUG] body keys:', Object.keys(rawBody)); console.log('[JSAPI-DEBUG] body:', JSON.stringify(rawBody, null, 2)); console.log('[JSAPI-DEBUG] headers:', JSON.stringify({ 'content-type': ctx.request.headers['content-type'], 'user-agent': ctx.request.headers['user-agent'], 'authorization': ctx.request.headers['authorization'] ? '***' : 'none', })); const { planId, productType, quantity, period = 'monthly', openid } = rawBody as { planId?: number; productType?: string; quantity?: number; period?: 'monthly' | 'yearly'; openid: string; }; console.log('[JSAPI-DEBUG] 解析后参数:', JSON.stringify({ planId, productType, quantity, period, openid: openid ? openid.substring(0, 8) + '...' : 'null' })); if (!openid) { console.log('[JSAPI-DEBUG] ❌ 缺少openid'); throw new BadRequestError('缺少用户标识(openid),请在微信中打开'); } // Token 包支付 if (productType === 'token-pack' && quantity) { const orderNo = PaymentService.generateOrderNo(); const packPrice = 4.80; // 100分钟×0.048 const amount = Math.round(packPrice * quantity * 100) / 100; const subject = `${quantity}包积分包(共${100 * quantity}分钟)`; await prisma.order.create({ data: { userId, orderNo, planId: null, productType: 'token-pack', amount, status: 'pending', paymentMethod: 'wechat', } }); const payParams = await PaymentService.generateWechatJsapiPayment(orderNo, amount, subject, openid); console.log('[JSAPI-DEBUG] ✅ Token包支付创建成功, payParams keys:', Object.keys(payParams || {})); console.log('[JSAPI-DEBUG] 返回前端数据:', JSON.stringify({ orderNo, amount, payParams: { ...payParams, paySign: payParams?.paySign?.substring(0, 20) + '...' } })); ctx.body = { code: 0, message: '订单创建成功', data: { orderNo, amount, planName: subject, payParams } }; return; } if (!planId) { throw new BadRequestError('请选择套餐'); } // 获取套餐信息 const plan = await prisma.subscriptionPlan.findUnique({ where: { id: planId } }); if (!plan) { throw new BadRequestError('套餐不存在'); } // 生成订单 const orderNo = PaymentService.generateOrderNo(); const amount = period === 'yearly' ? Number(plan.priceYearly) : Number(plan.priceMonthly); // 创建订单记录 await prisma.order.create({ data: { userId, orderNo, planId, productType: period, amount, status: 'pending', paymentMethod: 'wechat', } }); // 调用微信 JSAPI 支付 const payParams = await PaymentService.generateWechatJsapiPayment( orderNo, amount, plan.name, openid ); console.log('[JSAPI-DEBUG] ✅ 套餐支付创建成功, payParams keys:', Object.keys(payParams || {})); console.log('[JSAPI-DEBUG] 返回前端数据:', JSON.stringify({ orderNo, amount, planName: plan.name, payParams: { ...payParams, paySign: payParams?.paySign?.substring(0, 20) + '...' } })); ctx.body = { code: 0, message: '订单创建成功', data: { orderNo, amount, planName: plan.name, payParams // 用于 wx.chooseWXPay 的参数 } }; }); export default router;