QuizForge:在不停踩坑后的技术决策复盘
一个单用户、本地部署的私人刷题系统,覆盖「出题 → 作答 → 记录 → 统计 → 间隔复习」完整闭环。本文是技术决策复盘,适合正在用 NestJS 做个人工具类项目的开发者参考------重点不在怎么写 CRUD,而在每个选型背后的权衡、踩过的坑和事后才想明白的道理。
如果对代码感兴趣的话可以看看 源码仓库: QuizForge
架构概览:

业务流程总览:
技术选型一句话版: NestJS(模块化)+ Prisma(类型安全 ORM)+ SQLite(零运维)+ Vue3(轻量)+ pnpm workspace(前后端共享类型)
一、架构决策与取舍
1.1 为什么选 NestJS 而不是 Express?
问题: 个人项目也要模块化吗?Express 不是更简单?
决策过程:
| 考量 | Express | NestJS |
|---|---|---|
| 学习曲线 | 低 | 中高 |
| 模块边界 | 靠自觉 | 框架强制 |
| 依赖注入 | 手动管理 | 内置 IoC |
| 请求管道 | 中间件链 | 守卫→拦截器→过滤器分层 |
| TypeScript | 需配置 | 原生支持 |
为什么选 NestJS: 这里的核心判断不是"NestJS 比 Express 好",而是"这个项目的生命周期有多长"。一次性脚本用 Express 更快,但一个会持续迭代的工具,模块化的收益会随时间复利增长。
NestJS 的模块化本质是领域驱动的代码组织 ------每个业务模块(questions、practice、stats)天然有边界,新增功能时知道代码该放哪,不会出现"一个 service 三千行"的情况。依赖注入的真正价值不是"测试方便",而是可替换性:将来把 SQLite 换成 PostgreSQL、把本地 AI 换成云端 API,只改 provider 的实现,业务代码不动。
请求管道的分层(守卫→拦截器→过滤器)是另一个关键收益:横切关注点(认证、限流、响应包装、异常处理)各归其位,不会散落在每个 controller 里。
代价: 样板代码多、学习曲线陡。一个简单接口要凑齐 module/controller/service/dto 四个文件。但对长期维护的工具来说,这个前期成本很快就回本了。
1.2 为什么选 Prisma 而不是 TypeORM?
选 Prisma 的核心理由是「Schema 即文档」:schema.prisma 本身就是数据库最清晰的文档,改表结构有版本化迁移文件可追溯,写错字段名编译期就报错。代价是复杂原生 SQL 支持有限------个人题库的数据模型不复杂,这个限制可以接受。
1.3 为什么选 SQLite 而不是 PostgreSQL?
问题: 单用户场景,真的需要一个独立的数据库服务吗?
决策: SQLite。理由:
- 单用户数据量小(几千条题目),完全够用
- 零运维:不需要单独部署数据库服务
- 备份 = 复制文件 :
cp quiz.db backup.db(注意:WAL 模式下需先执行PRAGMA wal_checkpoint;确保数据落盘,或用sqlite3 quiz.db ".backup backup.db"安全备份) - 一个文件就是整个库,调试时直接用 Prisma Studio 可视化查看
代价: 并发写入受限、无原生 JSON、扩展性差。但对个人工具,这些无所谓。
二、鉴权与安全实战
2.1 单用户场景的鉴权策略
项目跑在 localhost 只有自己用,但健康检查探针需要被 Docker 访问。策略是:预留 JWT 认证框架,默认不启用(AuthModule 注释掉),用 @Public() 标记公开接口(如健康检查),业务接口默认需要认证。
typescript
// app.module.ts - AuthModule 被注释掉
// import { AuthModule } from './modules/auth/auth.module';
2.2 实现:守卫 + 装饰器
当前状态: 代码已就绪,默认注释。需要认证时取消 AuthModule 注释即可。
typescript
// 1. @Public() 装饰器:标记公开接口
@Public()
@Get('health')
async check() { ... }
// 2. JwtAuthGuard:读取 isPublic 元数据,跳过认证
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
2.3 踩坑:@Public() 对限流守卫不生效
现象: 健康检查接口 @Public() 了,但还是被 429 Too Many Requests 拦截。
排查: 健康检查探针每 10 秒访问一次 /health,被 ThrottlerGuard 限流了。
根因: @Public() 只对 JwtAuthGuard 生效,ThrottlerGuard 不读 isPublic 元数据------两个守卫各自独立检查。
修复: 重写 CustomThrottlerGuard,在 canActivate 里检查 isPublic:
typescript
@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return Promise.resolve(true); // 公开接口跳过限流
return super.canActivate(context);
}
}
教训: NestJS 的守卫是独立管道,@Public() 不会自动跳过所有守卫。需要在每个需要跳过的守卫里显式检查元数据。
三、健康检查设计
3.1 问题:健康检查返回 200 但数据库已断
现象: 负载均衡器显示服务健康,但实际数据库连接已断开,接口全部 500。
排查: 原来的健康检查代码:
typescript
// 错误示例:异常被 catch 后返回 200
try {
await prisma.$queryRaw`SELECT 1`;
return { status: 'ok' };
} catch (e) {
return { status: 'error', message: e.message }; // 还是 200!
}
根因: 这里的本质问题是"健康检查的信号应该给谁看"。负载均衡器和容器编排器只看 HTTP 状态码------它们不会解析响应 body,也不关心你返回了什么 error message。把故障信息写在 body 里却返回 200,等于把故障藏在了机器看不懂的地方。
更深层的认知是:健康检查验证的是"服务可用",不是"进程活着"。进程在不代表服务能用------数据库断了、连接池耗尽、依赖服务挂了,进程都还活着。所以健康检查必须探测真实依赖,并用机器能理解的协议(状态码)表达结果。
修复: 抛 ServiceUnavailableException 返回 503:
typescript
@Get()
async check() {
try {
await Promise.race([
this.prisma.$queryRaw`SELECT 1`,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 3000),
),
]);
return { status: 'ok', database: 'connected' };
} catch (error) {
throw new ServiceUnavailableException({
status: 'error',
database: 'disconnected',
error: error.message,
});
}
}
3.2 3 秒超时的设计考量
SQLite 慢查询时健康检查会挂死,导致容器反复重启。用 Promise.race + 3 秒超时快速失败------健康检查本身不能成为服务负担。
注意:
Promise.race只是放弃等待,底层 Prisma 查询仍会继续执行直到完成。要彻底根治,需要在 Prisma 层配置query_timeout或使用连接池策略(详见第十二章踩坑 12.3)。
3.3 双探针设计
| 探针 | 路径 | 检查内容 | 失败后果 |
|---|---|---|---|
| liveness | /health/live |
进程存活 | 重启容器 |
| readiness | /health |
数据库连接 | 摘除流量 |
为什么分开: 进程活着不代表服务能用(数据库断了进程还活着)。liveness 失败重启容器,readiness 失败只是摘除流量不重启。
四、数据一致性保障
讨论事务边界之前,先看核心数据模型和一次完整刷题的时序:
数据模型:
刷题提交时序(注意事务边界):
4.1 问题:刷题记录和会话统计不一致
场景: 用户答完一题,需要同时:
- 插入
PracticeRecord(刷题记录) - 更新
SessionQuestion状态(会话快照) - 更新
PracticeSession统计
如果中间某一步失败,数据就不一致了。
修复: 用 Prisma 事务包裹核心写操作:
typescript
// practice.service.ts - submitAnswer
const record = await this.prisma.$transaction(async (tx) => {
// 1. 插入刷题记录
const r = await tx.practiceRecord.create({
data: {
questionId: dto.questionId,
sessionId: dto.sessionId,
result: dto.result,
myAnswer: dto.myAnswer,
durationMs: dto.durationMs,
},
});
// 2. 更新会话快照状态
if (dto.sessionId) {
await tx.sessionQuestion.updateMany({
where: { sessionId: dto.sessionId, questionId: dto.questionId },
data: { status: 'answered', result: dto.result },
});
}
// 3. 更新题目 updatedAt(标记最近练习时间)
await tx.question.update({
where: { id: dto.questionId },
data: { updatedAt: new Date() },
});
return r;
});
// 非核心操作在事务外(失败不影响刷题记录)
await this.spacedReputation.submitReview(dto.questionId, dto.result); // SM-2
await this.audit.log({ entity: 'practice', entityId: dto.questionId, action: 'create', ... }); // 审计日志
为什么 SM-2 和审计日志在事务外? 这是一个典型的一致性 vs 可用性权衡。
把所有操作放事务内,得到强一致性------要么全成功要么全失败。但代价是:SM-2 计算出错(比如算法 bug)会导致刷题记录也写不进去,用户的核心操作被辅助功能阻塞。事务持有时间也会变长,增加锁冲突概率。
放在事务外,得到的是最终一致性------刷题记录一定能写入(核心功能不受影响),SM-2 和审计日志可能短暂不同步,但下次提交时会重新计算,最终收敛。单用户场景下并发极低,这个窗口期的风险可以忽略。
这个决策的本质是:核心业务路径必须短、必须可靠,辅助功能可以容忍延迟和失败。不是所有操作都值得用事务保护。
4.2 事务边界原则
什么时候用事务:
- 多条关联写操作(如上面的刷题记录 + 会话更新)
- 批量操作(批量删除、批量更新)
- 涉及关联表的增删改
什么时候不用事务:
- 单条插入(无关联操作)
- 只读查询
- 日志写入(失败不影响业务)
五、派生数据不落库
5.1 问题:掌握度要不要缓存?

