初始化:职引项目 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,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,
}
}
}