翼度科技»论坛 编程开发 JavaScript 查看内容

nodejs从0到1搭建后台服务

3

主题

3

帖子

9

积分

新手上路

Rank: 1

积分
9
项目主体搭建


  • 前端:vue3、element-plus、ts、axios、vue-router、pinia
  • 后端:nodejs、koa、koa-router、koa-body、jsonwebtoken
  • 部署:nginx、pm2、xshell、腾讯云自带宝塔面板
  • 数据库:mysql、redis
  • 开发软件:vs code、Another Redis Desktop Manager、Navicat Premium 15
后端主要使用的依赖包


  • dotenv: 将环境变量中的变量从 .env 文件加载到 process.env 中
  • jsonwebtoken: 颁发token,不会缓存到mysql和redis中
  • 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生成模型
文件划分


常量文件配置


  • 创建.env.development和config文件夹,配置如下
  1. # 数据库ip地址
  2. APP_HOST = 1.15.42.9
  3. # 服务监听端口
  4. APP_PORT = 40001
  5. # 数据库名
  6. APP_DATA_BASE = test
  7. # 用户名
  8. APP_USERNAME = test
  9. # 密码
  10. APP_PASSWORD = 123456
  11. # redis地址
  12. APP_REDIS_HOST = 1.15.42.9
  13. # redis端口
  14. APP_REDIS_PORT = 6379
  15. # redis密码
  16. APP_REDIS_PASSWORD = 123456
  17. # redis仓库
  18. APP_REDIS_DB = 15
  19. # websocket后缀
  20. APP_BASE_PATH = /
  21. # token标识
  22. APP_JWT_SECRET = LOVE-TOKEN
  23. # 保存文件的绝对路径
  24. APP_FILE_PATH = ""
  25. # 网络url地址
  26. APP_NETWORK_PATH = blob:http://192.168.10.20:40001/
复制代码
然后在config文件中将.env的配置暴露出去
  1. const dotenv = require("dotenv");
  2. const path = process.env.NODE_ENV
  3.   ? ".env.development"
  4.   : ".env.production.local";
  5. dotenv.config({ path });
  6. module.exports = process.env;
复制代码
2.在最外层创建app.js文件
  1. const Koa = require("koa");
  2. const http = require("http");
  3. const cors = require("koa2-cors");
  4. const WebSocket = require("ws");
  5. const { koaBody } = require("koa-body");
  6. const { APP_PORT, APP_BASE_PATH } = require("./src/config/index");
  7. const router = require("./src/router/index");
  8. const seq = require("./src/mysql/sequelize");
  9. const PersonModel = require("./src/models/person");
  10. const Mperson = PersonModel(seq);
  11. // 创建一个Koa对象
  12. const app = new Koa();
  13. const server = http.createServer(app.callback());
  14. // 同时需要在nginx配置/ws
  15. const wss = new WebSocket.Server({ server, path: APP_BASE_PATH }); // 同一端口监听不同的服务
  16. // 使用了代理
  17. app.proxy = true;
  18. // 处理跨域
  19. app.use(cors());
  20. // 解析请求体(也可以使用koa-body)
  21. app.use(
  22.   koaBody({
  23.     multipart: true,
  24.     // textLimit: "1mb",  // 限制text body的大小,默认56kb
  25.     formLimit: "10mb", // 限制表单请求体的大小,默认56kb,前端报错413
  26.     // encoding: "gzip",    // 前端报415
  27.     formidable: {
  28.       // uploadDir: path.join(__dirname, "./static/"), // 设置文件上传目录
  29.       keepExtensions: true, // 保持文件的后缀
  30.       // 最大文件上传大小为512MB(如果使用反向代理nginx,需要设置client_max_body_size)
  31.       maxFieldsSize: 512 * 1024 * 1024,
  32.     },
  33.   })
  34. );
  35. app.use(router.routes());
  36. wss.on("connection", function (ws) {
  37.   let messageIndex = 0;
  38.   ws.on("message", async function (data, isBinary) {
  39.     console.log(data);
  40.     const message = isBinary ? data : data.toString();
  41.     if (JSON.parse(message).type !== "personData") {
  42.       return;
  43.     }
  44.     const result = await Mperson.findAll();
  45.     wss.clients.forEach((client) => {
  46.       messageIndex++;
  47.       client.send(JSON.stringify(result));
  48.     });
  49.   });
  50.   ws.onmessage = (msg) => {
  51.     ws.send(JSON.stringify({ isUpdate: false, message: "pong" }));
  52.   };
  53.   ws.onclose = () => {
  54.     console.log("服务连接关闭");
  55.   };
  56.   ws.send(JSON.stringify({ isUpdate: false, message: "首次建立连接" }));
  57. });
  58. server.listen(APP_PORT, () => {
  59.   const host = process.env.APP_REDIS_HOST;
  60.   const port = process.env.APP_PORT;
  61.   console.log(
  62.     `环境:${
  63.       process.env.NODE_ENV ? "开发环境" : "生产环境"
  64.     },服务器地址:http://${host}:${port}/findExcerpt`
  65.   );
  66. });
  67. module.exports = server;