场景: 用户问「这个知识点我掌握得怎么样?」需要统计该知识点下所有题目的刷题记录,实时计算正确率。
决策: 不缓存,实时计算。
这里的核心原则是单一数据源(Single Source of Truth):刷题记录是原始事实,掌握度是从事实派生出来的视图。一旦把派生值落库,就引入了"缓存值和原始值可能不一致"的维护成本------每次新增记录都要记得更新缓存,批量导入要重算,数据修复要同步刷新。
实时计算的代价是查询耗时,但在当前规模(几千题、几万条记录)下,groupBy 是毫秒级的。只有当计算成本超过一致性维护成本时,才值得引入缓存------这个拐点大约在刷题记录 10 万条以上、统计查询超过 500ms 时出现。
这也是个人项目和 SaaS 产品的关键区别:SaaS 必须为十万级用户做缓存优化,个人工具用最简单的方案就够了,过度设计反而增加 bug 面。
typescript
async getMasteryOverview() {
const grouped = await this.prisma.practiceRecord.groupBy({
by: ['result'],
_count: true,
});
// 直接从原始记录算,不落库
const total = grouped.reduce((sum, g) => sum + g._count, 0);
const correct = grouped.find(g => g.result === 'correct')?._count ?? 0;
return { totalRecords: total, accuracy: Math.round((correct / total) * 100) };
}
5.2 什么时候值得缓存?
当数据量达到以下规模时,考虑引入 Redis:
- 刷题记录 > 10 万条
- 统计查询 > 500ms
- 高并发读取(多人使用)
当前规模完全不需要。
六、软删除与审计追溯
6.1 软删除策略
用 deletedAt 字段做软删除,不真删。删除 = 打标记,查询默认排除已删除,恢复 = 清除标记。
typescript
// 删除 = 打标记
async remove(id: number) {
await this.prisma.question.update({
where: { id },
data: { deletedAt: new Date() },
});
}
// 查询默认排除已删除
const where = { deletedAt: null };
// 恢复 = 清除标记
async restore(id: number) {
await this.prisma.question.update({
where: { id },
data: { deletedAt: null },
});
}
6.2 审计日志:谁在什么时候做了什么
扩展: 审计日志不仅记录题目操作,还覆盖标签、知识点、练习记录:
typescript
// AuditService 记录操作
await this.audit.log({
entity: 'question', // question | tag | knowledge_point | practice
entityId: question.id,
action: 'update', // create | update | delete | restore
changes: {
title: { old: '旧标题', new: '新标题' },
tags: { old: [1, 2], new: [1, 2, 3] },
},
});
审计日志表结构:
prisma
model AuditLog {
id Int @id @default(autoincrement())
entity String // question | tag | knowledge_point | practice
entityId Int
action String // create | update | delete | restore | batch_delete | batch_update
changes String? // JSON 变更记录
createdAt DateTime @default(now())
}
审计覆盖范围:
- 题目 CRUD + 批量操作
- 标签增删改
- 知识点增删改
- 练习记录提交(result、sessionId)
查询接口:
GET /audit-logs/recent- 最近 100 条GET /audit-logs/:entity/:entityId- 指定实体历史
价值: 误删后查审计日志找回原始数据;问题追溯时能看到「谁在什么时候改了什么」。
七、SM-2 间隔复习算法
7.1 为什么需要间隔复习
刷完题后不知道什么时候复习,手动管理太容易放弃。用 SM-2 算法自动计算下次复习时间,把"复习节奏"从用户负担变成系统能力。
7.2 SM-2 算法原理
markdown
质量分数 quality ∈ {0,1,2,3,4,5}
- 0-2: 需要重新学习 (wrong/fuzzy)
- 3-5: 基本掌握 (correct)
难度因子 easeFactor (初始 2.5,最小 1.3)
EF' = EF + (0.1 - (5-q) * (0.08 + (5-q) * 0.02))
间隔天数 intervalDays
- rep=0: 1 天
- rep=1: 6 天
- rep>=2: interval × EF
SM-2 的核心假设: 人的记忆遵循遗忘曲线------刚学会的东西忘得快,反复复习过的东西忘得慢。算法的三个参数对应这个假设:
quality(作答质量):你对这道题的掌握程度,0-5 分easeFactor(难度因子):这道题对你来说有多难,越低说明越需要频繁复习intervalDays(间隔天数):下次复习的等待时间,掌握越好间隔越长
quality < 3 时重置 repetition 为 0,不是惩罚,而是承认"这道题还没进入长期记忆"------从头开始建立间隔。easeFactor 最低 1.3,是为了防止难题的间隔无限缩短,导致用户被同一道题反复轰炸。
7.3 实现
typescript
// spaced-reputation.service.ts
private resultToQuality(result: string): number {
switch (result) {
case 'correct': return 5;
case 'fuzzy': return 3;
case 'wrong': return 1;
default: return 0;
}
}
private calculate(easeFactor: number, intervalDays: number, repetition: number, quality: number) {
const newEF = easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
const ef = Math.max(newEF, 1.3); // 最小 1.3
if (quality < 3) {
return { easeFactor: ef, intervalDays: 1, repetition: 0 }; // 未掌握,重置
}
const rep = repetition + 1;
const interval = rep === 1 ? 1 : rep === 2 ? 6 : Math.round(intervalDays * ef);
return { easeFactor: ef, intervalDays: interval, repetition: rep };
}
async submitReview(questionId: number, result: string) {
const quality = this.resultToQuality(result);
const existing = await this.prisma.spacedReputation.findUnique({ where: { questionId } });
const calc = existing
? this.calculate(existing.easeFactor, existing.intervalDays, existing.repetition, quality)
: this.calculate(2.5, 0, 0, quality); // 首次:EF=2.5, rep=0
const nextReview = new Date();
nextReview.setDate(nextReview.getDate() + calc.intervalDays);
const data = {
easeFactor: calc.easeFactor,
intervalDays: calc.intervalDays,
repetition: calc.repetition,
nextReviewAt: nextReview,
lastReviewAt: new Date(),
};
return existing
? this.prisma.spacedReputation.update({ where: { questionId }, data })
: this.prisma.spacedReputation.create({ data: { questionId, ...data } });
}
7.4 数据模型
prisma
model SpacedReputation {
id Int @id @default(autoincrement())
questionId Int @unique // 与 Question 1:1 关系
repetition Int @default(0) // 已复习次数
easeFactor Float @default(2.5) // 难度因子
intervalDays Int @default(0) // 当前间隔天数
nextReviewAt DateTime @default(now()) // 下次复习时间
lastReviewAt DateTime?
}
关系说明: SpacedReputation 与 Question 是 1:1 关系(通过 questionId @unique),不是与 PracticeRecord 1:1。每次用户作答时,更新的是该题目的唯一 SM-2 调度状态------正确率、间隔、难度因子都累积在同一记录上。
7.5 复习队列
GET /practice/review 返回到期复习题目:
typescript
const where = {
nextReviewAt: { lte: new Date() }, // 到期的题目
question: { deletedAt: null },
};
价值: 科学间隔复习,比「每天刷」减少无效重复。用户无需手动管理复习计划。
7.6 事务边界考量
问题: SM-2 更新应该放在刷题事务内还是外?
分析:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 事务内 | 强一致性,一起成功失败 | 事务变长,SM-2 失败导致刷题失败 |
| 事务外 | 刷题不受影响,事务短 | 弱一致性,SM-2 可能不同步 |
决策: 事务外。理由:
- SM-2 是辅助功能,不是核心业务
- 单用户场景,并发低,一致性风险可控
- 失败时下次提交会重新计算,最终一致
typescript
// 核心操作(事务内)
const record = await this.prisma.$transaction(async (tx) => { ... });
// 辅助操作(事务外,失败可容忍)
await this.spacedReputation.submitReview(questionId, result);
八、AI 辅助功能
8.1 多厂商适配的需求
用户可能用 OpenAI、Claude、DeepSeek 或本地 Ollama,需要一个可扩展的适配层,而不是把厂商逻辑写死在业务代码里。
8.2 多厂商适配器模式
typescript
// ai.service.ts
private createAdapter(settings: AiSettings): AiAdapter {
switch (settings.provider) {
case 'openai':
if (!settings.apiKey) throw new BadRequestException('OpenAI 需要 API Key');
return new OpenAiAdapter(settings.apiKey);
case 'claude':
if (!settings.apiKey) throw new BadRequestException('Claude 需要 API Key');
return new ClaudeAdapter(settings.apiKey);
case 'deepseek':
if (!settings.apiKey) throw new BadRequestException('DeepSeek 需要 API Key');
return new DeepSeekAdapter(settings.apiKey);
case 'ollama':
return new OllamaAdapter(settings.baseUrl);
default:
throw new BadRequestException(`不支持的 AI 服务商: ${settings.provider}`);
}
}
适配器接口:
typescript
interface AiAdapter {
chat(messages: AiMessage[], model?: string): Promise<AiCompletionResponse>;
}
8.3 配置存储
AI 设置存储在 SystemConfig 表:
prisma
model SystemConfig {
key String @id
value String
updatedAt DateTime @updatedAt
}
配置项:
ai_provider: openai | claude | deepseek | ollamaai_api_key: API 密钥ai_base_url: 自定义地址(Ollama)ai_model: 模型名称(可选)
8.4 API 接口
| 接口 | 功能 |
|---|---|
GET /ai/settings |
获取当前 AI 配置 |
POST /ai/settings |
更新 AI 配置 |
POST /ai/generate |
AI 智能出题 |
POST /ai/analyze |
AI 答案解析 |
8.5 出题 Prompt 设计
typescript
// ai.service.ts - generateQuestions
const messages: AiMessage[] = [
{
role: 'system',
content: `你是一个面试题生成专家。根据要求生成高质量的面试题。
输出格式为 JSON 数组,每道题包含 title 和 referenceAnswer 字段。
题目类型: ${type}
难度: ${difficulty}/5
知识点: ${knowledgePoint?.name || '通用'}`,
},
{
role: 'user',
content: `请生成 ${count} 道${type}类型的面试题,难度为 ${difficulty}。要求:
1. 题目要具体、有深度
2. 参考答案要详细、包含代码示例(如适用)
3. 适合面试场景`,
},
];
// AI 返回后解析 JSON(处理 markdown 包裹、非 JSON 输出等异常)
const questions = this.parseQuestionsResponse(response.content);
private parseQuestionsResponse(content: string): { title: string; referenceAnswer: string }[] {
try {
const jsonMatch = content.match(/\[[\s\S]*\]/); // 提取 JSON 数组
if (jsonMatch) return JSON.parse(jsonMatch[0]);
return [{ title: content, referenceAnswer: '待补充' }]; // fallback
} catch {
return [{ title: content, referenceAnswer: '待补充' }];
}
}
8.6 答案解析
用户提交答案后,可选调用 AI 分析:
typescript
// ai.service.ts - analyzeAnswer
const messages: AiMessage[] = [
{
role: 'system',
content: `你是一个面试评分专家。分析用户的答案并给出评分和反馈。
输出格式为 JSON,包含 score (0-100), feedback (字符串), suggestions (字符串数组) 字段。`,
},
{
role: 'user',
content: `题目: ${question.title}
参考答案: ${question.referenceAnswer || '无'}
用户答案: ${dto.userAnswer}
请分析用户的答案,给出评分和改进建议。`,
},
];
价值: 不绑定单一厂商,用户可自由选择。Ollama 支持完全离线使用。
九、前后端契约优先
9.1 问题:接口改了,前端不知道
场景: 后端把 question.tags 从 Tag[] 改成了 { items: Tag[], total: number },前端不知道,联调时才发现报错。
决策: 前后端共享类型定义,放 packages/shared:
typescript
// packages/shared/src/types.ts
export interface Question {
id: number;
title: string;
type: QuestionType;
tags?: Tag[];
companies?: Company[];
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
9.2 价值验证
改接口后:
- 后端改了
types.ts→ 前端 TypeScript 编译报错 → 立即发现 - 不再出现「联调时才发现字段名不对」
构建顺序: shared → server → web(依赖链保证类型同步)
9.3 前端 API 层封装
typescript
// apps/web/src/api/request.ts
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
});
// 响应拦截:自动提取 data,错误自动 toast
request.interceptors.response.use(
(res) => res.data.data ?? res.data, // 成功:直接返回数据
(err) => {
const msg = err.response?.data?.message ?? err.message;
window.dispatchEvent(new CustomEvent('toast', { detail: { message: msg, type: 'error' } }));
return Promise.reject(err);
},
);
设计取舍: 用 CustomEvent 触发 toast,不依赖 Vue 组件层级,任何地方都能触发。代价是类型不安全、调试不直观------对个人工具可接受。
9.4 最佳实践:契约优先的并行开发
核心流程:
markdown
1. 先定契约(shared/types.ts)
↓
2. 后端实现 API(server)
前端 Mock 数据(web)
↓ 同时进行
3. 后端完成 → 前端切换到真实 API
↓
4. 联调验证(类型已保证一致性)
为什么这样更好:
| 传统方式 | 契约优先 |
|---|---|
| 后端写完 → 前端等着 | 契约定完 → 前后端同时开工 |
| 联调时才发现字段不对 | TypeScript 编译期就报错 |
| Mock 数据随手写,和后端不一致 | Mock 基于 shared 类型,天然一致 |
| 接口改动全靠口头通知 | 改 types.ts → 前后端同时感知 |
本项目的实践:
typescript
// packages/shared/src/types.ts - 契约定义
export interface Question {
id: number;
title: string;
type: QuestionType;
tags?: Tag[];
companies?: Company[];
}
// 前端 Mock(基于 shared 类型,不会偏移)
export const mockQuestion: Question = {
id: 1,
title: '什么是闭包?',
type: 'concept',
tags: [{ id: 1, name: 'JavaScript', color: '#f7df1e' }],
};
// 后端返回值也遵循同一类型
// 联调时只需切换 baseURL,类型天然匹配
关键点: 契约不只是文档,是可执行的代码。shared/types.ts 既约束后端响应格式,也约束前端 Mock 和消费逻辑------改一处,全链路感知。
十、日志与可观测性
10.1 HTTP 访问日志
LoggerMiddleware 记录所有请求:
json
{
"timestamp": "2026-09-05T10:30:00.000Z",
"level": "INFO",
"method": "GET",
"url": "/api/questions",
"status": 200,
"duration": "45ms",
"ip": "127.0.0.1"
}
日志分级: 4xx → WARN,5xx → ERROR,其余 → INFO
日志落盘: 写入 logs/access.log,JSON 格式便于后续分析
10.2 异常过滤器
typescript
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
let status = 500;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
message = (exception.getResponse() as any).message ?? message;
}
response.status(status).json({ code: status, message, data: null });
}
}
价值: 所有未处理异常统一格式返回,不让错误裸奔到前端。
十一、导入导出:批量操作的错误处理
11.1 批量导入的错误处理
1000 条题目导入时第 500 条格式错误,如果整体失败用户不知道哪条有问题。策略是逐行导入,失败的行记录错误明细(行号、标题、原因)返回给用户,修复后可重新导入。
typescript
async importQuestions(data: any[]): Promise<ImportResult> {
let imported = 0;
let skipped = 0;
const errors: ImportError[] = [];
for (let i = 0; i < data.length; i++) {
try {
await this.prisma.question.create({ data: data[i] });
imported++;
} catch (error) {
skipped++;
errors.push({
index: i,
title: data[i]?.title,
error: error.message,
});
}
}
return { imported, skipped, total: data.length, errors };
}
11.2 返回示例
json
{
"imported": 998,
"skipped": 2,
"total": 1000,
"errors": [
{ "index": 499, "title": "手写防抖", "error": "title 和 type 为必填字段" },
{ "index": 872, "title": null, "error": "Invalid input" }
]
}
价值: 用户能看到「第 500 行、题目标题是 xxx、失败原因是 yyy」,修复后可重新导入。
十二、踩坑实录
12.1 响应拦截器与异常过滤器的执行顺序
现象: 异常被过滤器处理后,拦截器还会二次包装,出现重复结构:
json
{
"code": 0,
"message": "ok",
"data": {
"code": 404,
"message": "题目不存在",
"data": null
}
}
根因: NestJS 请求管道的完整执行顺序:守卫 → 拦截器 (pre) → 管道 → 处理器 → 拦截器 (post) → 异常过滤器。
这个坑的本质是对洋葱模型的理解不到位 。NestJS 的请求管道是洋葱:守卫和拦截器的 pre 逻辑从外到内执行,post 逻辑从内到外返回,异常过滤器在最外层兜底。拦截器如果用 catchError,就等于在洋葱的内层先接住了异常并包装,外层过滤器收到的已经是被包装过的响应------双重包装就是这么来的。
正确的心智模型是:拦截器只管成功路径的变形,异常是过滤器的专属领域。两者职责不重叠,管道就清晰了。
修复: 拦截器只用 map 包成功路径,异常全部交给过滤器:
typescript
// 正确:拦截器只处理成功响应
intercept(context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
map((data) => ({ code: 0, message: 'ok', data })), // 只包成功
// 不要用 catchError,让异常过滤器处理
);
}
12.2 Docker 容器重建后数据丢失
现象: docker compose down && docker compose up 后,数据库空了。
根因: SQLite 文件存在容器内部,容器销毁时数据一起删除。
修复: 只挂载数据库文件,不要挂载整个 prisma 目录(会遮蔽镜像内的 migrations):
yaml
volumes:
- ./data:/app/data # 只挂载数据库文件目录
env
# .env
DATABASE_URL="file:/app/data/dev.db"
12.3 Prisma 查询无超时,慢查询挂死接口
现象: 数据库慢查询时,接口无限等待,连接被占用。
修复: Promise.race + 3 秒超时,快速失败的是调用方(详见第三章健康检查设计):
typescript
await Promise.race([
this.prisma.$queryRaw`SELECT 1`,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
]);
注意: Promise.race 只是放弃等待,底层 Prisma 查询仍会继续执行直到完成。Node.js 是单线程事件循环,不存在"线程释放"。要彻底根治,需要在 Prisma 层配置 query_timeout 或使用连接池策略。
十三、性能考量(量化数据)
13.1 查询性能(1000 道题目实测)
测试环境: Windows 11 / Node 20 / SQLite 3.x / Prisma 5.x / 本地 localhost
测试方法: 每个场景跑 100 次迭代,取平均值和百分位数
| 场景 | 数据量 | avg | P50 | P95 | P99 | 说明 |
|---|---|---|---|---|---|---|
| 题目列表(全量) | 1000 条 | 46ms | 46ms | 51ms | 64ms | include 3 层关联 |
| 题目列表(分页 20 条) | 20 条 | 2.3ms | 2.2ms | 2.8ms | 3.7ms | skip + take |
| 题目详情(单条含关联) | 1 条 | 1.7ms | 1.7ms | 2.2ms | 3.0ms | include tags + companies |
| 关键词搜索(LIKE) | 匹配若干 | 0.76ms | 0.72ms | 1.0ms | 1.3ms | title LIKE |
| 知识点树 | 30 个 | 2.7ms | 2.7ms | 3.2ms | 3.5ms | include questions |
| 统计面板(总览) | 1 条 | 0.57ms | 0.55ms | 0.8ms | 1.2ms | groupBy result |
| 统计面板(知识点掌握) | 30 知识点 | 24ms | 24ms | 25ms | 26ms | 嵌套 include + flatMap |
| 统计面板(趋势 30 天) | 30 天 | 28ms | 28ms | 29ms | 39ms | 日期分组 + 填充 |
| 随机出题(OFFSET 法) | 1 题 | 1.75ms | 1.7ms | 2.3ms | 3.1ms | count + skip |
| 健康检查(SELECT 1) | - | 0.11ms | 0.08ms | 0.2ms | 1.1ms | 最轻量查询 |
关键发现:
- 1000 道题目规模下,分页查询 2.3ms、详情 1.7ms,用户体验流畅
- 全量查询 46ms 可接受,但生产环境应强制分页
getKnowledgeMastery最慢(24ms),因为嵌套 include 3 层 + flatMap 计算- 健康检查 0.08ms,超时保护是兜底而非性能瓶颈
13.2 写入性能(50 次迭代)
| 场景 | avg | P50 | P95 | P99 | 说明 |
|---|---|---|---|---|---|
| 创建题目(含审计) | 12ms | 12ms | 14ms | 20ms | create + auditLog |
| 更新题目(事务 + 审计) | 5.4ms | 5.3ms | 6.5ms | 7.3ms | $transaction |
| 提交答案(事务 + SM-2) | 5.7ms | 5.6ms | 6.8ms | 8.3ms | practiceRecord + update |
关键发现:
- 写入操作均 <20ms,事务开销可忽略
- 审计日志写入增加 ~5ms,对个人工具可接受
- 提交答案事务包含 record + sessionQuestion + question 三个表更新,仍 <10ms
13.3 前端性能
构建产物分析:
| 指标 | 数值 | 说明 |
|---|---|---|
| 总 bundle | 187KB gzip | 含 Vue3 + Pinia + Axios |
| 首屏 JS | 42KB gzip | 路由懒加载分割 |
| CSS | 12KB gzip | 手写样式,无 UI 框架 |
| 图标 | 8KB | 内联 SVG,无图标库 |
首屏加载时间(本地):
| 阶段 | 耗时 | 说明 |
|---|---|---|
| DNS + TCP | <1ms | localhost |
| HTML 加载 | 2ms | 约 2KB |
| JS 解析执行 | 180ms | Vue3 + Router + Pinia 初始化 |
| API 请求 | 2.3ms | 题目列表(分页) |
| DOM 渲染 | 15ms | 首屏 20 条题目 |
| Total | ~200ms | 用户可感知的首屏完成 |
13.4 API 响应时间分布
1000 道题目实测(100 次迭代):
| 接口 | P50 | P95 | P99 | 说明 |
|---|---|---|---|---|
| GET /questions | 2.2ms | 2.8ms | 3.7ms | 分页查询 |
| GET /questions/:id | 1.7ms | 2.2ms | 3.0ms | 含关联数据 |
| POST /questions | 12ms | 14ms | 20ms | 创建 + 审计日志 |
| PATCH /questions/:id | 5.3ms | 6.5ms | 7.3ms | 事务更新 + 审计 |
| POST /practice/sessions | 5.6ms | 6.8ms | 8.3ms | 事务创建会话 |
| GET /stats/mastery | 24ms | 25ms | 26ms | 嵌套查询 + 计算 |
| GET /health | 0.08ms | 0.2ms | 1.1ms | SELECT 1 |
瓶颈分析:
- 最慢接口:
GET /stats/mastery(24ms),因为嵌套 include 3 层 + flatMap 计算 - 写入操作均 <20ms,事务开销可忽略
- 所有接口 <50ms,用户体验流畅
测试脚本位置: apps/server/prisma/perf-test.ts
注意: 以上数据为本地 SQLite 场景(零网络延迟)。生产环境(PostgreSQL + 网络延迟)需重新测量。
十四、SM-2 间隔复习性能测试
测试环境: Windows 11 / Node 20 / SQLite 3.x / Prisma 5.x / 本地 localhost
测试方法: 每个场景跑 100 次迭代,取平均值和百分位数
14.1 SM-2 算法计算性能
| 场景 | 迭代次数 | avg | P50 | P95 | 说明 |
|---|---|---|---|---|---|
| SM-2 计算 (EF + Interval) | 10000 | 0.14μs | 0.10μs | 0.30μs | 纯算法计算,无 IO |
结论: 纯算法计算 <0.1μs,可忽略不计。
14.2 提交复习结果 (submitReview)
| 场景 | avg | P50 | P95 | P99 | 说明 |
|---|---|---|---|---|---|
| 首次创建复习记录 | 0.84ms | 0.77ms | 1.28ms | 2.46ms | create 单条记录 |
| 更新复习记录 (correct) | 4.94ms | 4.56ms | 7.16ms | 16.31ms | upsert + 更新 |
| 更新复习记录 (wrong - 重置) | 4.75ms | 4.72ms | 5.74ms | 6.29ms | 重置间隔 |
关键发现:
- 首次创建 <1ms,更新操作 ~5ms
- 写入性能与普通 PracticeRecord 写入相当(5-6ms)
- 审计日志未计入,实际场景会增加 ~5ms
14.3 获取复习队列 (getReviewQueue)
| 场景 | avg | P50 | P95 | P99 | 说明 |
|---|---|---|---|---|---|
| limit=10, 无筛选 | 1.57ms | 1.51ms | 2.19ms | 2.81ms | 默认分页 |
| limit=50, 无筛选 | 1.70ms | 1.64ms | 2.43ms | 3.14ms | 大分页 |
| limit=10, 按知识点筛选 | 0.96ms | 0.90ms | 1.45ms | 1.72ms | 索引命中 |
关键发现:
- 所有查询 <3ms,用户体验流畅
- 筛选条件命中索引时更快(0.96ms vs 1.57ms)
- limit 从 10 增加到 50,耗时仅增加 8%(1.57ms → 1.70ms)
14.4 SM-2 状态统计 (getStats)
| 场景 | avg | P50 | P95 | P99 | 说明 |
|---|---|---|---|---|---|
| 全局统计 | 5.91ms | 1.21ms | 16.31ms | 31.64ms | 4 个并行 count |
关键发现:
- P50 仅 1.2ms,但 P95 达到 16ms------原因是 SQLite 并发读取时偶发锁等待
- 个人工具场景可接受,高并发场景需考虑缓存
14.5 SM-2 间隔增长模拟
场景: 用户连续答对 10 次
| 次数 | EF | 间隔(天) | 下次复习 |
|---|---|---|---|
| 1 | 2.60 | 1 | 2025-01-02 |
| 2 | 2.70 | 6 | 2025-01-07 |
| 3 | 2.80 | 17 | 2025-01-18 |
| 4 | 2.90 | 49 | 2025-02-19 |
| 5 | 3.00 | 147 | 2025-05-28 |
| 6 | 3.10 | 456 | 2026-04-02 |
| 7 | 3.20 | 1459 | 2028-12-30 |
| 8 | 3.30 | 4815 | 2038-03-09 |
| 9 | 3.40 | 16371 | 2069-10-28 |
| 10 | 3.50 | 57299 | 2181-11-18 |
结论:
- 间隔从 1 天指数增长,第 5 次已达 147 天(约 5 个月)
- EF 稳定在 2.6-3.5 之间,符合 SM-2 算法预期
- 第 10 次间隔超过 150 年------实际使用中不会达到,因为用户会遗忘触发重置
14.6 异常场景测试
| 场景 | avg | P50 | P95 | P99 | 说明 |
|---|---|---|---|---|---|
| 连续答错 (重置间隔) | 5.18ms | 4.75ms | 6.81ms | 23.66ms | 重置为 1 天 |
| EF 下限保护 (质量=0) | <0.01ms | <0.01ms | <0.01ms | 0.06ms | 纯计算 |
关键发现:
- 连续答错时,间隔立即重置为 1 天,EF 降至 1.3 下限
- EF 下限保护有效,不会无限衰减
14.7 SM-2 性能总结
| 操作 | 耗时 | 说明 |
|---|---|---|
| 算法计算 | <0.1μs | 纯 CPU 计算,可忽略 |
| 创建复习记录 | ~1ms | 单条 insert |
| 更新复习记录 | ~5ms | upsert + 更新字段 |
| 查询复习队列 | ~2ms | 分页 + 关联查询 |
| 统计复习状态 | ~6ms | 4 个并行 count |
结论: SM-2 间隔复习模块性能优秀,所有操作 <10ms,用户体验流畅。
测试脚本位置: apps/server/prisma/sm2-test.ts
十五、总结与反思
14.1 关键决策回顾
| 决策 | 选择 | 核心理由 |
|---|---|---|
| 后端框架 | NestJS | 模块化 + 依赖注入,长期维护友好 |
| ORM | Prisma | Schema 即文档,类型安全 |
| 数据库 | SQLite | 零运维,备份=sqlite3 .backup |
| 派生数据 | 实时计算 | 数据量小,避免缓存复杂度 |
| 删除策略 | 软删除 | 可恢复 + 审计追溯 |
| 类型共享 | pnpm workspace | 编译期发现接口不一致 |
| 间隔复习 | SM-2 算法 | 科学复习,减少无效重复 |
| AI 辅助 | 多厂商适配器 | 灵活切换,支持离线 |
14.2 如果重来
- 数据库换 PostgreSQL:JSON 支持更好、扩展性更强
- TypeScript strict mode 从头开始
- 补单元测试 + E2E 测试(当前只有基础测试)
- 接入 Redis 缓存统计数据(如果数据量增长)
- AI 适配器用依赖注入容器管理,而非手动实例化(当前每次 settings 变更需要重新创建适配器)
14.3 适用场景
- 个人工具项目的架构模板
- NestJS + Prisma 全栈实践参考
- Docker 单机部署方案
- 间隔复习 + AI 辅助的功能集成案例
如果这篇复盘对你有帮助,欢迎点个赞支持一下;文中有任何理解有误或可以改进的地方,也欢迎在评论区指出来,一起交流。