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:
@@ -11,3 +11,7 @@ test-*
|
||||
*.pdf
|
||||
nul
|
||||
start-*.sh
|
||||
seed_admin.js
|
||||
coverage/
|
||||
*.swo
|
||||
*.swp
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testRegex": "test/.*\\.e2e-spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"testEnvironment": "node",
|
||||
"setupFiles": ["<rootDir>/test/jest-setup.ts"],
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/src/$1"
|
||||
}
|
||||
}
|
||||
Generated
+64
@@ -42,6 +42,7 @@
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@nestjs/testing": "^10.4.22",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20.10.0",
|
||||
@@ -2247,6 +2248,22 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.34.49",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
|
||||
@@ -8009,6 +8026,53 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"postbuild": "node -e \"const fs=require('fs');if(fs.existsSync('certs')){fs.cpSync('certs','dist/certs',{recursive:true})}\"",
|
||||
"test": "jest --forceExit --detectOpenHandles",
|
||||
"test:watch": "jest --watch --forceExit",
|
||||
"test:cov": "jest --coverage --forceExit"
|
||||
"test:cov": "jest --coverage --forceExit",
|
||||
"test:e2e": "jest --config jest-e2e.json --forceExit --detectOpenHandles",
|
||||
"test:browser": "playwright test"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
@@ -68,6 +70,7 @@
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@nestjs/testing": "^10.4.22",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20.10.0",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './test',
|
||||
testMatch: '*.browser.spec.ts',
|
||||
timeout: 30000,
|
||||
use: {
|
||||
baseURL: 'http://localhost:3006',
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
@@ -51,6 +51,16 @@ export class Progress {
|
||||
|
||||
@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,10 +89,12 @@ export class UserService {
|
||||
if (sent) {
|
||||
return { message: '验证码已发送到邮箱' }
|
||||
}
|
||||
// 邮件发送失败时返回 devCode 方便调试
|
||||
console.log(`[EMAIL] Dev code for ${email}: ${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) {
|
||||
const record = emailCodeStore.get(email)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const BASE = 'http://localhost:3006/api'
|
||||
|
||||
test.describe('Backend API (Playwright)', () => {
|
||||
test('GET /api/user/info returns 401 without token', async ({ request }) => {
|
||||
const res = await request.get(`${BASE}/user/info`)
|
||||
expect(res.status()).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/user/send-code returns 200', async ({ request }) => {
|
||||
const res = await request.post(`${BASE}/user/send-code`, {
|
||||
data: { phone: '13800138000' },
|
||||
})
|
||||
expect(res.status()).toBe(201)
|
||||
const body = await res.json()
|
||||
expect(body.message).toBe('验证码已发送')
|
||||
})
|
||||
|
||||
test('POST /api/payment/create returns 401 without auth', async ({ request }) => {
|
||||
const res = await request.post(`${BASE}/payment/create`, {
|
||||
data: { plan: 'growth' },
|
||||
})
|
||||
expect(res.status()).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/progress/checkin returns 401 without auth', async ({ request }) => {
|
||||
const res = await request.post(`${BASE}/progress/checkin`)
|
||||
expect(res.status()).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/contribution returns 401 without auth', async ({ request }) => {
|
||||
const res = await request.post(`${BASE}/contribution`, {
|
||||
data: { company: 'Test', position: '前端工程师' },
|
||||
})
|
||||
expect(res.status()).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /api/progress requires auth', async ({ request }) => {
|
||||
const res = await request.get(`${BASE}/progress`)
|
||||
expect(res.status()).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /api/admin/check requires auth', async ({ request }) => {
|
||||
const res = await request.get(`${BASE}/admin/check`)
|
||||
expect(res.status()).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import request from 'supertest'
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common'
|
||||
import { getModelToken } from '@nestjs/mongoose'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { WechatPayService } from '../src/modules/payment/wechat-pay.service'
|
||||
import { AppModule } from '../src/app.module'
|
||||
|
||||
function makeChain(execValue: any = null) {
|
||||
const chain: Record<string, jest.Mock> = {}
|
||||
const methods = ['find', 'findOne', 'findById', 'findByIdAndUpdate', 'sort', 'skip', 'limit', 'select', 'lean', 'countDocuments']
|
||||
for (const m of methods) {
|
||||
chain[m] = jest.fn(() => chain)
|
||||
}
|
||||
chain.exec = jest.fn().mockResolvedValue(execValue)
|
||||
chain.save = jest.fn().mockResolvedValue(true)
|
||||
return chain
|
||||
}
|
||||
|
||||
function mockModel(extra: Record<string, any> = {}, defaultExecValue: any = null) {
|
||||
const chain = makeChain(defaultExecValue)
|
||||
return {
|
||||
find: jest.fn(() => chain),
|
||||
findOne: jest.fn(() => chain),
|
||||
findById: jest.fn(() => chain),
|
||||
findByIdAndUpdate: jest.fn(() => chain),
|
||||
create: jest.fn(),
|
||||
countDocuments: jest.fn(() => chain),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
describe('API Integration (e2e)', () => {
|
||||
let app: INestApplication
|
||||
let authToken: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
.overrideProvider(WechatPayService).useValue({
|
||||
nativePay: jest.fn().mockResolvedValue({ codeUrl: 'weixin://pay/test' }),
|
||||
jsapiPay: jest.fn().mockResolvedValue({ paySign: 'test', nonceStr: 'test', package: 'test', timeStamp: '0', signType: 'RSA' }),
|
||||
queryOrder: jest.fn().mockResolvedValue({ trade_state: 'SUCCESS' }),
|
||||
verifyAndDecrypt: jest.fn().mockReturnValue({ out_trade_no: 'TEST', transaction_id: 'wx_test' }),
|
||||
})
|
||||
.overrideProvider(getModelToken('User')).useValue(mockModel({ countDocuments: jest.fn().mockResolvedValue(0) }))
|
||||
.overrideProvider(getModelToken('Interview')).useValue(mockModel({}, []))
|
||||
.overrideProvider(getModelToken('PaymentOrder')).useValue(mockModel({}, null))
|
||||
.overrideProvider(getModelToken('Progress')).useValue(mockModel({}, null))
|
||||
.overrideProvider(getModelToken('CompanyBank')).useValue(mockModel())
|
||||
.overrideProvider(getModelToken('Contribution')).useValue(mockModel())
|
||||
.overrideProvider(getModelToken('SiteConfig')).useValue(mockModel())
|
||||
.overrideProvider(getModelToken('Resume')).useValue(mockModel())
|
||||
.compile()
|
||||
|
||||
app = moduleFixture.createNestApplication()
|
||||
app.setGlobalPrefix('api')
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
|
||||
await app.init()
|
||||
|
||||
const jwtService = app.get(JwtService)
|
||||
authToken = jwtService.sign({ userId: '507f1f77bcf86cd799439011', phone: '13800138000' })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Unauthenticated access', () => {
|
||||
it('GET /api/user/info should return 401', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api/user/info')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('POST /api/user/send-code should be public', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/user/send-code')
|
||||
.send({ phone: '13800138000' })
|
||||
expect(res.status).toBe(201)
|
||||
})
|
||||
|
||||
it('POST /api/payment/create should return 401', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/payment/create')
|
||||
.send({ plan: 'growth' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('GET /api/progress should return 401', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api/progress')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('POST /api/progress/checkin should return 401', async () => {
|
||||
const res = await request(app.getHttpServer()).post('/api/progress/checkin')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authenticated access', () => {
|
||||
const headers = () => ({ Authorization: `Bearer ${authToken}` })
|
||||
|
||||
it('POST /api/payment/create should validate plan', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/payment/create')
|
||||
.set(headers())
|
||||
.send({ plan: 'invalid' })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('POST /api/payment/jsapi should handle missing wxOpenid', async () => {
|
||||
const userModel = app.get(getModelToken('User'))
|
||||
const chain = makeChain({ plan: 'free', wxOpenid: null })
|
||||
userModel.findById.mockReturnValueOnce(chain)
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/payment/jsapi')
|
||||
.set(headers())
|
||||
.send({ plan: 'growth' })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('GET /api/admin/check should check admin status', async () => {
|
||||
const userModel = app.get(getModelToken('User'))
|
||||
const chain = makeChain({ role: 'user' })
|
||||
userModel.findById.mockReturnValueOnce(chain)
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/admin/check')
|
||||
.set(headers())
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.isAdmin).toBe(false)
|
||||
})
|
||||
|
||||
it('POST /api/progress/redeem should validate item', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/progress/redeem')
|
||||
.set(headers())
|
||||
.send({ item: 'nonexistent' })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('GET /api/progress should return default progress for new user', async () => {
|
||||
const progressModel = app.get(getModelToken('Progress'))
|
||||
const nullChain = makeChain(null)
|
||||
progressModel.findOne.mockReturnValueOnce(nullChain)
|
||||
progressModel.create.mockResolvedValue({
|
||||
userId: '507f1f77bcf86cd799439011',
|
||||
totalInterviews: 0,
|
||||
completedInterviews: 0,
|
||||
streak: 0,
|
||||
points: 0,
|
||||
totalCheckins: 0,
|
||||
recentScores: [],
|
||||
streakHistory: [],
|
||||
lastCheckinDate: null,
|
||||
})
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/progress')
|
||||
.set(headers())
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toHaveProperty('totalInterviews', 0)
|
||||
expect(res.body).toHaveProperty('points')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Payment flow', () => {
|
||||
const headers = () => ({ Authorization: `Bearer ${authToken}` })
|
||||
|
||||
it('POST /api/payment/check should verify order ownership', async () => {
|
||||
const orderModel = app.get(getModelToken('PaymentOrder'))
|
||||
const chain = makeChain(null)
|
||||
orderModel.findOne.mockReturnValueOnce(chain)
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/payment/check/ORD123')
|
||||
.set(headers())
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
process.env.JWT_SECRET = 'test-jwt-secret-for-e2e-tests'
|
||||
+114
-84
@@ -1,8 +1,8 @@
|
||||
# 职引项目 · 状态报告 v4.1
|
||||
# 职引项目 · 状态报告 v4.3
|
||||
|
||||
> **项目版本**: v4.2
|
||||
> **更新时间**: 2026-06-09
|
||||
> **项目状态**: 🚀 Phase 0.5 壁垒构建完成 + 全量代码评审修复
|
||||
> **项目版本**: v4.3
|
||||
> **更新时间**: 2026-06-11
|
||||
> **项目状态**: ✅ 代码质量修复 + 全量测试体系搭建完成
|
||||
|
||||
---
|
||||
|
||||
@@ -23,17 +23,16 @@
|
||||
|
||||
| 模块 | 完成度 | 说明 |
|
||||
|------|------|------|
|
||||
| 后端 API | **98%** | 核心 + Phase 0.5 接口全部实现并编译通过 |
|
||||
| 前端页面 | **85%** | 16 个页面全部含真实 API 调用,有真实实现 |
|
||||
| 后端 API | **98%** | 核心 + 护城河 P0-P5 全部实现 |
|
||||
| 前端页面 | **85%** | 16 个页面含真实 API 调用 |
|
||||
| AI 面试模拟 | **95%** | 多轮对话 + 评分 + 报告 + 进度追踪 |
|
||||
| 简历诊断/优化 | **95%** | 完整代码,文件上传 + AI 分析 + 下载 |
|
||||
| 支付系统(微信) | **95%** | API v3 完整对接,含签名/解密/回调/生产密钥 |
|
||||
| 会员系统 | **100%** | 成长版(¥19.9) + 冲刺版(¥49.9)完整实现,含权益扣减 |
|
||||
| 进步轨迹雷达图 | **100%** | 后端维度统计 + 前端雷达图/打卡日历 |
|
||||
| 面经贡献系统 | **100%** | 贡献提交 + 公司题库自动去重/频次统计 |
|
||||
| 每日一题 | **90%** | 读取 + 定时推送(早8点) + 微信订阅消息,缺模板ID配置 |
|
||||
| 微信登录 | **70%** | 后端接口齐,前端待联调真实 appid |
|
||||
| 生产部署 | **50%** | 服务器已购买,域名已配置,微信支付证书已就位,miniprogram-ci 编译上传脚本就绪 |
|
||||
| 简历诊断/优化 | **95%** | 文件上传 + AI 分析 + 下载 |
|
||||
| 支付系统(微信) | **95%** | API v3 完整对接,含真实证书 |
|
||||
| 会员系统 | **100%** | 成长版 + 冲刺版,含权益扣减 |
|
||||
| 护城河 P0-P5 | **100%** | AI 结构化 / 行业基准 / VIP 过期 / 分享卡片 / 打卡积分 / 岗位匹配 |
|
||||
| 测试体系 | **85%** | 43 单元 + 11 e2e + 7 前端 + Playwright 框架 |
|
||||
| 代码质量 | **95%** | console→Logger,as any 类型化,空 catch 检查 |
|
||||
| 安全审计 | **90%** | JWT 硬编码 / 凭据泄漏 / IDOR / NoSQL 注入 全部修复 |
|
||||
| 小程序审核 | **0%** | 类目已备案,未提交审核 |
|
||||
|
||||
---
|
||||
@@ -51,118 +50,149 @@
|
||||
| 使用次数限制 | ✅ | N/A | **完成** |
|
||||
| 连续打卡(进步轨迹) | ✅ | ✅ | **完成** |
|
||||
|
||||
### 3.2 数据飞轮 (Phase 0.5)
|
||||
| 功能 | 后端 | 前端 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 面经贡献 | ✅ | ✅ | **完成** |
|
||||
| 公司-岗位-题库映射 | ✅ | N/A | **完成** |
|
||||
| 脱敏存储 | ✅ | N/A | **完成** |
|
||||
| 题库自动扩充(去重+频次) | ✅ | N/A | **完成** |
|
||||
### 3.2 护城河 P0-P5
|
||||
| 优先级 | 功能 | 后端 | 文件 | 状态 |
|
||||
|--------|------|------|------|------|
|
||||
| **P0** | 公司真题结构化(AI 自动处理) | ✅ | `contribution.controller.ts`, `contribution.schema.ts` | **完成** |
|
||||
| **P1** | 行业基准线 + 用户百分位 | ✅ | `benchmark.service.ts`, `progress.controller.ts` | **完成** |
|
||||
| **P2** | VIP 过期 cron 自动降级 | ✅ | `vip-expiry.service.ts` | **完成** |
|
||||
| **P3** | 分享卡片(canvas 生成) | ✅ | `report.vue` | **完成** |
|
||||
| **P4** | 每日打卡 + 积分兑换 | ✅ | `progress.controller.ts`, `progress.schema.ts` | **完成** |
|
||||
| **P5** | 岗位匹配度预测 | ✅ | `progress.controller.ts` | **完成** |
|
||||
|
||||
### 3.3 留存入围 (Phase 0.5)
|
||||
| 功能 | 后端 | 前端 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 进步轨迹雷达图 | ✅ | ✅ | **完成** |
|
||||
| 历史对比分析 | ✅ | ✅ | **完成** |
|
||||
| 日历打卡视图 | N/A | ✅ | **完成** |
|
||||
| 每日一题推送 | ✅ 定时推送(早8点) | ✅ 首页展示 | **完成**(缺微信模板ID) |
|
||||
|
||||
### 3.4 用户系统
|
||||
### 3.3 用户系统
|
||||
| 功能 | 后端 | 前端 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 手机验证码登录 | ✅ | ✅ | **完成** |
|
||||
| 邮箱验证码登录 | ✅ | ✅ | **完成** |
|
||||
| 密码登录/注册 | ✅ | ✅ | **完成** |
|
||||
| 微信静默登录 | ✅ 有接口 | ✅ 有调用 | ⚠️ 缺真实 appid |
|
||||
| 微信静默登录 | ✅ | ✅ | ⚠️ 缺真实 appid 联调 |
|
||||
| JWT 认证 | ✅ | ✅ | **完成** |
|
||||
| 个人信息设置 | ✅ | ✅ | **完成** |
|
||||
|
||||
### 3.5 商业化
|
||||
### 3.4 商业化
|
||||
| 功能 | 后端 | 前端 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 免费版额度(日2次/5轮) | ✅ | ✅ | **完成** |
|
||||
| 成长版 ¥19.9/月 | ✅ | ✅ | **完成** |
|
||||
| 冲刺版 ¥49.9/月(含权益扣减) | ✅ | ✅ | **完成** |
|
||||
| 每日一题定时推送(微信订阅消息) | ✅ | N/A | **完成**(需配置模板ID) |
|
||||
| 微信支付 Native QR | ✅ | ✅ H5 | **完成** |
|
||||
| 微信支付 JSAPI | ✅ | ✅ MP | **完成** |
|
||||
| 微信支付 Native QR / JSAPI | ✅ | ✅ H5+MP | **完成** |
|
||||
| 支付回调/自动开会员 | ✅ | N/A | **完成** |
|
||||
| 每日一题定时推送 | ✅ | N/A | **完成**(需配置模板ID) |
|
||||
| 会员状态/套餐查询 | ✅ | ✅ | **完成** |
|
||||
|
||||
### 3.6 简历
|
||||
### 3.5 简历
|
||||
| 功能 | 后端 | 前端 | 状态 |
|
||||
|------|------|------|------|
|
||||
| AI 简历诊断 | ✅ | ✅ | **完成** |
|
||||
| AI 简历优化 | ✅ | ✅ | **完成** |
|
||||
| AI 简历诊断/优化 | ✅ | ✅ | **完成** |
|
||||
| 简历 CRUD | ✅ | ✅ | **完成** |
|
||||
| 文件上传(PDF/图片) | ✅ | ✅ | **完成** |
|
||||
| 结果下载(TXT/HTML) | N/A | ✅ | **完成** |
|
||||
|
||||
---
|
||||
|
||||
## 四、后端模块清单
|
||||
## 四、测试体系
|
||||
|
||||
| 层次 | 工具 | 文件 | 测试数 | 状态 |
|
||||
|------|------|------|--------|------|
|
||||
| 后端单元 | Jest | `benchmark.service.spec.ts` | 15 | ✅ |
|
||||
| | | `user.service.spec.ts` | 13 | ✅ |
|
||||
| | | `payment.controller.spec.ts` | 15 | ✅ |
|
||||
| 后端集成 | Supertest+Jest | `app.e2e-spec.ts` | 11 | ✅ |
|
||||
| 前端单元 | Vitest | `config.spec.ts` | 7 | ✅ |
|
||||
| 浏览器自动化 | Playwright | `api.browser.spec.ts` | 7 | ✅ (需后端运行) |
|
||||
| **总计** | | | **61** | ✅ |
|
||||
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
| `npm test` | 43 个单元测试 |
|
||||
| `npm run test:e2e` | 11 个集成测试 |
|
||||
| `npm run test:cov` | 覆盖率报告 |
|
||||
| `npm run test:browser` | Playwright API 测试 |
|
||||
| `cd zhiyin-app && npm test` | 7 个前端测试 |
|
||||
|
||||
---
|
||||
|
||||
## 五、安全修复清单
|
||||
|
||||
| 严重度 | 问题 | 文件 | 修复 |
|
||||
|--------|------|------|------|
|
||||
| 🔴 CRITICAL | JWT 硬编码 fallback (3处) | `jwt.strategy.ts`, `app.module.ts`, `user.module.ts` | 移除 `zhiyin-jwt-secret` 默认值 |
|
||||
| 🟠 HIGH | seed_admin.js 写死 MongoDB 凭据 | `.gitignore` | 加入 ignore 列表 |
|
||||
| 🟡 MEDIUM | 邮箱验证码泄漏 | `user.service.ts:91` | 仅开发环境暴露 devCode |
|
||||
| 🟡 MEDIUM | 支付订单查询 IDOR | `payment.controller.ts:123` | 校验 userId 归属 |
|
||||
| 🟡 MEDIUM | 管理后台 NoSQL 注入 | `admin.controller.ts:61` | 正则特殊字符转义 |
|
||||
| 🟢 LOW | console.log 泄漏敏感信息 (2处) | `user.service.ts:29,91` | 替换为 Logger |
|
||||
|
||||
---
|
||||
|
||||
## 六、代码质量修复
|
||||
|
||||
| 类别 | 文件 | 修复 |
|
||||
|------|------|------|
|
||||
| `console.log`→Logger | `user.service.ts` | 开发版日志改为 `this.logger.log()` |
|
||||
| `as any`→类型化 (11处) | 7 个文件 | Mongoose `_id`/`createdAt` 直接访问;基准类型用 `PositionBenchmark` |
|
||||
| Schema 联合类型 | `progress.schema.ts:63` | `lastCheckinDate?: Date \| null` 加 `@Prop({ type: Date })` |
|
||||
| Module 依赖缺失 | `progress.module.ts` | 缺少 `UserModel` 注入导致启动崩溃 |
|
||||
|
||||
---
|
||||
|
||||
## 七、后端模块清单
|
||||
|
||||
| 模块 | 文件 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `user` | controller + service + schema | ✅ | 手机/邮箱/密码/微信多种登录方式 |
|
||||
| `interview` | controller + service + schema | ✅ | AI 面试核心,含进度追踪调用 |
|
||||
| `ai` | module + service | ✅ | AI 模型调用封装(主/备切换) |
|
||||
| `analyze` | controller + module + service | ✅ | 简历诊断/优化 |
|
||||
| `user` | controller + service + schema | ✅ | 手机/邮箱/密码/微信登录 |
|
||||
| `interview` | controller + service + schema | ✅ | AI 面试核心 |
|
||||
| `ai` | module + service | ✅ | AI 主/备模型切换,TLS 修复 |
|
||||
| `analyze` | controller + module + service | ✅ | 诊断/优化/技能缺口 |
|
||||
| `resume` | controller + service + schema | ✅ | 简历 CRUD |
|
||||
| `member` | controller | ✅ | 会员套餐/状态/冲刺版权益扣减 |
|
||||
| `payment` | controller + service + schema | ✅ | 微信支付 v3 完整对接(生产密钥已配) |
|
||||
| `positions` | controller + schema | ✅ | 热门岗位 CRUD |
|
||||
| `upload` | controller + module | ✅ | 文件上传 |
|
||||
| `member` | controller | ✅ | 会员套餐/权益扣减 |
|
||||
| `payment` | controller + service + schema | ✅ | 微信支付 v3,含证书 |
|
||||
| `progress` | controller + schema + benchmark service | ✅ | 打卡/积分/基准/匹配 |
|
||||
| `contribution` | controller + schema (×2) | ✅ | 面经 + AI 结构化 + 公司题库 |
|
||||
| `schedule` | module + service (×3) | ✅ | VIP 过期 / 每日一题 / 微信 token |
|
||||
| `admin` | controller + module | ✅ | 管理后台 |
|
||||
| `email` | module + service | ✅ | 邮件发送 |
|
||||
| `progress` | controller + schema | ✅ | 进步轨迹四维统计 |
|
||||
| `contribution` | controller + schema (×2) | ✅ | 面经贡献 + 公司题库 |
|
||||
| `daily-question` | controller + schema | ✅ | 读取 + 定时推送 @schedule |
|
||||
| `schedule` | module + service (×2) | ✅ | 每日一题早8点推送 + 微信token管理 |
|
||||
| `upload` | controller + module | ✅ | 文件上传 |
|
||||
|
||||
---
|
||||
|
||||
## 五、前端页面清单
|
||||
## 八、前端页面清单
|
||||
|
||||
| 页面 | 路径 | 类型 | 状态 |
|
||||
|------|------|------|------|
|
||||
| 首页 | index/index | Tab | ✅ 岗位/每日一题/功能入口 |
|
||||
| 登录 | login/login | 页面 | ✅ 5 种登录方式 + 注册 |
|
||||
| 面试模拟 | interview/interview | 页面 | ✅ 多轮对话 + 计时 |
|
||||
| 面试报告 | report/report | 页面 | ✅ 评分/分析/全文回放 |
|
||||
| 历史记录 | history/history | Tab | ✅ 筛选/统计/跳转报告 |
|
||||
| 个人中心 | user/user | Tab | ✅ 用户信息/统计/管理员入口 |
|
||||
| 会员中心 | member/member | 页面 | ✅ 套餐对比 + 支付流程 |
|
||||
| 进步轨迹 | progress/progress | 页面 | ✅ 雷达图 + 打卡日历 |
|
||||
| 面经贡献 | contribute/contribute | 页面 | ✅ 表单提交 |
|
||||
| 简历优化 | resume/resume | 页面 | ✅ 诊断/优化/上传/下载 |
|
||||
| 优化结果 | result/result | 页面 | ✅ 双模式结果展示 |
|
||||
| 实习搜索 | internship/internship | 页面 | ✅ 热门岗位列表 |
|
||||
| 管理后台 | admin/admin | 页面 | ✅ 仪表盘 |
|
||||
| 关于 | about/about | 页面 | ✅ |
|
||||
| 用户协议 | agreement/agreement | 页面 | ✅ |
|
||||
| 隐私政策 | privacy/privacy | 页面 | ✅ |
|
||||
| 页面 | 路径 | 状态 |
|
||||
|------|------|------|
|
||||
| 首页 | index/index | ✅ 岗位/每日一题/功能入口 |
|
||||
| 登录 | login/login | ✅ 5 种登录方式 + 注册 |
|
||||
| 面试模拟 | interview/interview | ✅ 多轮对话 + 计时 |
|
||||
| 面试报告 | report/report | ✅ 评分/分析/全文回放/分享卡片 |
|
||||
| 历史记录 | history/history | ✅ 筛选/统计 |
|
||||
| 个人中心 | user/user | ✅ 信息/统计/管理员入口 |
|
||||
| 会员中心 | member/member | ✅ 套餐对比 + 支付 |
|
||||
| 进步轨迹 | progress/progress | ✅ 雷达图 + 打卡日历 |
|
||||
| 面经贡献 | contribute/contribute | ✅ 表单提交 |
|
||||
| 简历优化 | resume/resume | ✅ 诊断/优化/上传/下载 |
|
||||
| 实习搜索 | internship/internship | ✅ 热门岗位 |
|
||||
| 管理后台 | admin/admin | ✅ 仪表盘 |
|
||||
| 关于/协议/隐私 | about/agreement/privacy | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 六、技术债务
|
||||
## 九、技术债务
|
||||
|
||||
| 问题 | 影响 | 优先级 |
|
||||
|------|------|------|
|
||||
| 微信登录未用真实 appid 联调 | 无法真机测试微信登录 | P0 |
|
||||
| 前端两套 API 调用方式(`uni.request` vs `apiService`) | 代码维护负担 | P2 |
|
||||
| 前端无状态管理(Pinia) + 无组件复用 | 代码重复 | P2 |
|
||||
|------|------|--------|
|
||||
| 微信登录需真实 appid 联调 | 无法真机测试 | P0 |
|
||||
| 前端两套 API 调用方式 | 维护负担 | P2 |
|
||||
| AI 调用无重试机制 | 偶发失败 | P1 |
|
||||
| 无单元测试 | 回归风险 | P2 |
|
||||
| UploadController 含 pdf-parse 原生依赖 | e2e 测试 open handle | P3 |
|
||||
|
||||
---
|
||||
|
||||
## 七、变更记录
|
||||
## 十、变更记录
|
||||
|
||||
| 日期 | 变更内容 | 操作者 |
|
||||
|------|----------|--------|
|
||||
| 2026-06-02 | 项目状态初版,测试 10/10 通过 | AI |
|
||||
| 2026-06-05 | 战略升级:文档重构 + 新增功能启动 | 小之 |
|
||||
| 2026-06-09 | 全面更新:Phase 0.5 功能实际已完成,修正完成度数据与模块清单 | AI |
|
||||
| 2026-06-09 | 更新部署状态:服务器已购,域名 zhiyinwx.yzrcloud.cn / zhiyin.yzrcloud.cn 已配 | 小之 |
|
||||
| 2026-06-09 | v4.2 冲刺版+每日推送+支付修复+全量代码评审 | AI |
|
||||
| 日期 | 版本 | 变更内容 | 操作者 |
|
||||
|------|------|----------|--------|
|
||||
| 2026-06-02 | v1.0 | 项目状态初版 | AI |
|
||||
| 2026-06-05 | v2.0 | 战略升级:文档重构 + 新增功能启动 | 小之 |
|
||||
| 2026-06-09 | v4.2 | 冲刺版+每日推送+支付修复+全量代码评审 | AI |
|
||||
| 2026-06-11 | **v4.3** | **安全修复 5 项 + 代码质量 14 处 + 测试体系 61 项 + 护城河 P0-P5 全部验证** | AI |
|
||||
|
||||
Generated
+2784
-105
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"dev:h5": "uni",
|
||||
"build:h5": "uni build"
|
||||
"build:h5": "uni build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-4060620250520001",
|
||||
@@ -23,9 +25,12 @@
|
||||
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"jsdom": "^29.1.1",
|
||||
"miniprogram-ci": "^2.1.31",
|
||||
"sass": "^1.70.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.2.0"
|
||||
"vite": "^5.2.0",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
describe('APP_CONFIG', () => {
|
||||
it('should have APP_NAME', async () => {
|
||||
const { APP_CONFIG } = await import('./config')
|
||||
expect(APP_CONFIG.APP_NAME).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should have storage keys', async () => {
|
||||
const { APP_CONFIG } = await import('./config')
|
||||
expect(APP_CONFIG.STORAGE_KEYS.TOKEN).toBe('token')
|
||||
expect(APP_CONFIG.STORAGE_KEYS.USER_ID).toBe('userId')
|
||||
})
|
||||
|
||||
it('should have page routes', async () => {
|
||||
const { APP_CONFIG } = await import('./config')
|
||||
expect(APP_CONFIG.PAGES.INDEX).toBe('/pages/index/index')
|
||||
expect(APP_CONFIG.PAGES.LOGIN).toBe('/pages/login/login')
|
||||
expect(APP_CONFIG.PAGES.MEMBER).toBe('/pages/member/member')
|
||||
})
|
||||
})
|
||||
|
||||
describe('API_ENDPOINTS', () => {
|
||||
it('should have all endpoint groups', async () => {
|
||||
const { API_ENDPOINTS } = await import('./config')
|
||||
expect(API_ENDPOINTS.USER).toBeDefined()
|
||||
expect(API_ENDPOINTS.INTERVIEW).toBeDefined()
|
||||
expect(API_ENDPOINTS.PAYMENT).toBeDefined()
|
||||
expect(API_ENDPOINTS.PROGRESS).toBeDefined()
|
||||
expect(API_ENDPOINTS.CONTRIBUTION).toBeDefined()
|
||||
expect(API_ENDPOINTS.MEMBER).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have user endpoints', async () => {
|
||||
const { API_ENDPOINTS } = await import('./config')
|
||||
expect(API_ENDPOINTS.USER.SEND_CODE).toBe('/user/send-code')
|
||||
expect(API_ENDPOINTS.USER.LOGIN).toBe('/user/login')
|
||||
expect(API_ENDPOINTS.USER.INFO).toBe('/user/info')
|
||||
})
|
||||
|
||||
it('should generate dynamic interview endpoints', async () => {
|
||||
const { API_ENDPOINTS } = await import('./config')
|
||||
expect(API_ENDPOINTS.INTERVIEW.ANSWER('abc')).toBe('/interview/abc/answer')
|
||||
expect(API_ENDPOINTS.INTERVIEW.GET('xyz')).toBe('/interview/xyz')
|
||||
})
|
||||
|
||||
it('should generate dynamic payment check endpoint', async () => {
|
||||
const { API_ENDPOINTS } = await import('./config')
|
||||
expect(API_ENDPOINTS.PAYMENT.CHECK('ORD123')).toBe('/payment/check/ORD123')
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,21 @@
|
||||
<text class="info-value">{{ report.questionCount }} 题</text>
|
||||
</view>
|
||||
|
||||
<view class="section" v-if="report.dimensions">
|
||||
<view class="section-title">📊 四维能力评估</view>
|
||||
<view class="dim-grid">
|
||||
<view class="dim-item" v-for="dim in dimList" :key="dim.key">
|
||||
<view class="dim-header">
|
||||
<text class="dim-name">{{ dim.label }}</text>
|
||||
<text class="dim-score">{{ dim.value }}分</text>
|
||||
</view>
|
||||
<view class="dim-bar-bg">
|
||||
<view class="dim-bar-fill" :style="{ width: dim.value + '%', background: dim.color }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section" v-if="report.summary">
|
||||
<view class="section-title">📝 评估总结</view>
|
||||
<text class="summary-text">{{ report.summary }}</text>
|
||||
@@ -40,22 +55,36 @@
|
||||
</view>
|
||||
|
||||
<view class="actions">
|
||||
<button class="btn-primary" @click="retryInterview">再面一次</button>
|
||||
<button class="btn-outline" @click="goHistory">返回记录</button>
|
||||
<button class="btn-primary" @click="generateCard">📸 生成分享卡片</button>
|
||||
<button class="btn-outline" @click="retryInterview">再面一次</button>
|
||||
<button class="btn-ghost" @click="goHistory">返回记录</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="empty-box"><text>暂无报告数据</text></view>
|
||||
|
||||
<!-- Share card canvas (hidden) -->
|
||||
<canvas canvas-id="shareCard" class="hidden-canvas" :style="{ width: cardWidth + 'px', height: cardHeight + 'px' }"></canvas>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { api } from '../../config'
|
||||
|
||||
const loading = ref(true)
|
||||
const report = ref(null)
|
||||
const dimList = ref([])
|
||||
const cardWidth = 600
|
||||
const cardHeight = 900
|
||||
|
||||
const dimDefs = [
|
||||
{ key: 'logic', label: '逻辑思维', color: '#6366F1' },
|
||||
{ key: 'expression', label: '表达能力', color: '#10B981' },
|
||||
{ key: 'professionalism', label: '专业度', color: '#F59E0B' },
|
||||
{ key: 'stability', label: '稳定性', color: '#EF4444' },
|
||||
]
|
||||
|
||||
onLoad(async (options) => {
|
||||
const interviewId = options?.interviewId || ''
|
||||
@@ -65,7 +94,6 @@ onLoad(async (options) => {
|
||||
const token = uni.getStorageSync('token') || ''
|
||||
if (!token) { loading.value = false; return }
|
||||
|
||||
// Get interview details
|
||||
const res = await uni.request({
|
||||
url: api(`/interview/${interviewId}`),
|
||||
method: 'GET',
|
||||
@@ -73,14 +101,45 @@ onLoad(async (options) => {
|
||||
})
|
||||
if (res.statusCode === 200) {
|
||||
const data = res.data
|
||||
const rawSummary = data.summary || ''
|
||||
|
||||
// Try to parse summary JSON for structured display
|
||||
let summaryText = rawSummary
|
||||
let dimensions = null
|
||||
try {
|
||||
const parsed = JSON.parse(rawSummary)
|
||||
dimensions = {
|
||||
logic: parsed['逻辑思维'] || 0,
|
||||
expression: parsed['表达能力'] || 0,
|
||||
professionalism: parsed['专业度'] || 0,
|
||||
stability: parsed['稳定性'] || 0,
|
||||
}
|
||||
const parts = []
|
||||
if (parsed['优点']) parts.push('✅ 优点:' + parsed['优点'].join('、'))
|
||||
if (parsed['不足']) parts.push('⚠️ 不足:' + parsed['不足'].join('、'))
|
||||
if (parsed['建议']) parts.push('💡 建议:' + parsed['建议'].join('、'))
|
||||
summaryText = parts.join('\n')
|
||||
} catch {}
|
||||
|
||||
// Use backend dimensions if available, else parsed
|
||||
const dims = data.dimensions || dimensions
|
||||
if (dims) {
|
||||
dimList.value = dimDefs.map(d => ({
|
||||
...d,
|
||||
value: Math.min(100, Math.max(0, Math.round(dims[d.key] || 0))),
|
||||
}))
|
||||
}
|
||||
|
||||
report.value = {
|
||||
position: data.position || '通用岗位',
|
||||
totalScore: data.totalScore || 0,
|
||||
questionCount: data.questionCount || 0,
|
||||
summary: data.summary || '',
|
||||
summary: summaryText,
|
||||
messages: data.messages || [],
|
||||
dimensions: dims,
|
||||
interviewId,
|
||||
}
|
||||
// Auto-complete if in progress
|
||||
|
||||
if (data.status === 'in_progress') {
|
||||
uni.request({
|
||||
url: api(`/interview/${interviewId}/complete`),
|
||||
@@ -90,6 +149,12 @@ onLoad(async (options) => {
|
||||
if (c.statusCode === 200 && c.data) {
|
||||
report.value.totalScore = c.data.totalScore || report.value.totalScore
|
||||
report.value.summary = c.data.summary || report.value.summary
|
||||
if (c.data.dimensions) {
|
||||
dimList.value = dimDefs.map(d => ({
|
||||
...d,
|
||||
value: Math.min(100, Math.max(0, Math.round(c.data.dimensions[d.key] || 0))),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
@@ -101,10 +166,160 @@ onLoad(async (options) => {
|
||||
const scoreLevel = (s) => { if (s >= 80) return 'good'; if (s >= 60) return 'medium'; return 'poor' }
|
||||
const retryInterview = () => uni.switchTab({ url: '/pages/index/index' })
|
||||
const goHistory = () => uni.switchTab({ url: '/pages/history/history' })
|
||||
|
||||
async function generateCard() {
|
||||
if (!report.value) return
|
||||
uni.showLoading({ title: '生成中...' })
|
||||
await nextTick()
|
||||
|
||||
const ctx = uni.createCanvasContext('shareCard')
|
||||
const w = cardWidth
|
||||
const h = cardHeight
|
||||
const r = dimList.value
|
||||
const total = report.value.totalScore
|
||||
const pos = report.value.position
|
||||
const date = new Date().toLocaleDateString('zh-CN')
|
||||
|
||||
// Background gradient
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, h)
|
||||
gradient.addColorStop(0, '#1E1B4B')
|
||||
gradient.addColorStop(0.5, '#312E81')
|
||||
gradient.addColorStop(1, '#1E1B4B')
|
||||
ctx.setFillStyle(gradient)
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
|
||||
// Border decoration
|
||||
ctx.setStrokeStyle('#4F46E5')
|
||||
ctx.setLineWidth(4)
|
||||
ctx.strokeRect(16, 16, w - 32, h - 32)
|
||||
|
||||
// Title
|
||||
ctx.setFillStyle('#FFFFFF')
|
||||
ctx.setFontSize(36)
|
||||
ctx.setTextAlign('center')
|
||||
ctx.fillText('🎯 模拟面试报告', w / 2, 80)
|
||||
|
||||
// Position
|
||||
ctx.setFontSize(24)
|
||||
ctx.setFillStyle('#A5B4FC')
|
||||
ctx.fillText(pos + ' | ' + date, w / 2, 120)
|
||||
|
||||
// Total score circle
|
||||
const cx = w / 2
|
||||
const cy = 220
|
||||
const radius = 72
|
||||
|
||||
// Glow effect
|
||||
ctx.setStrokeStyle('rgba(99,102,241,0.3)')
|
||||
ctx.setLineWidth(16)
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, radius + 10, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
|
||||
// Circle background
|
||||
ctx.setFillStyle('#2D2A6E')
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Total score text
|
||||
ctx.setFillStyle(total >= 80 ? '#34D399' : total >= 60 ? '#FBBF24' : '#F87171')
|
||||
ctx.setFontSize(52)
|
||||
ctx.setTextAlign('center')
|
||||
ctx.fillText(String(total), cx, cy - 8)
|
||||
|
||||
ctx.setFontSize(16)
|
||||
ctx.setFillStyle('#A5B4FC')
|
||||
ctx.fillText('总分', cx, cy + 34)
|
||||
|
||||
// Dimension bars
|
||||
const barColors = ['#6366F1', '#10B981', '#F59E0B', '#EF4444']
|
||||
const barStartY = 330
|
||||
const barH = 36
|
||||
const barGap = 16
|
||||
const barMaxW = 380
|
||||
const barX = 130
|
||||
|
||||
for (let i = 0; i < r.length; i++) {
|
||||
const d = r[i]
|
||||
const y = barStartY + i * (barH + barGap)
|
||||
const v = d.value
|
||||
|
||||
// Label
|
||||
ctx.setFontSize(20)
|
||||
ctx.setFillStyle('#A5B4FC')
|
||||
ctx.setTextAlign('left')
|
||||
ctx.fillText(d.label, 40, y + 26)
|
||||
|
||||
// Bar background
|
||||
ctx.setFillStyle('rgba(255,255,255,0.08)')
|
||||
ctx.beginPath()
|
||||
ctx.roundRect ? ctx.roundRect(barX, y, barMaxW, barH, barH / 2) : ctx.rect(barX, y, barMaxW, barH)
|
||||
ctx.fill()
|
||||
|
||||
// Bar fill
|
||||
ctx.setFillStyle(barColors[i])
|
||||
ctx.beginPath()
|
||||
const fillW = Math.max(0, (v / 100) * barMaxW)
|
||||
ctx.roundRect ? ctx.roundRect(barX, y, fillW, barH, barH / 2) : ctx.rect(barX, y, fillW, barH)
|
||||
ctx.fill()
|
||||
|
||||
// Score text
|
||||
ctx.setFontSize(20)
|
||||
ctx.setFillStyle('#FFFFFF')
|
||||
ctx.setTextAlign('right')
|
||||
ctx.fillText(v + '分', w - 40, y + 26)
|
||||
}
|
||||
|
||||
// Divider line
|
||||
ctx.setStrokeStyle('rgba(165,180,252,0.3)')
|
||||
ctx.setLineWidth(1)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(40, 600)
|
||||
ctx.lineTo(w - 40, 600)
|
||||
ctx.stroke()
|
||||
|
||||
// Bottom info
|
||||
ctx.setFontSize(20)
|
||||
ctx.setFillStyle('#A5B4FC')
|
||||
ctx.setTextAlign('center')
|
||||
ctx.fillText('「职引」- AI 模拟面试助手', w / 2, 650)
|
||||
ctx.setFontSize(16)
|
||||
ctx.setFillStyle('rgba(165,180,252,0.5)')
|
||||
ctx.fillText('扫码开始你的模拟面试 → 在微信搜索"职引"小程序', w / 2, 690)
|
||||
|
||||
// QR code hint (simulated)
|
||||
ctx.setFillStyle('#FFFFFF')
|
||||
ctx.setFontSize(12)
|
||||
ctx.setTextAlign('center')
|
||||
ctx.fillText('⬛ ⬛ ⬛ ⬛ ⬛', w / 2, 760)
|
||||
ctx.fillText('⬛ ⬛ ⬛ ⬛ ⬛', w / 2, 780)
|
||||
ctx.fillText('⬛ ⬛ ⬛ ⬛ ⬛', w / 2, 800)
|
||||
ctx.fillText('⬛ ⬛ ⬛ ⬛ ⬛', w / 2, 820)
|
||||
ctx.fillText('微信小程序', w / 2, 855)
|
||||
|
||||
ctx.draw(false, async () => {
|
||||
try {
|
||||
const tempRes = await uni.canvasToTempFilePath({ canvasId: 'shareCard' })
|
||||
uni.hideLoading()
|
||||
uni.showActionSheet({
|
||||
itemList: ['保存到相册', '分享给好友'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
uni.saveImageToPhotosAlbum({ filePath: tempRes.tempFilePath })
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '卡片生成失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #F3F4F6; }
|
||||
.page { background: #F3F4F6; min-height: 100vh; }
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--color-gradient-start) 0%, var(--color-gradient-mid) 50%, var(--color-gradient-end) 100%);
|
||||
padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; min-height: 90rpx;
|
||||
@@ -140,8 +355,16 @@ const goHistory = () => uni.switchTab({ url: '/pages/history/history' })
|
||||
.msg-item.user { background: #EEF2FF; border-left: 4rpx solid #818CF8; }
|
||||
.msg-label { font-size: 20rpx; font-weight: 600; color: #4F46E5; margin-bottom: 8rpx; }
|
||||
.msg-content { font-size: 24rpx; color: #111827; line-height: 1.7; white-space: pre-wrap; }
|
||||
.actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
|
||||
.btn-primary { flex: 1; background: linear-gradient(135deg,#4F46E5,#7C3AED); color: #FFFFFF; border-radius: 16rpx; height: 88rpx; line-height: 88rpx; font-size: 28rpx; font-weight: 600; }
|
||||
.btn-outline { flex: 1; background: #FFFFFF; color: #4F46E5; border-radius: 16rpx; height: 88rpx; line-height: 88rpx; font-size: 28rpx; border: 2rpx solid #4F46E5; }
|
||||
.actions { display: flex; flex-direction: column; gap: 16rpx; margin-top: 32rpx; }
|
||||
.btn-primary { background: linear-gradient(135deg,#4F46E5,#7C3AED); color: #FFFFFF; border-radius: 16rpx; height: 88rpx; line-height: 88rpx; font-size: 28rpx; font-weight: 600; }
|
||||
.btn-outline { background: #FFFFFF; color: #4F46E5; border-radius: 16rpx; height: 88rpx; line-height: 88rpx; font-size: 28rpx; border: 2rpx solid #4F46E5; }
|
||||
.btn-ghost { background: transparent; color: #6B7280; border-radius: 16rpx; height: 72rpx; line-height: 72rpx; font-size: 24rpx; }
|
||||
.empty-box { padding: 200rpx 0; text-align: center; color: #9CA3AF; font-size: 28rpx; }
|
||||
.hidden-canvas { position: fixed; left: -9999px; top: -9999px; pointer-events: none; }
|
||||
.dim-grid { display: flex; flex-direction: column; gap: 20rpx; }
|
||||
.dim-header { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
|
||||
.dim-name { font-size: 24rpx; color: #374151; }
|
||||
.dim-score { font-size: 24rpx; color: #4F46E5; font-weight: 600; }
|
||||
.dim-bar-bg { height: 20rpx; background: #F3F4F6; border-radius: 10rpx; overflow: hidden; }
|
||||
.dim-bar-fill { height: 100%; border-radius: 10rpx; transition: width 0.6s ease; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
include: ['src/**/*.{test,spec}.{ts,js}'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user