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 { QuotaService } from '../user/quota.service' import { TtsService } from '../tts/tts.service' import { analyzeSpeech } from '../../common/utils/filler-words' @Injectable() export class InterviewService { constructor( @InjectModel(Interview.name) private interviewModel: Model, @InjectModel(Progress.name) private progressModel: Model, private aiService: AiService, private userService: UserService, private quotaService: QuotaService, private ttsService: TtsService, ) {} async create(userId: string, position: string) { await this.quotaService.checkAndDeductInterview(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 !== 'free' ? 10 : 5 if (interview.questionCount >= maxRounds) { throw new HttpException( user?.plan !== 'free' ? '已达到每场面试最大轮次(10轮)' : '免费版每场最多5轮,升级会员可享10轮', HttpStatus.FORBIDDEN ) } // Save user's answer 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) .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 answerWithAvatar(interviewId: string, userId: string, answer: string) { const base = await this.answer(interviewId, userId, answer) const aiMsg = base.messages?.find(m => m.role === 'ai') if (aiMsg?.content) { try { const tts = await this.ttsService.synthesize(aiMsg.content) return { ...base, ttsHash: tts.hash, ttsDurationMs: tts.durationMs, ttsAmplitude: tts.amplitudeData } } catch { // TTS failure is non-critical, return without audio } } return base } 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) // 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 // Parse dimensions from summary JSON let dimensions: Record | null = null if (interview.summary) { try { const parsed = JSON.parse(interview.summary) dimensions = { logic: parsed['逻辑思维'] || 0, expression: parsed['表达能力'] || 0, professionalism: parsed['专业度'] || 0, stability: parsed['稳定性'] || 0, } } catch {} } return { ...interview.toObject(), fillerWords: speechAnalysis?.fillerWords || interview.fillerWords, fillerScore: speechAnalysis?.fillerScore || interview.fillerScore, fillerDensity: speechAnalysis?.fillerDensity || interview.fillerDensity, speechAnalysis, dimensions, } } 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.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, } } }