diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 09a387f..be63a0d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -24,6 +24,7 @@ import { ContributionModule } from './modules/contribution/contribution.module' import { DailyQuestionModule } from './modules/daily-question/daily-question.module' import { ScheduleModule } from './modules/schedule/schedule.module' import { TtsModule } from './modules/tts/tts.module' +import { PricingModule } from './modules/schemas/pricing.module' const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin' @@ -56,6 +57,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin DailyQuestionModule, ScheduleModule, TtsModule, + PricingModule, ], providers: [ JwtStrategy, diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 66362b7..0f9885c 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -163,6 +163,25 @@ export class AdminController { return { success: true } } + // --- Pricing Management --- + + @Get('pricing') + async getPricing() { + const cfg = await this.configModel.findOne({ key: 'pricing' }).exec() + if (cfg) return cfg.value + return DEFAULT_PRICING + } + + @Post('pricing/save') + async savePricing(@Body() body: any) { + await this.configModel.findOneAndUpdate( + { key: 'pricing' }, + { key: 'pricing', value: body, description: '定价配置' }, + { upsert: true }, + ).exec() + return { success: true } + } + @Get('questions') async getQuestions() { const cfg = await this.configModel.findOne({ key: 'daily_questions' }).exec() @@ -191,6 +210,24 @@ const DEFAULT_CONFIG = { }, } +const DEFAULT_PRICING = { + interview: { pricePerSession: 500, creditsPerPurchase: 1 }, + resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 }, + resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 }, + plans: { + growth: { + price: 1990, durationDays: 30, + credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, + features: ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'], + }, + sprint: { + price: 4990, durationDays: 30, + credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, + features: ['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', '简历优化 50 次/月', '简历下载 30 次/月'], + }, + }, +} + const DEFAULT_QUESTIONS = [ { position: '通用', category: 'behavioral', question: '请做一个简单的自我介绍,突出你的核心优势和职业目标。', referenceAnswer: '建议结构:1) 基本信息 2) 教育背景与专业方向 3) 实习/项目经历 4) 核心优势 5) 职业目标' }, { position: '前端工程师', category: 'technical', question: '请用 JavaScript 实现一个深拷贝函数,并说明可能存在的问题。', referenceAnswer: '可使用递归遍历,注意循环引用用 WeakMap 处理,特殊类型如 Date/RegExp/Map/Set 需单独处理。' }, diff --git a/backend/src/modules/member/member.controller.ts b/backend/src/modules/member/member.controller.ts index f6f9e3e..71b0641 100644 --- a/backend/src/modules/member/member.controller.ts +++ b/backend/src/modules/member/member.controller.ts @@ -6,46 +6,11 @@ import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.sch 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 GROWTH_PRICE = 1990 -const SPRINT_PRICE = 4990 -const DURATION_DAYS = 30 const FREE_DAILY_LIMIT = 2 -interface PlanConfig { - id: string - name: string - price: number - dailyLimit: number - features: string[] -} - -const PLANS: Record = { - free: { - id: 'free', name: '免费版', price: 0, dailyLimit: FREE_DAILY_LIMIT, - features: [ - 'AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)', - ], - }, - growth: { - id: 'growth', name: '成长版', price: GROWTH_PRICE, dailyLimit: 999, - features: [ - '免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', - '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', - '简历优化 20 次/月', '简历下载 10 次/月', - ], - }, - sprint: { - id: 'sprint', name: '冲刺版', price: SPRINT_PRICE, dailyLimit: 999, - features: [ - '成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', - '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', - '简历优化 50 次/月', '简历下载 30 次/月', - ], - }, -} - @Controller('member') @UseGuards(JwtAuthGuard) export class MemberController { @@ -55,21 +20,41 @@ export class MemberController { @InjectModel(User.name) private userModel: Model, @InjectModel(PaymentOrder.name) private orderModel: Model, private quotaService: QuotaService, + private pricingService: PricingService, ) {} @Public() @Get('plans') - getPlans() { + 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 }, - diagnosis: { dailyFreeLimit: 2 }, - optimize: { dailyFreeLimit: 2 }, - price: { monthly: GROWTH_PRICE, sprint: SPRINT_PRICE }, - plans: Object.values(PLANS).map(p => ({ - id: p.id, name: p.name, price: p.price, - priceDisplay: p.price === 0 ? '免费' : `¥${(p.price / 100).toFixed(1)}/月`, - dailyLimit: p.dailyLimit, features: p.features, - })), + plans, } } @@ -77,12 +62,11 @@ export class MemberController { async getStatus(@CurrentUser('userId') userId: string) { const user = await this.userModel.findById(userId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) - const planConfig = PLANS[user.plan] || PLANS.free return { plan: user.plan, - planName: planConfig.name, + planName: user.plan === 'growth' ? '成长版' : user.plan === 'sprint' ? '冲刺版' : '免费版', remaining: user.remaining, - dailyLimit: planConfig.dailyLimit, + dailyLimit: user.plan !== 'free' ? 999 : FREE_DAILY_LIMIT, vipExpireAt: user.vipExpireAt, sprintExpireAt: user.sprintExpireAt, sprintRemaining: user.sprintRemaining || 0, @@ -105,22 +89,20 @@ export class MemberController { 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() + DURATION_DAYS) - const credits = { interview: 999, resumeOptimize: 20, resumeDownload: 10 } + expireAt.setDate(expireAt.getDate() + planCfg.durationDays) 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 } - await this.quotaService.setPlanQuota(userId, order.plan, credits) - return { success: true, plan: user.plan, planName: PLANS[user.plan]?.name, expireAt } + await this.quotaService.setPlanQuota(userId, order.plan, planCfg.credits) + return { success: true, plan: user.plan, planName: user.plan === 'growth' ? '成长版' : '冲刺版', expireAt } } /** 扣减冲刺版权益次数 */ diff --git a/backend/src/modules/payment/payment.controller.spec.ts b/backend/src/modules/payment/payment.controller.spec.ts index 15c4904..d0124de 100644 --- a/backend/src/modules/payment/payment.controller.spec.ts +++ b/backend/src/modules/payment/payment.controller.spec.ts @@ -3,6 +3,7 @@ import { getModelToken } from '@nestjs/mongoose' import { PaymentController } from './payment.controller' import { WechatPayService } from './wechat-pay.service' import { QuotaService } from '../user/quota.service' +import { PricingService } from '../schemas/pricing.service' describe('PaymentController', () => { let controller: PaymentController @@ -10,6 +11,7 @@ describe('PaymentController', () => { let mockOrderModel: any let mockWechatPay: any let mockQuotaService: any + let mockPricingService: any const mockUserId = '507f1f77bcf86cd799439011' @@ -32,6 +34,17 @@ describe('PaymentController', () => { grantCredits: jest.fn().mockResolvedValue(undefined), setPlanQuota: jest.fn().mockResolvedValue(undefined), } + mockPricingService = { + getConfig: jest.fn().mockResolvedValue({ + interview: { pricePerSession: 500, creditsPerPurchase: 1 }, + resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 }, + resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 }, + plans: { + growth: { price: 1990, durationDays: 30, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: [] }, + sprint: { price: 4990, durationDays: 30, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: [] }, + }, + }), + } const module: TestingModule = await Test.createTestingModule({ controllers: [PaymentController], @@ -40,6 +53,7 @@ describe('PaymentController', () => { { provide: getModelToken('PaymentOrder'), useValue: mockOrderModel }, { provide: WechatPayService, useValue: mockWechatPay }, { provide: QuotaService, useValue: mockQuotaService }, + { provide: PricingService, useValue: mockPricingService }, ], }).compile() diff --git a/backend/src/modules/payment/payment.controller.ts b/backend/src/modules/payment/payment.controller.ts index 7c58fd5..0be6abc 100644 --- a/backend/src/modules/payment/payment.controller.ts +++ b/backend/src/modules/payment/payment.controller.ts @@ -7,24 +7,9 @@ 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 { PricingService } from '../schemas/pricing.service' import { Public } from '../../common/decorators/public.decorator' -const GROWTH_AMOUNT = 1990 -const SPRINT_AMOUNT = 4990 -const VIP_DURATION_DAYS = 30 - -const PRODUCT_PRICES: Record = { - interview: 500, - optimize: 300, - download: 200, -} - -const PRODUCT_CREDITS: Record = { - interview: 1, - optimize: 1, - download: 1, -} - @Controller('payment') export class PaymentController { private readonly logger = new Logger(PaymentController.name) @@ -34,6 +19,7 @@ export class PaymentController { @InjectModel(PaymentOrder.name) private orderModel: Model, private wechatPay: WechatPayService, private quotaService: QuotaService, + private pricingService: PricingService, ) {} /** 创建套餐订单(H5:Native 扫码支付) */ @@ -45,7 +31,9 @@ export class PaymentController { if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) if (user.plan !== 'free') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST) - const amount = plan === 'sprint' ? SPRINT_AMOUNT : GROWTH_AMOUNT + const pricing = await this.pricingService.getConfig() + const planCfg = pricing.plans[plan === 'sprint' ? 'sprint' : 'growth'] + const amount = planCfg.price const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员' const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}` const result = await this.wechatPay.nativePay(title, outTradeNo, amount) @@ -69,7 +57,13 @@ export class PaymentController { const user = await this.userModel.findById(userId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) - const price = PRODUCT_PRICES[type] + const pricing = await this.pricingService.getConfig() + const priceMap: Record = { + interview: pricing.interview.pricePerSession, + optimize: pricing.resumeOptimize.pricePerOptimize, + download: pricing.resumeDownload.pricePerDownload, + } + const price = priceMap[type] if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR) const titles: Record = { @@ -97,7 +91,9 @@ export class PaymentController { const openid = user.wxOpenid if (!openid) throw new HttpException('未绑定微信', HttpStatus.BAD_REQUEST) - const amount = plan === 'sprint' ? SPRINT_AMOUNT : GROWTH_AMOUNT + const pricing = await this.pricingService.getConfig() + const planCfg = pricing.plans[plan === 'sprint' ? 'sprint' : 'growth'] + const amount = planCfg.price const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员' const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}` const result = await this.wechatPay.jsapiPay(title, outTradeNo, amount, openid) @@ -123,7 +119,13 @@ export class PaymentController { const openid = user.wxOpenid if (!openid) throw new HttpException('未绑定微信', HttpStatus.BAD_REQUEST) - const price = PRODUCT_PRICES[type] + const pricing = await this.pricingService.getConfig() + const priceMap: Record = { + interview: pricing.interview.pricePerSession, + optimize: pricing.resumeOptimize.pricePerOptimize, + download: pricing.resumeDownload.pricePerDownload, + } + const price = priceMap[type] if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR) const titles: Record = { @@ -184,8 +186,10 @@ export class PaymentController { const user = await this.userModel.findById(order.userId).exec() if (!user || user.plan !== 'free') return + const pricing = await this.pricingService.getConfig() + const planCfg = pricing.plans[order.plan === 'sprint' ? 'sprint' : 'growth'] const expireAt = new Date() - expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS) + expireAt.setDate(expireAt.getDate() + planCfg.durationDays) const isSprint = order.plan === 'sprint' if (isSprint) { user.plan = 'sprint' @@ -195,9 +199,7 @@ export class PaymentController { user.plan = 'growth' user.vipExpireAt = expireAt } - const credits = isSprint - ? { interview: 999, resumeOptimize: 50, resumeDownload: 30 } - : { interview: 999, resumeOptimize: 20, resumeDownload: 10 } + const credits = planCfg.credits user.remaining = 999 user.interviewCredits = credits.interview user.resumeOptimizeCredits = credits.resumeOptimize @@ -207,7 +209,13 @@ export class PaymentController { } private async activateProduct(order: PaymentOrderDocument) { - const credits = PRODUCT_CREDITS[order.type] + const pricing = await this.pricingService.getConfig() + const creditMap: Record = { + interview: pricing.interview.creditsPerPurchase, + optimize: pricing.resumeOptimize.creditsPerPurchase, + download: pricing.resumeDownload.creditsPerPurchase, + } + const credits = creditMap[order.type] if (!credits) return const typeMap: Record = { diff --git a/backend/src/modules/schemas/pricing.module.ts b/backend/src/modules/schemas/pricing.module.ts new file mode 100644 index 0000000..3134581 --- /dev/null +++ b/backend/src/modules/schemas/pricing.module.ts @@ -0,0 +1,12 @@ +import { Module, Global } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { SiteConfig, SiteConfigSchema } from './site-config.schema' +import { PricingService } from './pricing.service' + +@Global() +@Module({ + imports: [MongooseModule.forFeature([{ name: SiteConfig.name, schema: SiteConfigSchema }])], + providers: [PricingService], + exports: [PricingService], +}) +export class PricingModule {} diff --git a/backend/src/modules/schemas/pricing.service.ts b/backend/src/modules/schemas/pricing.service.ts new file mode 100644 index 0000000..b12ef5c --- /dev/null +++ b/backend/src/modules/schemas/pricing.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { Model } from 'mongoose' +import { SiteConfig, SiteConfigDocument } from './site-config.schema' + +interface PricingConfig { + interview: { pricePerSession: number; creditsPerPurchase: number } + resumeOptimize: { freeLimit: number; pricePerOptimize: number; creditsPerPurchase: number } + resumeDownload: { pricePerDownload: number; creditsPerPurchase: number } + plans: { + growth: { price: number; durationDays: number; credits: { interview: number; resumeOptimize: number; resumeDownload: number }; features: string[] } + sprint: { price: number; durationDays: number; credits: { interview: number; resumeOptimize: number; resumeDownload: number }; features: string[] } + } +} + +const DEFAULT_PRICING: PricingConfig = { + interview: { pricePerSession: 500, creditsPerPurchase: 1 }, + resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 }, + resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 }, + plans: { + growth: { price: 1990, durationDays: 30, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: [] }, + sprint: { price: 4990, durationDays: 30, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: [] }, + }, +} + +@Injectable() +export class PricingService { + private cache: PricingConfig | null = null + private cacheTime = 0 + + constructor( + @InjectModel(SiteConfig.name) private configModel: Model, + ) {} + + async getConfig(): Promise { + // Cache for 60s + if (this.cache && Date.now() - this.cacheTime < 60000) { + return this.cache + } + try { + const doc = await this.configModel.findOne({ key: 'pricing' }).exec() + if (doc?.value) { + this.cache = this.mergeDefaults(doc.value) + this.cacheTime = Date.now() + return this.cache + } + } catch {} + return DEFAULT_PRICING + } + + invalidateCache() { + this.cache = null + } + + private mergeDefaults(value: any): PricingConfig { + return { + interview: { ...DEFAULT_PRICING.interview, ...value?.interview }, + resumeOptimize: { ...DEFAULT_PRICING.resumeOptimize, ...value?.resumeOptimize }, + resumeDownload: { ...DEFAULT_PRICING.resumeDownload, ...value?.resumeDownload }, + plans: { + growth: { ...DEFAULT_PRICING.plans.growth, ...value?.plans?.growth, credits: { ...DEFAULT_PRICING.plans.growth.credits, ...value?.plans?.growth?.credits } }, + sprint: { ...DEFAULT_PRICING.plans.sprint, ...value?.plans?.sprint, credits: { ...DEFAULT_PRICING.plans.sprint.credits, ...value?.plans?.sprint?.credits } }, + }, + } + } +} diff --git a/zhiyin-app/src/config.ts b/zhiyin-app/src/config.ts index 48a21ab..ef59860 100644 --- a/zhiyin-app/src/config.ts +++ b/zhiyin-app/src/config.ts @@ -79,6 +79,18 @@ export const API_ENDPOINTS = { TODAY: '/daily-question', BY_POSITION: (position: string) => `/daily-question/position/${position}`, }, + ADMIN: { + CHECK: '/admin/check', + OVERVIEW: '/admin/overview', + USERS: '/admin/users', + SET_VIP: '/admin/set-vip', + CONFIG: '/admin/config', + CONFIG_SAVE: '/admin/config/save', + PRICING: '/admin/pricing', + PRICING_SAVE: '/admin/pricing/save', + ORDERS: '/admin/orders', + ORDER_SYNC: '/admin/order/sync', + }, PAYMENT: { CREATE: '/payment/create', JSAPI: '/payment/jsapi', diff --git a/zhiyin-app/src/pages/admin/admin.vue b/zhiyin-app/src/pages/admin/admin.vue index c22dc16..c1f0a64 100644 --- a/zhiyin-app/src/pages/admin/admin.vue +++ b/zhiyin-app/src/pages/admin/admin.vue @@ -15,11 +15,11 @@ 概览 - 用户管理 - 面试记录 + 用户 + 面试 订单 - 管理员 - 配置 + 定价 + 管理 @@ -102,24 +102,86 @@ 加载中... - - - - 面试限制 - 免费版每场最大轮次{{ memberConfig.interview.maxRoundsFree }} - 会员每场最大轮次{{ memberConfig.interview.maxRoundsVip }} - 免费版每日面试次数{{ memberConfig.interview.dailyFreeLimit }} + + + + 产品定价 + + AI 面试(元/次) + + + + 简历优化(元/次) + + + + 简历下载(元/次) + + + + 免费优化次数 + + - - 诊断与优化限制 - 免费版每日诊断次数{{ memberConfig.diagnosis.dailyFreeLimit }} - 免费版每日优化次数{{ memberConfig.optimize.dailyFreeLimit }} + + + 成长版 ¥{{ growthPriceDisplay }} + + 价格(元/月) + + + + 面试额度/月 + + + + 优化额度/月 + + + + 下载额度/月 + + + + 功能列表(每行一个) + +