复制代码
这里可以先不引入websocket和Mperson,这是后续发布内容时才会用到。
注: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默认也是不允许远程连接,需要手动放开。

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

    • 将bind 127.0.0.1修改为bind 0.0.0.0;
    • 设置protected-mode no;
    • 设置密码requirepass 123456密码123456替换为自己的。

  • 进入src使用./redis-server ../redis.conf重新启动
使用sequelize-automate自动生成表模型

创建文件sequelize-automate.config.js,然后在package.json增加一条命令"automate": "sequelize-automate -c './sequelize-automate.config.js'",使用npm run automate自动生成表模型。
注:建议自己维护createdAt和updatedAt,因为在Navicat Premium 15上创建表后,自动生成的模型虽然会自动增加createdAt和updatedAt,但是不会同步到数据库上,需要删除数据库中的表,然后再重新启动服务才会同步,但是当id为主键且不为null时,模板会生成defaultValuenull,在个别表中会报错
  1. const Automate = require("sequelize-automate");
  2. const dbOptions = {
  3.   database: "test",
  4.   username: "test",
  5.   password: "123456",
  6.   dialect: "mysql",
  7.   host: "1.15.42.9",
  8.   port: 3306,
  9.   logging: false,
  10.   define: {
  11.     underscored: false,
  12.     freezeTableName: false,
  13.     charset: "utf8mb4",
  14.     timezone: "+00:00",
  15.     dialectOptions: {
  16.       collate: "utf8_general_ci",
  17.     },
  18.     timestamps: true, // 自动创建createAt和updateAt
  19.   },
  20. };
  21. const options = {
  22.   type: "js",
  23.   dir: "./src/models",
  24.   camelCase: true,
  25. };
  26. const automate = new Automate(dbOptions, options);
  27. (async function main() {
  28.   const code = await automate.run();
  29.   console.log(code);
  30. })();
复制代码
连接mysql和redis

mysql连接,timezone默认是世界时区, "+08:00"指标准时间加8个小时,也就是北京时间
  1. const { Sequelize } = require("sequelize");
  2. const { APP_DATA_BASE, APP_USERNAME, APP_PASSWORD, APP_DATA_HOST } = require("../config/index");
  3. const seq = new Sequelize(APP_DATA_BASE, APP_USERNAME, APP_PASSWORD, {
  4.   host: APP_DATA_HOST,
  5.   dialect: "mysql",
  6.   define: {
  7.     timestamps: true,
  8.   },
  9.   timezone: "+08:00"
  10. });
  11. seq
  12.   .authenticate()
  13.   .then(() => {
  14.     console.log("数据库连接成功");
  15.   })
  16.   .catch((err) => {
  17.     console.log("数据库连接失败", err);
  18.   });
  19. seq.sync();
  20. // 强制同步数据库,会先删除表,然后创建 seq.sync({ force: true });
  21. module.exports = seq;
复制代码
redis连接,database不同环境使用不同的分片
  1. const Redis = require("redis");
  2. const {
  3.   APP_REDIS_HOST,
  4.   APP_REDIS_PORT,
  5.   APP_REDIS_PASSWORD,
  6.   APP_REDIS_DB
  7. } = require("../config");
  8. const client = Redis.createClient({
  9.   url: "redis://" + APP_REDIS_HOST + ":" + APP_REDIS_PORT,
  10.   host: APP_REDIS_HOST,
  11.   port: APP_REDIS_PORT,
  12.   password: APP_REDIS_PASSWORD,
  13.   database: APP_REDIS_DB,
  14. });
  15. client.connect();
  16. client.on("connect", () => {
  17.   console.log("Redis连接成功!");
  18. });
  19. // 连接错误处理
  20. client.on("error", (err) => {
  21.   console.error(err);
  22. });
  23. // client.set("age", 1);
  24. module.exports = client;
