核心结论(金字塔尖顶) : 我把「吞噬星空」RAG 项目的登录从硬编码的 admin/123456 升级为查 MySQL 数据库 + bcrypt 密码加密 的真实鉴权,并顺带完成了前端注册功能。这一步让项目从"能跑的 demo"跨入了"有真实数据存储的工程"。围绕这次改造,我彻底搞懂了三件事: ① 分层架构 ------路由(routes)→ 模型(models)→ 连接层(db),每一层各司其职、单向依赖。 ② 数据安全 ------为什么密码要 bcrypt 哈希而不是明文、参数化查询为什么能防 SQL 注入、为什么"用户不存在"和"密码错误"要返回同样的提示。 ③ 配置管理 ------数据库地址/密钥为什么必须进
.env,而不是写死在代码里。这篇总结是实战复盘,也是「AI 应用开发后端实习」的面试弹药库。
一、为什么要做 MySQL 接入?(动机 → 背景)
在动手前,我先问了自己一句:硬编码登录不也能跑吗?为什么非要接数据库? 答案是------有三个硬理由:
| 维度 | 硬编码登录 | 数据库登录 |
|---|---|---|
| 可维护性 | 换密码 = 改代码 + 重启服务 | 改数据库一条 UPDATE 即可 |
| 用户扩展 | 永远只有一个 admin | 任意注册新用户 |
| 对话记录 | 无法按用户存数据 | user_id 关联一切(为 Day 2 铺路) |
| 简历含金量 | 应届生 demo 水平 | 真实工程能力 |
目标明确:登录从"背暗号"变成"翻花名册"。这次的"花名册"就是 MySQL。
二、支柱一:分层架构(MVC 思想落地)
2.1 为什么要分层
很多新手把代码全塞在一个文件里------能跑,但没法维护、没法测试、没法扩展 。分层的本质是关注点分离 + 单向依赖:
sql
routes/ (URL → 处理函数映射) 只负责"接到请求,调对应函数"
↓ 调用
models/ (数据访问层,写 SQL) 只负责"和数据库打交道"
↓ 调用
db.js (连接池 + 查询封装) 只负责"建立连接,安全执行 SQL"
↓
MySQL
单向依赖:上层依赖下层,下层绝不反向依赖上层。换数据库(MySQL → PostgreSQL)只改 models 和 db 两层,routes 完全不动。
2.2 三层各自的文件(Day 1 新建/改动)
| 层 | 文件 | 职责 | 关键代码 |
|---|---|---|---|
| 连接层 | models/db.js | 连接池 + query() 统一入口 |
mysql.createPool() + pool.execute(sql, params) |
| 模型层 | models/userModel.js | 用户表操作 | findUserByUsername() / createUser() |
| 路由层 | routes/auth.js | 登录/注册接口 | POST /login + POST /register |
2.3 连接层为什么用"连接池"
js
const pool = mysql.createPool({
host: process.env.DB_HOST, // 从 .env 读,不写死
port: Number(process.env.DB_PORT),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true, // 连接池满时排队等待
connectionLimit: 10, // 最多 10 条连接
queueLimit: 0, // 无限排队
charset: 'utf8mb4', // 支持中文
})
面试回答:每次查询都新建 MySQL 连接太慢(TCP 握手 + 鉴权),连接池预建 10 条连接复用。高并发时连接不足就排队,而不是崩溃。这是所有后端连接数据库的标准做法。
为什么用 pool.execute() 而不是 pool.query() :execute 用预处理语句 (Prepared Statement),SQL 和参数分离发送给 MySQL,天然防 SQL 注入。
三、支柱二:数据安全(面试最高频考点)
3.1 为什么密码必须 bcrypt 哈希
错误做法 :INSERT INTO sys_user (username, password) VALUES ('admin', '123456') ------ 明文存密码。
后果:数据库一旦泄露,黑客直接拿到所有用户密码。而用户很可能在别的网站也用同一密码(撞库攻击)。
正确做法:
js
// 注册:bcrypt 哈希后入库
const passwordHash = await bcrypt.hash(password, 10) // 10 是成本因子
await createUser({ username, passwordHash })
// 登录:bcrypt.compare 比对(不是比字符串!)
const match = await bcrypt.compare(password, user.password)
bcrypt 两个关键特性(面试要能讲):
- 加盐 :每个密码哈希都带随机盐值,同样的
123456存出来的哈希也不同,无法用彩虹表破解。 - 成本因子可调 :
bcrypt.hash(password, 10)的 10 控制哈希计算耗时(约 100ms),让暴力破解的成本指数级上升。未来硬件变强,可以调大这个数字。
面试追问:为什么不用 MD5 / SHA-256?→ 它们算得快,所以暴力破解也快;bcrypt 故意算得慢,是"以时间换安全"。
3.2 为什么 SQL 要用参数化查询(防注入)
危险写法:
js
query(`SELECT * FROM sys_user WHERE username = '${username}'`) // ❌ 危险!
如果用户输入 ' OR '1'='1,拼出来的 SQL 变成:
sql
SELECT * FROM sys_user WHERE username = '' OR '1'='1'
------永远为真,等于不需要密码就能登录。这就是经典的 SQL 注入。
安全写法:
js
query('SELECT * FROM sys_user WHERE username = ?', [username]) // ✅ 安全
? 是占位符,参数由 mysql2 的预处理机制独立传递,永远不会被当成 SQL 执行。
面试回答 :我全程用 ? 占位符 + 参数数组,避免字符串拼接 SQL。execute() 走预处理语句,参数和语句分离传输,从根上杜绝 SQL 注入。
3.3 为什么"用户不存在"和"密码错误"返回一样的话
面试考点:防用户名枚举。
如果用户不存在返回"用户不存在",密码错返回"密码错误",黑客就能通过返回信息探测哪些用户名注册过,再针对性爆破。
正确做法 :两者统一返回 账号或密码错误。代码里:
js
const user = await findUserByUsername(username)
if (!user) return res.status(400).json({ msg: '账号或密码错误' }) // 不存在 → 同一句话
const match = await bcrypt.compare(password, user.password)
if (!match) return res.status(400).json({ msg: '账号或密码错误' }) // 密码错 → 同一句话
加分项:真的让两个分支的耗时也接近(bcrypt.compare 对不存在的用户也跑一次),进一步防时序攻击。这个细节面试官会眼前一亮。
四、支柱三:配置管理(.env 的秘密)
4.1 为什么数据库地址和密钥不能写死在代码里
看 .env:
ini
DB_HOST=127.0.0.1
DB_PORT=3307
DB_USER=root
DB_PASSWORD=Rag@123456
DB_NAME=rag_system
JWT_SECRET=tsxk_rag_jwt_secret_2026_change_me
JWT_EXPIRES_IN=2h
三个理由:
- 安全 :
.env里有真实密码和密钥,绝不能进 git(已在.gitignore里)。一旦 push 到公开仓库,密钥被脚本扫到就是盗刷。 - 可移植 :本地开发连本地库,上线连云库------只改
.env,代码不用动。 - 配置与代码分离:这是 12-Factor App 的核心原则,面试必提。
4.2 .env 和 .env.example 的区别
| 文件 | 内容 | 是否提交 git |
|---|---|---|
.env |
真实配置(真实密码/密钥) | ❌ 提交(已 gitignore) |
.env.example |
模板(你的_MySQL_密码 占位) |
✅ 提交 |
做法 :.env.example 提交到仓库,新同事/新环境复制一份改成 .env 填真实值。这样既安全又方便协作。
4.3 JWT_SECRET 的生产环境要求
Day 1 我把 JWT_SECRET 从硬编码挪进了 .env,但 utils/jwt.js 里还应该有兜底:
js
const JWT_SECRET = process.env.JWT_SECRET || 'dev-fallback-secret-change-me'
面试回答 :开发环境用兜底值方便启动,生产环境必须从 .env/环境变量注入高强度随机密钥。密钥泄露 = 任何人都能伪造 token。
五、支柱四:数据库设计(建表思路)
5.1 三张表的职责
sql
-- 用户表:存账号密码(Day 1 复用)
CREATE TABLE sys_user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL, -- bcrypt 哈希,绝不存明文
nickname VARCHAR(30),
role VARCHAR(20) DEFAULT 'user',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 会话表:一次对话(Day 2 用,Day 1 先建好)
CREATE TABLE conversations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(100),
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id, update_time)
);
-- 消息表:会话里的每条问答(Day 2 用)
CREATE TABLE messages (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
conversation_id BIGINT NOT NULL,
role VARCHAR(10) NOT NULL, -- user / assistant
content TEXT NOT NULL,
sources JSON NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_conv (conversation_id, create_time)
);
5.2 设计要点(面试能讲)
| 设计点 | 为什么 |
|---|---|
username UNIQUE |
数据库层面保证用户名不重复,不依赖业务代码判断 |
password VARCHAR(100) |
bcrypt 哈希约 60 字符,VARCHAR(100) 留余量 |
role DEFAULT 'user' |
用户表补 role 字段,为权限分级做准备 |
utf8mb4 |
支持中文和 emoji,MySQL 5.7+ 默认 |
BIGINT 主键 |
自增主键,避免 INT 溢出(21 亿条) |
| 会话/消息分两张表 | 1 对 N 关系,避免单表冗余(一个会话的标题不用每条消息重复存) |
联合索引 (user_id, update_time) |
按用户倒序查会话列表的查询走索引,避免全表扫描 |
5.3 关键认知:MySQL 和 Milvus 的分工(AI 岗位必答)
MySQL 存人的对话记录 (用户问了什么、系统答了什么);Milvus 向量库存书的原文向量(《吞噬星空》1363 章切块 + embedding)。
两者职责完全不同:MySQL 是业务数据,Milvus 是语义检索数据。RAG 流程是------用户问题 → 向量检索 Milvus 拿原文片段 → 拼接 prompt → LLM 生成回答 → 把问答写入 MySQL。
六、支柱五:前端注册功能(单面板模式切换)
6.1 设计思路
登录/注册共用一个面板 ,用 isRegisterMode 布尔变量控制 UI:
js
let isRegisterMode = false // false=登录模式,true=注册模式
function toggleMode() {
isRegisterMode = !isRegisterMode
loginTitle.textContent = isRegisterMode ? '注册新账号' : '吞噬星空 RAG 助手'
loginBtn.textContent = isRegisterMode ? '注 册' : '登 录'
confirmField.classList.toggle('hidden', !isRegisterMode) // 注册才显示确认密码框
loginHint.classList.toggle('hidden', isRegisterMode) // 注册模式隐藏演示账号提示
}
// 统一入口:按钮点击根据模式分派
function handleAuthAction() {
if (isRegisterMode) doRegister()
else doLogin()
}
6.2 前端注册校验(面试能讲的前端严谨性)
js
if (password.length < 6) {
loginError.textContent = '密码长度至少 6 位'
return
}
if (password !== confirm) {
loginError.textContent = '两次密码不一致'
return
}
注意 :前端校验只是体验优化 ,真正的校验必须在后端也做一遍------因为任何人都能绕过前端直接调 API。我在 routes/auth.js 的 /register 里也校验了密码长度和空值,这就是双重校验思想。
6.3 注册成功后的体验细节
注册成功 → 自动切回登录模式 + 预填用户名 + 清空密码框 + 绿色提示"注册成功,请登录"。减少用户操作步骤,这个细节面试官问"你怎么考虑用户体验"时能加分。
七、验证方法(怎么证明做对了)
7.1 接口测试(Apifox / PowerShell)
| 用例 | 期望 |
|---|---|
POST /api/auth/register {"username":"test1","password":"123456"} |
200 注册成功 |
POST /api/auth/login 正确密码 |
200 返回 accessToken |
POST /api/auth/login 错误密码 |
400 账号或密码错误 |
POST /api/auth/register 重复用户名 |
400 用户名已存在 |
GET /api/auth/profile 带 token |
200 用户信息 |
GET /api/auth/profile 不带 token |
401 |
7.2 最硬核的验证(证明真的走数据库)
sql
-- 1. DBeaver 查 sys_user,密码是 $2b$10$... 哈希(不是明文 123456)
SELECT username, password FROM sys_user;
-- 2. 把密码哈希改坏
UPDATE sys_user SET password = '$2b$10$invalid' WHERE username = 'admin';
-- 3. 回浏览器用 123456 登录 → 必须失败(数据库变了,登录就变 = 证明走数据库)
-- 4. 验证完改回(重新注册或用正确哈希覆盖)
面试回答:我通过"改数据库密码 → 登录结果随之变化"验证了登录逻辑 100% 读数据库,中间没有任何硬编码。
八、Day 1 的踩坑实录(面试谈资,体现真实经验)
8.1 坑 1:pnpm / npm 被沙箱拦截
- 现象 :
pnpm add mysql2卡在 store 目录(指向受限路径),npm 装到一半崩溃。 - 根因:pnpm 全局 store 被指向受限路径,npm 无法解析 pnpm 的符号链接 node_modules。
- 解法 :把 store 复制到项目内 +
pnpm install --store-dir <项目内路径>重链。 - 收获:理解了 pnpm 的 store 机制(全局缓存 + 硬链接)、node_modules 符号链接结构,以及 npm/pnpm 不能混用的原因。
8.2 坑 2:sys_user 表缺 role 字段
- 现象 :JWT payload 里想带
role,但表里没有。 - 解法 :
ALTER TABLE sys_user ADD COLUMN role VARCHAR(20) DEFAULT 'user'; - 收获 :数据库 schema 演进是常态,
ALTER TABLE是基本功;新增字段要设DEFAULT避免影响已有数据。
九、面试问答速查(Day 1 相关高频题)
Q1:你项目里密码怎么存的?
答 :bcrypt 哈希存储,成本因子 10。注册时 bcrypt.hash(password, 10),登录时 bcrypt.compare(password, user.password)。bcrypt 自带随机盐,相同密码哈希值也不同,且计算慢,能防彩虹表和暴力破解。
Q2:怎么防 SQL 注入?
答 :全项目用 ? 占位符 + 参数数组,走 mysql2 的 execute() 预处理语句。SQL 和参数分离传输,用户输入永远不会被当成 SQL 执行。不用字符串拼接 SQL。
Q3:你登录接口怎么判断"用户不存在"和"密码错误"?
答:统一返回"账号或密码错误",防止黑客通过错误提示枚举已注册用户名。两个分支响应一致,甚至让耗时也接近,防时序攻击。
Q4:为什么用连接池?
答:每次查询新建连接开销大(TCP 握手 + MySQL 鉴权)。连接池预建 10 条连接复用,连接不足时排队等待而非崩溃。
Q5:为什么登录要接 MySQL?硬编码不行吗?
答:硬编码只能有固定账号、换密码要改代码、无法做用户维度的数据隔离(对话记录无法按人存)。MySQL 让用户可注册、数据可按 user_id 隔离,是真实项目的必要演进。
Q6:MySQL 和 Milvus 在你项目里各管什么?
答:MySQL 管业务数据(用户、会话、消息),Milvus 管《吞噬星空》原文的向量索引用于语义检索。用户提问 → Milvus 检索原文片段 → LLM 生成回答 → 问答写入 MySQL。两者职责分离。
Q7:.env 里的密钥泄露了怎么办?
答:立即轮换(重新生成)密钥,并排查 git 历史是否提交过 .env。以后 .env 进 .gitignore,只提交 .env.example 模板。
Q8:前端校验和后端校验的区别?
答:前端校验只优化体验(少发无效请求),后端校验才是安全边界(绕过前端直接调 API 也能拦)。我做双重校验,密码长度前后端都查。
十、总结(金字塔底座)
Day 1 用"硬编码 → MySQL + bcrypt"这一步,把项目从 demo 推向工程,同时覆盖了后端面试最高频的四块考点:
bash
分层架构(routes/models/db) ──▶ 关注点分离 + 单向依赖
数据安全(bcrypt + 参数化 + 防枚举)──▶ 每个都能单独拎出来讲 3 分钟
配置管理(.env 分离) ──▶ 12-Factor 原则
数据库设计(三表 + 索引) ──▶ 为 Day 2 对话记录铺路
下一步 :Day 2 做对话记录保存(会话/消息接口 + 前端历史侧边栏),把 conversations / messages 两张表真正用起来。