From 065fe7a1868a7e73a2ace1c5122203c90c842255 Mon Sep 17 00:00:00 2001 From: yuzhiran Date: Fri, 12 Jun 2026 09:31:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=98=E8=B4=B9=E4=BD=93=E7=B3=BB?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20P0=20-=20=E9=85=8D=E9=A2=9D=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E5=8C=96/=E7=AE=80=E5=8E=86=E4=BB=98=E8=B4=B9?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD/PDF=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package-lock.json | 167 +++++++++- backend/package.json | 1 + backend/src/modules/admin/admin.controller.ts | 31 +- backend/src/modules/admin/admin.module.ts | 2 + .../src/modules/analyze/analyze.controller.ts | 29 +- backend/src/modules/analyze/analyze.module.ts | 2 + .../modules/interview/interview.service.ts | 5 +- .../src/modules/member/member.controller.ts | 19 +- backend/src/modules/member/member.module.ts | 12 +- .../modules/payment/payment-order.schema.ts | 6 + .../payment/payment.controller.spec.ts | 23 +- .../src/modules/payment/payment.controller.ts | 181 ++++++++--- backend/src/modules/payment/payment.module.ts | 13 +- .../src/modules/resume/resume-pdf.service.ts | 81 +++++ .../src/modules/resume/resume.controller.ts | 36 ++- backend/src/modules/resume/resume.module.ts | 10 +- backend/src/modules/resume/resume.schema.ts | 9 + backend/src/modules/resume/resume.service.ts | 31 +- backend/src/modules/user/quota.service.ts | 92 ++++++ backend/src/modules/user/user.module.ts | 5 +- backend/src/modules/user/user.schema.ts | 13 + backend/src/modules/user/user.service.ts | 4 + docs/PAYMENT-REDESIGN.md | 299 ++++++++++++++++++ 23 files changed, 965 insertions(+), 106 deletions(-) create mode 100644 backend/src/modules/resume/resume-pdf.service.ts create mode 100644 backend/src/modules/user/quota.service.ts create mode 100644 docs/PAYMENT-REDESIGN.md diff --git a/backend/package-lock.json b/backend/package-lock.json index 70942ba..b675afc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -33,6 +33,7 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pdf-parse": "^2.4.5", + "puppeteer": "^25.1.0", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", "tesseract.js": "^7.0.0", @@ -2264,6 +2265,30 @@ "node": ">=18" } }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-3.0.4.tgz", + "integrity": "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==", + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + } + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.49", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", @@ -3211,7 +3236,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -3914,6 +3938,22 @@ "node": ">=6.0" } }, + "node_modules/chromium-bidi": { + "version": "16.0.1", + "resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-16.0.1.tgz", + "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -4023,7 +4063,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -4038,7 +4077,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -4422,6 +4460,12 @@ "node": ">=8" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1624250", + "resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz", + "integrity": "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==", + "license": "BSD-3-Clause" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -4519,7 +4563,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "devOptional": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -4611,7 +4654,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5136,7 +5178,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5707,7 +5748,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -6946,6 +6986,18 @@ "immediate": "~3.0.5" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7296,6 +7348,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -7308,6 +7366,15 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmmirror.com/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/mongodb": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", @@ -8149,6 +8216,44 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "25.1.0", + "resolved": "https://registry.npmmirror.com/puppeteer/-/puppeteer-25.1.0.tgz", + "integrity": "sha512-7L6/0JM7XStK99lIL4xQySyNEXNfII6pk0BxkI5kKBTOhR7AsoQiv067YTsE/rIXxQiq9ajlO4WcqBjS/FWK1A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.4", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1624250", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.1.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.1.0", + "resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-25.1.0.tgz", + "integrity": "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.4", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1624250", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -8308,7 +8413,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8806,7 +8910,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -8837,7 +8940,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -9522,6 +9624,12 @@ "node": ">= 0.6" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "license": "MIT" + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -9775,6 +9883,12 @@ "defaults": "^1.0.3" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -9964,6 +10078,27 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlbuilder": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", @@ -9986,7 +10121,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -10003,7 +10137,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -10022,7 +10155,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -10049,6 +10181,15 @@ "engines": { "node": "*" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 44ef29a..236adf4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -61,6 +61,7 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pdf-parse": "^2.4.5", + "puppeteer": "^25.1.0", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", "tesseract.js": "^7.0.0", diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 85a401d..66362b7 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -8,6 +8,7 @@ import { User, UserDocument } from '../user/user.schema' import { Interview, InterviewDocument } from '../interview/interview.schema' import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema' import { SiteConfig, SiteConfigDocument } from '../schemas/site-config.schema' +import { QuotaService } from '../user/quota.service' import { WechatPayService } from '../payment/wechat-pay.service' const VIP_DURATION_DAYS = 30 @@ -20,6 +21,7 @@ export class AdminController { @InjectModel(Interview.name) private interviewModel: Model, @InjectModel(PaymentOrder.name) private orderModel: Model, @InjectModel(SiteConfig.name) private configModel: Model, + private quotaService: QuotaService, private wechatPay: WechatPayService, ) {} @@ -82,8 +84,7 @@ export class AdminController { expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS) user.plan = 'growth' user.vipExpireAt = expireAt - user.remaining = 999 - await user.save() + await this.quotaService.setPlanQuota(targetUserId, 'growth', { interview: 999, resumeOptimize: 20, resumeDownload: 10 }) return { success: true, plan: 'growth', expireAt } } @@ -126,14 +127,20 @@ export class AdminController { order.wxTransactionId = wxResult?.transaction_id || '' order.paidAt = new Date() await order.save() - const user = await this.userModel.findById(order.userId).exec() - if (user && user.plan === 'free') { - const expireAt = new Date() - expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS) - user.plan = 'growth' - user.vipExpireAt = expireAt - user.remaining = 999 - await user.save() + if (order.type === 'membership') { + const user = await this.userModel.findById(order.userId).exec() + if (user && user.plan === 'free') { + const expireAt = new Date() + expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS) + user.plan = 'growth' + user.vipExpireAt = expireAt + await this.quotaService.setPlanQuota(order.userId, 'growth', { interview: 999, resumeOptimize: 20, resumeDownload: 10 }) + } + } else { + const credits = { interview: 1, optimize: 1, download: 1 }[order.type] + if (credits) { + await this.quotaService.grantCredits(order.userId, order.type as any, credits) + } } } return { order, wxResult } @@ -179,8 +186,8 @@ const DEFAULT_CONFIG = { optimize: { dailyFreeLimit: 2 }, price: { monthly: 1990 }, plans: { - free: { name: '免费版', price: 0, features: ['每日 3 次 AI 模拟面试', '每场最多 5 轮 AI 对话', '基础面试报告', '简历诊断', '简历优化'] }, - growth: { name: '成长版', price: 1990, features: ['免费版全部权益', '无限面试次数', '每场最多 10 轮 AI 对话', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '参考回答思路', '公司真题库'] }, + free: { name: '免费版', price: 0, features: ['AI 模拟面试 1 次(体验)', '每场最多 5 轮 AI 对话', '基础面试报告', '简历优化(限 3 次)'] }, + growth: { name: '成长版', price: 1990, features: ['免费版全部权益', 'AI 数字人面试无限次', '每场最多 10 轮 AI 对话', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'] }, }, } diff --git a/backend/src/modules/admin/admin.module.ts b/backend/src/modules/admin/admin.module.ts index 92b139c..62fd687 100644 --- a/backend/src/modules/admin/admin.module.ts +++ b/backend/src/modules/admin/admin.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common' import { MongooseModule } from '@nestjs/mongoose' import { AdminController } from './admin.controller' import { User, UserSchema } from '../user/user.schema' +import { UserModule } from '../user/user.module' import { Interview, InterviewSchema } from '../interview/interview.schema' import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema' import { WechatPayService } from '../payment/wechat-pay.service' @@ -16,6 +17,7 @@ import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema' { name: PaymentOrder.name, schema: PaymentOrderSchema }, { name: SiteConfig.name, schema: SiteConfigSchema }, ]), + UserModule, ], controllers: [AdminController], providers: [WechatPayService, AdminGuard], diff --git a/backend/src/modules/analyze/analyze.controller.ts b/backend/src/modules/analyze/analyze.controller.ts index f8b650a..09949e0 100644 --- a/backend/src/modules/analyze/analyze.controller.ts +++ b/backend/src/modules/analyze/analyze.controller.ts @@ -5,6 +5,8 @@ import { AnalyzeService } from './analyze.service' import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard' import { CurrentUser } from '../../common/decorators/current-user.decorator' import { UserService } from '../user/user.service' +import { QuotaService } from '../user/quota.service' +import { ResumeService } from '../resume/resume.service' import { BenchmarkService, PositionBenchmark } from '../progress/benchmark.service' import { Progress, ProgressDocument } from '../schemas/progress.schema' @@ -13,6 +15,8 @@ export class AnalyzeController { constructor( private analyzeService: AnalyzeService, private userService: UserService, + private quotaService: QuotaService, + private resumeService: ResumeService, private benchmarkService: BenchmarkService, @InjectModel(Progress.name) private progressModel: Model, ) {} @@ -26,9 +30,21 @@ export class AnalyzeController { @UseGuards(JwtAuthGuard) @Post('optimize') - async optimize(@Body('content') content: string, @Body('direction') direction: string, @CurrentUser('userId') userId: string) { + async optimize( + @Body('content') content: string, + @Body('direction') direction: string, + @Body('resumeId') resumeId: string, + @CurrentUser('userId') userId: string, + ) { await this.checkAnalyzeLimit(userId) - return this.analyzeService.optimize(content, direction) + const result = await this.analyzeService.optimize(content, direction) + + if (resumeId && result.optimized && result.optimized !== content) { + const updated = await this.resumeService.updateAfterOptimize(resumeId, userId, result.optimized, direction) + return { ...result, newVersion: updated } + } + + return result } /** 技能缺口分析:将用户四维分数与目标岗位基准对比 */ @@ -106,13 +122,6 @@ export class AnalyzeController { } private async checkAnalyzeLimit(userId: string) { - const user = await this.userService.getModel().findById(userId).exec() - if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) - if (user.plan !== 'free') return - if (user.remaining <= 0) { - throw new HttpException('免费版每日次数已用完,升级会员后不限次使用', HttpStatus.FORBIDDEN) - } - user.remaining -= 1 - await user.save() + await this.quotaService.checkAndDeductOptimize(userId) } } diff --git a/backend/src/modules/analyze/analyze.module.ts b/backend/src/modules/analyze/analyze.module.ts index 3d54402..e2ca2cb 100644 --- a/backend/src/modules/analyze/analyze.module.ts +++ b/backend/src/modules/analyze/analyze.module.ts @@ -3,12 +3,14 @@ import { MongooseModule } from '@nestjs/mongoose' import { AnalyzeController } from './analyze.controller' import { AnalyzeService } from './analyze.service' import { UserModule } from '../user/user.module' +import { ResumeModule } from '../resume/resume.module' import { ProgressModule } from '../progress/progress.module' import { Progress, ProgressSchema } from '../schemas/progress.schema' @Module({ imports: [ UserModule, + ResumeModule, ProgressModule, MongooseModule.forFeature([{ name: Progress.name, schema: ProgressSchema }]), ], diff --git a/backend/src/modules/interview/interview.service.ts b/backend/src/modules/interview/interview.service.ts index 52480f8..1d26d86 100644 --- a/backend/src/modules/interview/interview.service.ts +++ b/backend/src/modules/interview/interview.service.ts @@ -5,6 +5,7 @@ import { Interview, InterviewDocument } from './interview.schema' import { Progress, ProgressDocument } from '../schemas/progress.schema' import { AiService } from '../ai/ai.service' import { UserService } from '../user/user.service' +import { QuotaService } from '../user/quota.service' import { analyzeSpeech } from '../../common/utils/filler-words' @Injectable() @@ -14,11 +15,11 @@ export class InterviewService { @InjectModel(Progress.name) private progressModel: Model, private aiService: AiService, private userService: UserService, + private quotaService: QuotaService, ) {} async create(userId: string, position: string) { - // 扣减使用次数 - await this.userService.deductRemaining(userId) + await this.quotaService.checkAndDeductInterview(userId) const firstQuestion = await this.aiService.call({ systemPrompt: `你是一位专业的${position}面试官。请针对校招该岗位提出第一个面试问题,要求具体且有针对性。直接输出问题,不要多余内容。`, diff --git a/backend/src/modules/member/member.controller.ts b/backend/src/modules/member/member.controller.ts index 33168a2..f6f9e3e 100644 --- a/backend/src/modules/member/member.controller.ts +++ b/backend/src/modules/member/member.controller.ts @@ -5,6 +5,7 @@ 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 { Public } from '../../common/decorators/public.decorator' const GROWTH_PRICE = 1990 @@ -24,14 +25,15 @@ const PLANS: Record = { free: { id: 'free', name: '免费版', price: 0, dailyLimit: FREE_DAILY_LIMIT, features: [ - '每日 2 次 AI 模拟面试', '基础面试报告', '通用题库随机出题', '简历诊断(限 3 次)', + 'AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)', ], }, growth: { id: 'growth', name: '成长版', price: GROWTH_PRICE, dailyLimit: 999, features: [ - '免费版全部权益', '无限面试次数', '详细面试报告(四维评分)', + '免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', + '简历优化 20 次/月', '简历下载 10 次/月', ], }, sprint: { @@ -39,6 +41,7 @@ const PLANS: Record = { features: [ '成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', + '简历优化 50 次/月', '简历下载 30 次/月', ], }, } @@ -51,6 +54,7 @@ export class MemberController { constructor( @InjectModel(User.name) private userModel: Model, @InjectModel(PaymentOrder.name) private orderModel: Model, + private quotaService: QuotaService, ) {} @Public() @@ -82,6 +86,10 @@ export class MemberController { 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', } } @@ -99,16 +107,19 @@ export class MemberController { const expireAt = new Date() expireAt.setDate(expireAt.getDate() + DURATION_DAYS) + const credits = { interview: 999, resumeOptimize: 20, resumeDownload: 10 } 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 } - user.remaining = 999 - await user.save() + await this.quotaService.setPlanQuota(userId, order.plan, credits) return { success: true, plan: user.plan, planName: PLANS[user.plan]?.name, expireAt } } diff --git a/backend/src/modules/member/member.module.ts b/backend/src/modules/member/member.module.ts index 9520a6d..3c8b60f 100644 --- a/backend/src/modules/member/member.module.ts +++ b/backend/src/modules/member/member.module.ts @@ -2,13 +2,17 @@ import { Module } from '@nestjs/common' import { MongooseModule } from '@nestjs/mongoose' import { MemberController } from './member.controller' import { User, UserSchema } from '../user/user.schema' +import { UserModule } from '../user/user.module' import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema' @Module({ - imports: [MongooseModule.forFeature([ - { name: User.name, schema: UserSchema }, - { name: PaymentOrder.name, schema: PaymentOrderSchema }, - ])], + imports: [ + MongooseModule.forFeature([ + { name: User.name, schema: UserSchema }, + { name: PaymentOrder.name, schema: PaymentOrderSchema }, + ]), + UserModule, + ], controllers: [MemberController], }) export class MemberModule {} diff --git a/backend/src/modules/payment/payment-order.schema.ts b/backend/src/modules/payment/payment-order.schema.ts index 65ed379..e872bec 100644 --- a/backend/src/modules/payment/payment-order.schema.ts +++ b/backend/src/modules/payment/payment-order.schema.ts @@ -27,12 +27,18 @@ export class PaymentOrder { @Prop({ default: 'pending' }) status: string + @Prop({ default: 'membership' }) + type: string // membership | interview | optimize | download + @Prop({ default: 'growth' }) plan: string // growth | sprint @Prop({ default: 'native' }) channel: string // native | jsapi + @Prop({ type: Object }) + metadata?: Record + @Prop() paidAt?: Date diff --git a/backend/src/modules/payment/payment.controller.spec.ts b/backend/src/modules/payment/payment.controller.spec.ts index d9a8900..15c4904 100644 --- a/backend/src/modules/payment/payment.controller.spec.ts +++ b/backend/src/modules/payment/payment.controller.spec.ts @@ -2,12 +2,14 @@ import { Test, TestingModule } from '@nestjs/testing' import { getModelToken } from '@nestjs/mongoose' import { PaymentController } from './payment.controller' import { WechatPayService } from './wechat-pay.service' +import { QuotaService } from '../user/quota.service' describe('PaymentController', () => { let controller: PaymentController let mockUserModel: any let mockOrderModel: any let mockWechatPay: any + let mockQuotaService: any const mockUserId = '507f1f77bcf86cd799439011' @@ -26,6 +28,10 @@ describe('PaymentController', () => { jsapiPay: jest.fn().mockResolvedValue({ paySign: 'mock-sign', nonceStr: 'mock-nonce', package: 'prepay_id=mock', timeStamp: '123456', signType: 'RSA' }), queryOrder: jest.fn().mockResolvedValue({ trade_state: 'SUCCESS', transaction_id: 'wx123' }), } + mockQuotaService = { + grantCredits: jest.fn().mockResolvedValue(undefined), + setPlanQuota: jest.fn().mockResolvedValue(undefined), + } const module: TestingModule = await Test.createTestingModule({ controllers: [PaymentController], @@ -33,6 +39,7 @@ describe('PaymentController', () => { { provide: getModelToken('User'), useValue: mockUserModel }, { provide: getModelToken('PaymentOrder'), useValue: mockOrderModel }, { provide: WechatPayService, useValue: mockWechatPay }, + { provide: QuotaService, useValue: mockQuotaService }, ], }).compile() @@ -107,9 +114,9 @@ describe('PaymentController', () => { }) it('should return order status', async () => { - mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'pending', plan: 'growth' }) }) + mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'pending', plan: 'growth', type: 'membership' }) }) const result = await controller.checkOrder('ORD123', mockUserId) - expect(result).toEqual({ status: 'pending', plan: 'growth' }) + expect(result).toEqual({ status: 'pending', plan: 'growth', type: 'membership' }) expect(mockOrderModel.findOne).toHaveBeenCalledWith({ outTradeNo: 'ORD123', userId: mockUserId }) }) }) @@ -128,19 +135,23 @@ describe('PaymentController', () => { }) it('should activate growth plan', async () => { - mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'growth' }) }) - const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, save: jest.fn().mockResolvedValue(true) } + mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'growth', type: 'membership' }) }) + const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, interviewCredits: 1, resumeOptimizeCredits: 0, resumeDownloadCredits: 0, freeOptimizeUsed: 0, save: jest.fn().mockResolvedValue(true) } mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) }) const result = await controller.activate(mockUserId, 'ORD123') expect(result.success).toBe(true) expect(result.plan).toBe('growth') expect(mockUser.save).toHaveBeenCalled() + expect(mockUser.plan).toBe('growth') + expect(mockUser.interviewCredits).toBe(999) + expect(mockUser.resumeOptimizeCredits).toBe(20) + expect(mockUser.resumeDownloadCredits).toBe(10) }) it('should activate sprint plan', async () => { - mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'sprint' }) }) - const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, save: jest.fn().mockResolvedValue(true) } + mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'sprint', type: 'membership' }) }) + const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, interviewCredits: 1, resumeOptimizeCredits: 0, resumeDownloadCredits: 0, freeOptimizeUsed: 0, save: jest.fn().mockResolvedValue(true) } mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) }) const result = await controller.activate(mockUserId, 'ORD123') diff --git a/backend/src/modules/payment/payment.controller.ts b/backend/src/modules/payment/payment.controller.ts index 82bd742..7c58fd5 100644 --- a/backend/src/modules/payment/payment.controller.ts +++ b/backend/src/modules/payment/payment.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Get, Param, Query, Body, UseGuards, HttpException, HttpStatus, Logger, Req } from '@nestjs/common' +import { Controller, Post, Get, Param, Body, UseGuards, HttpException, HttpStatus, Logger, Req } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' import { User, UserDocument } from '../user/user.schema' @@ -6,12 +6,25 @@ import { PaymentOrder, PaymentOrderDocument } from './payment-order.schema' 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 { Public } from '../../common/decorators/public.decorator' -const GROWTH_AMOUNT = 1990 // 19.9 元(分) -const SPRINT_AMOUNT = 4990 // 49.9 元(分) +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) @@ -20,9 +33,10 @@ export class PaymentController { @InjectModel(User.name) private userModel: Model, @InjectModel(PaymentOrder.name) private orderModel: Model, private wechatPay: WechatPayService, + private quotaService: QuotaService, ) {} - /** 创建订单(H5:Native 扫码支付) */ + /** 创建套餐订单(H5:Native 扫码支付) */ @UseGuards(JwtAuthGuard) @Post('create') async create(@CurrentUser('userId') userId: string, @Body('plan') plan: string = 'growth') { @@ -36,11 +50,42 @@ export class PaymentController { const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}` const result = await this.wechatPay.nativePay(title, outTradeNo, amount) - await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount, title, status: 'pending', channel: 'native', plan }) + await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount, title, status: 'pending', channel: 'native', type: 'membership', plan }) return { outTradeNo, codeUrl: result.codeUrl, amount, title } } + /** 创建按次购买订单 */ + @UseGuards(JwtAuthGuard) + @Post('create-product') + async createProduct( + @CurrentUser('userId') userId: string, + @Body('type') type: string, + @Body('metadata') metadata?: Record, + ) { + if (!['interview', 'optimize', 'download'].includes(type)) { + throw new HttpException('无效产品类型', HttpStatus.BAD_REQUEST) + } + const user = await this.userModel.findById(userId).exec() + if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) + + const price = PRODUCT_PRICES[type] + if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR) + + const titles: Record = { + interview: 'AI 模拟面试单次', + optimize: '简历优化单次', + download: '简历下载', + } + const title = titles[type] || type + const outTradeNo = `${type.slice(0, 3).toUpperCase()}${Date.now()}${userId.slice(-6)}` + const result = await this.wechatPay.nativePay(title, outTradeNo, price) + + await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount: price, title, status: 'pending', channel: 'native', type, plan: 'growth', metadata }) + + return { outTradeNo, codeUrl: result.codeUrl, amount: price, title } + } + /** JSAPI 支付(微信小程序) */ @UseGuards(JwtAuthGuard) @Post('jsapi') @@ -57,7 +102,40 @@ export class PaymentController { const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}` const result = await this.wechatPay.jsapiPay(title, outTradeNo, amount, openid) - await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount, title, status: 'pending', channel: 'jsapi', plan }) + await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount, title, status: 'pending', channel: 'jsapi', type: 'membership', plan }) + + return { ...result, outTradeNo } + } + + /** JSAPI 按次购买 */ + @UseGuards(JwtAuthGuard) + @Post('jsapi-product') + async jsapiProduct( + @CurrentUser('userId') userId: string, + @Body('type') type: string, + @Body('metadata') metadata?: Record, + ) { + if (!['interview', 'optimize', 'download'].includes(type)) { + throw new HttpException('无效产品类型', HttpStatus.BAD_REQUEST) + } + const user = await this.userModel.findById(userId).exec() + if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) + const openid = user.wxOpenid + if (!openid) throw new HttpException('未绑定微信', HttpStatus.BAD_REQUEST) + + const price = PRODUCT_PRICES[type] + if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR) + + const titles: Record = { + interview: 'AI 模拟面试单次', + optimize: '简历优化单次', + download: '简历下载', + } + const title = titles[type] || type + const outTradeNo = `${type.slice(0, 3).toUpperCase()}${Date.now()}${userId.slice(-6)}` + const result = await this.wechatPay.jsapiPay(title, outTradeNo, price, openid) + + await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount: price, title, status: 'pending', channel: 'jsapi', type, plan: 'growth', metadata }) return { ...result, outTradeNo } } @@ -65,10 +143,7 @@ export class PaymentController { /** 支付回调通知 */ @Public() @Post('notify') - async notify( - @Body() body: any, - @Req() req: any, - ) { + async notify(@Body() body: any, @Req() req: any) { try { const wechatSignature = req.headers['wechatpay-signature'] || '' const wechatTimestamp = req.headers['wechatpay-timestamp'] || '' @@ -79,7 +154,6 @@ export class PaymentController { const outTradeNo = decrypted.out_trade_no const wxTransactionId = decrypted.transaction_id - // 从数据库订单查找 userId,而非从 outTradeNo 解析 const order = await this.orderModel.findOne({ outTradeNo }).exec() if (!order) { this.logger.warn(`支付回调:订单不存在 ${outTradeNo}`) @@ -94,21 +168,10 @@ export class PaymentController { await order.save() } - // 根据订单 plan 激活对应套餐 - const user = await this.userModel.findById(order.userId).exec() - if (user && user.plan === 'free') { - const expireAt = new Date() - expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS) - if (order.plan === 'sprint') { - user.plan = 'sprint' - user.sprintExpireAt = expireAt - user.sprintRemaining = 10 // 每月 10 次冲刺权益(语音分析+缺口分析) - } else { - user.plan = 'growth' - user.vipExpireAt = expireAt - } - user.remaining = 999 - await user.save() + if (order.type === 'membership') { + await this.activateMembership(order) + } else { + await this.activateProduct(order) } return { code: 'SUCCESS', message: '成功' } } catch (e) { @@ -117,6 +180,47 @@ export class PaymentController { } } + private async activateMembership(order: PaymentOrderDocument) { + const user = await this.userModel.findById(order.userId).exec() + if (!user || user.plan !== 'free') return + + const expireAt = new Date() + expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS) + const isSprint = order.plan === 'sprint' + if (isSprint) { + user.plan = 'sprint' + user.sprintExpireAt = expireAt + user.sprintRemaining = 10 + } else { + user.plan = 'growth' + user.vipExpireAt = expireAt + } + const credits = isSprint + ? { interview: 999, resumeOptimize: 50, resumeDownload: 30 } + : { interview: 999, resumeOptimize: 20, resumeDownload: 10 } + user.remaining = 999 + user.interviewCredits = credits.interview + user.resumeOptimizeCredits = credits.resumeOptimize + user.resumeDownloadCredits = credits.resumeDownload + user.freeOptimizeUsed = 3 + await user.save() + } + + private async activateProduct(order: PaymentOrderDocument) { + const credits = PRODUCT_CREDITS[order.type] + if (!credits) return + + const typeMap: Record = { + interview: 'interview', + optimize: 'optimize', + download: 'download', + } + const mapped = typeMap[order.type] + if (mapped) { + await this.quotaService.grantCredits(order.userId, mapped, credits) + } + } + /** 查询订单(微信侧) */ @UseGuards(JwtAuthGuard) @Post('query') @@ -132,32 +236,23 @@ export class PaymentController { async checkOrder(@Param('outTradeNo') outTradeNo: string, @CurrentUser('userId') userId: string) { const order = await this.orderModel.findOne({ outTradeNo, userId }).exec() if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND) - return { status: order.status, plan: order.plan } + return { status: order.status, plan: order.plan, type: order.type } } - /** 凭订单号激活套餐(前端支付成功后调用,兜底) */ + /** 凭订单号激活(前端支付成功后调用,兜底) */ @UseGuards(JwtAuthGuard) @Post('activate') async activate(@CurrentUser('userId') userId: string, @Body('outTradeNo') outTradeNo: string) { 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 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 expireAt = new Date() - expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS) - if (order.plan === 'sprint') { - user.plan = 'sprint' - user.sprintExpireAt = expireAt - user.sprintRemaining = 10 - } else { - user.plan = 'growth' - user.vipExpireAt = expireAt + if (order.type === 'membership') { + await this.activateMembership(order) + return { success: true, plan: order.plan } } - user.remaining = 999 - await user.save() - return { success: true, plan: user.plan } + + await this.activateProduct(order) + return { success: true, type: order.type } } } diff --git a/backend/src/modules/payment/payment.module.ts b/backend/src/modules/payment/payment.module.ts index 49e0bbf..4bda398 100644 --- a/backend/src/modules/payment/payment.module.ts +++ b/backend/src/modules/payment/payment.module.ts @@ -3,14 +3,17 @@ import { MongooseModule } from '@nestjs/mongoose' import { PaymentController } from './payment.controller' import { WechatPayService } from './wechat-pay.service' import { User, UserSchema } from '../user/user.schema' - +import { UserModule } from '../user/user.module' import { PaymentOrder, PaymentOrderSchema } from './payment-order.schema' @Module({ - imports: [MongooseModule.forFeature([ - { name: User.name, schema: UserSchema }, - { name: PaymentOrder.name, schema: PaymentOrderSchema }, - ])], + imports: [ + MongooseModule.forFeature([ + { name: User.name, schema: UserSchema }, + { name: PaymentOrder.name, schema: PaymentOrderSchema }, + ]), + UserModule, + ], controllers: [PaymentController], providers: [WechatPayService], exports: [WechatPayService], diff --git a/backend/src/modules/resume/resume-pdf.service.ts b/backend/src/modules/resume/resume-pdf.service.ts new file mode 100644 index 0000000..d7ce976 --- /dev/null +++ b/backend/src/modules/resume/resume-pdf.service.ts @@ -0,0 +1,81 @@ +import * as crypto from 'crypto' +import { Injectable, Logger } from '@nestjs/common' + +@Injectable() +export class ResumePdfService { + private readonly logger = new Logger(ResumePdfService.name) + + async generatePdf(params: { + title: string + content: string + targetPosition?: string + userName?: string + }): Promise { + const { default: puppeteer } = await import('puppeteer') + const html = this.buildHtml(params) + const browser = await puppeteer.launch({ + executablePath: '/root/.cache/puppeteer/chrome/linux-149.0.7827.22/chrome-linux64/chrome', + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }) + try { + const page = await browser.newPage() + await page.setContent(html, { waitUntil: 'load' }) + const pdf = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }, + }) + return Buffer.from(pdf) + } finally { + await browser.close() + } + } + + private buildHtml(params: { + title: string + content: string + targetPosition?: string + userName?: string + }): string { + const contentHtml = params.content + .replace(/\n/g, '
') + .replace(/### (.+)/g, '

