payment.controller.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. import Router from '@koa/router';
  2. import { Context } from 'koa';
  3. import * as PaymentService from './payment.service';
  4. import { BadRequestError } from '../../middleware/errorHandler';
  5. import { authMiddleware } from '../../middleware/auth';
  6. import { prisma } from '../../models';
  7. import { safeParseInt } from '../../utils/safe-parse';
  8. const router = new Router();
  9. // 创建支付订单
  10. router.post('/create', authMiddleware, async (ctx: Context) => {
  11. const userId = safeParseInt(ctx.state.user.userId);
  12. const { planId, paymentMethod, period = 'monthly', returnUrl } = ctx.request.body as {
  13. planId: number;
  14. paymentMethod: 'alipay' | 'wechat' | 'mock';
  15. period?: 'monthly' | 'yearly';
  16. returnUrl?: string;
  17. };
  18. if (!planId) {
  19. throw new BadRequestError('请选择套餐');
  20. }
  21. if (!['alipay', 'wechat', 'mock'].includes(paymentMethod)) {
  22. throw new BadRequestError('请选择支付方式');
  23. }
  24. const result = await PaymentService.createPaymentOrder(userId, planId, paymentMethod, period, returnUrl);
  25. ctx.body = {
  26. code: 0,
  27. message: '订单创建成功',
  28. data: result
  29. };
  30. });
  31. // 创建 Token 包支付订单
  32. router.post('/token-packs/create', authMiddleware, async (ctx: Context) => {
  33. const userId = safeParseInt(ctx.state.user.userId);
  34. const { quantity, paymentMethod, returnUrl } = ctx.request.body as {
  35. quantity: number;
  36. paymentMethod: 'alipay' | 'wechat' | 'mock';
  37. returnUrl?: string;
  38. };
  39. if (!quantity || quantity < 1) {
  40. throw new BadRequestError('购买数量至少为1');
  41. }
  42. if (!['alipay', 'wechat', 'mock'].includes(paymentMethod)) {
  43. throw new BadRequestError('请选择支付方式');
  44. }
  45. const result = await PaymentService.createTokenPackOrder(userId, quantity, paymentMethod, returnUrl);
  46. ctx.body = {
  47. code: 0,
  48. message: '订单创建成功',
  49. data: result
  50. };
  51. });
  52. // 模拟支付(仅开发环境)
  53. router.post('/mock', authMiddleware, async (ctx: Context) => {
  54. if (process.env.NODE_ENV === 'production') {
  55. throw new BadRequestError('生产环境不可用');
  56. }
  57. const userId = safeParseInt(ctx.state.user.userId);
  58. const { orderNo } = ctx.request.body as { orderNo: string };
  59. if (!orderNo) {
  60. throw new BadRequestError('订单号不能为空');
  61. }
  62. const result = await PaymentService.mockPaymentSuccess(orderNo, userId);
  63. ctx.body = {
  64. code: 0,
  65. message: result.message,
  66. data: result
  67. };
  68. });
  69. // 支付宝异步通知回调
  70. router.post('/alipay/notify', async (ctx: Context) => {
  71. const params = ctx.request.body as Record<string, string>;
  72. console.log('[Alipay Notify] 收到异步通知:', params);
  73. // 验证签名
  74. const signVerified = PaymentService.verifyAlipaySign(params);
  75. if (!signVerified) {
  76. console.error('[Alipay Notify] 签名验证失败');
  77. ctx.status = 400;
  78. ctx.body = 'fail';
  79. return;
  80. }
  81. const { out_trade_no, trade_status, trade_no } = params;
  82. try {
  83. if (trade_status === 'TRADE_SUCCESS' || trade_status === 'TRADE_FINISHED') {
  84. // 支付成功
  85. await PaymentService.handlePaymentCallback(out_trade_no, trade_no, 'success');
  86. console.log('[Alipay Notify] 订单支付成功:', out_trade_no);
  87. ctx.body = 'success';
  88. } else if (trade_status === 'WAIT_BUYER_PAY') {
  89. // 等待买家付款
  90. console.log('[Alipay Notify] 等待买家付款:', out_trade_no);
  91. ctx.body = 'success';
  92. } else {
  93. // 其他状态视为失败
  94. await PaymentService.handlePaymentCallback(out_trade_no, trade_no, 'failed');
  95. console.log('[Alipay Notify] 订单支付失败:', out_trade_no, trade_status);
  96. ctx.body = 'success';
  97. }
  98. } catch (error) {
  99. console.error('[Alipay Notify] 处理回调失败:', error);
  100. ctx.status = 500;
  101. ctx.body = 'fail';
  102. }
  103. });
  104. // 支付宝同步回调(用户从支付宝页面返回)
  105. router.get('/alipay/return', async (ctx: Context) => {
  106. const params = ctx.query as Record<string, string>;
  107. console.log('[Alipay Return] 收到同步返回:', params);
  108. // 验证签名
  109. const signVerified = PaymentService.verifyAlipaySign(params);
  110. if (!signVerified) {
  111. console.error('[Alipay Return] 签名验证失败');
  112. // 重定向到支付结果页面(失败)
  113. const failUrl = `${process.env.ALIPAY_RETURN_URL || '/#/pages/payment-result/index'}?status=failed&error_msg=签名验证失败`;
  114. ctx.redirect(failUrl);
  115. return;
  116. }
  117. const { out_trade_no, trade_status } = params;
  118. // 根据支付状态重定向
  119. if (trade_status === 'TRADE_SUCCESS' || trade_status === 'TRADE_FINISHED') {
  120. // 支付成功
  121. const successUrl = `${process.env.ALIPAY_RETURN_URL || '/#/pages/payment-result/index'}?status=success&orderNo=${out_trade_no}&trade_status=${trade_status}`;
  122. ctx.redirect(successUrl);
  123. } else {
  124. // 支付未完成
  125. const failUrl = `${process.env.ALIPAY_RETURN_URL || '/#/pages/payment-result/index'}?status=failed&orderNo=${out_trade_no}`;
  126. ctx.redirect(failUrl);
  127. }
  128. });
  129. // 微信支付回调(V3 加密格式)
  130. router.post('/wechat/notify', async (ctx: Context) => {
  131. try {
  132. const body = ctx.request.body as any;
  133. console.log('[WeChat Notify] 收到回调:', JSON.stringify(body));
  134. // WeChat V3 回调是加密的,需要先解密 resource.ciphertext
  135. const eventType = body.event_type;
  136. const decrypted = PaymentService.decryptWechatNotify(body);
  137. if (!decrypted) {
  138. console.error('[WeChat Notify] 回调解密失败,返回 500');
  139. ctx.status = 500;
  140. ctx.body = { code: 'FAIL', message: '解密失败' };
  141. return;
  142. }
  143. const { out_trade_no, transaction_id, trade_state } = decrypted;
  144. if (!out_trade_no) {
  145. console.error('[WeChat Notify] 解密后缺少 out_trade_no:', JSON.stringify(decrypted));
  146. ctx.status = 400;
  147. ctx.body = { code: 'FAIL', message: '缺少订单号' };
  148. return;
  149. }
  150. if (trade_state === 'SUCCESS') {
  151. await PaymentService.handlePaymentCallback(out_trade_no, transaction_id, 'success');
  152. console.log('[WeChat Notify] 订单支付成功:', out_trade_no);
  153. } else {
  154. await PaymentService.handlePaymentCallback(out_trade_no, transaction_id, 'failed');
  155. console.log('[WeChat Notify] 订单支付失败:', out_trade_no, trade_state);
  156. }
  157. ctx.body = { code: 'SUCCESS', message: '成功' };
  158. } catch (error) {
  159. console.error('[WeChat Notify] 处理回调失败:', error);
  160. ctx.status = 500;
  161. ctx.body = { code: 'FAIL', message: '处理失败' };
  162. }
  163. });
  164. // 查询微信支付订单状态
  165. router.get('/wechat/query/:orderNo', authMiddleware, async (ctx: Context) => {
  166. const { orderNo } = ctx.params;
  167. const result = await PaymentService.queryWechatOrder(orderNo);
  168. if (!result) {
  169. ctx.body = {
  170. code: -1,
  171. message: '未配置微信支付或查询失败',
  172. data: null
  173. };
  174. return;
  175. }
  176. ctx.body = {
  177. code: 0,
  178. message: 'success',
  179. data: result
  180. };
  181. });
  182. // 获取订单列表
  183. router.get('/orders', authMiddleware, async (ctx: Context) => {
  184. const userId = safeParseInt(ctx.state.user.userId);
  185. const { page = '1', pageSize = '20' } = ctx.query as { page?: string; pageSize?: string };
  186. const result = await PaymentService.getOrderList(
  187. userId,
  188. Number(page) || 1,
  189. Number(pageSize) || 20
  190. );
  191. ctx.body = {
  192. code: 0,
  193. message: 'success',
  194. data: result
  195. };
  196. });
  197. // 获取订单详情(含主动同步微信支付状态)
  198. router.get('/orders/:orderNo', authMiddleware, async (ctx: Context) => {
  199. const userId = safeParseInt(ctx.state.user.userId);
  200. const { orderNo } = ctx.params;
  201. const result = await PaymentService.getOrderDetailWithSync(orderNo, userId);
  202. ctx.body = {
  203. code: 0,
  204. message: 'success',
  205. data: result
  206. };
  207. });
  208. // 生成支付宝扫码支付二维码
  209. router.post('/alipay/qrcode', authMiddleware, async (ctx: Context) => {
  210. const userId = parseInt(ctx.state.user.userId);
  211. const { planId } = ctx.request.body as { planId: number };
  212. if (!planId) {
  213. throw new BadRequestError('请选择套餐');
  214. }
  215. // 获取套餐信息
  216. const plan = await prisma.subscriptionPlan.findUnique({
  217. where: { id: planId }
  218. });
  219. if (!plan) {
  220. throw new BadRequestError('套餐不存在');
  221. }
  222. // 生成订单
  223. const orderNo = PaymentService.generateOrderNo();
  224. const amount = Number(plan.priceMonthly);
  225. // 创建订单记录
  226. await prisma.order.create({
  227. data: {
  228. userId,
  229. orderNo,
  230. planId,
  231. productType: 'monthly',
  232. amount,
  233. status: 'pending',
  234. paymentMethod: 'alipay',
  235. }
  236. });
  237. // 生成支付二维码链接
  238. const qrcodeUrl = await PaymentService.generateAlipayQrcode(orderNo, amount, plan.name);
  239. ctx.body = {
  240. code: 0,
  241. message: 'success',
  242. data: {
  243. orderNo,
  244. amount,
  245. planName: plan.name,
  246. qrcode: qrcodeUrl
  247. }
  248. };
  249. });
  250. // ==================== 微信 JSAPI 支付(公众号内支付) ====================
  251. // 🔍 微信支付配置诊断接口(一键排查所有常见配置问题)
  252. router.get('/wechat/diagnose', async (ctx: Context) => {
  253. const issues: string[] = [];
  254. const checks: Record<string, any> = {};
  255. const fs = require('fs');
  256. const path = require('path');
  257. // 1. 检查环境变量
  258. const publicAppId = process.env.WECHAT_PUBLIC_APP_ID;
  259. const appId = process.env.WECHAT_APP_ID;
  260. const mchId = process.env.WECHAT_MCH_ID;
  261. const serialNo = process.env.WECHAT_SERIAL_NO;
  262. const apiv3Key = process.env.WECHAT_APIV3_KEY;
  263. const publicSecret = process.env.WECHAT_PUBLIC_APP_SECRET;
  264. const notifyUrl = process.env.WECHAT_NOTIFY_URL;
  265. checks['WECHAT_PUBLIC_APP_ID'] = publicAppId ? '✅' : '❌ 缺少(JSAPI必须用公众号appId)';
  266. checks['WECHAT_MCH_ID'] = mchId ? '✅' : '❌ 缺少商户号';
  267. checks['WECHAT_SERIAL_NO'] = serialNo ? '✅' : '❌ 缺少证书序列号';
  268. checks['WECHAT_APIV3_KEY'] = apiv3Key ? '✅' : '❌ 缺少APIv3密钥';
  269. checks['WECHAT_PUBLIC_APP_SECRET'] = publicSecret ? '✅' : '❌ 缺少(OAuth需要)';
  270. checks['WECHAT_NOTIFY_URL'] = notifyUrl ? '✅' : '❌ 缺少回调URL';
  271. // 2. 检查证书文件
  272. const certDir = path.join(__dirname, '../../../cert/wx');
  273. checks['证书目录'] = fs.existsSync(certDir) ? '✅' : '❌ 目录不存在';
  274. if (fs.existsSync(certDir)) {
  275. checks['apiclient_key.pem'] = fs.existsSync(path.join(certDir, 'apiclient_key.pem')) ? '✅' : '❌ 缺失';
  276. checks['pub_key.pem'] = fs.existsSync(path.join(certDir, 'pub_key.pem')) ? '✅' : '❌ 缺失(可能需要)';
  277. }
  278. // 3. 汇总问题
  279. if (!publicAppId) issues.push('缺少 WECHAT_PUBLIC_APP_ID(JSAPI支付必须用公众号appId)');
  280. if (!mchId) issues.push('缺少 WECHAT_MCH_ID');
  281. if (!apiv3Key) issues.push('缺少 WECHAT_APIV3_KEY');
  282. if (!serialNo) issues.push('缺少 WECHAT_SERIAL_NO');
  283. if (!publicSecret) issues.push('缺少 WECHAT_PUBLIC_APP_SECRET(OAuth授权获取openid必须)');
  284. // 4. 检查关键平台配置(需要人工确认的)
  285. const domain = process.env.BASE_URL || `https://${ctx.request.hostname}`;
  286. issues.push(`🔔 请人工确认:微信商户平台 → 产品中心 → 开发配置 → JSAPI 支付 → 支付授权目录 → 添加 "${domain}/"`);
  287. issues.push(`🔔 请人工确认:公众号后台 → 设置与开发 → 功能设置 → 网页授权域名 → 添加 "${new URL(domain).hostname}"`);
  288. ctx.body = {
  289. code: 0,
  290. message: issues.length ? `发现 ${issues.length} 个需要注意的项` : '配置正常',
  291. data: {
  292. checks,
  293. issues,
  294. tips: [
  295. '1. JSAPI 支付目录必须精确匹配页面 URL 前缀,建议直接配域名根目录',
  296. '2. 网页授权域名只需填域名(不带 http://),需上传验证文件到服务器',
  297. '3. IP白名单:微信商户平台 → 账户安全 → API安全 → 添加服务器出口IP',
  298. '4. timeStamp 必须是字符串类型(本代码已修复)',
  299. ]
  300. }
  301. };
  302. });
  303. // 生成微信 OAuth 授权链接(前端跳转到微信获取 code)
  304. router.get('/wechat/oauth-url', async (ctx: Context) => {
  305. let { redirect } = ctx.query as { redirect?: string };
  306. // 修复HTML实体编码问题(Nginx或Koa可能把 / 编码成 &#x2F;)
  307. if (redirect) {
  308. redirect = redirect.replace(/&#x2F;|&#x2f;/gi, '/').replace(/&#x3A;|&#x3a;/gi, ':');
  309. }
  310. // OAuth授权必须使用公众号appId
  311. const appId = process.env.WECHAT_PUBLIC_APP_ID || process.env.WECHAT_APP_ID;
  312. if (!appId) {
  313. ctx.body = { code: -1, message: '未配置微信公众号', data: null };
  314. return;
  315. }
  316. // 构造 OAuth 授权链接
  317. // scope=snsapi_base 静默授权,不需要用户确认,但只能获取 openid
  318. const baseUrl = redirect || (process.env.BASE_URL || 'https://book.rrbrr.com');
  319. const redirectUri = encodeURIComponent(baseUrl);
  320. const state = Math.random().toString(36).substring(2, 10);
  321. 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`;
  322. console.log('[WeChat OAuth] 构造授权链接:', { appId, baseUrl, redirectUri: decodeURIComponent(redirectUri), oauthUrl });
  323. ctx.body = {
  324. code: 0,
  325. message: 'success',
  326. data: { oauthUrl, state }
  327. };
  328. });
  329. // 通过 OAuth code 获取 openid
  330. router.post('/wechat/openid', async (ctx: Context) => {
  331. const { code } = ctx.request.body as { code: string };
  332. if (!code) {
  333. ctx.body = { code: -1, message: '授权码不能为空', data: null };
  334. return;
  335. }
  336. const openid = await PaymentService.getWechatOpenid(code);
  337. if (!openid) {
  338. ctx.body = { code: -1, message: '获取用户信息失败', data: null };
  339. return;
  340. }
  341. ctx.body = {
  342. code: 0,
  343. message: 'success',
  344. data: { openid }
  345. };
  346. });
  347. // 获取微信 JS-SDK 签名(用于 wx.config)
  348. router.get('/wechat/jssdk-sign', async (ctx: Context) => {
  349. const { url } = ctx.query as { url?: string };
  350. if (!url) {
  351. ctx.body = { code: -1, message: '缺少url参数', data: null };
  352. return;
  353. }
  354. const signature = await PaymentService.generateJsSdkSignature(url);
  355. if (!signature) {
  356. ctx.body = { code: -1, message: '生成签名失败', data: null };
  357. return;
  358. }
  359. ctx.body = {
  360. code: 0,
  361. message: 'success',
  362. data: signature
  363. };
  364. });
  365. // 创建微信 JSAPI 支付订单(公众号内支付)
  366. router.post('/wechat/jsapi', authMiddleware, async (ctx: Context) => {
  367. const userId = safeParseInt(ctx.state.user.userId);
  368. const rawBody = ctx.request.body as any;
  369. // ===== DEBUG: 完整请求日志 =====
  370. console.log('[JSAPI-DEBUG] ========== 收到JSAPI支付请求 ==========');
  371. console.log('[JSAPI-DEBUG] userId:', userId);
  372. console.log('[JSAPI-DEBUG] body keys:', Object.keys(rawBody));
  373. console.log('[JSAPI-DEBUG] body:', JSON.stringify(rawBody, null, 2));
  374. console.log('[JSAPI-DEBUG] headers:', JSON.stringify({
  375. 'content-type': ctx.request.headers['content-type'],
  376. 'user-agent': ctx.request.headers['user-agent'],
  377. 'authorization': ctx.request.headers['authorization'] ? '***' : 'none',
  378. }));
  379. const { planId, productType, quantity, period = 'monthly', openid } = rawBody as {
  380. planId?: number;
  381. productType?: string;
  382. quantity?: number;
  383. period?: 'monthly' | 'yearly';
  384. openid: string;
  385. };
  386. console.log('[JSAPI-DEBUG] 解析后参数:', JSON.stringify({ planId, productType, quantity, period, openid: openid ? openid.substring(0, 8) + '...' : 'null' }));
  387. if (!openid) {
  388. console.log('[JSAPI-DEBUG] ❌ 缺少openid');
  389. throw new BadRequestError('缺少用户标识(openid),请在微信中打开');
  390. }
  391. // Token 包支付
  392. if (productType === 'token-pack' && quantity) {
  393. const orderNo = PaymentService.generateOrderNo();
  394. const packPrice = 4.80; // 100分钟×0.048
  395. const amount = Math.round(packPrice * quantity * 100) / 100;
  396. const subject = `${quantity}包积分包(共${100 * quantity}分钟)`;
  397. await prisma.order.create({
  398. data: {
  399. userId, orderNo, planId: null,
  400. productType: 'token-pack', amount,
  401. status: 'pending', paymentMethod: 'wechat',
  402. }
  403. });
  404. const payParams = await PaymentService.generateWechatJsapiPayment(orderNo, amount, subject, openid);
  405. console.log('[JSAPI-DEBUG] ✅ Token包支付创建成功, payParams keys:', Object.keys(payParams || {}));
  406. console.log('[JSAPI-DEBUG] 返回前端数据:', JSON.stringify({ orderNo, amount, payParams: { ...payParams, paySign: payParams?.paySign?.substring(0, 20) + '...' } }));
  407. ctx.body = {
  408. code: 0, message: '订单创建成功',
  409. data: { orderNo, amount, planName: subject, payParams }
  410. };
  411. return;
  412. }
  413. if (!planId) {
  414. throw new BadRequestError('请选择套餐');
  415. }
  416. // 获取套餐信息
  417. const plan = await prisma.subscriptionPlan.findUnique({
  418. where: { id: planId }
  419. });
  420. if (!plan) {
  421. throw new BadRequestError('套餐不存在');
  422. }
  423. // 生成订单
  424. const orderNo = PaymentService.generateOrderNo();
  425. const amount = period === 'yearly' ? Number(plan.priceYearly) : Number(plan.priceMonthly);
  426. // 创建订单记录
  427. await prisma.order.create({
  428. data: {
  429. userId,
  430. orderNo,
  431. planId,
  432. productType: period,
  433. amount,
  434. status: 'pending',
  435. paymentMethod: 'wechat',
  436. }
  437. });
  438. // 调用微信 JSAPI 支付
  439. const payParams = await PaymentService.generateWechatJsapiPayment(
  440. orderNo,
  441. amount,
  442. plan.name,
  443. openid
  444. );
  445. console.log('[JSAPI-DEBUG] ✅ 套餐支付创建成功, payParams keys:', Object.keys(payParams || {}));
  446. console.log('[JSAPI-DEBUG] 返回前端数据:', JSON.stringify({ orderNo, amount, planName: plan.name, payParams: { ...payParams, paySign: payParams?.paySign?.substring(0, 20) + '...' } }));
  447. ctx.body = {
  448. code: 0,
  449. message: '订单创建成功',
  450. data: {
  451. orderNo,
  452. amount,
  453. planName: plan.name,
  454. payParams // 用于 wx.chooseWXPay 的参数
  455. }
  456. };
  457. });
  458. export default router;