初始化:职引项目 v1.0
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user