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 = "sqlite"url = "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 -->
<template><div class="home"><h1>今天你爱了吗?</h1><button @click="handleConfirm" :disabled="loading || isLoved"class="love-btn">{{ loading ? '加载中...' : (isLoved ? '已确认' : '确认爱意') }}</button><p v-if="message" class="message">{{ message }}</p><p v-if="error" class="error">{{ error }}</p></div>
</template><script 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 错误。
这些坑,每一个都可能让你在生产环境翻车。希望这篇避坑指南能帮你少走弯路。
这个知识点你面试被问过吗?留言说说,看看谁被问得最惨。