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:
yuzhiran
2026-06-09 20:03:05 +08:00
parent 37cfdfe93c
commit 9276ab9028
44 changed files with 15205 additions and 2062 deletions
-17
View File
@@ -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
-31
View File
@@ -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
+46
View File
@@ -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",
+1
View File
@@ -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",
+4
View File
@@ -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,
+72
View File
@@ -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
View File
@@ -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,
})
+21 -16
View File
@@ -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) {
+54 -57
View File
@@ -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 }
}
}
+5 -1
View File
@@ -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 {
/** 创建订单(H5Native 扫码支付) */
@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
}
}
}
+6
View File
@@ -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'
+212 -212
View File
@@ -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,
}
}
}