项目主体搭建

  • 前端:vue3element-plustsaxiosvue-routerpinia
  • 后端:nodejskoakoa-routerkoa-bodyjsonwebtoken
  • 部署:nginxpm2xshell、腾讯云自带宝塔面板
  • 数据库:mysqlredis
  • 开发软件:vs codeAnother Redis Desktop ManagerNavicat Premium 15

后端主要使用的依赖包

  • dotenv: 将环境变量中的变量从 .env 文件加载到 process.env
  • jsonwebtoken: 颁发token,不会缓存到mysqlredis
  • koa: 快速搭建后台服务的框架
  • koa-body: 解析前端传来的参数,并将参数挂到ctx.request.body
  • koa-router: 路由中间件,处理不同url路径的请求
  • koa2-cors: 处理跨域请求的中间件
  • mysql2: 在nodejs中连接和操作mysql数据库,mysql也可以,不过要自己封装连接数据库
  • redis: 在nodejs中操作redis的库,通常用作持久化token、点赞等功能
  • sequelize: 基于promise的orm(对象关系映射)库,不用写sql语句,更方便的操作数据库
  • nodemon: 自动重启服务
  • sequelize-automete: 自动化为sequelize生成模型

文件划分

常量文件配置

  1. 创建.env.developmentconfig文件夹,配置如下
# 数据库ip地址APP_HOST = 1.15.42.9# 服务监听端口APP_PORT = 40001# 数据库名APP_DATA_BASE = test# 用户名APP_USERNAME = test# 密码APP_PASSWORD = 123456# redis地址APP_REDIS_HOST = 1.15.42.9# redis端口APP_REDIS_PORT = 6379# redis密码APP_REDIS_PASSWORD = 123456# redis仓库APP_REDIS_DB = 15# websocket后缀APP_BASE_PATH = /# token标识APP_JWT_SECRET = LOVE-TOKEN# 保存文件的绝对路径APP_FILE_PATH = ""# 网络url地址APP_NETWORK_PATH = blob:http://192.168.10.20:40001/

然后在config文件中将.env的配置暴露出去

const dotenv = require("dotenv");const path = process.env.NODE_ENV  ? ".env.development"  : ".env.production.local";dotenv.config({ path });module.exports = process.env;

2.在最外层创建app.js文件

const Koa = require("koa");const http = require("http");const cors = require("koa2-cors");const WebSocket = require("ws");const { koaBody } = require("koa-body");const { APP_PORT, APP_BASE_PATH } = require("./src/config/index");const router = require("./src/router/index");const seq = require("./src/mysql/sequelize");const PersonModel = require("./src/models/person");const Mperson = PersonModel(seq);// 创建一个Koa对象const app = new Koa();const server = http.createServer(app.callback());// 同时需要在nginx配置/wsconst wss = new WebSocket.Server({ server, path: APP_BASE_PATH }); // 同一端口监听不同的服务// 使用了代理app.proxy = true;// 处理跨域app.use(cors());// 解析请求体(也可以使用koa-body)app.use(  koaBody({    multipart: true,    // textLimit: "1mb",  // 限制text body的大小,默认56kb    formLimit: "10mb", // 限制表单请求体的大小,默认56kb,前端报错413    // encoding: "gzip",    // 前端报415    formidable: {      // uploadDir: path.join(__dirname, "./static/"), // 设置文件上传目录      keepExtensions: true, // 保持文件的后缀      // 最大文件上传大小为512MB(如果使用反向代理nginx,需要设置client_max_body_size)      maxFieldsSize: 512 * 1024 * 1024,    },  }));app.use(router.routes());wss.on("connection", function (ws) {  let messageIndex = 0;  ws.on("message", async function (data, isBinary) {    console.log(data);    const message = isBinary ? data : data.toString();    if (JSON.parse(message).type !== "personData") {      return;    }    const result = await Mperson.findAll();    wss.clients.forEach((client) => {      messageIndex++;      client.send(JSON.stringify(result));    });  });  ws.onmessage = (msg) => {    ws.send(JSON.stringify({ isUpdate: false, message: "pong" }));  };  ws.onclose = () => {    console.log("服务连接关闭");  };  ws.send(JSON.stringify({ isUpdate: false, message: "首次建立连接" }));});server.listen(APP_PORT, () => {  const host = process.env.APP_REDIS_HOST;  const port = process.env.APP_PORT;  console.log(    `环境:${      process.env.NODE_ENV ? "开发环境" : "生产环境"    },服务器地址:http://${host}:${port}/findExcerpt`  );});module.exports = server;

