Q4来咯,背上个绩效搭建类似于Profound的一个geo数据分析平台,没有后端资源,但是作为把挑战当兴趣的我来说问题一点不大,直接全栈开发(cursor启动!)
框架选择
后端:express框架(此篇文章需要一定express基础,后续有时间会补充详细,目前是SOP记录)
前端:Ant Design Pro(我目前都是用vue,但是好不容易有个机会,想试试react)
需求分析
和同事沟通下来,目前一期要完成的是,统计各大AI大模型对话中输入指定问题返回指定关键字的频率,图表展示
通过上述这段话,基本上后端的表也确定了
users表:维护用户信息以及权限,username、password、avatar、role、routes
agent表:维护模型信息,name、avatar、apiKey
keywords表:维护要监控的关键字,keyword
question表:维护给大模型提问的问题,question
question_keyword:可以理解为任务表,question_id、keyword_id
在前端层面,新建时选择问题然后再选择关键字这样就在任务表中创建了一条任务,后端会取任务表的每条数据跑定时任务,比如每天查询一次
task_log日志表:把定时任务的结果在数据库中记录,question_id、agent_id、keyword_id、status、info
status:0|1 其实是记录大模型api请求是否成功,info具体的报错信息
keyword_frequence_stats结果表:其实和日志表差不多,多了个frequency字段
后端搭建
先基于express-generator搭建express项目
数据库连接
const mysql = require('mysql2/promise');
let pool;
function getPool() {
if (pool) return pool;
pool = mysql.createPool({
host: process.env.MYSQL_HOST || '127.0.0.1',
port: Number(process.env.MYSQL_PORT || 3306),
user: process.env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD ,
database: process.env.MYSQL_DATABASE,
waitForConnections: true,
connectionLimit: Number(process.env.MYSQL_POOL_LIMIT || 10),
queueLimit: 0,
});
return pool;
}
async function connectToDatabase() {
const p = getPool();
// 做一次 ping 验证连接
const conn = await p.getConnection();
try {
await conn.ping();
console.log('MySQL connection success');
} finally {
conn.release();
}
return p;
}
module.exports = { getPool, connectToDatabase };
其中getPool实现数据库连接,connectToDatabase是项目启动时对连接状态的反馈
此时数据库连接上了,接下来是需要创建接口了,通常是在routes/xxx.js中创建,然后在app.js中导入
开发接口前的准备
后端目前大部分都是CRUD接口,其实很多代码会重复,比如数据库的查询、删除之类所以可以先抽离出来
const { getPool } = require("../config/db");
/**
* execute:执行一段你自己写的 SQL
* 示例:await execute('SELECT * FROM `users` WHERE id = ?', [1])
*/
function execute(sql, params = []) {
const pool = getPool();
return pool.execute(sql, params).then(([rows, fields]) => {
console.log("查询结果 rows =", rows);
console.log("列信息 fields =", fields);
return { rows, fields };
});
}
/**
* create:新增一条数据(INSERT)
* 示例:
* - 默认返回 insertId 和完整记录:await create('users', { name: 'Tom', age: 18 })
* @param {string} table 表名
* @param {object} data 要插入的内容(用对象写,键是列名,值是要存的值)
*/
function create(table, data) {
const keys = Object.keys(data);
// SQL 查询中的 占位符,以防止 SQL 注入攻击
const placeholders = keys.map(() => "?").join(",");
const sql = `INSERT INTO \`${table}\` (${keys
.map((k) => `\`${k}\``)
.join(",")}) VALUES (${placeholders})`;
const params = keys.map((k) => data[k]);
return execute(sql, params).then(async ({ rows }) => {
const insertId = rows.insertId;
const primaryKey = "id";
const selectSql = `SELECT * FROM \`${table}\` WHERE \`${primaryKey}\` = ? LIMIT 1`;
const { rows: selectedRows } = await execute(selectSql, [insertId]);
const row =
Array.isArray(selectedRows) && selectedRows.length > 0
? selectedRows[0]
: null;
return { insertId, row };
});
}
/**
* read:查询数据(SELECT)
* 示例:await read('users', { age: 18 }, { orderBy: 'id DESC', limit: 10 })
* @param {string} table 表名
* @param {object} where 筛选条件(只支持等号,例如 { id: 1 })
* @param {object} options 可选项:{ columns, orderBy, limit, offset }
*/
function read(table, where = {}, options = {}) {
const columns =
Array.isArray(options.columns) && options.columns.length > 0
? options.columns.map((c) => `\`${c}\``).join(",")
: "*";
const whereKeys = Object.keys(where);
const whereSql = whereKeys.length
? "WHERE " + whereKeys.map((k) => `\`${k}\` = ?`).join(" AND ")
: "";
// 如果没有传入 orderBy, 默认按 created_at 降序排列
const orderBy = options.orderBy
? `ORDER BY ${options.orderBy}`
: `ORDER BY \`created_at\` DESC`; // 默认按创建时间倒序排序
// 分页
const limit = Number.isFinite(options.limit)
? `LIMIT ${Number(options.limit)}`
: "";
const offset = Number.isFinite(options.offset)
? `OFFSET ${Number(options.offset)}`
: "";
const sql =
`SELECT ${columns} FROM \`${table}\` ${whereSql} ${orderBy} ${limit} ${offset}`.trim();
const params = whereKeys.map((k) => where[k]);
return execute(sql, params);
}
/**
* update:按条件修改数据(UPDATE)
* 示例:await update('users', { age: 19 }, { name: 'Tom' })
* @param {string} table 表名
* @param {object} data 要修改成的内容
* @param {object} where 条件(只支持等号)
*/
function update(table, data, where = {}) {
const setKeys = Object.keys(data);
if (setKeys.length === 0) return Promise.resolve({ affectedRows: 0 });
const whereKeys = Object.keys(where);
const sql = `UPDATE \`${table}\` SET ${setKeys
.map((k) => `\`${k}\` = ?`)
.join(", ")} ${
whereKeys.length
? "WHERE " + whereKeys.map((k) => `\`${k}\` = ?`).join(" AND ")
: ""
}`;
const params = [
...setKeys.map((k) => data[k]),
...whereKeys.map((k) => where[k]),
];
return execute(sql, params).then(({ rows }) => ({
affectedRows: rows.affectedRows ?? 0,
}));
}
/**
* remove:按条件删除数据(DELETE)
* 示例:await remove('users', { name: 'Tom' })
* 注意:必须带 where,避免误删整张表
* @param {string} table 表名
* @param {object} where 条件(只支持等号)
*/
function remove(table, where = {}) {
const whereKeys = Object.keys(where);
if (whereKeys.length === 0) {
return Promise.reject(new Error("DELETE 需要 where 条件以避免全表删除"));
}
const sql = `DELETE FROM \`${table}\` WHERE ${whereKeys
.map((k) => `\`${k}\` = ?`)
.join(" AND ")}`;
const params = whereKeys.map((k) => where[k]);
return execute(sql, params).then(({ rows }) => ({
affectedRows: rows.affectedRows ?? 0,
}));
}
/**
* findOne - 查询表中某个字段匹配的数据
* @param table 表名
* @param where 查询条件对象,例如 { username: 'alice' }
* @param columns 要查询的字段数组,默认 ['*']
* @returns 返回匹配的第一条数据,没找到返回 null
*/
async function findOne(table, where, columns) {
const keys = Object.keys(where);
if (keys.length === 0) {
throw new Error("查询条件不能为空");
}
const whereSql = keys.map((k) => `\`${k}\` = ?`).join(" AND ");
const sql = `SELECT ${columns
.map((c) => `\`${c}\``)
.join(", ")} FROM \`${table}\` WHERE ${whereSql} LIMIT 1`;
const params = keys.map((k) => where[k]);
const { rows } = await execute(sql, params);
return rows[0] || null;
}
module.exports = {
execute,
create,
read,
update,
remove,
findOne,
};
函数实测可用了,大家可以借助Ai进一步理解代码
下面是登录注册的接口
const express = require("express");
const router = express.Router();
const bcrypt = require("bcryptjs");
const { findOne, execute } = require("../utils/api");
const { generateToken } = require("../utils/jwt");
/* GET users listing. */
// 用户创建接口
router.post("/create", async function (req, res) {
try {
const { username, password, role } = req.body;
const routes = req.body?.routes || ["table-list", "home"];
const avatar = req.body?.avatar ?? "https://666666666.svg";
if (!username || !password) {
return res.status(400).json({ code: 0, message: "用户名或密码不能为空" });
}
// 检查用户是否已存在
const exist = await findOne("users", { username }, ["username"]);
if (exist) {
return res.status(400).json({ code: 0, message: "用户已存在" });
}
// 密码加密
const hashedPassword = await bcrypt.hash(password, 10);
// 插入用户
const { rows: resultRow } = await execute(
`INSERT INTO users (username, password, role, avatar, routes) VALUES (?, ?, ?, ?, ?)`,
[
username,
hashedPassword,
role,
avatar,
routes ? JSON.stringify(routes) : null,
]
);
const user = await findOne("users", { id: resultRow.insertId }, [
"id",
"username",
"avatar",
"role",
]);
return res.json({
code: 0,
message: "用户创建成功",
data: user,
});
} catch (err) {
console.error(err);
return res.status(500).json({ code: 10086, message: "服务器错误" });
}
});
router.post("/login", async (req, res) => {
const { username, password } = req.body; // 注意:是 req.body,不是 req.params
if (!username || !password) {
return res.status(400).send({
data: {},
code: 0,
message: "用户名或者密码不能为空",
});
}
try {
// 查询用户
const user = await findOne("users", { username }, [
"id",
"username",
"avatar",
"role",
"password",
]);
if (!user) {
return res.status(404).send({
data: {},
code: 0,
message: "用户不存在",
});
}
// // 验证密码
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
return res.status(401).send({
data: {},
code: 0,
message: "密码错误",
});
}
// 生成 JWT
const token = generateToken({ id: user.id });
delete user.password;
// 返回用户信息 + token
return res.send({
data: {
...user,
token,
},
code: 0,
message: "登录成功",
});
} catch (error) {
console.error(error);
return res.status(500).send({
data: {},
code: 10086,
message: "服务器错误",
});
}
});
module.exports = router;
这块对于报错的返回其实可以抽离的,准备放二期了
重点在于jwt后如何开启其他接口的验权?
需要借助中间件
中间件的搭建
目录结构大家可以参考我的
对jwt的统一维护
const jwt = require("jsonwebtoken");
const SECRET_KEY = process.env.JWT_SECRET; // 建议放到 .env 里
// 生成 token
function generateToken(
payload,
SECRET_KEY = process.env.JWT_SECRET,
expiresIn = "30d"
) {
return jwt.sign(payload, SECRET_KEY, { expiresIn });
}
// 验证 token
function verifyToken(token) {
try {
return jwt.verify(token, SECRET_KEY);
} catch (err) {
return null;
}
}
module.exports = { generateToken, verifyToken };
auth.js实现
const { verifyToken } = require("../utils/jwt");
const { findOne } = require("../utils/api");
async function authMiddleware(req, res, next) {
try {
// 从请求头里取 token
const authHeader = req.headers["authorization"];
if (!authHeader) {
return res.status(401).json({ code: 401, message: "缺少 token" });
}
const token = authHeader.replace("Bearer ", "").trim(); // 约定 "Bearer xxx"
let decoded;
try {
decoded = verifyToken(token);
if (!decoded) {
return res.status(401).json({ code: 401, message: "无效的 token" });
}
} catch (err) {
// 区分错误类型
if (err.name === "TokenExpiredError") {
return res.status(401).json({ code: 401, message: "token 已过期" });
} else if (err.name === "JsonWebTokenError") {
return res.status(401).json({ code: 401, message: "无效的 token" });
} else {
return res.status(401).json({ code: 401, message: "token 验证失败" });
}
}
const { id } = decoded;
const user = await findOne("users", { id }, ["id", "username", "role"]);
if (!user) {
return res.status(404).json({ code: 404, message: "用户不存在" });
}
if (user.role !== "admin") {
return res.status(403).json({ code: 403, message: "权限不足" });
}
// 把用户信息挂到请求对象上,后续路由可直接使用
req.user = user;
next();
} catch (err) {
console.error("authMiddleware error:", err);
return res.status(500).json({ code: 500, message: "服务器错误" });
}
}
module.exports = authMiddleware;
实测可运行
=========================我是分割线============================
对于权限处理看你自己逻辑 可能是 100 10 0数字去划分
if (user.role !== "admin") { return res.status(403).json({ code: 403, message: "权限不>足" }); }
最后在app.js中引入使用
至此,接口的鉴权,常规的代码抽离也完成咯,后续会持续更新
Ant Design Pro
感觉这个项目还是需要仔细看看的,目前还只是粗略的看了,修改了登录的逻辑,就先不献丑了,后续也是会持续更新
大佬们欢迎添加我的好友一起交流学习,如果有不懂的也可以交流