feat: unified gravity system - VIP members consume gravity instead of unlimited; add monthly gravity top-up cron

This commit is contained in:
yuzhiran
2026-06-19 22:43:52 +08:00
parent c2ba810a02
commit 2fbab1072f
22 changed files with 956 additions and 216 deletions
@@ -40,8 +40,8 @@ describe('PaymentController', () => {
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 },
resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 },
plans: {
growth: { price: 1990, durationDays: 30, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: [] },
sprint: { price: 4990, durationDays: 30, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: [] },
growth: { price: 1990, durationDays: 30, gravityPerMonth: 250, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: [] },
sprint: { price: 4990, durationDays: 30, gravityPerMonth: 600, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: [] },
},
}),
}
@@ -150,7 +150,7 @@ describe('PaymentController', () => {
it('should activate growth plan', async () => {
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'growth', type: 'membership' }) })
const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, interviewCredits: 1, resumeOptimizeCredits: 0, resumeDownloadCredits: 0, freeOptimizeUsed: 0, save: jest.fn().mockResolvedValue(true) }
const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, gravity: 0, freeOptimizeUsed: 0, save: jest.fn().mockResolvedValue(true) }
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
const result = await controller.activate(mockUserId, 'ORD123')
@@ -158,9 +158,8 @@ describe('PaymentController', () => {
expect(result.plan).toBe('growth')
expect(mockUser.save).toHaveBeenCalled()
expect(mockUser.plan).toBe('growth')
expect(mockUser.interviewCredits).toBe(999)
expect(mockUser.resumeOptimizeCredits).toBe(20)
expect(mockUser.resumeDownloadCredits).toBe(10)
expect(mockUser.gravity).toBe(250)
expect(mockUser.freeOptimizeUsed).toBe(3)
})
it('should activate sprint plan', async () => {
@@ -1,4 +1,4 @@
import { Controller, Post, Get, Param, Body, UseGuards, HttpException, HttpStatus, Logger, Req } from '@nestjs/common'
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'
@@ -25,6 +25,7 @@ export class PaymentController {
/** 创建套餐订单(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()
@@ -49,11 +50,13 @@ export class PaymentController {
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)
@@ -63,40 +66,55 @@ export class PaymentController {
optimize: pricing.resumeOptimize.pricePerOptimize,
download: pricing.resumeDownload.pricePerDownload,
}
const price = priceMap[type]
if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR)
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 = titles[type] || type
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 })
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 }
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) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.plan !== 'free') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
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) throw new HttpException('未绑定微信', HttpStatus.BAD_REQUEST)
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)}`
const result = await this.wechatPay.jsapiPay(title, outTradeNo, amount, openid)
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 })
@@ -109,15 +127,19 @@ export class PaymentController {
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('未绑定微信', HttpStatus.BAD_REQUEST)
if (!openid) {
throw new HttpException({ message: '未绑定微信openid', needBindWx: true }, HttpStatus.BAD_REQUEST)
}
const pricing = await this.pricingService.getConfig()
const priceMap: Record<string, number> = {
@@ -125,21 +147,21 @@ export class PaymentController {
optimize: pricing.resumeOptimize.pricePerOptimize,
download: pricing.resumeDownload.pricePerDownload,
}
const price = priceMap[type]
if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR)
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 = titles[type] || type
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 })
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 }
return { ...result, outTradeNo, quantity: qty }
}
/** 支付回调通知 */
@@ -201,34 +223,22 @@ export class PaymentController {
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.gravity = planCfg.gravityPerMonth
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)
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)
}
/** 查询订单(微信侧) */
@@ -51,6 +51,8 @@ export class WechatPayService {
/** 发起 API v3 请求 */
private async request(method: string, apiPath: string, body?: any) {
const url = `${WX_API_BASE}${apiPath}`
const bodyStr = body ? JSON.stringify(body) : ''
this.logger.log(`[wxpay-request] ${method} ${apiPath} 请求体: ${bodyStr}`)
try {
const res = await axios({
method,
@@ -63,9 +65,12 @@ export class WechatPayService {
},
data: body,
})
this.logger.log(`[wxpay-request] ${method} ${apiPath} 成功: ${JSON.stringify(res.data)}`)
return res.data
} catch (e: any) {
this.logger.error(`微信支付请求失败: ${method} ${apiPath}`, e.response?.data || e.message)
const errDetail = e.response?.data ? JSON.stringify(e.response.data) : e.message
const errStatus = e.response?.status || '无状态码'
this.logger.error(`[wxpay-request] ${method} ${apiPath} 失败 status=${errStatus}: ${errDetail}`)
throw e
}
}
@@ -99,8 +104,15 @@ export class WechatPayService {
amount: { total: amount, currency: 'CNY' },
payer: { openid },
}
this.logger.log(`[jsapiPay] 下单参数: description=${description}, outTradeNo=${outTradeNo}, amount=${amount}, openid=${openid}`)
this.logger.log(`[jsapiPay] 完整请求体: ${JSON.stringify(body)}`)
const result = await this.request('POST', '/v3/pay/transactions/jsapi', body)
this.logger.log(`[jsapiPay] 微信返回: ${JSON.stringify(result)}`)
const prepayId = result.prepay_id
if (!prepayId) {
this.logger.error(`[jsapiPay] 微信返回缺少prepay_id: ${JSON.stringify(result)}`)
throw new Error('微信下单失败: 缺少prepay_id')
}
// 生成小程序/JSAPI 调起支付参数
const nonce = crypto.randomBytes(16).toString('hex')
const timestamp = Math.floor(Date.now() / 1000).toString()