feat: 付费体系重构 P0 - 配额独立化/简历付费下载/PDF生成
This commit is contained in:
Generated
+154
-13
@@ -33,6 +33,7 @@
|
|||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
|
"puppeteer": "^25.1.0",
|
||||||
"reflect-metadata": "^0.2.1",
|
"reflect-metadata": "^0.2.1",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
@@ -2264,6 +2265,30 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.34.49",
|
"version": "0.34.49",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
|
||||||
@@ -3211,7 +3236,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -3914,6 +3938,22 @@
|
|||||||
"node": ">=6.0"
|
"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": {
|
"node_modules/ci-info": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
|
||||||
@@ -4023,7 +4063,6 @@
|
|||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"string-width": "^4.2.0",
|
"string-width": "^4.2.0",
|
||||||
@@ -4038,7 +4077,6 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
@@ -4422,6 +4460,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/dezalgo": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||||
@@ -4519,7 +4563,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
@@ -4611,7 +4654,6 @@
|
|||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -5136,7 +5178,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -5707,7 +5748,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -6946,6 +6986,18 @@
|
|||||||
"immediate": "~3.0.5"
|
"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": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"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": ">=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": {
|
"node_modules/mkdirp": {
|
||||||
"version": "0.5.6",
|
"version": "0.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
@@ -7308,6 +7366,15 @@
|
|||||||
"mkdirp": "bin/cmd.js"
|
"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": {
|
"node_modules/mongodb": {
|
||||||
"version": "6.20.0",
|
"version": "6.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz",
|
||||||
@@ -8149,6 +8216,44 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/pure-rand": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
|
||||||
@@ -8308,7 +8413,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -8806,7 +8910,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -8837,7 +8940,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -9522,6 +9624,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/typedarray": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
@@ -9775,6 +9883,12 @@
|
|||||||
"defaults": "^1.0.3"
|
"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": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
"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": "^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": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "10.1.1",
|
"version": "10.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||||
@@ -9986,7 +10121,6 @@
|
|||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -10003,7 +10137,6 @@
|
|||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cliui": "^8.0.1",
|
"cliui": "^8.0.1",
|
||||||
@@ -10022,7 +10155,6 @@
|
|||||||
"version": "21.1.1",
|
"version": "21.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -10049,6 +10181,15 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": "*"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
|
"puppeteer": "^25.1.0",
|
||||||
"reflect-metadata": "^0.2.1",
|
"reflect-metadata": "^0.2.1",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { User, UserDocument } from '../user/user.schema'
|
|||||||
import { Interview, InterviewDocument } from '../interview/interview.schema'
|
import { Interview, InterviewDocument } from '../interview/interview.schema'
|
||||||
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
|
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
|
||||||
import { SiteConfig, SiteConfigDocument } from '../schemas/site-config.schema'
|
import { SiteConfig, SiteConfigDocument } from '../schemas/site-config.schema'
|
||||||
|
import { QuotaService } from '../user/quota.service'
|
||||||
import { WechatPayService } from '../payment/wechat-pay.service'
|
import { WechatPayService } from '../payment/wechat-pay.service'
|
||||||
|
|
||||||
const VIP_DURATION_DAYS = 30
|
const VIP_DURATION_DAYS = 30
|
||||||
@@ -20,6 +21,7 @@ export class AdminController {
|
|||||||
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
|
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
|
||||||
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||||||
@InjectModel(SiteConfig.name) private configModel: Model<SiteConfigDocument>,
|
@InjectModel(SiteConfig.name) private configModel: Model<SiteConfigDocument>,
|
||||||
|
private quotaService: QuotaService,
|
||||||
private wechatPay: WechatPayService,
|
private wechatPay: WechatPayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -82,8 +84,7 @@ export class AdminController {
|
|||||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
||||||
user.plan = 'growth'
|
user.plan = 'growth'
|
||||||
user.vipExpireAt = expireAt
|
user.vipExpireAt = expireAt
|
||||||
user.remaining = 999
|
await this.quotaService.setPlanQuota(targetUserId, 'growth', { interview: 999, resumeOptimize: 20, resumeDownload: 10 })
|
||||||
await user.save()
|
|
||||||
return { success: true, plan: 'growth', expireAt }
|
return { success: true, plan: 'growth', expireAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,14 +127,20 @@ export class AdminController {
|
|||||||
order.wxTransactionId = wxResult?.transaction_id || ''
|
order.wxTransactionId = wxResult?.transaction_id || ''
|
||||||
order.paidAt = new Date()
|
order.paidAt = new Date()
|
||||||
await order.save()
|
await order.save()
|
||||||
|
if (order.type === 'membership') {
|
||||||
const user = await this.userModel.findById(order.userId).exec()
|
const user = await this.userModel.findById(order.userId).exec()
|
||||||
if (user && user.plan === 'free') {
|
if (user && user.plan === 'free') {
|
||||||
const expireAt = new Date()
|
const expireAt = new Date()
|
||||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
||||||
user.plan = 'growth'
|
user.plan = 'growth'
|
||||||
user.vipExpireAt = expireAt
|
user.vipExpireAt = expireAt
|
||||||
user.remaining = 999
|
await this.quotaService.setPlanQuota(order.userId, 'growth', { interview: 999, resumeOptimize: 20, resumeDownload: 10 })
|
||||||
await user.save()
|
}
|
||||||
|
} 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 }
|
return { order, wxResult }
|
||||||
@@ -179,8 +186,8 @@ const DEFAULT_CONFIG = {
|
|||||||
optimize: { dailyFreeLimit: 2 },
|
optimize: { dailyFreeLimit: 2 },
|
||||||
price: { monthly: 1990 },
|
price: { monthly: 1990 },
|
||||||
plans: {
|
plans: {
|
||||||
free: { name: '免费版', price: 0, features: ['每日 3 次 AI 模拟面试', '每场最多 5 轮 AI 对话', '基础面试报告', '简历诊断', '简历优化'] },
|
free: { name: '免费版', price: 0, features: ['AI 模拟面试 1 次(体验)', '每场最多 5 轮 AI 对话', '基础面试报告', '简历优化(限 3 次)'] },
|
||||||
growth: { name: '成长版', price: 1990, features: ['免费版全部权益', '无限面试次数', '每场最多 10 轮 AI 对话', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '参考回答思路', '公司真题库'] },
|
growth: { name: '成长版', price: 1990, features: ['免费版全部权益', 'AI 数字人面试无限次', '每场最多 10 轮 AI 对话', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'] },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'
|
|||||||
import { MongooseModule } from '@nestjs/mongoose'
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
import { AdminController } from './admin.controller'
|
import { AdminController } from './admin.controller'
|
||||||
import { User, UserSchema } from '../user/user.schema'
|
import { User, UserSchema } from '../user/user.schema'
|
||||||
|
import { UserModule } from '../user/user.module'
|
||||||
import { Interview, InterviewSchema } from '../interview/interview.schema'
|
import { Interview, InterviewSchema } from '../interview/interview.schema'
|
||||||
import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema'
|
import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema'
|
||||||
import { WechatPayService } from '../payment/wechat-pay.service'
|
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: PaymentOrder.name, schema: PaymentOrderSchema },
|
||||||
{ name: SiteConfig.name, schema: SiteConfigSchema },
|
{ name: SiteConfig.name, schema: SiteConfigSchema },
|
||||||
]),
|
]),
|
||||||
|
UserModule,
|
||||||
],
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [WechatPayService, AdminGuard],
|
providers: [WechatPayService, AdminGuard],
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { AnalyzeService } from './analyze.service'
|
|||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
import { UserService } from '../user/user.service'
|
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 { BenchmarkService, PositionBenchmark } from '../progress/benchmark.service'
|
||||||
import { Progress, ProgressDocument } from '../schemas/progress.schema'
|
import { Progress, ProgressDocument } from '../schemas/progress.schema'
|
||||||
|
|
||||||
@@ -13,6 +15,8 @@ export class AnalyzeController {
|
|||||||
constructor(
|
constructor(
|
||||||
private analyzeService: AnalyzeService,
|
private analyzeService: AnalyzeService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
|
private quotaService: QuotaService,
|
||||||
|
private resumeService: ResumeService,
|
||||||
private benchmarkService: BenchmarkService,
|
private benchmarkService: BenchmarkService,
|
||||||
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
|
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
|
||||||
) {}
|
) {}
|
||||||
@@ -26,9 +30,21 @@ export class AnalyzeController {
|
|||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post('optimize')
|
@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)
|
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) {
|
private async checkAnalyzeLimit(userId: string) {
|
||||||
const user = await this.userService.getModel().findById(userId).exec()
|
await this.quotaService.checkAndDeductOptimize(userId)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { MongooseModule } from '@nestjs/mongoose'
|
|||||||
import { AnalyzeController } from './analyze.controller'
|
import { AnalyzeController } from './analyze.controller'
|
||||||
import { AnalyzeService } from './analyze.service'
|
import { AnalyzeService } from './analyze.service'
|
||||||
import { UserModule } from '../user/user.module'
|
import { UserModule } from '../user/user.module'
|
||||||
|
import { ResumeModule } from '../resume/resume.module'
|
||||||
import { ProgressModule } from '../progress/progress.module'
|
import { ProgressModule } from '../progress/progress.module'
|
||||||
import { Progress, ProgressSchema } from '../schemas/progress.schema'
|
import { Progress, ProgressSchema } from '../schemas/progress.schema'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
UserModule,
|
UserModule,
|
||||||
|
ResumeModule,
|
||||||
ProgressModule,
|
ProgressModule,
|
||||||
MongooseModule.forFeature([{ name: Progress.name, schema: ProgressSchema }]),
|
MongooseModule.forFeature([{ name: Progress.name, schema: ProgressSchema }]),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Interview, InterviewDocument } from './interview.schema'
|
|||||||
import { Progress, ProgressDocument } from '../schemas/progress.schema'
|
import { Progress, ProgressDocument } from '../schemas/progress.schema'
|
||||||
import { AiService } from '../ai/ai.service'
|
import { AiService } from '../ai/ai.service'
|
||||||
import { UserService } from '../user/user.service'
|
import { UserService } from '../user/user.service'
|
||||||
|
import { QuotaService } from '../user/quota.service'
|
||||||
import { analyzeSpeech } from '../../common/utils/filler-words'
|
import { analyzeSpeech } from '../../common/utils/filler-words'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -14,11 +15,11 @@ export class InterviewService {
|
|||||||
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
|
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
|
||||||
private aiService: AiService,
|
private aiService: AiService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
|
private quotaService: QuotaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(userId: string, position: string) {
|
async create(userId: string, position: string) {
|
||||||
// 扣减使用次数
|
await this.quotaService.checkAndDeductInterview(userId)
|
||||||
await this.userService.deductRemaining(userId)
|
|
||||||
|
|
||||||
const firstQuestion = await this.aiService.call({
|
const firstQuestion = await this.aiService.call({
|
||||||
systemPrompt: `你是一位专业的${position}面试官。请针对校招该岗位提出第一个面试问题,要求具体且有针对性。直接输出问题,不要多余内容。`,
|
systemPrompt: `你是一位专业的${position}面试官。请针对校招该岗位提出第一个面试问题,要求具体且有针对性。直接输出问题,不要多余内容。`,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { User, UserDocument } from '../user/user.schema'
|
|||||||
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
|
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
|
import { QuotaService } from '../user/quota.service'
|
||||||
import { Public } from '../../common/decorators/public.decorator'
|
import { Public } from '../../common/decorators/public.decorator'
|
||||||
|
|
||||||
const GROWTH_PRICE = 1990
|
const GROWTH_PRICE = 1990
|
||||||
@@ -24,14 +25,15 @@ const PLANS: Record<string, PlanConfig> = {
|
|||||||
free: {
|
free: {
|
||||||
id: 'free', name: '免费版', price: 0, dailyLimit: FREE_DAILY_LIMIT,
|
id: 'free', name: '免费版', price: 0, dailyLimit: FREE_DAILY_LIMIT,
|
||||||
features: [
|
features: [
|
||||||
'每日 2 次 AI 模拟面试', '基础面试报告', '通用题库随机出题', '简历诊断(限 3 次)',
|
'AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
growth: {
|
growth: {
|
||||||
id: 'growth', name: '成长版', price: GROWTH_PRICE, dailyLimit: 999,
|
id: 'growth', name: '成长版', price: GROWTH_PRICE, dailyLimit: 999,
|
||||||
features: [
|
features: [
|
||||||
'免费版全部权益', '无限面试次数', '详细面试报告(四维评分)',
|
'免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)',
|
||||||
'进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库',
|
'进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库',
|
||||||
|
'简历优化 20 次/月', '简历下载 10 次/月',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
sprint: {
|
sprint: {
|
||||||
@@ -39,6 +41,7 @@ const PLANS: Record<string, PlanConfig> = {
|
|||||||
features: [
|
features: [
|
||||||
'成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告',
|
'成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告',
|
||||||
'学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先',
|
'学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先',
|
||||||
|
'简历优化 50 次/月', '简历下载 30 次/月',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -51,6 +54,7 @@ export class MemberController {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||||
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||||||
|
private quotaService: QuotaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@@ -82,6 +86,10 @@ export class MemberController {
|
|||||||
vipExpireAt: user.vipExpireAt,
|
vipExpireAt: user.vipExpireAt,
|
||||||
sprintExpireAt: user.sprintExpireAt,
|
sprintExpireAt: user.sprintExpireAt,
|
||||||
sprintRemaining: user.sprintRemaining || 0,
|
sprintRemaining: user.sprintRemaining || 0,
|
||||||
|
interviewCredits: user.interviewCredits ?? 1,
|
||||||
|
resumeOptimizeCredits: user.resumeOptimizeCredits ?? 0,
|
||||||
|
resumeDownloadCredits: user.resumeDownloadCredits ?? 0,
|
||||||
|
freeOptimizeUsed: user.freeOptimizeUsed ?? 0,
|
||||||
isVip: user.plan !== 'free',
|
isVip: user.plan !== 'free',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,16 +107,19 @@ export class MemberController {
|
|||||||
|
|
||||||
const expireAt = new Date()
|
const expireAt = new Date()
|
||||||
expireAt.setDate(expireAt.getDate() + DURATION_DAYS)
|
expireAt.setDate(expireAt.getDate() + DURATION_DAYS)
|
||||||
|
const credits = { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
|
||||||
if (order.plan === 'sprint') {
|
if (order.plan === 'sprint') {
|
||||||
user.plan = 'sprint'
|
user.plan = 'sprint'
|
||||||
user.sprintExpireAt = expireAt
|
user.sprintExpireAt = expireAt
|
||||||
user.sprintRemaining = 10
|
user.sprintRemaining = 10
|
||||||
|
credits.interview = 999
|
||||||
|
credits.resumeOptimize = 50
|
||||||
|
credits.resumeDownload = 30
|
||||||
} else {
|
} else {
|
||||||
user.plan = 'growth'
|
user.plan = 'growth'
|
||||||
user.vipExpireAt = expireAt
|
user.vipExpireAt = expireAt
|
||||||
}
|
}
|
||||||
user.remaining = 999
|
await this.quotaService.setPlanQuota(userId, order.plan, credits)
|
||||||
await user.save()
|
|
||||||
return { success: true, plan: user.plan, planName: PLANS[user.plan]?.name, expireAt }
|
return { success: true, plan: user.plan, planName: PLANS[user.plan]?.name, expireAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import { Module } from '@nestjs/common'
|
|||||||
import { MongooseModule } from '@nestjs/mongoose'
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
import { MemberController } from './member.controller'
|
import { MemberController } from './member.controller'
|
||||||
import { User, UserSchema } from '../user/user.schema'
|
import { User, UserSchema } from '../user/user.schema'
|
||||||
|
import { UserModule } from '../user/user.module'
|
||||||
import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema'
|
import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MongooseModule.forFeature([
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
{ name: User.name, schema: UserSchema },
|
{ name: User.name, schema: UserSchema },
|
||||||
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
|
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
|
||||||
])],
|
]),
|
||||||
|
UserModule,
|
||||||
|
],
|
||||||
controllers: [MemberController],
|
controllers: [MemberController],
|
||||||
})
|
})
|
||||||
export class MemberModule {}
|
export class MemberModule {}
|
||||||
|
|||||||
@@ -27,12 +27,18 @@ export class PaymentOrder {
|
|||||||
@Prop({ default: 'pending' })
|
@Prop({ default: 'pending' })
|
||||||
status: string
|
status: string
|
||||||
|
|
||||||
|
@Prop({ default: 'membership' })
|
||||||
|
type: string // membership | interview | optimize | download
|
||||||
|
|
||||||
@Prop({ default: 'growth' })
|
@Prop({ default: 'growth' })
|
||||||
plan: string // growth | sprint
|
plan: string // growth | sprint
|
||||||
|
|
||||||
@Prop({ default: 'native' })
|
@Prop({ default: 'native' })
|
||||||
channel: string // native | jsapi
|
channel: string // native | jsapi
|
||||||
|
|
||||||
|
@Prop({ type: Object })
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
|
||||||
@Prop()
|
@Prop()
|
||||||
paidAt?: Date
|
paidAt?: Date
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { Test, TestingModule } from '@nestjs/testing'
|
|||||||
import { getModelToken } from '@nestjs/mongoose'
|
import { getModelToken } from '@nestjs/mongoose'
|
||||||
import { PaymentController } from './payment.controller'
|
import { PaymentController } from './payment.controller'
|
||||||
import { WechatPayService } from './wechat-pay.service'
|
import { WechatPayService } from './wechat-pay.service'
|
||||||
|
import { QuotaService } from '../user/quota.service'
|
||||||
|
|
||||||
describe('PaymentController', () => {
|
describe('PaymentController', () => {
|
||||||
let controller: PaymentController
|
let controller: PaymentController
|
||||||
let mockUserModel: any
|
let mockUserModel: any
|
||||||
let mockOrderModel: any
|
let mockOrderModel: any
|
||||||
let mockWechatPay: any
|
let mockWechatPay: any
|
||||||
|
let mockQuotaService: any
|
||||||
|
|
||||||
const mockUserId = '507f1f77bcf86cd799439011'
|
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' }),
|
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' }),
|
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({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [PaymentController],
|
controllers: [PaymentController],
|
||||||
@@ -33,6 +39,7 @@ describe('PaymentController', () => {
|
|||||||
{ provide: getModelToken('User'), useValue: mockUserModel },
|
{ provide: getModelToken('User'), useValue: mockUserModel },
|
||||||
{ provide: getModelToken('PaymentOrder'), useValue: mockOrderModel },
|
{ provide: getModelToken('PaymentOrder'), useValue: mockOrderModel },
|
||||||
{ provide: WechatPayService, useValue: mockWechatPay },
|
{ provide: WechatPayService, useValue: mockWechatPay },
|
||||||
|
{ provide: QuotaService, useValue: mockQuotaService },
|
||||||
],
|
],
|
||||||
}).compile()
|
}).compile()
|
||||||
|
|
||||||
@@ -107,9 +114,9 @@ describe('PaymentController', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return order status', async () => {
|
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)
|
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 })
|
expect(mockOrderModel.findOne).toHaveBeenCalledWith({ outTradeNo: 'ORD123', userId: mockUserId })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -128,19 +135,23 @@ describe('PaymentController', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should activate growth plan', async () => {
|
it('should activate growth plan', async () => {
|
||||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'growth' }) })
|
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, save: jest.fn().mockResolvedValue(true) }
|
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) })
|
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
|
||||||
|
|
||||||
const result = await controller.activate(mockUserId, 'ORD123')
|
const result = await controller.activate(mockUserId, 'ORD123')
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
expect(result.plan).toBe('growth')
|
expect(result.plan).toBe('growth')
|
||||||
expect(mockUser.save).toHaveBeenCalled()
|
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 () => {
|
it('should activate sprint plan', async () => {
|
||||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'sprint' }) })
|
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, save: jest.fn().mockResolvedValue(true) }
|
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) })
|
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
|
||||||
|
|
||||||
const result = await controller.activate(mockUserId, 'ORD123')
|
const result = await controller.activate(mockUserId, 'ORD123')
|
||||||
|
|||||||
@@ -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 { InjectModel } from '@nestjs/mongoose'
|
||||||
import { Model } from 'mongoose'
|
import { Model } from 'mongoose'
|
||||||
import { User, UserDocument } from '../user/user.schema'
|
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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
import { WechatPayService } from './wechat-pay.service'
|
import { WechatPayService } from './wechat-pay.service'
|
||||||
|
import { QuotaService } from '../user/quota.service'
|
||||||
import { Public } from '../../common/decorators/public.decorator'
|
import { Public } from '../../common/decorators/public.decorator'
|
||||||
|
|
||||||
const GROWTH_AMOUNT = 1990 // 19.9 元(分)
|
const GROWTH_AMOUNT = 1990
|
||||||
const SPRINT_AMOUNT = 4990 // 49.9 元(分)
|
const SPRINT_AMOUNT = 4990
|
||||||
const VIP_DURATION_DAYS = 30
|
const VIP_DURATION_DAYS = 30
|
||||||
|
|
||||||
|
const PRODUCT_PRICES: Record<string, number> = {
|
||||||
|
interview: 500,
|
||||||
|
optimize: 300,
|
||||||
|
download: 200,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRODUCT_CREDITS: Record<string, number> = {
|
||||||
|
interview: 1,
|
||||||
|
optimize: 1,
|
||||||
|
download: 1,
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('payment')
|
@Controller('payment')
|
||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
private readonly logger = new Logger(PaymentController.name)
|
private readonly logger = new Logger(PaymentController.name)
|
||||||
@@ -20,9 +33,10 @@ export class PaymentController {
|
|||||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||||
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||||||
private wechatPay: WechatPayService,
|
private wechatPay: WechatPayService,
|
||||||
|
private quotaService: QuotaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 创建订单(H5:Native 扫码支付) */
|
/** 创建套餐订单(H5:Native 扫码支付) */
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post('create')
|
@Post('create')
|
||||||
async create(@CurrentUser('userId') userId: string, @Body('plan') plan: string = 'growth') {
|
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 outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}`
|
||||||
const result = await this.wechatPay.nativePay(title, outTradeNo, amount)
|
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 }
|
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<string, any>,
|
||||||
|
) {
|
||||||
|
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<string, string> = {
|
||||||
|
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 支付(微信小程序) */
|
/** JSAPI 支付(微信小程序) */
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post('jsapi')
|
@Post('jsapi')
|
||||||
@@ -57,7 +102,40 @@ export class PaymentController {
|
|||||||
const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}`
|
const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}`
|
||||||
const result = await this.wechatPay.jsapiPay(title, outTradeNo, amount, openid)
|
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<string, any>,
|
||||||
|
) {
|
||||||
|
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<string, string> = {
|
||||||
|
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 }
|
return { ...result, outTradeNo }
|
||||||
}
|
}
|
||||||
@@ -65,10 +143,7 @@ export class PaymentController {
|
|||||||
/** 支付回调通知 */
|
/** 支付回调通知 */
|
||||||
@Public()
|
@Public()
|
||||||
@Post('notify')
|
@Post('notify')
|
||||||
async notify(
|
async notify(@Body() body: any, @Req() req: any) {
|
||||||
@Body() body: any,
|
|
||||||
@Req() req: any,
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const wechatSignature = req.headers['wechatpay-signature'] || ''
|
const wechatSignature = req.headers['wechatpay-signature'] || ''
|
||||||
const wechatTimestamp = req.headers['wechatpay-timestamp'] || ''
|
const wechatTimestamp = req.headers['wechatpay-timestamp'] || ''
|
||||||
@@ -79,7 +154,6 @@ export class PaymentController {
|
|||||||
const outTradeNo = decrypted.out_trade_no
|
const outTradeNo = decrypted.out_trade_no
|
||||||
const wxTransactionId = decrypted.transaction_id
|
const wxTransactionId = decrypted.transaction_id
|
||||||
|
|
||||||
// 从数据库订单查找 userId,而非从 outTradeNo 解析
|
|
||||||
const order = await this.orderModel.findOne({ outTradeNo }).exec()
|
const order = await this.orderModel.findOne({ outTradeNo }).exec()
|
||||||
if (!order) {
|
if (!order) {
|
||||||
this.logger.warn(`支付回调:订单不存在 ${outTradeNo}`)
|
this.logger.warn(`支付回调:订单不存在 ${outTradeNo}`)
|
||||||
@@ -94,21 +168,10 @@ export class PaymentController {
|
|||||||
await order.save()
|
await order.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据订单 plan 激活对应套餐
|
if (order.type === 'membership') {
|
||||||
const user = await this.userModel.findById(order.userId).exec()
|
await this.activateMembership(order)
|
||||||
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 {
|
} else {
|
||||||
user.plan = 'growth'
|
await this.activateProduct(order)
|
||||||
user.vipExpireAt = expireAt
|
|
||||||
}
|
|
||||||
user.remaining = 999
|
|
||||||
await user.save()
|
|
||||||
}
|
}
|
||||||
return { code: 'SUCCESS', message: '成功' }
|
return { code: 'SUCCESS', message: '成功' }
|
||||||
} catch (e) {
|
} 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<string, 'interview' | 'optimize' | 'download'> = {
|
||||||
|
interview: 'interview',
|
||||||
|
optimize: 'optimize',
|
||||||
|
download: 'download',
|
||||||
|
}
|
||||||
|
const mapped = typeMap[order.type]
|
||||||
|
if (mapped) {
|
||||||
|
await this.quotaService.grantCredits(order.userId, mapped, credits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 查询订单(微信侧) */
|
/** 查询订单(微信侧) */
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post('query')
|
@Post('query')
|
||||||
@@ -132,32 +236,23 @@ export class PaymentController {
|
|||||||
async checkOrder(@Param('outTradeNo') outTradeNo: string, @CurrentUser('userId') userId: string) {
|
async checkOrder(@Param('outTradeNo') outTradeNo: string, @CurrentUser('userId') userId: string) {
|
||||||
const order = await this.orderModel.findOne({ outTradeNo, userId }).exec()
|
const order = await this.orderModel.findOne({ outTradeNo, userId }).exec()
|
||||||
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
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)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post('activate')
|
@Post('activate')
|
||||||
async activate(@CurrentUser('userId') userId: string, @Body('outTradeNo') outTradeNo: string) {
|
async activate(@CurrentUser('userId') userId: string, @Body('outTradeNo') outTradeNo: string) {
|
||||||
const order = await this.orderModel.findOne({ outTradeNo, userId }).exec()
|
const order = await this.orderModel.findOne({ outTradeNo, userId }).exec()
|
||||||
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
||||||
if (order.status !== 'success') throw new HttpException('支付未完成', HttpStatus.BAD_REQUEST)
|
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()
|
if (order.type === 'membership') {
|
||||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
await this.activateMembership(order)
|
||||||
if (order.plan === 'sprint') {
|
return { success: true, plan: order.plan }
|
||||||
user.plan = 'sprint'
|
|
||||||
user.sprintExpireAt = expireAt
|
|
||||||
user.sprintRemaining = 10
|
|
||||||
} else {
|
|
||||||
user.plan = 'growth'
|
|
||||||
user.vipExpireAt = expireAt
|
|
||||||
}
|
}
|
||||||
user.remaining = 999
|
|
||||||
await user.save()
|
await this.activateProduct(order)
|
||||||
return { success: true, plan: user.plan }
|
return { success: true, type: order.type }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import { MongooseModule } from '@nestjs/mongoose'
|
|||||||
import { PaymentController } from './payment.controller'
|
import { PaymentController } from './payment.controller'
|
||||||
import { WechatPayService } from './wechat-pay.service'
|
import { WechatPayService } from './wechat-pay.service'
|
||||||
import { User, UserSchema } from '../user/user.schema'
|
import { User, UserSchema } from '../user/user.schema'
|
||||||
|
import { UserModule } from '../user/user.module'
|
||||||
import { PaymentOrder, PaymentOrderSchema } from './payment-order.schema'
|
import { PaymentOrder, PaymentOrderSchema } from './payment-order.schema'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MongooseModule.forFeature([
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
{ name: User.name, schema: UserSchema },
|
{ name: User.name, schema: UserSchema },
|
||||||
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
|
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
|
||||||
])],
|
]),
|
||||||
|
UserModule,
|
||||||
|
],
|
||||||
controllers: [PaymentController],
|
controllers: [PaymentController],
|
||||||
providers: [WechatPayService],
|
providers: [WechatPayService],
|
||||||
exports: [WechatPayService],
|
exports: [WechatPayService],
|
||||||
|
|||||||
@@ -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<Buffer> {
|
||||||
|
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, '<br>')
|
||||||
|
.replace(/### (.+)/g, '<h3>$1</h3>')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@page { size: A4; margin: 0; }
|
||||||
|
body {
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
font-size: 12pt; line-height: 1.6; color: #333; padding: 0; margin: 0;
|
||||||
|
}
|
||||||
|
.page { padding: 40px 30px; max-width: 210mm; margin: 0 auto; }
|
||||||
|
h1 { font-size: 22pt; color: #1a1a1a; margin-bottom: 4px; }
|
||||||
|
.subtitle { color: #666; font-size: 10pt; margin-bottom: 20px; }
|
||||||
|
h2 { font-size: 14pt; color: #2c6b9e; border-bottom: 2px solid #2c6b9e; padding-bottom: 4px; margin-top: 20px; }
|
||||||
|
h3 { font-size: 12pt; color: #333; margin-top: 12px; margin-bottom: 4px; }
|
||||||
|
strong { color: #1a1a1a; }
|
||||||
|
p { margin: 6px 0; }
|
||||||
|
ul { margin: 4px 0; padding-left: 20px; }
|
||||||
|
li { margin: 2px 0; }
|
||||||
|
.footer { margin-top: 30px; font-size: 9pt; color: #999; text-align: center; border-top: 1px solid #eee; padding-top: 10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<h1>${params.title}</h1>
|
||||||
|
<div class="subtitle">${params.targetPosition ? `目标岗位: ${params.targetPosition}` : ''}</div>
|
||||||
|
<div class="content">${contentHtml}</div>
|
||||||
|
<div class="footer">由 AI磁场·职引 生成</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
computeHash(content: string): string {
|
||||||
|
return crypto.createHash('md5').update(content).digest('hex')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { ResumeService } from './resume.service'
|
||||||
|
import { ResumePdfService } from './resume-pdf.service'
|
||||||
|
import { QuotaService } from '../user/quota.service'
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||||
|
|
||||||
@Controller('resume')
|
@Controller('resume')
|
||||||
export class ResumeController {
|
export class ResumeController {
|
||||||
constructor(private resumeService: ResumeService) {}
|
constructor(
|
||||||
|
private resumeService: ResumeService,
|
||||||
|
private resumePdfService: ResumePdfService,
|
||||||
|
private quotaService: QuotaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('create')
|
@Post('create')
|
||||||
async create(
|
async create(
|
||||||
@@ -21,6 +28,31 @@ export class ResumeController {
|
|||||||
return this.resumeService.list(userId)
|
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')
|
@Get(':id')
|
||||||
async getDetail(@Param('id') id: string, @CurrentUser('userId') userId: string) {
|
async getDetail(@Param('id') id: string, @CurrentUser('userId') userId: string) {
|
||||||
return this.resumeService.getDetail(id, userId)
|
return this.resumeService.getDetail(id, userId)
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ import { Module } from '@nestjs/common'
|
|||||||
import { MongooseModule } from '@nestjs/mongoose'
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
import { ResumeController } from './resume.controller'
|
import { ResumeController } from './resume.controller'
|
||||||
import { ResumeService } from './resume.service'
|
import { ResumeService } from './resume.service'
|
||||||
|
import { ResumePdfService } from './resume-pdf.service'
|
||||||
import { Resume, ResumeSchema } from './resume.schema'
|
import { Resume, ResumeSchema } from './resume.schema'
|
||||||
|
import { UserModule } from '../user/user.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MongooseModule.forFeature([{ name: Resume.name, schema: ResumeSchema }])],
|
imports: [
|
||||||
|
MongooseModule.forFeature([{ name: Resume.name, schema: ResumeSchema }]),
|
||||||
|
UserModule,
|
||||||
|
],
|
||||||
controllers: [ResumeController],
|
controllers: [ResumeController],
|
||||||
providers: [ResumeService],
|
providers: [ResumeService, ResumePdfService],
|
||||||
|
exports: [ResumeService],
|
||||||
})
|
})
|
||||||
export class ResumeModule {}
|
export class ResumeModule {}
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ export class Resume {
|
|||||||
@Prop({ default: '' })
|
@Prop({ default: '' })
|
||||||
targetPosition: string
|
targetPosition: string
|
||||||
|
|
||||||
|
@Prop({ default: 1 })
|
||||||
|
version: number
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
contentHash: string
|
||||||
|
|
||||||
|
@Prop({ default: false })
|
||||||
|
paidDownload: boolean
|
||||||
|
|
||||||
readonly createdAt?: Date
|
readonly createdAt?: Date
|
||||||
readonly updatedAt?: Date
|
readonly updatedAt?: Date
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as crypto from 'crypto'
|
||||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||||
import { InjectModel } from '@nestjs/mongoose'
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
import { Model } from 'mongoose'
|
import { Model } from 'mongoose'
|
||||||
@@ -8,7 +9,8 @@ export class ResumeService {
|
|||||||
constructor(@InjectModel(Resume.name) private resumeModel: Model<ResumeDocument>) {}
|
constructor(@InjectModel(Resume.name) private resumeModel: Model<ResumeDocument>) {}
|
||||||
|
|
||||||
async create(userId: string, title: string, content: string, targetPosition?: string) {
|
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()
|
return resume.toObject()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +20,9 @@ export class ResumeService {
|
|||||||
id: r._id.toString(),
|
id: r._id.toString(),
|
||||||
title: r.title,
|
title: r.title,
|
||||||
targetPosition: r.targetPosition,
|
targetPosition: r.targetPosition,
|
||||||
|
version: r.version,
|
||||||
|
contentHash: r.contentHash,
|
||||||
|
paidDownload: r.paidDownload,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -28,6 +33,30 @@ export class ResumeService {
|
|||||||
return resume.toObject()
|
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) {
|
async delete(resumeId: string, userId: string) {
|
||||||
const res = await this.resumeModel.deleteOne({ _id: resumeId, userId }).exec()
|
const res = await this.resumeModel.deleteOne({ _id: resumeId, userId }).exec()
|
||||||
if (res.deletedCount === 0) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND)
|
if (res.deletedCount === 0) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND)
|
||||||
|
|||||||
@@ -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<UserDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { MongooseModule } from '@nestjs/mongoose'
|
|||||||
import { JwtModule } from '@nestjs/jwt'
|
import { JwtModule } from '@nestjs/jwt'
|
||||||
import { UserController } from './user.controller'
|
import { UserController } from './user.controller'
|
||||||
import { UserService } from './user.service'
|
import { UserService } from './user.service'
|
||||||
|
import { QuotaService } from './quota.service'
|
||||||
import { User, UserSchema } from './user.schema'
|
import { User, UserSchema } from './user.schema'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -14,7 +15,7 @@ import { User, UserSchema } from './user.schema'
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService],
|
providers: [UserService, QuotaService],
|
||||||
exports: [UserService],
|
exports: [UserService, QuotaService],
|
||||||
})
|
})
|
||||||
export class UserModule {}
|
export class UserModule {}
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ export class User {
|
|||||||
@Prop({ default: 0 })
|
@Prop({ default: 0 })
|
||||||
sprintRemaining: number // 冲刺版剩余次数(语音分析等)
|
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' })
|
@Prop({ default: 'user' })
|
||||||
role: string // 'user' | 'admin'
|
role: string // 'user' | 'admin'
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,10 @@ export class UserService {
|
|||||||
isSystemAdmin: user.isSystemAdmin || false,
|
isSystemAdmin: user.isSystemAdmin || false,
|
||||||
remaining: user.remaining,
|
remaining: user.remaining,
|
||||||
interviewCount: user.interviewCount,
|
interviewCount: user.interviewCount,
|
||||||
|
interviewCredits: user.interviewCredits ?? 1,
|
||||||
|
resumeOptimizeCredits: user.resumeOptimizeCredits ?? 0,
|
||||||
|
resumeDownloadCredits: user.resumeDownloadCredits ?? 0,
|
||||||
|
freeOptimizeUsed: user.freeOptimizeUsed ?? 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 不过期
|
||||||
Reference in New Issue
Block a user