node + koa + ts 构建服务端应用

13,875 阅读15分钟

适合新手或前端学习 node.js 做后台的起步教程

这里我使用typescript去编写,理由是因为非常好用的类型提示和代码追踪,在纯javascript编程的项目中typescript是最好维护和阅读的(当然因人而异)。

代码地址

功能清单:

  1. 项目构建和配置
  2. 前后端请求header基本配置说明
  3. 编写测试接口
  4. 连接数据库处理
  5. 接口与数据库处理
  6. 上传文件处理
  7. 用户模块程序设计
  8. token认证的使用(基于用户模块设计)
  9. 项目构建推送到线上

先来看下目录结构

每个目录下面都有一个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传参格式:jsonFormDatafile等作用;
  • 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

  1. 这里用upupw命令窗口获取数据的账号密码、或者在下图所示目录文件获取账号密码后,在navicat工具中新建一个数据库的连接,照着账号密码填就行,注意不要改默认的端口,不然会连接失败。

Screenshot 2022-10-06 210435.png

  1. 连接好后在左侧栏新建一个数据库,叫node_ts,代码中config.db.database就是这个,待会建表也是在这个栏目下面建表。

  1. 开始建两个表user_tabletodo_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的表建好之后,就可以开始写增删改查的操作了,下面代码中:handleTokentokenInfo是用户的相关属性对象,因为比较复杂,所以放在下文中再详细说明;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就会有的了;但是这些库在一些特定的情况下不太灵活,比如我需要和前面的handleTokentokenInfo还有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"];是什么意思了,只需要在用户相关的路由方法中,加入中间件,就能够获取当前用户信息,从而做一些相关的操作。

项目构建推送到线上

  1. 购买云服务器 ECS腾讯阿里随意;
  2. 安装对应的node.jsmyqlgit等工具,实质跟新电脑装工具一样,只不过服务器上是Linux系统,操作起来需要借助MobaXterm这个工具来远程连接,然后安装和进行其他操作,这里不展开说了,都是不用写代码的...
  3. 创建目录然后用git把代码拉下来,npm install再运行npm run build就可以了,但是发现退出之后就关闭服务了,所以还要再安装一个进程管理 pm2 来代替我们手动去npm run build;
  4. 安装完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等工具去进行调试接口