Files
zhiyin/backend/src/modules/interview-review/interview-review.controller.ts
T
wlt 4cd889c081 feat: interview review module with whisper.cpp ASR + AI analysis + frontend page
New backend module 'interview-review' provides:
- Audio upload (50MB limit, MP3/M4A/WAV/AAC/OGG/MP4/WebM)
- Text transcript submission
- whisper.cpp local ASR integration (tiny + base models)
- AI analysis (4-dimension scoring: logic/expression/professionalism/stability)
- Speech analysis (filler words detection, pace, duration)
- Async processing pipeline with status polling
- Graceful fallback to mock ASR when whisper unavailable

New frontend page 'pages/review/review.vue' with 3 modes:
- List mode: review history with status indicators
- Upload mode: audio file upload or text paste
- Report mode: score radar, dimension bars, analysis details

Docs updated: PROJECT-STATUS.md v4.4, FEATURE-LIST.md v4.2, ROADMAP.md v4.2
2026-06-16 18:32:25 +08:00

101 lines
3.0 KiB
TypeScript

import {
Controller, Post, Get, Delete, Param, Query,
UseInterceptors, UploadedFile, Body,
HttpException, HttpStatus,
} from '@nestjs/common'
import { FileInterceptor } from '@nestjs/platform-express'
import { diskStorage } from 'multer'
import { extname, join } from 'path'
import * as fs from 'fs'
import { randomUUID } from 'crypto'
import { InterviewReviewService } from './interview-review.service'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
const UPLOAD_DIR = join(process.cwd(), 'uploads', 'reviews')
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
}
@Controller('interview-review')
export class InterviewReviewController {
constructor(private service: InterviewReviewService) {}
/** Upload audio file + metadata */
@Post()
@UseInterceptors(FileInterceptor('file', {
storage: diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
filename: (_req, file, cb) => {
const name = randomUUID() + extname(file.originalname || '.mp3')
cb(null, name)
},
}),
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = /\.(mp3|m4a|wav|aac|ogg|mp4|webm)$/i
if (allowed.test(extname(file.originalname))) {
cb(null, true)
} else {
cb(new HttpException('仅支持 mp3/m4a/wav/aac/ogg 格式', HttpStatus.BAD_REQUEST), false)
}
},
}))
async uploadFile(
@UploadedFile() file: any,
@Body('position') position: string,
@Body('company') company: string,
@CurrentUser('userId') userId: string,
) {
if (!file) {
throw new HttpException('请上传录音文件', HttpStatus.BAD_REQUEST)
}
if (!position || !position.trim()) {
throw new HttpException('请填写面试岗位', HttpStatus.BAD_REQUEST)
}
return this.service.create(userId, position.trim(), company?.trim(), file)
}
/** Submit text transcript directly (no audio) */
@Post('text')
async submitText(
@Body('position') position: string,
@Body('company') company: string,
@Body('text') text: string,
@CurrentUser('userId') userId: string,
) {
if (!position || !position.trim()) {
throw new HttpException('请填写面试岗位', HttpStatus.BAD_REQUEST)
}
if (!text || !text.trim()) {
throw new HttpException('请填写面试转录文本', HttpStatus.BAD_REQUEST)
}
return this.service.createFromText(userId, position.trim(), text.trim(), company?.trim())
}
@Get('list')
async list(
@Query('page') page: string,
@Query('limit') limit: string,
@CurrentUser('userId') userId: string,
) {
return this.service.listByUser(userId, parseInt(page) || 1, parseInt(limit) || 20)
}
@Get(':id')
async getDetail(
@Param('id') id: string,
@CurrentUser('userId') userId: string,
) {
return this.service.getDetail(id, userId)
}
@Delete(':id')
async delete(
@Param('id') id: string,
@CurrentUser('userId') userId: string,
) {
return this.service.delete(id, userId)
}
}