278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
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<UserDocument>,
|
|
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
|
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<string, any>,
|
|
) {
|
|
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<string, number> = {
|
|
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<string, string> = {
|
|
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<string, any>,
|
|
) {
|
|
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<string, number> = {
|
|
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<string, string> = {
|
|
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<string, number> = {
|
|
interview: pricing.interview.creditsPerPurchase,
|
|
optimize: pricing.resumeOptimize.creditsPerPurchase,
|
|
download: pricing.resumeDownload.creditsPerPurchase,
|
|
}
|
|
const credits = creditMap[order.type]
|
|
if (!credits) return
|
|
|
|
const typeMap: Record<string, 'interview' | 'optimize' | 'download'> = {
|
|
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 }
|
|
}
|
|
}
|