复制代码
一切都没问题后,现在开始编写路由代码,也就是一个个接口
编写登录注册

在controllers新建user.js模块,具体逻辑:登录和注册是同一个接口,前端提交时,会先判断这个用户是否存在,不存在会将传来的密码进行加密,然后将用户信息存入到数据库,同时使jsonwebtoken颁发token,token本身是无状态的,也就是说,在时间到期之前不会销毁!这里可以在服务端维护一个令牌黑名单,用于退出登录。
  1. const response = require("../utils/resData");
  2. const bcrypt = require("bcryptjs");
  3. const jwt = require("jsonwebtoken");
  4. const { APP_JWT_SECRET } = require("../config/index");
  5. const seq = require("../mysql/sequelize");
  6. const UserModel = require("../models/user");
  7. const Muser = UserModel(seq);
  8. // 类定义
  9. class User {
  10.   constructor() {}
  11.   // 注册用户
  12.   async register(ctx, next) {
  13.     try {
  14.       const { userName: user_name, password: pass_word } = ctx.request.body;
  15.       if (!user_name || !pass_word) {
  16.         ctx.body = response.ERROR("userNotNull");
  17.         return;
  18.       }
  19.       // 判断用户是否存在
  20.       const isExist = await Muser.findOne({
  21.         where: {
  22.           user_name: user_name,
  23.         },
  24.       });
  25.       if (isExist) {
  26.         const res = await Muser.findOne({
  27.           where: {
  28.             user_name: user_name,
  29.           },
  30.         });
  31.         // 密码是否正确
  32.         if (bcrypt.compareSync(pass_word, res.dataValues.password)) {
  33.           // 登录成功
  34.           const { password, ...data } = res.dataValues;
  35.           ctx.body = response.SUCCESS("userLogin", {
  36.             token: jwt.sign(data, APP_JWT_SECRET, { expiresIn: "30d" }),
  37.             userInfo: res.dataValues,
  38.           });
  39.         } else {
  40.           ctx.body = response.ERROR("userAlreadyExist");
  41.         }
  42.       } else {
  43.         // 加密
  44.         const salt = bcrypt.genSaltSync(10);
  45.         // hash保存的是 密文
  46.         const hash = bcrypt.hashSync(pass_word, salt);
  47.         const userInfo = await Muser.create({ user_name, password: hash });
  48.         const { password, ...data } = userInfo.dataValues;
  49.         ctx.body = response.SUCCESS("userRegister", {
  50.           token: jwt.sign(data, APP_JWT_SECRET, { expiresIn: "30d" }),
  51.           userInfo,
  52.         });
  53.       }
  54.     } catch (error) {
  55.       console.error(error);
  56.       ctx.body = response.SERVER_ERROR();
  57.     }
  58.   }
  59. }
  60. module.exports = new User();
复制代码
增加校验用户是否登录的中间件

在middleware创建index.js,将用户信息挂载到ctx.state.user上,方便后续别的地方需要用到用户信息
  1. const jwt = require("jsonwebtoken");
  2. const { APP_JWT_SECRET } = require("../config/index");
  3. const response = require("../utils/resData");
  4. // 上传文件时,如果存在token,将token添加到state,方便后面使用,没有或者失效,则返回null
  5. const auth = async (ctx, next) => {
  6.   // 会返回小写secret
  7.   const token = ctx.request.header["love-token"];
  8.   if (token) {
  9.     try {
  10.       const user = jwt.verify(token, APP_JWT_SECRET);
  11.       // 在已经颁发token接口上面添加user对象,便于使用
  12.       ctx.state.user = user;
  13.     } catch (error) {
  14.       ctx.state.user = null;
  15.       console.log(error.name);
  16.       if (error.name === "TokenExpiredError") {
  17.         ctx.body = response.ERROR("tokenExpired");
  18.       } else if (error.name === "JsonWebTokenError") {
  19.         ctx.body = response.ERROR("tokenInvaild");
  20.       } else {
  21.         ctx.body = response.ERROR("unknownError");
  22.       }
  23.       return;
  24.     }
  25.   }
  26.   await next();
  27. };
  28. module.exports = {
  29.   auth,
  30. };
复制代码
增加路由页面

