2230a95b45
- New GravityTransaction schema tracks all gravity changes (registration, interview/optimize/download deduction, purchase, monthly topup, migration) - GravityTopUpService: bulk log for monthly VIP topup - PaymentController.activateMembership: log plan_set transactions - QuotaService: add logTransaction, wire into all gravity-modifying methods - UserService: log registration grants (phone/wx/email/password) - GET /user/gravity-transactions?page=&limit= API - Frontend: user.vue '明细' button + paginated popup - Docs: update PROJECT-STATUS v4.10, FEATURE-LIST, DEPLOYMENT
298 lines
13 KiB
TypeScript
298 lines
13 KiB
TypeScript
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<UserDocument>,
|
||
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||
@InjectModel(GravityTransaction.name) private gravityTxModel: Model<GravityTransaction>,
|
||
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<string, any>,
|
||
) {
|
||
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<string, number> = {
|
||
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<string, string> = {
|
||
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<string, any>,
|
||
) {
|
||
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<string, number> = {
|
||
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<string, string> = {
|
||
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<string, number> = {
|
||
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 }
|
||
}
|
||
}
|