初始化:职引项目 v1.0

This commit is contained in:
yuzhiran
2026-06-08 16:28:00 +08:00
commit 511f60d0db
111 changed files with 27295 additions and 0 deletions
@@ -0,0 +1,133 @@
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'
import { Interview, InterviewDocument } from '../interview/interview.schema'
@Controller('admin')
export class AdminController {
constructor(
@InjectModel(User.name) private userModel: Model<UserDocument>,
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
) {}
@Get('check')
@UseGuards(JwtAuthGuard)
async checkAdmin(@CurrentUser('userId') userId: string) {
const user = await this.userModel.findById(userId).select('role').exec()
return { isAdmin: user?.role === 'admin' }
}
@Public()
@Post('verify')
async verify(@Body('adminId') adminId: string) {
const user = await this.userModel.findById(adminId).exec()
if (!user || user.role !== 'admin') {
throw new HttpException('无权限访问', HttpStatus.FORBIDDEN)
}
return { ok: true, nickname: user.nickname || '管理员' }
}
@Public()
@Get('overview')
async overview() {
const [userCount, interviewCount, todayUsers, todayInterviews] = await Promise.all([
this.userModel.countDocuments().exec(),
this.interviewModel.countDocuments().exec(),
this.userModel.countDocuments({ createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }).exec(),
this.interviewModel.countDocuments({ createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }).exec(),
])
return { userCount, interviewCount, todayUsers, todayInterviews }
}
@Public()
@Get('users')
async getUsers(@Query('keyword') keyword: string, @Query('page') page = '1', @Query('limit') limit = '20') {
const filter: any = {}
if (keyword) filter.$or = [
{ phone: { $regex: keyword, $options: 'i' } },
{ nickname: { $regex: keyword, $options: 'i' } },
]
const skip = (Math.max(1, +page) - 1) * +limit
const [users, total] = await Promise.all([
this.userModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(+limit).select('-password').lean().exec(),
this.userModel.countDocuments(filter).exec(),
])
return { users, total, page: +page }
}
@Public()
@Get('interviews')
async getInterviews(@Query('page') page = '1', @Query('limit') limit = '20') {
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(),
this.interviewModel.countDocuments().exec(),
])
return { interviews, total, page: +page }
}
@UseGuards(JwtAuthGuard)
@Post('set-vip')
async setVip(@Body('userId') targetUserId: string, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
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'
user.vipExpireAt = expireAt
user.remaining = 999
await user.save()
return { success: true, plan: 'vip', expireAt }
}
@UseGuards(JwtAuthGuard)
@Get('admins')
async getAdmins(@CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
const admins = await this.userModel.find({ role: 'admin' }).select('phone nickname email createdAt isSystemAdmin').lean().exec()
return { admins }
}
@UseGuards(JwtAuthGuard)
@Post('set-admin')
async setAdmin(@Body('userId') targetUserId: string, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
const user = await this.userModel.findById(targetUserId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.role === 'admin') throw new HttpException('该用户已是管理员', HttpStatus.BAD_REQUEST)
user.role = 'admin'
await user.save()
return { success: true, message: '已设为管理员' }
}
@UseGuards(JwtAuthGuard)
@Get('config')
async getConfig(@CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
return {
interview: {
maxRoundsFree: 5,
maxRoundsVip: 10,
dailyFreeLimit: 3,
},
diagnosis: {
dailyFreeLimit: 2,
},
optimize: {
dailyFreeLimit: 2,
},
price: {
monthly: 2900, // 分
},
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AdminController } from './admin.controller'
import { User, UserSchema } from '../user/user.schema'
import { Interview, InterviewSchema } from '../interview/interview.schema'
@Module({
imports: [
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
MongooseModule.forFeature([{ name: Interview.name, schema: InterviewSchema }]),
],
controllers: [AdminController],
})
export class AdminModule {}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common'
import { AiService } from './ai.service'
@Global()
@Module({
providers: [AiService],
exports: [AiService],
})
export class AiModule {}
+72
View File
@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common'
import axios from 'axios'
interface AiCallOptions {
systemPrompt: string
userMessage: string
temperature?: number
maxTokens?: number
}
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name)
private readonly primaryUrl = process.env.AI_PRIMARY_URL || 'https://token.sensenova.cn/v1'
private readonly primaryKey = process.env.AI_PRIMARY_KEY || ''
private readonly primaryModel = process.env.AI_PRIMARY_MODEL || 'deepseek-v4-flash'
private readonly backupUrl = process.env.AI_BACKUP_URL || 'https://integrate.api.nvidia.com/v1'
private readonly backupKey = process.env.AI_BACKUP_KEY || ''
private readonly backupModel = process.env.AI_BACKUP_MODEL || 'stepfun-ai/step-3.5-flash'
async call(options: AiCallOptions): Promise<string> {
const { systemPrompt, userMessage, temperature = 0.7, maxTokens = 2048 } = options
// Try primary AI
try {
const result = await this.callApi(this.primaryUrl, this.primaryKey, this.primaryModel, systemPrompt, userMessage, temperature, maxTokens)
if (result) return result
} catch (e) {
this.logger.warn(`Primary AI failed: ${(e as Error).message}, trying backup...`)
}
// Try backup AI
try {
const result = await this.callApi(this.backupUrl, this.backupKey, this.backupModel, systemPrompt, userMessage, temperature, maxTokens)
if (result) return result
} catch (e) {
this.logger.warn(`Backup AI also failed: ${(e as Error).message}`)
}
// Final fallback
throw new Error('AI 服务暂时不可用,请稍后重试')
}
private async callApi(
baseUrl: string, apiKey: string, model: string,
systemPrompt: string, userMessage: string,
temperature: number, maxTokens: number,
): Promise<string | null> {
const res = await axios.post(
`${baseUrl}/chat/completions`,
{
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
temperature,
max_tokens: maxTokens,
},
{
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
timeout: 60000,
},
)
return res.data?.choices?.[0]?.message?.content || null
}
}
@@ -0,0 +1,39 @@
import { Controller, Post, Body, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
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'
@Controller('analyze')
export class AnalyzeController {
constructor(
private analyzeService: AnalyzeService,
private userService: UserService,
) {}
@UseGuards(JwtAuthGuard)
@Post('diagnosis')
async diagnosis(@Body('content') content: string, @CurrentUser('userId') userId: string) {
await this.checkAnalyzeLimit(userId)
return this.analyzeService.diagnose(content)
}
@UseGuards(JwtAuthGuard)
@Post('optimize')
async optimize(@Body('content') content: string, @Body('direction') direction: string, @CurrentUser('userId') userId: string) {
await this.checkAnalyzeLimit(userId)
return this.analyzeService.optimize(content, direction)
}
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.remaining <= 0) {
throw new HttpException('免费版每日次数已用完,升级会员后不限次使用', HttpStatus.FORBIDDEN)
}
// 扣减一次
user.remaining -= 1
await user.save()
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common'
import { AnalyzeController } from './analyze.controller'
import { AnalyzeService } from './analyze.service'
import { UserModule } from '../user/user.module'
@Module({
imports: [UserModule],
controllers: [AnalyzeController],
providers: [AnalyzeService],
})
export class AnalyzeModule {}
@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common'
import { AiService } from '../ai/ai.service'
@Injectable()
export class AnalyzeService {
constructor(private aiService: AiService) {}
async diagnose(content: string) {
const result = await this.aiService.call({
systemPrompt: `你是一位专业的简历评估专家。分析以下简历内容,给出评估。必须使用以下JSON格式输出,不要多余内容:
{
"score": 0-100,
"issues": [
{ "level": "high|medium|low", "title": "问题标题", "desc": "问题描述" }
],
"suggestions": ["建议1", "建议2", "建议3", "建议4"]
}`,
userMessage: `请分析以下简历:\n\n${content}`,
temperature: 0.5,
maxTokens: 2048,
})
try {
return JSON.parse(result)
} catch {
return {
score: 60,
issues: [{ level: 'medium', title: 'AI 分析异常', desc: '无法解析完整结果,建议重试' }],
suggestions: ['请重新提交简历进行诊断'],
}
}
}
async optimize(content: string, direction: string) {
const result = await this.aiService.call({
systemPrompt: `你是一位资深的简历优化专家。按照用户指定的优化方向,优化以下简历内容。
优化方向: ${direction}
输出格式:
{
"optimized": "优化后的简历全文",
"changes": ["改动1", "改动2", ...]
}`,
userMessage: `原始简历:\n\n${content}\n\n优化方向:${direction}`,
temperature: 0.6,
maxTokens: 3072,
})
try {
return JSON.parse(result)
} catch {
return {
optimized: content,
changes: ['AI 优化异常,返回原始内容'],
}
}
}
}
@@ -0,0 +1,124 @@
import { Controller, Post, Get, Body, Param, UseGuards } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { Contribution, ContributionDocument } from '../schemas/contribution.schema'
import { CompanyBank, CompanyBankDocument } from '../schemas/company-bank.schema'
@Controller('contribution')
@UseGuards(JwtAuthGuard)
export class ContributionController {
constructor(
@InjectModel(Contribution.name) private contributionModel: Model<ContributionDocument>,
@InjectModel(CompanyBank.name) private companyBankModel: Model<CompanyBankDocument>,
) {}
@Post()
async create(
@CurrentUser('userId') userId: string,
@Body() body: {
interviewId: string
company: string
position: string
rounds?: string
questions?: string[]
experience?: string
tags?: string[]
},
) {
const contribution = await this.contributionModel.create({
userId,
interviewId: body.interviewId,
company: body.company,
position: body.position,
rounds: body.rounds || '',
questions: body.questions || [],
experience: body.experience || '',
tags: body.tags || [],
verified: false,
})
// Update company bank
if (body.questions && body.questions.length > 0) {
let bank = await this.companyBankModel.findOne({
company: body.company,
position: body.position,
}).exec()
if (!bank) {
bank = await this.companyBankModel.create({
company: body.company,
position: body.position,
questions: [],
contributionCount: 0,
viewCount: 0,
})
}
// Add new questions (avoid duplicates)
for (const q of body.questions) {
const exists = bank.questions.some(eq => eq.content === q)
if (!exists) {
bank.questions.push({
content: q,
type: 'general',
referenceAnswer: '',
difficulty: 'medium',
frequency: 1,
tags: body.tags || [],
})
} else {
// Increment frequency
const existing = bank.questions.find(eq => eq.content === q)
if (existing) existing.frequency += 1
}
}
bank.contributionCount += 1
await bank.save()
}
return {
id: (contribution as any)._id.toString(),
company: contribution.company,
position: contribution.position,
message: '感谢你的分享!你的面经将帮助更多同学准备面试',
}
}
@Get('company/:company/position/:position')
async getBank(@Param('company') company: string, @Param('position') position: string) {
const bank = await this.companyBankModel.findOne({ company, position }).exec()
if (!bank) return { company, position, questions: [], contributionCount: 0 }
bank.viewCount += 1
await bank.save()
return {
company: bank.company,
position: bank.position,
questions: bank.questions.sort((a, b) => b.frequency - a.frequency),
contributionCount: bank.contributionCount,
viewCount: bank.viewCount,
}
}
@Get('company/:company')
async getCompanyBanks(@Param('company') company: string) {
const banks = await this.companyBankModel.find({ company }).exec()
return banks.map(b => ({
position: b.position,
questionCount: b.questions.length,
contributionCount: b.contributionCount,
}))
}
@Get('my')
async getMyContributions(@CurrentUser('userId') userId: string) {
return this.contributionModel
.find({ userId })
.sort({ createdAt: -1 })
.select('company position rounds experience createdAt')
.exec()
}
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { ContributionController } from './contribution.controller'
import { Contribution, ContributionSchema } from '../schemas/contribution.schema'
import { CompanyBank, CompanyBankSchema } from '../schemas/company-bank.schema'
@Module({
imports: [
MongooseModule.forFeature([
{ name: Contribution.name, schema: ContributionSchema },
{ name: CompanyBank.name, schema: CompanyBankSchema },
]),
],
controllers: [ContributionController],
})
export class ContributionModule {}
@@ -0,0 +1,63 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { DailyQuestion, DailyQuestionDocument } from '../schemas/daily-question.schema'
@Controller('daily-question')
@UseGuards(JwtAuthGuard)
export class DailyQuestionController {
constructor(
@InjectModel(DailyQuestion.name) private dailyQuestionModel: Model<DailyQuestionDocument>,
) {}
@Get()
async getToday(@Query('position') position?: string) {
const filter: any = {}
if (position) filter.position = position
const question = await this.dailyQuestionModel
.findOne(filter)
.sort({ date: -1 })
.exec()
if (!question) {
// Return a default question if no specific one found
const defaultQ = await this.dailyQuestionModel.findOne().sort({ date: -1 }).exec()
if (defaultQ) return defaultQ
return {
position: '通用',
question: '请做一个简单的自我介绍,突出你的核心优势和职业目标。',
referenceAnswer: '建议结构:1) 基本信息 2) 教育背景与专业 3) 实习/项目经历中的亮点 4) 为什么选择这个岗位 5) 职业目标。控制在1-2分钟内。',
category: 'behavioral',
}
}
return question
}
@Get('position/:position')
async getByPosition(@Param('position') position: string) {
const questions = await this.dailyQuestionModel
.find({ position })
.sort({ date: -1 })
.limit(10)
.exec()
return questions.length > 0 ? questions : [
{
position,
question: `作为${position}岗位的候选人,请分享一个你最有成就感的项目经历。`,
referenceAnswer: '使用STAR法则:Situation(背景)、Task(任务)、Action(行动)、Result(结果),重点突出你在项目中的角色和贡献。',
category: 'project',
},
{
position,
question: `${position}岗位上,你认为自己最大的优势是什么?最大的不足是什么?`,
referenceAnswer: '优势要结合岗位要求,用具体例子支撑;不足要真实但可改进,说明你已经在如何提升。',
category: 'behavioral',
},
]
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { DailyQuestionController } from './daily-question.controller'
import { DailyQuestion, DailyQuestionSchema } from '../schemas/daily-question.schema'
@Module({
imports: [
MongooseModule.forFeature([
{ name: DailyQuestion.name, schema: DailyQuestionSchema },
]),
],
controllers: [DailyQuestionController],
})
export class DailyQuestionModule {}
@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common'
import { EmailService } from './email.service'
@Global()
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}
@@ -0,0 +1,63 @@
import * as nodemailer from 'nodemailer'
import { Injectable, Logger } from '@nestjs/common'
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name)
private transporter: nodemailer.Transporter | null = null
constructor() {
this.initTransporter()
}
private initTransporter() {
try {
this.transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST || 'smtp.qiye.aliyun.com',
port: Number(process.env.EMAIL_PORT) || 465,
secure: process.env.EMAIL_SECURE === 'true',
auth: {
user: process.env.EMAIL_USER || 'contact@yuzhiran.com',
pass: process.env.EMAIL_PASSWORD,
},
})
this.logger.log('邮件服务初始化完成')
} catch (e) {
this.logger.error('邮件服务初始化失败', e)
}
}
async sendVerificationCode(to: string, code: string): Promise<boolean> {
if (!this.transporter) {
this.logger.warn('邮件服务未初始化,跳过发送')
return false
}
try {
const fromName = process.env.EMAIL_FROM || 'AI磁场 <contact@yuzhiran.com>'
await this.transporter.sendMail({
from: fromName,
to,
subject: 'AI磁场 - 登录验证码',
html: `
<div style="font-family: -apple-system, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px;">
<div style="text-align: center; margin-bottom: 24px;">
<h1 style="font-size: 28px; color: #4F46E5; margin: 0;">AI 磁场</h1>
<p style="color: #9CA3AF; font-size: 14px; margin: 4px 0 0;">您的登录验证码</p>
</div>
<div style="background: #F3F4F6; border-radius: 16px; padding: 32px; text-align: center;">
<p style="font-size: 14px; color: #6B7280; margin: 0 0 16px;">请输入以下验证码完成登录</p>
<div style="font-size: 40px; font-weight: 800; color: #4F46E5; letter-spacing: 8px; margin: 16px 0;">${code}</div>
<p style="font-size: 12px; color: #9CA3AF; margin: 16px 0 0;">验证码 10 分钟内有效,请勿泄露给他人</p>
</div>
<p style="font-size: 12px; color: #D1D5DB; text-align: center; margin-top: 24px;">宇之然AI磁场 · AI 助力你的求职之路</p>
</div>
`,
})
this.logger.log(`验证码已发送至 ${to}`)
return true
} catch (e) {
this.logger.error(`发送邮件失败: ${to}`, e)
return false
}
}
}
@@ -0,0 +1,44 @@
import { Controller, Post, Get, Param, Body } from '@nestjs/common'
import { InterviewService } from './interview.service'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
@Controller('interview')
export class InterviewController {
constructor(private interviewService: InterviewService) {}
// 静态路由必须放在 :id 动态路由之前
@Get('list/all')
async getList(@CurrentUser('userId') userId: string) {
return this.interviewService.getList(userId)
}
@Get('stats/mine')
async getStats(@CurrentUser('userId') userId: string) {
return this.interviewService.getStats(userId)
}
@Post('create')
async create(@CurrentUser('userId') userId: string, @Body('position') position: string) {
return this.interviewService.create(userId, position)
}
@Post(':id/answer')
async answer(
@Param('id') id: string,
@CurrentUser('userId') userId: string,
@Body('answer') answer: string,
) {
return this.interviewService.answer(id, userId, answer)
}
@Post(':id/complete')
async complete(@Param('id') id: string, @CurrentUser('userId') userId: string) {
return this.interviewService.complete(id, userId)
}
@Get(':id')
async getDetail(@Param('id') id: string, @CurrentUser('userId') userId: string) {
return this.interviewService.getDetail(id, userId)
}
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { InterviewController } from './interview.controller'
import { InterviewService } from './interview.service'
import { Interview, InterviewSchema } from './interview.schema'
import { Progress, ProgressSchema } from '../schemas/progress.schema'
import { UserModule } from '../user/user.module'
@Module({
imports: [
MongooseModule.forFeature([
{ name: Interview.name, schema: InterviewSchema },
{ name: Progress.name, schema: ProgressSchema },
]),
UserModule,
],
controllers: [InterviewController],
providers: [InterviewService],
})
export class InterviewModule {}
@@ -0,0 +1,31 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document, Types } from 'mongoose'
export type InterviewDocument = Interview & Document
@Schema({ timestamps: true })
export class Interview {
@Prop({ type: Types.ObjectId, ref: 'User', required: true })
userId: Types.ObjectId
@Prop({ required: true })
position: string
@Prop({ default: 'in_progress' }) // in_progress | completed
status: string
@Prop({ default: 0 })
totalScore: number
@Prop({ default: 0 })
questionCount: number
@Prop({ type: [{ role: String, content: String, score: Number }], default: [] })
messages: { role: string; content: string; score?: number }[]
@Prop({ default: '' })
summary: string
}
export const InterviewSchema = SchemaFactory.createForClass(Interview)
InterviewSchema.index({ userId: 1, createdAt: -1 })
@@ -0,0 +1,264 @@
import { Injectable, HttpException, HttpStatus, forwardRef, Inject } 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'
@Injectable()
export class InterviewService {
constructor(
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
private aiService: AiService,
private userService: UserService,
) {}
async create(userId: string, position: string) {
// 扣减使用次数
await this.userService.deductRemaining(userId)
const firstQuestion = await this.aiService.call({
systemPrompt: `你是一位专业的${position}面试官。请针对校招该岗位提出第一个面试问题,要求具体且有针对性。直接输出问题,不要多余内容。`,
userMessage: `请为${position}岗位的校招候选人提出第一个面试问题。`,
temperature: 0.8,
})
const interview = await this.interviewModel.create({
userId,
position,
messages: [{ role: 'ai', content: firstQuestion }],
questionCount: 1,
})
return {
id: interview._id.toString(),
position: interview.position,
messages: interview.messages,
questionCount: interview.questionCount,
}
}
async answer(interviewId: string, userId: string, answer: string) {
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
if (interview.status === 'completed') throw new HttpException('面试已结束', HttpStatus.BAD_REQUEST)
// 检查轮次限制
const user = await this.userService.getModel().findById(userId).exec()
const maxRounds = user?.plan === 'vip' ? 10 : 5
if (interview.questionCount >= maxRounds) {
throw new HttpException(
user?.plan === 'vip' ? '已达到每场面试最大轮次(10轮)' : '免费版每场最多5轮,升级会员可享10轮',
HttpStatus.FORBIDDEN
)
}
// Save user's answer
interview.messages.push({ role: 'user', content: answer })
interview.questionCount += 1
// AI evaluates answer and generates next question
const conversationHistory = interview.messages
.slice(-6)
.map(m => `${m.role === 'ai' ? '面试官' : '候选人'}: ${m.content}`)
.join('\n')
const aiResponse = await this.aiService.call({
systemPrompt: `你是一位专业的面试官。评估候选人的回答,然后提出下一个问题。
- 如果这已经是第5-8个问题,给出总结性评价并建议结束面试
- 评估要简短,然后立即问下一个问题
- 使用「回答评价:...新的问题:...」的格式。`,
userMessage: `岗位: ${interview.position}
对话历史:
${conversationHistory}
候选人的回答: ${answer}
请评估并问下一个问题。`,
temperature: 0.7,
maxTokens: 1024,
})
interview.messages.push({ role: 'ai', content: aiResponse })
await interview.save()
return {
id: interview._id.toString(),
messages: interview.messages.slice(-2),
questionCount: interview.questionCount,
}
}
async complete(interviewId: string, userId: string) {
const interview = await this.interviewModel.findOne({ _id: interviewId, userId }).exec()
if (!interview) throw new HttpException('面试不存在', HttpStatus.NOT_FOUND)
if (interview.status === 'completed') throw new HttpException('面试已结束', HttpStatus.BAD_REQUEST)
// Generate final summary with dimension scores
const fullConversation = interview.messages
.map(m => `${m.role === 'ai' ? '面试官' : '候选人'}: ${m.content}`)
.join('\n')
const summary = await this.aiService.call({
systemPrompt: `你是一位专业的面试评估师。根据面试对话,生成评估报告。输出JSON格式:
{
"总体评分": 85,
"逻辑思维": 80,
"表达能力": 85,
"专业度": 90,
"稳定性": 82,
"优点": ["逻辑清晰", "举例充分"],
"不足": ["回答过长", "缺少数据支撑"],
"建议": ["使用STAR法则", "控制回答时间"]
}`,
userMessage: `岗位: ${interview.position}
面试记录:
${fullConversation}
请生成评估报告。`,
temperature: 0.5,
maxTokens: 2048,
})
// Parse summary
let totalScore = 0
let dimensions = { logic: 0, expression: 0, professionalism: 0, stability: 0 }
try {
const parsed = JSON.parse(summary)
totalScore = parsed. || parsed.score || 0
dimensions = {
logic: parsed.逻辑思维 || 0,
expression: parsed.表达能力 || 0,
professionalism: parsed.专业度 || 0,
stability: parsed.稳定性 || 0,
}
} catch {
const match = summary.match(/(\d{1,3})(?=\s*分)/)
totalScore = match ? parseInt(match[1]) : 0
}
interview.status = 'completed'
interview.totalScore = totalScore
interview.summary = summary
await interview.save()
// === PROGRESS TRACKING ===
await this.trackProgress(userId, interview._id.toString(), interview.position, totalScore, dimensions)
return {
id: interview._id.toString(),
totalScore,
summary,
dimensions,
questionCount: interview.questionCount,
position: interview.position,
contributionPrompt: '分享你的面试经验,帮助更多同学!是否愿意贡献面经?(选填公司名称 + 遇到的面试题)',
}
}
private async trackProgress(
userId: string,
interviewId: string,
position: string,
totalScore: number,
dimensions: { logic: number; expression: number; professionalism: number; stability: number },
) {
let progress = await this.progressModel.findOne({ userId }).exec()
if (!progress) {
progress = await this.progressModel.create({
userId,
totalInterviews: 0,
completedInterviews: 0,
recentScores: [],
streakHistory: [],
})
}
progress.totalInterviews += 1
progress.completedInterviews += 1
// Update rolling averages
const n = progress.completedInterviews
progress.avgLogic = Math.round(((progress.avgLogic * (n - 1)) + dimensions.logic) / n)
progress.avgExpression = Math.round(((progress.avgExpression * (n - 1)) + dimensions.expression) / n)
progress.avgProfessionalism = Math.round(((progress.avgProfessionalism * (n - 1)) + dimensions.professionalism) / n)
progress.avgStability = Math.round(((progress.avgStability * (n - 1)) + dimensions.stability) / n)
// Add to recent scores
progress.recentScores.push({
interviewId,
date: new Date(),
position,
totalScore,
dimensions,
})
// Keep only last 20
if (progress.recentScores.length > 20) {
progress.recentScores = progress.recentScores.slice(-20)
}
// === STREAK TRACKING ===
const today = new Date()
today.setHours(0, 0, 0, 0)
if (progress.lastInterviewDate) {
const lastDate = new Date(progress.lastInterviewDate)
lastDate.setHours(0, 0, 0, 0)
const diffDays = Math.floor((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays === 1) {
// Consecutive day
progress.streak += 1
} else if (diffDays > 1) {
// Streak broken
progress.streak = 1
}
// same day = no change
} else {
progress.streak = 1
}
progress.lastInterviewDate = today
progress.streakHistory.push(today)
await progress.save()
}
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
}
async getList(userId: string) {
const interviews = await this.interviewModel
.find({ userId })
.sort({ createdAt: -1 })
.select('position status totalScore questionCount createdAt')
.exec()
return interviews.map(i => ({
id: i._id.toString(),
position: i.position,
status: i.status,
totalScore: i.totalScore,
questionCount: i.questionCount,
time: (i as any).createdAt,
}))
}
async getStats(userId: string) {
const interviews = await this.interviewModel.find({ userId }).exec()
const completed = interviews.filter(i => i.status === 'completed')
const totalScore = completed.reduce((s, i) => s + i.totalScore, 0)
return {
interviewCount: interviews.length,
completedCount: completed.length,
avgScore: completed.length > 0 ? Math.round(totalScore / completed.length) : 0,
}
}
}
@@ -0,0 +1,130 @@
import { Controller, Post, Get, Body, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { User, UserDocument } from '../user/user.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 DURATION_DAYS = 30
const FREE_DAILY_LIMIT = 2
interface PlanConfig {
id: string
name: string
price: number
dailyLimit: number
features: string[]
}
const PLANS: Record<string, PlanConfig> = {
free: {
id: 'free',
name: '免费版',
price: 0,
dailyLimit: FREE_DAILY_LIMIT,
features: [
'每日 2 次 AI 模拟面试',
'基础面试报告',
'通用题库随机出题',
'简历诊断(限 3 次)',
],
},
growth: {
id: 'growth',
name: '成长版',
price: GROWTH_PRICE,
dailyLimit: 999,
features: [
'免费版全部权益',
'无限面试次数',
'详细面试报告(四维评分)',
'进步轨迹雷达图 + 打卡',
'每日一题推送',
'参考回答思路',
'公司真题库',
],
},
}
@Controller('member')
@UseGuards(JwtAuthGuard)
export class MemberController {
constructor(
@InjectModel(User.name) private userModel: Model<UserDocument>,
) {}
// 公开的套餐配置(给前端会员页和限制拦截用)
@Public()
@Get('plans')
getPlans() {
return {
interview: {
dailyFreeLimit: FREE_DAILY_LIMIT,
maxRoundsFree: 5,
maxRoundsVip: 10,
},
diagnosis: { dailyFreeLimit: 2 },
optimize: { dailyFreeLimit: 2 },
price: { monthly: GROWTH_PRICE },
plans: Object.values(PLANS).map(p => ({
id: p.id,
name: p.name,
price: p.price,
priceDisplay: p.price === 0 ? '免费' : `¥${(p.price / 100).toFixed(1)}/月`,
dailyLimit: p.dailyLimit,
features: p.features,
})),
}
}
@Get('status')
async getStatus(@CurrentUser('userId') userId: string) {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
const planConfig = PLANS[user.plan] || PLANS.free
return {
plan: user.plan,
planName: planConfig.name,
remaining: user.remaining,
dailyLimit: planConfig.dailyLimit,
vipExpireAt: user.vipExpireAt,
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}`,
}
}
@Post('pay')
async pay(@CurrentUser('userId') userId: string, @Body('orderId') orderId: string) {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
const expireAt = new Date()
expireAt.setDate(expireAt.getDate() + DURATION_DAYS)
user.plan = 'growth'
user.vipExpireAt = expireAt
user.remaining = 999
await user.save()
return {
success: true,
plan: 'growth',
planName: '成长版',
expireAt,
message: '支付成功!欢迎开通成长版',
}
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { MemberController } from './member.controller'
import { User, UserSchema } from '../user/user.schema'
@Module({
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
controllers: [MemberController],
})
export class MemberModule {}
@@ -0,0 +1,88 @@
import { Controller, Post, Body, UseGuards, HttpException, HttpStatus } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { User, UserDocument } from '../user/user.schema'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { WechatPayService } from './wechat-pay.service'
import { Public } from '../../common/decorators/public.decorator'
const VIP_AMOUNT = 2900 // 29 元(分)
const VIP_DURATION_DAYS = 30
@Controller('payment')
export class PaymentController {
constructor(
@InjectModel(User.name) private userModel: Model<UserDocument>,
private wechatPay: WechatPayService,
) {}
/** 创建订单(H5Native 扫码支付) */
@UseGuards(JwtAuthGuard)
@Post('create')
async create(@CurrentUser('userId') userId: string) {
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)
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
const result = await this.wechatPay.nativePay(
'职引月度会员',
outTradeNo,
VIP_AMOUNT,
)
return {
outTradeNo: result.outTradeNo,
codeUrl: result.codeUrl, // 二维码链接
amount: VIP_AMOUNT,
title: '职引月度会员',
}
}
/** JSAPI 支付(微信小程序/公众号内使用) */
@UseGuards(JwtAuthGuard)
@Post('jsapi')
async jsapi(@CurrentUser('userId') userId: string, @Body('openid') openid: string) {
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)
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
const result = await this.wechatPay.jsapiPay('职引月度会员', outTradeNo, VIP_AMOUNT, openid)
return result
}
/** 支付回调通知 */
@Public()
@Post('notify')
async notify(@Body() body: any, @Body('headers') headers: any) {
// 实际运行时从 request 读取 header
try {
const decrypted = this.wechatPay.verifyAndDecrypt(body, '', '', '')
if (!decrypted) return { code: 'FAIL', message: '验签失败' }
// 处理成功支付
const outTradeNo = decrypted.out_trade_no
const userId = outTradeNo.slice(-6) // 从订单号取 userId
const user = await this.userModel.findOne({ _id: { $regex: userId + '$' } }).exec()
if (user && user.plan !== 'vip') {
const expireAt = new Date()
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
user.plan = 'vip'
user.vipExpireAt = expireAt
user.remaining = 999
await user.save()
}
return { code: 'SUCCESS', message: '成功' }
} catch {
return { code: 'FAIL', message: '处理失败' }
}
}
/** 查询订单 */
@UseGuards(JwtAuthGuard)
@Post('query')
async query(@Body('outTradeNo') outTradeNo: string) {
return this.wechatPay.queryOrder(outTradeNo)
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { PaymentController } from './payment.controller'
import { WechatPayService } from './wechat-pay.service'
import { User, UserSchema } from '../user/user.schema'
@Module({
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
controllers: [PaymentController],
providers: [WechatPayService],
exports: [WechatPayService],
})
export class PaymentModule {}
@@ -0,0 +1,152 @@
import * as fs from 'fs'
import * as path from 'path'
import * as crypto from 'crypto'
import axios from 'axios'
import { Injectable, Logger } from '@nestjs/common'
const MCHID = process.env.WX_MCHID
const API_V3_KEY = process.env.WX_API_V3_KEY
const NOTIFY_URL = process.env.WX_NOTIFY_URL
const APPID = process.env.WX_APPID
const WX_API_BASE = 'https://api.mch.weixin.qq.com'
@Injectable()
export class WechatPayService {
private readonly logger = new Logger(WechatPayService.name)
private readonly privateKey: string
private readonly mchSerialNo: string
constructor() {
if (!MCHID || !API_V3_KEY || !APPID) {
this.logger.warn('微信支付配置不完整,支付功能不可用')
}
const certDir = path.resolve(__dirname, '../../certs')
this.privateKey = fs.readFileSync(path.join(certDir, 'apiclient_key.pem'), 'utf8')
// 从证书中提取序列号
const cert = fs.readFileSync(path.join(certDir, 'apiclient_cert.pem'), 'utf8')
const certObj = new crypto.X509Certificate(cert)
this.mchSerialNo = certObj.serialNumber
this.logger.log(`微信支付初始化完成,商户号: ${MCHID}`)
}
/** 生成请求签名(API v3 */
private sign(method: string, url: string, body: string, nonce: string, timestamp: string): string {
const message = `${method}\n${url}\n${timestamp}\n${nonce}\n${body}\n`
return crypto.createSign('RSA-SHA256').update(message).sign(this.privateKey, 'base64')
}
/** 获取 Authorization header */
private getAuth(method: string, path: string, body: any) {
const nonce = crypto.randomBytes(16).toString('hex')
const timestamp = Math.floor(Date.now() / 1000).toString()
const bodyStr = body ? JSON.stringify(body) : ''
const signature = this.sign(method, path, bodyStr, nonce, timestamp)
const auth = `WECHATPAY2-SHA256-RSA2048 mchid="${MCHID}",nonce_str="${nonce}",timestamp="${timestamp}",serial_no="${this.mchSerialNo}",signature="${signature}"`
return auth
}
/** 发起 API v3 请求 */
private async request(method: string, apiPath: string, body?: any) {
const url = `${WX_API_BASE}${apiPath}`
try {
const res = await axios({
method,
url,
headers: {
'Authorization': this.getAuth(method, apiPath, body || ''),
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'zhiyin-backend/1.0',
},
data: body,
})
return res.data
} catch (e: any) {
this.logger.error(`微信支付请求失败: ${method} ${apiPath}`, e.response?.data || e.message)
throw e
}
}
/** Native 支付:获取二维码链接 */
async nativePay(description: string, outTradeNo: string, amount: number, openid?: string) {
// amount 单位:分
const body: any = {
appid: APPID,
mchid: MCHID,
description,
out_trade_no: outTradeNo,
notify_url: NOTIFY_URL,
amount: { total: amount, currency: 'CNY' },
}
// JSAPI 时需要 payer.openid
if (openid) body.payer = { openid }
const apiPath = '/v3/pay/transactions/native'
const result = await this.request('POST', apiPath, body)
return { codeUrl: result.code_url, outTradeNo }
}
/** JSAPI 支付:获取调起支付的参数 */
async jsapiPay(description: string, outTradeNo: string, amount: number, openid: string) {
const body = {
appid: APPID,
mchid: MCHID,
description,
out_trade_no: outTradeNo,
notify_url: NOTIFY_URL,
amount: { total: amount, currency: 'CNY' },
payer: { openid },
}
const result = await this.request('POST', '/v3/pay/transactions/jsapi', body)
const prepayId = result.prepay_id
// 生成小程序/JSAPI 调起支付参数
const nonce = crypto.randomBytes(16).toString('hex')
const timestamp = Math.floor(Date.now() / 1000).toString()
const packageStr = `prepay_id=${prepayId}`
const signStr = `${APPID}\n${timestamp}\n${nonce}\n${packageStr}\n`
const paySign = crypto.createSign('RSA-SHA256').update(signStr).sign(this.privateKey, 'base64')
return {
prepayId,
payParams: {
appId: APPID,
timeStamp: timestamp,
nonceStr: nonce,
package: packageStr,
signType: 'RSA',
paySign,
},
}
}
/** 验证并解密回调通知 */
verifyAndDecrypt(body: any, wechatSignature: string, wechatTimestamp: string, wechatNonce: string) {
// 1. 验签
const message = `${wechatTimestamp}\n${wechatNonce}\n${JSON.stringify(body)}\n`
const certDir = path.resolve(__dirname, '../../certs')
const platformCert = fs.readFileSync(path.join(certDir, 'pub_key.pem'), 'utf8')
const verify = crypto.createVerify('RSA-SHA256').update(message)
const isValid = verify.verify(platformCert, wechatSignature, 'base64')
if (!isValid) {
this.logger.warn('微信支付回调验签失败')
return null
}
// 2. 解密 resource
const resource = body.resource
const ciphertext = Buffer.from(resource.ciphertext, 'base64')
const associatedData = resource.associated_data || ''
const nonce = resource.nonce
const key = API_V3_KEY
// AES-256-GCM 解密
const authTag = ciphertext.subarray(ciphertext.length - 16)
const data = ciphertext.subarray(0, ciphertext.length - 16)
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce)
decipher.setAAD(Buffer.from(associatedData))
decipher.setAuthTag(authTag)
const decrypted = decipher.update(data) + decipher.final('utf8')
return JSON.parse(decrypted)
}
/** 查询订单 */
async queryOrder(outTradeNo: string) {
return this.request('GET', `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${MCHID}`)
}
}
@@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common'
import { Public } from '../../common/decorators/public.decorator'
@Controller('positions')
export class PositionsController {
@Public()
@Get('hot')
hot() {
return [
{ name: '前端工程师', salary: '15-25K', company: '腾讯' },
{ name: '后端工程师', salary: '18-30K', company: '阿里巴巴' },
{ name: 'AI 算法工程师', salary: '20-35K', company: '字节跳动' },
{ name: '产品经理', salary: '12-20K', company: '美团' },
{ name: 'UI 设计师', salary: '10-18K', company: '网易' },
]
}
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common'
import { PositionsController } from './positions.controller'
@Module({
controllers: [PositionsController],
})
export class PositionsModule {}
@@ -0,0 +1,89 @@
import { Controller, Get, UseGuards } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { Progress, ProgressDocument } from '../schemas/progress.schema'
import { Interview, InterviewDocument } from '../interview/interview.schema'
@Controller('progress')
@UseGuards(JwtAuthGuard)
export class ProgressController {
constructor(
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
) {}
@Get()
async getProgress(@CurrentUser('userId') userId: string) {
let progress = await this.progressModel.findOne({ userId }).exec()
if (!progress) {
progress = await this.progressModel.create({
userId,
totalInterviews: 0,
completedInterviews: 0,
streak: 0,
recentScores: [],
streakHistory: [],
})
}
const recentInterviews = await this.interviewModel
.find({ userId, status: 'completed' })
.sort({ createdAt: -1 })
.limit(10)
.select('position totalScore questionCount createdAt')
.exec()
return {
totalInterviews: progress.totalInterviews,
completedInterviews: progress.completedInterviews,
dimensions: {
logic: progress.avgLogic || Math.round(60 + Math.random() * 20),
expression: progress.avgExpression || Math.round(60 + Math.random() * 20),
professionalism: progress.avgProfessionalism || Math.round(60 + Math.random() * 20),
stability: progress.avgStability || Math.round(60 + Math.random() * 20),
},
streak: progress.streak,
lastInterviewDate: progress.lastInterviewDate,
recentScores: progress.recentScores.slice(-5),
interviews: recentInterviews.map(i => ({
id: (i as any)._id.toString(),
position: i.position,
totalScore: i.totalScore,
questionCount: i.questionCount,
date: (i as any).createdAt,
})),
}
}
@Get('stats')
async getStats(@CurrentUser('userId') userId: string) {
const progress = await this.progressModel.findOne({ userId }).exec()
if (!progress) {
return { totalInterviews: 0, completedInterviews: 0, avgScore: 0, streak: 0 }
}
const completedInterviews = await this.interviewModel
.find({ userId, status: 'completed' })
.select('totalScore')
.exec()
const avgScore = completedInterviews.length > 0
? Math.round(completedInterviews.reduce((s, i) => s + i.totalScore, 0) / completedInterviews.length)
: 0
return {
totalInterviews: progress.totalInterviews,
completedInterviews: progress.completedInterviews,
avgScore,
streak: progress.streak,
dimensions: {
logic: progress.avgLogic || 0,
expression: progress.avgExpression || 0,
professionalism: progress.avgProfessionalism || 0,
stability: progress.avgStability || 0,
},
}
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { ProgressController } from './progress.controller'
import { Progress, ProgressSchema } from '../schemas/progress.schema'
import { Interview, InterviewSchema } from '../interview/interview.schema'
@Module({
imports: [
MongooseModule.forFeature([
{ name: Progress.name, schema: ProgressSchema },
{ name: Interview.name, schema: InterviewSchema },
]),
],
controllers: [ProgressController],
exports: [],
})
export class ProgressModule {}
@@ -0,0 +1,33 @@
import { Controller, Post, Get, Delete, Param, Body } from '@nestjs/common'
import { ResumeService } from './resume.service'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
@Controller('resume')
export class ResumeController {
constructor(private resumeService: ResumeService) {}
@Post('create')
async create(
@CurrentUser('userId') userId: string,
@Body('title') title: string,
@Body('content') content: string,
@Body('targetPosition') targetPosition?: string,
) {
return this.resumeService.create(userId, title, content, targetPosition)
}
@Get('list')
async list(@CurrentUser('userId') userId: string) {
return this.resumeService.list(userId)
}
@Get(':id')
async getDetail(@Param('id') id: string, @CurrentUser('userId') userId: string) {
return this.resumeService.getDetail(id, userId)
}
@Delete(':id')
async delete(@Param('id') id: string, @CurrentUser('userId') userId: string) {
return this.resumeService.delete(id, userId)
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { ResumeController } from './resume.controller'
import { ResumeService } from './resume.service'
import { Resume, ResumeSchema } from './resume.schema'
@Module({
imports: [MongooseModule.forFeature([{ name: Resume.name, schema: ResumeSchema }])],
controllers: [ResumeController],
providers: [ResumeService],
})
export class ResumeModule {}
@@ -0,0 +1,22 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document, Types } from 'mongoose'
export type ResumeDocument = Resume & Document
@Schema({ timestamps: true })
export class Resume {
@Prop({ type: Types.ObjectId, ref: 'User', required: true })
userId: Types.ObjectId
@Prop({ required: true })
title: string
@Prop({ default: '' })
content: string
@Prop({ default: '' })
targetPosition: string
}
export const ResumeSchema = SchemaFactory.createForClass(Resume)
ResumeSchema.index({ userId: 1, createdAt: -1 })
@@ -0,0 +1,36 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { Resume, ResumeDocument } from './resume.schema'
@Injectable()
export class ResumeService {
constructor(@InjectModel(Resume.name) private resumeModel: Model<ResumeDocument>) {}
async create(userId: string, title: string, content: string, targetPosition?: string) {
const resume = await this.resumeModel.create({ userId, title, content, targetPosition })
return resume.toObject()
}
async list(userId: string) {
const list = await this.resumeModel.find({ userId }).sort({ createdAt: -1 }).exec()
return list.map(r => ({
id: r._id.toString(),
title: r.title,
targetPosition: r.targetPosition,
createdAt: (r as any).createdAt,
}))
}
async getDetail(resumeId: string, userId: string) {
const resume = await this.resumeModel.findOne({ _id: resumeId, userId }).exec()
if (!resume) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND)
return resume.toObject()
}
async delete(resumeId: string, userId: string) {
const res = await this.resumeModel.deleteOne({ _id: resumeId, userId }).exec()
if (res.deletedCount === 0) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND)
return { message: '删除成功' }
}
}
@@ -0,0 +1,31 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'
export type CompanyBankDocument = CompanyBank & Document
@Schema({ timestamps: true })
export class CompanyBank {
@Prop({ required: true })
company: string // 公司名称
@Prop({ required: true })
position: string // 岗位
@Prop({ type: [Object], default: [] })
questions: Array<{
content: string // 问题内容
type: string // basic/algorithm/project/behavioral
referenceAnswer: string // 参考回答
difficulty: string // easy/medium/hard
frequency: number // 出现频率统计
tags: string[] // 标签
}>
@Prop({ default: 0 })
contributionCount: number // 用户贡献次数
@Prop({ default: 0 })
viewCount: number // 查看次数
}
export const CompanyBankSchema = SchemaFactory.createForClass(CompanyBank)
@@ -0,0 +1,36 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'
export type ContributionDocument = Contribution & Document
@Schema({ timestamps: true })
export class Contribution {
@Prop({ required: true, index: true })
userId: string
@Prop({ required: true })
interviewId: string
@Prop({ required: true })
company: string // 公司名称
@Prop({ required: true })
position: string // 岗位
@Prop({ default: '' })
rounds: string // 面试轮次描述
@Prop({ type: [String], default: [] })
questions: string[] // 遇到的面试题(脱敏)
@Prop({ default: '' })
experience: string // 面试经验/感受
@Prop({ type: [String], default: [] })
tags: string[] // 标签(如"算法题多"、"重视项目经历"
@Prop({ default: false })
verified: boolean // 是否经过审核
}
export const ContributionSchema = SchemaFactory.createForClass(Contribution)
@@ -0,0 +1,27 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'
export type DailyQuestionDocument = DailyQuestion & Document
@Schema({ timestamps: true })
export class DailyQuestion {
@Prop({ required: true })
position: string // 适用岗位
@Prop({ required: true })
question: string // 面试题
@Prop({ required: true })
referenceAnswer: string // 参考思路
@Prop({ default: 'general' })
category: string // basic/algorithm/project/behavioral
@Prop()
date?: Date // 推送日期
@Prop({ default: false })
pushed: boolean // 是否已推送
}
export const DailyQuestionSchema = SchemaFactory.createForClass(DailyQuestion)
@@ -0,0 +1,56 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'
export type ProgressDocument = Progress & Document
@Schema({ timestamps: true })
export class Progress {
@Prop({ required: true, index: true })
userId: string
@Prop({ default: 0 })
totalInterviews: number
@Prop({ default: 0 })
completedInterviews: number
// 四维能力平均分
@Prop({ default: 0 })
avgLogic: number // 逻辑思维
@Prop({ default: 0 })
avgExpression: number // 表达能力
@Prop({ default: 0 })
avgProfessionalism: number // 专业度
@Prop({ default: 0 })
avgStability: number // 稳定性
// 最近面试记录(用于进步对比)
@Prop({ type: [Object], default: [] })
recentScores: Array<{
interviewId: string
date: Date
position: string
totalScore: number
dimensions: {
logic: number
expression: number
professionalism: number
stability: number
}
}>
// 打卡记录
@Prop({ default: 0 })
streak: number // 连续打卡天数
@Prop()
lastInterviewDate?: Date
@Prop({ type: [Date], default: [] })
streakHistory: Date[]
}
export const ProgressSchema = SchemaFactory.createForClass(Progress)
@@ -0,0 +1,60 @@
import { Controller, Post, UseInterceptors, UploadedFile, HttpException, HttpStatus } from '@nestjs/common'
import { FileInterceptor } from '@nestjs/platform-express'
import * as mammoth from 'mammoth'
import { memoryStorage } from 'multer'
import { Public } from '../../common/decorators/public.decorator'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pdfParse = require('pdf-parse')
@Controller('upload')
export class UploadController {
@Public()
@Post()
@UseInterceptors(FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
}))
async uploadFile(@UploadedFile() file: any) {
if (!file) {
throw new HttpException('请选择文件', HttpStatus.BAD_REQUEST)
}
const ext = file.originalname.toLowerCase().split('.').pop() || ''
let text = ''
try {
switch (ext) {
case 'pdf':
const pdfData = await pdfParse(file.buffer)
text = pdfData.text
break
case 'docx':
const docxResult = await mammoth.extractRawText({ buffer: file.buffer })
text = docxResult.value
break
case 'doc':
text = '[旧版 Word 格式 (.doc) 暂不支持解析,请转换为 .docx 或粘贴文本]'
break
case 'txt':
text = file.buffer.toString('utf-8')
break
default:
throw new HttpException('不支持的文件格式,请上传 PDF、DOCX 或 TXT', HttpStatus.BAD_REQUEST)
}
} catch (e: any) {
if (e instanceof HttpException) throw e
throw new HttpException('文件解析失败:' + (e.message || '未知错误'), HttpStatus.INTERNAL_SERVER_ERROR)
}
if (!text.trim()) {
throw new HttpException('未能从文件中提取到有效文本', HttpStatus.BAD_REQUEST)
}
return {
text: text.trim(),
fileName: file.originalname,
fileSize: file.size,
}
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common'
import { MulterModule } from '@nestjs/platform-express'
import { memoryStorage } from 'multer'
import { UploadController } from './upload.controller'
@Module({
imports: [
MulterModule.register({ storage: memoryStorage() }),
],
controllers: [UploadController],
})
export class UploadModule {}
@@ -0,0 +1,56 @@
import { Controller, Post, Get, Put, Body, Req } from '@nestjs/common'
import { UserService } from './user.service'
import { Public } from '../../common/decorators/public.decorator'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
@Controller('user')
export class UserController {
constructor(private userService: UserService) {}
@Public()
@Post('send-code')
async sendCode(@Body('phone') phone: string) {
return this.userService.sendCode(phone)
}
@Public()
@Post('login')
async login(@Body('phone') phone: string, @Body('code') code: string) {
return this.userService.loginByPhone(phone, code)
}
// 📧 邮箱验证码登录(H5 用)
@Public()
@Post('send-email-code')
async sendEmailCode(@Body('email') email: string) {
return this.userService.sendEmailCode(email)
}
@Public()
@Post('email-login')
async emailLogin(@Body('email') email: string, @Body('code') code: string) {
return this.userService.loginByEmail(email, code)
}
// 微信静默登录
@Public()
@Post('wx-login')
async wxLogin(@Body('code') code: string) {
return this.userService.loginByWx(code)
}
@Get('info')
async getInfo(@CurrentUser('userId') userId: string) {
return this.userService.getInfo(userId)
}
@Put('update')
async update(@CurrentUser('userId') userId: string, @Body() data: { nickname?: string; avatar?: string }) {
return this.userService.update(userId, data)
}
@Get('usage')
async getUsage(@CurrentUser('userId') userId: string) {
return this.userService.getUsage(userId)
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { JwtModule } from '@nestjs/jwt'
import { UserController } from './user.controller'
import { UserService } from './user.service'
import { User, UserSchema } from './user.schema'
@Module({
imports: [
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
JwtModule.register({
secret: process.env.JWT_SECRET || 'zhiyin-jwt-secret-2026',
signOptions: { expiresIn: '7d' },
}),
],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
+42
View File
@@ -0,0 +1,42 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'
export type UserDocument = User & Document
@Schema({ timestamps: true })
export class User {
@Prop({ unique: true, sparse: true })
phone?: string
@Prop({ unique: true, sparse: true })
wxOpenid?: string
@Prop({ default: '' })
nickname?: string
@Prop({ default: '' })
avatar?: string
@Prop({ default: 0 })
interviewCount: number
@Prop({ default: 3 })
remaining: number
@Prop({ default: 'free' })
plan: string
@Prop()
vipExpireAt?: Date
@Prop({ default: 'user' })
role: string // 'user' | 'admin'
@Prop({ default: false })
isSystemAdmin: boolean
@Prop({ unique: true, sparse: true })
email?: string
}
export const UserSchema = SchemaFactory.createForClass(User)
+165
View File
@@ -0,0 +1,165 @@
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()
if (!user) {
const nick = email.split('@')[0]
user = await this.userModel.create({ email, nickname: nick, remaining: 3 })
}
return this.generateAuthResponse(user)
}
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,
}
}
}