将controllers所有文件都导入到index.js中,然后再暴露出去,这样在router文件就只需要引入controllers/index.js即可
  1. const hotword = require("./hotword");
  2. const issue = require("./issue");
  3. const person = require("./person");
  4. const user = require("./user");
  5. const common = require("./common");
  6. const wallpaper = require("./wallpaper");
  7. const fileList = require("./fileList");
  8. const ips = require("./ips");
  9. module.exports = {
  10.   hotword,
  11.   issue,
  12.   person,
  13.   user,
  14.   common,
  15.   wallpaper,
  16.   fileList,
  17.   ips,
  18. };
复制代码
  1. const router = require("koa-router")();
  2. const {
  3.   hotword,
  4.   person,
  5.   issue,
  6.   common,
  7.   user,
  8.   wallpaper,
  9.   fileList,
  10.   ips,
  11. } = require("../controllers/index");
  12. const { auth } = require("../middleware/index");
  13. // router.get("/", async (ctx) => {
  14. //   ctx.body = "欢迎访问该接口";
  15. // });
  16. router.get("/wy/find", hotword.findHotword);
  17. router.get("/wy/pageQuery", hotword.findPageHotword);
  18. // 登录才能删除,修改评论(协商缓存)
  19. router.get("/findExcerpt", person.findExcerpt);
  20. router.get("/addExcerpt", person.addExcerpt);
  21. router.get("/updateExcerpt", auth, person.updateExcerpt);
  22. router.get("/delExcerpt", auth, person.delExcerpt);
  23. // 不走缓存
  24. router.post("/findIssue", issue.findIssue);
  25. router.post("/addIssue", issue.addIssue);
  26. router.post("/delIssue", issue.delIssue);
  27. router.post("/editIssue", issue.editIssue);
  28. router.post("/register/user", user.register);
  29. router.post("/upload/file", common.uploadFile);
  30. router.post("/paste/upload/file", common.pasteUploadFile);
  31. // router.get("/download/file/:name", common.downloadFile);
  32. // 强缓存
  33. router.get("/wallpaper/findList", wallpaper.findPageWallpaper);
  34. // 文件列表
  35. router.post("/file/list", auth, fileList.findFileLsit);
  36. router.post("/save/list", auth, fileList.saveFileInfo);
  37. router.post("/delete/file", auth, fileList.delFile);
  38. // ip
  39. router.post("/find/ipList", (ctx, next) => ips.findIpsList(ctx, next));
  40. module.exports = router;
复制代码
注:需要用户登录的接口在路由前加auth中间件即可
获取ip、上传、下载

获取ip

获取ip可以单独提取出来,当作中间件,这里当用户访问我的ip地址页面时,会自动在数据库中添加记录,同时使用redis存储当前ip,10分钟内再次访问不会再增加。
注:当服务在线上使用nginx反向代理时,需要在app.js添加app.proxy = true,同时需要配置nginx来获取用户真实ip
  1. const response = require("../utils/resData");
  2. const { getClientIP } = require("../utils/common");
  3. const seq = require("../mysql/sequelize");
  4. const IpsModel = require("../models/ips");
  5. const MIps = IpsModel(seq);
  6. const client = require("../utils/redis");
  7. const { reqIp } = require("../api/ip");
  8. class Ips {
  9.   constructor() {}
  10.   async findIpsList(ctx, next) {
  11.     try {
  12.       if (!process.env.NODE_ENV) {
  13.         await this.addIps(ctx, next);
  14.       }
  15.       const { size, page } = ctx.request.body;
  16.       const total = await MIps.findAndCountAll();
  17.       const data = await MIps.findAll({
  18.         order: [["id", "DESC"]],
  19.         limit: parseInt(size),
  20.         offset: parseInt(size) * (page - 1),
  21.       });
  22.       ctx.body = response.SUCCESS("common", { total: total.count, data });
  23.     } catch (error) {
  24.       console.error(error);
  25.       ctx.body = response.SERVER_ERROR();
  26.     }
  27.   }
  28.   async addIps(ctx, next) {
  29.     try {
  30.       const ip = getClientIP(ctx);
  31.       const res = await client.get(ip);
  32.       // 没有才在redis中设置
  33.       if (!res) {
  34.         // 需要将值转为字符串
  35.         await client.set(ip, new Date().toString(), {
  36.           EX: 10 * 60, // 以秒为单位存储10分钟
  37.           NX: true, // 键不存在时才进行set操作
  38.         });
  39.         if (ip.length > 6) {
  40.           const obj = {
  41.             id: Date.now(),
  42.             ip,
  43.           };
  44.           const info = await reqIp({ ip });
  45.           if (info.code === 200) {
  46.             obj.operator = info.ipdata.isp;
  47.             obj.address = info.adcode.o;
  48.             await MIps.create(obj);
  49.           } else {
  50.             console.log("ip接口请求失败");
  51.           }
  52.         }
  53.       }
  54.     } catch (error) {
  55.       console.error(error);
  56.     }
  57.     await next();
  58.   }
  59. }
  60. module.exports = new Ips();
