QuizForge:在不停踩坑后的技术决策复盘

QuizForge:在不停踩坑后的技术决策复盘

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

如果对代码感兴趣的话可以看看 源码仓库: QuizForge

架构概览:

业务流程总览:

graph LR A[题目管理] --> B[创建/编辑/删除] B --> C[软删除 + 审计日志] C --> D[导入导出] D --> E[开始刷题] E --> F[作答 + 计时] F --> G[SM-2 调度更新] G --> H[统计面板] H --> I[间隔复习提醒] I --> J[AI 答案解析] J --> A

技术选型一句话版: 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 失败只是摘除流量不重启。


四、数据一致性保障

讨论事务边界之前,先看核心数据模型和一次完整刷题的时序:

数据模型:

erDiagram QUESTION ||--o| SPACED_REPUTATION : &#34;1:1 调度状态&#34; QUESTION ||--o{ PRACTICE_RECORD : &#34;1:N 刷题记录&#34; QUESTION }o--|| KNOWLEDGE_POINT : &#34;N:1 知识点&#34; QUESTION ||--o{ QUESTION_TAG : &#34;M:N 标签&#34; QUESTION ||--o{ QUESTION_COMPANY : &#34;M:N 公司&#34; PRACTICE_SESSION ||--o{ SESSION_QUESTION : &#34;1:N 会话题目&#34; PRACTICE_SESSION ||--o{ PRACTICE_RECORD : &#34;1:N 刷题记录&#34; SESSION_QUESTION }o--|| QUESTION : &#34;N:1 关联题目&#34; QUESTION_TAG }o--|| TAG : &#34;N:1 标签&#34; QUESTION_COMPANY }o--|| COMPANY : &#34;N:1 公司&#34; QUESTION { int id PK string title string type int difficulty string referenceAnswer int knowledgePointId FK datetime deletedAt } SPACED_REPUTATION { int id PK int questionId FK &#34;unique&#34; float easeFactor &#34;初始2.5&#34; int intervalDays int repetition datetime nextReviewAt } PRACTICE_RECORD { int id PK int questionId FK int sessionId FK string result &#34;correct/wrong/fuzzy&#34; int durationMs } PRACTICE_SESSION { int id PK datetime startedAt datetime endedAt } KNOWLEDGE_POINT { int id PK string name string description } TAG { int id PK string name string color } COMPANY { int id PK string name } AUDIT_LOG { int id PK string entity int entityId string action string changes datetime createdAt } SYSTEM_CONFIG { string key PK string value datetime updatedAt }

刷题提交时序(注意事务边界):

sequenceDiagram participant U as 用户 participant FE as 前端 Vue3 participant BE as 后端 NestJS participant DB as SQLite U->>FE: 点击&#34;开始刷题&#34; FE->>BE: POST /practice/sessions BE->>DB: 创建会话 + 随机选题 DB-->>BE: 返回会话数据 BE-->>FE: 会话 + 题目列表 FE-->>U: 显示第一题 U->>FE: 提交答案 FE->>BE: POST /practice/submit-answer Note over BE,DB: 事务开始 BE->>DB: 插入 PracticeRecord BE->>DB: 更新 SessionQuestion 状态 BE->>DB: 更新 Question 练习时间 Note over BE,DB: 事务结束 BE->>DB: SM-2 更新(事务外,失败可容忍) BE->>DB: 审计日志(事务外,失败可容忍) DB-->>BE: 写入成功 BE-->>FE: 返回结果 FE-->>U: 显示正确/错误 + AI 分析

4.1 问题:刷题记录和会话统计不一致

场景: 用户答完一题,需要同时:

  1. 插入 PracticeRecord(刷题记录)
  2. 更新 SessionQuestion 状态(会话快照)
  3. 更新 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?
}

关系说明: SpacedReputationQuestion 是 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 | ollama
  • ai_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.tagsTag[] 改成了 { 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 辅助的功能集成案例

如果这篇复盘对你有帮助,欢迎点个赞支持一下;文中有任何理解有误或可以改进的地方,也欢迎在评论区指出来,一起交流。

相关推荐
心易行者1 小时前
用html在线运行做数据可视化大屏,5个实战场景从入门到上线
大数据·前端·数据库·人工智能·python
光影少年1 小时前
从输入URL到页面渲染,React/RN 整体加载流程
前端·javascript·react native·react.js·前端框架
兔子零10241 小时前
我给 Pi Coding Agent 做了一个桌面控制台:Pi-Harness
前端·javascript·后端
Kevin Coding1 小时前
鸿蒙 emitter/EventHub 没有 Sticky 粘性事件?手写一个轻量级 EmitterManager 解决
前端·华为·前端框架·移动开发·harmonyos
Rescenix1 小时前
agent前台被批量举报的复盘:频率特征 + uv venv shim 幻影导致的进程双开误判
前端·人工智能
runningshark2 小时前
Lecture: The ‘Why & How‘ Principle: Moving Beyond Simple Statements
开发语言·前端·javascript
公爵爱学习2 小时前
无人机多点导航笔记
java·前端·笔记
是立不是利2 小时前
HTML 的尊严:一种被低估的语言
前端·html
秋天的一阵风2 小时前
🤔首屏Banner压到40KB,LCP还是4秒?原来一直搞错了最大渲染元素
前端·人工智能·面试