4cd889c081
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
101 lines
3.0 KiB
TypeScript
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)
|
|
}
|
|
}
|