$1

') + .replace(/\*\*(.+?)\*\*/g, '$1') + + return ` + + + + + + +
+

${params.title}

+
${params.targetPosition ? `目标岗位: ${params.targetPosition}` : ''}
+
${contentHtml}
+ +
+ +` + } + + computeHash(content: string): string { + return crypto.createHash('md5').update(content).digest('hex') + } +} diff --git a/backend/src/modules/resume/resume.controller.ts b/backend/src/modules/resume/resume.controller.ts index b92f62d..cfd3e92 100644 --- a/backend/src/modules/resume/resume.controller.ts +++ b/backend/src/modules/resume/resume.controller.ts @@ -1,10 +1,17 @@ -import { Controller, Post, Get, Delete, Param, Body } from '@nestjs/common' +import { Controller, Post, Get, Delete, Param, Body, Res, HttpException, HttpStatus } from '@nestjs/common' +import { Response } from 'express' import { ResumeService } from './resume.service' +import { ResumePdfService } from './resume-pdf.service' +import { QuotaService } from '../user/quota.service' import { CurrentUser } from '../../common/decorators/current-user.decorator' @Controller('resume') export class ResumeController { - constructor(private resumeService: ResumeService) {} + constructor( + private resumeService: ResumeService, + private resumePdfService: ResumePdfService, + private quotaService: QuotaService, + ) {} @Post('create') async create( @@ -21,6 +28,31 @@ export class ResumeController { return this.resumeService.list(userId) } + @Post(':id/download') + async download(@Param('id') id: string, @CurrentUser('userId') userId: string, @Res() res: Response) { + const resume = await this.resumeService.getDetail(id, userId) + + const canDownload = await this.quotaService.checkDownload(userId, resume) + if (!canDownload) { + throw new HttpException('请先付费下载', HttpStatus.PAYMENT_REQUIRED) + } + + await this.quotaService.deductDownload(userId, resume) + if (!resume.paidDownload) { + await this.resumeService.markPaid(id, userId) + } + + const pdf = await this.resumePdfService.generatePdf({ + title: resume.title, + content: resume.content, + targetPosition: resume.targetPosition, + }) + + res.setHeader('Content-Type', 'application/pdf') + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(resume.title)}.pdf"`) + res.send(pdf) + } + @Get(':id') async getDetail(@Param('id') id: string, @CurrentUser('userId') userId: string) { return this.resumeService.getDetail(id, userId) diff --git a/backend/src/modules/resume/resume.module.ts b/backend/src/modules/resume/resume.module.ts index ebf917d..1c2c209 100644 --- a/backend/src/modules/resume/resume.module.ts +++ b/backend/src/modules/resume/resume.module.ts @@ -2,11 +2,17 @@ import { Module } from '@nestjs/common' import { MongooseModule } from '@nestjs/mongoose' import { ResumeController } from './resume.controller' import { ResumeService } from './resume.service' +import { ResumePdfService } from './resume-pdf.service' import { Resume, ResumeSchema } from './resume.schema' +import { UserModule } from '../user/user.module' @Module({ - imports: [MongooseModule.forFeature([{ name: Resume.name, schema: ResumeSchema }])], + imports: [ + MongooseModule.forFeature([{ name: Resume.name, schema: ResumeSchema }]), + UserModule, + ], controllers: [ResumeController], - providers: [ResumeService], + providers: [ResumeService, ResumePdfService], + exports: [ResumeService], }) export class ResumeModule {} diff --git a/backend/src/modules/resume/resume.schema.ts b/backend/src/modules/resume/resume.schema.ts index 21125ce..3f5a7f9 100644 --- a/backend/src/modules/resume/resume.schema.ts +++ b/backend/src/modules/resume/resume.schema.ts @@ -17,6 +17,15 @@ export class Resume { @Prop({ default: '' }) targetPosition: string + @Prop({ default: 1 }) + version: number + + @Prop({ default: '' }) + contentHash: string + + @Prop({ default: false }) + paidDownload: boolean + readonly createdAt?: Date readonly updatedAt?: Date } diff --git a/backend/src/modules/resume/resume.service.ts b/backend/src/modules/resume/resume.service.ts index 53ce050..eb8331f 100644 --- a/backend/src/modules/resume/resume.service.ts +++ b/backend/src/modules/resume/resume.service.ts @@ -1,3 +1,4 @@ +import * as crypto from 'crypto' import { Injectable, HttpException, HttpStatus } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' @@ -8,7 +9,8 @@ export class ResumeService { constructor(@InjectModel(Resume.name) private resumeModel: Model) {} async create(userId: string, title: string, content: string, targetPosition?: string) { - const resume = await this.resumeModel.create({ userId, title, content, targetPosition }) + const contentHash = crypto.createHash('md5').update(content).digest('hex') + const resume = await this.resumeModel.create({ userId, title, content, targetPosition, contentHash, version: 1 }) return resume.toObject() } @@ -18,6 +20,9 @@ export class ResumeService { id: r._id.toString(), title: r.title, targetPosition: r.targetPosition, + version: r.version, + contentHash: r.contentHash, + paidDownload: r.paidDownload, createdAt: r.createdAt, })) } @@ -28,6 +33,30 @@ export class ResumeService { return resume.toObject() } + /** AI 优化后更新内容,若内容变化则新建版本 */ + async updateAfterOptimize(resumeId: string, userId: string, newContent: string, targetPosition?: string) { + const resume = await this.resumeModel.findOne({ _id: resumeId, userId }).exec() + if (!resume) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND) + + const newHash = crypto.createHash('md5').update(newContent).digest('hex') + if (newHash === resume.contentHash) return resume.toObject() + + // Same user, increment version as new record + const created = await this.resumeModel.create({ + userId, + title: resume.title, + content: newContent, + targetPosition: targetPosition || resume.targetPosition, + contentHash: newHash, + version: (resume.version || 1) + 1, + }) + return created.toObject() + } + + async markPaid(resumeId: string, userId: string) { + await this.resumeModel.updateOne({ _id: resumeId, userId }, { paidDownload: true }).exec() + } + async delete(resumeId: string, userId: string) { const res = await this.resumeModel.deleteOne({ _id: resumeId, userId }).exec() if (res.deletedCount === 0) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND) diff --git a/backend/src/modules/user/quota.service.ts b/backend/src/modules/user/quota.service.ts new file mode 100644 index 0000000..b7d8902 --- /dev/null +++ b/backend/src/modules/user/quota.service.ts @@ -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, + ) {} + + 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 { + 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() + } +} diff --git a/backend/src/modules/user/user.module.ts b/backend/src/modules/user/user.module.ts index 4eba63b..0bbd3c7 100644 --- a/backend/src/modules/user/user.module.ts +++ b/backend/src/modules/user/user.module.ts @@ -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 {} diff --git a/backend/src/modules/user/user.schema.ts b/backend/src/modules/user/user.schema.ts index 811699a..c1f493d 100644 --- a/backend/src/modules/user/user.schema.ts +++ b/backend/src/modules/user/user.schema.ts @@ -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' diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index f70b0f4..252a659 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -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, } } } diff --git a/docs/PAYMENT-REDESIGN.md b/docs/PAYMENT-REDESIGN.md new file mode 100644 index 0000000..1c322b2 --- /dev/null +++ b/docs/PAYMENT-REDESIGN.md @@ -0,0 +1,299 @@ +# 产品改造方案:付费体系重构 + 数字人面试 + +> 2026-06-11 · v1.0 + +## 概述 + +将现有一刀切的 `remaining` 免费额度模式,改为**按产品线独立计费 + 会员套餐含额度 + 数据库配置化**的灵活付费体系。 + +## 数据库模型变更 + +### User Schema 新增字段 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `interviewCredits` | number | 1 | AI 数字人面试可用次数(含首次免费) | +| `resumeOptimizeCredits` | number | 0 | 简历优化可用次数 | +| `resumeDownloadCredits` | number | 0 | 简历下载可用次数 | +| `freeOptimizeUsed` | number | 0 | 已使用免费优化次数(上限 3) | + +旧的 `remaining` 字段保留,仅限老用户兼容,新逻辑不再使用。 + +### Resume Schema 新增字段 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `version` | number | 1 | 版本号,每次 AI 改写 +1 | +| `contentHash` | string | '' | 内容 MD5,用于判断是否改版 | +| `paidDownload` | boolean | false | 是否已购买该版本的下载权 | + +`contentHash` 在 create 和 optimize 后自动更新。 + +### PaymentOrder Schema 新增字段 + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `type` | string | 'membership' | `membership` / `interview` / `optimize` / `download` | +| `metadata` | Mixed | {} | 产品粒度信息(如 `{ resumeId, version }`) | + +### 定价配置(SiteConfig key=`pricing`) + +单一配置源,所有定价和套餐内容后台可编辑: + +```json +{ + "interview": { + "firstFree": true, + "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 数字人面试无限次", + "详细面试报告(四维评分)", + "进步轨迹雷达图 + 打卡", + "每日一题推送", + "参考回答思路", + "公司真题库" + ] + }, + "sprint": { + "price": 4990, + "durationDays": 30, + "credits": { + "interview": 999, + "resumeOptimize": 50, + "resumeDownload": 30 + }, + "features": [ + "成长版全部权益", + "AI 语音分析(语气词/语速检测)", + "技能缺口分析报告", + "学习路径推荐", + "真人导师 1v1 点评(每月 1 次)", + "简历精修(每月 1 次)", + "内推优先" + ] + } + } +} +``` + +## 配额检查逻辑 + +所有配额检查收敛到统一的 `QuotaGuard` 或 `QuotaService`,不再散落在各处。 + +```typescript +// 配额服务 +class QuotaService { + checkInterview(user): // user.interviewCredits >= 1 + checkOptimize(user): // user.resumeOptimizeCredits > 0 + // || user.freeOptimizeUsed < freeLimit + checkDownload(user, resume): // resume.paidDownload + // || user.resumeDownloadCredits > 0 + + deductInterview(user) + deductOptimize(user) + deductDownload(user, resume) +} +``` + +### 各类用户行为对应的检查 + +| 行为 | 检查条件 | 扣减项 | +|------|----------|--------| +| 开始 AI 面试 | `interviewCredits >= 1` | `interviewCredits -= 1` | +| AI 诊断/优化简历 | `resumeOptimizeCredits > 0 \|\| freeOptimizeUsed < 3` | 付费优先扣,免费次之 | +| 下载简历 PDF | `resume.paidDownload \|\| resumeDownloadCredits > 0` | 标记 resume.paidDownload | + +## 支付流程扩展 + +### 现有:套餐购买(不变) + +``` +POST /payment/create { plan: 'growth' | 'sprint' } + → 创建 Membership 订单(type='membership') + → WeChat Pay + → 回调/activate → 注入 plan 对应的全部 credits +``` + +### 新增:按次购买 + +``` +POST /payment/create-product { type, resumeId?, count? } + → 从 pricing config 读取单价 + → 创建对应 type 订单 + → WeChat Pay + → 回调 → 根据 type 增加对应用户 credits / 标记下载 +``` + +支持的产品: +- `interview`:增加 `interviewCredits` +- `optimize`:增加 `resumeOptimizeCredits` +- `download`:标记 `resume.paidDownload=true` + +## 数字人面试架构 + +### 整体流程 + +``` +用户选择岗位 → 配额检查 → AI 出题 → 回答 + → AI 评分+下一题 → ... → 完成 → 报告 → 头像回顾视频(可选) +``` + +每次 AI 回复时,同步调用 TTS 生成音频,返回给前端: + +```typescript +// 面试回答响应 +{ + text: "请介绍一下你的项目经验", + audioUrl: "/tts/cache/abc123.mp3", // edge-tts 生成 + durationMs: 2800, // 音频时长 + animationTimings: [], // 可选:逐字时间戳 +} +``` + +### TTS 服务 + +`backend/src/modules/tts/tts.service.ts` + +```typescript +class TtsService { + async synthesize(text: string): Promise<{ audioPath: string; durationMs: number }> { + // 1. 按 text MD5 检查缓存 + // 2. 未命中 → child_process.exec('edge-tts ...') + // 3. 保存到 /tmp/tts-cache/ 或 certs 同目录 + // 4. 返回路径和时长 + } +} +``` + +edge-tts 命令示例: +```bash +edge-tts --voice zh-CN-XiaoxiaoNeural --text "你好" --write-media /tmp/tts-cache/abc.mp3 +``` + +### 前端数字人组件 + +`zhiyin-app/src/components/digital-human.vue` + +``` +┌─────────────────────┐ +│ ┌───────┐ │ +│ │ 头像 │ │ ← 静态 PNG + CSS呼吸/眨眼 +│ │ (嘴型) │ │ ← Canvas 覆盖嘴部区域 +│ └───────┘ │ +│ "请介绍你的项目" │ ← 字幕气泡 +│ │ +│ [▼ 音频波形] │ ← 可选 +└─────────────────────┘ +``` + +嘴型驱动: +1. `AudioContext.createMediaElementSource(audioEl)` → `AnalyserNode` +2. 每帧读取 `analyser.getByteTimeDomainData(dataArray)` +3. 计算 RMS → 映射到嘴型开合度 (0~1) +4. Canvas 绘制椭圆(开)或线(闭) + +### 新接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/interview/create-avatar` | 创建数字人面试(含配额检查) | +| POST | `/interview/:id/answer-avatar` | 回答+AI 回复(含 TTS audioUrl) | +| GET | `/tts/:hash` | 获取 TTS 音频文件 | + +## 简历下载流程图 + +``` +用户查看优化结果 + │ + ├─ 点击「下载 PDF」 + │ │ + │ ├─ resume.paidDownload === true → 直接生成 → 返回 PDF + │ │ + │ └─ resume.paidDownload === false + │ │ + │ ├─ user.resumeDownloadCredits > 0 + │ │ → 扣 1 credit → 标记 paidDownload → 生成 PDF + │ │ + │ └─ 无 credits + │ → 弹出支付窗口(¥X/次) + │ → WeChat Pay → 标记 paidDownload → 生成 PDF + │ + └─ 再次优化(内容改变) + → contentHash 变化 → 新 resume 记录(version++) + → paidDownload = false + → 需重新付费下载 +``` + +Puppeteer PDF 生成 (`resume-pdf.service.ts`): +1. 读取 resume.content(markdown/HTML) +2. 套用模板(含姓名、岗位、日期等) +3. `puppeteer.launch()` → `page.setContent(html)` → `page.pdf({ format: 'A4' })` +4. 返回 PDF buffer +5. 可选:同时生成 Word(用 HTML → mammoth 或 libreoffice) + +## 管理后台配置 + +在现有 `admin.vue` 的 Config Tab 中增加: + +``` +┌─────────────────────────────────────────────┐ +│ 定价配置 [保存] │ +│ │ +│ AI 面试单价 ¥ [ 5 ] /次 │ +│ 简历优化单价 ¥ [ 3 ] /次 │ +│ 简历下载单价 ¥ [ 2 ] /次 │ +│ 免费优化次数 [ 3 ] 次 │ +│ 首次面试免费 [✓] │ +│ │ +│ ─── 成长版 ─── │ +│ 价格 ¥ [ 19.9 ] /月 │ +│ 面试额度 [ 999 ] 次 │ +│ 优化额度 [ 20 ] 次 │ +│ 下载额度 [ 10 ] 次 │ +│ 功能列表 [编辑] │ +│ │ +│ ─── 冲刺版 ─── │ +│ ... │ +└─────────────────────────────────────────────┘ +``` + +## 实施优先级 + +| 阶段 | 内容 | 文件涉及 | +|------|------|----------| +| P0-1 | 定价配置化 + User 新字段 + 统一配额检查 | user.schema, SiteConfig, QuotaService | +| P0-2 | 简历下载付费 + Puppeteer PDF | resume.schema, resume.service, resume.controller, ResumePdfService | +| P0-3 | 支付扩展(按次购买) | payment-order.schema, payment.controller | +| P1-1 | TTS 服务 | tts.service, tts.controller, tts.module | +| P1-2 | 数字人面试前端组件 | digital-human.vue, interview.vue | +| P1-3 | 面试接口改版 + 配额接入 | interview.service, interview.controller | +| P2 | Admin 定价管理界面 | admin.vue, admin.controller | + +## 兼容性注意事项 + +- `remaining` 字段保留,老用户数据不受影响 +- 新用户注册默认 `interviewCredits=1`(首次免费) +- 会员激活时注入 plan 定义的 credits +- 过期降级时不清零已购买的 credits,仅阻止会员专属 privileges +- 已购买的单次 credits 不过期