ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零搭建后端:数据库建模与REST API实操全记录

从零搭建后端:数据库建模与REST API实操全记录 这是「30天全栈开发挑战」系列的第三天。前两天的内容分别完成了环境准备和一个极简前端页面很多朋友私信问我“第三天到底干什么”今天的答案很明确把数据库表建明白把接口写能跑起来。文章会围绕一个演示项目——团队任务管理工具——展开从数据库建模到 REST API 实操全程记录思路、SQL、代码和踩坑过程适合正在学全栈开发、想自己动手从零搭一套后端服务的读者参考。直接开始。1. 今天的目标从零到能跑数据库与接口一起做1.1 前两天做了什么为什么第三天是这个节奏很多自学全栈的人最容易犯的一个毛病就是一上来就写代码写完前端才发现没有数据补后端时又发现表结构设计得一塌糊涂最后来回返工。前两天我把环境先立起来选型也固定了Node.js Express MySQL前端暂时不管先把服务端这条线走通。第三天正好是关键的转折点因为数据库和接口是后端能力的“地基骨架”地基打不好后面写业务全是坑。第三天的任务不是“了解概念”而是交付可运行的东西。你不需要把数据库原理背下来也不用学完整个 HTTP 协议。今天只围绕一个目标有一个建好的数据库、几张合理设计的表、一组能增删改查的 REST API。任务管理工具只需要做到这一步就已经具备一个后端 demo 的雏形了。1.2 今日产出清单先定交付标准再动手动手之前先列清楚今天要交付什么避免做到一半迷失方向。我的清单如下建库脚本和建表 SQL能重复执行且不报错。Express 项目骨架能启动服务并响应请求。用户注册、用户登录两个基础接口。任务的创建、列表查询、更新、删除四个接口。用 curl 或 Postman 完整跑通一遍注册、登录、增删改查流程。这几条都完成今天的任务才算真正结束。很多初学者喜欢把“学了什么”当作成果但工程实践的判断标准只有一个跑起来没有。所以我建议你也按这个交付清单来约束自己别把时间花在看教程、收藏文章上打开终端动起来。2. 数据建模先想清楚字段再张嘴写 SQL2.1 业务对象梳理用户、任务、项目、评论在写任何 SQL 之前先把业务场景里有哪些“东西”列出来。这个演示项目是一个团队任务管理工具核心对象至少包括四类用户、任务、项目、评论。它们之间的关系也很清晰一个用户属于多个项目一个任务归属于某个项目评论挂在任务下面。这里有一个初学者容易忽略的点表设计不是凭空想象的而是从业务动作反推的。你想让用户做什么注册登录、建任务、改状态、留评论。这样反推下来表结构就自然浮现了。Day 3 先不追求一步到位不要一开始就把权限、消息通知、操作日志全部设计进去那些属于后续迭代今天先把主干跑通。2.2 用户表与任务表的关键设计细节先看用户表。核心字段包括 id、email、password_hash、nickname、status、created_at、updated_at。几个细节值得展开说明。第一密码绝对不能明文保存。我会用 bcryptjs 对密码做加盐哈希生成类似$2a$10$...的字符串存入数据库。这样即使数据库泄露密码也不会直接暴露。第二email 要加唯一索引因为它是登录凭证不能重复。第三status 字段用 tinyint0 表示禁用、1 表示正常不要用字符串维护状态整型更省空间且便于扩展。任务表稍微复杂一点。字段包括 id、project_id、creator_id、assignee_id、title、description、status、priority、sort_order、deleted_at、created_at、updated_at。其中 status 使用枚举值字符串还是整型我的建议是整型并约定 0待办、1进行中、2完成这样后续做筛选和统计都很方便。任务表里有两个字段特别容易忽略sort_order 和 deleted_at。sort_order 是排序权重比如用户可以通过拖拽调整任务顺序这个字段就存的是顺序值初始可以给 100两个任务交换时改一下数值就行。deleted_at 是软删除标记默认 NULL删除任务时写入当前时间而不是物理删除行未来做数据恢复和审计日志时非常有用。2.3 索引设计先别迷信“所有字段都加索引”索引是数据库性能的核心但很多新手喜欢给每个字段都加索引结果写入变慢、占用空间变大。正确的做法是“从查询条件反推索引”。你未来会怎么查任务大概率是通过 project_id 查某项目下的所有任务、通过 assignee_id 查某人负责的任务、通过 status 过滤状态。所以要在 project_id、assignee_id、status 这三列上建立索引。更高效的方式是建立组合索引。比如查询场景“某项目下所有待办任务”可以用(project_id, status)组合索引避免回表查询。组合索引有一个通用原则最左前缀。也就是说查询条件里必须以组合索引的第一列开头索引才会生效。所以设计的时候要把区分度高、查询稳定的字段放在前面我这里把 project_id 放在前面完全符合查询习惯。2.4 建表 SQL 实操字符集和引擎别用默认值一套到底创建数据库时我建议显式声明字符集。MySQL 8 默认字符集已经比较友好但为了项目中文字符完全不乱码直接用utf8mb4是最稳妥的。utf8mb4和utf8的区别是前者支持完整的 Unicode包括 emoji 和一些特殊符号你永远不知道用户会在任务描述里输入什么字符所以无脑选utf8mb4就好。引擎方面选 InnoDB它有事务支持数据一致性更好。下面是实际的建表脚本可以直接在 MySQL 里执行CREATE DATABASE IF NOT EXISTS task_manager DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE task_manager; CREATE TABLE IF NOT EXISTS users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, email VARCHAR(128) NOT NULL, password_hash VARCHAR(255) NOT NULL, nickname VARCHAR(32) DEFAULT , status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_email (email) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE IF NOT EXISTS tasks ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, project_id BIGINT UNSIGNED NOT NULL, creator_id BIGINT UNSIGNED NOT NULL, assignee_id BIGINT UNSIGNED DEFAULT NULL, title VARCHAR(128) NOT NULL, description TEXT, status TINYINT NOT NULL DEFAULT 0, priority TINYINT NOT NULL DEFAULT 1, sort_order INT NOT NULL DEFAULT 100, deleted_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_project_status (project_id, status), KEY idx_assignee (assignee_id), CONSTRAINT fk_creator FOREIGN KEY (creator_id) REFERENCES users(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意updated_at使用了ON UPDATE CURRENT_TIMESTAMP这样每次更新行时 MySQL 会自动维护修改时间。外键我没有全部加是因为示例里 project 表还没建Day 4 会补齐先留一个 creator 外键做演示。实际项目里外键加不加、什么时候加要根据团队规范来不能一概而论。3. REST API把数据库变成别人能用的服务3.1 接口设计约定先统一返回结构和错误码很多后端新手写接口时很随意每个接口返回格式都不一样导致前端联调时一脸懵。我给自己定了一个规范所有接口统一返回{ code, message, data }。code 为 0 表示成功非 0 表示业务错误。data 是业务数据可以是对象也可以是数组。错误码也要提前约定比如 1001 表示参数错误、1002 表示未登录、1003 表示资源不存在。这样前端判断逻辑时只需要检查 code不用到处解析异常结构。接口路径采用/api/v1前缀方便未来 API 版本升级。REST 风格里资源用复数名词操作方式用 HTTP 方法表达。比如POST /api/v1/users/register注册POST /api/v1/users/login登录GET /api/v1/tasks任务列表POST /api/v1/tasks创建任务PATCH /api/v1/tasks/:id更新任务DELETE /api/v1/tasks/:id删除任务这种设计的好处是见名知意前端和后端不需要反复翻文档就能对齐。Day 3 可以暂时不引入完整的权限校验但 JWT 令牌可以顺手带上给后续功能留个口子。3.2 项目初始化和依赖安装五条命令搭好骨架后端工程化没有想象中复杂Express 项目从零初始化只需要几条命令。我先把 package.json 初始化出来然后安装今天需要的依赖mkdir task-manager-api cd task-manager-api npm init -y npm install express mysql2 dotenv bcryptjs jsonwebtoken cors逐一解释这些依赖的作用。express 负责路由和中间件mysql2 是 MySQL 的驱动支持 Promise 写法比老旧的 mysql 库好用很多dotenv 用来加载.env配置文件把数据库密码等敏感信息放在代码仓库之外bcryptjs 用于密码加盐哈希jsonwebtoken 负责签发和验证 JWTcors 解决跨域问题方便后续前端页面调用接口。项目结构上我习惯把路由、数据库连接、控制器分开但不做过度的分层。Day 3 的文件组织如下task-manager-api/ ├── .env ├── package.json ├── src/ │ ├── index.js │ ├── db.js │ ├── routes/ │ │ ├── users.js │ │ └── tasks.js │ └── middleware/ │ └── auth.js.env文件内容如下注意不要提交到 Git 仓库PORT3000 DB_HOST127.0.0.1 DB_PORT3306 DB_USERroot DB_PASSWORDyourpassword DB_NAMEtask_manager JWT_SECRETdev_secret_key_change_me3.3 连接池和启动入口服务先跑起来数据库连接不能每次请求都新建要使用连接池。mysql2 提供了现成的createPool请求时从池子里拿连接用完归还避免频繁握手造成性能浪费。数据库连接配置尽量从环境变量读取不要把密码硬编码在代码里。// src/db.js require(dotenv).config(); const mysql require(mysql2); const pool mysql.createPool({ host: process.env.DB_HOST, port: process.env.DB_PORT, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0, namedPlaceholders: true, }); module.exports pool.promise();入口文件里先初始化中间件再挂载路由。这里要注意中间件的顺序cors 和 express.json 要放在路由之前不然请求体解析不了。// src/index.js require(dotenv).config(); const express require(express); const cors require(cors); const usersRouter require(./routes/users); const tasksRouter require(./routes/tasks); const app express(); app.use(cors()); app.use(express.json()); app.use(/api/v1/users, usersRouter); app.use(/api/v1/tasks, tasksRouter); app.get(/health, (req, res) { res.json({ code: 0, message: ok, data: null }); }); app.listen(process.env.PORT, () { console.log(API server is running at http://localhost:${process.env.PORT}); });启动服务后先访问/health探活能返回 JSON 说明基础环境没问题。这一步很多新手容易卡住遇到listen EADDRINUSE就说明端口被占了换个端口或者关掉占用进程就好。3.4 注册登录接口密码加密和 JWT 签发的一次完整实践用户模块是今天最容易出效果的模块。注册接口的逻辑是接收邮箱、密码、昵称先检查参数是否完整再查一下邮箱是否已存在如果不存在则用 bcryptjs 生成密码哈希最后插入数据库。注意 bcryptjs 的hashSync是同步方法示例阶段用起来没问题高并发场景可以换成异步hash方法。// src/routes/users.js const express require(express); const bcrypt require(bcryptjs); const jwt require(jsonwebtoken); const db require(../db); const router express.Router(); router.post(/register, async (req, res) { const { email, password, nickname } req.body || {}; if (!email || !password) { return res.status(400).json({ code: 1001, message: 邮箱和密码不能为空, data: null }); } const [exists] await db.query( SELECT id FROM users WHERE email ? LIMIT 1, [email] ); if (exists.length 0) { return res.status(400).json({ code: 1002, message: 邮箱已被注册, data: null }); } const hashed await bcrypt.hash(password, 10); const [result] await db.query( INSERT INTO users (email, password_hash, nickname) VALUES (?, ?, ?), [email, hashed, nickname || ] ); res.json({ code: 0, message: 注册成功, data: { id: result.insertId } }); });登录接口稍微变一下先根据邮箱查用户然后用bcrypt.compare比对密码匹配成功后签发 JWT返回给客户端。签发的 token 里可以放用户 id 和邮箱过期时间先设置 7 天实际项目再根据安全规范调整。router.post(/login, async (req, res) { const { email, password } req.body || {}; if (!email || !password) { return res.status(400).json({ code: 1001, message: 邮箱和密码不能为空, data: null }); } const [users] await db.query( SELECT id, email, password_hash, nickname, status FROM users WHERE email ? LIMIT 1, [email] ); if (users.length 0) { return res.status(400).json({ code: 1003, message: 用户不存在, data: null }); } const user users[0]; if (user.status ! 1) { return res.status(403).json({ code: 1004, message: 账号已被禁用, data: null }); } const matched await bcrypt.compare(password, user.password_hash); if (!matched) { return res.status(400).json({ code: 1005, message: 密码错误, data: null }); } const token jwt.sign( { id: user.id, email: user.email }, process.env.JWT_SECRET, { expiresIn: 7d } ); res.json({ code: 0, message: 登录成功, data: { token, user: { id: user.id, email: user.email, nickname: user.nickname } } }); });bcrypt 的第二个参数是盐的轮数我设 10这是安全性和性能的平衡点。设太低容易被暴力破解设太高每个请求都要几十毫秒影响接口响应。注册时同邮箱并发提交的问题严格一点应该捕获唯一索引冲突然后在 catch 里返回“邮箱已被注册”示例代码为了易读没有写全实际项目这一层最好补上。3.5 任务接口实现列表分页、创建、更新、软删除任务列表接口是今天比较复杂的接口因为要支持分页和筛选。前端会传 page、pageSize、status、priority、assigneeId 这些参数查询时需要动态构造 SQL。为了防止 SQL 注入参数必须全部使用占位符不能拼接字符串。// src/routes/tasks.js const express require(express); const db require(../db); const router express.Router(); router.get(/, async (req, res) { const page Math.max(parseInt(req.query.page) || 1, 1); const pageSize Math.min(Math.max(parseInt(req.query.pageSize) || 20, 1), 100); const { status, assigneeId } req.query; const where [deleted_at IS NULL]; const params []; if (status ! undefined) { where.push(status ?); params.push(parseInt(status)); } if (assigneeId ! undefined) { where.push(assignee_id ?); params.push(parseInt(assigneeId)); } const whereSql where.join( AND ); const [totalRows] await db.query( SELECT COUNT(*) AS total FROM tasks WHERE ${whereSql}, params ); const offset (page - 1) * pageSize; const [rows] await db.query( SELECT id, project_id, creator_id, assignee_id, title, description, status, priority, sort_order, created_at, updated_at FROM tasks WHERE ${whereSql} ORDER BY sort_order ASC, id DESC LIMIT ? OFFSET ?, [...params, pageSize, offset] ); res.json({ code: 0, message: ok, data: { list: rows, total: totalRows[0].total, page, pageSize, }, }); });分页参数有两个细节需要强调。第一page 必须大于等于 1pageSize 要设置上限否则用户传一个pageSize999999就能把整库拉走。第二LIMIT ? OFFSET ?中的占位符在 mysql2 里会被当作字符串处理有时会导致类型报错解决办法是用parseInt把 pageSize 和 offset 显式转成数字或者写进 SQL 前先做数值处理。这一段代码我选择直接转数字比较简单可靠。创建任务的逻辑相对简单字段校验后直接 INSERT。这里我加了一个小细节如果前端没有传 creator_id则从 JWT 中解析。Day 3 阶段还没完整接入鉴权中间件但创建任务接口里读取一下请求头里的Authorization字段也是给后续权限功能做一个铺垫。router.post(/, async (req, res) { const { projectId, assigneeId, title, description, priority } req.body || {}; if (!projectId || !title) { return res.status(400).json({ code: 1001, message: 项目 ID 和任务标题不能为空, data: null }); } const [result] await db.query( INSERT INTO tasks (project_id, creator_id, assignee_id, title, description, priority) VALUES (?, ?, ?, ?, ?, ?), [projectId, 1, assigneeId || null, title, description || null, priority || 1] ); res.json({ code: 0, message: 创建成功, data: { id: result.insertId } }); });更新任务用 PATCH涉及 “部分更新” 的场景调用方只需要传那些要改的字段。在 SQL 层面需要动态拼接 SET 子句这也是一个实操难点。我的做法是先声明一个可更新的字段映射表遍历请求体只拼接存在的字段避免出现SET title NULL之类的误伤。router.patch(/:id, async (req, res) { const { id } req.params; const { title, description, status, priority, assigneeId, sortOrder } req.body || {}; const fieldMap { title, description, status, priority, assignee_id: assigneeId, sort_order: sortOrder, }; const assignments []; const params []; for (const [key, value] of Object.entries(fieldMap)) { if (value ! undefined) { assignments.push(${key} ?); params.push(value); } } if (assignments.length 0) { return res.status(400).json({ code: 1001, message: 没有可更新的字段, data: null }); } params.push(id); const [result] await db.query( UPDATE tasks SET ${assignments.join(, )} WHERE id ? AND deleted_at IS NULL, params ); if (result.affectedRows 0) { return res.status(404).json({ code: 1003, message: 任务不存在或已被删除, data: null }); } res.json({ code: 0, message: 更新成功, data: null }); });删除接口用软删除策略。DELETE 请求到达后执行一条 UPDATE把 deleted_at 设置为当前时间。查询列表时永远带上deleted_at IS NULL条件这样已删除的任务就从业务视角里消失了。硬删除不是不行但它会带来历史数据不可追溯的问题所以我的默认选择是软删除。router.delete(/:id, async (req, res) { const { id } req.params; const [result] await db.query( UPDATE tasks SET deleted_at NOW() WHERE id ? AND deleted_at IS NULL, [id] ); if (result.affectedRows 0) { return res.status(404).json({ code: 1003, message: 任务不存在或已被删除, data: null }); } res.json({ code: 0, message: 删除成功, data: null }); });字段映射表这种写法值得多说一句。使用对象字面量把 API 参数名映射成数据库字段名可以有效防止前端传一个idxxx之类的字段进来直接修改主键。安全上这叫“字段白名单”比直接Object.keys(req.body)然后循环拼接严谨得多建议养成这个习惯。3.6 用 curl 全流程验证接口眼见为实写完接口必须做一轮真实的联调不能只在浏览器里看看代码。我习惯用 curl 做快速验证命令如下# 注册用户 curl -X POST http://localhost:3000/api/v1/users/register \ -H Content-Type: application/json \ -d {email:devexample.com,password:123456,nickname:dev} # 登录拿 token curl -X POST http://localhost:3000/api/v1/users/login \ -H Content-Type: application/json \ -d {email:devexample.com,password:123456} # 创建任务 curl -X POST http://localhost:3000/api/v1/tasks \ -H Content-Type: application/json \ -H Authorization: Bearer token \ -d {projectId:1,assigneeId:1,title:完成数据库设计文档,priority:2} # 查任务列表 curl http://localhost:3000/api/v1/tasks?page1pageSize10 # 更新任务状态 curl -X PATCH http://localhost:3000/api/v1/tasks/1 \ -H Content-Type: application/json \ -d {status:1} # 删除任务 curl -X DELETE http://localhost:3000/api/v1/tasks/1每一行命令都要能看到对应 JSON 响应。比如注册成功后返回data.id登录成功后返回data.token创建任务后返回新的 id更新后返回成功 code。如果哪一步报错不要急着往后看先把报错信息复制到搜索引擎或 AI 工具里搞清楚再继续。调试的过程也是学习的一部分。4. 今天的坑与排查实录4.1 常见问题速查表照着检查能省半小时今天实际动手过程中我整理了五个高频问题基本覆盖了初学者容易卡住的场景。先看这张表问题现象常见原因处理方法启动后端口被占用3000 端口已有其他进程改 .env 里的 PORT或杀掉占用进程接口返回 500 ECONNREFUSED数据库没启动或配置错误检查 MySQL 服务状态逐项核对 .env中文写入后变成问号建库字符集不是 utf8mb4重建数据库明确指定 utf8mb4SQL 里order、group报语法错误字段名与保留字冲突表名和字段用反引号包裹或更换字段名登录接口总是 “密码错误”注册时加密、登录时未比对哈希确认使用了 bcrypt.hash / bcrypt.compare更新任务时 updated_at 没有变化建表语句漏了 ON UPDATE修改表结构补上 ON UPDATE CURRENT_TIMESTAMPPATCH 接口把字段更新成 NULL未做字段白名单过滤使用字段映射表遍历请求体保留字问题我今天差点踩进去。任务表想用一个字段叫order用来做排序结果写 SQL 时一直报语法错误最后才反应过来ORDER是 SQL 关键字。解决办法是改成sort_order语义更清晰也不会撞保留字。4.2 三个值得养成的小习惯第一个习惯是所有 SQL 都先手动在数据库客户端里执行一遍确认没有语法问题再往 Node 代码里粘。直接写进代码再启动调试报错信息里还要来回区分是 SQL 问题还是 Node 问题效率很低。第二个习惯是设计接口时先规定统一返回结构再写具体路由。哪怕是 demo也要用真实项目的标准要求自己。前端和后端最烦的就是同一个接口这次返回{ success: true }下次返回{ code: 0 }联调时全是沟通成本。第三个习惯是.env文件里永远不要放真实环境的数据库密码和 JWT 密钥。本地开发可以随意但一旦项目发布到服务器必须用健壮的随机字符串并且把.env排除在版本库之外。同时node_modules目录要在.gitignore里明确排除避免仓库体积失控。4.3 调试接口时的一个省钱技巧如果不想用 Postman 这类重量级工具又觉得 curl 每次敲太繁琐可以装一个 httpie命令行里用http POST http://localhost:3000/api/v1/users/register emailxx passwordxx就能发起请求可读性好很多。也可以在 VS Code 里安装 Thunder Client 插件它比 Postman 轻量适合本地联调。不过说到底调试器里的堆栈信息才是最可靠的答案。遇到 500 错误先看终端输出Node 默认会把异常堆栈打印出来。比如ER_DUP_ENTRY说明唯一索引冲突ER_BAD_FIELD_ERROR说明字段名写错了这些信息比任何揣测都准。把堆栈信息读懂了很多问题根本不用上网查。5. 第三天验收与后续扩展方向今天做完这些任务管理工具的后端已经有一个可以演示的骨架了。你既可以把这些接口接给前一天的静态页面也可以用 curl 演示给同事看。我个人的体会是数据库设计和接口约定的功夫在项目初期花得越足后期写业务逻辑时就越省心。尤其是统一返回结构和软删除这两个决定今天看起来多写了几行代码但后续做前端联调、数据统计、问题追溯时回报会非常明显。最后分享一个扩展思路今天只做了任务表project 表和 comment 表还没建。下一步可以按照同样的方法把项目权限加进来把评论接口做出来顺便把 JWT 中间件正式接入到所有任务接口里。到时候你会发现有了 Day 3 打下的数据库骨架和接口返回规范后面每加一张表、每写一个接口都只是重复劳动真正需要动脑子的地方已经不多了。这就是全栈项目最实在的正反馈。
返回列表