127 lines
5.5 KiB
TypeScript
127 lines
5.5 KiB
TypeScript
import { Controller, Post, Get, Body, HttpException, HttpStatus, UseGuards, Logger } from '@nestjs/common'
|
|
import { InjectModel } from '@nestjs/mongoose'
|
|
import { Model } from 'mongoose'
|
|
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 { PricingService } from '../schemas/pricing.service'
|
|
import { Public } from '../../common/decorators/public.decorator'
|
|
|
|
const FREE_DAILY_LIMIT = 2
|
|
|
|
@Controller('member')
|
|
@UseGuards(JwtAuthGuard)
|
|
export class MemberController {
|
|
private readonly logger = new Logger(MemberController.name)
|
|
|
|
constructor(
|
|
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
|
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
|
private quotaService: QuotaService,
|
|
private pricingService: PricingService,
|
|
) {}
|
|
|
|
@Public()
|
|
@Get('plans')
|
|
async getPlans() {
|
|
const pricing = await this.pricingService.getConfig()
|
|
const plans = [
|
|
{
|
|
id: 'free', name: '免费版', price: 0,
|
|
priceDisplay: '免费',
|
|
dailyLimit: FREE_DAILY_LIMIT,
|
|
features: ['AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)'],
|
|
credits: { interview: 1, resumeOptimize: 0, resumeDownload: 0 },
|
|
},
|
|
]
|
|
|
|
for (const planId of ['growth', 'sprint'] as const) {
|
|
const cfg = pricing.plans[planId]
|
|
if (cfg) {
|
|
plans.push({
|
|
id: planId,
|
|
name: planId === 'growth' ? '成长版' : '冲刺版',
|
|
price: cfg.price,
|
|
priceDisplay: `¥${(cfg.price / 100).toFixed(1)}/月`,
|
|
dailyLimit: 999,
|
|
features: cfg.features,
|
|
credits: cfg.credits,
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
interview: { dailyFreeLimit: FREE_DAILY_LIMIT, maxRoundsFree: 5, maxRoundsVip: 10 },
|
|
gravityRates: pricing.gravityRates,
|
|
products: {
|
|
interview: { price: pricing.interview.pricePerSession, title: 'AI 模拟面试单次', gravity: pricing.gravityRates.interviewPerUse },
|
|
optimize: { price: pricing.resumeOptimize.pricePerOptimize, title: '简历优化单次', gravity: pricing.gravityRates.optimizePerUse },
|
|
download: { price: pricing.resumeDownload.pricePerDownload, title: '简历下载', gravity: pricing.gravityRates.downloadPerUse },
|
|
},
|
|
plans,
|
|
}
|
|
}
|
|
|
|
@Get('status')
|
|
async getStatus(@CurrentUser('userId') userId: string) {
|
|
const user = await this.userModel.findById(userId).exec()
|
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
|
return {
|
|
plan: user.plan,
|
|
planName: user.plan === 'growth' ? '成长版' : user.plan === 'sprint' ? '冲刺版' : '免费版',
|
|
remaining: user.remaining,
|
|
gravity: user.gravity ?? 0,
|
|
dailyLimit: user.plan !== 'free' ? 999 : FREE_DAILY_LIMIT,
|
|
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',
|
|
}
|
|
}
|
|
|
|
/** 凭订单激活套餐(前端 JSAPI 支付成功后兜底调用) */
|
|
@Post('pay')
|
|
async pay(@CurrentUser('userId') userId: string, @Body('outTradeNo') outTradeNo: string) {
|
|
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 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 pricing = await this.pricingService.getConfig()
|
|
const planCfg = pricing.plans[order.plan === 'sprint' ? 'sprint' : 'growth']
|
|
const expireAt = new Date()
|
|
expireAt.setDate(expireAt.getDate() + planCfg.durationDays)
|
|
if (order.plan === 'sprint') {
|
|
user.plan = 'sprint'
|
|
user.sprintExpireAt = expireAt
|
|
user.sprintRemaining = 10
|
|
} else {
|
|
user.plan = 'growth'
|
|
user.vipExpireAt = expireAt
|
|
}
|
|
await this.quotaService.setPlanQuota(userId, planCfg.gravityPerMonth)
|
|
return { success: true, plan: user.plan, planName: user.plan === 'growth' ? '成长版' : '冲刺版', expireAt }
|
|
}
|
|
|
|
/** 扣减冲刺版权益次数 */
|
|
@Post('sprint/deduct')
|
|
async deductSprint(@CurrentUser('userId') userId: string) {
|
|
const user = await this.userModel.findById(userId).exec()
|
|
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
|
if (user.plan !== 'sprint') throw new HttpException('非冲刺版会员', HttpStatus.FORBIDDEN)
|
|
if (user.sprintExpireAt && user.sprintExpireAt < new Date()) throw new HttpException('会员已过期', HttpStatus.FORBIDDEN)
|
|
if ((user.sprintRemaining || 0) <= 0) throw new HttpException('剩余次数不足', HttpStatus.FORBIDDEN)
|
|
user.sprintRemaining = (user.sprintRemaining || 0) - 1
|
|
await user.save()
|
|
return { success: true, sprintRemaining: user.sprintRemaining }
|
|
}
|
|
} |