适合新手或前端学习 node.js 做后台的起步教程
这里我使用typescript
去编写,理由是因为非常好用的类型提示和代码追踪,在纯javascript
编程的项目中typescript
是最好维护和阅读的(当然因人而异)。
功能清单:
- 项目构建和配置
- 前后端请求
header
基本配置说明 - 编写测试接口
- 连接数据库处理
- 接口与数据库处理
- 上传文件处理
- 用户模块程序设计
token
认证的使用(基于用户模块设计)- 项目构建推送到线上
先来看下目录结构
每个目录下面都有一个README.md
描述当前目录,这里就不所有展开了,只展开文章用到的示例文件。
- mysql/
- public/
- template/
- upload/
- src/
- middleware/
- index.ts
- modules/
- Config.ts
- Jwt.ts
- TableUser.ts
- routes/
- main.ts
- test.ts
- todo.ts
- upload.ts
- user.ts
- types/
- utils/
- mysql.ts
- index.ts
- package.json
- tsconfig.json
package.json
的配置如下
{
"scripts": {
"build": "rimraf dist/* && tsc && node dist/index.js",
"dev": "ts-node-dev --respawn ./src/index.ts",
"build-sql": "ts-node-dev --respawn ./mysql/creator.ts"
},
"dependencies": {
"koa": "2.15.3",
"koa-body": "4.2.0",
"koa-router": "10.1.1",
"koa-static": "5.0.0",
"mysql": "2.18.1"
},
"devDependencies": {
"@types/koa": "2.15.0",
"@types/koa-router": "7.4.8",
"@types/koa-static": "4.0.4",
"@types/mysql": "2.15.26",
"ts-node": "9.1.1",
"ts-node-dev": "2.0.0",
"typescript": "4.9.5"
}
}
koa-body
是解析post
传参格式:json
、FormData
、file
等作用;koa-static
是处理目录下的路劲操作功能,使其可以在访问服务端地址时,可以通过绝对路径读取到指定路径下的内容;ts-node-dev
则是开发时代码热更新的作用;
用到的东西不多,但完全够用了,可覆盖常用的绝大多数场景;
基本配置
src/modules/Config.ts
中配置项目中所有用到的功能
import * as os from "os";
function getIPAdress() {
const interfaces = os.networkInterfaces();
for (const key in interfaces) {
const iface = interfaces[key];
for (let i = 0; i < iface.length; i++) {
const alias = iface[i];
if (alias.family === "IPv4" && alias.address !== "127.0.0.1" && !alias.internal) {
return alias.address;
}
}
}
}
class ModuleConfig {
constructor() {
const ip = getIPAdress();
const devDomain = `http://${ip}`;
this._ip = ip;
this.origins = [
`http://${this.publicIp}`,
"http://huangjingsheng.gitee.io",
// 本地开发
devDomain + ":5050",
devDomain + ":6060",
];
}
private _ip: string;
/** 当前服务`ip`地址 */
get ip() {
return this._ip;
}
/** 服务器公网`ip` */
readonly publicIp = "123.123.123";
/** 服务器内网`ip` */
readonly privateIp = "17.17.17.17";
/** 是否开发模式 */
get isDev() {
return this.ip != this.privateIp;
}
/** 端口号 */
get port() {
return this.isDev ? 1995 : 80;
}
/** 数据库配置 */
get db() {
return {
/**
* 服务器中使用`localhost`或者`this.privateIp`,注意:`localhost`需要`mysql`配置才能使用,不确定是不是程序 bug;
* 调试环境需要手动改成`this.publicIp`
*/
host: this.isDev ? "localhost" : this.privateIp,
/** 默认是`root`除非在服务器配置时有修改过 */
user: "root",
/** 这个密码是服务器配置的时候设置的 */
password: this.isDev ? "DRsXT5ZJ6Oi55LPQ" : "服务器密码",
/** 数据库名 */
database: "node_ts",
/** 链接上限次数 */
maxLimit: 10
}
}
/** 允许访问的域名源 */
readonly origins: Array<string>
/**
* 接口前缀
* - 注意!!!可以为空`""`,但不能写成`"/"`
*/
readonly apiPrefix = "/api";
/** 上传文件存放目录 */
readonly uploadPath = "public/upload/";
/** 上传文件大小限制 */
readonly uploadImgSize = 5 * 1024 * 1024;
/**
* 前端上传文件时约定的字段
* @example
* const formData = new FormData()
* formData.append("file", file)
* XHR.send(formData)
*/
readonly uploadName = "file";
/**
* 路由路径,加上接口前缀处理
* @param path
*/
getRoutePath(path: string) {
return this.apiPrefix + path;
}
}
/** 项目配置 */
const config = new ModuleConfig();
export default config;
配置服务端header
src/index.ts
中
import * as Koa from "koa"; // learn: https://www.npmjs.com/package/koa
import * as koaBody from "koa-body"; // learn: http://www.ptbird.cn/koa-body.html
import * as staticFiles from "koa-static"; // 静态文件处理模块
import * as path from "path";
import config from "./modules/Config";
import router from "./routes/main";
import utils from "./utils";
import "./routes/test"; // 基础测试模块
import "./routes/user"; // 用户模块
import "./routes/upload"; // 上传文件模块
import "./routes/todo"; // 用户列表模块
import { TheContext } from "./types/base";
const App = new Koa();
// 指定 public目录为静态资源目录,用来存放 js css images 等
// 注意:这里`template`目录下如果有`index.html`的话,会默认使用`index.html`代`router.get("/")`监听的
App.use(staticFiles(path.resolve(__dirname, "../public/template")))
// 上传文件读取图片的目录也需要设置为静态目录
App.use(staticFiles(path.resolve(__dirname, "../public/upload")))
// 先统一设置请求配置 => 跨域,请求头信息...
App.use(async (ctx: TheContext, next) => {
const { origin, referer } = ctx.headers;
const domain = utils.getDomain(referer || "");
// 如果是 允许访问的域名源 ,则给它设置跨域访问和正常的请求头配置
if (domain && config.origins.includes(domain)) {
ctx.set({
"Access-Control-Allow-Origin": domain,
// "Access-Control-Allow-Origin": "*", // 开启跨域,一般用于调试环境,正式环境设置指定 ip 或者指定域名
// "Content-Type": "application/json",
// "Access-Control-Allow-Credentials": "true",
// "Access-Control-Allow-Methods": "OPTIONS, GET, PUT, POST, DELETE",
"Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept, Authorization",
// "X-Powered-By": "3.2.1",
// "Content-Security-Policy": `script-src "self"` // 只允许页面`script`引入自身域名的地址
});
}
// 如果前端设置了 XHR.setRequestHeader("Content-Type", "application/json")
// ctx.set 就必须携带 "Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept, Authorization"
// 如果前端设置了 XHR.setRequestHeader("Authorization", "xxxx") 那对应的字段就是 Authorization
// 并且这里要转换一下状态码
// console.log(ctx.request.method);
if (ctx.request.method === "OPTIONS") {
ctx.response.status = 200;
}
// const hasPath = router.stack.some(item => item.path == path);
// // 判断是否 404
// if (path != "/" && !hasPath) {
// return ctx.body = "<h1 style="text-align: center; line-height: 40px; font-size: 24px; color: tomato">404:访问的页面(路径)不存在</h1>";
// }
try {
await next();
} catch (err) {
ctx.response.status = err.statusCode || err.status || 500;
ctx.response.body = {
message: err.message
}
}
});
// 使用中间件处理 post 传参 和上传图片
App.use(koaBody({
multipart: true,
formidable: {
maxFileSize: config.uploadImgSize
}
}));
// 开始使用路由
App.use(router.routes())
// 默认无路由模式
// App.use((ctx, next) => {
// ctx.body = html;
// // console.log(ctx.response);
// });
App.on("error", (err, ctx) => {
console.log(`\x1B[91m server error !!!!!!!!!!!!! \x1B[0m`, err, ctx);
})
App.listen(config.port, () => {
// for (let i = 0; i < 100; i++) {
// console.log(`\x1B[${i}m 颜色 \x1B[0m`, i);
// }
const suffix = config.port + config.apiPrefix;
console.log("服务器启动完成:");
console.log(` - Local: \x1B[36m http://localhost:\x1B[0m\x1B[96m${suffix} \x1B[0m`);
console.log(` - Network: \x1B[36m http://${config.ip}:\x1B[0m\x1B[96m${suffix} \x1B[0m`);
})
服务端通过ctx.set
去设置和客户端的交互配置,如果设置了"Access-Control-Allow-Origin": "*"
则是全部可以访问,也就是开启浏览器环境中的跨域请求。这里通过ctx.headers.referer
可以获取请求的地址,从而设置对应的访问源,也就是指定地址可以跨域访问;当然,前端可以通过脚手架去配置代理信息来实现跨域请求;如果是严格校验不允许未指定的访问源请求时,可以在接口中使用handleToken
中间件去进行拦截处理。
src/middleware/index.ts
中
import { Next } from "koa";
import utils from "../utils";
import config from "../modules/Config";
import { TheContext } from "../types/base";
/**
* 中间件-处理域名请求:严格判断当前请求域名是否在白名单内
* @param ctx
* @param next
*/
export async function handleDoMain(ctx: TheContext, next: Next) {
const { referer, origin } = ctx.headers;
// console.log(referer, origin);
const domain = utils.getDomain(referer || "");
const list = config.origins.concat([`http://${config.ip}:${config.port}`]);
// 严格判断当前请求域名是否在白名单内
if (domain && list.indexOf(domain) > -1) {
await next();
}
}
路由使用handleToken
import { handleToken } from "../middleware";
router.get("/getUserList", handleToken, (ctx, next) => {
// ...只有通过校验的访问源才会执行到这里来
})
这种方式前端的脚手架代理就请求不了,同时对一些恶意访问的ip
也可以拦截掉。
编写测试接口
考虑到接口前缀的设计方式,所以在写接口之前,先统一在路由配置中设置一个公共文件,其他路由都从这里导出使用
src/routes/main.ts
中
import * as Router from "koa-router";
import config from "../modules/Config";
/**
* api路由模块
*/
const router = new Router({
prefix: config.apiPrefix
});
export default router;
src/routes/test.ts
中
import * as fs from "fs";
import * as path from "path";
import router from "./main";
import utils from "../utils";
import { apiSuccess, apiFail } from "../utils/apiResult";
import config from "../modules/Config";
import request from "../utils/request";
import { BaseObj } from "../types/base";
/** 资源路径 */
const resourcePath = path.resolve(__dirname, "../../public/template");
const template = fs.readFileSync(resourcePath + "/page.html", "utf-8");
// "/*" 监听全部
router.get("/", (ctx, next) => {
// 指定返回类型
// ctx.response.type = "html";
// ctx.response.type = "text/html; charset=utf-8";
// const data = {
// pageTitle: "serve-root",
// jsLabel: "",
// content: `<button class="button button_green"><a href="/home">go to home<a></button>`
// }
// ctx.body = utils.replaceText(template, data);
// console.log("根目录");
// 路由重定向
ctx.redirect(config.getRoutePath("/home"));
// 302 重定向到其他网站
// ctx.status = 302;
// ctx.redirect("https://www.baidu.com");
})
router.get("/home", (ctx, next) => {
const userAgent = ctx.header["user-agent"];
ctx.response.type = "text/html; charset=utf-8";
const path = `http://${config.ip}:${config.port}`;
const data = {
pageTitle: "serve-root",
path: path,
jsLabel: `<script src="https://res.wx.qq.com/open/js/jweixin-1.0.0.js"></script>`,
content: `
<div style="font-size: 24px; margin-bottom: 8px; font-weight: bold;">当前环境信息:</div>
<p style="font-size: 15px; margin-bottom: 10px; font-weight: 500;">${userAgent}</p>
<button class="the-btn purple"><a href="${path}/api-index.html">open test</a></button>
`
}
ctx.body = utils.replaceText(template, data);
// console.log("/home");
})
// get 请求
router.get("/getData", (ctx, next) => {
/** 接收参数 */
const params = ctx.query || ctx.querystring;
console.log("/getData", params);
ctx.body = apiSuccess({
method: "get",
port: config.port,
date: utils.formatDate()
});
})
// post 请求
router.post("/postData", (ctx, next) => {
/** 接收参数 */
const params: BaseObj = ctx.request.body || ctx.params;
// console.log("/postData", params);
const result = {
data: params
}
ctx.body = apiSuccess(result, "post success")
})
// 请求第三方接口并把数据返回到前端
router.get("/getWeather", async (ctx, next) => {
// console.log("ctx.query >>", ctx.query);
const cityCode = ctx.query.cityCode as string;
if (!cityCode) {
ctx.body = apiSuccess({}, "缺少传参字段 cityCode", 400);
return;
}
/**
* 自行去申请`appKey`才能使用
* - [高德地图应用入口](https://console.amap.com/dev/key/app)
* - [天气预报接口文档](https://lbs.amap.com/api/webservice/guide/api/weatherinfo/)
*/
const appKey = "";
if (!appKey) {
ctx.status = 500;
ctx.body = apiFail("服务端缺少 appKey 请检查再重试", 500, {})
return;
}
const path = utils.jsonToPath({
key: appKey,
city: cityCode
})
const res = await request({
method: "GET",
hostname: "restapi.amap.com",
path: "/v3/weather/weatherInfo?" + path
})
// console.log("获取天气信息 >>", res);
if (res.state === 1) {
if (utils.isType(res.result, "string")) {
res.result = JSON.parse(res.result);
}
ctx.body = apiSuccess(res.result)
} else {
ctx.status = 500;
ctx.body = apiFail(res.msg, 500, res.result)
}
})
完了之后就可以开始运行npm run dev
了,这个时候在浏览器中访问控制台的地址,就成功搭建了一个基础网络服务。
连接数据库处理
有了上面的基础服务之后,就需要链接数据库做一些数据交互处理
这里我用到的本地服务是用(upupw)搭建,超简单的操作,数据库表工具是navicat
- upupw下载地址
- navicat下载地址 网上破解的也很多,自行下载即可
- 这里用
upupw
命令窗口获取数据的账号密码、或者在下图所示目录文件获取账号密码后,在navicat
工具中新建一个数据库的连接,照着账号密码填就行,注意不要改默认的端口,不然会连接失败。
- 连接好后在左侧栏新建一个数据库,叫
node_ts
,代码中config.db.database
就是这个,待会建表也是在这个栏目下面建表。
- 开始建两个表
user_table
、todo_table
不想建表的可以直接将项目mysql/node_ts.sql
文件导入node_ts
数据库即可
再回到项目中src/utils/mysql.ts
文件下,这里封装了一个处理数据库语句的方法,之后所有的数据库操作都是通过这个方法去完成。
import * as mysql from "mysql"; // learn: https://www.npmjs.com/package/mysql
import config from "../modules/Config";
/** `mysql`查询结果 */
interface MsqlResult<T = any> {
/** `state === 1`时为成功 */
state: number
/** 结果数组 或 对象 */
results: T
/** 状态 */
fields: Array<mysql.FieldInfo>
/** 错误信息 */
error: mysql.MysqlError
/** 描述信息 */
msg: string
}
/** 数据库链接池 */
const pool = mysql.createPool({
host: config.db.host,
user: config.db.user,
password: config.db.password,
database: config.db.database
});
/**
* 数据库增删改查
* @param command 增删改查语句 [mysql语句参考](https://blog.csdn.net/gymaisyl/article/details/84777139)
* @param value 对应的值
*/
export function query<T = any>(command: string, value?: Array<any>) {
const result: MsqlResult = {
state: 0,
results: undefined,
fields: [],
error: undefined,
msg: ""
}
return new Promise<MsqlResult<T>>(resolve => {
pool.getConnection((error: any, connection) => {
if (error) {
result.error = error;
result.msg = "数据库连接出错";
resolve(result);
} else {
const callback: mysql.queryCallback = (error: any, results, fields) => {
// pool.end();
connection.release();
if (error) {
result.error = error;
result.msg = "数据库增删改查出错";
resolve(result);
} else {
result.state = 1;
result.msg = "ok";
result.results = results;
result.fields = fields;
resolve(result);
}
}
if (value) {
pool.query(command, value, callback);
} else {
pool.query(command, callback);
}
}
});
});
}
接口与数据库处理
todo_table
的表建好之后,就可以开始写增删改查的操作了,下面代码中:handleToken
、tokenInfo
是用户的相关属性对象,因为比较复杂,所以放在下文中再详细说明;utils.xxx
则是处理对象与字符串的装换操作,具体看对应代码实现即可,这里只需要知道query()
方法中传入的是sql
语句就OK
src/routes/todo.ts
import router from "./main";
import { query } from "../utils/mysql";
import utils from "../utils";
import { ApiResult, TheContext } from "../types/base";
import { apiSuccess, apiFail } from "../utils/apiResult";
import { handleToken } from "../middleware";
// 获取所有列表
router.get("/getList", handleToken, async (ctx: TheContext) => {
const tokenInfo = ctx["theToken"];
/** 返回结果 */
let bodyResult: ApiResult;
// console.log("getList >>", tokenInfo);
const res = await query(`select * from todo_table where create_user_id = '${tokenInfo.id}'`)
if (res.state === 1) {
// console.log("/getList 查询", res.results);
bodyResult = apiSuccess({
list: res.results.length > 0 ? utils.arrayItemToHump(res.results) : []
});
} else {
ctx.response.status = 500;
bodyResult = apiFail(res.msg, 500, res.error);
}
ctx.body = bodyResult;
})
// 添加列表
router.post("/addList", handleToken, async (ctx: TheContext) => {
const tokenInfo = ctx["theToken"];
/** 接收参数 */
const params = ctx.request.body;
/** 返回结果 */
let bodyResult;
if (!params.content) {
return ctx.body = apiSuccess({}, "添加的列表内容不能为空!", 400);
}
const mysqlInfo = utils.mysqlFormatParams({
"content": params.content,
"create_user_id": tokenInfo.id,
"create_time": utils.formatDate()
})
// 写入列表
const res = await query(`insert into todo_table(${mysqlInfo.keys}) values(${mysqlInfo.symbols})`, mysqlInfo.values)
// console.log("写入列表", res);
if (res.state === 1) {
bodyResult = apiSuccess({
id: res.results.insertId
}, "添加成功");
} else {
ctx.response.status = 500;
bodyResult = apiFail(res.msg, 500, res.error);
}
ctx.body = bodyResult;
})
// 修改列表
router.post("/modifyList", handleToken, async (ctx: TheContext) => {
const tokenInfo = ctx["theToken"];
/** 接收参数 */
const params = ctx.request.body;
/** 返回结果 */
let bodyResult;
if (!params.id) {
return ctx.body = apiSuccess({}, "列表id不能为空", 400);
}
if (!params.content) {
return ctx.body = apiSuccess({}, "列表内容不能为空", 400);
}
const setData = utils.mysqlSetParams({
"content": params.content,
"update_time": utils.formatDate(),
"update_user_id": tokenInfo.id
})
// 修改列表
const res = await query(`update todo_table ${setData} where id = '${params.id}'`)
// console.log("修改列表", res);
if (res.state === 1) {
if (res.results.affectedRows > 0) {
bodyResult = apiSuccess({}, "修改成功");
} else {
bodyResult = apiSuccess({}, "列表id不存在", 400);
}
} else {
ctx.response.status = 500;
bodyResult = apiFail(res.msg, 500, res.error);
}
ctx.body = bodyResult;
})
// 删除列表
router.post("/deleteList", handleToken, async (ctx: TheContext) => {
// const state = ctx["theToken"];
/** 接收参数 */
const params = ctx.request.body;
/** 返回结果 */
let bodyResult;
// 从数据库中删除
// const res = await query(`delete from todo_table where id='${params.id}' and user_id='${state.info.id}'`)
const res = await query(`delete from todo_table where id = '${params.id}'`)
// const res = await query(`delete from todo_table where id in(${params.ids.toString()})`) // 批量删除
// console.log("从数据库中删除", res);
if (res.state === 1) {
if (res.results.affectedRows > 0) {
bodyResult = apiSuccess({}, "删除成功");
} else {
bodyResult = apiSuccess({}, "当前列表id不存在或已删除", 400);
}
} else {
ctx.response.status = 500;
bodyResult = apiFail(res.msg, 500, res.error);
}
ctx.body = bodyResult;
})
这样就完成增删改查的功能了
上传文件处理
src/routes/upload.ts
import * as fs from "fs";
import * as path from "path";
import router from "./main";
import config from "../modules/Config";
import { apiSuccess } from "../utils/apiResult";
import { UploadFile } from "../types/base";
// 上传文件
// learn: https://www.cnblogs.com/nicederen/p/10758000.html
// learn: https://blog.csdn.net/qq_24134853/article/details/81745104
// [图片类型参考](https://developer.mozilla.org/zh-CN/docs/Web/Media/Formats/Image_types#webp_image)
router.post("/uploadFile", async (ctx, next) => {
const file: UploadFile = ctx.request.files[config.uploadName] as any;
// console.log("file >>", file);
const fileName = file.name;
// console.log("fileName >>", fileName);
const isImage = file.type.includes("image");
/** 目录名 */
const folder = isImage ? "/images/" : "/assets/";
/** 上传的资源目录名 */
const folderPath = config.uploadPath + folder;
// 创建可读流
const render = fs.createReadStream(file.path);
const filePath = path.join(folderPath, fileName);
const fileDir = path.join(folderPath);
if (!fs.existsSync(fileDir)) {
fs.mkdirSync(fileDir);
}
// 创建写入流
const upStream = fs.createWriteStream(filePath);
render.pipe(upStream);
// console.log(fileName, file);
/** 模拟上传到`oss`云存储 */
function uploadToCloud() {
const result = {
image: ""
}
return new Promise<{ image: string }>(function (resolve) {
const delay = Math.floor(Math.random() * 5) * 100 + 500;
setTimeout(() => {
result.image = `http://${config.ip}:${config.port}${folder}${fileName}`;
resolve(result);
}, delay);
});
}
const res = await uploadToCloud();
ctx.body = apiSuccess(res, "上传成功");
})
上传至阿里云
首先安装对应的SDK,这里看文档操作就可以了,代码十分简单
# 模块依赖
npm install ali-oss -S
# 类型包
npm install @types/ali-oss -D
代码片段
import * as path from "path";
import * as OSS from "ali-oss";
import router from "./main";
import config from "../modules/Config";
import { TheContext, UploadFile } from "../utils/interfaces";
import { apiSuccess } from "../utils/apiResult";
/**
* - [阿里云 OSS-API 文档](https://help.aliyun.com/document_detail/32068.html?spm=a2c4g.11186623.6.1074.612626fdu6LBB7)
* - [用户管理](https://ram.console.aliyun.com/users)
*/
const client = new OSS({
// yourregion填写Bucket所在地域。以华东1(杭州)为例,Region填写为oss-cn-hangzhou。
region: "oss-cn-guangzhou",
accessKeyId: "阿里云平台生成的key",
accessKeySecret: "阿里云平后台生成的secret",
bucket: "指定上传的 bucket 名",
});
// 上传图片
// learn: https://www.cnblogs.com/nicederen/p/10758000.html
// learn: https://blog.csdn.net/qq_24134853/article/details/81745104
router.post("/uploadImg", async ctx => {
const file: UploadFile = ctx.request.files[config.uploadImgName] as any;
// console.log("file >>", file);
let fileName: string = `${Math.random().toString(36).slice(2)}-${Date.now()}-${file.name}`;
const result = await client.put(`images/${fileName}`, path.normalize(file.path));
// console.log("上传文件结果 >>", result);
ctx.body = apiSuccess(result, "上传成功");
})
用户模块程序设计
这里我将所有用户的信息全部存储在一个变量中,在做一些通过用户id
去匹配信息时,就可以非常方便的从这个变量中去获取,避免了频繁的查询数据库,最重要的是,这个操作是同步的;比如数据库查询出来的数据只有userId
,那么我就可以通过内存中的这个变量去读取到userName
等相关信息;在后面做token
相关的认证校验处理中同样适用。
src/modules/TableUser.ts
import utils from "../utils";
import { query } from "../utils/mysql";
import { TableUserInfo } from "../types/user";
import { BaseObj } from "../types/base";
class ModuleUser {
constructor() {
this.update();
}
/** 用户缓存表格数据 */
table = new Map<string, TableUserInfo>();
/** 用户总数 */
get total() {
return this.table.size;
}
/** 从数据库中更新缓存用户表 */
async update() {
const res = await query("select * from user_table")
if (res.state === 1) {
const list: Array<TableUserInfo> = res.results || [];
this.table.clear();
for (let i = 0; i < list.length; i++) {
const item = utils.objectToHump(list[i]) as TableUserInfo;
item.createTime = utils.formatDate(item.createTime);
this.table.set(item.id.toString(), item);
}
console.log("\x1B[42m 更新用户表缓存 \x1B[0m", this.total, "条数据");
} else {
console.log("用户表更新失败 >>", res.msg, res.error);
}
}
/**
* 新增用户
* @param id
* @param value 用户信息
*/
add(id: number, value: TableUserInfo) {
this.table.set(id.toString(), value);
console.log("\x1B[42m 新增用户 \x1B[0m", value);
}
/**
* 通过用户`id`获取对应用户信息
* @param id 用户`id`
*/
getUserById(id: number | string) {
return this.table.get(id.toString());
}
/**
* 通过`id`删除用户记录
* @param id
*/
remove(id: number) {
this.table.delete(id.toString());
}
/**
* 通过`id`更新指定用户信息
* @param id
* @param value 用户信息
*/
updateById(id: number, value: Partial<TableUserInfo>) {
const user = this.getUserById(id);
if (user) {
utils.modifyData(user, value);
}
}
/**
* 【单个对象】匹配用户名,包括:创建用户名、编辑用户名
* - 并返回新的驼峰数据对象
* @param item
*/
matchName(item: BaseObj) {
const createId = item["create_user_id"] as number;
const updateId = item["update_user_id"] as number;
item["create_user_name"] = this.getUserById(createId)?.name || "";
if (updateId) {
item["update_user_name"] = this.getUserById(updateId)?.name || "";
}
return utils.objectToHump(item);
}
/**
* 【数组】匹配用户名,包括:创建用户名、编辑用户名
* - 并返回驼峰数据对象
* @param list
* @returns
*/
matchNameArray<T extends BaseObj>(list: Array<T>) {
const result = [];
for (let i = 0; i < list.length; i++) {
const item = this.matchName(list[i]);
result.push(item);
}
return result;
}
}
/**
* 用户表数据
* - 缓存一份到内存里面,方便读取使用
*/
const tableUser = new ModuleUser();
export default tableUser;
对应的,在用户相关的数据库操作中,增删改都应该同步tableUser
中的数据,这样才能保证tableUser
数据的准确性。
再回到路由目录文件src/routes/user.ts
中
import router from "./main";
import { query } from "../utils/mysql";
import jwt from "../modules/Jwt";
import { handleToken } from "../middleware";
import { apiSuccess, apiFail } from "../utils/apiResult";
import utils from "../utils";
import tableUser from "../modules/TableUser";
import { ApiResult, TheContext } from "../types/base";
import { UserInfo } from "../types/user";
// 注册
router.post("/register", async (ctx) => {
/** 接收参数 */
const params: UserInfo = ctx.request.body;
/** 返回结果 */
let bodyResult: ApiResult;
/** 账号是否可用 */
let validAccount = false;
// console.log("注册传参", params);
if (!/^[A-Za-z0-9]+$/.test(params.account)) {
return ctx.body = apiSuccess({}, "注册失败!账号必须由英文或数字组成", 400);
}
if (!/^[A-Za-z0-9]+$/.test(params.password)) {
return ctx.body = apiSuccess({}, "注册失败!密码必须由英文或数字组成", 400);
}
if (!params.name.trim()) {
params.name = "用户未设置昵称";
}
// 先查询是否有重复账号
const res = await query(`select account from user_table where account='${params.account}'`)
// console.log("注册查询", res);
if (res.state === 1) {
if (res.results.length > 0) {
bodyResult = apiSuccess({}, "该账号已被注册", 400);
} else {
validAccount = true;
}
} else {
ctx.response.status = 500;
bodyResult = apiFail(res.msg, 500, res.error);
}
// 再写入表格
if (validAccount) {
/** 暂无分组、用户类型、创建用户id;所以给以默认值,方便后面扩充使用 */
const defaultValue = 1;
const createTime = utils.formatDate();
const mysqlInfo = utils.mysqlFormatParams({
"account": params.account,
"password": params.password,
"name": params.name,
"create_time": createTime,
"type": defaultValue,
"group_id": defaultValue,
"create_user_id": defaultValue
})
// const res = await query(`insert into user_table(${mysqlInfo.keys}) values(${mysqlInfo.values})`) 这样也可以,不过 mysqlInfo.values 每个值都必须用单引号括起来,下面的方式就不用
const res = await query(`insert into user_table(${mysqlInfo.keys}) values(${mysqlInfo.symbols})`, mysqlInfo.values)
if (res.state === 1) {
bodyResult = apiSuccess(params, "注册成功");
const userId: number = res.results.insertId;
tableUser.add(userId, {
id: userId,
account: params.account,
password: params.password,
name: params.name,
type: defaultValue,
groupId: defaultValue,
createUserId: defaultValue,
createTime: createTime,
})
} else {
ctx.response.status = 500;
bodyResult = apiFail(res.msg, 500, res.error);
}
}
ctx.body = bodyResult;
})
// 登录
router.post("/login", async (ctx) => {
/** 接收参数 */
const params: UserInfo = ctx.request.body;
/** 返回结果 */
let bodyResult: ApiResult;
// console.log("登录", params);
if (!params.account || params.account.trim() === "") {
return ctx.body = apiSuccess({}, "登录失败!账号不能为空", 400);
}
if (!params.password || params.password.trim() === "") {
return ctx.body = apiSuccess({}, "登录失败!密码不能为空", 400);
}
// 先查询是否有当前账号
const res = await query(`select * from user_table where account='${params.account}'`)
// console.log("登录查询", res);
if (res.state === 1) {
// 再判断账号是否可用
if (res.results.length > 0) {
const data = utils.objectToHump(res.results[0]) as UserInfo;
// console.log("login UserInfo >>", data);
// 最后判断密码是否正确
if (data.password == params.password) {
data.token = jwt.createToken({
id: data.id,
account: data.account,
password: data.password,
type: data.type,
groupId: data.groupId,
})
bodyResult = apiSuccess(data, "登录成功");
} else {
bodyResult = apiSuccess({}, "密码不正确", 400);
}
} else {
bodyResult = apiSuccess({}, "该账号不存在,请先注册", 400);
}
} else {
ctx.response.status = 500;
bodyResult = apiFail(res.msg, 500, res.error);
}
ctx.body = bodyResult;
})
// 获取用户信息
router.get("/getUserInfo", handleToken, async (ctx: TheContext) => {
const tokenInfo = ctx["theToken"];
/** 返回结果 */
let bodyResult: ApiResult;
const res = await query(`select * from user_table where account = '${tokenInfo.account}'`)
// console.log("获取用户信息 >>", res);
if (res.state === 1) {
// 判断账号是否可用
if (res.results.length > 0) {
const data: UserInfo = res.results[0];
bodyResult = apiSuccess(utils.objectToHump(data));
} else {
bodyResult = apiSuccess({}, "该账号不存在,可能已经从数据库中删除", 400);
}
} else {
ctx.response.status = 500;
bodyResult = apiFail(res.msg, 500, res.error);
}
ctx.body = bodyResult;
})
// 编辑用户信息
router.post("/editUserInfo", handleToken, async (ctx: TheContext) => {
const tokenInfo = ctx["theToken"];
/** 接收参数 */
const params: UserInfo = ctx.request.body;
/** 返回结果 */
let bodyResult: ApiResult;
/** 账号是否可用 */
let validAccount = false;
// console.log("注册传参", params);
if (!params.id) {
ctx.response.status = 400;
return ctx.body = apiSuccess({}, "编辑失败!用户id不正确", 400);
}
if (!params.account || !/^[A-Za-z0-9]+$/.test(params.account)) {
ctx.response.status = 400;
return ctx.body = apiSuccess({}, "编辑失败!账号必须由英文或数字组成", 400);
}
if (!params.password || !/^[A-Za-z0-9]+$/.test(params.password)) {
ctx.response.status = 400;
return ctx.body = apiSuccess({}, "编辑失败!密码必须由英文或数字组成", 400);
}
if (utils.checkType(params.groupId) !== "number") {
ctx.response.status = 400;
return ctx.body = apiSuccess({}, "编辑失败!分组不正确", 400);
}
if (!params.name.trim()) {
params.name = "用户-" + utils.formatDate(Date.now(), "YMDhms");
}
if (tableUser.getUserById(params.id)) {
validAccount = true;
for (const iterator of tableUser.table) {
const user = iterator[1];
if (user.account == params.account && user.id != params.id) {
validAccount = false;
bodyResult = apiSuccess({}, "当前账户已存在", -1);
break;
}
}
} else {
bodyResult = apiSuccess({}, "当前用户 id 不存在", -1);
}
// 再写入表格
if (validAccount) {
const createTime = utils.formatDate();
const setData = utils.mysqlSetParams({
"account": params.account,
"password": params.password,
"name": params.name,
"type": params.type,
"group_id": params.groupId,
"update_time": createTime,
"update_user_id": tokenInfo.id
})
// console.log("修改用户信息语句 >>", `update user_table ${setData} where id = '${params.id}'`);
const res = await query(`update user_table ${setData} where id = '${params.id}'`)
// console.log("再写入表格 >>", res);
if (res.state === 1) {
const data: { token?: string } = {};
tableUser.updateById(params.id, {
password: params.password,
name: params.name,
type: params.type,
groupId: params.groupId,
updateUserId: tokenInfo.id,
updateTime: createTime,
})
// 判断是否修改自己信息
if (params.id == tokenInfo.id) {
data.token = jwt.createToken({
id: params.id,
account: params.account,
password: params.password,
type: params.type,
groupId: params.groupId,
})
}
bodyResult = apiSuccess(data, "编辑成功");
} else {
ctx.response.status = 500;
bodyResult = apiFail(res.msg, 500, res.error);
}
}
ctx.body = bodyResult;
})
// 删除用户
router.post("/deleteUser", handleToken, async (ctx: TheContext) => {
const tokenInfo = ctx["theToken"];
/** 接收参数 */
const params = ctx.request.body as UserInfo;
// console.log(params);
if (tokenInfo && tokenInfo.type != 0) {
return ctx.body = apiSuccess({}, "当前账号没有权限删除用户", -1);
}
if (!params.id) {
ctx.response.status = 400;
return ctx.body = apiSuccess({}, "编辑失败!用户id不正确", 400);
}
/** 返回结果 */
let bodyResult: ApiResult;
// 从数据库中删除
const res = await query(`delete from user_table where id = '${params.id}'`)
// console.log("获取用户列表 >>", res);
if (res.state === 1) {
if (res.results.affectedRows > 0) {
bodyResult = apiSuccess({}, "删除成功");
tableUser.remove(params.id);
// 异步删除所有关联到的表单数据即可,不需要等待响应
// query(`delete from street_shop_table where user_id='${params.id}'`)
} else {
bodyResult = apiSuccess({}, "当前列表id不存在或已删除", 400);
}
} else {
ctx.response.status = 500;
bodyResult = apiFail(res.msg, 500, res.error);
}
ctx.body = bodyResult;
})
// 退出登录
router.get("/logout", handleToken, ctx => {
const token: string = ctx.header.authorization;
if (token) {
return ctx.body = apiSuccess({}, "退出登录成功");
} else {
return ctx.body = apiSuccess({}, "token 不存在", 400);
}
})
如果其他表的数据字段有userID
的就可以通过tableUser
去直接匹配上了。
token认证的使用
或许有疑惑:这不应该要在最前面就该说明的吗?为什么我要将他放在最后,理由就是我的做法和传统的都不同,绝大部分服务端都是用的第三方集成库,对应的搜jwt
就会有的了;但是这些库在一些特定的情况下不太灵活,比如我需要和前面的handleToken
、tokenInfo
还有tableUser
的联动时,这些第三方库就出现不兼容情况,所以我需要的token应该是扩展性和自由度都满足的情况;
思路:
- 设计
token
字段的响应和拦截、读取、解析操作;我这里把一些用户主要字段组装成一个对象,然后转成base64
返回给客户端,读取的时候再安装组装的规则解析即可; - 同时这里将多加一个字段
online
,代表用户的在线时间,这样在每次接口处理时,就可以判断这个token
是否过期等操作; - 在登录方法中,将用户信息加密后返回给客户端,当修改用户信息时,可以对应的做处理,比如下线、重新登录、重新生成
token
操作;
src/modules/Jwt.ts
import tableUser from "./TableUser";
import { apiSuccess } from "../utils/apiResult";
import { ApiResult, TheContext } from "../types/base";
import { UserInfoToken, UserInfo } from "../types/user";
class ModuleJWT {
constructor() {
// tableUser.update();
}
/** 效期(小时) */
private maxAge = 24 * 7;
/** 前缀长度 */
private prefixSize = 8;
/**
* 通过用户信息创建`token`
* @param info 用户信息
*/
createToken(info: Omit<UserInfo, "name">) {
const decode = JSON.stringify({
i: info.id,
a: info.account,
p: info.password,
t: info.type,
g: info.groupId,
o: Date.now()
} as UserInfoToken);
// const decode = encodeURI(JSON.stringify(info))
const base64 = Buffer.from(decode, "utf-8").toString("base64");
const secret = Math.random().toString(36).slice(2).slice(0, this.prefixSize);
return secret + base64;
}
/**
* 检测需要`token`的接口状态
* @param ctx `http`上下文
*/
checkToken(ctx: TheContext) {
const token: string = ctx.header.authorization;
/** 是否失败的`token` */
let fail = false;
/** 检测结果信息 */
let info: ApiResult;
/**
* 设置失败信息
* @param msg
*/
function setFail(msg: string) {
fail = true;
ctx.response.status = 401;
info = apiSuccess({}, msg, -1);
}
if (!token) {
setFail("缺少 token");
}
if (token && token.length < this.prefixSize * 2) {
setFail("token 错误");
}
if (!fail) {
/** 准备解析的字符串 */
const str = Buffer.from(token.slice(this.prefixSize), "base64").toString("utf-8");
/** 解析出来的结果 */
let result: UserInfoToken;
try {
result = JSON.parse(str);
} catch (error) {
console.log("错误的 token 解析", error);
setFail("token 错误");
}
if (!fail) {
if (result.o && Date.now() - result.o < this.maxAge * 3600000) {
const info = tableUser.getUserById(result.i);
// console.log("userInfo >>", info);
// console.log("token 解析 >>", result);
if (info) {
// 设置`token`信息到上下文中给接口模块里面调用
// 1. 严格判断账号、密码、用户权限等是否相同
// 2. 后台管理修改个人信息之后需要重新返回`token`
if (info.password == result.p && info.groupId == result.g && info.type == result.t) {
ctx["theToken"] = info;
} else {
setFail("token 已失效");
}
} else {
setFail("token 不存在");
}
} else {
setFail("token 过期");
}
}
}
return {
fail,
info
}
}
}
/** `jwt-token`模块 */
const jwt = new ModuleJWT();
export default jwt;
这样一个自定义的“jwt”模块就完成了,接下来再写一个处理token
的中间件
再回到src/middleware/index.ts
中
/**
* 中间件-处理`token`验证
* @param ctx
* @param next
* @description 需要`token`验证的接口时使用
*/
export async function handleToken(ctx: TheContext, next: Next) {
const checkInfo = jwt.checkToken(ctx);
if (checkInfo.fail) {
ctx.body = checkInfo.info;
} else {
await next();
}
}
后面需要对token
做其他一些事情,就方便多了;最后再看回之前的路由代码,就明白const tokenInfo = ctx["theToken"];
是什么意思了,只需要在用户相关的路由方法中,加入中间件,就能够获取当前用户信息,从而做一些相关的操作。
项目构建推送到线上
- 购买云服务器 ECS腾讯阿里随意;
- 安装对应的
node.js
、myql
、git
等工具,实质跟新电脑装工具一样,只不过服务器上是Linux
系统,操作起来需要借助MobaXterm这个工具来远程连接,然后安装和进行其他操作,这里不展开说了,都是不用写代码的... - 创建目录然后用
git
把代码拉下来,npm install
再运行npm run build
就可以了,但是发现退出之后就关闭服务了,所以还要再安装一个进程管理 pm2 来代替我们手动去npm run build
; - 安装完
pm2
之后还需要在当前项目根目录下写一个pm2.json
的配置运行文件,类似package.json
一样,代码片段如下:
{
// 进程的名字,进程的名字要对应`package.json`中的`name`,
// 不然会报错,虽然不影响我们程序运行,但是最好还是避免出现不必要的报错
"name": "node-koa-service",
// 运行代码的命令,对应`package.json`里面的即可
"script": "npm run build"
}
最后执行pm2 start pm2.json
命令就启动项目了,要想查看和更多操作则参考下面命令
# 启动任务
pm2 start pm2.json
# 强制停止所有的服务
pm2 kill
# 查看服务
pm2 list
# 查看指定进程日志,0是任务列表索引值
pm2 log 0
# 重启指定进程,0是任务列表索引值
pm2 restart 0
# 停止指定进程,0是任务列表索引值
pm2 stop 0
补充
当前项目下的public/template/*.html
都是演示页面,接口请求测试不一定要通过页面发起,也可以使用postman
等工具去进行调试接口