复制代码
  1. location / {
  2.   proxy_set_header Host $http_host;
  3.   proxy_set_header X-Real-IP $remote_addr;
  4.   proxy_set_header REMOTE-HOST $remote_addr;
  5.   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  6.   proxy_set_header Public-Network-URL http://$http_host$request_uri;
  7.   proxy_pass http://127.0.0.1:40001;
  8. }
复制代码
上传

配置文件.env需要配置好绝对路径:APP_FILE_PATH = /任意位置
  1. const fs = require("fs");
  2. const path = require("path");
  3. const crypto = require("crypto");
  4. const response = require("../utils/resData");
  5. const { APP_NETWORK_PATH, APP_FILE_PATH } = require("../config/index");
  6. class Common {
  7.   constructor() {}
  8.   async uploadFile(ctx, next) {
  9.     try {
  10.       const { file } = ctx.request.files;
  11.       // 检查文件夹是否存在,不存在则创建文件夹
  12.       if (!fs.existsSync(APP_FILE_PATH)) {
  13.         APP_FILE_PATH && fs.mkdirSync(APP_FILE_PATH);
  14.       }
  15.       // 上传的文件具体地址
  16.       let filePath = path.join(
  17.         APP_FILE_PATH || __dirname,
  18.         `${APP_FILE_PATH ? "./" : "../static/"}${file.originalFilename}`
  19.       );
  20.       // 创建可读流(默认一次读64kb)
  21.       const reader = fs.createReadStream(file.filepath);
  22.       // 创建可写流
  23.       const upStream = fs.createWriteStream(filePath);
  24.       // 可读流通过管道写入可写流
  25.       reader.pipe(upStream);
  26.       const fileInfo = {
  27.         id: Date.now(),
  28.         url: APP_NETWORK_PATH + file.originalFilename,
  29.         name: file.originalFilename,
  30.         size: file.size,
  31.         type: file.originalFilename.match(/[^.]+$/)[0],
  32.       };
  33.       ctx.body = response.SUCCESS("common", fileInfo);
  34.     } catch (error) {
  35.       console.error(error);
  36.       ctx.body = response.ERROR("upload");
  37.     }
  38.   }
  39.   async pasteUploadFile(ctx, next) {
  40.     try {
  41.       const { file } = ctx.request.body;
  42.       const dataBuffer = Buffer.from(file, "base64");
  43.       // 生成随机40个字符的hash
  44.       const hash = crypto.randomBytes(20).toString("hex");
  45.       // 文件名
  46.       const filename = hash + ".png";
  47.       let filePath = path.join(
  48.         APP_FILE_PATH || __dirname,
  49.         `${APP_FILE_PATH ? "./" : "../static/"}${filename}`
  50.       );
  51.       // 以写入模式打开文件,文件不存在则创建
  52.       const fd = fs.openSync(filePath, "w");
  53.       // 写入
  54.       fs.writeSync(fd, dataBuffer);
  55.       // 关闭
  56.       fs.closeSync(fd);
  57.       const fileInfo = {
  58.         id: Date.now(),
  59.         url: APP_NETWORK_PATH + filename,
  60.         name: filename,
  61.         size: file.size || "",
  62.         type: 'png',
  63.       };
  64.       ctx.body = response.SUCCESS("common", fileInfo);
  65.     } catch (error) {
  66.       console.error(error);
  67.       ctx.body = response.ERROR("upload");
  68.     }
  69.   }
  70. }
  71. module.exports = new Common();
复制代码
下载

