feat: 付费体系重构 P0 - 配额独立化/简历付费下载/PDF生成
This commit is contained in:
@@ -27,12 +27,18 @@ export class PaymentOrder {
|
||||
@Prop({ default: 'pending' })
|
||||
status: string
|
||||
|
||||
@Prop({ default: 'membership' })
|
||||
type: string // membership | interview | optimize | download
|
||||
|
||||
@Prop({ default: 'growth' })
|
||||
plan: string // growth | sprint
|
||||
|
||||
@Prop({ default: 'native' })
|
||||
channel: string // native | jsapi
|
||||
|
||||
@Prop({ type: Object })
|
||||
metadata?: Record<string, any>
|
||||
|
||||
@Prop()
|
||||
paidAt?: Date
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { getModelToken } from '@nestjs/mongoose'
|
||||
import { PaymentController } from './payment.controller'
|
||||
import { WechatPayService } from './wechat-pay.service'
|
||||
import { QuotaService } from '../user/quota.service'
|
||||
|
||||
describe('PaymentController', () => {
|
||||
let controller: PaymentController
|
||||
let mockUserModel: any
|
||||
let mockOrderModel: any
|
||||
let mockWechatPay: any
|
||||
let mockQuotaService: any
|
||||
|
||||
const mockUserId = '507f1f77bcf86cd799439011'
|
||||
|
||||
@@ -26,6 +28,10 @@ describe('PaymentController', () => {
|
||||
jsapiPay: jest.fn().mockResolvedValue({ paySign: 'mock-sign', nonceStr: 'mock-nonce', package: 'prepay_id=mock', timeStamp: '123456', signType: 'RSA' }),
|
||||
queryOrder: jest.fn().mockResolvedValue({ trade_state: 'SUCCESS', transaction_id: 'wx123' }),
|
||||
}
|
||||
mockQuotaService = {
|
||||
grantCredits: jest.fn().mockResolvedValue(undefined),
|
||||
setPlanQuota: jest.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [PaymentController],
|
||||
@@ -33,6 +39,7 @@ describe('PaymentController', () => {
|
||||
{ provide: getModelToken('User'), useValue: mockUserModel },
|
||||
{ provide: getModelToken('PaymentOrder'), useValue: mockOrderModel },
|
||||
{ provide: WechatPayService, useValue: mockWechatPay },
|
||||
{ provide: QuotaService, useValue: mockQuotaService },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
@@ -107,9 +114,9 @@ describe('PaymentController', () => {
|
||||
})
|
||||
|
||||
it('should return order status', async () => {
|
||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'pending', plan: 'growth' }) })
|
||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'pending', plan: 'growth', type: 'membership' }) })
|
||||
const result = await controller.checkOrder('ORD123', mockUserId)
|
||||
expect(result).toEqual({ status: 'pending', plan: 'growth' })
|
||||
expect(result).toEqual({ status: 'pending', plan: 'growth', type: 'membership' })
|
||||
expect(mockOrderModel.findOne).toHaveBeenCalledWith({ outTradeNo: 'ORD123', userId: mockUserId })
|
||||
})
|
||||
})
|
||||
@@ -128,19 +135,23 @@ describe('PaymentController', () => {
|
||||
})
|
||||
|
||||
it('should activate growth plan', async () => {
|
||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'growth' }) })
|
||||
const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, save: jest.fn().mockResolvedValue(true) }
|
||||
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) }
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
|
||||
|
||||
const result = await controller.activate(mockUserId, 'ORD123')
|
||||
expect(result.success).toBe(true)
|
||||
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)
|
||||
})
|
||||
|
||||
it('should activate sprint plan', async () => {
|
||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'sprint' }) })
|
||||
const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, save: jest.fn().mockResolvedValue(true) }
|
||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'sprint', 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) }
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
|
||||
|
||||
const result = await controller.activate(mockUserId, 'ORD123')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Post, Get, Param, Query, Body, UseGuards, HttpException, HttpStatus, Logger, Req } from '@nestjs/common'
|
||||
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'
|
||||
@@ -6,12 +6,25 @@ 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 { Public } from '../../common/decorators/public.decorator'
|
||||
|
||||
const GROWTH_AMOUNT = 1990 // 19.9 元(分)
|
||||
const SPRINT_AMOUNT = 4990 // 49.9 元(分)
|
||||
const GROWTH_AMOUNT = 1990
|
||||
const SPRINT_AMOUNT = 4990
|
||||
const VIP_DURATION_DAYS = 30
|
||||
|
||||
const PRODUCT_PRICES: Record<string, number> = {
|
||||
interview: 500,
|
||||
optimize: 300,
|
||||
download: 200,
|
||||
}
|
||||
|
||||
const PRODUCT_CREDITS: Record<string, number> = {
|
||||
interview: 1,
|
||||
optimize: 1,
|
||||
download: 1,
|
||||
}
|
||||
|
||||
@Controller('payment')
|
||||
export class PaymentController {
|
||||
private readonly logger = new Logger(PaymentController.name)
|
||||
@@ -20,9 +33,10 @@ export class PaymentController {
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||||
private wechatPay: WechatPayService,
|
||||
private quotaService: QuotaService,
|
||||
) {}
|
||||
|
||||
/** 创建订单(H5:Native 扫码支付) */
|
||||
/** 创建套餐订单(H5:Native 扫码支付) */
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('create')
|
||||
async create(@CurrentUser('userId') userId: string, @Body('plan') plan: string = 'growth') {
|
||||
@@ -36,11 +50,42 @@ export class PaymentController {
|
||||
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', plan })
|
||||
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 price = PRODUCT_PRICES[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')
|
||||
@@ -57,7 +102,40 @@ export class PaymentController {
|
||||
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', plan })
|
||||
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 price = PRODUCT_PRICES[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 }
|
||||
}
|
||||
@@ -65,10 +143,7 @@ export class PaymentController {
|
||||
/** 支付回调通知 */
|
||||
@Public()
|
||||
@Post('notify')
|
||||
async notify(
|
||||
@Body() body: any,
|
||||
@Req() req: any,
|
||||
) {
|
||||
async notify(@Body() body: any, @Req() req: any) {
|
||||
try {
|
||||
const wechatSignature = req.headers['wechatpay-signature'] || ''
|
||||
const wechatTimestamp = req.headers['wechatpay-timestamp'] || ''
|
||||
@@ -79,7 +154,6 @@ export class PaymentController {
|
||||
const outTradeNo = decrypted.out_trade_no
|
||||
const wxTransactionId = decrypted.transaction_id
|
||||
|
||||
// 从数据库订单查找 userId,而非从 outTradeNo 解析
|
||||
const order = await this.orderModel.findOne({ outTradeNo }).exec()
|
||||
if (!order) {
|
||||
this.logger.warn(`支付回调:订单不存在 ${outTradeNo}`)
|
||||
@@ -94,21 +168,10 @@ export class PaymentController {
|
||||
await order.save()
|
||||
}
|
||||
|
||||
// 根据订单 plan 激活对应套餐
|
||||
const user = await this.userModel.findById(order.userId).exec()
|
||||
if (user && user.plan === 'free') {
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
||||
if (order.plan === 'sprint') {
|
||||
user.plan = 'sprint'
|
||||
user.sprintExpireAt = expireAt
|
||||
user.sprintRemaining = 10 // 每月 10 次冲刺权益(语音分析+缺口分析)
|
||||
} else {
|
||||
user.plan = 'growth'
|
||||
user.vipExpireAt = expireAt
|
||||
}
|
||||
user.remaining = 999
|
||||
await user.save()
|
||||
if (order.type === 'membership') {
|
||||
await this.activateMembership(order)
|
||||
} else {
|
||||
await this.activateProduct(order)
|
||||
}
|
||||
return { code: 'SUCCESS', message: '成功' }
|
||||
} catch (e) {
|
||||
@@ -117,6 +180,47 @@ export class PaymentController {
|
||||
}
|
||||
}
|
||||
|
||||
private async activateMembership(order: PaymentOrderDocument) {
|
||||
const user = await this.userModel.findById(order.userId).exec()
|
||||
if (!user || user.plan !== 'free') return
|
||||
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
||||
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 = isSprint
|
||||
? { interview: 999, resumeOptimize: 50, resumeDownload: 30 }
|
||||
: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
|
||||
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 credits = PRODUCT_CREDITS[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')
|
||||
@@ -132,32 +236,23 @@ export class PaymentController {
|
||||
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 }
|
||||
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)
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.plan !== 'free') return { success: true, plan: user.plan, message: '已是会员' }
|
||||
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
||||
if (order.plan === 'sprint') {
|
||||
user.plan = 'sprint'
|
||||
user.sprintExpireAt = expireAt
|
||||
user.sprintRemaining = 10
|
||||
} else {
|
||||
user.plan = 'growth'
|
||||
user.vipExpireAt = expireAt
|
||||
if (order.type === 'membership') {
|
||||
await this.activateMembership(order)
|
||||
return { success: true, plan: order.plan }
|
||||
}
|
||||
user.remaining = 999
|
||||
await user.save()
|
||||
return { success: true, plan: user.plan }
|
||||
|
||||
await this.activateProduct(order)
|
||||
return { success: true, type: order.type }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { PaymentController } from './payment.controller'
|
||||
import { WechatPayService } from './wechat-pay.service'
|
||||
import { User, UserSchema } from '../user/user.schema'
|
||||
|
||||
import { UserModule } from '../user/user.module'
|
||||
import { PaymentOrder, PaymentOrderSchema } from './payment-order.schema'
|
||||
|
||||
@Module({
|
||||
imports: [MongooseModule.forFeature([
|
||||
{ name: User.name, schema: UserSchema },
|
||||
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
|
||||
])],
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: User.name, schema: UserSchema },
|
||||
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
|
||||
]),
|
||||
UserModule,
|
||||
],
|
||||
controllers: [PaymentController],
|
||||
providers: [WechatPayService],
|
||||
exports: [WechatPayService],
|
||||
|
||||
Reference in New Issue
Block a user