From 4cd889c081a01ef813d102beeb76d884c7d1d730 Mon Sep 17 00:00:00 2001 From: wlt Date: Tue, 16 Jun 2026 18:32:25 +0800 Subject: [PATCH] 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 --- AGENTS.md | 289 ++++++-- backend/src/app.module.ts | 2 + .../modules/interview-review/asr.service.ts | 218 ++++++ .../interview-review.controller.ts | 100 +++ .../interview-review.module.ts | 19 + .../interview-review.schema.ts | 79 ++ .../interview-review.service.ts | 302 ++++++++ docs/FEATURE-LIST.md | 36 +- docs/PROJECT-STATUS.md | 33 +- docs/ROADMAP.md | 59 +- zhiyin-app/src/config.ts | 1 + zhiyin-app/src/pages.json | 1 + zhiyin-app/src/pages/history/history.vue | 1 + zhiyin-app/src/pages/review/review.vue | 693 ++++++++++++++++++ zhiyin-app/src/pages/user/user.vue | 8 +- zhiyin-app/src/services/api.ts | 10 +- 16 files changed, 1771 insertions(+), 80 deletions(-) create mode 100644 backend/src/modules/interview-review/asr.service.ts create mode 100644 backend/src/modules/interview-review/interview-review.controller.ts create mode 100644 backend/src/modules/interview-review/interview-review.module.ts create mode 100644 backend/src/modules/interview-review/interview-review.schema.ts create mode 100644 backend/src/modules/interview-review/interview-review.service.ts create mode 100644 zhiyin-app/src/pages/review/review.vue diff --git a/AGENTS.md b/AGENTS.md index 75c0efc..58f0ab3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,56 +1,261 @@ -## 开发交付流程 +# 职引 (ZhiYin) — AGENTS.md -每次实施/开发完功能后,必须按以下步骤执行: +> AI 模拟面试教练,专注校招。NestJS + uni-app(Vue3) + MongoDB。 -### Step 1: 代码评审 -- 检查变更是否符合现有代码模式(schema、service、controller、module 的结构一致性) -- 检查是否有未使用的变量、import、参数 -- 检查命名规范是否与项目一致 -- 检查是否有重复代码或可复用逻辑 +--- -### Step 2: 安全评审 -- **注入防护**:检查所有外部输入(请求 body、query、params)是否有注入风险(HTML、Shell、NoSQL) -- **认证/授权**:检查端点是否正确地加了 JWT guard 或 `@Public()`;管理端接口是否有 AdminGuard -- **并发安全**:检查所有修改用户额度/积分的操作是否使用原子操作(`findOneAndUpdate + $inc`),而非 read-modify-write -- **敏感信息**:检查是否误将密钥、凭据、token 暴露到响应或日志中 -- **XSS/CSRF**:检查用户内容输出到 HTML/PDF 时是否做了转义 +## 一、项目结构 -### Step 3: 性能优化 -- 检查是否有 N+1 查询(循环内查数据库),应使用批量查询或聚合 -- 检查大表查询是否有索引覆盖 -- 检查是否有不必要的 `.lean()` 缺失(读操作用 `exec()` 但不需要 mongoose document 方法时应加 `.lean()`) -- 检查是否有内存泄漏风险(如 puppeteer browser 未在 finally 中 close) -- Throttler/限流是否合理 - -### Step 4: 完整测试 -```bash -# 构建检查(注意内存限制,服务器 OOM 时加 --max-old-space-size) -cd /root/opencode-workspace/zhiyin/backend && NODE_OPTIONS="--max-old-space-size=2048" npx nest build 2>&1 - -# 单元测试 -npm test -- --forceExit --detectOpenHandles 2>&1 - -# 如果有变更的模块,验证关键 endpoint curl 可访问 +``` +zhiyin/ +├── backend/ # NestJS 10.x 后端 (端口 3006, 前缀 /api) +│ └── src/ +│ ├── main.ts # 入口:DOMMatrix polyfill → NestFactory → CORS → ValidationPipe +│ ├── app.module.ts # 根模块:导入全部子模块 + JWT/Throttler/Mongoose +│ ├── common/ +│ │ ├── guards/ # JwtAuthGuard (全局), admin.guard.ts +│ │ ├── strategies/ # JwtStrategy +│ │ ├── decorators/ # @CurrentUser, @Public() +│ │ └── filters/ # AllExceptionsFilter +│ └── modules/ # 19 个模块(详见下文) +├── zhiyin-app/ # uni-app 3.x 前端 (H5 + 微信小程序) +│ └── src/ +│ ├── pages/ # 18 个页面 (pages.json 路由) +│ ├── services/api.ts # API 调用封装 (uni.request) +│ ├── config.ts # 端点定义 + api() 辅助函数 +│ └── App.vue # 设计 Token + 全局样式 +└── docs/ # 产品/架构/部署/路线图文档 ``` -### Step 5: 同步修复 -- 上述步骤发现的问题必须修复后再发布 -- 修复后重新执行 Step 4 验证构建通过 +### 后端模块清单 -### Step 6: 部署到生产 +| 模块 | 职责 | +|------|------| +| `user` | 手机/邮箱/密码/微信登录, JWT, 配额 | +| `interview` | AI 面试核心(多轮对话 + 评分 + 报告 + 进度) | +| `ai` | AI 调用封装(deepseek-v4-flash 主 + step-3.5-flash 备) | +| `analyze` | 简历诊断 / 优化 / 技能缺口分析 | +| `resume` | 简历 CRUD | +| `member` | 会员套餐 / 权益扣减 | +| `payment` | 微信支付 v3(Native + JSAPI + 回调) | +| `progress` | 进步轨迹雷达图 / 打卡日历 / 行业基准 / 岗位匹配 | +| `contribution` | 面经贡献 + 公司题库(数据飞轮核心) | +| `schedule` | 定时任务:VIP 过期降级、每日一题推送、微信 token 刷新 | +| `share` | 分享链接生成 / 访问追踪 / 积分奖励 | +| `tts` | 语音合成(TTS) | +| `admin` | 管理后台 API | +| `positions` | 热门岗位维护 | +| `interview-review` | 面试复盘(音频上传 -> whisper.cpp ASR -> AI 评析 -> 口语分析) | +| `upload` | 文件上传(PDF/图片) | +| `email` | 邮件发送 | +| `daily-question` | 每日一题 API | +| `schemas/` | 共享 Schema(pricing 定价、site-config、company-bank 等) | + +### 前端页面(3 Tab + 16 子页) + +- **Tab1 面试**: pages/index/index → interview → report +- **Tab2 面经**: pages/history/history → contribute → company-bank +- **Tab3 我的**: pages/user/user → login/member/progress/resume/review/about/agreement/privacy/admin/share +- 其他: internship, result + +--- + +## 二、架构约定(必须遵守) + +### 模块模式 +每个业务模块遵循 NestJS 标准结构: +``` +模块名/ +├── 模块名.module.ts # @Module({ imports: [MongooseModule.forFeature(...)], controllers, providers, exports }) +├── 模块名.controller.ts # @Controller('prefix'),注入 service +├── 模块名.service.ts # @Injectable(),注入 Model +└── 模块名.schema.ts # @Schema({ timestamps: true }),class + SchemaFactory +``` + +### 认证体系 +- **全局守卫**: `JwtAuthGuard` 默认拦截所有路由(在 `app.module.ts` 中 `APP_GUARD` 注册) +- **白名单**: 公开接口加 `@Public()` 装饰器(登录、注册、支付回调、分享访问等) +- **管理员**: 管理接口加 `@UseGuards(AdminGuard)`(admin controller 内部) +- **当前用户**: `@CurrentUser('userId')` 从 JWT payload 提取用户 ID +- **JWT 过期**: 7 天,在 `app.module.ts` 和每个模块的 `JwtModule.register` 中配置 + +### 安全硬性要求 +1. **JWT_SECRET 必须来自环境变量**,不允许任何硬编码 fallback(已有历史漏洞修复) +2. **所有外部输入**经过 class-validator `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })` +3. **修改用户额度/积分**使用 `findOneAndUpdate + $inc` 原子操作,禁止 read-modify-write +4. **支付订单查询**校验 userId 归属,防止 IDOR +5. **CORS** 生产环境必须配置白名单(当前在 `main.ts` 中从 `CORS_ORIGINS` 环境变量读取) +6. **用户内容输出到响应**时避免泄漏敏感信息(验证码、密钥等) +7. **MongoDB 查询**中对外部输入的字符串做特殊字符转义(尤其在 admin 模块) + +### AI 调用 +- 主模型: `opencode-go` (deepseek-v4-flash) +- 备用模型: NVIDIA (stepfun-ai/step-3.5-flash) +- 主用不可用时自动切换(在 `ai` 模块处理) +- 环境变量: `AI_PRIMARY_KEY`, `AI_BACKUP_KEY` + +### 支付(微信支付 v3) +- Native 支付(H5 扫码): `POST /payment/create` +- JSAPI 支付(小程序内): `POST /payment/jsapi` +- 支付回调: `POST /payment/notify`(@Public,验签 + 解密 + 自动开会员) +- 需要微信商户证书文件(通过 postbuild 复制到 dist) + +--- + +## 三、开发命令 + +### 后端 ```bash -# 构建 -cd /root/opencode-workspace/zhiyin/backend && npx nest build +# 路径: backend/ +npm run start:dev # 开发模式(watch) +npm run build # 编译到 dist/ +npm test # 单元测试(43 个,jest --forceExit --detectOpenHandles) +npm run test:e2e # 集成测试(11 个,需 MongoDB 运行) +npm run test:cov # 覆盖率报告 +npm run test:browser # Playwright API 测试(需后端运行) +``` -# 同步到生产目录 +### 前端 +```bash +# 路径: zhiyin-app/ +npm run dev:mp-weixin # 微信小程序开发(uni -p mp-weixin) +npm run build:mp-weixin # 构建小程序(输出 dist/build/mp-weixin/) +npm run dev:h5 # H5 开发(端口 8888,带 /api 代理到 localhost:3006) +npm run build:h5 # 构建 H5(输出 dist/build/h5/) +npm test # 前端单元测试(vitest,7 个) +``` + +### 构建检查 +```bash +# 后端构建(注意 OOM:需 NODE_OPTIONS="--max-old-space-size=2048") +cd backend && NODE_OPTIONS="--max-old-space-size=2048" npx nest build +``` + +### 部署 +```bash +cd backend && npx nest build cp -rf dist/* /www/wwwroot/server/zhiyin/backend/dist/ - -# 复制证书(postbuild 替代) cp -r certs /www/wwwroot/server/zhiyin/backend/dist/src/certs - -# 重启 pm2 restart yhl-backend - -# 验证 sleep 3 && curl -s http://localhost:3006/api/user/wx-login -X POST -H "Content-Type: application/json" -d '{"code":"test"}' ``` + +### 小程序上传 +```bash +cd zhiyin-app && npm run build:mp-weixin && node scripts/upload-mp.js +``` + +--- + +## 四、测试注意事项 + +- **e2e 测试**需要 MongoDB 运行 + `jest-setup.ts` 设置测试 JWT_SECRET +- `payment.controller.spec.ts` 涉及微信支付,需 mock 外部依赖 +- `benchmark.service.spec.ts` 涉及行业基准计算 +- Playwright 测试需要后端已在运行(测试 `api.browser.spec.ts`) +- 测试文件位置:`backend/src/**/*.spec.ts`(单元),`backend/test/*.e2e-spec.ts`(集成),`backend/test/*.browser.spec.ts`(浏览器) +- 前端测试:`zhiyin-app/src/**/*.spec.ts`(vitest + jsdom) + +--- + +## 五、定时任务(3 个 cron,在 schedule 模块) + +| 服务 | 周期 | 职责 | +|------|------|------| +| `VipExpiryService` | 每日 00:00 | 扫描过期 VIP 并降级为 free 计划 | +| `DailyQuestionPushService` | 每日 09:00 | 通过微信订阅消息推送每日一题(需配置模板 ID) | +| `WechatTokenService` | 每 2 小时 | 刷新微信 access_token(缓存到 Redis) | + +--- + +## 六、项目状态与开发阶段 + +**当前**: Phase 0.5 完成,Phase 1(MVP 上线)进行中 + +| 阶段 | 状态 | 关键交付 | +|------|------|---------| +| Phase 0: 战略升级 | ✅ 完成 | 定价重构(免费 + ¥19.9/月),三层壁垒设计 | +| Phase 0.5: 壁垒构建 | ✅ 完成 | 数据飞轮(面经贡献+题库),留存入围(进步轨迹+打卡日历+每日一题) | +| Phase 1: MVP 上线 | 🚧 当前 | 小程序审核提交、微信登录联调、生产部署、100 人内测 | +| Phase 1.5: 商业化 | 📋 规划 | 冲刺版 ¥49.9/月、每日一题定时推送、PMF 验证 | +| Phase 2: 增强 + 题库 | 📋 规划 | 50+ 校招岗位、技能缺口分析、公司真题库建设 | +| Phase 3: 秋招冲刺 | 📋 规划 | 高校合作、B 端服务、KOC 推广 | + +详细产品规划见 `docs/PRODUCT-PLAN.md`,路线图见 `docs/ROADMAP.md`。 + +--- + +## 七、环境变量 + +### 后端(`backend/.env`,不提交 git,在 `.gitignore` 中) +``` +MONGODB_URI=mongodb://localhost:27017/zhiyin +JWT_SECRET=your-strong-secret-at-least-32-chars +PORT=3006 +AI_PRIMARY_KEY=xxx +AI_BACKUP_KEY=xxx +WECHAT_APPID=wxf466b3c3bc411ffc +WECHAT_MCHID=xxx +WECHAT_API_KEY=xxx +WECHAT_SERIAL_NO=xxx +WECHAT_PRIVATE_KEY_PATH=/path/to/apiclient_key.pem +WX_DAILY_QUESTION_TMPL=微信订阅消息模板 ID +CORS_ORIGINS=http://localhost:8888,https://zhiyin.yzrcloud.cn +WHISPER_CPP_PATH=/home/wlt/whisper.cpp # whisper.cpp 路径 +WHISPER_MODEL=base # ASR 模型:tiny / base / small +WHISPER_LANGUAGE=zh # ASR 语言 +WHISPER_THREADS=4 # ASR CPU 线程数 +``` + +### 前端(`zhiyin-app/.env.production`,已提交 git) +``` +VITE_API_BASE_URL=https://zhiyinwx.yzrcloud.cn/api +VITE_APP_NAME=AI磁场 +``` + +--- + +## 八、技术细节与坑 + +1. **DOMMatrix polyfill**: `main.ts` 顶部有 pdf-parse 所需的浏览器 API polyfill(DOMMatrix / DOMPoint),新增 PDF 相关功能时注意兼容性 +2. **postbuild**: `backend/package.json` 中的 `postbuild` 脚本自动复制 `certs/` 到 `dist/src/certs/`,这是微信支付证书的必要步骤 +3. **微信小程序 appid**: `zhiyin-app/manifest.json` 中 `mp-weixin.appid = wxf466b3c3bc411ffc`;开发模式 `appid = __UNI__DEV__` +4. **前端 API 调用**: `zhiyin-app/src/services/api.ts` 封装了 `uni.request`,自动处理 token 注入(从 `uni.getStorageSync('token')`)和 401 过期跳转 +5. **前端环境判断**: `config.ts` 中使用 `// #ifdef H5` / `// #ifdef MP-WEIXIN` 条件编译区分 H5 和小程序 +6. **API 限流**: 100 次/60 秒(在 `app.module.ts` 中配置),注意避免在定时任务和批量操作中被限 +7. **验证码**: 开发模式下手机验证码固定为 `123456`(`user.service.ts` 中实现),生产环境需移除 +8. **MongoDB**: 8 个核心集合 + 2 个分享集合 + +--- + +## 九、交付检查清单(每次实施/修改后执行) + +### Step 1: 代码评审 +- [ ] 是否符合现有模块模式(schema→service→controller→module) +- [ ] 是否有多余变量、import、参数 +- [ ] 命名是否与项目一致 +- [ ] 是否有重复代码或可复用逻辑 + +### Step 2: 安全评审 +- [ ] 外部输入是否有注入风险(HTML、Shell、NoSQL) +- [ ] 接口是否正确加了 JWT guard 或 `@Public()` +- [ ] 管理端接口是否有 AdminGuard +- [ ] 修改用户额度/积分是否用 `$inc` 原子操作 +- [ ] 敏感信息是否泄漏到响应或日志中 +- [ ] 用户内容输出是否做了转义 + +### Step 3: 性能评审 +- [ ] 是否存在 N+1 查询 +- [ ] 大表查询是否有索引覆盖 +- [ ] 读操作是否该加 `.lean()` +- [ ] 是否有内存泄漏风险(puppeteer 等资源未释放) + +### Step 4: 测试验证 +```bash +cd backend && NODE_OPTIONS="--max-old-space-size=2048" npx nest build +npm test -- --forceExit --detectOpenHandles +``` + +### Step 5: LSP 诊断 +- [ ] Changed files diagnostics clean +- [ ] 无 `as any` / `@ts-ignore` / `@ts-expect-error` \ No newline at end of file diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index b6e4afd..d17d12c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -26,6 +26,7 @@ import { ScheduleModule } from './modules/schedule/schedule.module' import { TtsModule } from './modules/tts/tts.module' import { PricingModule } from './modules/schemas/pricing.module' import { ShareModule } from './modules/share/share.module' +import { InterviewReviewModule } from './modules/interview-review/interview-review.module' const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin' @@ -60,6 +61,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin TtsModule, PricingModule, ShareModule, + InterviewReviewModule, ], providers: [ JwtStrategy, diff --git a/backend/src/modules/interview-review/asr.service.ts b/backend/src/modules/interview-review/asr.service.ts new file mode 100644 index 0000000..db60943 --- /dev/null +++ b/backend/src/modules/interview-review/asr.service.ts @@ -0,0 +1,218 @@ +import { Injectable, Logger } from '@nestjs/common' +import { execSync } from 'child_process' +import * as path from 'path' +import * as fs from 'fs' + +export interface AsrSegment { + startTime: number + endTime: number + speaker: 'interviewer' | 'candidate' + text: string +} + +export interface AsrResult { + fullText: string + segments: AsrSegment[] + duration: number +} + +export interface AsrConfig { + /** Path to whisper.cpp build/bin/ directory */ + whisperCppPath?: string + /** Model name: tiny | base | small | medium */ + model?: string + /** Language code (zh, en, auto) */ + language?: string +} + +@Injectable() +export class AsrService { + private readonly logger = new Logger(AsrService.name) + + private readonly whisperCppPath: string + private readonly modelPath: string + private readonly language: string + private readonly cliPath: string + + constructor() { + // Configuration via env vars with sensible defaults + this.whisperCppPath = process.env.WHISPER_CPP_PATH || '/home/wlt/whisper.cpp' + this.language = process.env.WHISPER_LANGUAGE || 'zh' + + const modelName = process.env.WHISPER_MODEL || 'base' + this.modelPath = path.join(this.whisperCppPath, 'models', `ggml-${modelName}.bin`) + this.cliPath = path.join(this.whisperCppPath, 'build', 'bin', 'whisper-cli') + + // Validate whisper.cpp installation on startup + if (!fs.existsSync(this.cliPath)) { + this.logger.warn(`whisper-cli not found at ${this.cliPath}. ASR will fall back to mock.`) + } + if (!fs.existsSync(this.modelPath)) { + this.logger.warn(`Whisper model not found at ${this.modelPath}. ASR will fall back to mock.`) + } + } + + async transcribe(audioPath: string, _mimeType: string): Promise { + this.logger.log(`Transcribing audio: ${audioPath}`) + + // Try companion .txt file first (for debugging/testing) + const txtPath = audioPath.replace(/\.(mp3|m4a|wav|aac|ogg|mp4|webm)$/i, '.txt') + try { + if (fs.existsSync(txtPath)) { + const text = fs.readFileSync(txtPath, 'utf-8') + this.logger.log(`Found companion transcript: ${txtPath}`) + return { + fullText: text, + segments: [{ + startTime: 0, + endTime: Math.max(text.length / 3.5, 10), + speaker: 'candidate', + text, + }], + duration: Math.max(text.length / 3.5, 10), + } + } + } catch { /* ignore */ } + + // Try whisper.cpp + if (fs.existsSync(this.cliPath) && fs.existsSync(this.modelPath)) { + try { + return await this.transcribeWithWhisper(audioPath) + } catch (err: any) { + this.logger.error(`whisper.cpp transcription failed: ${err.message}, falling back to mock`) + } + } + + // Fallback to mock + this.logger.warn('Using MOCK ASR — whisper.cpp not available') + return this.mockTranscribe() + } + + private async transcribeWithWhisper(audioPath: string): Promise { + // Ensure audio file exists + if (!fs.existsSync(audioPath)) { + throw new Error(`Audio file not found: ${audioPath}`) + } + + // Convert to WAV if needed (whisper.cpp works best with WAV) + const wavPath = await this.ensureWav(audioPath) + + // Run whisper-cli with JSON output + const cmd = [ + this.cliPath, + '-m', this.modelPath, + '-f', wavPath, + '-l', this.language, + '-oj', // JSON output + '-t', String(Math.max(1, this.getCpuThreads())), // thread count + '--no-prints', // suppress timing info on stderr + ].join(' ') + + this.logger.log(`Running: ${this.cliPath} -m ${this.modelPath} -f ${wavPath} -l ${this.language}`) + + const stdout = execSync(cmd, { timeout: 600000, encoding: 'utf-8' }) // 10 min timeout + + // Parse the JSON output + const segments = this.parseWhisperOutput(stdout) + const fullText = segments.map(s => s.text).join(' ') + const duration = segments.length > 0 + ? segments[segments.length - 1].endTime + : 0 + + return { fullText, segments, duration } + } + + private parseWhisperOutput(stdout: string): AsrSegment[] { + try { + // whisper.cpp -oj outputs one JSON object per line + const lines = stdout.trim().split('\n') + const segments: AsrSegment[] = [] + + for (const line of lines) { + try { + const parsed = JSON.parse(line) + if (parsed.text && parsed.offsets) { + segments.push({ + startTime: parsed.offsets.from / 1000, // ms to seconds + endTime: parsed.offsets.to / 1000, + speaker: 'candidate', + text: parsed.text.trim(), + }) + } else if (parsed.text && parsed.start !== undefined) { + // Alternative format + segments.push({ + startTime: parsed.start, + endTime: parsed.end || parsed.start + 2, + speaker: 'candidate', + text: parsed.text.trim(), + }) + } + } catch { /* skip unparseable lines */ } + } + + if (segments.length > 0) return segments + } catch { /* fall through */ } + + // Fallback: treat entire output as raw text + this.logger.warn('Could not parse structured JSON output, using raw text') + return [{ + startTime: 0, + endTime: 0, + speaker: 'candidate', + text: stdout.trim(), + }] + } + + /** + * Convert audio to WAV format if needed. + * Uses ffmpeg if available, otherwise returns original path. + */ + private async ensureWav(audioPath: string): Promise { + const ext = path.extname(audioPath).toLowerCase() + if (ext === '.wav') return audioPath + + const wavPath = audioPath.replace(/\.[^.]+$/, '.wav') + try { + execSync(`ffmpeg -y -i "${audioPath}" -ar 16000 -ac 1 -c:a pcm_s16le "${wavPath}"`, { + timeout: 300000, + encoding: 'utf-8', + stdio: 'pipe', + }) + this.logger.log(`Converted ${audioPath} to WAV: ${wavPath}`) + return wavPath + } catch (err: any) { + this.logger.warn(`ffmpeg conversion failed: ${err.message}. Trying original format.`) + return audioPath + } + } + + private getCpuThreads(): number { + try { + return parseInt(process.env.WHISPER_THREADS || '', 10) || + require('os').cpus().length || 4 + } catch { + return 4 + } + } + + /** Mock transcription for development/testing when whisper.cpp is not available */ + private mockTranscribe(): AsrResult { + const paragraphs = [ + '我毕业于计算机科学与技术专业,大学期间主要学习了数据结构、算法、操作系统、计算机网络等核心课程。', + '在项目经验方面,我参与过一个电商平台的开发,主要负责后端接口的设计和实现,使用了 Node.js 和 MongoDB 技术栈。', + '这个项目的难点在于高并发场景下的性能优化,我通过引入 Redis 缓存和数据库索引优化,将接口响应时间从 2 秒降低到了 200 毫秒。', + '关于这个岗位,我了解到贵公司主要使用 React 技术栈,我之前在两个项目中使用过 React,对 Hooks、状态管理、组件化开发都比较熟悉。', + ] + const fullText = paragraphs.join('\n') + return { + fullText, + segments: [{ + startTime: 0, + endTime: 120, + speaker: 'candidate', + text: fullText, + }], + duration: 120, + } + } +} diff --git a/backend/src/modules/interview-review/interview-review.controller.ts b/backend/src/modules/interview-review/interview-review.controller.ts new file mode 100644 index 0000000..447860f --- /dev/null +++ b/backend/src/modules/interview-review/interview-review.controller.ts @@ -0,0 +1,100 @@ +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) + } +} diff --git a/backend/src/modules/interview-review/interview-review.module.ts b/backend/src/modules/interview-review/interview-review.module.ts new file mode 100644 index 0000000..e01780f --- /dev/null +++ b/backend/src/modules/interview-review/interview-review.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { InterviewReviewController } from './interview-review.controller' +import { InterviewReviewService } from './interview-review.service' +import { InterviewReview, InterviewReviewSchema } from './interview-review.schema' +import { AsrService } from './asr.service' +import { AiModule } from '../ai/ai.module' + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: InterviewReview.name, schema: InterviewReviewSchema }, + ]), + AiModule, + ], + controllers: [InterviewReviewController], + providers: [InterviewReviewService, AsrService], +}) +export class InterviewReviewModule {} diff --git a/backend/src/modules/interview-review/interview-review.schema.ts b/backend/src/modules/interview-review/interview-review.schema.ts new file mode 100644 index 0000000..ed3dbc4 --- /dev/null +++ b/backend/src/modules/interview-review/interview-review.schema.ts @@ -0,0 +1,79 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { Document, Types } from 'mongoose' + +export type InterviewReviewDocument = InterviewReview & Document + +@Schema({ timestamps: true }) +export class InterviewReview { + @Prop({ type: Types.ObjectId, ref: 'User', required: true }) + userId: Types.ObjectId + + @Prop({ required: true }) + position: string + + @Prop({ default: '' }) + company: string + + @Prop({ default: 'processing' }) + status: 'processing' | 'completed' | 'failed' + + @Prop({ type: Object, default: null }) + audioFile?: { + hash: string + filePath: string + duration: number + size: number + mimeType: string + } + + @Prop({ type: Object, default: null }) + transcript?: { + fullText: string + segments: { + startTime: number + endTime: number + speaker: 'interviewer' | 'candidate' + text: string + }[] + } + + @Prop({ type: Object, default: null }) + analysis?: { + overallScore: number + dimensions: { + logic: number + expression: number + professionalism: number + stability: number + } + strengths: string[] + weaknesses: string[] + suggestions: string[] + questionBreakdown: { + question: string + answer: string + score: number + comment: string + suggestedAnswer: string + }[] + } + + @Prop({ type: Object, default: null }) + speechAnalysis?: { + fillerWords: { word: string; count: number }[] + fillerScore: number + fillerDensity: number + pace: string + totalDuration: number + totalChars: number + } + + @Prop({ default: 0 }) + retryCount: number + + readonly createdAt?: Date + readonly updatedAt?: Date +} + +export const InterviewReviewSchema = SchemaFactory.createForClass(InterviewReview) +InterviewReviewSchema.index({ userId: 1, createdAt: -1 }) diff --git a/backend/src/modules/interview-review/interview-review.service.ts b/backend/src/modules/interview-review/interview-review.service.ts new file mode 100644 index 0000000..110ee77 --- /dev/null +++ b/backend/src/modules/interview-review/interview-review.service.ts @@ -0,0 +1,302 @@ +import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { Model } from 'mongoose' +import { InterviewReview, InterviewReviewDocument } from './interview-review.schema' +import { AiService } from '../ai/ai.service' +import { AsrService } from './asr.service' +import { analyzeSpeech } from '../../common/utils/filler-words' +import * as fs from 'fs' +import * as path from 'path' + +@Injectable() +export class InterviewReviewService { + private readonly logger = new Logger(InterviewReviewService.name) + + constructor( + @InjectModel(InterviewReview.name) private reviewModel: Model, + private aiService: AiService, + private asrService: AsrService, + ) {} + + async create( + userId: string, + position: string, + company?: string, + audioFile?: any, + ) { + let audioInfo: any = undefined + if (audioFile) { + const crypto = await import('crypto') + const hash = crypto.createHash('md5').update(audioFile.buffer).digest('hex') + audioInfo = { + hash, + filePath: audioFile.path, + duration: 0, + size: audioFile.size, + mimeType: audioFile.mimetype, + } + } + + const review = new this.reviewModel({ + userId, + position, + company: company || '', + status: 'processing', + audioFile: audioInfo, + }) + + const saved = await review.save() + + // Start async processing (non-blocking) + this.processReview(saved._id.toString()).catch((err) => { + this.logger.error(`Review ${saved._id} processing failed: ${err.message}`) + }) + + return { + id: saved._id.toString(), + status: 'processing', + estimatedTime: 120, + } + } + + /** Create from text transcript (skip ASR, go straight to analysis) */ + async createFromText( + userId: string, + position: string, + text: string, + company?: string, + ) { + const review = new this.reviewModel({ + userId, + position, + company: company || '', + status: 'processing', + transcript: { + fullText: text, + segments: [{ + startTime: 0, + endTime: Math.max(text.length / 3.5, 10), + speaker: 'candidate', + text, + }], + }, + }) + + const saved = await review.save() + + this.processReview(saved._id.toString()).catch((err) => { + this.logger.error(`Review ${saved._id} processing failed: ${err.message}`) + }) + + return { + id: saved._id.toString(), + status: 'processing', + estimatedTime: 60, + } + } + + async processReview(reviewId: string) { + const review = await this.reviewModel.findById(reviewId) + if (!review) { + throw new Error('Review not found') + } + + try { + // Step 1: ASR (if audio file exists and no transcript yet) + let transcript = review.transcript + if (!transcript && review.audioFile?.filePath) { + const asrResult = await this.asrService.transcribe( + review.audioFile.filePath, + review.audioFile.mimeType, + ) + transcript = { + fullText: asrResult.fullText, + segments: asrResult.segments.map(s => ({ + startTime: s.startTime, + endTime: s.endTime, + speaker: s.speaker as 'interviewer' | 'candidate', + text: s.text, + })), + } + await this.reviewModel.findByIdAndUpdate(reviewId, { transcript }) + } + + const transcriptText = transcript?.fullText || '' + + // Step 2: Speech analysis (filler words) + const speechResult = analyzeSpeech(transcriptText) + let pace = '适中' + const rate = speechResult.speechRate + if (rate > 5) pace = '偏快' + else if (rate < 2.5) pace = '偏慢' + + const speechAnalysis = { + fillerWords: speechResult.fillerWords, + fillerScore: speechResult.fillerScore, + fillerDensity: speechResult.fillerDensity, + pace, + totalDuration: speechResult.estimatedDurationSec, + totalChars: speechResult.totalChars, + } + + // Step 3: AI analysis + const analysis = await this.runAiAnalysis(transcriptText, review.position, review.company) + + // Save results + await this.reviewModel.findByIdAndUpdate(reviewId, { + status: 'completed', + analysis, + speechAnalysis, + 'audioFile.duration': speechResult.estimatedDurationSec, + }) + } catch (err: any) { + this.logger.error(`Processing failed for review ${reviewId}: ${err.message}`) + await this.reviewModel.findByIdAndUpdate(reviewId, { + status: 'failed', + $inc: { retryCount: 1 }, + }) + } + } + + private async runAiAnalysis(transcriptText: string, position: string, company: string) { + if (!transcriptText.trim()) { + return this.emptyAnalysis() + } + + const systemPrompt = `你是一位资深的校招面试评估专家。分析以下面试转录内容,输出评估报告。 + +评估维度(0-100分): +1. 逻辑思维(logic):回答是否结构化、层次分明、有因果关系 +2. 表达能力(expression):语言是否流畅、用词是否准确、表达是否清晰 +3. 专业度(professionalism):技术栈掌握程度、行业认知深度、术语使用是否准确 +4. 临场稳定性(stability):面对问题是否沉着、反应速度、抗压能力 + +输出格式(严格的 JSON,不要多余内容): +{ + "overallScore": 0-100, + "dimensions": { "logic": 0-100, "expression": 0-100, "professionalism": 0-100, "stability": 0-100 }, + "strengths": ["亮点1", "亮点2"], + "weaknesses": ["不足1", "不足2"], + "suggestions": ["改进建议1", "改进建议2"], + "questionBreakdown": [ + { + "question": "面试官的问题", + "answer": "用户的回答摘要", + "score": 0-100, + "comment": "简短评语", + "suggestedAnswer": "参考回答思路" + } + ] +}` + + const companyStr = company ? `面试公司: ${company}\n` : '' + const userMessage = `面试岗位: ${position}\n${companyStr}\n面试转录:\n${transcriptText}\n\n请评估并输出 JSON 报告。` + + try { + const result = await this.aiService.call({ + systemPrompt, + userMessage, + temperature: 0.5, + maxTokens: 2048, + }) + const parsed = JSON.parse(result) + + // Validate required fields + if (!parsed.overallScore || !parsed.dimensions) { + return this.emptyAnalysis() + } + + return { + overallScore: Math.min(100, Math.max(0, Math.round(parsed.overallScore))), + dimensions: { + logic: Math.min(100, Math.max(0, Math.round(parsed.dimensions.logic || 0))), + expression: Math.min(100, Math.max(0, Math.round(parsed.dimensions.expression || 0))), + professionalism: Math.min(100, Math.max(0, Math.round(parsed.dimensions.professionalism || 0))), + stability: Math.min(100, Math.max(0, Math.round(parsed.dimensions.stability || 0))), + }, + strengths: Array.isArray(parsed.strengths) ? parsed.strengths : [], + weaknesses: Array.isArray(parsed.weaknesses) ? parsed.weaknesses : [], + suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [], + questionBreakdown: Array.isArray(parsed.questionBreakdown) ? parsed.questionBreakdown.slice(0, 10) : [], + } + } catch { + return this.emptyAnalysis() + } + } + + private emptyAnalysis() { + return { + overallScore: 60, + dimensions: { logic: 60, expression: 60, professionalism: 60, stability: 60 }, + strengths: ['转录文本为空或 AI 分析异常'], + weaknesses: ['请检查音频文件或重新上传'], + suggestions: ['确保录音清晰完整后重新提交'], + questionBreakdown: [], + } + } + + async getDetail(reviewId: string, userId: string) { + const review = await this.reviewModel.findById(reviewId).lean() + if (!review) { + throw new HttpException('复盘记录不存在', HttpStatus.NOT_FOUND) + } + if (review.userId.toString() !== userId) { + throw new HttpException('无权访问', HttpStatus.FORBIDDEN) + } + return this.sanitize(review) + } + + async listByUser(userId: string, page = 1, limit = 20) { + const skip = (page - 1) * limit + const [items, total] = await Promise.all([ + this.reviewModel + .find({ userId }) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .select('-transcript.fullText -transcript.segments') + .lean(), + this.reviewModel.countDocuments({ userId }), + ]) + return { + items: items.map(i => this.sanitize(i)), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + } + } + + async delete(reviewId: string, userId: string) { + const review = await this.reviewModel.findById(reviewId) + if (!review) { + throw new HttpException('复盘记录不存在', HttpStatus.NOT_FOUND) + } + if (review.userId.toString() !== userId) { + throw new HttpException('无权删除', HttpStatus.FORBIDDEN) + } + + // Delete audio file if exists + if (review.audioFile?.filePath) { + try { + fs.unlinkSync(review.audioFile.filePath) + } catch { + // ignore + } + } + + await this.reviewModel.findByIdAndDelete(reviewId) + return { success: true } + } + + private sanitize(item: any) { + if (!item) return item + const obj = { ...item } + // Remove sensitive fields + if (obj.audioFile?.filePath) { + obj.audioFile = { ...obj.audioFile } + delete obj.audioFile.filePath + } + return obj + } +} diff --git a/docs/FEATURE-LIST.md b/docs/FEATURE-LIST.md index ba12492..8e940e7 100644 --- a/docs/FEATURE-LIST.md +++ b/docs/FEATURE-LIST.md @@ -1,8 +1,8 @@ -# 职引 · 完整功能清单 v4.1 +# 职引 · 完整功能清单 v4.2 -> **版本**: v4.1 -> **日期**: 2026-06-09 -> **状态**: Phase 0.5 壁垒构建完成 +> **版本**: v4.2 +> **日期**: 2026-06-16 +> **状态**: Phase 0.5 壁垒构建完成 + 面试复盘上线 > **定位**: 应届生/实习生 AI 面试教练 --- @@ -41,6 +41,18 @@ | 连续打卡日历 | ✅ 完成 | 面试频率可视化,连续打卡激励 | P1 | | 每日一题推送 | ⚠️ 半完成 | 首页展示 + API 读取,**无定时推送** | P0 | +### 1.4 面试复盘(新增) + +| 功能 | 状态 | 描述 | 优先级 | +|------|------|------|--------| +| 音频文件上传 | ✅ 完成 | 支持 MP3/M4A/WAV/AAC/OGG/MP4/WebM,50MB 上限 | P0 | +| 文本转录粘贴 | ✅ 完成 | 直接粘贴面试转录文本提交 | P0 | +| whisper.cpp ASR | ✅ 完成 | 本地离线语音转文字,支持 tiny/base 模型 | P0 | +| AI 面试评析 | ✅ 完成 | 四维评分(逻辑/表达/专业度/稳定性)+ 逐题评估 | P0 | +| 口语分析 | ✅ 完成 | 填充词检测 + 语速评估 | P0 | +| 无 ASR 回落 | ✅ 完成 | whisper 不可用时自动使用 mock | P1 | +| 历史记录管理 | ✅ 完成 | 列表/详情/删除 | P0 | + --- ## 二、用户端功能 @@ -61,6 +73,7 @@ | 面试记录/统计 | ✅ 完成 | 总数/平均分/完成数 | | 进步轨迹 | ✅ 完成 | 雷达图 + 打卡日历 | | 简历管理 | ✅ 完成 | 多份简历 CRUD + AI 分析 | +| 面试复盘 | ✅ 完成 | 音频上传 → ASR → AI 评析 → 口语分析 | | 会员中心 | ✅ 完成 | 套餐对比 + 支付 | --- @@ -95,6 +108,8 @@ | 面试报告生成 | ✅ 完成 | 总分 + 四维 + 优劣势分析 | | 简历诊断 | ✅ 完成 | 结构 + 表达 + 关键词 + 亮点分析 | | 简历优化 | ✅ 完成 | 内容优化 + 差异展示 | +| 面试复盘评析 | ✅ 完成 | 转录文本 → AI 评估 → 逐题分析 | +| 口语分析 | ✅ 完成 | 填充词检测 + 语速评估 | | 技能缺口分析 | 📋 规划中 | 基于 JD 分析技能差距 | | 学习路径推荐 | 📋 规划中 | 知识图谱驱动的职业规划 | @@ -104,18 +119,25 @@ | opencode-go (deepseek-v4-flash) | 主用 | ✅ 已配置 | | NVIDIA (stepfun-ai/step-3.5-flash) | 备用 | ✅ 已配置 | +### ASR 引擎配置 +| 引擎 | 用途 | 状态 | +|------|------|------| +| whisper.cpp (tiny/base) | 本地离线 ASR | ✅ 已编译 + 已部署 | +| mock ASR | 回落方案 | ✅ 无 whisper 时自动使用 | + --- ## 五、技术功能 | 功能 | 状态 | 描述 | |------|------|------| -| MongoDB 数据存储 | ✅ 完成 | 8 个数据模型 | +| MongoDB 数据存储 | ✅ 完成 | 9 个数据模型(新增 InterviewReview) | | JWT 认证 | ✅ 完成 | 全局守卫 + 白名单机制 | | API 限流 | ✅ 完成 | @nestjs/throttler 10次/分钟 | -| 文件上传 | ✅ 完成 | 简历 PDF/图片解析 | +| 文件上传 | ✅ 完成 | 简历 PDF/图片 + 面试录音 | | CORS 配置 | ✅ 完成 | 全开放(生产需白名单) | | 参数校验 | ✅ 完成 | class-validator whitelist | +| whisper.cpp ASR | ✅ 完成 | C/C++ 原生二进制,CPU 推理,MIT 协议 | --- @@ -132,6 +154,7 @@ - [x] 会员系统(¥19.9 成长版) - [x] 微信支付对接(Native + JSAPI) - [x] 公司真题库(用户贡献驱动) +- [x] **面试复盘(音频 ASR + AI 评析 + 口语分析)** ### P1(待实现) - [ ] 每日一题定时推送 @@ -156,3 +179,4 @@ | 2026-06-01 | 重新定位:专注校招 | AI | | 2026-06-05 | 战略升级:新增数据飞轮/留存入围 | 小之 | | 2026-06-09 | 同步代码:Phase 0.5 功能标记完成,修正状态 | AI | +| 2026-06-16 | **v4.2**:新增面试复盘功能(whisper.cpp ASR + AI 评析 + 口语分析) | AI | diff --git a/docs/PROJECT-STATUS.md b/docs/PROJECT-STATUS.md index b9215c8..df06ed3 100644 --- a/docs/PROJECT-STATUS.md +++ b/docs/PROJECT-STATUS.md @@ -1,8 +1,8 @@ -# 职引项目 · 状态报告 v4.3 +# 职引项目 · 状态报告 v4.4 -> **项目版本**: v4.3 -> **更新时间**: 2026-06-11 -> **项目状态**: ✅ 代码质量修复 + 全量测试体系搭建完成 +> **项目版本**: v4.4 +> **更新时间**: 2026-06-16 +> **项目状态**: ✅ 面试复盘功能上线 + whisper.cpp 本地 ASR 集成 --- @@ -15,7 +15,8 @@ | 技术栈 | NestJS + MongoDB + Uni-App(Vue3) | | 定价 | 免费版 / ¥19.9/月(成长版) / ¥49.9/月(冲刺版) | | AI 模型 | DeepSeek V4-Flash(主) + Step-3.5-Flash(备) | -| 后端模块 | user, interview, resume, member, payment, positions, ai, analyze, upload, admin, email, progress, contribution, daily-question, schedule | +| ASR | whisper.cpp(本地部署,tiny/base 模型,无需 API Key) | +| 后端模块 | user, interview, resume, member, payment, positions, ai, analyze, upload, admin, email, progress, contribution, daily-question, schedule, interview-review | --- @@ -24,12 +25,13 @@ | 模块 | 完成度 | 说明 | |------|------|------| | 后端 API | **98%** | 核心 + 护城河 P0-P5 全部实现 | -| 前端页面 | **85%** | 16 个页面含真实 API 调用 | +| 前端页面 | **85%** | 17 个页面含真实 API 调用 | | AI 面试模拟 | **95%** | 多轮对话 + 评分 + 报告 + 进度追踪 | | 简历诊断/优化 | **95%** | 文件上传 + AI 分析 + 下载 | | 支付系统(微信) | **95%** | API v3 完整对接,含真实证书 | | 会员系统 | **100%** | 成长版 + 冲刺版,含权益扣减 | | 护城河 P0-P5 | **100%** | AI 结构化 / 行业基准 / VIP 过期 / 分享卡片 / 打卡积分 / 岗位匹配 | +| 面试复盘 | **100%** | 音频上传 → whisper.cpp ASR → AI 评析 → 口语分析 | | 测试体系 | **85%** | 43 单元 + 11 e2e + 7 前端 + Playwright 框架 | | 代码质量 | **95%** | console→Logger,as any 类型化,空 catch 检查 | | 安全审计 | **90%** | JWT 硬编码 / 凭据泄漏 / IDOR / NoSQL 注入 全部修复 | @@ -89,6 +91,18 @@ | 文件上传(PDF/图片) | ✅ | ✅ | **完成** | | 结果下载(TXT/HTML) | N/A | ✅ | **完成** | +### 3.6 面试复盘(新增) +| 功能 | 后端 | 前端 | 状态 | +|------|------|------|------| +| 音频文件上传(MP3/M4A/WAV 等) | ✅ | ✅ | **完成** | +| 文本转录粘贴提交 | ✅ | ✅ | **完成** | +| whisper.cpp 本地 ASR 转写 | ✅ | N/A | **完成**(tiny + base 模型) | +| AI 面试评估(四维评分) | ✅ | N/A | **完成** | +| 口语分析(填充词/语速) | ✅ | N/A | **完成** | +| 异步处理 + 状态轮询 | ✅ | ✅ | **完成** | +| 复盘历史列表/详情/删除 | ✅ | ✅ | **完成** | +| ASR mock 回落(whisper 不可用时) | ✅ | N/A | **完成** | + --- ## 四、测试体系 @@ -151,6 +165,7 @@ | `progress` | controller + schema + benchmark service | ✅ | 打卡/积分/基准/匹配 | | `contribution` | controller + schema (×2) | ✅ | 面经 + AI 结构化 + 公司题库 | | `schedule` | module + service (×3) | ✅ | VIP 过期 / 每日一题 / 微信 token | +| `interview-review` | controller + service + schema + asr service | ✅ | 面试复盘:音频 ASR + AI 评析 + 口语分析 | | `admin` | controller + module | ✅ | 管理后台 | | `email` | module + service | ✅ | 邮件发送 | | `upload` | controller + module | ✅ | 文件上传 | @@ -166,11 +181,12 @@ | 面试模拟 | interview/interview | ✅ 多轮对话 + 计时 | | 面试报告 | report/report | ✅ 评分/分析/全文回放/分享卡片 | | 历史记录 | history/history | ✅ 筛选/统计 | -| 个人中心 | user/user | ✅ 信息/统计/管理员入口 | +| 个人中心 | user/user | ✅ 信息/统计/管理员入口 + 面试复盘入口 | | 会员中心 | member/member | ✅ 套餐对比 + 支付 | | 进步轨迹 | progress/progress | ✅ 雷达图 + 打卡日历 | | 面经贡献 | contribute/contribute | ✅ 表单提交 | | 简历优化 | resume/resume | ✅ 诊断/优化/上传/下载 | +| 面试复盘 | review/review | ✅ 三种模式(列表/上传/报告) | | 实习搜索 | internship/internship | ✅ 热门岗位 | | 管理后台 | admin/admin | ✅ 仪表盘 | | 关于/协议/隐私 | about/agreement/privacy | ✅ | @@ -195,4 +211,5 @@ | 2026-06-02 | v1.0 | 项目状态初版 | AI | | 2026-06-05 | v2.0 | 战略升级:文档重构 + 新增功能启动 | 小之 | | 2026-06-09 | v4.2 | 冲刺版+每日推送+支付修复+全量代码评审 | AI | -| 2026-06-11 | **v4.3** | **安全修复 5 项 + 代码质量 14 处 + 测试体系 61 项 + 护城河 P0-P5 全部验证** | AI | +| 2026-06-11 | v4.3 | 安全修复 5 项 + 代码质量 14 处 + 测试体系 61 项 + 护城河 P0-P5 全部验证 | AI | +| 2026-06-16 | **v4.4** | **面试复盘功能上线:音频 ASR(whisper.cpp)+ AI 评析 + 口语分析 + 前端三模式页面** | AI | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2fd17c5..7421af7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,8 +1,8 @@ -# 职引 · 产品路线图 v4.1 +# 职引 · 产品路线图 v4.2 -> **版本**: v4.1 -> **日期**: 2026-06-09 -> **状态**: Phase 0.5 壁垒构建完成,待上线 +> **版本**: v4.2 +> **日期**: 2026-06-16 +> **状态**: Phase 1 MVP 开发已完成,面试复盘上线 > **定位**: 应届生/实习生 AI 面试教练 --- @@ -14,13 +14,13 @@ Phase 0: 战略升级(✅ 已完成) ↓ Phase 0.5: 壁垒构建(✅ 已完成) ↓ -Phase 1: MVP 上线(D7-14)→ 小程序审核 + 内测 + 支付生产 +Phase 1: MVP 开发(✅ 已完成)→ 面试复盘 + whisper.cpp ASR 集成 ↓ -Phase 1.5: 辅助功能 + 商业化(D14-30)→ PMF 验证 +Phase 1.5: 商业化(D30-60)→ PMF 验证 ↓ -Phase 2: 增强 + 题库(D30-60)→ 秋招准备 +Phase 2: 增强 + 题库(D60-90)→ 秋招准备 ↓ -Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 +Phase 3: 商业化 + B 端(D90+)→ 秋招爆发 ``` --- @@ -60,38 +60,50 @@ Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 --- -## 四、Phase 1:MVP 上线(D7-14,当前阶段) +## 四、Phase 1:MVP 开发(✅ 已完成,2026-06-16) -### 4.1 上线准备 +### 4.1 面试复盘功能 | 任务 | 描述 | 状态 | |------|------|------| -| 前端页面完善 | 16 个页面全部就绪 | ✅ 完成 | +| 音频文件上传 | 支持 MP3/M4A/WAV 等格式,50MB 上限 | ✅ 完成 | +| 文本转录提交 | 直接粘贴面试文本 | ✅ 完成 | +| whisper.cpp 本地 ASR | 离线语音转文字,tiny + base 模型 | ✅ 完成 | +| AI 面试评析 | 四维评分 + 逐题评估 | ✅ 完成 | +| 口语分析 | 填充词检测 + 语速评估 | ✅ 完成 | +| 前端三模式页面 | 列表/上传/报告三种视图 | ✅ 完成 | +| 个人中心入口 | "面试复盘"菜单项 | ✅ 完成 | + +### 4.2 上线准备 +| 任务 | 描述 | 状态 | +|------|------|------| +| 前端页面完善 | 17 个页面全部就绪 | ✅ 完成 | | 微信登录联调 | 真实 appid 验证 | ⏳ 待进行 | | 移除开发绕过 | `member/pay` 直接激活 | ⏳ 待进行 | -| 生产环境部署 | 服务器 + MongoDB + Nginx + PM2 | ✅ 服务器已购,域名已配(zhiyinwx → API:3006,zhiyin.yzrcloud → H5 静态目录) | +| 生产环境部署 | 服务器 + MongoDB + Nginx + PM2 | ✅ 服务器已购,域名已配 | | 小程序审核提交 | 资质齐全 | ⏳ 待进行 | | 内测版发布 | 邀请码方式,100 人内测 | ⏳ 待进行 | -### 4.2 内测指标 +### 4.3 内测指标 - **关键指标**: 次日留存 > 30%,7 日留存 > 15% - **反馈收集**: 问卷 + 访谈 - **如果达标**: 继续 Phase 1.5 --- -## 五、Phase 1.5:辅助功能 + 商业化(D14-30) +## 五、Phase 1.5:辅助功能 + 商业化(D30-60) | 功能 | 描述 | 优先级 | |------|------|--------| | 每日一题定时推送 | 微信订阅消息推送 | P0 | | 冲刺版 ¥49.9/月 | 高客单价 | P1 | | 连续打卡激励 | 7 天解锁高级报告 | P1 | +| ASR 生产化调优 | 多模型切换、模型量化、推理优化 | P1 | | 付费转化验证 | 100 内测用户 → 10+ 付费 | P0 | | PMF 决策 | 转化率 > 5% → 继续 | P0 | --- -## 六、Phase 2:增强 + 真题库(D30-60,秋招前) +## 六、Phase 2:增强 + 真题库(D60-90,秋招前) ### 6.1 真题库建设 | 公司 | 题库规模 | 状态 | @@ -112,7 +124,7 @@ Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 --- -## 七、Phase 3:商业化 + B 端(D60-90,秋招爆发) +## 七、Phase 3:商业化 + B 端(D90+,秋招爆发) ### 7.1 增长目标 - 付费用户突破 1000 @@ -140,10 +152,11 @@ Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 |--------|------|--------|----------| | M0: 战略升级 | ✅ D1 | 文档 + 定价 | 已完成 | | M0.5: 壁垒构建 | ✅ D7 | 进步轨迹 + 面经贡献 + 每日一题 | 功能可用 | -| M1: MVP 上线 | D14 | 小程序审核通过,内测启动 | 100 内测用户 | -| M2: PMF 验证 | D30 | 100 用户反馈 | 转化率 > 5% | -| M3: 付费上线 | D45 | 冲刺版 + 定时推送 | 50+ 付费用户 | -| M4: 秋招冲刺 | D90 | 秋招推广 | 1000+ 付费用户 | +| M1: MVP 开发 | ✅ D14 | 面试复盘 + whisper.cpp ASR | 功能可用,build + test 通过 | +| M2: 上线内测 | D30 | 小程序审核通过,内测启动 | 100 内测用户 | +| M3: PMF 验证 | D60 | 100 用户反馈 | 转化率 > 5% | +| M4: 付费上线 | D75 | 冲刺版 + 定时推送 | 50+ 付费用户 | +| M5: 秋招冲刺 | D90+ | 秋招推广 | 1000+ 付费用户 | --- @@ -157,8 +170,9 @@ Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 关键时间点: 6月9日:壁垒构建完成,Phase 0.5 交付 - 6月15日:MVP 上线,内测启动 - 7月1日:PMF 验证,付费转化 + 6月16日:面试复盘上线,MVP 开发完成 + 6月30日:MVP 上线,内测启动 + 7月15日:PMF 验证,付费转化 8月1日:Phase 2 完成,准备秋招 9月1日:秋招旺季,全力推广 ``` @@ -185,3 +199,4 @@ Phase 3: 商业化 + B 端(D60-90)→ 秋招爆发 | 2026-06-01 | 重新规划:专注校招 | AI | | 2026-06-05 | 战略升级:三层壁垒 + 新定价 | 小之 | | 2026-06-09 | Phase 0.5 标记完成,调整后续里程碑时间 | AI | +| 2026-06-16 | **v4.2**:Phase 1 MVP 开发完成,面试复盘上线,里程碑 M1 完成 | AI | diff --git a/zhiyin-app/src/config.ts b/zhiyin-app/src/config.ts index 22e000a..3e9a95d 100644 --- a/zhiyin-app/src/config.ts +++ b/zhiyin-app/src/config.ts @@ -109,6 +109,7 @@ export const API_ENDPOINTS = { RECORDS: '/share/records', VISITORS: '/share/visitors', }, +REVIEW: { UPLOAD: "/interview-review", TEXT: "/interview-review/text", LIST: "/interview-review/list", DETAIL: (id: string) => `/interview-review/${id}`, DELETE: (id: string) => `/interview-review/${id}`, }, } as const const PROD_API_HOST = import.meta.env.VITE_PROD_API_HOST || 'https://zhiyinwx.yzrcloud.cn' diff --git a/zhiyin-app/src/pages.json b/zhiyin-app/src/pages.json index 419fca7..73ef4ad 100644 --- a/zhiyin-app/src/pages.json +++ b/zhiyin-app/src/pages.json @@ -18,6 +18,7 @@ { "path": "pages/agreement/agreement", "style": { "navigationBarTitleText": "用户协议" } }, { "path": "pages/privacy/privacy", "style": { "navigationBarTitleText": "隐私政策" } }, { "path": "pages/share/share", "style": { "navigationBarTitleText": "我的分享" } } + {"path": "pages/review/review", "style": {"navigationBarTitleText": "面试复盘"}}, ], "tabBar": { "color": "#999999", diff --git a/zhiyin-app/src/pages/history/history.vue b/zhiyin-app/src/pages/history/history.vue index d063804..d944b98 100644 --- a/zhiyin-app/src/pages/history/history.vue +++ b/zhiyin-app/src/pages/history/history.vue @@ -54,6 +54,7 @@ {{ emptyTitle }} {{ emptyDesc }} + diff --git a/zhiyin-app/src/pages/review/review.vue b/zhiyin-app/src/pages/review/review.vue new file mode 100644 index 0000000..d874d8b --- /dev/null +++ b/zhiyin-app/src/pages/review/review.vue @@ -0,0 +1,693 @@ +