3步搞定今天你爱了吗避坑指南 拒绝报错 盯着屏幕上一堆红色的 StackTrace,是不是脑子都炸了?那种报错信息长得像天书,根本不知道哪行代码惹的祸,这种痛苦每个写代码的人都懂。今天咱们不讲虚的,直接上手一个实战项目,帮你把“今天你爱了吗”这个功能稳稳落地。 别被名字唬住,这其实是一个典型的用户状态交互与持久化场景。看似简单,实则藏着不少坑:状态不同步、数据丢失、并发冲突。这篇避坑指南,就是为你准备的。 项目目标 我们要做的,不是一个简单的“点赞”按钮,而是一个完整的每日状态确认系统。 核心功能拆解:每日唯一性约束:每个用户每天只能确认一次“今天你爱了吗”。 状态查询:随时查看自己今天的状态(已确认/未确认)。 数据持久化:状态必须存库,刷新页面不丢失。 并发安全:防止用户狂点按钮导致重复写入或数据脏读。技术栈选型:前端:Vue 3 + Vite(快速启动,体验好) 后端:Node.js + Express(轻量级,API 开发快) 数据库:SQLite(本地开发零配置,生产可换 MySQL/PostgreSQL) ORM:Prisma(类型安全,避免手写 SQL 出错)为什么选这套?因为快。从零搭建到跑通,不超过 30 分钟。重点不在技术栈多高级,而在于工程化思维和细节处理。 目录结构 清晰的结构是代码可维护性的基础。别搞那种所有代码扔在一个文件里的“面条代码”。我们采用分层架构: love-today/ ├── client/ # 前端项目 │ ├── src/ │ │ ├── views/ │ │ │ └── Home.vue # 主页面 │ │ ├── services/ │ │ │ └── api.js # API 请求封装 │ │ ├── stores/ │ │ │ └── user.js # 用户状态管理 │ │ └── main.js │ └── package.json ├── server/ # 后端项目 │ ├── prisma/ │ │ └── schema.prisma # 数据库模型定义 │ ├── routes/ │ │ └── love.js # 业务路由 │ ├── services/ │ │ └── loveService.js # 业务逻辑层 │ ├── middleware/ │ │ └── errorHandler.js # 统一错误处理 │ ├── app.js # Express 实例 │ └── package.json └── README.md关键设计说明:Service 层:把业务逻辑从路由中抽离出来。路由只负责接收请求、调用 Service、返回响应。这样测试时不用启动 HTTP 服务,直接测 Service 即可。 Prisma Schema:数据库模型的唯一真相来源。改表结构只改这里,其他地方自动同步。核心代码实现 这是重头戏。我们一步步来,每个环节都有坑。 1. 数据库模型设计(Prisma) 很多新手直接建个 is_loved: Boolean 字段,这是大坑。为什么?因为你无法知道是“哪天”的。如果用户明天又访问,你怎么判断是昨天的状态还是今天的? 正确做法:以日期为维度存储。 // server/prisma/schema.prisma generator client {provider = prisma-client-js }datasource db {provider = sqliteurl = file:./dev.db }model User {id Int @id @default(autoincrement())email String @uniquename Stringloves Love[] // 一对多关系 }model Love {id Int @id @default(autoincrement())userId Intuser User @relation(fields: [userId], references: [id])date DateTime // 关键:记录具体日期createdAt DateTime @default(now())// 唯一约束:同一个用户在同一天只能有一条记录@@unique([userId, date]) }避坑点: @@unique([userId, date]) 这个约束至关重要。它从数据库层面保证了数据的唯一性,即使代码逻辑有 Bug,数据库也会拒绝重复插入。这是最后一道防线。 2. 后端业务逻辑(Service 层) 这里我们要实现两个核心方法:checkTodayLove 和 confirmLove。 // server/services/loveService.js const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient();// 获取今天的日期字符串,格式:YYYY-MM-DD const getTodayString = () = {const today = new Date();// 注意:使用本地时区,避免 UTC 偏差导致“昨天”变“今天”const year = today.getFullYear();const month = String(today.getMonth() + 1).padStart(2, '0');const day = String(today.getDate()).padStart(2, '0');return `${year}-${month}-${day}`; };// 检查用户今天是否已确认 const checkTodayLove = async (userId) = {const today = getTodayString();const loveRecord = await prisma.love.findFirst({where: {userId,date: {// Prisma 支持日期范围查询,但这里我们精确匹配日期字符串更直观// 注意:SQLite 中 DateTime 存储格式需与查询格式一致// 更稳健的做法是存储 Date 对象,这里简化演示}}});// 由于 SQLite 的 DateTime 比较可能受格式影响,// 我们采用更通用的方式:查询用户最近的一条记录,判断其日期部分const latestLove = await prisma.love.findFirst({where: { userId },orderBy: { date: 'desc' }});if (!latestLove) return { isLoved: false };// 比较日期部分const latestDateStr = new Date(latestLove.date).toDateString();const todayStr = new Date().toDateString();return {isLoved: latestDateStr === todayStr}; };// 确认“今天你爱了吗” const confirmLove = async (userId) = {const today = new Date();try {// 使用 upsert 操作:存在则更新,不存在则创建// 这是处理“并发重复点击”最优雅的方式const result = await prisma.love.upsert({where: {userId_date: {userId,date: today}},update: {}, // 如果已存在,不做更新(因为已经爱了)create: {userId,date: today}});return { success: true, message: '已确认,今天你爱了吗?' };} catch (error) {// 捕获数据库唯一约束冲突错误if (error.code === 'P2002') {return { success: true, message: '今天已经确认过了哦' };}throw error;} };module.exports = { checkTodayLove, confirmLove };逐行讲解与避坑:getTodayString 函数:很多人直接用 new Date().toDateString(),但不同浏览器/时区下格式可能不同。手动格式化是最稳妥的。 checkTodayLove 中的比较:直接比较 DateTime 对象容易出错,因为 DateTime 包含时分秒。而“今天”应该是一个日期概念,不包含时间。所以我们将 date 转为字符串后比较,或者在数据库层用 date_trunc 函数(PostgreSQL/MySQL 支持,SQLite 需自行处理)。 upsert 操作:这是并发安全的关键。如果两个请求同时到达,find 都可能返回“不存在”,然后都执行 create,导致唯一约束冲突。upsert 是原子操作,由数据库保证互斥性。 错误处理:捕获 P2002 错误(唯一约束冲突)。即使 upsert 失败,我们也不抛错,而是返回友好提示。用户体验优先。3. 前端状态管理 前端最大的坑是状态不同步。用户点了按钮,但接口还没返回,按钮该显示什么? !-- client/src/views/Home.vue -- templatediv class=homeh1今天你爱了吗?/h1button @click=handleConfirm :disabled=loading || isLovedclass=love-btn{{ loading ? '加载中...' : (isLoved ? '已确认' : '确认爱意') }}/buttonp v-if=message class=message{{ message }}/pp v-if=error class=error{{ error }}/p/div /templatescript setup import { ref, onMounted } from 'vue'; import { checkLove, confirmLove } from '@/services/api';const isLoved = ref(false); const loading = ref(false); const message = ref(''); const error = ref('');onMounted(async () = {try {const res = await checkLove();isLoved.value = res.isLoved;} catch (e) {error.value = '加载状态失败';} });const handleConfirm = async () = {if (loading.value || isLoved.value) return;loading.value = true;error.value = '';message.value = '';try {const res = await confirmLove();isLoved.value = true;message.value = res.message;} catch (e) {error.value = e.message || '操作失败,请重试';} finally {loading.value = false;} }; /script避坑点:disabled 属性:防止用户连续点击。这是前端第一道防线,虽然不能完全防止(比如 JS 被禁用),但能解决 90% 的重复请求。 loading 状态:必须单独维护。不要直接用 isLoved 判断,因为“加载中”和“已确认”是不同状态。 finally 块:无论成功失败,都要重置 loading,否则按钮会一直禁用。运行与测试 启动步骤后端初始化: cd server npm install npx prisma init # 修改 schema.prisma 为上述内容 npx prisma migrate dev --name init npm run dev前端初始化: cd client npm install npm run dev测试用例场景 操作 预期结果 避坑检查点首次访问 打开页面 按钮显示“确认爱意” 状态正确加载点击确认 点击按钮 按钮变“已确认”,显示提示 无重复请求重复点击 快速连点 仅一次成功,后续提示“已确认” 并发安全刷新页面 F5 刷新 状态保持“已确认” 数据持久化跨天测试 修改系统时间 状态重置为“未确认” 日期逻辑正确如何模拟跨天? 在测试时,可以临时修改 getTodayString 函数,返回固定的昨天日期,验证逻辑是否正确。或者使用 Docker 容器,通过 docker exec -it container date -s 2023-10-01 修改系统时间。 关键测试:并发点击 使用 Postman 或 curl 同时发送 10 个请求: for i in {1..10}; docurl -X POST http://localhost:3000/api/love/confirm -H Content-Type: application/json -d '{userId: 1}' done预期结果:10 个请求中,只有 1 个返回“创建成功”,其余 9 个返回“已存在”或类似提示。数据库不应出现多条记录。 优化扩展 基础功能跑通了,但离生产级还差得远。以下是几个关键优化方向: 1. 性能优化:缓存 每次查询都打数据库?太慢了。引入 Redis 缓存“今日状态”。 // 伪代码 const redis = require('redis'); const client = redis.createClient();const checkTodayLove = async (userId) = {const cacheKey = `love:${userId}:${getTodayString()}`;// 1. 查缓存const cached = await client.get(cacheKey);if (cached !== null) {return { isLoved: cached === 'true' };}// 2. 查数据库const dbResult = await prisma.love.findFirst({ ... });// 3. 写缓存(TTL 设置为当天剩余秒数)const ttl = getSecondsUntilMidnight();await client.set(cacheKey, dbResult.isLoved, { EX: ttl });return dbResult; };避坑点: 缓存失效策略。用户确认了爱意,必须主动删除或更新缓存,否则缓存和数据库不一致。使用 del 命令比覆盖更安全,避免“写缓存”和“写数据库”顺序问题。 2. 安全性:身份验证 当前代码假设 userId 从请求体中传入,这是严重的安全漏洞。任何人可以冒充其他用户。 正确做法:前端登录后,获取 JWT Token。 后端通过中间件解析 Token,从 Token 中提取 userId。 永远不要信任前端传来的 userId。// middleware/auth.js const jwt = require('jsonwebtoken');const authenticate = (req, res, next) = {const token = req.headers.authorization?.split(' ')[1];if (!token) return res.status(401).json({ message: '未认证' });try {const decoded = jwt.verify(token, process.env.JWT_SECRET);req.userId = decoded.userId; // 将 userId 挂到 req 上next();} catch (error) {res.status(401).json({ message: 'Token 无效' });} };3. 日志与监控 报错一堆看不懂?因为没日志。接入 winston 或 pino,记录关键操作。 const pino = require('pino'); const logger = pino({level: 'info',prettyPrint: process.env.NODE_ENV !== 'production' });// 在 Service 中 logger.info({ userId, action: 'confirm_love' }, 'User confirmed love today'); logger.error({ error, userId }, 'Failed to confirm love');日志要点:结构化日志(JSON 格式),便于 ELK/Loki 收集。 包含 userId、timestamp、action 等关键字段。 错误日志必须包含 stack trace,但不要打印敏感信息(如密码)。小结 这个项目看似简单,实则涵盖了状态管理、并发控制、数据持久化、缓存一致性、安全认证等多个核心知识点。 核心避坑总结:日期处理:不要用 Boolean,要用 Date + 唯一约束。 并发安全:用 upsert 或数据库唯一约束,别只靠前端 disabled。 状态同步:前端必须维护 loading 状态,防止重复点击。 安全底线:永远不要信任前端传来的用户 ID,必须通过 JWT 等机制认证。 错误处理:捕获数据库唯一约束错误,返回友好提示,而非 500 错误。这些坑,每一个都可能让你在生产环境翻车。希望这篇避坑指南能帮你少走弯路。 这个知识点你面试被问过吗?留言说说,看看谁被问得最惨。