v4.3 安全修复+代码质量+测试体系+护城河验证
## 安全修复 (5项) - CRITICAL JWT 硬编码 fallback(jwt.strategy / app.module / user.module) - HIGH seed_admin.js MongoDB 凭据泄漏 - MEDIUM 邮箱验证码泄漏 - MEDIUM 支付订单查询 IDOR - MEDIUM 管理后台 NoSQL 注入 ## 代码质量 (14处) - console.log→Logger(user.service.ts) - as any 类型化(11处跨7个文件) - Schema 联合类型修复(progress.schema) - Module 依赖缺失修复(progress.module) ## 测试体系 (61项) - 后端单元测试 Jest(43项):BenchmarkService/UserService/PaymentController - 后端集成测试 Supertest(11项):API 认证/支付/进度/管理 - 前端单元测试 Vitest(7项):配置文件/API端点 - 浏览器自动化 Playwright(7项):API smoke test - 覆盖率报告 + e2e 配置 ## 护城河 P0-P5 启动验证通过 + 编译通过
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { getModelToken } from '@nestjs/mongoose'
|
||||
import { BenchmarkService, PositionBenchmark, DimensionSet } from './benchmark.service'
|
||||
|
||||
describe('BenchmarkService', () => {
|
||||
let service: BenchmarkService
|
||||
let mockProgressModel: any
|
||||
|
||||
beforeEach(async () => {
|
||||
mockProgressModel = {
|
||||
find: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
lean: jest.fn().mockReturnThis(),
|
||||
exec: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockReturnThis(),
|
||||
}
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BenchmarkService,
|
||||
{ provide: getModelToken('Progress'), useValue: mockProgressModel },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
service = module.get<BenchmarkService>(BenchmarkService)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('percentile', () => {
|
||||
it('should return 0 for empty array', () => {
|
||||
const result = (service as any).percentile([], 50)
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it('should return the exact value for p50 with odd count', () => {
|
||||
const result = (service as any).percentile([10, 20, 30, 40, 50], 50)
|
||||
expect(result).toBe(30)
|
||||
})
|
||||
|
||||
it('should interpolate for p50 with even count', () => {
|
||||
const result = (service as any).percentile([10, 20, 30, 40], 50)
|
||||
expect(result).toBe(25)
|
||||
})
|
||||
|
||||
it('should return min for p0', () => {
|
||||
const result = (service as any).percentile([5, 15, 25], 0)
|
||||
expect(result).toBe(5)
|
||||
})
|
||||
|
||||
it('should return max for p100', () => {
|
||||
const result = (service as any).percentile([5, 15, 25], 100)
|
||||
expect(result).toBe(25)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calcPercentile', () => {
|
||||
it('should return 50 for empty array', () => {
|
||||
const result = (service as any).calcPercentile([], 75)
|
||||
expect(result).toBe(50)
|
||||
})
|
||||
|
||||
it('should return 0 if score is below all', () => {
|
||||
const result = (service as any).calcPercentile([50, 60, 70], 40)
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it('should return 100 if score is above all', () => {
|
||||
const result = (service as any).calcPercentile([50, 60, 70], 80)
|
||||
expect(result).toBe(100)
|
||||
})
|
||||
|
||||
it('should return correct percentile', () => {
|
||||
const result = (service as any).calcPercentile([50, 60, 70, 80, 90], 75)
|
||||
expect(result).toBe(60)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultBenchmark', () => {
|
||||
it('should return default values for given position', () => {
|
||||
const result = service['getDefaultBenchmark']('前端工程师')
|
||||
expect(result.position).toBe('前端工程师')
|
||||
expect(result.sampleCount).toBe(0)
|
||||
expect(result.p50.logic).toBe(72)
|
||||
expect(result.p50.expression).toBe(70)
|
||||
expect(result.p50.professionalism).toBe(72)
|
||||
expect(result.p50.stability).toBe(68)
|
||||
})
|
||||
|
||||
it('should have all required fields', () => {
|
||||
const result = service['getDefaultBenchmark']('测试')
|
||||
const keys: (keyof PositionBenchmark)[] = ['position', 'sampleCount', 'p25', 'p50', 'p75', 'p90', 'avg']
|
||||
for (const key of keys) {
|
||||
expect(result).toHaveProperty(key)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBenchmark', () => {
|
||||
it('should return default benchmark when no data in cache', async () => {
|
||||
const result = await service.getBenchmark('前端工程师')
|
||||
expect(result).toBeDefined()
|
||||
expect((result as PositionBenchmark).position).toBe('前端工程师')
|
||||
})
|
||||
|
||||
it('should return all benchmarks when no position specified', async () => {
|
||||
const result = await service.getBenchmark()
|
||||
expect(result).toBeDefined()
|
||||
expect(typeof result).toBe('object')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUserPercentile', () => {
|
||||
it('should return null when user has no progress', async () => {
|
||||
mockProgressModel.findOne.mockReturnValueOnce({
|
||||
exec: jest.fn().mockResolvedValue(null),
|
||||
})
|
||||
const result = await service.getUserPercentile('nonexistent')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when user has 0 completed interviews', async () => {
|
||||
mockProgressModel.findOne.mockReturnValueOnce({
|
||||
exec: jest.fn().mockResolvedValue({ completedInterviews: 0 }),
|
||||
})
|
||||
const result = await service.getUserPercentile('user1')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { Progress, ProgressDocument } from '../schemas/progress.schema'
|
||||
|
||||
export interface DimensionSet {
|
||||
logic: number
|
||||
expression: number
|
||||
professionalism: number
|
||||
stability: number
|
||||
}
|
||||
|
||||
export interface PositionBenchmark {
|
||||
position: string
|
||||
sampleCount: number
|
||||
p25: DimensionSet
|
||||
p50: DimensionSet
|
||||
p75: DimensionSet
|
||||
p90: DimensionSet
|
||||
avg: DimensionSet
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BenchmarkService {
|
||||
private readonly logger = new Logger(BenchmarkService.name)
|
||||
private cache: Record<string, PositionBenchmark> = {}
|
||||
private cacheTime = 0
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000
|
||||
|
||||
constructor(
|
||||
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
|
||||
) {}
|
||||
|
||||
private percentile(sorted: number[], p: number): number {
|
||||
if (sorted.length === 0) return 0
|
||||
const idx = (p / 100) * (sorted.length - 1)
|
||||
const lo = Math.floor(idx)
|
||||
const hi = Math.ceil(idx)
|
||||
if (lo === hi) return sorted[lo]
|
||||
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo)
|
||||
}
|
||||
|
||||
async getBenchmark(position?: string): Promise<PositionBenchmark | Record<string, PositionBenchmark>> {
|
||||
if (Date.now() - this.cacheTime > this.CACHE_TTL) {
|
||||
await this.refreshCache()
|
||||
}
|
||||
if (position) return this.cache[position] || this.getDefaultBenchmark(position)
|
||||
return this.cache
|
||||
}
|
||||
|
||||
async getUserPercentile(userId: string, position?: string): Promise<{
|
||||
percentile: DimensionSet
|
||||
userScores: DimensionSet
|
||||
benchmark: PositionBenchmark
|
||||
} | null> {
|
||||
const progress = await this.progressModel.findOne({ userId }).exec()
|
||||
if (!progress || progress.completedInterviews === 0) return null
|
||||
|
||||
const userScores: DimensionSet = {
|
||||
logic: progress.avgLogic || 0,
|
||||
expression: progress.avgExpression || 0,
|
||||
professionalism: progress.avgProfessionalism || 0,
|
||||
stability: progress.avgStability || 0,
|
||||
}
|
||||
|
||||
const targetPosition = position || (progress.recentScores.length > 0
|
||||
? progress.recentScores[progress.recentScores.length - 1].position : '')
|
||||
const benchmark = await this.getBenchmark(targetPosition)
|
||||
const bm = 'p50' in benchmark ? benchmark as PositionBenchmark : this.getDefaultBenchmark(targetPosition)
|
||||
|
||||
const allScores = await this.getAllScoresForPosition(targetPosition)
|
||||
|
||||
const percentile: DimensionSet = {
|
||||
logic: this.calcPercentile(allScores.logic, userScores.logic),
|
||||
expression: this.calcPercentile(allScores.expression, userScores.expression),
|
||||
professionalism: this.calcPercentile(allScores.professionalism, userScores.professionalism),
|
||||
stability: this.calcPercentile(allScores.stability, userScores.stability),
|
||||
}
|
||||
|
||||
return { percentile, userScores, benchmark: bm }
|
||||
}
|
||||
|
||||
private calcPercentile(sorted: number[], score: number): number {
|
||||
if (sorted.length === 0) return 50
|
||||
const below = sorted.filter(s => s < score).length
|
||||
return Math.round((below / sorted.length) * 100)
|
||||
}
|
||||
|
||||
private async getAllScoresForPosition(position: string): Promise<Record<string, number[]>> {
|
||||
const allProgress = await this.progressModel.find({}).select('recentScores').lean().exec()
|
||||
const logic: number[] = []
|
||||
const expression: number[] = []
|
||||
const professionalism: number[] = []
|
||||
const stability: number[] = []
|
||||
|
||||
for (const p of allProgress) {
|
||||
const scores = (p.recentScores || []) as Array<{
|
||||
position: string
|
||||
dimensions: { logic: number; expression: number; professionalism: number; stability: number }
|
||||
}>
|
||||
for (const s of scores) {
|
||||
if (s.position === position && s.dimensions) {
|
||||
logic.push(s.dimensions.logic)
|
||||
expression.push(s.dimensions.expression)
|
||||
professionalism.push(s.dimensions.professionalism)
|
||||
stability.push(s.dimensions.stability)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logic.sort((a, b) => a - b)
|
||||
expression.sort((a, b) => a - b)
|
||||
professionalism.sort((a, b) => a - b)
|
||||
stability.sort((a, b) => a - b)
|
||||
|
||||
return { logic, expression, professionalism, stability }
|
||||
}
|
||||
|
||||
private async refreshCache() {
|
||||
try {
|
||||
const allProgress = await this.progressModel.find({}).select('recentScores').lean().exec()
|
||||
const positionScores: Record<string, Record<string, number[]>> = {}
|
||||
|
||||
for (const p of allProgress) {
|
||||
const scores = (p.recentScores || []) as Array<{
|
||||
position: string
|
||||
dimensions: { logic: number; expression: number; professionalism: number; stability: number }
|
||||
}>
|
||||
for (const s of scores) {
|
||||
if (!s.position || !s.dimensions) continue
|
||||
if (!positionScores[s.position]) {
|
||||
positionScores[s.position] = { logic: [], expression: [], professionalism: [], stability: [] }
|
||||
}
|
||||
positionScores[s.position].logic.push(s.dimensions.logic)
|
||||
positionScores[s.position].expression.push(s.dimensions.expression)
|
||||
positionScores[s.position].professionalism.push(s.dimensions.professionalism)
|
||||
positionScores[s.position].stability.push(s.dimensions.stability)
|
||||
}
|
||||
}
|
||||
|
||||
const newCache: Record<string, PositionBenchmark> = {}
|
||||
for (const [pos, scores] of Object.entries(positionScores)) {
|
||||
for (const key of Object.keys(scores)) {
|
||||
scores[key].sort((a, b) => a - b)
|
||||
}
|
||||
const n = scores.logic.length
|
||||
newCache[pos] = {
|
||||
position: pos,
|
||||
sampleCount: n,
|
||||
p25: {
|
||||
logic: this.percentile(scores.logic, 25),
|
||||
expression: this.percentile(scores.expression, 25),
|
||||
professionalism: this.percentile(scores.professionalism, 25),
|
||||
stability: this.percentile(scores.stability, 25),
|
||||
},
|
||||
p50: {
|
||||
logic: this.percentile(scores.logic, 50),
|
||||
expression: this.percentile(scores.expression, 50),
|
||||
professionalism: this.percentile(scores.professionalism, 50),
|
||||
stability: this.percentile(scores.stability, 50),
|
||||
},
|
||||
p75: {
|
||||
logic: this.percentile(scores.logic, 75),
|
||||
expression: this.percentile(scores.expression, 75),
|
||||
professionalism: this.percentile(scores.professionalism, 75),
|
||||
stability: this.percentile(scores.stability, 75),
|
||||
},
|
||||
p90: {
|
||||
logic: this.percentile(scores.logic, 90),
|
||||
expression: this.percentile(scores.expression, 90),
|
||||
professionalism: this.percentile(scores.professionalism, 90),
|
||||
stability: this.percentile(scores.stability, 90),
|
||||
},
|
||||
avg: {
|
||||
logic: Math.round(scores.logic.reduce((a, b) => a + b, 0) / n),
|
||||
expression: Math.round(scores.expression.reduce((a, b) => a + b, 0) / n),
|
||||
professionalism: Math.round(scores.professionalism.reduce((a, b) => a + b, 0) / n),
|
||||
stability: Math.round(scores.stability.reduce((a, b) => a + b, 0) / n),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
this.cache = newCache
|
||||
this.cacheTime = Date.now()
|
||||
this.logger.log(`Benchmark cache refreshed: ${Object.keys(newCache).length} positions`)
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to refresh benchmark cache: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
private getDefaultBenchmark(position: string): PositionBenchmark {
|
||||
return {
|
||||
position,
|
||||
sampleCount: 0,
|
||||
p25: { logic: 60, expression: 58, professionalism: 60, stability: 58 },
|
||||
p50: { logic: 72, expression: 70, professionalism: 72, stability: 68 },
|
||||
p75: { logic: 82, expression: 80, professionalism: 82, stability: 78 },
|
||||
p90: { logic: 90, expression: 88, professionalism: 90, stability: 85 },
|
||||
avg: { logic: 72, expression: 70, professionalism: 72, stability: 68 },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common'
|
||||
import { Controller, Get, Post, Query, UseGuards, Body, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { Progress, ProgressDocument } from '../schemas/progress.schema'
|
||||
import { Interview, InterviewDocument } from '../interview/interview.schema'
|
||||
import { BenchmarkService, PositionBenchmark } from './benchmark.service'
|
||||
import { User, UserDocument } from '../user/user.schema'
|
||||
|
||||
const REDEEM_ITEMS = {
|
||||
extraInterview: { name: '额外面试次数 +1', cost: 30, effect: 'remaining+1' },
|
||||
streakFreeze: { name: '补签卡', cost: 20, effect: 'streak_freeze' },
|
||||
vipDiscount: { name: '会员 8 折券', cost: 100, effect: 'vip_discount' },
|
||||
} as const
|
||||
|
||||
const DIM_KEYS = ['logic', 'expression', 'professionalism', 'stability'] as const
|
||||
|
||||
@Controller('progress')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -12,6 +22,8 @@ export class ProgressController {
|
||||
constructor(
|
||||
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
|
||||
@InjectModel(Interview.name) private interviewModel: Model<InterviewDocument>,
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -23,6 +35,8 @@ export class ProgressController {
|
||||
totalInterviews: 0,
|
||||
completedInterviews: 0,
|
||||
streak: 0,
|
||||
points: 0,
|
||||
totalCheckins: 0,
|
||||
recentScores: [],
|
||||
streakHistory: [],
|
||||
})
|
||||
@@ -35,6 +49,13 @@ export class ProgressController {
|
||||
.select('position totalScore questionCount createdAt')
|
||||
.exec()
|
||||
|
||||
const today = new Date()
|
||||
const todayStr = today.toISOString().slice(0, 10)
|
||||
const lastCheckinStr = progress.lastCheckinDate
|
||||
? new Date(progress.lastCheckinDate).toISOString().slice(0, 10)
|
||||
: ''
|
||||
const checkedToday = todayStr === lastCheckinStr
|
||||
|
||||
return {
|
||||
totalInterviews: progress.totalInterviews,
|
||||
completedInterviews: progress.completedInterviews,
|
||||
@@ -45,18 +66,162 @@ export class ProgressController {
|
||||
stability: progress.avgStability || Math.round(60 + Math.random() * 20),
|
||||
},
|
||||
streak: progress.streak,
|
||||
points: progress.points || 0,
|
||||
totalCheckins: progress.totalCheckins || 0,
|
||||
checkedToday,
|
||||
lastInterviewDate: progress.lastInterviewDate,
|
||||
recentScores: progress.recentScores.slice(-5),
|
||||
interviews: recentInterviews.map(i => ({
|
||||
id: (i as any)._id.toString(),
|
||||
id: i._id.toString(),
|
||||
position: i.position,
|
||||
totalScore: i.totalScore,
|
||||
questionCount: i.questionCount,
|
||||
date: (i as any).createdAt,
|
||||
date: i.createdAt,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// P4: Daily check-in
|
||||
@Post('checkin')
|
||||
async checkin(@CurrentUser('userId') userId: string) {
|
||||
let progress = await this.progressModel.findOne({ userId }).exec()
|
||||
if (!progress) {
|
||||
progress = await this.progressModel.create({ userId, streak: 0, points: 0, totalCheckins: 0, recentScores: [], streakHistory: [] })
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
const todayStr = today.toISOString().slice(0, 10)
|
||||
const lastStr = progress.lastCheckinDate
|
||||
? new Date(progress.lastCheckinDate).toISOString().slice(0, 10)
|
||||
: ''
|
||||
|
||||
if (todayStr === lastStr) {
|
||||
throw new HttpException('今日已打卡', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
// Calculate streak continuity
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const yesterdayStr = yesterday.toISOString().slice(0, 10)
|
||||
const isConsecutive = lastStr === yesterdayStr || !progress.lastCheckinDate
|
||||
|
||||
const newStreak = isConsecutive ? (progress.streak || 0) + 1 : 1
|
||||
const bonusPoints = newStreak % 7 === 0 ? 10 : 5
|
||||
|
||||
progress.streak = newStreak
|
||||
progress.points = (progress.points || 0) + bonusPoints
|
||||
progress.totalCheckins = (progress.totalCheckins || 0) + 1
|
||||
progress.lastCheckinDate = today
|
||||
progress.streakHistory.push(today)
|
||||
await progress.save()
|
||||
|
||||
return {
|
||||
streak: newStreak,
|
||||
points: progress.points,
|
||||
bonusPoints,
|
||||
checkedIn: true,
|
||||
message: isConsecutive ? `🔥 连续打卡 ${newStreak} 天,获得 ${bonusPoints} 积分` : `打卡成功,获得 ${bonusPoints} 积分`,
|
||||
}
|
||||
}
|
||||
|
||||
// P4: Redeem points
|
||||
@Post('redeem')
|
||||
async redeem(@CurrentUser('userId') userId: string, @Body('item') item: string) {
|
||||
const redeemItem = REDEEM_ITEMS[item as keyof typeof REDEEM_ITEMS]
|
||||
if (!redeemItem) throw new HttpException('无效兑换项目', HttpStatus.BAD_REQUEST)
|
||||
|
||||
const progress = await this.progressModel.findOne({ userId }).exec()
|
||||
if (!progress || (progress.points || 0) < redeemItem.cost) {
|
||||
throw new HttpException('积分不足', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
progress.points = (progress.points || 0) - redeemItem.cost
|
||||
await progress.save()
|
||||
|
||||
if (redeemItem.effect === 'remaining+1') {
|
||||
await this.userModel.findByIdAndUpdate(userId, { $inc: { remaining: 1 } }).exec()
|
||||
}
|
||||
|
||||
return { success: true, points: progress.points, item: redeemItem.name, message: `兑换成功,消耗 ${redeemItem.cost} 积分` }
|
||||
}
|
||||
|
||||
// P5: Position match prediction
|
||||
@Post('match')
|
||||
async matchPosition(@CurrentUser('userId') userId: string, @Body('targetPosition') targetPosition?: string) {
|
||||
const progress = await this.progressModel.findOne({ userId }).exec()
|
||||
if (!progress || progress.completedInterviews === 0) {
|
||||
throw new HttpException('暂无面试数据', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
const userDims = {
|
||||
logic: progress.avgLogic || 0,
|
||||
expression: progress.avgExpression || 0,
|
||||
professionalism: progress.avgProfessionalism || 0,
|
||||
stability: progress.avgStability || 0,
|
||||
}
|
||||
|
||||
const allBenchmarks = await this.benchmarkService.getBenchmark()
|
||||
const positions = Object.keys(allBenchmarks).length > 0
|
||||
? Object.keys(allBenchmarks)
|
||||
: ['前端工程师', '后端工程师', 'AI算法工程师', '产品经理', '数据分析师', 'UI/UX设计师', '运营', '测试工程师']
|
||||
|
||||
const matches: Array<{
|
||||
position: string
|
||||
matchScore: number
|
||||
dimGaps: Record<string, number>
|
||||
level: string
|
||||
}> = []
|
||||
|
||||
for (const pos of positions) {
|
||||
const bmData = await this.benchmarkService.getBenchmark(pos)
|
||||
const bm = 'p50' in bmData ? (bmData as PositionBenchmark).p50 : { logic: 70, expression: 70, professionalism: 70, stability: 70 }
|
||||
|
||||
const dimGaps: Record<string, number> = {}
|
||||
let totalGap = 0
|
||||
for (const key of DIM_KEYS) {
|
||||
const gap = Math.max(0, bm[key] - userDims[key])
|
||||
dimGaps[key] = Math.round(gap)
|
||||
totalGap += gap
|
||||
}
|
||||
|
||||
const maxGap = 120 // 四维满分差 (4 * 30)
|
||||
const matchScore = Math.max(0, Math.min(100, Math.round(100 - (totalGap / maxGap) * 100)))
|
||||
const level = matchScore >= 85 ? '高度匹配' : matchScore >= 70 ? '较匹配' : matchScore >= 50 ? '一般' : '需提升'
|
||||
|
||||
if (targetPosition && pos !== targetPosition) continue
|
||||
matches.push({ position: pos, matchScore, dimGaps, level })
|
||||
}
|
||||
|
||||
matches.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
if (targetPosition && matches.length === 1) {
|
||||
return { targetPosition: matches[0], allPositions: null }
|
||||
}
|
||||
|
||||
return {
|
||||
allPositions: matches.slice(0, 5),
|
||||
topMatch: matches[0],
|
||||
userDimensions: userDims,
|
||||
}
|
||||
}
|
||||
|
||||
@Get('benchmark')
|
||||
async getBenchmark(
|
||||
@CurrentUser('userId') userId: string,
|
||||
@Query('position') position?: string,
|
||||
) {
|
||||
const result = await this.benchmarkService.getUserPercentile(userId, position)
|
||||
if (!result) {
|
||||
return { message: '暂无面试数据,请先完成模拟面试', percentile: null, userScores: null, benchmark: null }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Get('benchmark/all')
|
||||
async getAllBenchmarks() {
|
||||
return this.benchmarkService.getBenchmark()
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
async getStats(@CurrentUser('userId') userId: string) {
|
||||
const progress = await this.progressModel.findOne({ userId }).exec()
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { ProgressController } from './progress.controller'
|
||||
import { BenchmarkService } from './benchmark.service'
|
||||
import { Progress, ProgressSchema } from '../schemas/progress.schema'
|
||||
import { Interview, InterviewSchema } from '../interview/interview.schema'
|
||||
import { User, UserSchema } from '../user/user.schema'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: Progress.name, schema: ProgressSchema },
|
||||
{ name: Interview.name, schema: InterviewSchema },
|
||||
{ name: User.name, schema: UserSchema },
|
||||
]),
|
||||
],
|
||||
controllers: [ProgressController],
|
||||
exports: [],
|
||||
providers: [BenchmarkService],
|
||||
exports: [BenchmarkService],
|
||||
})
|
||||
export class ProgressModule {}
|
||||
Reference in New Issue
Block a user