feat: unified gravity system - VIP members consume gravity instead of unlimited; add monthly gravity top-up cron
This commit is contained in:
@@ -2,6 +2,7 @@ import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { User, UserDocument } from './user.schema'
|
||||
import { PricingService } from '../schemas/pricing.service'
|
||||
|
||||
const FREE_OPTIMIZE_LIMIT = 3
|
||||
|
||||
@@ -11,119 +12,143 @@ export class QuotaService {
|
||||
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
private pricingService: PricingService,
|
||||
) {}
|
||||
|
||||
/** 检查并扣除面试引力值(所有计划统一走引力值) */
|
||||
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
|
||||
|
||||
// Backward compat: migrate remaining → interviewCredits
|
||||
if ((user.interviewCredits ?? 0) <= 0 && (user.remaining ?? 0) > 0) {
|
||||
await this.userModel.findByIdAndUpdate(userId, {
|
||||
$set: { interviewCredits: user.remaining, remaining: 0 },
|
||||
}).exec()
|
||||
// 迁移旧字段到 gravity
|
||||
if ((user.gravity ?? 0) <= 0 && this.hasOldCredits(user)) {
|
||||
await this.migrateOldCredits(userId)
|
||||
}
|
||||
|
||||
const result = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, interviewCredits: { $gt: 0 } },
|
||||
{ $inc: { interviewCredits: -1, interviewCount: 1 } },
|
||||
{ new: true },
|
||||
).exec()
|
||||
const rates = (await this.pricingService.getConfig()).gravityRates
|
||||
const cost = rates.interviewPerUse
|
||||
|
||||
// 用 gravity 支付,后备 shareCredits
|
||||
const result = await this.deductGravityOrFallback(userId, cost)
|
||||
if (result) return
|
||||
|
||||
// Fallback to share credits
|
||||
const shareResult = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, shareCredits: { $gt: 0 } },
|
||||
{ $inc: { shareCredits: -1, interviewCount: 1 } },
|
||||
).exec()
|
||||
if (shareResult) return
|
||||
|
||||
throw new HttpException('面试次数已用完,请购买面试次数或开通会员', HttpStatus.FORBIDDEN)
|
||||
throw new HttpException('引力值不足,请充值或分享获取', HttpStatus.FORBIDDEN)
|
||||
}
|
||||
|
||||
/** 检查并扣除优化引力值(所有计划统一走引力值) */
|
||||
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
|
||||
|
||||
// Try paid credits first
|
||||
const paid = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, resumeOptimizeCredits: { $gt: 0 } },
|
||||
{ $inc: { resumeOptimizeCredits: -1 } },
|
||||
).exec()
|
||||
if (paid) return
|
||||
// 迁移旧字段
|
||||
if ((user.gravity ?? 0) <= 0 && this.hasOldCredits(user)) {
|
||||
await this.migrateOldCredits(userId)
|
||||
}
|
||||
|
||||
// Try old remaining credits (backward compat)
|
||||
const oldRemaining = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, remaining: { $gt: 0 } },
|
||||
{ $inc: { remaining: -1 } },
|
||||
).exec()
|
||||
if (oldRemaining) return
|
||||
|
||||
// Then free limit
|
||||
// 免费优化次数
|
||||
const freeResult = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, freeOptimizeUsed: { $lt: FREE_OPTIMIZE_LIMIT } },
|
||||
{ $inc: { freeOptimizeUsed: 1 } },
|
||||
).exec()
|
||||
if (freeResult) return
|
||||
|
||||
// Fallback to share credits
|
||||
const rates = (await this.pricingService.getConfig()).gravityRates
|
||||
const cost = rates.optimizePerUse
|
||||
|
||||
const result = await this.deductGravityOrFallback(userId, cost)
|
||||
if (result) return
|
||||
|
||||
throw new HttpException('引力值不足,请充值或分享获取', HttpStatus.FORBIDDEN)
|
||||
}
|
||||
|
||||
/** 检查并扣除下载引力值 */
|
||||
async checkAndDeductDownload(userId: string, paidDownload: boolean): Promise<boolean> {
|
||||
if (paidDownload) return true
|
||||
|
||||
const rates = (await this.pricingService.getConfig()).gravityRates
|
||||
const cost = rates.downloadPerUse
|
||||
|
||||
const result = await this.deductGravityOrFallback(userId, cost)
|
||||
if (result) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/** 从 gravity 扣除,后备从 shareCredits 扣除(兼容旧数据) */
|
||||
private async deductGravityOrFallback(userId: string, cost: number): Promise<boolean> {
|
||||
// 主路径:gravity
|
||||
const gravResult = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, gravity: { $gte: cost } },
|
||||
{ $inc: { gravity: -cost } },
|
||||
).exec()
|
||||
if (gravResult) return true
|
||||
|
||||
// 后备:旧 shareCredits
|
||||
const shareResult = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, shareCredits: { $gt: 0 } },
|
||||
{ $inc: { shareCredits: -1 } },
|
||||
).exec()
|
||||
if (shareResult) return
|
||||
if (shareResult) return true
|
||||
|
||||
throw new HttpException('简历优化次数已用完,请购买优化次数或开通会员', HttpStatus.FORBIDDEN)
|
||||
return false
|
||||
}
|
||||
|
||||
async grantShareCredits(userId: string, amount = 1): Promise<boolean> {
|
||||
const result = await this.userModel.findByIdAndUpdate(
|
||||
userId,
|
||||
{ $inc: { shareCredits: amount } },
|
||||
).exec()
|
||||
return !!result
|
||||
}
|
||||
|
||||
async checkAndDeductDownload(userId: string, paidDownload: boolean): Promise<boolean> {
|
||||
if (paidDownload) return true
|
||||
|
||||
const result = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, resumeDownloadCredits: { $gt: 0 } },
|
||||
{ $inc: { resumeDownloadCredits: -1 } },
|
||||
).exec()
|
||||
return !!result
|
||||
}
|
||||
|
||||
async grantCredits(userId: string, type: 'interview' | 'optimize' | 'download', amount: number) {
|
||||
/** 增加引力值 */
|
||||
async grantGravity(userId: string, amount: number) {
|
||||
if (amount <= 0) throw new HttpException('无效数量', HttpStatus.BAD_REQUEST)
|
||||
|
||||
const fieldMap: Record<string, string> = {
|
||||
interview: 'interviewCredits',
|
||||
optimize: 'resumeOptimizeCredits',
|
||||
download: 'resumeDownloadCredits',
|
||||
}
|
||||
const field = fieldMap[type]
|
||||
if (!field) throw new HttpException('无效类型', HttpStatus.BAD_REQUEST)
|
||||
|
||||
const result = await this.userModel.findByIdAndUpdate(
|
||||
userId,
|
||||
{ $inc: { [field]: amount } },
|
||||
{ $inc: { gravity: amount } },
|
||||
).exec()
|
||||
if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
async setPlanQuota(userId: string, _plan: string, credits: { interview: number; resumeOptimize: number; resumeDownload: number }) {
|
||||
/** 设置 VIP 套餐引力值额度 */
|
||||
async setPlanQuota(userId: string, gravityAmount: number) {
|
||||
const result = await this.userModel.findByIdAndUpdate(userId, {
|
||||
$set: {
|
||||
remaining: 999,
|
||||
interviewCredits: credits.interview,
|
||||
resumeOptimizeCredits: credits.resumeOptimize,
|
||||
resumeDownloadCredits: credits.resumeDownload,
|
||||
gravity: gravityAmount,
|
||||
freeOptimizeUsed: FREE_OPTIMIZE_LIMIT,
|
||||
},
|
||||
}).exec()
|
||||
if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
/** 是否为非会员用户授予初始引力值 */
|
||||
async grantFreeGravity(userId: string) {
|
||||
await this.userModel.findByIdAndUpdate(userId, {
|
||||
$set: { interviewCredits: 1, gravity: 5 },
|
||||
}).exec()
|
||||
}
|
||||
|
||||
/** 判断是否有旧额度需要迁移 */
|
||||
private hasOldCredits(user: UserDocument): boolean {
|
||||
return (user.interviewCredits ?? 0) > 0
|
||||
|| (user.resumeOptimizeCredits ?? 0) > 0
|
||||
|| (user.resumeDownloadCredits ?? 0) > 0
|
||||
|| (user.remaining ?? 0) > 0
|
||||
}
|
||||
|
||||
/** 迁移旧额度到 gravity */
|
||||
private async migrateOldCredits(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) return
|
||||
const interviewVal = (user.interviewCredits ?? 0) * 5
|
||||
const optimizeVal = (user.resumeOptimizeCredits ?? 0) * 3
|
||||
const downloadVal = (user.resumeDownloadCredits ?? 0) * 2
|
||||
const oldRemainVal = (user.remaining ?? 0) * 5
|
||||
const shareVal = (user.shareCredits ?? 0) * 1
|
||||
const total = interviewVal + optimizeVal + downloadVal + oldRemainVal + shareVal
|
||||
if (total <= 0) return
|
||||
await this.userModel.findByIdAndUpdate(userId, {
|
||||
$inc: { gravity: total },
|
||||
$set: {
|
||||
interviewCredits: 0,
|
||||
resumeOptimizeCredits: 0,
|
||||
resumeDownloadCredits: 0,
|
||||
remaining: 0,
|
||||
shareCredits: 0,
|
||||
},
|
||||
}).exec()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user