import { Controller, Post, Get, Param, Body, Query, UseGuards, HttpException, HttpStatus, Logger, Req, HttpCode } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' import { User, UserDocument } from '../user/user.schema' import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema' import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard' import { CurrentUser } from '../../common/decorators/current-user.decorator' import { VirtualPaymentService } from './virtual-payment.service' import { PricingService } from '../schemas/pricing.service' import { QuotaService } from '../user/quota.service' import { Public } from '../../common/decorators/public.decorator' @Controller('virtual-payment') export class VirtualPaymentController { private readonly logger = new Logger(VirtualPaymentController.name) constructor( @InjectModel(User.name) private userModel: Model, @InjectModel(PaymentOrder.name) private orderModel: Model, private vpService: VirtualPaymentService, private pricingService: PricingService, private quotaService: QuotaService, ) {} /** * 创建虚拟支付订单(小程序代币充值) * 返回前端调起 wx.requestVirtualPayment 所需的全部参数 */ @UseGuards(JwtAuthGuard) @Post('create') @HttpCode(200) async create( @CurrentUser('userId') userId: string, @Body('type') type: string, @Body('quantity') quantity: number = 1, @Body('wxCode') wxCode: string, @Req() req: any, ) { if (!['interview', 'optimize', 'download', 'growth', 'sprint'].includes(type)) { throw new HttpException('无效产品类型', HttpStatus.BAD_REQUEST) } const user = await this.userModel.findById(userId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) if (!user.wxOpenid) { throw new HttpException({ message: '未绑定微信', needBindWx: true }, HttpStatus.BAD_REQUEST) } if (!wxCode) { throw new HttpException('缺少 wxCode,请先调用 wx.login()', HttpStatus.BAD_REQUEST) } const isPlan = type === 'growth' || type === 'sprint' if (isPlan && user.plan !== 'free') { throw new HttpException('已是会员', HttpStatus.BAD_REQUEST) } const pricing = await this.pricingService.getConfig() let totalFee: number let qty = 1 let productQty = Math.max(1, Math.min(99, quantity || 1)) if (isPlan) { const planCfg = pricing.plans[type] if (!planCfg) throw new HttpException('套餐未配置', HttpStatus.INTERNAL_SERVER_ERROR) totalFee = planCfg.price } else { const priceMap: Record = { interview: pricing.interview.pricePerSession, optimize: pricing.resumeOptimize.pricePerOptimize, download: pricing.resumeDownload.pricePerDownload, } qty = productQty totalFee = priceMap[type] * qty if (!totalFee) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR) } const mode = 'short_series_coin' const buyQuantity = totalFee / 100 // 控制台配 1 币 = 1 元,totalFee 单位分 const outTradeNo = `VP${type.slice(0, 2).toUpperCase()}${Date.now()}${userId.slice(-6)}` const env = process.env.VP_SANDBOX === 'true' ? 1 : (process.env.NODE_ENV === 'production' ? 0 : 1) const userIp = req.ip || '127.0.0.1' // 1. 用 wx.login code 换取 session_key + openid,计算用户态签名 let openid: string let signature: string try { const signData = this.vpService.buildSignData(outTradeNo, user.wxOpenid, totalFee, userIp, env, mode, buyQuantity) const result = await this.vpService.exchangeCodeAndSign(wxCode, signData) openid = result.openid signature = result.signature } catch (e: any) { this.logger.error(`[VP] code2session 失败: userId=${userId}, wxCode=${wxCode?.slice(0, 20)}, error=${e.message}, stack=${e.stack?.slice(0, 300)}`) throw new HttpException(`微信身份验证失败: ${e.message}`, HttpStatus.BAD_REQUEST) } // 校验 openid 一致 this.logger.log(`[VP] code2session 成功: userId=${userId}, wxOpenid=${user.wxOpenid}, code2session_openid=${openid}`) if (openid !== user.wxOpenid) { this.logger.warn(`[VP] openid 不匹配: userId=${userId}, stored=${user.wxOpenid}, got=${openid}`) throw new HttpException('微信身份不匹配', HttpStatus.FORBIDDEN) } // 2. 计算支付签名 pay_sig const signData = this.vpService.buildSignData(outTradeNo, openid, totalFee, userIp, env, mode, buyQuantity) const paySig = this.vpService.computePaySig('requestVirtualPayment', signData, env) // 3. 创建本地订单 let title: string const titles: Record = { interview: 'AI 模拟面试', optimize: '简历优化', download: '简历下载', growth: '成长版月度会员', sprint: '冲刺版月度会员', } if (isPlan) { title = titles[type] } else { title = qty > 1 ? `${titles[type]} ×${qty}` : titles[type] } await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount: totalFee, title, status: 'pending', channel: 'virtual', type, plan: isPlan ? type : 'growth', metadata: { quantity: qty }, }) return { outTradeNo, env, mode, offerId: this.vpService.getOfferId(), signData, paySig, signature, openid, } } /** * 微信消息推送回调——虚拟支付通知 * 在小程序管理后台 → 开发 → 开发管理 → 消息推送 中配置服务器地址指向此接口 */ @Public() @Post('callback') async callback(@Body() body: any, @Req() req: any) { try { // 微信消息体可能是 XML 或 JSON const msg = body.xml || body const event = msg.Event || msg.event this.logger.log(`[vp-callback] event=${event}, body=${JSON.stringify(body).slice(0, 500)}`) if (event === 'xpay_coin_pay_notify') { await this.handleCoinPayNotify(msg) } else if (event === 'xpay_goods_deliver_notify') { await this.handleGoodsDeliverNotify(msg) } else if (event === 'xpay_refund_notify') { await this.handleRefundNotify(msg) } else { this.logger.warn(`[vp-callback] 未知事件: ${event}`) } return { ErrCode: 0, ErrMsg: 'success' } } catch (e: any) { this.logger.error(`[vp-callback] 处理失败: ${e.message}`) return { ErrCode: -1, ErrMsg: e.message } } } private async handleCoinPayNotify(msg: any) { const outTradeNo = msg.OutTradeNo || msg.out_trade_no if (!outTradeNo) { this.logger.warn('[vp-callback] 代币支付通知缺少 outTradeNo') return } const order = await this.orderModel.findOne({ outTradeNo }).exec() if (!order) { this.logger.warn(`[vp-callback] 订单不存在: ${outTradeNo}`) return } if (order.status !== 'pending') { this.logger.log(`[vp-callback] 订单已处理: ${outTradeNo} status=${order.status}`) return } order.status = 'success' order.paidAt = new Date() order.description = `虚拟支付代币充值成功 env=${msg.Env ?? ''}` await order.save() const pricing = await this.pricingService.getConfig() if (order.type === 'growth' || order.type === 'sprint') { // 套餐激活 const planCfg = pricing.plans[order.type] if (!planCfg) return const expireAt = new Date() expireAt.setDate(expireAt.getDate() + planCfg.durationDays) await this.userModel.findByIdAndUpdate(order.userId, { $set: { plan: order.type, [order.type === 'sprint' ? 'sprintExpireAt' : 'vipExpireAt']: expireAt, ...(order.type === 'sprint' ? { sprintRemaining: 10 } : {}), }, }).exec() await this.quotaService.setPlanQuota(order.userId, planCfg.gravityPerMonth) this.logger.log(`[vp-callback] 套餐已激活: userId=${order.userId}, plan=${order.type}, gravityPerMonth=${planCfg.gravityPerMonth}`) } else { // 发放引力值(按次购买) const gravityMap: Record = { interview: pricing.gravityRates.interviewPerUse, optimize: pricing.gravityRates.optimizePerUse, download: pricing.gravityRates.downloadPerUse, } const g = gravityMap[order.type] const quantity = order.metadata?.quantity || 1 if (g) { await this.quotaService.grantGravity(order.userId, g * quantity) this.logger.log(`[vp-callback] 引力值已发放: userId=${order.userId}, gravity=${g * quantity}`) } } } private async handleGoodsDeliverNotify(msg: any) { // 道具发货通知——代币模式下通常不需要额外处理 this.logger.log(`[vp-callback] 道具发货通知: outTradeNo=${msg.OutTradeNo}`) } private async handleRefundNotify(msg: any) { const outTradeNo = msg.MchOrderId || msg.MchOrderNo if (!outTradeNo) return const order = await this.orderModel.findOne({ outTradeNo }).exec() if (!order) return order.status = 'refunded' order.refundAmount = msg.RefundFee || order.amount order.refundedAt = new Date() await order.save() this.logger.log(`[vp-callback] 订单已退款: ${outTradeNo}`) } /** 查询本地订单状态(前端轮询) */ @UseGuards(JwtAuthGuard) @Get('check/:outTradeNo') async checkOrder(@Param('outTradeNo') outTradeNo: string, @CurrentUser('userId') userId: string) { const order = await this.orderModel.findOne({ outTradeNo, userId }).exec() if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND) return { status: order.status, type: order.type, paidAt: order.paidAt } } }