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
+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,
}
}
}