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, @InjectModel(PaymentOrder.name) private orderModel: Model, 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 } } }