feat: 付费体系重构 P0 - 配额独立化/简历付费下载/PDF生成

This commit is contained in:
yuzhiran
2026-06-12 09:31:11 +08:00
parent 5d407b4f79
commit 065fe7a186
23 changed files with 965 additions and 106 deletions
+19 -12
View File
@@ -8,6 +8,7 @@ import { User, UserDocument } from '../user/user.schema'
import { Interview, InterviewDocument } from '../interview/interview.schema'
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
import { SiteConfig, SiteConfigDocument } from '../schemas/site-config.schema'
import { QuotaService } from '../user/quota.service'
import { WechatPayService } from '../payment/wechat-pay.service'
const VIP_DURATION_DAYS = 30
@@ -20,6 +21,7 @@ export class AdminController {
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
@InjectModel(SiteConfig.name) private configModel: Model<SiteConfigDocument>,
private quotaService: QuotaService,
private wechatPay: WechatPayService,
) {}
@@ -82,8 +84,7 @@ export class AdminController {
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
user.plan = 'growth'
user.vipExpireAt = expireAt
user.remaining = 999
await user.save()
await this.quotaService.setPlanQuota(targetUserId, 'growth', { interview: 999, resumeOptimize: 20, resumeDownload: 10 })
return { success: true, plan: 'growth', expireAt }
}
@@ -126,14 +127,20 @@ export class AdminController {
order.wxTransactionId = wxResult?.transaction_id || ''
order.paidAt = new Date()
await order.save()
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)
user.plan = 'growth'
user.vipExpireAt = expireAt
user.remaining = 999
await user.save()
if (order.type === 'membership') {
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)
user.plan = 'growth'
user.vipExpireAt = expireAt
await this.quotaService.setPlanQuota(order.userId, 'growth', { interview: 999, resumeOptimize: 20, resumeDownload: 10 })
}
} else {
const credits = { interview: 1, optimize: 1, download: 1 }[order.type]
if (credits) {
await this.quotaService.grantCredits(order.userId, order.type as any, credits)
}
}
}
return { order, wxResult }
@@ -179,8 +186,8 @@ const DEFAULT_CONFIG = {
optimize: { dailyFreeLimit: 2 },
price: { monthly: 1990 },
plans: {
free: { name: '免费版', price: 0, features: ['每日 3 次 AI 模拟面试', '每场最多 5 轮 AI 对话', '基础面试报告', '简历诊断', '简历优化'] },
growth: { name: '成长版', price: 1990, features: ['免费版全部权益', '无限面试次数', '每场最多 10 轮 AI 对话', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '参考回答思路', '公司真题库'] },
free: { name: '免费版', price: 0, features: ['AI 模拟面试 1 次(体验)', '每场最多 5 轮 AI 对话', '基础面试报告', '简历优化(限 3 次)'] },
growth: { name: '成长版', price: 1990, features: ['免费版全部权益', 'AI 数字人面试无限次', '每场最多 10 轮 AI 对话', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'] },
},
}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AdminController } from './admin.controller'
import { User, UserSchema } from '../user/user.schema'
import { UserModule } from '../user/user.module'
import { Interview, InterviewSchema } from '../interview/interview.schema'
import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema'
import { WechatPayService } from '../payment/wechat-pay.service'
@@ -16,6 +17,7 @@ import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema'
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
{ name: SiteConfig.name, schema: SiteConfigSchema },
]),
UserModule,
],
controllers: [AdminController],
providers: [WechatPayService, AdminGuard],
@@ -5,6 +5,8 @@ import { AnalyzeService } from './analyze.service'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { UserService } from '../user/user.service'
import { QuotaService } from '../user/quota.service'
import { ResumeService } from '../resume/resume.service'
import { BenchmarkService, PositionBenchmark } from '../progress/benchmark.service'
import { Progress, ProgressDocument } from '../schemas/progress.schema'
@@ -13,6 +15,8 @@ export class AnalyzeController {
constructor(
private analyzeService: AnalyzeService,
private userService: UserService,
private quotaService: QuotaService,
private resumeService: ResumeService,
private benchmarkService: BenchmarkService,
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
) {}
@@ -26,9 +30,21 @@ export class AnalyzeController {
@UseGuards(JwtAuthGuard)
@Post('optimize')
async optimize(@Body('content') content: string, @Body('direction') direction: string, @CurrentUser('userId') userId: string) {
async optimize(
@Body('content') content: string,
@Body('direction') direction: string,
@Body('resumeId') resumeId: string,
@CurrentUser('userId') userId: string,
) {
await this.checkAnalyzeLimit(userId)
return this.analyzeService.optimize(content, direction)
const result = await this.analyzeService.optimize(content, direction)
if (resumeId && result.optimized && result.optimized !== content) {
const updated = await this.resumeService.updateAfterOptimize(resumeId, userId, result.optimized, direction)
return { ...result, newVersion: updated }
}
return result
}
/** 技能缺口分析:将用户四维分数与目标岗位基准对比 */
@@ -106,13 +122,6 @@ export class AnalyzeController {
}
private async checkAnalyzeLimit(userId: string) {
const user = await this.userService.getModel().findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.plan !== 'free') return
if (user.remaining <= 0) {
throw new HttpException('免费版每日次数已用完,升级会员后不限次使用', HttpStatus.FORBIDDEN)
}
user.remaining -= 1
await user.save()
await this.quotaService.checkAndDeductOptimize(userId)
}
}
@@ -3,12 +3,14 @@ import { MongooseModule } from '@nestjs/mongoose'
import { AnalyzeController } from './analyze.controller'
import { AnalyzeService } from './analyze.service'
import { UserModule } from '../user/user.module'
import { ResumeModule } from '../resume/resume.module'
import { ProgressModule } from '../progress/progress.module'
import { Progress, ProgressSchema } from '../schemas/progress.schema'
@Module({
imports: [
UserModule,
ResumeModule,
ProgressModule,
MongooseModule.forFeature([{ name: Progress.name, schema: ProgressSchema }]),
],
@@ -5,6 +5,7 @@ import { Interview, InterviewDocument } from './interview.schema'
import { Progress, ProgressDocument } from '../schemas/progress.schema'
import { AiService } from '../ai/ai.service'
import { UserService } from '../user/user.service'
import { QuotaService } from '../user/quota.service'
import { analyzeSpeech } from '../../common/utils/filler-words'
@Injectable()
@@ -14,11 +15,11 @@ export class InterviewService {
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
private aiService: AiService,
private userService: UserService,
private quotaService: QuotaService,
) {}
async create(userId: string, position: string) {
// 扣减使用次数
await this.userService.deductRemaining(userId)
await this.quotaService.checkAndDeductInterview(userId)
const firstQuestion = await this.aiService.call({
systemPrompt: `你是一位专业的${position}面试官。请针对校招该岗位提出第一个面试问题,要求具体且有针对性。直接输出问题,不要多余内容。`,
@@ -5,6 +5,7 @@ import { User, UserDocument } from '../user/user.schema'
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { QuotaService } from '../user/quota.service'
import { Public } from '../../common/decorators/public.decorator'
const GROWTH_PRICE = 1990
@@ -24,14 +25,15 @@ const PLANS: Record<string, PlanConfig> = {
free: {
id: 'free', name: '免费版', price: 0, dailyLimit: FREE_DAILY_LIMIT,
features: [
'每日 2 次 AI 模拟面试', '基础面试报告', '通用题库随机出题', '简历诊断(限 3 次)',
'AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费',
],
},
growth: {
id: 'growth', name: '成长版', price: GROWTH_PRICE, dailyLimit: 999,
features: [
'免费版全部权益', '无限面试次数', '详细面试报告(四维评分)',
'免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)',
'进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库',
'简历优化 20 次/月', '简历下载 10 次/月',
],
},
sprint: {
@@ -39,6 +41,7 @@ const PLANS: Record<string, PlanConfig> = {
features: [
'成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告',
'学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先',
'简历优化 50 次/月', '简历下载 30 次/月',
],
},
}
@@ -51,6 +54,7 @@ export class MemberController {
constructor(
@InjectModel(User.name) private userModel: Model<UserDocument>,
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
private quotaService: QuotaService,
) {}
@Public()
@@ -82,6 +86,10 @@ export class MemberController {
vipExpireAt: user.vipExpireAt,
sprintExpireAt: user.sprintExpireAt,
sprintRemaining: user.sprintRemaining || 0,
interviewCredits: user.interviewCredits ?? 1,
resumeOptimizeCredits: user.resumeOptimizeCredits ?? 0,
resumeDownloadCredits: user.resumeDownloadCredits ?? 0,
freeOptimizeUsed: user.freeOptimizeUsed ?? 0,
isVip: user.plan !== 'free',
}
}
@@ -99,16 +107,19 @@ export class MemberController {
const expireAt = new Date()
expireAt.setDate(expireAt.getDate() + DURATION_DAYS)
const credits = { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
if (order.plan === 'sprint') {
user.plan = 'sprint'
user.sprintExpireAt = expireAt
user.sprintRemaining = 10
credits.interview = 999
credits.resumeOptimize = 50
credits.resumeDownload = 30
} else {
user.plan = 'growth'
user.vipExpireAt = expireAt
}
user.remaining = 999
await user.save()
await this.quotaService.setPlanQuota(userId, order.plan, credits)
return { success: true, plan: user.plan, planName: PLANS[user.plan]?.name, expireAt }
}
+8 -4
View File
@@ -2,13 +2,17 @@ import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { MemberController } from './member.controller'
import { User, UserSchema } from '../user/user.schema'
import { UserModule } from '../user/user.module'
import { PaymentOrder, PaymentOrderSchema } from '../payment/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: [MemberController],
})
export class MemberModule {}
@@ -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')
+138 -43
View File
@@ -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,
) {}
/** 创建订单(H5Native 扫码支付) */
/** 创建套餐订单(H5Native 扫码支付) */
@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],
@@ -0,0 +1,81 @@
import * as crypto from 'crypto'
import { Injectable, Logger } from '@nestjs/common'
@Injectable()
export class ResumePdfService {
private readonly logger = new Logger(ResumePdfService.name)
async generatePdf(params: {
title: string
content: string
targetPosition?: string
userName?: string
}): Promise<Buffer> {
const { default: puppeteer } = await import('puppeteer')
const html = this.buildHtml(params)
const browser = await puppeteer.launch({
executablePath: '/root/.cache/puppeteer/chrome/linux-149.0.7827.22/chrome-linux64/chrome',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
})
try {
const page = await browser.newPage()
await page.setContent(html, { waitUntil: 'load' })
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
})
return Buffer.from(pdf)
} finally {
await browser.close()
}
}
private buildHtml(params: {
title: string
content: string
targetPosition?: string
userName?: string
}): string {
const contentHtml = params.content
.replace(/\n/g, '<br>')
.replace(/### (.+)/g, '<h3>$1</h3>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4; margin: 0; }
body {
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 12pt; line-height: 1.6; color: #333; padding: 0; margin: 0;
}
.page { padding: 40px 30px; max-width: 210mm; margin: 0 auto; }
h1 { font-size: 22pt; color: #1a1a1a; margin-bottom: 4px; }
.subtitle { color: #666; font-size: 10pt; margin-bottom: 20px; }
h2 { font-size: 14pt; color: #2c6b9e; border-bottom: 2px solid #2c6b9e; padding-bottom: 4px; margin-top: 20px; }
h3 { font-size: 12pt; color: #333; margin-top: 12px; margin-bottom: 4px; }
strong { color: #1a1a1a; }
p { margin: 6px 0; }
ul { margin: 4px 0; padding-left: 20px; }
li { margin: 2px 0; }
.footer { margin-top: 30px; font-size: 9pt; color: #999; text-align: center; border-top: 1px solid #eee; padding-top: 10px; }
</style>
</head>
<body>
<div class="page">
<h1>${params.title}</h1>
<div class="subtitle">${params.targetPosition ? `目标岗位: ${params.targetPosition}` : ''}</div>
<div class="content">${contentHtml}</div>
<div class="footer">由 AI磁场·职引 生成</div>
</div>
</body>
</html>`
}
computeHash(content: string): string {
return crypto.createHash('md5').update(content).digest('hex')
}
}
@@ -1,10 +1,17 @@
import { Controller, Post, Get, Delete, Param, Body } from '@nestjs/common'
import { Controller, Post, Get, Delete, Param, Body, Res, HttpException, HttpStatus } from '@nestjs/common'
import { Response } from 'express'
import { ResumeService } from './resume.service'
import { ResumePdfService } from './resume-pdf.service'
import { QuotaService } from '../user/quota.service'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
@Controller('resume')
export class ResumeController {
constructor(private resumeService: ResumeService) {}
constructor(
private resumeService: ResumeService,
private resumePdfService: ResumePdfService,
private quotaService: QuotaService,
) {}
@Post('create')
async create(
@@ -21,6 +28,31 @@ export class ResumeController {
return this.resumeService.list(userId)
}
@Post(':id/download')
async download(@Param('id') id: string, @CurrentUser('userId') userId: string, @Res() res: Response) {
const resume = await this.resumeService.getDetail(id, userId)
const canDownload = await this.quotaService.checkDownload(userId, resume)
if (!canDownload) {
throw new HttpException('请先付费下载', HttpStatus.PAYMENT_REQUIRED)
}
await this.quotaService.deductDownload(userId, resume)
if (!resume.paidDownload) {
await this.resumeService.markPaid(id, userId)
}
const pdf = await this.resumePdfService.generatePdf({
title: resume.title,
content: resume.content,
targetPosition: resume.targetPosition,
})
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(resume.title)}.pdf"`)
res.send(pdf)
}
@Get(':id')
async getDetail(@Param('id') id: string, @CurrentUser('userId') userId: string) {
return this.resumeService.getDetail(id, userId)
+8 -2
View File
@@ -2,11 +2,17 @@ import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { ResumeController } from './resume.controller'
import { ResumeService } from './resume.service'
import { ResumePdfService } from './resume-pdf.service'
import { Resume, ResumeSchema } from './resume.schema'
import { UserModule } from '../user/user.module'
@Module({
imports: [MongooseModule.forFeature([{ name: Resume.name, schema: ResumeSchema }])],
imports: [
MongooseModule.forFeature([{ name: Resume.name, schema: ResumeSchema }]),
UserModule,
],
controllers: [ResumeController],
providers: [ResumeService],
providers: [ResumeService, ResumePdfService],
exports: [ResumeService],
})
export class ResumeModule {}
@@ -17,6 +17,15 @@ export class Resume {
@Prop({ default: '' })
targetPosition: string
@Prop({ default: 1 })
version: number
@Prop({ default: '' })
contentHash: string
@Prop({ default: false })
paidDownload: boolean
readonly createdAt?: Date
readonly updatedAt?: Date
}
+30 -1
View File
@@ -1,3 +1,4 @@
import * as crypto from 'crypto'
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
@@ -8,7 +9,8 @@ export class ResumeService {
constructor(@InjectModel(Resume.name) private resumeModel: Model<ResumeDocument>) {}
async create(userId: string, title: string, content: string, targetPosition?: string) {
const resume = await this.resumeModel.create({ userId, title, content, targetPosition })
const contentHash = crypto.createHash('md5').update(content).digest('hex')
const resume = await this.resumeModel.create({ userId, title, content, targetPosition, contentHash, version: 1 })
return resume.toObject()
}
@@ -18,6 +20,9 @@ export class ResumeService {
id: r._id.toString(),
title: r.title,
targetPosition: r.targetPosition,
version: r.version,
contentHash: r.contentHash,
paidDownload: r.paidDownload,
createdAt: r.createdAt,
}))
}
@@ -28,6 +33,30 @@ export class ResumeService {
return resume.toObject()
}
/** AI 优化后更新内容,若内容变化则新建版本 */
async updateAfterOptimize(resumeId: string, userId: string, newContent: string, targetPosition?: string) {
const resume = await this.resumeModel.findOne({ _id: resumeId, userId }).exec()
if (!resume) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND)
const newHash = crypto.createHash('md5').update(newContent).digest('hex')
if (newHash === resume.contentHash) return resume.toObject()
// Same user, increment version as new record
const created = await this.resumeModel.create({
userId,
title: resume.title,
content: newContent,
targetPosition: targetPosition || resume.targetPosition,
contentHash: newHash,
version: (resume.version || 1) + 1,
})
return created.toObject()
}
async markPaid(resumeId: string, userId: string) {
await this.resumeModel.updateOne({ _id: resumeId, userId }, { paidDownload: true }).exec()
}
async delete(resumeId: string, userId: string) {
const res = await this.resumeModel.deleteOne({ _id: resumeId, userId }).exec()
if (res.deletedCount === 0) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND)
+92
View File
@@ -0,0 +1,92 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { User, UserDocument } from './user.schema'
const FREE_OPTIMIZE_LIMIT = 3
@Injectable()
export class QuotaService {
constructor(
@InjectModel(User.name) private userModel: Model<UserDocument>,
) {}
async checkAndDeductInterview(userId: string) {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.plan !== 'free') return
if ((user.interviewCredits || 0) <= 0) {
throw new HttpException('面试次数已用完,请购买面试次数或开通会员', HttpStatus.FORBIDDEN)
}
user.interviewCredits = (user.interviewCredits || 0) - 1
user.interviewCount = (user.interviewCount || 0) + 1
await user.save()
}
async checkAndDeductOptimize(userId: string) {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.plan !== 'free') return
// 优先扣付费额度
if ((user.resumeOptimizeCredits || 0) > 0) {
user.resumeOptimizeCredits = (user.resumeOptimizeCredits || 0) - 1
await user.save()
return
}
// 免费额度
if ((user.freeOptimizeUsed || 0) < FREE_OPTIMIZE_LIMIT) {
user.freeOptimizeUsed = (user.freeOptimizeUsed || 0) + 1
await user.save()
return
}
throw new HttpException('简历优化次数已用完,请购买优化次数或开通会员', HttpStatus.FORBIDDEN)
}
async checkDownload(userId: string, resume: { paidDownload?: boolean }): Promise<boolean> {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (resume.paidDownload) return true
if ((user.resumeDownloadCredits || 0) > 0) return true
return false
}
async deductDownload(userId: string, resume: { paidDownload?: boolean; _id?: any }) {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (resume.paidDownload) return
if ((user.resumeDownloadCredits || 0) > 0) {
user.resumeDownloadCredits = (user.resumeDownloadCredits || 0) - 1
await user.save()
}
}
async grantCredits(userId: string, type: 'interview' | 'optimize' | 'download', amount: number) {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (type === 'interview') user.interviewCredits = (user.interviewCredits || 0) + amount
else if (type === 'optimize') user.resumeOptimizeCredits = (user.resumeOptimizeCredits || 0) + amount
else if (type === 'download') user.resumeDownloadCredits = (user.resumeDownloadCredits || 0) + amount
await user.save()
}
async setPlanQuota(userId: string, plan: string, credits: { interview: number; resumeOptimize: number; resumeDownload: number }) {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
user.remaining = 999
user.interviewCredits = credits.interview
user.resumeOptimizeCredits = credits.resumeOptimize
user.resumeDownloadCredits = credits.resumeDownload
user.freeOptimizeUsed = FREE_OPTIMIZE_LIMIT // 会员不再消耗免费次数
await user.save()
}
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { MongooseModule } from '@nestjs/mongoose'
import { JwtModule } from '@nestjs/jwt'
import { UserController } from './user.controller'
import { UserService } from './user.service'
import { QuotaService } from './quota.service'
import { User, UserSchema } from './user.schema'
@Module({
@@ -14,7 +15,7 @@ import { User, UserSchema } from './user.schema'
}),
],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
providers: [UserService, QuotaService],
exports: [UserService, QuotaService],
})
export class UserModule {}
+13
View File
@@ -35,6 +35,19 @@ export class User {
@Prop({ default: 0 })
sprintRemaining: number // 冲刺版剩余次数(语音分析等)
// --- 新版独立额度(产品改造 v2) ---
@Prop({ default: 1 })
interviewCredits: number // AI 面试可用次数(含首次免费)
@Prop({ default: 0 })
resumeOptimizeCredits: number // 简历优化可用次数
@Prop({ default: 0 })
resumeDownloadCredits: number // 简历下载可用次数
@Prop({ default: 0 })
freeOptimizeUsed: number // 已使用免费优化次数(上限 3
@Prop({ default: 'user' })
role: string // 'user' | 'admin'
+4
View File
@@ -211,6 +211,10 @@ export class UserService {
isSystemAdmin: user.isSystemAdmin || false,
remaining: user.remaining,
interviewCount: user.interviewCount,
interviewCredits: user.interviewCredits ?? 1,
resumeOptimizeCredits: user.resumeOptimizeCredits ?? 0,
resumeDownloadCredits: user.resumeDownloadCredits ?? 0,
freeOptimizeUsed: user.freeOptimizeUsed ?? 0,
}
}
}