import { Controller, Post, Get, Param, Body, 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-order.schema' import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard' import { CurrentUser } from '../../common/decorators/current-user.decorator' import { WechatPayService } from './wechat-pay.service' import { QuotaService } from '../user/quota.service' import { PricingService } from '../schemas/pricing.service' import { Public } from '../../common/decorators/public.decorator' import { GravityTransaction } from '../schemas/gravity-transaction.schema' @Controller('payment') export class PaymentController { private readonly logger = new Logger(PaymentController.name) constructor( @InjectModel(User.name) private userModel: Model, @InjectModel(PaymentOrder.name) private orderModel: Model, @InjectModel(GravityTransaction.name) private gravityTxModel: Model, private wechatPay: WechatPayService, private quotaService: QuotaService, private pricingService: PricingService, ) {} /** 创建套餐订单(H5:Native 扫码支付) */ @UseGuards(JwtAuthGuard) @Post('create') @HttpCode(200) async create(@CurrentUser('userId') userId: string, @Body('plan') plan: string = 'growth') { if (!['growth', 'sprint'].includes(plan)) throw new HttpException('无效套餐', HttpStatus.BAD_REQUEST) const user = await this.userModel.findById(userId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) if (user.plan !== 'free') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST) const pricing = await this.pricingService.getConfig() const planCfg = pricing.plans[plan === 'sprint' ? 'sprint' : 'growth'] const amount = planCfg.price const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员' const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}` const result = await this.wechatPay.nativePay(title, outTradeNo, amount) await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount, title, status: 'pending', channel: 'native', type: 'membership', plan }) return { outTradeNo, codeUrl: result.codeUrl, amount, title } } /** 创建按次购买订单 */ @UseGuards(JwtAuthGuard) @Post('create-product') async createProduct( @CurrentUser('userId') userId: string, @Body('type') type: string, @Body('quantity') quantity: number = 1, @Body('metadata') metadata?: Record, ) { if (!['interview', 'optimize', 'download'].includes(type)) { throw new HttpException('无效产品类型', HttpStatus.BAD_REQUEST) } const qty = Math.max(1, Math.min(99, quantity || 1)) const user = await this.userModel.findById(userId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) const pricing = await this.pricingService.getConfig() const priceMap: Record = { interview: pricing.interview.pricePerSession, optimize: pricing.resumeOptimize.pricePerOptimize, download: pricing.resumeDownload.pricePerDownload, } const price = priceMap[type] * qty if (!priceMap[type]) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR) const titles: Record = { interview: 'AI 模拟面试单次', optimize: '简历优化单次', download: '简历下载', } const title = qty > 1 ? `${titles[type]}×${qty}` : titles[type] const outTradeNo = `${type.slice(0, 3).toUpperCase()}${Date.now()}${userId.slice(-6)}` const result = await this.wechatPay.nativePay(title, outTradeNo, price) await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount: price, title, status: 'pending', channel: 'native', type, plan: 'growth', metadata: { ...metadata, quantity: qty } }) return { outTradeNo, codeUrl: result.codeUrl, amount: price, title, quantity: qty } } /** JSAPI 支付(微信小程序) */ @UseGuards(JwtAuthGuard) @Post('jsapi') @HttpCode(200) async jsapi(@CurrentUser('userId') userId: string, @Body('plan') plan: string = 'growth') { this.logger.log(`[jsapi] userId=${userId}, plan=${plan}`) if (!['growth', 'sprint'].includes(plan)) throw new HttpException('无效套餐', HttpStatus.BAD_REQUEST) const user = await this.userModel.findById(userId).exec() if (!user) { this.logger.warn(`[jsapi] 用户不存在 userId=${userId}`); throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) } this.logger.log(`[jsapi] 用户查询结果: plan=${user.plan}, wxOpenid=${user.wxOpenid ? '已设置' : '空'}, phone=${user.phone || '无'}`) if (user.plan !== 'free') { this.logger.warn(`[jsapi] 已是会员 plan=${user.plan}`); throw new HttpException('已是会员', HttpStatus.BAD_REQUEST) } const openid = user.wxOpenid if (!openid) { this.logger.warn(`[jsapi] 未绑定微信openid userId=${userId}`) throw new HttpException({ message: '未绑定微信openid', needBindWx: true }, HttpStatus.BAD_REQUEST) } const pricing = await this.pricingService.getConfig() this.logger.log(`[jsapi] pricing获取成功`) const planCfg = pricing.plans[plan === 'sprint' ? 'sprint' : 'growth'] const amount = planCfg.price const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员' const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}` this.logger.log(`[jsapi] 准备调用微信: outTradeNo=${outTradeNo}, amount=${amount}, openid=${openid}`) let result: any try { result = await this.wechatPay.jsapiPay(title, outTradeNo, amount, openid) this.logger.log(`[jsapi] 微信下单成功 prepayId=${result?.prepayId}`) } catch (e: any) { this.logger.error(`[jsapi] 微信下单失败: ${e.message}`, e.response?.data ? JSON.stringify(e.response.data) : '') throw new HttpException(e.response?.data?.message || '微信支付下单失败', HttpStatus.INTERNAL_SERVER_ERROR) } await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount, title, status: 'pending', channel: 'jsapi', type: 'membership', plan }) return { ...result, outTradeNo } } /** JSAPI 按次购买 */ @UseGuards(JwtAuthGuard) @Post('jsapi-product') async jsapiProduct( @CurrentUser('userId') userId: string, @Body('type') type: string, @Body('quantity') quantity: number = 1, @Body('metadata') metadata?: Record, ) { if (!['interview', 'optimize', 'download'].includes(type)) { throw new HttpException('无效产品类型', HttpStatus.BAD_REQUEST) } const qty = Math.max(1, Math.min(99, quantity || 1)) const user = await this.userModel.findById(userId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) const openid = user.wxOpenid if (!openid) { throw new HttpException({ message: '未绑定微信openid', needBindWx: true }, HttpStatus.BAD_REQUEST) } const pricing = await this.pricingService.getConfig() const priceMap: Record = { interview: pricing.interview.pricePerSession, optimize: pricing.resumeOptimize.pricePerOptimize, download: pricing.resumeDownload.pricePerDownload, } const price = priceMap[type] * qty if (!priceMap[type]) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR) const titles: Record = { interview: 'AI 模拟面试单次', optimize: '简历优化单次', download: '简历下载', } const title = qty > 1 ? `${titles[type]}×${qty}` : titles[type] const outTradeNo = `${type.slice(0, 3).toUpperCase()}${Date.now()}${userId.slice(-6)}` const result = await this.wechatPay.jsapiPay(title, outTradeNo, price, openid) await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount: price, title, status: 'pending', channel: 'jsapi', type, plan: 'growth', metadata: { ...metadata, quantity: qty } }) return { ...result, outTradeNo, quantity: qty } } /** 支付回调通知 */ @Public() @Post('notify') async notify(@Body() body: any, @Req() req: any) { try { const wechatSignature = req.headers['wechatpay-signature'] || '' const wechatTimestamp = req.headers['wechatpay-timestamp'] || '' const wechatNonce = req.headers['wechatpay-nonce'] || '' const decrypted = this.wechatPay.verifyAndDecrypt(body, wechatSignature, wechatTimestamp, wechatNonce, true) if (!decrypted) return { code: 'FAIL', message: '处理失败' } const outTradeNo = decrypted.out_trade_no const wxTransactionId = decrypted.transaction_id const order = await this.orderModel.findOne({ outTradeNo }).exec() if (!order) { this.logger.warn(`支付回调:订单不存在 ${outTradeNo}`) return { code: 'FAIL', message: '订单不存在' } } if (order.status === 'pending') { order.status = 'success' order.paidAt = new Date() order.wxTransactionId = wxTransactionId order.description = `${decrypted.trade_type || ''} 支付成功` await order.save() } else { return { code: 'SUCCESS', message: '已处理' } } if (order.type === 'membership') { await this.activateMembership(order) } else { await this.activateProduct(order) } return { code: 'SUCCESS', message: '成功' } } catch (e) { this.logger.error(`支付回调处理失败: ${e.message}`) return { code: 'FAIL', message: '处理失败' } } } private async activateMembership(order: PaymentOrderDocument) { const user = await this.userModel.findById(order.userId).exec() if (!user || user.plan !== 'free') return const pricing = await this.pricingService.getConfig() const planCfg = pricing.plans[order.plan === 'sprint' ? 'sprint' : 'growth'] const expireAt = new Date() expireAt.setDate(expireAt.getDate() + planCfg.durationDays) const isSprint = order.plan === 'sprint' if (isSprint) { user.plan = 'sprint' user.sprintExpireAt = expireAt user.sprintRemaining = 10 } else { user.plan = 'growth' user.vipExpireAt = expireAt } user.gravity = planCfg.gravityPerMonth user.freeOptimizeUsed = 3 await user.save() await this.gravityTxModel.create({ userId: order.userId, amount: planCfg.gravityPerMonth, balance: planCfg.gravityPerMonth, type: 'plan_set', description: `开通${isSprint ? '冲刺版' : '成长版'}会员,获得 ${planCfg.gravityPerMonth} 引力值`, refId: order.outTradeNo, }) } private async activateProduct(order: PaymentOrderDocument) { const pricing = await this.pricingService.getConfig() const gravityMap: Record = { interview: pricing.gravityRates.interviewPerUse, optimize: pricing.gravityRates.optimizePerUse, download: pricing.gravityRates.downloadPerUse, } const g = gravityMap[order.type] if (!g) return const quantity = order.metadata?.quantity || 1 await this.quotaService.grantGravity(order.userId, g * quantity) } /** 查询订单(微信侧) */ @UseGuards(JwtAuthGuard) @Post('query') async query(@Body('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 this.wechatPay.queryOrder(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, plan: order.plan, type: order.type } } /** 凭订单号激活(前端支付成功后调用,兜底) */ @UseGuards(JwtAuthGuard) @Post('activate') async activate(@CurrentUser('userId') userId: string, @Body('outTradeNo') outTradeNo: string) { const order = await this.orderModel.findOne({ outTradeNo, userId }).exec() if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND) if (order.status !== 'success') throw new HttpException('支付未完成', HttpStatus.BAD_REQUEST) if (order.type === 'membership') { await this.activateMembership(order) return { success: true, plan: order.plan } } // For product orders, check if already activated via callback const user = await this.userModel.findById(userId).exec() if (user && order.type === 'interview' && (user.interviewCredits || 0) > 0) { return { success: true, type: order.type, alreadyActivated: true } } if (user && order.type === 'optimize' && (user.resumeOptimizeCredits || 0) > 0) { return { success: true, type: order.type, alreadyActivated: true } } await this.activateProduct(order) return { success: true, type: order.type } } }