payment.controller.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  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. // 生成微信 OAuth 授权链接(前端跳转到微信获取 code)
  252. router.get('/wechat/oauth-url', async (ctx: Context) => {
  253. let { redirect } = ctx.query as { redirect?: string };
  254. // 修复HTML实体编码问题(Nginx或Koa可能把 / 编码成 &#x2F;)
  255. if (redirect) {
  256. redirect = redirect.replace(/&#x2F;|&#x2f;/gi, '/').replace(/&#x3A;|&#x3a;/gi, ':');
  257. }
  258. // OAuth授权必须使用公众号appId
  259. const appId = process.env.WECHAT_PUBLIC_APP_ID || process.env.WECHAT_APP_ID;
  260. if (!appId) {
  261. ctx.body = { code: -1, message: '未配置微信公众号', data: null };
  262. return;
  263. }
  264. // 构造 OAuth 授权链接
  265. // scope=snsapi_base 静默授权,不需要用户确认,但只能获取 openid
  266. const baseUrl = redirect || (process.env.BASE_URL || 'https://book.rrbrr.com');
  267. const redirectUri = encodeURIComponent(baseUrl);
  268. const state = Math.random().toString(36).substring(2, 10);
  269. 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`;
  270. console.log('[WeChat OAuth] 构造授权链接:', { appId, baseUrl, redirectUri: decodeURIComponent(redirectUri), oauthUrl });
  271. ctx.body = {
  272. code: 0,
  273. message: 'success',
  274. data: { oauthUrl, state }
  275. };
  276. });
  277. // 通过 OAuth code 获取 openid
  278. router.post('/wechat/openid', async (ctx: Context) => {
  279. const { code } = ctx.request.body as { code: string };
  280. if (!code) {
  281. ctx.body = { code: -1, message: '授权码不能为空', data: null };
  282. return;
  283. }
  284. const openid = await PaymentService.getWechatOpenid(code);
  285. if (!openid) {
  286. ctx.body = { code: -1, message: '获取用户信息失败', data: null };
  287. return;
  288. }
  289. ctx.body = {
  290. code: 0,
  291. message: 'success',
  292. data: { openid }
  293. };
  294. });
  295. // 获取微信 JS-SDK 签名(用于 wx.config)
  296. router.get('/wechat/jssdk-sign', async (ctx: Context) => {
  297. const { url } = ctx.query as { url?: string };
  298. if (!url) {
  299. ctx.body = { code: -1, message: '缺少url参数', data: null };
  300. return;
  301. }
  302. const signature = await PaymentService.generateJsSdkSignature(url);
  303. if (!signature) {
  304. ctx.body = { code: -1, message: '生成签名失败', data: null };
  305. return;
  306. }
  307. ctx.body = {
  308. code: 0,
  309. message: 'success',
  310. data: signature
  311. };
  312. });
  313. // 创建微信 JSAPI 支付订单(公众号内支付)
  314. router.post('/wechat/jsapi', authMiddleware, async (ctx: Context) => {
  315. const userId = safeParseInt(ctx.state.user.userId);
  316. const rawBody = ctx.request.body as any;
  317. // ===== DEBUG: 完整请求日志 =====
  318. console.log('[JSAPI-DEBUG] ========== 收到JSAPI支付请求 ==========');
  319. console.log('[JSAPI-DEBUG] userId:', userId);
  320. console.log('[JSAPI-DEBUG] body keys:', Object.keys(rawBody));
  321. console.log('[JSAPI-DEBUG] body:', JSON.stringify(rawBody, null, 2));
  322. console.log('[JSAPI-DEBUG] headers:', JSON.stringify({
  323. 'content-type': ctx.request.headers['content-type'],
  324. 'user-agent': ctx.request.headers['user-agent'],
  325. 'authorization': ctx.request.headers['authorization'] ? '***' : 'none',
  326. }));
  327. const { planId, productType, quantity, period = 'monthly', openid } = rawBody as {
  328. planId?: number;
  329. productType?: string;
  330. quantity?: number;
  331. period?: 'monthly' | 'yearly';
  332. openid: string;
  333. };
  334. console.log('[JSAPI-DEBUG] 解析后参数:', JSON.stringify({ planId, productType, quantity, period, openid: openid ? openid.substring(0, 8) + '...' : 'null' }));
  335. if (!openid) {
  336. console.log('[JSAPI-DEBUG] ❌ 缺少openid');
  337. throw new BadRequestError('缺少用户标识(openid),请在微信中打开');
  338. }
  339. // Token 包支付
  340. if (productType === 'token-pack' && quantity) {
  341. const orderNo = PaymentService.generateOrderNo();
  342. const packPrice = 4.80; // 100分钟×0.048
  343. const amount = Math.round(packPrice * quantity * 100) / 100;
  344. const subject = `${quantity}包积分包(共${100 * quantity}分钟)`;
  345. await prisma.order.create({
  346. data: {
  347. userId, orderNo, planId: null,
  348. productType: 'token-pack', amount,
  349. status: 'pending', paymentMethod: 'wechat',
  350. }
  351. });
  352. const payParams = await PaymentService.generateWechatJsapiPayment(orderNo, amount, subject, openid);
  353. console.log('[JSAPI-DEBUG] ✅ Token包支付创建成功, payParams keys:', Object.keys(payParams || {}));
  354. console.log('[JSAPI-DEBUG] 返回前端数据:', JSON.stringify({ orderNo, amount, payParams: { ...payParams, paySign: payParams?.paySign?.substring(0, 20) + '...' } }));
  355. ctx.body = {
  356. code: 0, message: '订单创建成功',
  357. data: { orderNo, amount, planName: subject, payParams }
  358. };
  359. return;
  360. }
  361. if (!planId) {
  362. throw new BadRequestError('请选择套餐');
  363. }
  364. // 获取套餐信息
  365. const plan = await prisma.subscriptionPlan.findUnique({
  366. where: { id: planId }
  367. });
  368. if (!plan) {
  369. throw new BadRequestError('套餐不存在');
  370. }
  371. // 生成订单
  372. const orderNo = PaymentService.generateOrderNo();
  373. const amount = period === 'yearly' ? Number(plan.priceYearly) : Number(plan.priceMonthly);
  374. // 创建订单记录
  375. await prisma.order.create({
  376. data: {
  377. userId,
  378. orderNo,
  379. planId,
  380. productType: period,
  381. amount,
  382. status: 'pending',
  383. paymentMethod: 'wechat',
  384. }
  385. });
  386. // 调用微信 JSAPI 支付
  387. const payParams = await PaymentService.generateWechatJsapiPayment(
  388. orderNo,
  389. amount,
  390. plan.name,
  391. openid
  392. );
  393. console.log('[JSAPI-DEBUG] ✅ 套餐支付创建成功, payParams keys:', Object.keys(payParams || {}));
  394. console.log('[JSAPI-DEBUG] 返回前端数据:', JSON.stringify({ orderNo, amount, planName: plan.name, payParams: { ...payParams, paySign: payParams?.paySign?.substring(0, 20) + '...' } }));
  395. ctx.body = {
  396. code: 0,
  397. message: '订单创建成功',
  398. data: {
  399. orderNo,
  400. amount,
  401. planName: plan.name,
  402. payParams // 用于 wx.chooseWXPay 的参数
  403. }
  404. };
  405. });
  406. export default router;