吞噬星空 RAG 项目 MySQL 实战:从硬编码登录到数据库鉴权(Day 1)

核心结论(金字塔尖顶) : 我把「吞噬星空」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 两个关键特性(面试要能讲)

  1. 加盐 :每个密码哈希都带随机盐值,同样的 123456 存出来的哈希也不同,无法用彩虹表破解。
  2. 成本因子可调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

三个理由

  1. 安全.env 里有真实密码和密钥,绝不能进 git(已在 .gitignore 里)。一旦 push 到公开仓库,密钥被脚本扫到就是盗刷。
  2. 可移植 :本地开发连本地库,上线连云库------只改 .env,代码不用动。
  3. 配置与代码分离:这是 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 两张表真正用起来。

相关推荐
Heo1 小时前
怎么保证缓存和数据库一致性?
前端·后端·面试
延凡科技1 小时前
从 “人管机器“ 到 “数据管人“:智慧矿山综合管控落地实践
java·后端·struts
ServBay1 小时前
2026 年值得关注的 8 款 AI 智能体工具
后端·aigc·ai编程
QX_hao2 小时前
【Go】--Cobra-cli的用法
开发语言·后端·golang
大厂码农老A2 小时前
177K star,扒开DeepSeek Harness的营销,我看到了什么
人工智能·后端·deepseek
别看我只是一直狼2 小时前
补充:证书自动续约检查、故障诊断与重新签发
后端
Lear2 小时前
Java 实现多步骤异步任务编排
后端
小强19882 小时前
告别 "if-else 地狱":PHP 中的策略模式、管道模式与责任链模式实战
后端
面试鸭2 小时前
Codex 跑进法务部,周活居然暴涨了 108 倍???
后端·面试·求职