这里可以先不引入websocketMperson,这是后续发布内容时才会用到。

注:app.use(cors())必须在app.use(router.routes())之前,不然访问路由时会显示跨域。

3.在package.json中添加命令"dev": "set NODE_ENV=development && nodemon app.js",
然后就可以直接运行npm run dev启动服务了

mysql创建数据库建表

在服务器上开放mysql端口3306,还有接下来使用到的redis端口6379
使用root连接数据库,可以看到所有的数据库。

注:sequelize6版本最低支持mysql5.7,虽然不会报错,但是有提示

mysql默认情况下不允许root直接连接,需要手动放开

  • 进入mysql:mysql -uroot -p
  • 使用mysql:use mysql;
  • 授权给所有ip:GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' IDENTIFIED BY '123456(密码)' WITH GRANT OPTION;
  • 刷新权限:FLUSH PRIVILEGES;

redis使用密码远程连接后,就不需要在本地安装redis

redis默认也是不允许远程连接,需要手动放开。

  1. 使用find / -name redis.conf先找到redis配置文件

    • bind 127.0.0.1修改为bind 0.0.0.0;
    • 设置protected-mode no;
    • 设置密码requirepass 123456密码123456替换为自己的。
  2. 进入src使用./redis-server ../redis.conf重新启动

使用sequelize-automate自动生成表模型

创建文件sequelize-automate.config.js,然后在package.json增加一条命令"automate": "sequelize-automate -c './sequelize-automate.config.js'",使用npm run automate自动生成表模型。

注:建议自己维护createdAtupdatedAt,因为在Navicat Premium 15上创建表后,自动生成的模型虽然会自动增加createdAtupdatedAt,但是不会同步到数据库上,需要删除数据库中的表,然后再重新启动服务才会同步,但是当id为主键且不为null时,模板会生成defaultValuenull,在个别表中会报错

