我是小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 让"答对自动移出错题本"不需要写一行额外代码;联合唯一索引让"防止重复收藏"不需要在前端做判断。
欢迎在评论区交流你的全栈学习经验~