从收藏到错题本,我在 React 全栈项目里实现了一套复习系统

我是小ao!在做 AI 面试准备平台「面试克星」的过程中,我发现一个很现实的问题:用户刷完题就忘了。做对的题没留下印象,做错的题也不会再看第二遍。

于是我决定做一套复习系统------收藏和错题本。这篇文章记录我从数据建模到前端交互的完整过程。


一、收藏功能:为什么我不复用答题记录

最初的设计里,我的 AnswerRecord 模型里有一个 isFavorite 字段,想着"用户答过的题,顺手标记收藏就行了"。

但很快发现两个问题:

问题1:没做过的题没法收藏

用户在题库里浏览,看到一道好题想收藏,但他还没做过。这时候没有 AnswerRecord 记录,isFavorite 无处可存。

问题2:收藏和答题是两件不同生命周期的事

  • 答题记录:用户做完题才有
  • 收藏记录:用户可以随时收藏,与做题无关

把它们塞进同一个模型,职责就混了。

于是我新建了一个独立的 Favorite 模型:

typescript 复制代码
const FavoriteSchema = new Schema(
  {
    userId: { type: Types.ObjectId, ref: "User", required: true },
    questionId: { type: Types.ObjectId, ref: "Question", required: true }
  },
  { timestamps: true }
)

// 联合唯一索引:同一个用户对同一道题只能有一条收藏记录
FavoriteSchema.index({ userId: 1, questionId: 1 }, { unique: true })

联合唯一索引是关键。它保证同一个用户不会对同一道题重复收藏,即使前端连点两次也不会出脏数据。


二、收藏接口:toggle 逻辑比 POST + DELETE 更简单

一开始我写的是"POST 收藏、DELETE 取消",但很快发现前端要维护"当前是否已收藏"的状态,再判断调哪个接口,代码很啰嗦。

改成了 toggle 模式 ------ 一个接口搞定:

typescript 复制代码
questionRoutes.post("/:id/favorite", authMiddleware, async (req, res) => {
  const existing = await Favorite.findOne({
    userId: req.user!._id,
    questionId: req.params.id
  })

  if (existing) {
    await Favorite.deleteOne({ _id: existing._id })
    return res.json({ isFavorite: false })
  }

  const favorite = new Favorite({
    userId: req.user!._id,
    questionId: req.params.id
  })
  await favorite.save()
  res.json({ isFavorite: true })
})

前端只需要一个按钮,点击后根据返回的 isFavorite 更新状态。用户不需要知道背后是创建还是删除。

注意 :这里我用了 new Favorite() 而不是 Favorite.create()。原因是 create() 的类型签名和 IFavorite extends Document 不兼容,TypeScript 会报错。new + save() 更宽松,运行时也完全一样。


三、错题本:这次一个模型都不用新建

收藏需要独立模型,但错题不一样------错题天然存在于 AnswerRecord 里。

错题就是 isCorrect: false 的记录:

typescript 复制代码
const wrongAnswers = await AnswerRecord.find({
  userId: req.user!._id,
  isCorrect: false
}).populate("questionId")

.populate("questionId") 会把关联的题目文档一起查出来,前端不用再发第二次请求。

一个意外的红利:答对自动移出错题本

我的提交答案接口用的是 findOneAndUpdate + upsert:

typescript 复制代码
AnswerRecord.findOneAndUpdate(
  { userId, questionId },
  { userAnswer, isCorrect },
  { upsert: true, returnDocument: "after" }
)

这意味着每个用户每道题只有一条记录 。用户重做错题、答对后,isCorrect 从 false 变成 true,这道题自动从错题列表消失。

不需要额外写"移除错题"的接口------这是 findOneAndUpdate 带来的红利。如果我当初用的是 new + save(),每次作答都新增记录,就得写去重逻辑了。

数据模型的选择会在几个月后回报你。


四、前端复盘页:两个卡片,显示错题数量

复盘页的入口在首页。进入后,用户看到两个卡片:

text 复制代码
┌─────────────────┐  ┌─────────────────┐
│  选择题错题      │  │  简答题错题      │
│      12         │  │      5          │
│  道题目待巩固    │  │  道题目待巩固    │
└─────────────────┘  └─────────────────┘

前端进来时,并行请求两个接口,把数量算出来:

