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:
yuzhiran
2026-06-11 10:27:35 +08:00
parent 9276ab9028
commit e6b79ddb21
39 changed files with 4576 additions and 246 deletions
@@ -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 {}