feat: unified gravity system - VIP members consume gravity instead of unlimited; add monthly gravity top-up cron
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user