typescript 复制代码
const [singleRes, textRes] = await Promise.all([
  axios.get('/api/questions/wrong?type=single', { headers }),
  axios.get('/api/questions/wrong?type=text', { headers })
])
setSingleCount(singleRes.data.count)
setTextCount(textRes.data.count)

注意接口里的 type 参数 。因为题型存在 Question 里,不在 AnswerRecord 里,所以要在 populate 之后才能过滤:

typescript 复制代码
const questions = wrongAnswers
  .filter((wa) => wa.questionId)
  .filter((wa: any) => !type || wa.questionId.type === type)
  .map((wa) => ({
    question: wa.questionId,
    userAnswer: wa.userAnswer
  }))

五、一个细腻的设计:做完才知道"上次错在哪"

我原本想的是,用户重做错题时,一开始就显示"你上次选了xxx"。但后来改了------先做题,提交后再展示上次答案。

理由是:如果一开始就告诉用户错在哪,他会直接选正确答案,失去了反思的意义。做完再展示,才有"原来我当时错在这里"的顿悟感。

但这带来一个技术问题:提交会覆盖旧的 userAnswer。

解决方式:在错题接口里就把 userAnswer 一起返回,前端在内存里保留:

typescript 复制代码
const questions = wrongAnswers.map((wa) => ({
  question: wa.questionId,
  userAnswer: wa.userAnswer  // 提前存
}))

用户提交后,把内存里的 userAnswer 当作"上次答案"展示。后端覆盖数据库没问题,因为旧值已经被前端拿到了。


六、踩坑清单

坑1:路由顺序

/favorites 和 /wrong 必须放在 /:id 前面 。否则 Express 会把 favorites 当成题目 ID,匹配到详情接口,返回 500。

坑2:Types.ObjectId 的类型

req.params.id 是 string,但 IFavorite.questionId 是 Types.ObjectId。直接赋值 TypeScript 会报错。

解决方式:用 new + save() 而不是 create(),因为前者对类型更宽松。

坑3:空对象被当成有效缓存

我在 AI 简历分析里也踩过类似的坑------if (resume.analysis) 对空对象 {} 也生效。判断缓存时一定要检查关键字段是否有值,而不是只检查字段是否存在。


七、总结

收藏和错题是两种不同的数据建模思路:

功能 存储方式 原因
收藏 独立 Favorite 模型 与做题无关,用户可随时收藏
错题 复用 AnswerRecord 天然存在于答题记录里,无需新模型

不是所有功能都需要新模型。 判断标准是:这个数据是否天然依附于现有模型?是的话就复用,不是的话才独立建。

在做这个功能的过程中,我越来越感受到一件事:好的数据模型会替你干活。 findOneAndUpdate + upsert 让"答对自动移出错题本"不需要写一行额外代码;联合唯一索引让"防止重复收藏"不需要在前端做判断。


欢迎在评论区交流你的全栈学习经验~

相关推荐
一木 之林2 小时前
Agent智能体学习总结:从一次调用到模型自主规划、工具调用与 ReAct 循环的项目实战-6/6
前端·人工智能·学习·react.js
SL_staff17 小时前
运维救火实录:钉钉考勤API变更引发工资计算异常,我们用JVS-Logic可视化逻辑引擎实现分钟级热修复
java·mysql·全栈
song50120 小时前
React Native for OpenHarmony 实战:三方库 react-native-parallax-scroll-view 的鸿蒙化适配指南
分布式·react native·react.js·transformer·harmonyos
song5011 天前
React Native for OpenHarmony 实战:三方库 react-native-ratings 的鸿蒙化适配指南
javascript·react native·react.js·分类·harmonyos
码云之上1 天前
Prompt 拦不住 Chatbot 的安全问题:九大风险与分层防护
人工智能·架构·全栈
不是株1 天前
零到全栈(让部署更稳、更简单:systemd、Nginx 反向代理与前后端同源)
全栈
前端双越老师1 天前
程序员 1v1 访谈系列第一期:转行也不会等到失业以后
程序员·agent·全栈
晓得迷路了1 天前
栗子前端技术周刊第 149 期 - Vite+ 1.0、Preact 11、Next.js 16.4...
前端·javascript·react.js
song5011 天前
React Native for OpenHarmony 实战:三方库 react-native-popup-menu 的鸿蒙化适配指南
css·深度学习·react native·react.js·transformer·harmonyos