直接配置nginx即可,当客户端请求路径以 /static 开头的静态资源时,nginx 会从指定的文件系统路径 /www/wwwroot/note.loveverse.top/static 中获取相应的文件。用于将 /static 路径下的静态资源标记为下载类型,用户访问这些资源时告诉浏览器下载文件
  1. location /static {
  2.   add_header Content-Type application/x-download;
  3.   alias   /www/wwwroot/note.loveverse.top/static;
  4. }
复制代码
小程序登录和公众号验证

小程序

在.env中新增如配置,在middleware/index.js中增加refresh中间件
  1. # 微信公众号appID
  2. APP_ID = wx862588761a1a5465
  3. # 微信公众号appSecret
  4. APP_SECRET = a06938aae54d2f72a41e4710854354534
  5. # 微信公众号token
  6. APP_TOKEN = 543543
  7. # 微信小程序appID
  8. APP_MINI_ID = wx663ca454434243
  9. # 微信小程序密钥
  10. APP_MINI_SECRET = ee17b15f95fcd597243243432432
复制代码
  1. const refresh = async (ctx, next) => {
  2.   // 将openId挂载到ids,供全局使用
  3.   const token = ctx.request.header["mini-love-token"];
  4.   if (token) {
  5.     try {
  6.       // -1说明没有设置过期时间,-2表示不存在该键
  7.       const ttl = await client.ttl(token);
  8.       if (ttl === -2) {
  9.         ctx.body = response.WARN("token已失效,请重新登录", 401);
  10.         return;
  11.       }
  12.       // 过期时间小于一个月,增加过期时间
  13.       if (ttl < 30 * 24 * 60 * 60) {
  14.         await client.expire(token, 60 * 24 * 60 * 60);
  15.       }
  16.       // 挂载openid
  17.       const ids = await client.get(token);
  18.       const info = ids.split(":");
  19.       ctx.state.ids = {
  20.         sessionId: info[0],
  21.         openId: info[1],
  22.       };
  23.     } catch (error) {
  24.       ctx.state.ids = null;
  25.       console.error(error);
  26.       ctx.body = response.ERROR("redis获取token失败");
  27.       return;
  28.     }
  29.   } else {
  30.     ctx.body = response.WARN("请先进行登录", 401);
  31.     return;
  32.   }
  33.   await next();
  34. };
复制代码
公众号验证

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

  1. const template = `
  2. <xml>
  3. <ToUserName><![CDATA[<%-toUsername%>]]></ToUserName>
  4. <FromUserName><![CDATA[<%-fromUsername%>]]></FromUserName>
  5. <CreateTime><%=createTime%></CreateTime>
  6. <MsgType><![CDATA[<%=msgType%>]]></MsgType>
  7. <% if (msgType === 'news') { %>
  8. <ArticleCount><%=content.length%></ArticleCount>
  9. <Articles>
  10. <% content.forEach(function(item){ %>
  11. <item>
  12. <Title><![CDATA[<%-item.title%>]]></Title>
  13. <Description><![CDATA[<%-item.description%>]]></Description>
  14. <PicUrl><![CDATA[<%-item.picUrl || item.picurl || item.pic || item.thumb_url %>]]></PicUrl>
  15. <Url><![CDATA[<%-item.url%>]]></Url>
  16. </item>
  17. <% }); %>
  18. </Articles>
  19. <% } else if (msgType === 'music') { %>
  20. <Music>
  21. <Title><![CDATA[<%-content.title%>]]></Title>
  22. <Description><![CDATA[<%-content.description%>]]></Description>
  23. <MusicUrl><![CDATA[<%-content.musicUrl || content.url %>]]></MusicUrl>
  24. <HQMusicUrl><![CDATA[<%-content.hqMusicUrl || content.hqUrl %>]]></HQMusicUrl>
  25. </Music>
  26. <% } else if (msgType === 'voice') { %>
  27. <Voice>
  28. <MediaId><![CDATA[<%-content.mediaId%>]]></MediaId>
  29. </Voice>
  30. <% } else if (msgType === 'image') { %>
  31. <Image>
  32. <MediaId><![CDATA[<%-content.mediaId%>]]></MediaId>
  33. </Image>
  34. <% } else if (msgType === 'video') { %>
  35. <Video>
  36. <MediaId><![CDATA[<%-content.mediaId%>]]></MediaId>
  37. <Title><![CDATA[<%-content.title%>]]></Title>
  38. <Description><![CDATA[<%-content.description%>]]></Description>
  39. </Video>
  40. <% } else { %>
  41. <Content><![CDATA[<%-content%>]]></Content>
  42. <% } %>
  43. </xml>
  44. `;
  45. module.exports = { template };
