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
@@ -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(),
+5
View File
@@ -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)
+1 -1
View File
@@ -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)
+11 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
})
})
})
+9 -5
View File
@@ -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) {