v4.2 冲刺版+每日推送+支付修复+全量代码评审
## 新增功能 - 冲刺版 ¥49.9/月:完整支付→激活→权益扣减链路 - 每日一题定时推送(@nestjs/schedule,早8点微信订阅消息) - miniprogram-ci 编译上传脚本(scripts/upload-mp.js) ## Bug修复 - 套餐值统一:vip→growth/sprint(interview轮次限制、analyze次数检查) - member/pay 移除开发绕过:改为订单校验后激活 - progress→report 参数名不匹配:id→interviewId - result.vue resume.create() 参数传错(对象→独立参数) - resume.vue analyze请求缺少Authorization header - bank.vue contribution请求缺少Authorization header - member.vue startPay() 缺少try/catch导致网络错误崩溃 - login.vue 调试面板 v-if="true" 生产泄漏 ## 配置 - 微信支付生产证书就位(商户号1113760598) - .env 清理冗余文件(删除.example/.production) - WX_NOTIFY_URL 更新为 zhiyinwx.yzrcloud.cn ## 文档 - PROJECT-STATUS.md v4.1→v4.2,状态全面更新 - DEPLOYMENT.md 新增小程序编译上传章节、清理检查清单
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
# 职引后端环境变量配置
|
||||
|
||||
# MongoDB
|
||||
MONGODB_URI=mongodb://localhost:27017/zhiyin
|
||||
|
||||
# AI 主服务商 - opencode-go (deepseek-v4-flash)
|
||||
AI_PRIMARY_URL=https://opencode.ai/zen/go/v1
|
||||
AI_PRIMARY_KEY=your_primary_api_key_here
|
||||
AI_PRIMARY_MODEL=deepseek-v4-flash
|
||||
|
||||
# AI 备用服务商 - NVIDIA (stepfun-ai/step-3.5-flash)
|
||||
AI_BACKUP_URL=https://integrate.api.nvidia.com/v1
|
||||
AI_BACKUP_KEY=your_backup_api_key_here
|
||||
AI_BACKUP_MODEL=stepfun-ai/step-3.5-flash
|
||||
|
||||
# 服务端口
|
||||
PORT=3000
|
||||
@@ -1,31 +0,0 @@
|
||||
# 生产环境配置
|
||||
NODE_ENV=production
|
||||
|
||||
# 服务器配置
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
|
||||
# MongoDB 生产环境(需修改为实际生产数据库)
|
||||
MONGODB_URI=mongodb://username:password@production-host:27017/zhiyin?authSource=admin
|
||||
|
||||
# JWT 密钥(生产环境必须使用强密钥)
|
||||
JWT_SECRET=your-super-strong-jwt-secret-key-here-minimum-32-chars
|
||||
|
||||
# AI 配置(已配置)
|
||||
AI_PRIMARY_URL=https://token.sensenova.cn/v1
|
||||
AI_PRIMARY_KEY=sk-2Bbcf8pSTSl1x2BV5fKtDsUIGdfjKX7M
|
||||
AI_PRIMARY_MODEL=deepseek-v4-flash
|
||||
|
||||
AI_BACKUP_URL=https://integrate.api.nvidia.com/v1
|
||||
AI_BACKUP_KEY=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
|
||||
AI_BACKUP_MODEL=stepfun-ai/step-3.5-flash
|
||||
|
||||
# 微信小程序配置(生产环境)
|
||||
WECHAT_APPID=your-production-appid
|
||||
WECHAT_SECRET=your-production-secret
|
||||
|
||||
# 日志级别
|
||||
LOG_LEVEL=info
|
||||
|
||||
# CORS 配置(生产环境指定域名)
|
||||
ALLOWED_ORIGINS=https://yourdomain.com,https://yourdomain.com
|
||||
Generated
+46
@@ -16,6 +16,7 @@
|
||||
"@nestjs/mongoose": "^10.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/serve-static": "^4.0.2",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"axios": "^1.16.1",
|
||||
@@ -2074,6 +2075,19 @@
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@nestjs/schedule/-/schedule-6.1.3.tgz",
|
||||
"integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron": "4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "10.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
||||
@@ -2464,6 +2478,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmmirror.com/@types/luxon/-/luxon-3.7.1.tgz",
|
||||
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
@@ -4241,6 +4261,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cron": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/cron/-/cron-4.4.0.tgz",
|
||||
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/luxon": "~3.7.0",
|
||||
"luxon": "~3.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.x"
|
||||
},
|
||||
"funding": {
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/intcreator"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -7017,6 +7054,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.8",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@nestjs/mongoose": "^10.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/serve-static": "^4.0.2",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"axios": "^1.16.1",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { JwtModule } from '@nestjs/jwt'
|
||||
import { PassportModule } from '@nestjs/passport'
|
||||
import { ThrottlerModule } from '@nestjs/throttler'
|
||||
import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule'
|
||||
import { APP_GUARD } from '@nestjs/core'
|
||||
|
||||
import { JwtStrategy } from './common/strategies/jwt.strategy'
|
||||
@@ -21,6 +22,7 @@ import { AnalyzeModule } from './modules/analyze/analyze.module'
|
||||
import { ProgressModule } from './modules/progress/progress.module'
|
||||
import { ContributionModule } from './modules/contribution/contribution.module'
|
||||
import { DailyQuestionModule } from './modules/daily-question/daily-question.module'
|
||||
import { ScheduleModule } from './modules/schedule/schedule.module'
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin'
|
||||
|
||||
@@ -36,6 +38,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
|
||||
ttl: 60000,
|
||||
limit: 10,
|
||||
}]),
|
||||
NestScheduleModule.forRoot(),
|
||||
UserModule,
|
||||
AiModule,
|
||||
InterviewModule,
|
||||
@@ -50,6 +53,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
|
||||
ProgressModule,
|
||||
ContributionModule,
|
||||
DailyQuestionModule,
|
||||
ScheduleModule,
|
||||
],
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 面试回答中的语气词/填充词分析
|
||||
* 检测用户回答中的语气词密度、语速估算等
|
||||
*/
|
||||
|
||||
const CHINESE_FILLER_WORDS = [
|
||||
'嗯', '啊', '呃', '哦', '那个', '这个', '然后', '就是',
|
||||
'对吧', '所以说', '反正', '其实', '就是说', '那', '那么',
|
||||
'还有一个', '另外', '基本上', '大概', '可能', '应该',
|
||||
'的话', '的时候', '一种', '一个', '一种', '可以说',
|
||||
]
|
||||
|
||||
const ENGLISH_FILLER_WORDS = [
|
||||
'um', 'uh', 'er', 'ah', 'like', 'you know', 'actually',
|
||||
'basically', 'literally', 'honestly', 'sort of', 'kind of',
|
||||
'i mean', 'you see', 'well', 'so', 'anyway',
|
||||
]
|
||||
|
||||
function countOccurrences(text: string, words: string[]): { word: string; count: number }[] {
|
||||
const result: { word: string; count: number }[] = []
|
||||
const lowerText = text.toLowerCase()
|
||||
for (const word of words) {
|
||||
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(escaped, 'g')
|
||||
const matches = lowerText.match(regex)
|
||||
if (matches && matches.length > 0) {
|
||||
result.push({ word, count: matches.length })
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => b.count - a.count)
|
||||
}
|
||||
|
||||
export function analyzeSpeech(text: string) {
|
||||
const totalChars = text.length
|
||||
const totalWords = text.split(/\s+/).filter(Boolean).length
|
||||
|
||||
const chineseFillers = countOccurrences(text, CHINESE_FILLER_WORDS)
|
||||
const englishFillers = countOccurrences(text, ENGLISH_FILLER_WORDS)
|
||||
const allFillers = [...chineseFillers, ...englishFillers]
|
||||
const totalFillerCount = allFillers.reduce((s, f) => s + f.count, 0)
|
||||
|
||||
// 估算语速(中文字符/秒,假设正常说话速度 ~3-4 字/秒)
|
||||
const estimatedDurationSec = Math.max(totalChars / 3.5, 10)
|
||||
const speechRate = totalChars / estimatedDurationSec
|
||||
|
||||
// 语气词密度
|
||||
const fillerDensity = totalChars > 0 ? totalFillerCount / totalChars : 0
|
||||
|
||||
// 评分:语气词越少越好
|
||||
let fillerScore = 100
|
||||
if (fillerDensity > 0.15) fillerScore = 40
|
||||
else if (fillerDensity > 0.10) fillerScore = 60
|
||||
else if (fillerDensity > 0.05) fillerScore = 80
|
||||
|
||||
// 判断是否过长/过短
|
||||
let lengthFeedback = ''
|
||||
if (totalChars < 20) lengthFeedback = '回答过短,建议展开阐述'
|
||||
else if (totalChars > 500) lengthFeedback = '回答偏长,建议精简重点'
|
||||
|
||||
return {
|
||||
totalChars,
|
||||
totalWords,
|
||||
fillerCount: totalFillerCount,
|
||||
fillerWords: allFillers.slice(0, 5),
|
||||
fillerDensity: Math.round(fillerDensity * 1000) / 10,
|
||||
fillerScore,
|
||||
estimatedDurationSec: Math.round(estimatedDurationSec),
|
||||
speechRate: Math.round(speechRate * 10) / 10,
|
||||
lengthFeedback,
|
||||
topFiller: allFillers[0]?.word || null,
|
||||
}
|
||||
}
|
||||
+42
-1
@@ -1,4 +1,38 @@
|
||||
import 'dotenv/config'
|
||||
|
||||
// Polyfill DOMMatrix for pdf-parse (pdf.js requires browser API)
|
||||
if (typeof globalThis.DOMMatrix === 'undefined') {
|
||||
class DOMMatrixPolyfill {
|
||||
a = 1; b = 0; c = 0; d = 1; e = 0; f = 0
|
||||
m11 = 1; m12 = 0; m13 = 0; m14 = 0
|
||||
m21 = 0; m22 = 1; m23 = 0; m24 = 0
|
||||
m31 = 0; m32 = 0; m33 = 1; m34 = 0
|
||||
m41 = 0; m42 = 0; m43 = 0; m44 = 1
|
||||
is2D = true; isIdentity = true
|
||||
constructor(init?: string | number[]) {
|
||||
if (typeof init === 'string') {
|
||||
const m = init.match(/matrix\(([^)]+)\)/)
|
||||
if (m) {
|
||||
const v = m[1].split(',').map(Number)
|
||||
if (v.length >= 6) { this.a = v[0]; this.b = v[1]; this.c = v[2]; this.d = v[3]; this.e = v[4]; this.f = v[5] }
|
||||
}
|
||||
}
|
||||
}
|
||||
multiply(m: DOMMatrixPolyfill) { return this }
|
||||
translate(x: number, y: number) { return this }
|
||||
scale(s: number) { return this }
|
||||
rotate(r: number) { return this }
|
||||
toString() { return `matrix(${this.a}, ${this.b}, ${this.c}, ${this.d}, ${this.e}, ${this.f})` }
|
||||
toJSON() { return { a: this.a, b: this.b, c: this.c, d: this.d, e: this.e, f: this.f } }
|
||||
}
|
||||
// @ts-ignore
|
||||
globalThis.DOMMatrix = DOMMatrixPolyfill
|
||||
// @ts-ignore
|
||||
globalThis.DOMMatrixReadOnly = DOMMatrixPolyfill
|
||||
// @ts-ignore
|
||||
globalThis.DOMPoint = class { x = 0; y = 0; z = 0; w = 1 }
|
||||
}
|
||||
|
||||
import { NestFactory } from '@nestjs/core'
|
||||
import { ValidationPipe } from '@nestjs/common'
|
||||
import { AppModule } from './app.module'
|
||||
@@ -8,8 +42,15 @@ async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule)
|
||||
|
||||
app.setGlobalPrefix('api')
|
||||
const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || ['http://localhost:8085', 'http://localhost:3006']
|
||||
app.enableCors({
|
||||
origin: '*',
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || allowedOrigins.includes(origin) || process.env.NODE_ENV !== 'production') {
|
||||
callback(null, true)
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'))
|
||||
}
|
||||
},
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
credentials: true,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Controller, Get, Post, Body, Query, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { User, UserDocument } from '../user/user.schema'
|
||||
@@ -29,19 +28,21 @@ export class AdminController {
|
||||
return { isAdmin: user?.role === 'admin' }
|
||||
}
|
||||
|
||||
@Public()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('verify')
|
||||
async verify(@Body('adminId') adminId: string) {
|
||||
const user = await this.userModel.findById(adminId).exec()
|
||||
async verify(@CurrentUser('userId') userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user || user.role !== 'admin') {
|
||||
throw new HttpException('无权限访问', HttpStatus.FORBIDDEN)
|
||||
}
|
||||
return { ok: true, nickname: user.nickname || '管理员' }
|
||||
}
|
||||
|
||||
@Public()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('overview')
|
||||
async overview() {
|
||||
async overview(@CurrentUser('userId') adminUserId: string) {
|
||||
const admin = await this.userModel.findById(adminUserId).exec()
|
||||
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||
const [userCount, interviewCount, todayUsers, todayInterviews] = await Promise.all([
|
||||
this.userModel.countDocuments().exec(),
|
||||
this.interviewModel.countDocuments().exec(),
|
||||
@@ -51,9 +52,11 @@ export class AdminController {
|
||||
return { userCount, interviewCount, todayUsers, todayInterviews }
|
||||
}
|
||||
|
||||
@Public()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('users')
|
||||
async getUsers(@Query('keyword') keyword: string, @Query('page') page = '1', @Query('limit') limit = '20') {
|
||||
async getUsers(@Query('keyword') keyword: string, @Query('page') page = '1', @Query('limit') limit = '20', @CurrentUser('userId') adminUserId: string) {
|
||||
const admin = await this.userModel.findById(adminUserId).exec()
|
||||
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||
const filter: any = {}
|
||||
if (keyword) filter.$or = [
|
||||
{ phone: { $regex: keyword, $options: 'i' } },
|
||||
@@ -67,9 +70,11 @@ export class AdminController {
|
||||
return { users, total, page: +page }
|
||||
}
|
||||
|
||||
@Public()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('interviews')
|
||||
async getInterviews(@Query('page') page = '1', @Query('limit') limit = '20') {
|
||||
async getInterviews(@Query('page') page = '1', @Query('limit') limit = '20', @CurrentUser('userId') adminUserId: string) {
|
||||
const admin = await this.userModel.findById(adminUserId).exec()
|
||||
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||
const skip = (Math.max(1, +page) - 1) * +limit
|
||||
const [interviews, total] = await Promise.all([
|
||||
this.interviewModel.find().sort({ createdAt: -1 }).skip(skip).limit(+limit).populate('userId', 'phone nickname').lean().exec(),
|
||||
@@ -86,12 +91,12 @@ export class AdminController {
|
||||
const user = await this.userModel.findById(targetUserId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + 30)
|
||||
user.plan = 'vip'
|
||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
||||
user.plan = 'growth'
|
||||
user.vipExpireAt = expireAt
|
||||
user.remaining = 999
|
||||
await user.save()
|
||||
return { success: true, plan: 'vip', expireAt }
|
||||
return { success: true, plan: 'growth', expireAt }
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -144,14 +149,14 @@ export class AdminController {
|
||||
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
||||
if (tradeState === 'SUCCESS' && order.status === 'pending') {
|
||||
order.status = 'success'
|
||||
order.wxTransactionId = wxResult.transaction_id
|
||||
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 !== 'vip') {
|
||||
if (user && user.plan === 'free') {
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
|
||||
user.plan = 'vip'
|
||||
user.plan = 'growth'
|
||||
user.vipExpireAt = expireAt
|
||||
user.remaining = 999
|
||||
await user.save()
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { Controller, Post, Body, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
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 { Progress, ProgressDocument } from '../schemas/progress.schema'
|
||||
|
||||
// 各岗位的四维基准分(基于校招平均水平)
|
||||
const POSITION_BENCHMARKS: Record<string, { logic: number; expression: number; professionalism: number; stability: number }> = {
|
||||
'前端工程师': { logic: 75, expression: 70, professionalism: 72, stability: 68 },
|
||||
'后端工程师': { logic: 80, expression: 65, professionalism: 78, stability: 65 },
|
||||
'AI算法工程师': { logic: 85, expression: 60, professionalism: 85, stability: 60 },
|
||||
'产品经理': { logic: 72, expression: 82, professionalism: 70, stability: 75 },
|
||||
'数据分析师': { logic: 78, expression: 72, professionalism: 75, stability: 70 },
|
||||
'UI/UX设计师': { logic: 65, expression: 80, professionalism: 75, stability: 72 },
|
||||
'运营': { logic: 68, expression: 82, professionalism: 65, stability: 78 },
|
||||
'测试工程师': { logic: 72, expression: 65, professionalism: 75, stability: 70 },
|
||||
}
|
||||
|
||||
const DEFAULT_BENCHMARK = { logic: 70, expression: 70, professionalism: 70, stability: 70 }
|
||||
|
||||
@Controller('analyze')
|
||||
export class AnalyzeController {
|
||||
constructor(
|
||||
private analyzeService: AnalyzeService,
|
||||
private userService: UserService,
|
||||
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -25,14 +43,82 @@ export class AnalyzeController {
|
||||
return this.analyzeService.optimize(content, direction)
|
||||
}
|
||||
|
||||
/** 技能缺口分析:将用户四维分数与目标岗位基准对比 */
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('skills-gap')
|
||||
async skillsGap(@CurrentUser('userId') userId: string, @Body('targetPosition') targetPosition?: string) {
|
||||
const progress = await this.progressModel.findOne({ userId }).exec()
|
||||
if (!progress || progress.completedInterviews === 0) {
|
||||
throw new HttpException('暂无面试数据,请先完成至少一次模拟面试', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
const userDims = {
|
||||
logic: progress.avgLogic || 0,
|
||||
expression: progress.avgExpression || 0,
|
||||
professionalism: progress.avgProfessionalism || 0,
|
||||
stability: progress.avgStability || 0,
|
||||
}
|
||||
|
||||
// 使用用户最近一次面试的岗位,或传入的目标岗位
|
||||
const lastPosition = targetPosition || (progress.recentScores.length > 0 ? progress.recentScores[progress.recentScores.length - 1].position : '')
|
||||
const benchmark = POSITION_BENCHMARKS[lastPosition] || DEFAULT_BENCHMARK
|
||||
|
||||
const gaps: Array<{ dimension: string; currentScore: number; targetScore: number; gap: number; level: string }> = []
|
||||
const dimLabels: Record<string, string> = {
|
||||
logic: '逻辑思维',
|
||||
expression: '表达能力',
|
||||
professionalism: '专业度',
|
||||
stability: '情绪稳定性',
|
||||
}
|
||||
|
||||
for (const [key, label] of Object.entries(dimLabels)) {
|
||||
const score = userDims[key as keyof typeof userDims]
|
||||
const target = benchmark[key as keyof typeof benchmark]
|
||||
const gap = target - score
|
||||
if (gap > 0) {
|
||||
gaps.push({
|
||||
dimension: label,
|
||||
currentScore: score,
|
||||
targetScore: target,
|
||||
gap: Math.round(gap),
|
||||
level: gap > 15 ? '严重不足' : gap > 5 ? '需提升' : '接近基准',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 生成学习建议
|
||||
const suggestions = gaps.map(g => {
|
||||
const tips: Record<string, string> = {
|
||||
'逻辑思维': '多用 STAR 法则组织回答,练习"结论先行+论据支撑"结构',
|
||||
'表达能力': '录音回听自己的回答,控制语速,减少语气词,精炼表达',
|
||||
'专业度': '补充岗位相关基础知识,阅读面经中的专业问题',
|
||||
'情绪稳定性': '进行更多模拟面试练习,适应面试压力环境',
|
||||
}
|
||||
return { dimension: g.dimension, tip: tips[g.dimension] || '持续练习提升' }
|
||||
})
|
||||
|
||||
const totalGap = gaps.reduce((s, g) => s + g.gap, 0)
|
||||
|
||||
return {
|
||||
dimensions: userDims,
|
||||
benchmark,
|
||||
gaps,
|
||||
suggestions,
|
||||
totalGap,
|
||||
assessment: totalGap > 30 ? '整体与目标岗位存在差距,建议针对性提升' :
|
||||
totalGap > 10 ? '接近目标水平,针对薄弱项提升即可' :
|
||||
'已达到或超过目标岗位基准,继续保持',
|
||||
targetPosition: lastPosition,
|
||||
}
|
||||
}
|
||||
|
||||
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 === 'vip') return // VIP 不限次
|
||||
if (user.plan !== 'free') return
|
||||
if (user.remaining <= 0) {
|
||||
throw new HttpException('免费版每日次数已用完,升级会员后不限次使用', HttpStatus.FORBIDDEN)
|
||||
}
|
||||
// 扣减一次
|
||||
user.remaining -= 1
|
||||
await user.save()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AnalyzeController } from './analyze.controller'
|
||||
import { AnalyzeService } from './analyze.service'
|
||||
import { UserModule } from '../user/user.module'
|
||||
import { Progress, ProgressSchema } from '../schemas/progress.schema'
|
||||
|
||||
@Module({
|
||||
imports: [UserModule],
|
||||
imports: [
|
||||
UserModule,
|
||||
MongooseModule.forFeature([{ name: Progress.name, schema: ProgressSchema }]),
|
||||
],
|
||||
controllers: [AnalyzeController],
|
||||
providers: [AnalyzeService],
|
||||
})
|
||||
|
||||
@@ -25,6 +25,15 @@ export class Interview {
|
||||
|
||||
@Prop({ default: '' })
|
||||
summary: string
|
||||
|
||||
@Prop({ type: [{ word: String, count: Number }], default: [] })
|
||||
fillerWords: { word: string; count: number }[]
|
||||
|
||||
@Prop({ default: 0 })
|
||||
fillerScore: number
|
||||
|
||||
@Prop({ default: 0 })
|
||||
fillerDensity: number
|
||||
}
|
||||
|
||||
export const InterviewSchema = SchemaFactory.createForClass(Interview)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Injectable, HttpException, HttpStatus, forwardRef, Inject } from '@nestjs/common'
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
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 { analyzeSpeech } from '../../common/utils/filler-words'
|
||||
|
||||
@Injectable()
|
||||
export class InterviewService {
|
||||
@@ -47,10 +48,10 @@ export class InterviewService {
|
||||
|
||||
// 检查轮次限制
|
||||
const user = await this.userService.getModel().findById(userId).exec()
|
||||
const maxRounds = user?.plan === 'vip' ? 10 : 5
|
||||
const maxRounds = user?.plan !== 'free' ? 10 : 5
|
||||
if (interview.questionCount >= maxRounds) {
|
||||
throw new HttpException(
|
||||
user?.plan === 'vip' ? '已达到每场面试最大轮次(10轮)' : '免费版每场最多5轮,升级会员可享10轮',
|
||||
user?.plan !== 'free' ? '已达到每场面试最大轮次(10轮)' : '免费版每场最多5轮,升级会员可享10轮',
|
||||
HttpStatus.FORBIDDEN
|
||||
)
|
||||
}
|
||||
@@ -59,6 +60,12 @@ export class InterviewService {
|
||||
interview.messages.push({ role: 'user', content: answer })
|
||||
interview.questionCount += 1
|
||||
|
||||
// Analyze filler words in user's answer
|
||||
const speechAnalysis = analyzeSpeech(answer)
|
||||
interview.fillerWords = speechAnalysis.fillerWords.map(f => ({ word: f.word, count: f.count }))
|
||||
interview.fillerScore = speechAnalysis.fillerScore
|
||||
interview.fillerDensity = speechAnalysis.fillerDensity
|
||||
|
||||
// AI evaluates answer and generates next question
|
||||
const conversationHistory = interview.messages
|
||||
.slice(-6)
|
||||
@@ -230,7 +237,21 @@ ${fullConversation}
|
||||
async getDetail(interviewId: string, userId: string) {
|
||||
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
|
||||
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
|
||||
return interview
|
||||
|
||||
// Compute aggregate speech analysis from user messages
|
||||
const userAnswers = interview.messages
|
||||
.filter(m => m.role === 'user')
|
||||
.map(m => m.content)
|
||||
.join('\n')
|
||||
const speechAnalysis = userAnswers ? analyzeSpeech(userAnswers) : null
|
||||
|
||||
return {
|
||||
...interview.toObject(),
|
||||
fillerWords: speechAnalysis?.fillerWords || interview.fillerWords,
|
||||
fillerScore: speechAnalysis?.fillerScore || interview.fillerScore,
|
||||
fillerDensity: speechAnalysis?.fillerDensity || interview.fillerDensity,
|
||||
speechAnalysis,
|
||||
}
|
||||
}
|
||||
|
||||
async getList(userId: string) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Controller, Post, Get, Body, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
|
||||
import { Controller, Post, Get, Body, HttpException, HttpStatus, UseGuards, Logger } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { User, UserDocument } from '../user/user.schema'
|
||||
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
|
||||
const GROWTH_PRICE = 1990
|
||||
const SPRINT_PRICE = 4990
|
||||
const DURATION_DAYS = 30
|
||||
const FREE_DAILY_LIMIT = 2
|
||||
|
||||
@@ -20,30 +22,23 @@ interface PlanConfig {
|
||||
|
||||
const PLANS: Record<string, PlanConfig> = {
|
||||
free: {
|
||||
id: 'free',
|
||||
name: '免费版',
|
||||
price: 0,
|
||||
dailyLimit: FREE_DAILY_LIMIT,
|
||||
id: 'free', name: '免费版', price: 0, dailyLimit: FREE_DAILY_LIMIT,
|
||||
features: [
|
||||
'每日 2 次 AI 模拟面试',
|
||||
'基础面试报告',
|
||||
'通用题库随机出题',
|
||||
'简历诊断(限 3 次)',
|
||||
'每日 2 次 AI 模拟面试', '基础面试报告', '通用题库随机出题', '简历诊断(限 3 次)',
|
||||
],
|
||||
},
|
||||
growth: {
|
||||
id: 'growth',
|
||||
name: '成长版',
|
||||
price: GROWTH_PRICE,
|
||||
dailyLimit: 999,
|
||||
id: 'growth', name: '成长版', price: GROWTH_PRICE, dailyLimit: 999,
|
||||
features: [
|
||||
'免费版全部权益',
|
||||
'无限面试次数',
|
||||
'详细面试报告(四维评分)',
|
||||
'进步轨迹雷达图 + 打卡',
|
||||
'每日一题推送',
|
||||
'参考回答思路',
|
||||
'公司真题库',
|
||||
'免费版全部权益', '无限面试次数', '详细面试报告(四维评分)',
|
||||
'进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库',
|
||||
],
|
||||
},
|
||||
sprint: {
|
||||
id: 'sprint', name: '冲刺版', price: SPRINT_PRICE, dailyLimit: 999,
|
||||
features: [
|
||||
'成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告',
|
||||
'学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先',
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -51,30 +46,25 @@ const PLANS: Record<string, PlanConfig> = {
|
||||
@Controller('member')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MemberController {
|
||||
private readonly logger = new Logger(MemberController.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||||
) {}
|
||||
|
||||
// 公开的套餐配置(给前端会员页和限制拦截用)
|
||||
@Public()
|
||||
@Get('plans')
|
||||
getPlans() {
|
||||
return {
|
||||
interview: {
|
||||
dailyFreeLimit: FREE_DAILY_LIMIT,
|
||||
maxRoundsFree: 5,
|
||||
maxRoundsVip: 10,
|
||||
},
|
||||
interview: { dailyFreeLimit: FREE_DAILY_LIMIT, maxRoundsFree: 5, maxRoundsVip: 10 },
|
||||
diagnosis: { dailyFreeLimit: 2 },
|
||||
optimize: { dailyFreeLimit: 2 },
|
||||
price: { monthly: GROWTH_PRICE },
|
||||
price: { monthly: GROWTH_PRICE, sprint: SPRINT_PRICE },
|
||||
plans: Object.values(PLANS).map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
price: p.price,
|
||||
id: p.id, name: p.name, price: p.price,
|
||||
priceDisplay: p.price === 0 ? '免费' : `¥${(p.price / 100).toFixed(1)}/月`,
|
||||
dailyLimit: p.dailyLimit,
|
||||
features: p.features,
|
||||
dailyLimit: p.dailyLimit, features: p.features,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -90,41 +80,48 @@ export class MemberController {
|
||||
remaining: user.remaining,
|
||||
dailyLimit: planConfig.dailyLimit,
|
||||
vipExpireAt: user.vipExpireAt,
|
||||
sprintExpireAt: user.sprintExpireAt,
|
||||
sprintRemaining: user.sprintRemaining || 0,
|
||||
isVip: user.plan !== 'free',
|
||||
}
|
||||
}
|
||||
|
||||
@Post('create-order')
|
||||
async createOrder(@CurrentUser('userId') userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
const orderId = `ZHI${Date.now()}${userId.slice(-4)}`
|
||||
return {
|
||||
orderId,
|
||||
planId: 'growth',
|
||||
planName: '成长版',
|
||||
amount: GROWTH_PRICE,
|
||||
amountDisplay: `¥${(GROWTH_PRICE / 100).toFixed(1)}`,
|
||||
duration: `${DURATION_DAYS} 天`,
|
||||
}
|
||||
}
|
||||
|
||||
/** 凭订单激活套餐(前端 JSAPI 支付成功后兜底调用) */
|
||||
@Post('pay')
|
||||
async pay(@CurrentUser('userId') userId: string, @Body('orderId') orderId: string) {
|
||||
async pay(@CurrentUser('userId') userId: string, @Body('outTradeNo') outTradeNo: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.plan !== 'free') return { success: true, plan: user.plan, message: '已是会员' }
|
||||
|
||||
const order = await this.orderModel.findOne({ outTradeNo, userId }).exec()
|
||||
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
||||
if (order.status !== 'success') throw new HttpException('支付未完成', HttpStatus.BAD_REQUEST)
|
||||
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + DURATION_DAYS)
|
||||
user.plan = 'growth'
|
||||
user.vipExpireAt = expireAt
|
||||
if (order.plan === 'sprint') {
|
||||
user.plan = 'sprint'
|
||||
user.sprintExpireAt = expireAt
|
||||
user.sprintRemaining = 10
|
||||
} else {
|
||||
user.plan = 'growth'
|
||||
user.vipExpireAt = expireAt
|
||||
}
|
||||
user.remaining = 999
|
||||
await user.save()
|
||||
return {
|
||||
success: true,
|
||||
plan: 'growth',
|
||||
planName: '成长版',
|
||||
expireAt,
|
||||
message: '支付成功!欢迎开通成长版',
|
||||
}
|
||||
return { success: true, plan: user.plan, planName: PLANS[user.plan]?.name, expireAt }
|
||||
}
|
||||
|
||||
/** 扣减冲刺版权益次数 */
|
||||
@Post('sprint/deduct')
|
||||
async deductSprint(@CurrentUser('userId') userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.plan !== 'sprint') throw new HttpException('非冲刺版会员', HttpStatus.FORBIDDEN)
|
||||
if (user.sprintExpireAt && user.sprintExpireAt < new Date()) throw new HttpException('会员已过期', HttpStatus.FORBIDDEN)
|
||||
if ((user.sprintRemaining || 0) <= 0) throw new HttpException('剩余次数不足', HttpStatus.FORBIDDEN)
|
||||
user.sprintRemaining = (user.sprintRemaining || 0) - 1
|
||||
await user.save()
|
||||
return { success: true, sprintRemaining: user.sprintRemaining }
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@ import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { MemberController } from './member.controller'
|
||||
import { User, UserSchema } from '../user/user.schema'
|
||||
import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema'
|
||||
|
||||
@Module({
|
||||
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
|
||||
imports: [MongooseModule.forFeature([
|
||||
{ name: User.name, schema: UserSchema },
|
||||
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
|
||||
])],
|
||||
controllers: [MemberController],
|
||||
})
|
||||
export class MemberModule {}
|
||||
|
||||
@@ -27,6 +27,9 @@ export class PaymentOrder {
|
||||
@Prop({ default: 'pending' })
|
||||
status: string
|
||||
|
||||
@Prop({ default: 'growth' })
|
||||
plan: string // growth | sprint
|
||||
|
||||
@Prop({ default: 'native' })
|
||||
channel: string // native | jsapi
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Post, Get, Query, Body, UseGuards, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { Controller, Post, Get, Param, Query, 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'
|
||||
@@ -8,11 +8,14 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { WechatPayService } from './wechat-pay.service'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
|
||||
const VIP_AMOUNT = 1990 // 19.9 元(分)
|
||||
const GROWTH_AMOUNT = 1990 // 19.9 元(分)
|
||||
const SPRINT_AMOUNT = 4990 // 49.9 元(分)
|
||||
const VIP_DURATION_DAYS = 30
|
||||
|
||||
@Controller('payment')
|
||||
export class PaymentController {
|
||||
private readonly logger = new Logger(PaymentController.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||||
@@ -22,73 +25,68 @@ export class PaymentController {
|
||||
/** 创建订单(H5:Native 扫码支付) */
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('create')
|
||||
async create(@CurrentUser('userId') userId: string) {
|
||||
async create(@CurrentUser('userId') userId: string, @Body('plan') plan: string = 'growth') {
|
||||
if (!['growth', 'sprint'].includes(plan)) 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 === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
||||
if (user.plan !== 'free') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
||||
|
||||
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
|
||||
const result = await this.wechatPay.nativePay('AI磁场月度会员', outTradeNo, VIP_AMOUNT)
|
||||
const amount = plan === 'sprint' ? SPRINT_AMOUNT : GROWTH_AMOUNT
|
||||
const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员'
|
||||
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: VIP_AMOUNT,
|
||||
title: 'AI磁场月度会员',
|
||||
status: 'pending',
|
||||
channel: 'native',
|
||||
})
|
||||
await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount, title, status: 'pending', channel: 'native', plan })
|
||||
|
||||
return {
|
||||
outTradeNo,
|
||||
codeUrl: result.codeUrl,
|
||||
amount: VIP_AMOUNT,
|
||||
title: 'AI磁场月度会员',
|
||||
}
|
||||
return { outTradeNo, codeUrl: result.codeUrl, amount, title }
|
||||
}
|
||||
|
||||
/** JSAPI 支付(微信小程序) */
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('jsapi')
|
||||
async jsapi(@CurrentUser('userId') userId: string, @Body('openid') openid: string) {
|
||||
async jsapi(@CurrentUser('userId') userId: string, @Body('plan') plan: string = 'growth') {
|
||||
if (!['growth', 'sprint'].includes(plan)) throw new HttpException('无效套餐', HttpStatus.BAD_REQUEST)
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (!openid) throw new HttpException('缺少 openid', HttpStatus.BAD_REQUEST)
|
||||
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
||||
if (user.plan !== 'free') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
|
||||
const openid = user.wxOpenid
|
||||
if (!openid) throw new HttpException('未绑定微信', HttpStatus.BAD_REQUEST)
|
||||
|
||||
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
|
||||
const result = await this.wechatPay.jsapiPay('AI磁场月度会员', outTradeNo, VIP_AMOUNT, openid)
|
||||
const amount = plan === 'sprint' ? SPRINT_AMOUNT : GROWTH_AMOUNT
|
||||
const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员'
|
||||
const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}`
|
||||
const result = await this.wechatPay.jsapiPay(title, outTradeNo, amount, openid)
|
||||
|
||||
// 保存订单
|
||||
await this.orderModel.create({
|
||||
outTradeNo,
|
||||
userId,
|
||||
userPhone: user.phone || '',
|
||||
amount: VIP_AMOUNT,
|
||||
title: 'AI磁场月度会员',
|
||||
status: 'pending',
|
||||
channel: 'jsapi',
|
||||
})
|
||||
await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount, title, status: 'pending', channel: 'jsapi', plan })
|
||||
|
||||
return result
|
||||
return { ...result, outTradeNo }
|
||||
}
|
||||
|
||||
/** 支付回调通知 */
|
||||
@Public()
|
||||
@Post('notify')
|
||||
async notify(@Body() body: any) {
|
||||
async notify(
|
||||
@Body() body: any,
|
||||
@Req() req: any,
|
||||
) {
|
||||
try {
|
||||
const decrypted = this.wechatPay.verifyAndDecrypt(body, '', '', '')
|
||||
const wechatSignature = req.headers['wechatpay-signature'] || ''
|
||||
const wechatTimestamp = req.headers['wechatpay-timestamp'] || ''
|
||||
const wechatNonce = req.headers['wechatpay-nonce'] || ''
|
||||
const decrypted = this.wechatPay.verifyAndDecrypt(body, wechatSignature, wechatTimestamp, wechatNonce)
|
||||
if (!decrypted) return { code: 'FAIL', message: '验签失败' }
|
||||
|
||||
const outTradeNo = decrypted.out_trade_no
|
||||
const wxTransactionId = decrypted.transaction_id
|
||||
|
||||
// 更新订单状态
|
||||
// 从数据库订单查找 userId,而非从 outTradeNo 解析
|
||||
const order = await this.orderModel.findOne({ outTradeNo }).exec()
|
||||
if (order && order.status === 'pending') {
|
||||
if (!order) {
|
||||
this.logger.warn(`支付回调:订单不存在 ${outTradeNo}`)
|
||||
return { code: 'FAIL', message: '订单不存在' }
|
||||
}
|
||||
|
||||
if (order.status === 'pending') {
|
||||
order.status = 'success'
|
||||
order.paidAt = new Date()
|
||||
order.wxTransactionId = wxTransactionId
|
||||
@@ -96,19 +94,25 @@ export class PaymentController {
|
||||
await order.save()
|
||||
}
|
||||
|
||||
// 开通会员
|
||||
const userId = outTradeNo.slice(-6)
|
||||
const user = await this.userModel.findOne({ _id: { $regex: userId + '$' } }).exec()
|
||||
if (user && user.plan !== 'vip') {
|
||||
// 根据订单 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)
|
||||
user.plan = 'vip'
|
||||
user.vipExpireAt = expireAt
|
||||
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()
|
||||
}
|
||||
return { code: 'SUCCESS', message: '成功' }
|
||||
} catch {
|
||||
} catch (e) {
|
||||
this.logger.error(`支付回调处理失败: ${e.message}`)
|
||||
return { code: 'FAIL', message: '处理失败' }
|
||||
}
|
||||
}
|
||||
@@ -119,4 +123,39 @@ export class PaymentController {
|
||||
async query(@Body('outTradeNo') outTradeNo: string) {
|
||||
return this.wechatPay.queryOrder(outTradeNo)
|
||||
}
|
||||
|
||||
/** 检查本地订单状态(前端轮询) */
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('check/:outTradeNo')
|
||||
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 }
|
||||
}
|
||||
|
||||
/** 凭订单号激活套餐(前端支付成功后调用,兜底) */
|
||||
@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
|
||||
}
|
||||
user.remaining = 999
|
||||
await user.save()
|
||||
return { success: true, plan: user.plan }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { Cron } from '@nestjs/schedule'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { WechatTokenService } from './wechat-token.service'
|
||||
import { DailyQuestion, DailyQuestionDocument } from '../schemas/daily-question.schema'
|
||||
import { User, UserDocument } from '../user/user.schema'
|
||||
|
||||
const DAILY_QUESTION_TEMPLATE_ID = process.env.WX_DAILY_QUESTION_TMPL || ''
|
||||
|
||||
@Injectable()
|
||||
export class DailyQuestionPushService {
|
||||
private readonly logger = new Logger(DailyQuestionPushService.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(DailyQuestion.name) private dailyQuestionModel: Model<DailyQuestionDocument>,
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
private wechatToken: WechatTokenService,
|
||||
) {}
|
||||
|
||||
@Cron('0 8 * * *')
|
||||
async pushDailyQuestion() {
|
||||
this.logger.log('开始每日一题推送任务')
|
||||
|
||||
if (!DAILY_QUESTION_TEMPLATE_ID) {
|
||||
this.logger.warn('未配置 WX_DAILY_QUESTION_TMPL,跳过每日推送')
|
||||
return
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const question = await this.dailyQuestionModel.findOne({ pushed: false }).sort({ date: -1 }).exec()
|
||||
if (!question) {
|
||||
this.logger.warn('没有未推送的题目')
|
||||
return
|
||||
}
|
||||
|
||||
const users = await this.userModel.find({ wxOpenid: { $ne: '', $exists: true } }).exec()
|
||||
this.logger.log(`找到 ${users.length} 个用户待推送`)
|
||||
|
||||
let successCount = 0
|
||||
for (const user of users) {
|
||||
if (!user.wxOpenid) continue
|
||||
const ok = await this.wechatToken.sendSubscribeMessage(
|
||||
user.wxOpenid,
|
||||
DAILY_QUESTION_TEMPLATE_ID,
|
||||
{
|
||||
thing1: { value: question.question.slice(0, 20) + (question.question.length > 20 ? '...' : '') },
|
||||
thing2: { value: question.position || '通用' },
|
||||
thing3: { value: question.referenceAnswer.slice(0, 20) + (question.referenceAnswer.length > 20 ? '...' : '') },
|
||||
},
|
||||
'pages/interview/interview',
|
||||
)
|
||||
if (ok) successCount++
|
||||
}
|
||||
|
||||
question.pushed = true
|
||||
await question.save()
|
||||
|
||||
this.logger.log(`每日一题推送完成:成功 ${successCount}/${users.length}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { DailyQuestionPushService } from './daily-question-push.service'
|
||||
import { WechatTokenService } from './wechat-token.service'
|
||||
import { DailyQuestion, DailyQuestionSchema } from '../schemas/daily-question.schema'
|
||||
import { User, UserSchema } from '../user/user.schema'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: DailyQuestion.name, schema: DailyQuestionSchema },
|
||||
{ name: User.name, schema: UserSchema },
|
||||
]),
|
||||
],
|
||||
providers: [WechatTokenService, DailyQuestionPushService],
|
||||
})
|
||||
export class ScheduleModule {}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import axios from 'axios'
|
||||
|
||||
const WX_APPID = process.env.WX_APPID
|
||||
const WX_SECRET = process.env.WX_SECRET
|
||||
|
||||
@Injectable()
|
||||
export class WechatTokenService {
|
||||
private readonly logger = new Logger(WechatTokenService.name)
|
||||
private accessToken: string = ''
|
||||
private expiresAt: number = 0
|
||||
|
||||
async getAccessToken(): Promise<string> {
|
||||
if (this.accessToken && Date.now() < this.expiresAt) return this.accessToken
|
||||
return this.refreshToken()
|
||||
}
|
||||
|
||||
async refreshToken(): Promise<string> {
|
||||
if (!WX_APPID || !WX_SECRET) {
|
||||
this.logger.warn('微信配置不完整,无法获取 access_token')
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const res = await axios.get('https://api.weixin.qq.com/cgi-bin/token', {
|
||||
params: { grant_type: 'client_credential', appid: WX_APPID, secret: WX_SECRET },
|
||||
})
|
||||
this.accessToken = res.data.access_token
|
||||
this.expiresAt = Date.now() + (res.data.expires_in - 300) * 1000
|
||||
this.logger.log(`微信 access_token 刷新成功,有效期 ${res.data.expires_in}s`)
|
||||
return this.accessToken
|
||||
} catch (e: any) {
|
||||
this.logger.error(`获取微信 access_token 失败: ${e.response?.data || e.message}`)
|
||||
return this.accessToken || ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送订阅消息 */
|
||||
async sendSubscribeMessage(openid: string, templateId: string, data: Record<string, { value: string }>, pagePath?: string) {
|
||||
const token = await this.getAccessToken()
|
||||
if (!token) { this.logger.warn('无有效 access_token,跳过消息推送'); return false }
|
||||
try {
|
||||
await axios.post(`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${token}`, {
|
||||
touser: openid,
|
||||
template_id: templateId,
|
||||
data,
|
||||
page: pagePath || 'pages/index/index',
|
||||
miniprogram_state: 'formal',
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.logger.error(`发送订阅消息失败: ${e.response?.data || e.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,12 @@ export class User {
|
||||
@Prop()
|
||||
vipExpireAt?: Date
|
||||
|
||||
@Prop()
|
||||
sprintExpireAt?: Date
|
||||
|
||||
@Prop({ default: 0 })
|
||||
sprintRemaining: number // 冲刺版剩余次数(语音分析等)
|
||||
|
||||
@Prop({ default: 'user' })
|
||||
role: string // 'user' | 'admin'
|
||||
|
||||
|
||||
@@ -1,212 +1,212 @@
|
||||
import * as bcrypt from 'bcrypt'
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { User, UserDocument } from './user.schema'
|
||||
import { EmailService } from '../email/email.service'
|
||||
|
||||
// In-memory stores
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
const emailCodeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
private jwtService: JwtService,
|
||||
private emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async sendCode(phone: string) {
|
||||
const code = process.env.NODE_ENV === 'production'
|
||||
? String(Math.floor(100000 + Math.random() * 900000))
|
||||
: '123456'
|
||||
|
||||
codeStore.set(phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`[DEV] Verification code for ${phone}: ${code}`)
|
||||
}
|
||||
return { message: '验证码已发送' }
|
||||
}
|
||||
|
||||
async loginByPhone(phone: string, code: string) {
|
||||
const record = codeStore.get(phone)
|
||||
if (!record || record.code !== code) {
|
||||
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
if (Date.now() > record.expiresAt) {
|
||||
codeStore.delete(phone)
|
||||
throw new HttpException('验证码已过期', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
codeStore.delete(phone)
|
||||
|
||||
let user = await this.userModel.findOne({ phone }).exec()
|
||||
if (!user) {
|
||||
user = await this.userModel.create({ phone, nickname: `用户${phone.slice(-4)}` })
|
||||
}
|
||||
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
async loginByWx(code: string) {
|
||||
// WeChat silent login - exchange code for openid
|
||||
const appid = process.env.WX_APPID
|
||||
const secret = process.env.WX_SECRET
|
||||
if (!appid || !secret) {
|
||||
throw new HttpException('微信登录未配置', HttpStatus.SERVICE_UNAVAILABLE)
|
||||
}
|
||||
|
||||
const wxRes = await fetch(
|
||||
`https://api.weixin.qq.com/sns/jscode2session?appid=${appid}&secret=${secret}&js_code=${code}&grant_type=authorization_code`,
|
||||
)
|
||||
const wxData: any = await wxRes.json()
|
||||
|
||||
if (wxData.errcode) {
|
||||
throw new HttpException(`微信登录失败: ${wxData.errmsg}`, HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
|
||||
const openid = wxData.openid
|
||||
let user = await this.userModel.findOne({ wxOpenid: openid }).exec()
|
||||
if (!user) {
|
||||
user = await this.userModel.create({ wxOpenid: openid, nickname: '微信用户' })
|
||||
}
|
||||
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
// 📧 邮箱验证码
|
||||
async sendEmailCode(email: string) {
|
||||
if (!email || !email.includes('@')) {
|
||||
throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000))
|
||||
emailCodeStore.set(email, { code, expiresAt: Date.now() + 10 * 60 * 1000 })
|
||||
const sent = await this.emailService.sendVerificationCode(email, code)
|
||||
if (sent) {
|
||||
return { message: '验证码已发送到邮箱' }
|
||||
}
|
||||
// 邮件发送失败时返回 devCode 方便调试
|
||||
console.log(`[EMAIL] Dev code for ${email}: ${code}`)
|
||||
return { message: '验证码已发送(邮件服务暂不可用,开发模式)', devCode: code }
|
||||
}
|
||||
|
||||
async loginByEmail(email: string, code: string) {
|
||||
const record = emailCodeStore.get(email)
|
||||
if (!record || record.code !== code) {
|
||||
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
if (Date.now() > record.expiresAt) {
|
||||
emailCodeStore.delete(email)
|
||||
throw new HttpException('验证码已过期', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
emailCodeStore.delete(email)
|
||||
|
||||
// 按邮箱查找或创建用户
|
||||
let user = await this.userModel.findOne({ email }).exec()
|
||||
let isNew = false
|
||||
if (!user) {
|
||||
isNew = true
|
||||
const nick = email.split('@')[0]
|
||||
user = await this.userModel.create({ email, nickname: nick, remaining: 3 })
|
||||
}
|
||||
return { ...this.generateAuthResponse(user), isNew, hasPassword: !!user.password }
|
||||
}
|
||||
|
||||
// 🔐 密码登录
|
||||
async loginByPassword(email: string, password: string) {
|
||||
const user = await this.userModel.findOne({ email }).exec()
|
||||
if (!user) throw new HttpException('账号不存在', HttpStatus.NOT_FOUND)
|
||||
if (!user.password) throw new HttpException('该账号未设置密码,请使用验证码登录', HttpStatus.UNAUTHORIZED)
|
||||
const match = await bcrypt.compare(password, user.password)
|
||||
if (!match) throw new HttpException('密码错误', HttpStatus.UNAUTHORIZED)
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
// 📝 邮箱+密码注册
|
||||
async registerWithPassword(email: string, password: string) {
|
||||
if (!email || !email.includes('@')) {
|
||||
throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
if (!password || password.length < 6) {
|
||||
throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const existing = await this.userModel.findOne({ email }).exec()
|
||||
if (existing) {
|
||||
if (existing.password) {
|
||||
throw new HttpException('该邮箱已注册,请直接登录', HttpStatus.CONFLICT)
|
||||
}
|
||||
// 已有验证码注册的用户,补充设置密码
|
||||
existing.password = await bcrypt.hash(password, 10)
|
||||
await existing.save()
|
||||
return this.generateAuthResponse(existing)
|
||||
}
|
||||
const nick = email.split('@')[0]
|
||||
const hashed = await bcrypt.hash(password, 10)
|
||||
const user = await this.userModel.create({ email, nickname: nick, password: hashed, remaining: 3 })
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
// 🔑 已登录用户设置/修改密码
|
||||
async setPassword(userId: string, password: string) {
|
||||
if (!password || password.length < 6) {
|
||||
throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const hashed = await bcrypt.hash(password, 10)
|
||||
await this.userModel.findByIdAndUpdate(userId, { password: hashed })
|
||||
return { message: '密码设置成功' }
|
||||
}
|
||||
|
||||
async getInfo(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return this.safeUser(user)
|
||||
}
|
||||
|
||||
async update(userId: string, data: { nickname?: string; avatar?: string }) {
|
||||
const user = await this.userModel.findByIdAndUpdate(userId, data, { new: true }).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return this.safeUser(user)
|
||||
}
|
||||
|
||||
getModel() { return this.userModel }
|
||||
|
||||
async getUsage(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return { remaining: user.remaining, plan: user.plan, interviewCount: user.interviewCount }
|
||||
}
|
||||
|
||||
async deductRemaining(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.remaining <= 0) throw new HttpException('使用次数已用完', HttpStatus.FORBIDDEN)
|
||||
user.remaining -= 1
|
||||
user.interviewCount += 1
|
||||
await user.save()
|
||||
}
|
||||
|
||||
private generateAuthResponse(user: UserDocument) {
|
||||
const payload = { userId: user._id.toString(), phone: user.phone || '' }
|
||||
return {
|
||||
token: this.jwtService.sign(payload),
|
||||
user: this.safeUser(user),
|
||||
}
|
||||
}
|
||||
|
||||
private safeUser(user: UserDocument) {
|
||||
return {
|
||||
id: user._id.toString(),
|
||||
phone: user.phone || '',
|
||||
email: user.email || '',
|
||||
nickname: user.nickname || '',
|
||||
avatar: user.avatar || '',
|
||||
plan: user.plan,
|
||||
role: user.role || 'user',
|
||||
isSystemAdmin: user.isSystemAdmin || false,
|
||||
remaining: user.remaining,
|
||||
interviewCount: user.interviewCount,
|
||||
}
|
||||
}
|
||||
}
|
||||
import * as bcrypt from 'bcrypt'
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { User, UserDocument } from './user.schema'
|
||||
import { EmailService } from '../email/email.service'
|
||||
|
||||
// In-memory stores
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
const emailCodeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
private jwtService: JwtService,
|
||||
private emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async sendCode(phone: string) {
|
||||
const code = process.env.NODE_ENV === 'production'
|
||||
? String(Math.floor(100000 + Math.random() * 900000))
|
||||
: '123456'
|
||||
|
||||
codeStore.set(phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`[DEV] Verification code for ${phone}: ${code}`)
|
||||
}
|
||||
return { message: '验证码已发送' }
|
||||
}
|
||||
|
||||
async loginByPhone(phone: string, code: string) {
|
||||
const record = codeStore.get(phone)
|
||||
if (!record || record.code !== code) {
|
||||
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
if (Date.now() > record.expiresAt) {
|
||||
codeStore.delete(phone)
|
||||
throw new HttpException('验证码已过期', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
codeStore.delete(phone)
|
||||
|
||||
let user = await this.userModel.findOne({ phone }).exec()
|
||||
if (!user) {
|
||||
user = await this.userModel.create({ phone, nickname: `用户${phone.slice(-4)}` })
|
||||
}
|
||||
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
async loginByWx(code: string) {
|
||||
// WeChat silent login - exchange code for openid
|
||||
const appid = process.env.WX_APPID
|
||||
const secret = process.env.WX_SECRET
|
||||
if (!appid || !secret) {
|
||||
throw new HttpException('微信登录未配置', HttpStatus.SERVICE_UNAVAILABLE)
|
||||
}
|
||||
|
||||
const wxRes = await fetch(
|
||||
`https://api.weixin.qq.com/sns/jscode2session?appid=${appid}&secret=${secret}&js_code=${code}&grant_type=authorization_code`,
|
||||
)
|
||||
const wxData: any = await wxRes.json()
|
||||
|
||||
if (wxData.errcode) {
|
||||
throw new HttpException(`微信登录失败: ${wxData.errmsg}`, HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
|
||||
const openid = wxData.openid
|
||||
let user = await this.userModel.findOne({ wxOpenid: openid }).exec()
|
||||
if (!user) {
|
||||
user = await this.userModel.create({ wxOpenid: openid, nickname: '微信用户' })
|
||||
}
|
||||
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
// 📧 邮箱验证码
|
||||
async sendEmailCode(email: string) {
|
||||
if (!email || !email.includes('@')) {
|
||||
throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000))
|
||||
emailCodeStore.set(email, { code, expiresAt: Date.now() + 10 * 60 * 1000 })
|
||||
const sent = await this.emailService.sendVerificationCode(email, code)
|
||||
if (sent) {
|
||||
return { message: '验证码已发送到邮箱' }
|
||||
}
|
||||
// 邮件发送失败时返回 devCode 方便调试
|
||||
console.log(`[EMAIL] Dev code for ${email}: ${code}`)
|
||||
return { message: '验证码已发送(邮件服务暂不可用,开发模式)', devCode: code }
|
||||
}
|
||||
|
||||
async loginByEmail(email: string, code: string) {
|
||||
const record = emailCodeStore.get(email)
|
||||
if (!record || record.code !== code) {
|
||||
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
if (Date.now() > record.expiresAt) {
|
||||
emailCodeStore.delete(email)
|
||||
throw new HttpException('验证码已过期', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
emailCodeStore.delete(email)
|
||||
|
||||
// 按邮箱查找或创建用户
|
||||
let user = await this.userModel.findOne({ email }).exec()
|
||||
let isNew = false
|
||||
if (!user) {
|
||||
isNew = true
|
||||
const nick = email.split('@')[0]
|
||||
user = await this.userModel.create({ email, nickname: nick, remaining: 3 })
|
||||
}
|
||||
return { ...this.generateAuthResponse(user), isNew, hasPassword: !!user.password }
|
||||
}
|
||||
|
||||
// 🔐 密码登录
|
||||
async loginByPassword(email: string, password: string) {
|
||||
const user = await this.userModel.findOne({ email }).exec()
|
||||
if (!user) throw new HttpException('账号不存在', HttpStatus.NOT_FOUND)
|
||||
if (!user.password) throw new HttpException('该账号未设置密码,请使用验证码登录', HttpStatus.UNAUTHORIZED)
|
||||
const match = await bcrypt.compare(password, user.password)
|
||||
if (!match) throw new HttpException('密码错误', HttpStatus.UNAUTHORIZED)
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
// 📝 邮箱+密码注册
|
||||
async registerWithPassword(email: string, password: string) {
|
||||
if (!email || !email.includes('@')) {
|
||||
throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
if (!password || password.length < 6) {
|
||||
throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const existing = await this.userModel.findOne({ email }).exec()
|
||||
if (existing) {
|
||||
if (existing.password) {
|
||||
throw new HttpException('该邮箱已注册,请直接登录', HttpStatus.CONFLICT)
|
||||
}
|
||||
// 已有验证码注册的用户,补充设置密码
|
||||
existing.password = await bcrypt.hash(password, 10)
|
||||
await existing.save()
|
||||
return this.generateAuthResponse(existing)
|
||||
}
|
||||
const nick = email.split('@')[0]
|
||||
const hashed = await bcrypt.hash(password, 10)
|
||||
const user = await this.userModel.create({ email, nickname: nick, password: hashed, remaining: 3 })
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
// 🔑 已登录用户设置/修改密码
|
||||
async setPassword(userId: string, password: string) {
|
||||
if (!password || password.length < 6) {
|
||||
throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const hashed = await bcrypt.hash(password, 10)
|
||||
await this.userModel.findByIdAndUpdate(userId, { password: hashed })
|
||||
return { message: '密码设置成功' }
|
||||
}
|
||||
|
||||
async getInfo(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return this.safeUser(user)
|
||||
}
|
||||
|
||||
async update(userId: string, data: { nickname?: string; avatar?: string }) {
|
||||
const user = await this.userModel.findByIdAndUpdate(userId, data, { new: true }).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return this.safeUser(user)
|
||||
}
|
||||
|
||||
getModel() { return this.userModel }
|
||||
|
||||
async getUsage(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return { remaining: user.remaining, plan: user.plan, interviewCount: user.interviewCount }
|
||||
}
|
||||
|
||||
async deductRemaining(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.remaining <= 0) throw new HttpException('使用次数已用完', HttpStatus.FORBIDDEN)
|
||||
user.remaining -= 1
|
||||
user.interviewCount += 1
|
||||
await user.save()
|
||||
}
|
||||
|
||||
private generateAuthResponse(user: UserDocument) {
|
||||
const payload = { userId: user._id.toString(), phone: user.phone || '' }
|
||||
return {
|
||||
token: this.jwtService.sign(payload),
|
||||
user: this.safeUser(user),
|
||||
}
|
||||
}
|
||||
|
||||
private safeUser(user: UserDocument) {
|
||||
return {
|
||||
id: user._id.toString(),
|
||||
phone: user.phone || '',
|
||||
email: user.email || '',
|
||||
nickname: user.nickname || '',
|
||||
avatar: user.avatar || '',
|
||||
plan: user.plan,
|
||||
role: user.role || 'user',
|
||||
isSystemAdmin: user.isSystemAdmin || false,
|
||||
remaining: user.remaining,
|
||||
interviewCount: user.interviewCount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user