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:
@@ -31,7 +31,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
|
||||
MongooseModule.forRoot(MONGODB_URI),
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'zhiyin-jwt-secret-2026',
|
||||
secret: process.env.JWT_SECRET,
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
ThrottlerModule.forRoot([{
|
||||
|
||||
@@ -20,7 +20,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
|
||||
const errorResponse = {
|
||||
code: status,
|
||||
message: typeof message === 'string' ? message : (message as any).message || message,
|
||||
message: typeof message === 'string' ? message : (message as Record<string, unknown>).message || message,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'zhiyin-jwt-secret-2026',
|
||||
secretOrKey: process.env.JWT_SECRET,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -58,10 +58,13 @@ export class AdminController {
|
||||
const admin = await this.userModel.findById(adminUserId).exec()
|
||||
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
|
||||
const filter: any = {}
|
||||
if (keyword) filter.$or = [
|
||||
{ phone: { $regex: keyword, $options: 'i' } },
|
||||
{ nickname: { $regex: keyword, $options: 'i' } },
|
||||
]
|
||||
if (keyword) {
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
filter.$or = [
|
||||
{ phone: { $regex: escaped, $options: 'i' } },
|
||||
{ nickname: { $regex: escaped, $options: 'i' } },
|
||||
]
|
||||
}
|
||||
const skip = (Math.max(1, +page) - 1) * +limit
|
||||
const [users, total] = await Promise.all([
|
||||
this.userModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(+limit).select('-password').lean().exec(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import axios from 'axios'
|
||||
import https from 'https'
|
||||
|
||||
interface AiCallOptions {
|
||||
systemPrompt: string
|
||||
@@ -8,6 +9,8 @@ interface AiCallOptions {
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
const httpAgent = new https.Agent({ rejectUnauthorized: true, keepAlive: true })
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name)
|
||||
@@ -65,6 +68,8 @@ export class AiService {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 60000,
|
||||
httpsAgent: httpAgent,
|
||||
transitional: { clarifyTimeoutError: true },
|
||||
},
|
||||
)
|
||||
return res.data?.choices?.[0]?.message?.content || null
|
||||
|
||||
@@ -5,27 +5,15 @@ import { AnalyzeService } from './analyze.service'
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator'
|
||||
import { UserService } from '../user/user.service'
|
||||
import { BenchmarkService, PositionBenchmark } from '../progress/benchmark.service'
|
||||
import { Progress, ProgressDocument } from '../schemas/progress.schema'
|
||||
|
||||
// 各岗位的四维基准分(基于校招平均水平)
|
||||
const POSITION_BENCHMARKS: Record<string, { logic: number; expression: number; professionalism: number; stability: number }> = {
|
||||
'前端工程师': { logic: 75, expression: 70, professionalism: 72, stability: 68 },
|
||||
'后端工程师': { logic: 80, expression: 65, professionalism: 78, stability: 65 },
|
||||
'AI算法工程师': { logic: 85, expression: 60, professionalism: 85, stability: 60 },
|
||||
'产品经理': { logic: 72, expression: 82, professionalism: 70, stability: 75 },
|
||||
'数据分析师': { logic: 78, expression: 72, professionalism: 75, stability: 70 },
|
||||
'UI/UX设计师': { logic: 65, expression: 80, professionalism: 75, stability: 72 },
|
||||
'运营': { logic: 68, expression: 82, professionalism: 65, stability: 78 },
|
||||
'测试工程师': { logic: 72, expression: 65, professionalism: 75, stability: 70 },
|
||||
}
|
||||
|
||||
const DEFAULT_BENCHMARK = { logic: 70, expression: 70, professionalism: 70, stability: 70 }
|
||||
|
||||
@Controller('analyze')
|
||||
export class AnalyzeController {
|
||||
constructor(
|
||||
private analyzeService: AnalyzeService,
|
||||
private userService: UserService,
|
||||
private benchmarkService: BenchmarkService,
|
||||
@InjectModel(Progress.name) private progressModel: Model<ProgressDocument>,
|
||||
) {}
|
||||
|
||||
@@ -59,9 +47,13 @@ export class AnalyzeController {
|
||||
stability: progress.avgStability || 0,
|
||||
}
|
||||
|
||||
// 使用用户最近一次面试的岗位,或传入的目标岗位
|
||||
const lastPosition = targetPosition || (progress.recentScores.length > 0 ? progress.recentScores[progress.recentScores.length - 1].position : '')
|
||||
const benchmark = POSITION_BENCHMARKS[lastPosition] || DEFAULT_BENCHMARK
|
||||
const bmData = await this.benchmarkService.getBenchmark(lastPosition)
|
||||
const bm = 'p50' in bmData ? (bmData as PositionBenchmark).p50 : { logic: 70, expression: 70, professionalism: 70, stability: 70 }
|
||||
const benchmark = { logic: bm.logic, expression: bm.expression, professionalism: bm.professionalism, stability: bm.stability }
|
||||
|
||||
// Compute percentile rank
|
||||
const percData = await this.benchmarkService.getUserPercentile(userId, lastPosition)
|
||||
|
||||
const gaps: Array<{ dimension: string; currentScore: number; targetScore: number; gap: number; level: string }> = []
|
||||
const dimLabels: Record<string, string> = {
|
||||
@@ -102,6 +94,7 @@ export class AnalyzeController {
|
||||
return {
|
||||
dimensions: userDims,
|
||||
benchmark,
|
||||
percentile: percData?.percentile || null,
|
||||
gaps,
|
||||
suggestions,
|
||||
totalGap,
|
||||
|
||||
@@ -3,11 +3,13 @@ import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AnalyzeController } from './analyze.controller'
|
||||
import { AnalyzeService } from './analyze.service'
|
||||
import { UserModule } from '../user/user.module'
|
||||
import { ProgressModule } from '../progress/progress.module'
|
||||
import { Progress, ProgressSchema } from '../schemas/progress.schema'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UserModule,
|
||||
ProgressModule,
|
||||
MongooseModule.forFeature([{ name: Progress.name, schema: ProgressSchema }]),
|
||||
],
|
||||
controllers: [AnalyzeController],
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { Controller, Post, Get, Body, Param, UseGuards } from '@nestjs/common'
|
||||
import { Controller, Post, Get, Body, Param, UseGuards, Logger } 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 { AiService } from '../ai/ai.service'
|
||||
import { Contribution, ContributionDocument } from '../schemas/contribution.schema'
|
||||
import { CompanyBank, CompanyBankDocument } from '../schemas/company-bank.schema'
|
||||
|
||||
@Controller('contribution')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ContributionController {
|
||||
private readonly logger = new Logger(ContributionController.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(Contribution.name) private contributionModel: Model<ContributionDocument>,
|
||||
@InjectModel(CompanyBank.name) private companyBankModel: Model<CompanyBankDocument>,
|
||||
private readonly aiService: AiService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -39,6 +43,11 @@ export class ContributionController {
|
||||
verified: false,
|
||||
})
|
||||
|
||||
// Async AI processing (non-blocking)
|
||||
this.aiProcessContribution(contribution, body).catch(e => {
|
||||
this.logger.error(`AI processing failed for contribution ${contribution._id}: ${e.message}`)
|
||||
})
|
||||
|
||||
// Update company bank
|
||||
if (body.questions && body.questions.length > 0) {
|
||||
let bank = await this.companyBankModel.findOne({
|
||||
@@ -69,7 +78,6 @@ export class ContributionController {
|
||||
tags: body.tags || [],
|
||||
})
|
||||
} else {
|
||||
// Increment frequency
|
||||
const existing = bank.questions.find(eq => eq.content === q)
|
||||
if (existing) existing.frequency += 1
|
||||
}
|
||||
@@ -79,13 +87,70 @@ export class ContributionController {
|
||||
}
|
||||
|
||||
return {
|
||||
id: (contribution as any)._id.toString(),
|
||||
id: contribution._id.toString(),
|
||||
company: contribution.company,
|
||||
position: contribution.position,
|
||||
message: '感谢你的分享!你的面经将帮助更多同学准备面试',
|
||||
}
|
||||
}
|
||||
|
||||
private async aiProcessContribution(contribution: ContributionDocument, body: any) {
|
||||
const prompt = `你是一个面试题分析专家。请分析以下面经内容,返回 JSON(不要 Markdown 包裹):
|
||||
|
||||
公司: ${body.company}
|
||||
岗位: ${body.position}
|
||||
轮次: ${body.rounds || '未说明'}
|
||||
面试题: ${(body.questions || []).join('\n')}
|
||||
面试经验: ${body.experience || ''}
|
||||
用户标签: ${(body.tags || []).join(', ')}
|
||||
|
||||
请返回严格 JSON:
|
||||
{
|
||||
"structuredQuestions": [
|
||||
{
|
||||
"content": "原题",
|
||||
"type": "技术题|行为题|场景题|算法题|系统设计题|HR题",
|
||||
"difficulty": "easy|medium|hard",
|
||||
"referenceAnswer": "参考答案",
|
||||
"tags": ["标签1", "标签2"]
|
||||
}
|
||||
],
|
||||
"aiSummary": "面试总结,包含难度评估、重点方向、准备建议(50字以内)"
|
||||
}`
|
||||
|
||||
const result = await this.aiService.call({ systemPrompt: prompt, userMessage: '请分析以上面经', maxTokens: 3000 })
|
||||
const parsed = JSON.parse(result.replace(/```json\s*|\s*```/g, '').trim())
|
||||
|
||||
await this.contributionModel.findByIdAndUpdate(contribution._id, {
|
||||
$set: {
|
||||
aiProcessed: true,
|
||||
structuredQuestions: parsed.structuredQuestions || [],
|
||||
aiSummary: parsed.aiSummary || '',
|
||||
},
|
||||
}).exec()
|
||||
|
||||
// Update company bank with structured data
|
||||
if (parsed.structuredQuestions?.length > 0) {
|
||||
const bank = await this.companyBankModel.findOne({
|
||||
company: body.company,
|
||||
position: body.position,
|
||||
}).exec()
|
||||
|
||||
if (bank) {
|
||||
for (const sq of parsed.structuredQuestions) {
|
||||
const existing = bank.questions.find(eq => eq.content === sq.content)
|
||||
if (existing) {
|
||||
existing.type = sq.type || existing.type
|
||||
existing.difficulty = sq.difficulty || existing.difficulty
|
||||
existing.referenceAnswer = sq.referenceAnswer || existing.referenceAnswer
|
||||
if (sq.tags) existing.tags = [...new Set([...existing.tags, ...sq.tags])]
|
||||
}
|
||||
}
|
||||
await bank.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Get('company/:company/position/:position')
|
||||
async getBank(@Param('company') company: string, @Param('position') position: string) {
|
||||
const bank = await this.companyBankModel.findOne({ company, position }).exec()
|
||||
|
||||
@@ -11,7 +11,7 @@ export class Interview {
|
||||
@Prop({ required: true })
|
||||
position: string
|
||||
|
||||
@Prop({ default: 'in_progress' }) // in_progress | completed
|
||||
@Prop({ default: 'in_progress' })
|
||||
status: string
|
||||
|
||||
@Prop({ default: 0 })
|
||||
@@ -34,6 +34,9 @@ export class Interview {
|
||||
|
||||
@Prop({ default: 0 })
|
||||
fillerDensity: number
|
||||
|
||||
readonly createdAt?: Date
|
||||
readonly updatedAt?: Date
|
||||
}
|
||||
|
||||
export const InterviewSchema = SchemaFactory.createForClass(Interview)
|
||||
|
||||
@@ -245,12 +245,27 @@ ${fullConversation}
|
||||
.join('\n')
|
||||
const speechAnalysis = userAnswers ? analyzeSpeech(userAnswers) : null
|
||||
|
||||
// Parse dimensions from summary JSON
|
||||
let dimensions: Record<string, number> | null = null
|
||||
if (interview.summary) {
|
||||
try {
|
||||
const parsed = JSON.parse(interview.summary)
|
||||
dimensions = {
|
||||
logic: parsed['逻辑思维'] || 0,
|
||||
expression: parsed['表达能力'] || 0,
|
||||
professionalism: parsed['专业度'] || 0,
|
||||
stability: parsed['稳定性'] || 0,
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
...interview.toObject(),
|
||||
fillerWords: speechAnalysis?.fillerWords || interview.fillerWords,
|
||||
fillerScore: speechAnalysis?.fillerScore || interview.fillerScore,
|
||||
fillerDensity: speechAnalysis?.fillerDensity || interview.fillerDensity,
|
||||
speechAnalysis,
|
||||
dimensions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +282,7 @@ ${fullConversation}
|
||||
status: i.status,
|
||||
totalScore: i.totalScore,
|
||||
questionCount: i.questionCount,
|
||||
time: (i as any).createdAt,
|
||||
time: i.createdAt,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { getModelToken } from '@nestjs/mongoose'
|
||||
import { PaymentController } from './payment.controller'
|
||||
import { WechatPayService } from './wechat-pay.service'
|
||||
|
||||
describe('PaymentController', () => {
|
||||
let controller: PaymentController
|
||||
let mockUserModel: any
|
||||
let mockOrderModel: any
|
||||
let mockWechatPay: any
|
||||
|
||||
const mockUserId = '507f1f77bcf86cd799439011'
|
||||
|
||||
beforeEach(async () => {
|
||||
mockUserModel = {
|
||||
findById: jest.fn(),
|
||||
findByIdAndUpdate: jest.fn(),
|
||||
create: jest.fn(),
|
||||
}
|
||||
mockOrderModel = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
}
|
||||
mockWechatPay = {
|
||||
nativePay: jest.fn().mockResolvedValue({ codeUrl: 'weixin://pay/abc123' }),
|
||||
jsapiPay: jest.fn().mockResolvedValue({ paySign: 'mock-sign', nonceStr: 'mock-nonce', package: 'prepay_id=mock', timeStamp: '123456', signType: 'RSA' }),
|
||||
queryOrder: jest.fn().mockResolvedValue({ trade_state: 'SUCCESS', transaction_id: 'wx123' }),
|
||||
}
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [PaymentController],
|
||||
providers: [
|
||||
{ provide: getModelToken('User'), useValue: mockUserModel },
|
||||
{ provide: getModelToken('PaymentOrder'), useValue: mockOrderModel },
|
||||
{ provide: WechatPayService, useValue: mockWechatPay },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
controller = module.get<PaymentController>(PaymentController)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
it('should throw for invalid plan', async () => {
|
||||
await expect(controller.create(mockUserId, 'invalid'))
|
||||
.rejects.toThrow('无效套餐')
|
||||
})
|
||||
|
||||
it('should throw if user not found', async () => {
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) })
|
||||
await expect(controller.create(mockUserId, 'growth'))
|
||||
.rejects.toThrow('用户不存在')
|
||||
})
|
||||
|
||||
it('should throw if user already has plan', async () => {
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ plan: 'growth' }) })
|
||||
await expect(controller.create(mockUserId, 'growth'))
|
||||
.rejects.toThrow('已是会员')
|
||||
})
|
||||
|
||||
it('should create a growth order', async () => {
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ plan: 'free', phone: '13800138000' }) })
|
||||
mockOrderModel.create.mockResolvedValue({})
|
||||
|
||||
const result = await controller.create(mockUserId, 'growth')
|
||||
expect(result).toHaveProperty('codeUrl')
|
||||
expect(result).toHaveProperty('outTradeNo')
|
||||
expect(mockWechatPay.nativePay).toHaveBeenCalled()
|
||||
expect(mockOrderModel.create).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should create a sprint order', async () => {
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ plan: 'free', phone: '13800138000' }) })
|
||||
mockOrderModel.create.mockResolvedValue({})
|
||||
|
||||
const result = await controller.create(mockUserId, 'sprint')
|
||||
expect(result).toHaveProperty('codeUrl')
|
||||
expect(mockWechatPay.nativePay).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('jsapi', () => {
|
||||
it('should throw if no wxOpenid', async () => {
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ plan: 'free', wxOpenid: null }) })
|
||||
await expect(controller.jsapi(mockUserId, 'growth'))
|
||||
.rejects.toThrow('未绑定微信')
|
||||
})
|
||||
|
||||
it('should return JSAPI pay params', async () => {
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ plan: 'free', wxOpenid: 'mock-openid', phone: '13800138000' }) })
|
||||
mockOrderModel.create.mockResolvedValue({})
|
||||
|
||||
const result = await controller.jsapi(mockUserId, 'growth')
|
||||
expect(result).toHaveProperty('paySign')
|
||||
expect(result).toHaveProperty('outTradeNo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkOrder', () => {
|
||||
it('should throw if order not found for user', async () => {
|
||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) })
|
||||
await expect(controller.checkOrder('no-such-order', mockUserId))
|
||||
.rejects.toThrow('订单不存在')
|
||||
})
|
||||
|
||||
it('should return order status', async () => {
|
||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'pending', plan: 'growth' }) })
|
||||
const result = await controller.checkOrder('ORD123', mockUserId)
|
||||
expect(result).toEqual({ status: 'pending', plan: 'growth' })
|
||||
expect(mockOrderModel.findOne).toHaveBeenCalledWith({ outTradeNo: 'ORD123', userId: mockUserId })
|
||||
})
|
||||
})
|
||||
|
||||
describe('activate', () => {
|
||||
it('should throw if order not found for user', async () => {
|
||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) })
|
||||
await expect(controller.activate(mockUserId, 'ORD123'))
|
||||
.rejects.toThrow('订单不存在')
|
||||
})
|
||||
|
||||
it('should throw if payment not completed', async () => {
|
||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'pending', plan: 'growth' }) })
|
||||
await expect(controller.activate(mockUserId, 'ORD123'))
|
||||
.rejects.toThrow('支付未完成')
|
||||
})
|
||||
|
||||
it('should activate growth plan', async () => {
|
||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'growth' }) })
|
||||
const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, save: jest.fn().mockResolvedValue(true) }
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
|
||||
|
||||
const result = await controller.activate(mockUserId, 'ORD123')
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.plan).toBe('growth')
|
||||
expect(mockUser.save).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should activate sprint plan', async () => {
|
||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'sprint' }) })
|
||||
const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, save: jest.fn().mockResolvedValue(true) }
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
|
||||
|
||||
const result = await controller.activate(mockUserId, 'ORD123')
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.plan).toBe('sprint')
|
||||
expect(mockUser.sprintRemaining).toBe(10)
|
||||
expect(mockUser.save).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('query', () => {
|
||||
it('should throw if order not owned by user', async () => {
|
||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) })
|
||||
await expect(controller.query('ORD123', mockUserId))
|
||||
.rejects.toThrow('订单不存在')
|
||||
})
|
||||
|
||||
it('should query WeChat for order status', async () => {
|
||||
mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId }) })
|
||||
const result = await controller.query('ORD123', mockUserId)
|
||||
expect(result).toHaveProperty('trade_state', 'SUCCESS')
|
||||
expect(mockWechatPay.queryOrder).toHaveBeenCalledWith('ORD123')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -120,7 +120,9 @@ export class PaymentController {
|
||||
/** 查询订单(微信侧) */
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('query')
|
||||
async query(@Body('outTradeNo') outTradeNo: string) {
|
||||
async query(@Body('outTradeNo') outTradeNo: string, @CurrentUser('userId') userId: string) {
|
||||
const order = await this.orderModel.findOne({ outTradeNo, userId }).exec()
|
||||
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
||||
return this.wechatPay.queryOrder(outTradeNo)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
@@ -16,6 +16,9 @@ export class Resume {
|
||||
|
||||
@Prop({ default: '' })
|
||||
targetPosition: string
|
||||
|
||||
readonly createdAt?: Date
|
||||
readonly updatedAt?: Date
|
||||
}
|
||||
|
||||
export const ResumeSchema = SchemaFactory.createForClass(Resume)
|
||||
|
||||
@@ -18,7 +18,7 @@ export class ResumeService {
|
||||
id: r._id.toString(),
|
||||
title: r.title,
|
||||
targetPosition: r.targetPosition,
|
||||
createdAt: (r as any).createdAt,
|
||||
createdAt: r.createdAt,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { DailyQuestionPushService } from './daily-question-push.service'
|
||||
import { WechatTokenService } from './wechat-token.service'
|
||||
import { VipExpiryService } from './vip-expiry.service'
|
||||
import { DailyQuestion, DailyQuestionSchema } from '../schemas/daily-question.schema'
|
||||
import { User, UserSchema } from '../user/user.schema'
|
||||
|
||||
@@ -12,6 +13,6 @@ import { User, UserSchema } from '../user/user.schema'
|
||||
{ name: User.name, schema: UserSchema },
|
||||
]),
|
||||
],
|
||||
providers: [WechatTokenService, DailyQuestionPushService],
|
||||
providers: [WechatTokenService, DailyQuestionPushService, VipExpiryService],
|
||||
})
|
||||
export class ScheduleModule {}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { Cron, CronExpression } from '@nestjs/schedule'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { User, UserDocument } from '../user/user.schema'
|
||||
|
||||
@Injectable()
|
||||
export class VipExpiryService {
|
||||
private readonly logger = new Logger(VipExpiryService.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_1AM)
|
||||
async handleExpiredVip() {
|
||||
const now = new Date()
|
||||
this.logger.log('Checking expired VIP/sprint memberships...')
|
||||
|
||||
const expired = await this.userModel.find({
|
||||
plan: { $in: ['growth', 'sprint'] },
|
||||
$or: [
|
||||
{ sprintExpireAt: { $lt: now } },
|
||||
{ vipExpireAt: { $lt: now } },
|
||||
],
|
||||
}).exec()
|
||||
|
||||
if (expired.length === 0) {
|
||||
this.logger.log('No expired memberships found')
|
||||
return
|
||||
}
|
||||
|
||||
const ids = expired.map(u => u._id)
|
||||
await this.userModel.updateMany(
|
||||
{ _id: { $in: ids } },
|
||||
{
|
||||
$set: {
|
||||
plan: 'free',
|
||||
remaining: 3,
|
||||
},
|
||||
$unset: {
|
||||
vipExpireAt: '',
|
||||
sprintExpireAt: '',
|
||||
sprintRemaining: '',
|
||||
},
|
||||
},
|
||||
).exec()
|
||||
|
||||
this.logger.log(`Downgraded ${expired.length} expired memberships to free plan`)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,23 @@ export class Contribution {
|
||||
|
||||
@Prop({ default: false })
|
||||
verified: boolean // 是否经过审核
|
||||
|
||||
// === P0: AI 结构化字段 ===
|
||||
@Prop({ default: false })
|
||||
aiProcessed: boolean // AI 是否已处理
|
||||
|
||||
@Prop({ type: [{
|
||||
content: String,
|
||||
type: String, // 技术题/行为题/场景题/算法题
|
||||
difficulty: String, // easy/medium/hard
|
||||
referenceAnswer: String,
|
||||
tags: [String],
|
||||
frequency: Number,
|
||||
}], default: [] })
|
||||
structuredQuestions: any[] // AI 解析后的结构化题目
|
||||
|
||||
@Prop({ default: '' })
|
||||
aiSummary: string // AI 生成的面试总结(关键词、难度评估、准备建议)
|
||||
}
|
||||
|
||||
export const ContributionSchema = SchemaFactory.createForClass(Contribution)
|
||||
@@ -45,12 +45,22 @@ export class Progress {
|
||||
// 打卡记录
|
||||
@Prop({ default: 0 })
|
||||
streak: number // 连续打卡天数
|
||||
|
||||
|
||||
@Prop()
|
||||
lastInterviewDate?: Date
|
||||
|
||||
@Prop({ type: [Date], default: [] })
|
||||
streakHistory: Date[]
|
||||
|
||||
// P4: 积分体系
|
||||
@Prop({ default: 0 })
|
||||
points: number // 累计积分
|
||||
|
||||
@Prop({ default: 0 })
|
||||
totalCheckins: number // 总打卡次数
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
lastCheckinDate?: Date | null // 上次打卡日期
|
||||
}
|
||||
|
||||
export const ProgressSchema = SchemaFactory.createForClass(Progress)
|
||||
@@ -9,7 +9,7 @@ import { User, UserSchema } from './user.schema'
|
||||
imports: [
|
||||
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'zhiyin-jwt-secret-2026',
|
||||
secret: process.env.JWT_SECRET,
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { getModelToken } from '@nestjs/mongoose'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { HttpException } from '@nestjs/common'
|
||||
import { UserService } from './user.service'
|
||||
import { EmailService } from '../email/email.service'
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService
|
||||
let mockUserModel: any
|
||||
let mockJwtService: any
|
||||
let mockEmailService: any
|
||||
|
||||
const mockUser = {
|
||||
_id: '507f1f77bcf86cd799439011',
|
||||
phone: '13800138000',
|
||||
nickname: '测试用户',
|
||||
email: 'test@example.com',
|
||||
plan: 'free',
|
||||
role: 'user',
|
||||
isSystemAdmin: false,
|
||||
remaining: 3,
|
||||
interviewCount: 0,
|
||||
password: null,
|
||||
save: jest.fn().mockResolvedValue(true),
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
mockUserModel = {
|
||||
findOne: jest.fn().mockReturnThis(),
|
||||
findById: jest.fn().mockReturnThis(),
|
||||
findByIdAndUpdate: jest.fn().mockReturnThis(),
|
||||
create: jest.fn().mockResolvedValue(mockUser),
|
||||
exec: jest.fn().mockResolvedValue(null),
|
||||
}
|
||||
mockJwtService = {
|
||||
sign: jest.fn().mockReturnValue('mock-jwt-token'),
|
||||
}
|
||||
mockEmailService = {
|
||||
sendVerificationCode: jest.fn().mockResolvedValue(true),
|
||||
}
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UserService,
|
||||
{ provide: getModelToken('User'), useValue: mockUserModel },
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: EmailService, useValue: mockEmailService },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
service = module.get<UserService>(UserService)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('sendCode', () => {
|
||||
it('should return success message', async () => {
|
||||
const result = await service.sendCode('13800138000')
|
||||
expect(result).toEqual({ message: '验证码已发送' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('loginByPhone', () => {
|
||||
it('should throw on wrong code', async () => {
|
||||
await expect(service.loginByPhone('13800138000', 'wrong'))
|
||||
.rejects.toThrow(HttpException)
|
||||
})
|
||||
|
||||
it('should create user on first login and return token', async () => {
|
||||
mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) })
|
||||
await service.sendCode('13800138000')
|
||||
const result = await service.loginByPhone('13800138000', '123456')
|
||||
expect(result).toHaveProperty('token', 'mock-jwt-token')
|
||||
expect(result.user).toHaveProperty('id')
|
||||
expect(mockUserModel.create).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should login existing user', async () => {
|
||||
mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
|
||||
await service.sendCode('13800138000')
|
||||
const result = await service.loginByPhone('13800138000', '123456')
|
||||
expect(result).toHaveProperty('token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendEmailCode', () => {
|
||||
it('should reject invalid email', async () => {
|
||||
await expect(service.sendEmailCode('invalid'))
|
||||
.rejects.toThrow(HttpException)
|
||||
})
|
||||
|
||||
it('should send email verification code', async () => {
|
||||
const result = await service.sendEmailCode('test@example.com')
|
||||
expect(result).toEqual({ message: '验证码已发送到邮箱' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('loginByEmail', () => {
|
||||
it('should throw on wrong code', async () => {
|
||||
await expect(service.loginByEmail('test@example.com', 'wrong'))
|
||||
.rejects.toThrow(HttpException)
|
||||
})
|
||||
|
||||
it('should login with valid email code', async () => {
|
||||
mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
|
||||
|
||||
const spy = jest.spyOn(mockEmailService, 'sendVerificationCode')
|
||||
await service.sendEmailCode('test@example.com')
|
||||
const storedCode = spy.mock.calls[0][1] as string
|
||||
|
||||
const result = await service.loginByEmail('test@example.com', storedCode)
|
||||
expect(result).toHaveProperty('token')
|
||||
expect(result.isNew).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loginByPassword', () => {
|
||||
it('should throw for nonexistent user', async () => {
|
||||
mockUserModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) })
|
||||
await expect(service.loginByPassword('test@example.com', 'pass'))
|
||||
.rejects.toThrow(HttpException)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInfo', () => {
|
||||
it('should return user info', async () => {
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) })
|
||||
const result = await service.getInfo('507f1f77bcf86cd799439011')
|
||||
expect(result).toHaveProperty('id', mockUser._id)
|
||||
expect(result).toHaveProperty('phone', mockUser.phone)
|
||||
})
|
||||
|
||||
it('should throw for nonexistent user', async () => {
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) })
|
||||
await expect(service.getInfo('nonexistent')).rejects.toThrow(HttpException)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deductRemaining', () => {
|
||||
it('should decrement remaining count', async () => {
|
||||
const user = { ...mockUser, remaining: 3, interviewCount: 0, save: jest.fn().mockResolvedValue(true) }
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(user) })
|
||||
await service.deductRemaining('507f1f77bcf86cd799439011')
|
||||
expect(user.remaining).toBe(2)
|
||||
expect(user.interviewCount).toBe(1)
|
||||
expect(user.save).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw when no remaining', async () => {
|
||||
const user = { ...mockUser, remaining: 0, save: jest.fn() }
|
||||
mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(user) })
|
||||
await expect(service.deductRemaining('507f1f77bcf86cd799439011')).rejects.toThrow(HttpException)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as bcrypt from 'bcrypt'
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
@@ -12,6 +12,8 @@ const emailCodeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private readonly logger = new Logger(UserService.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
private jwtService: JwtService,
|
||||
@@ -26,7 +28,7 @@ export class UserService {
|
||||
codeStore.set(phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`[DEV] Verification code for ${phone}: ${code}`)
|
||||
this.logger.log(`Verification code for ${phone}: ${code}`)
|
||||
}
|
||||
return { message: '验证码已发送' }
|
||||
}
|
||||
@@ -87,9 +89,11 @@ export class UserService {
|
||||
if (sent) {
|
||||
return { message: '验证码已发送到邮箱' }
|
||||
}
|
||||
// 邮件发送失败时返回 devCode 方便调试
|
||||
console.log(`[EMAIL] Dev code for ${email}: ${code}`)
|
||||
return { message: '验证码已发送(邮件服务暂不可用,开发模式)', devCode: code }
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
this.logger.log(`Email code for ${email}: ${code}`)
|
||||
return { message: '验证码已发送(邮件服务暂不可用,开发模式)', devCode: code }
|
||||
}
|
||||
return { message: '验证码已发送,请查收邮件' }
|
||||
}
|
||||
|
||||
async loginByEmail(email: string, code: string) {
|
||||
|
||||
Reference in New Issue
Block a user