import { Controller, Post, Get, Param, Body, UseGuards, HttpException, HttpStatus, Logger, Req } 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' @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, private wechatPay: WechatPayService, private quotaService: QuotaService, private pricingService: PricingService, ) {} /** 创建套餐订单(H5:Native 扫码支付) */ @UseGuards(JwtAuthGuard) @Post('create') 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('metadata') metadata?: Record, ) { if (!['interview', 'optimize', 'download'].includes(type)) { throw new HttpException('无效产品类型', HttpStatus.BAD_REQUEST) } 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] if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR) const titles: Record = { interview: 'AI 模拟面试单次', optimize: '简历优化单次', download: '简历下载', } const title = titles[type] || 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 }) return { outTradeNo, codeUrl: result.codeUrl, amount: price, title } } /** JSAPI 支付(微信小程序) */ @UseGuards(JwtAuthGuard) @Post('jsapi') async jsapi(@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 openid = user.wxOpenid if (!openid) 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.jsapiPay(title, outTradeNo, amount, openid) 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('metadata') metadata?: Record, ) { if (!['interview', 'optimize', 'download'].includes(type)) { throw new HttpException('无效产品类型', HttpStatus.BAD_REQUEST) } 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('未绑定微信', 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] if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR) const titles: Record = { interview: 'AI 模拟面试单次', optimize: '简历优化单次', download: '简历下载', } const title = titles[type] || 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 }) return { ...result, outTradeNo } } /** 支付回调通知 */ @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) 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 } const credits = planCfg.credits user.remaining = 999 user.interviewCredits = credits.interview user.resumeOptimizeCredits = credits.resumeOptimize user.resumeDownloadCredits = credits.resumeDownload user.freeOptimizeUsed = 3 await user.save() } private async activateProduct(order: PaymentOrderDocument) { const pricing = await this.pricingService.getConfig() const creditMap: Record = { interview: pricing.interview.creditsPerPurchase, optimize: pricing.resumeOptimize.creditsPerPurchase, download: pricing.resumeDownload.creditsPerPurchase, } const credits = creditMap[order.type] if (!credits) return const typeMap: Record = { interview: 'interview', optimize: 'optimize', download: 'download', } const mapped = typeMap[order.type] if (mapped) { await this.quotaService.grantCredits(order.userId, mapped, credits) } } /** 查询订单(微信侧) */ @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 } } }