复制代码
  1. const crypto = require("crypto");
  2. const xml2js = require("xml2js");
  3. const ejs = require("ejs");
  4. const {
  5.   template
  6. } = require("../utils/constant");
  7. const { APP_ID, APP_SECRET, APP_TOKEN } = require("../config/index");
  8. const response = require("../utils/resData");
  9. const wechat = {
  10.   appID: APP_ID,
  11.   appSecret: APP_SECRET,
  12.   token: APP_TOKEN,
  13. };
  14. const compiled = ejs.compile(template);
  15. function reply(content = "", fromUsername, toUsername) {
  16.   const info = {};
  17.   let type = "text";
  18.   info.content = content;
  19.   if (Array.isArray(content)) {
  20.     type = "news";
  21.   } else if (typeof content === "object") {
  22.     if (content.hasOwnProperty("type")) {
  23.       type = content.type;
  24.       info.content = content.content;
  25.     } else {
  26.       type = "music";
  27.     }
  28.   }
  29.   info.msgType = type;
  30.   info.createTime = new Date().getTime();
  31.   info.fromUsername = fromUsername;
  32.   info.toUsername = toUsername;
  33.   return compiled(info);
  34. }
  35. class Wechat {
  36.   constructor() {}
  37.   // 公众号验证
  38.   async verifyWechat(ctx, next) {
  39.     try {
  40.       let {
  41.         signature = "",
  42.         timestamp = "",
  43.         nonce = "",
  44.         echostr = "",
  45.       } = ctx.query;
  46.       const token = wechat.token;
  47.       // 验证token
  48.       let str = [token, timestamp, nonce].sort().join("");
  49.       let sha1 = crypto.createHash("sha1").update(str).digest("hex");
  50.       if (sha1 !== signature) {
  51.         ctx.body = "token验证失败";
  52.       } else {
  53.         // 验证成功
  54.         if (JSON.stringify(ctx.request.body) === "{}") {
  55.           ctx.body = echostr;
  56.         } else {
  57.           // 解析公众号xml
  58.           let obj = await xml2js.parseStringPromise(ctx.request.body);
  59.           let xmlObj = {};
  60.           for (const item in obj.xml) {
  61.             xmlObj[item] = obj.xml[item][0];
  62.           }
  63.           console.info("[ xmlObj.Content ] >", xmlObj);
  64.           // 文本消息
  65.           if (xmlObj.MsgType === "text") {
  66.               const replyMessageXml = reply(
  67.                 str,
  68.                 xmlObj.ToUserName,
  69.                 xmlObj.FromUserName
  70.               );
  71.               ctx.type = "application/xml";
  72.               ctx.body = replyMessageXml;
  73.             // 关注消息
  74.           } else if (xmlObj.MsgType === "event") {
  75.             if (xmlObj.Event === "subscribe") {
  76.               const str = msg.attendion;
  77.               const replyMessageXml = reply(
  78.                 str,
  79.                 xmlObj.ToUserName,
  80.                 xmlObj.FromUserName
  81.               );
  82.               ctx.type = "application/xml";
  83.               ctx.body = replyMessageXml;
  84.             } else {
  85.               ctx.body = null;
  86.             }
  87.             // 其他消息
  88.           } else {
  89.             const str = msg.other;
  90.             const replyMessageXml = reply(
  91.               str,
  92.               xmlObj.ToUserName,
  93.               xmlObj.FromUserName
  94.             );
  95.             ctx.type = "application/xml";
  96.             ctx.body = replyMessageXml;
  97.           }
  98.         }
  99.       }
  100.     } catch (error) {
  101.       console.error(error);
  102.       // 返回500,公众号会报错
  103.       ctx.body = null;
  104.     }
  105.   }
  106. }
  107. module.exports = new Wechat();
复制代码
注:公众号和小程序是另一个项目,所以不在github上
调试

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

存在的问题


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

https://github.com/loveverse/love-blog
参考文章

https://github.com/jj112358/node-api(nodejs从0到1更加详细版)
https://www.freesion.com/article/34551095837/(xshell内网穿透原理)

来源:https://www.cnblogs.com/loveverse/p/17661367.html
免责声明:由于采集信息均来自互联网,如果侵犯了您的权益,请联系我们【E-Mail:cb@itdo.tech】 我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x

举报 回复 使用道具