const Automate = require("sequelize-automate");const dbOptions = {  database: "test",  username: "test",  password: "123456",  dialect: "mysql",  host: "1.15.42.9",  port: 3306,  logging: false,  define: {    underscored: false,    freezeTableName: false,    charset: "utf8mb4",    timezone: "+00:00",    dialectOptions: {      collate: "utf8_general_ci",    },    timestamps: true, // 自动创建createAt和updateAt  },};const options = {  type: "js",  dir: "./src/models",  camelCase: true,};const automate = new Automate(dbOptions, options);(async function main() {  const code = await automate.run();  console.log(code);})();

连接mysql和redismysql连接,timezone默认是世界时区, “+08:00″指标准时间加8个小时,也就是北京时间

const { Sequelize } = require("sequelize");const { APP_DATA_BASE, APP_USERNAME, APP_PASSWORD, APP_DATA_HOST } = require("../config/index");const seq = new Sequelize(APP_DATA_BASE, APP_USERNAME, APP_PASSWORD, {  host: APP_DATA_HOST,  dialect: "mysql",  define: {    timestamps: true,  },  timezone: "+08:00"});seq  .authenticate()  .then(() => {    console.log("数据库连接成功");  })  .catch((err) => {    console.log("数据库连接失败", err);  });seq.sync();// 强制同步数据库,会先删除表,然后创建 seq.sync({ force: true });module.exports = seq;

redis连接,database不同环境使用不同的分片

const Redis = require("redis");const {  APP_REDIS_HOST,  APP_REDIS_PORT,  APP_REDIS_PASSWORD,  APP_REDIS_DB} = require("../config");const client = Redis.createClient({  url: "redis://" + APP_REDIS_HOST + ":" + APP_REDIS_PORT,  host: APP_REDIS_HOST,  port: APP_REDIS_PORT,  password: APP_REDIS_PASSWORD,  database: APP_REDIS_DB,});client.connect();client.on("connect", () => {  console.log("Redis连接成功!");});// 连接错误处理client.on("error", (err) => {  console.error(err);});// client.set("age", 1);module.exports = client;

一切都没问题后,现在开始编写路由代码,也就是一个个接口

编写登录注册

controllers新建user.js模块,具体逻辑:登录和注册是同一个接口,前端提交时,会先判断这个用户是否存在,不存在会将传来的密码进行加密,然后将用户信息存入到数据库,同时使jsonwebtoken颁发token,token本身是无状态的,也就是说,在时间到期之前不会销毁!这里可以在服务端维护一个令牌黑名单,用于退出登录。

const response = require("../utils/resData");const bcrypt = require("bcryptjs");const jwt = require("jsonwebtoken");const { APP_JWT_SECRET } = require("../config/index");const seq = require("../mysql/sequelize");const UserModel = require("../models/user");const Muser = UserModel(seq);// 类定义class User {  constructor() {}  // 注册用户  async register(ctx, next) {    try {      const { userName: user_name, password: pass_word } = ctx.request.body;      if (!user_name || !pass_word) {        ctx.body = response.ERROR("userNotNull");        return;      }      // 判断用户是否存在      const isExist = await Muser.findOne({        where: {          user_name: user_name,        },      });      if (isExist) {        const res = await Muser.findOne({          where: {            user_name: user_name,          },        });        // 密码是否正确        if (bcrypt.compareSync(pass_word, res.dataValues.password)) {          // 登录成功          const { password, ...data } = res.dataValues;          ctx.body = response.SUCCESS("userLogin", {            token: jwt.sign(data, APP_JWT_SECRET, { expiresIn: "30d" }),            userInfo: res.dataValues,          });        } else {          ctx.body = response.ERROR("userAlreadyExist");        }      } else {        // 加密        const salt = bcrypt.genSaltSync(10);        // hash保存的是 密文        const hash = bcrypt.hashSync(pass_word, salt);        const userInfo = await Muser.create({ user_name, password: hash });        const { password, ...data } = userInfo.dataValues;        ctx.body = response.SUCCESS("userRegister", {          token: jwt.sign(data, APP_JWT_SECRET, { expiresIn: "30d" }),          userInfo,        });      }    } catch (error) {      console.error(error);      ctx.body = response.SERVER_ERROR();    }  }}module.exports = new User();

增加校验用户是否登录的中间件

middleware创建index.js,将用户信息挂载到ctx.state.user上,方便后续别的地方需要用到用户信息

const jwt = require("jsonwebtoken");const { APP_JWT_SECRET } = require("../config/index");const response = require("../utils/resData");// 上传文件时,如果存在token,将token添加到state,方便后面使用,没有或者失效,则返回nullconst auth = async (ctx, next) => {  // 会返回小写secret  const token = ctx.request.header["love-token"];  if (token) {    try {      const user = jwt.verify(token, APP_JWT_SECRET);      // 在已经颁发token接口上面添加user对象,便于使用      ctx.state.user = user;    } catch (error) {      ctx.state.user = null;      console.log(error.name);      if (error.name === "TokenExpiredError") {        ctx.body = response.ERROR("tokenExpired");      } else if (error.name === "JsonWebTokenError") {        ctx.body = response.ERROR("tokenInvaild");      } else {        ctx.body = response.ERROR("unknownError");      }      return;    }  }  await next();};module.exports = {  auth,};

增加路由页面

controllers所有文件都导入到index.js中,然后再暴露出去,这样在router文件就只需要引入controllers/index.js即可

const hotword = require("./hotword");const issue = require("./issue");const person = require("./person");const user = require("./user");const common = require("./common");const wallpaper = require("./wallpaper");const fileList = require("./fileList");const ips = require("./ips");module.exports = {  hotword,  issue,  person,  user,  common,  wallpaper,  fileList,  ips,};
const router = require("koa-router")();const {  hotword,  person,  issue,  common,  user,  wallpaper,  fileList,  ips,} = require("../controllers/index");const { auth } = require("../middleware/index");// router.get("/", async (ctx) => {//   ctx.body = "欢迎访问该接口";// });router.get("/wy/find", hotword.findHotword);router.get("/wy/pageQuery", hotword.findPageHotword);// 登录才能删除,修改评论(协商缓存)router.get("/findExcerpt", person.findExcerpt);router.get("/addExcerpt", person.addExcerpt);router.get("/updateExcerpt", auth, person.updateExcerpt);router.get("/delExcerpt", auth, person.delExcerpt);// 不走缓存router.post("/findIssue", issue.findIssue);router.post("/addIssue", issue.addIssue);router.post("/delIssue", issue.delIssue);router.post("/editIssue", issue.editIssue);router.post("/register/user", user.register);router.post("/upload/file", common.uploadFile);router.post("/paste/upload/file", common.pasteUploadFile);// router.get("/download/file/:name", common.downloadFile);// 强缓存router.get("/wallpaper/findList", wallpaper.findPageWallpaper);// 文件列表router.post("/file/list", auth, fileList.findFileLsit);router.post("/save/list", auth, fileList.saveFileInfo);router.post("/delete/file", auth, fileList.delFile);// iprouter.post("/find/ipList", (ctx, next) => ips.findIpsList(ctx, next));module.exports = router;

注:需要用户登录的接口在路由前加auth中间件即可

获取ip、上传、下载获取ip

获取ip可以单独提取出来,当作中间件,这里当用户访问我的ip地址页面时,会自动在数据库中添加记录,同时使用redis存储当前ip,10分钟内再次访问不会再增加。

注:当服务在线上使用nginx反向代理时,需要在app.js添加app.proxy = true,同时需要配置nginx来获取用户真实ip

const response = require("../utils/resData");const { getClientIP } = require("../utils/common");const seq = require("../mysql/sequelize");const IpsModel = require("../models/ips");const MIps = IpsModel(seq);const client = require("../utils/redis");const { reqIp } = require("../api/ip");class Ips {  constructor() {}  async findIpsList(ctx, next) {    try {      if (!process.env.NODE_ENV) {        await this.addIps(ctx, next);      }      const { size, page } = ctx.request.body;      const total = await MIps.findAndCountAll();      const data = await MIps.findAll({        order: [["id", "DESC"]],        limit: parseInt(size),        offset: parseInt(size) * (page - 1),      });      ctx.body = response.SUCCESS("common", { total: total.count, data });    } catch (error) {      console.error(error);      ctx.body = response.SERVER_ERROR();    }  }  async addIps(ctx, next) {    try {      const ip = getClientIP(ctx);      const res = await client.get(ip);      // 没有才在redis中设置      if (!res) {        // 需要将值转为字符串        await client.set(ip, new Date().toString(), {          EX: 10 * 60, // 以秒为单位存储10分钟          NX: true, // 键不存在时才进行set操作        });        if (ip.length > 6) {          const obj = {            id: Date.now(),            ip,          };          const info = await reqIp({ ip });          if (info.code === 200) {            obj.operator = info.ipdata.isp;            obj.address = info.adcode.o;            await MIps.create(obj);          } else {            console.log("ip接口请求失败");          }        }      }    } catch (error) {      console.error(error);    }    await next();  }}module.exports = new Ips();
location / {  proxy_set_header Host $http_host;  proxy_set_header X-Real-IP $remote_addr;  proxy_set_header REMOTE-HOST $remote_addr;  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;  proxy_set_header Public-Network-URL http://$http_host$request_uri;  proxy_pass http://127.0.0.1:40001;}

上传

配置文件.env需要配置好绝对路径:APP_FILE_PATH = /任意位置

const fs = require("fs");const path = require("path");const crypto = require("crypto");const response = require("../utils/resData");const { APP_NETWORK_PATH, APP_FILE_PATH } = require("../config/index");class Common {  constructor() {}  async uploadFile(ctx, next) {    try {      const { file } = ctx.request.files;      // 检查文件夹是否存在,不存在则创建文件夹      if (!fs.existsSync(APP_FILE_PATH)) {        APP_FILE_PATH && fs.mkdirSync(APP_FILE_PATH);      }      // 上传的文件具体地址      let filePath = path.join(        APP_FILE_PATH || __dirname,        `${APP_FILE_PATH ? "./" : "../static/"}${file.originalFilename}`      );      // 创建可读流(默认一次读64kb)      const reader = fs.createReadStream(file.filepath);      // 创建可写流      const upStream = fs.createWriteStream(filePath);      // 可读流通过管道写入可写流      reader.pipe(upStream);      const fileInfo = {        id: Date.now(),        url: APP_NETWORK_PATH + file.originalFilename,        name: file.originalFilename,        size: file.size,        type: file.originalFilename.match(/[^.]+$/)[0],      };      ctx.body = response.SUCCESS("common", fileInfo);    } catch (error) {      console.error(error);      ctx.body = response.ERROR("upload");    }  }  async pasteUploadFile(ctx, next) {    try {      const { file } = ctx.request.body;      const dataBuffer = Buffer.from(file, "base64");      // 生成随机40个字符的hash      const hash = crypto.randomBytes(20).toString("hex");      // 文件名      const filename = hash + ".png";      let filePath = path.join(        APP_FILE_PATH || __dirname,        `${APP_FILE_PATH ? "./" : "../static/"}${filename}`      );      // 以写入模式打开文件,文件不存在则创建      const fd = fs.openSync(filePath, "w");      // 写入      fs.writeSync(fd, dataBuffer);      // 关闭      fs.closeSync(fd);      const fileInfo = {        id: Date.now(),        url: APP_NETWORK_PATH + filename,        name: filename,        size: file.size || "",        type: 'png',      };      ctx.body = response.SUCCESS("common", fileInfo);    } catch (error) {      console.error(error);      ctx.body = response.ERROR("upload");    }  }}module.exports = new Common();

下载

直接配置nginx即可,当客户端请求路径以 /static 开头的静态资源时,nginx 会从指定的文件系统路径 /www/wwwroot/note.loveverse.top/static 中获取相应的文件。用于将 /static 路径下的静态资源标记为下载类型,用户访问这些资源时告诉浏览器下载文件

location /static {  add_header Content-Type application/x-download;  alias   /www/wwwroot/note.loveverse.top/static;}

小程序登录和公众号验证小程序

.env中新增如配置,在middleware/index.js中增加refresh中间件

# 微信公众号appIDAPP_ID = wx862588761a1a5465# 微信公众号appSecretAPP_SECRET = a06938aae54d2f72a41e4710854354534# 微信公众号tokenAPP_TOKEN = 543543# 微信小程序appIDAPP_MINI_ID = wx663ca454434243# 微信小程序密钥APP_MINI_SECRET = ee17b15f95fcd597243243432432
const refresh = async (ctx, next) => {  // 将openId挂载到ids,供全局使用  const token = ctx.request.header["mini-love-token"];  if (token) {    try {      // -1说明没有设置过期时间,-2表示不存在该键      const ttl = await client.ttl(token);      if (ttl === -2) {        ctx.body = response.WARN("token已失效,请重新登录", 401);        return;      }      // 过期时间小于一个月,增加过期时间      if (ttl < 30 * 24 * 60 * 60) {        await client.expire(token, 60 * 24 * 60 * 60);      }      // 挂载openid      const ids = await client.get(token);      const info = ids.split(":");      ctx.state.ids = {        sessionId: info[0],        openId: info[1],      };    } catch (error) {      ctx.state.ids = null;      console.error(error);      ctx.body = response.ERROR("redis获取token失败");      return;    }  } else {    ctx.body = response.WARN("请先进行登录", 401);    return;  }  await next();};

公众号验证

安装xml2js,将包含 XML 数据的字符串解析为 JavaScript 对象传给公众号。在utils/constant.js添加常量,然后编写响应公众号的接口chat.js。这里公众号调试难免会需要将开发环境映射到公网,这里使用xshell隧道做内网穿透

const template = ` <![CDATA[]]> <![CDATA[]]>  <![CDATA[]]>      <![CDATA[]]> <![CDATA[]]> <![CDATA[]]> <![CDATA[]]>      <![CDATA[]]> <![CDATA[]]> <![CDATA[]]> <![CDATA[]]>    <![CDATA[]]>    <![CDATA[]]>     <![CDATA[]]> `;module.exports = { template };
const crypto = require("crypto");const xml2js = require("xml2js");const ejs = require("ejs");const {  template} = require("../utils/constant");const { APP_ID, APP_SECRET, APP_TOKEN } = require("../config/index");const response = require("../utils/resData");const wechat = {  appID: APP_ID,  appSecret: APP_SECRET,  token: APP_TOKEN,};const compiled = ejs.compile(template);function reply(content = "", fromUsername, toUsername) {  const info = {};  let type = "text";  info.content = content;  if (Array.isArray(content)) {    type = "news";  } else if (typeof content === "object") {    if (content.hasOwnProperty("type")) {      type = content.type;      info.content = content.content;    } else {      type = "music";    }  }  info.msgType = type;  info.createTime = new Date().getTime();  info.fromUsername = fromUsername;  info.toUsername = toUsername;  return compiled(info);}class Wechat {  constructor() {}  // 公众号验证  async verifyWechat(ctx, next) {    try {      let {        signature = "",        timestamp = "",        nonce = "",        echostr = "",      } = ctx.query;      const token = wechat.token;      // 验证token      let str = [token, timestamp, nonce].sort().join("");      let sha1 = crypto.createHash("sha1").update(str).digest("hex");      if (sha1 !== signature) {        ctx.body = "token验证失败";      } else {        // 验证成功        if (JSON.stringify(ctx.request.body) === "{}") {          ctx.body = echostr;        } else {          // 解析公众号xml          let obj = await xml2js.parseStringPromise(ctx.request.body);          let xmlObj = {};          for (const item in obj.xml) {            xmlObj[item] = obj.xml[item][0];          }          console.info("[ xmlObj.Content ] >", xmlObj);          // 文本消息          if (xmlObj.MsgType === "text") {              const replyMessageXml = reply(                str,                xmlObj.ToUserName,                xmlObj.FromUserName              );              ctx.type = "application/xml";              ctx.body = replyMessageXml;            // 关注消息          } else if (xmlObj.MsgType === "event") {            if (xmlObj.Event === "subscribe") {              const str = msg.attendion;              const replyMessageXml = reply(                str,                xmlObj.ToUserName,                xmlObj.FromUserName              );              ctx.type = "application/xml";              ctx.body = replyMessageXml;            } else {              ctx.body = null;            }            // 其他消息          } else {            const str = msg.other;            const replyMessageXml = reply(              str,              xmlObj.ToUserName,              xmlObj.FromUserName            );            ctx.type = "application/xml";            ctx.body = replyMessageXml;          }        }      }    } catch (error) {      console.error(error);      // 返回500,公众号会报错      ctx.body = null;    }  }}module.exports = new Wechat();

注:公众号和小程序是另一个项目,所以不在github上

调试

以脚本命令的方式运行,可以看到完整的数据打印,排查问题更方便

存在的问题

  1. websocket服务端没封装,应该单独分出一个文件来写。
  2. ip地址记录只有访问ip列表这个页面才会增加,一直停留在其他页面无法获取ip记录
  3. pseron接口测试协商缓存不生效,返回304,但是浏览器还是显示200
  4. sequelize-automate自动生成的createdAtupdatedAt没有同步到数据库中
  5. 上传文件缓慢

等等还有其他一些未列举的问题

代码链接

https://github.com/loveverse/love-blog

参考文章

https://github.com/jj112358/node-api(nodejs从0到1更加详细版)

https://www.freesion.com/article/34551095837/(xshell内网穿透原理)