从零搭建博客之后台开发

从零搭建博客之后台开发

2019年12月22日 阅读:53 字数:2313 阅读时长:5 分钟

包括Restful api、图片裁剪、文件上传、评论系统、图片验证码、邮件通知等

1.前言

后台基于Node.js进行开发,因为我是前端嘛,用Node.js可以不用切换语言,开发体验更好,而且它也满足开发功能的需要。

框架:Thinkjs,原本想用Koa自己写的,但那样很多底层的功能要自己封装,后面发现了更方便快捷的上层框架Thinkjs。

数据库:MySQL

缓存:File-cache,暂时使用文件系统存储缓存

鉴权:Session + Cookie,就内容管理系统需要使用,就先不用jwt了

2.目录结构

基于Thinkjs单模块项目的目录结构

├── config 配置
|   └── adapter.model.js 数据库配置
├── logs 日志文件
├── runtime
|   ├── cache 缓存
|   ├── session 会话信息
├── src 后台
|   ├── config 配置
│   |   ├── adapter 数据库、缓存、日志等配置
│   |   ├── middleware 中间件配置
│   |   |   └── nuxt.js nuxt中间件
│   |   ├── extend.js thinkjs扩展配置
│   |   └── router.js thinkjs路由
│   ├── controller 控制器
│   |   ├── admin 内容管理平台api
│   |   └── front 博客前台api
│   ├── logic 校验层
│   ├── model 模型,数据库操作
│   ├── service 服务,可复用,抽离controller逻辑
├── views
│   ├── rss.xml rss模板
│   └── sitemap.xml 网站地图模板
├── www 静态资源,nuxt打包目标目录
│   └── upload 上传目录
├── development.js thinkjs开发环境入口
├── production.js thinkjs生产环境入口

3.功能特性

3.1.图片处理

一开始想着直接用云服务进行图片处理的,像又拍云、腾讯云、阿里云都提供对应的数据处理服务,不过有一定的限制,首先就是必须要使用服务商的CDN或者对象存储,其次就是可能需要小钱钱。

所以咱自个用sharp.js来实现,详情可以查看这篇文章

咱的策略是请求列表接口的时候进行图片的裁剪,而不是上传图片的时候就进行裁剪,当然也可以使用中间件,根据URL地址返回缩略图的数据流。

这里存在一个问题就是每张图都要判断是否存在,如果目标图片存在则直接返回,没有则进行裁剪,所以咱给缩略图地址加上缓存,每次只需要和缓存做对比就可以了。

const path = require('path');
const fs = require('fs-extra');
const sharp = require('sharp');

module.exports = class extends think.Service {
  /**
   * 获取图片元数据
   * @param {String} src 图片地址
   * @see https://sharp.pixelplumbing.com/api-input
   * @returns {Object}
   */
  async getMetadata(src) {
    // 源文件不存在
    if (!think.isExist(src)) {
      return {};
    }
    const metadata = await sharp(src).metadata();
    return metadata || {};
  }

  /**
   * 调整图像大小
   * @param {String} src 源图片地址
   * @param {String} dest 目标图片地址
   * @param {Object} resizeOpts 裁剪图片参数 { width, height, fit, position, background }
   * @summary fit: {0:'cover', 1:'contain', 2:'fill', 3: 'inside', 4: 'outside'}
   *          cover:裁剪以覆盖两个提供的尺寸(默认值)。
   *          contain:嵌入两个提供的尺寸。
   *          fill:忽略输入的纵横比并拉伸到两个提供的尺寸。
   *          inside:保留纵横比,将图像调整为尽可能大,同时确保其尺寸小于或等于指定的尺寸。
   *          outside:保留纵横比,将图像调整为尽可能小,同时确保其尺寸大于或等于指定的尺寸。
   * @summary {String} position: top,right top,right,right bottom,bottom,left bottom,left,left top
   * @summary background: 背景颜色 { r, g, b, alpha }
   * @param {Object} outputOpts 目标图片参数 { quality: jpg/webp的压缩质量, lossless: webp无损压缩, compressionLevel: png的zlib压实级别 }
   * @see https://sharp.pixelplumbing.com/api-output
   */
  async resizeAndCrop(options, outputOpts) {
    const { src, dest, destAbsolutePath, width, height, fit, position, background } = options;

    const fileSrc = path.join(think.RESOURCE_PATH, src);
    // 目标地址或源文件不存在
    if (think.isEmpty(dest) || !think.isExist(fileSrc)) {
      return '';
    }

    let result;
    const extname = path.extname(dest); // 目标文件后缀

    // 先创建目标目录结构,才能输出图片
    try {
      await fs.ensureDir(path.dirname(destAbsolutePath));
    } catch (err) {
      console.error(err);
      return '';
    }

    // 先调整图像大小
    const fitEnum = ['cover', 'contain', 'fill', 'inside', 'outside'];
    const hasBackgroundEnum = ['.png', '.webp', '.tile'];
    const image = await sharp(fileSrc)
      .resize(width, height, {
        fit: fitEnum[fit] || 'cover',
        position,
        background: background || (
          hasBackgroundEnum.includes(extname)
            ? { r: 255, g: 255, b: 255, alpha: 0 }
            : { r: 255, g: 255, b: 255, alpha: 1 }
        )
      });

    // 再根据输出格式生成文件
    switch (extname) {
      case '.png':
        result = await image.png(outputOpts)
          .toFile(destAbsolutePath)
          .catch(error => console.error(error, fileSrc));
        break;
      case '.webp':
        result = await image.webp(outputOpts)
          .toFile(destAbsolutePath)
          .catch(error => console.error(error, fileSrc));
        break;
      default:
        const jpgOptions = {
          ...outputOpts,
          mozjpeg: true
        };
        result = await image.jpeg(jpgOptions)
          .toFile(destAbsolutePath)
          .catch(error => console.error(error, fileSrc));
    }

    return result ? dest : '';
  }
};

3.2.图片验证码

由于不想安装 c++ 模块,所以选择使用svg验证码

这里选用 svg-captcha npm包,创建的验证码结果存到redis或者session中用于后续验证

const svgCaptcha = require('svg-captcha');

module.exports = class extends think.Service {
  /**
   * svg图片验证码服务
   * @param {Object} options
   * @see https://github.com/produck/svg-captcha
   */
  constructor(options) {
    super();
    const defaultOptions = {
      size: 4, // 字符个数
      ignoreChars: '0oO1i', // 过滤字符
      noise: 3, // 噪点线条数量
      color: '#409eff', // default grey, true if background option is set
      background: '#fafafa', // svg image 背景颜色
      width: 120, // captcha 宽度
      height: 40, // captcha 高度
      fontSize: 36, // captcha 文字大小
      charPreset: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' // random character preset
    };
    this.options = Object.assign(defaultOptions, options);
  }

  /**
   * 创建普通字符验证码
   * @returns {Object} {data: 'svg path data', text: 'captcha text'}
   */
  createCaptcha() {
    return svgCaptcha.create(this.options);
  }

  /**
   * 创建算术验证码
   * @summary 生成的 svg 是一个算数式,而 text 属性上是算数式的结果
   * @returns {Object} {data: 'svg path data', text: 'captcha text'}
   */
  createMathExpr() {
    return svgCaptcha.createMathExpr(this.options);
  }
};

3.3.文件上传

这个功能也是内容管理系统必备的,不然就需要第三方图库挂外链咯。

目前主要是上传到本地服务器,再通过对象存储回源来取镜像资源,就不开发上传对象存储功能了,主打一个懒。

const url = require('url');
const path = require('path');
const fs = require('fs-extra');
const moment = require('moment');
const readdirSync = think.promisify(fs.readdir, fs);

module.exports = class extends think.Service {
  /**
   * @param {String} siteurl 网站地址
   */
  constructor(siteurl) {
    super();
    this.siteurl = siteurl;
  }

  /**
   * 获取当前的格式化时间
   * @returns {String} YYYYMM 年月
   */
  getYearMonth() {
    return moment(new Date()).format('YYYYMM');
  }

  /**
   * 文件
   * @param {Object} file
   */
  async upload(file) {
    const ext = /^\.\w+$/.test(path.extname(file.path)) ? path.extname(file.path) : '.png';
    // 文件命名16位MD5后的通用唯一识别码(think.md5是32位~太长)
    const basename = think.md5(think.uuid('v4')).substr(8, 16) + ext;

    const destDir = this.getYearMonth();
    const destPath = path.join(think.UPLOAD_PATH, destDir);

    try {
      // 如果目录结构不存在,则创建它,如果目录存在,则不进行创建
      await fs.ensureDir(destPath);

      // 上传文件路径
      const filepath = path.join(destPath, basename);
      await fs.move(file.path, filepath, { overwrite: true });
      return {
        name: basename,
        url: url.resolve(this.siteurl, filepath.replace(think.UPLOAD_PATH, '/upload'))
      };
    } catch (e) {
      console.error(e);
      throw Error('FILE_UPLOAD_MOVE_ERROR');
    }
  }

  /**
   * 获取文件列表
   * @param {String} keyword 搜索关键词
   * @param {String} src 目标路径
   * @param {Number} page 当前页
   * @param {Number} pageSize 每页个数
   */
  async getFileList(keyword, src = '', page = 1, pageSize = 20) {
    let list = [];
    let filesAndDirs = [];

    const targetPath = path.join(think.UPLOAD_PATH, src);
    if (think.isDirectory(targetPath)) {
      const sharpService = think.service('sharp');

      filesAndDirs = await readdirSync(targetPath);
      for (const item of filesAndDirs) {
        const itempath = path.join(targetPath, item);
        if (think.isDirectory(itempath)) {
          list.push({
            name: item,
            url: url.resolve('', itempath.replace(think.UPLOAD_PATH, '')),
            type: 1 // 目录
          });
        } else {
          const { size, mtime } = await fs.stat(itempath).catch(() => {
            return {
              size: 0,
              mtime: ''
            };
          });
          const extname = path.extname(item).replace('.', '');
          const isImage = /(gif|jpe?g|png|webp|tiff|bmp|ico)$/i.test(extname);

          // 获取图片宽、高元数据
          let metadata = {};
          if (isImage) {
            const { width, height } = await sharpService.getMetadata(itempath);
            metadata = { width, height };
          }

          list.push({
            ...metadata,
            extname,
            size,
            mtime,
            name: item,
            url: url.resolve(this.siteurl, itempath.replace(think.RESOURCE_PATH, '')),
            type: isImage ? 2 : 3
          });
        }
      }

      if (keyword) list = list.filter(item => item.name.includes(keyword));

      // 目录排前面
      list.sort((a, b) => {
        if (a.type === b.type) {
          return b.name - a.name;
        }
        return a.type - b.type;
      });
      list = list.slice((page - 1) * pageSize, page * pageSize);
    }

    return {
      count: keyword ? list.length : filesAndDirs.length,
      page: page,
      data: list
    };
  }
};

获取文件列表用来开发一个媒体库的功能,方便直接选择已经上传好的资源

3.4.评论

虽然社区有很多第三方评论系统如 畅言云评 、Waline | Waline介绍 | Valine 一款快速、简洁且高效的无后端评论系统。 等。

不过有的需要独立部署,管理不是很方便,当然更多是为了尝试自己开发下。

首先是评论的列表,接口主要把主评论以及回复都查出来,用前端根据层级关系组装。🤣 我写sql写得并不好。

module.exports = class extends think.Model {
  /**
   * 查询列表
   * @param {Object} params 查询条件
   * @returns {Array}
   */
  async selectComment({ topic_id: topicId, page, pageSize }) {
    // 查询主评论
    const field = 'id,topic_id,parent_id,reply_name,type,name,email,website,content,addtime,is_admin';
    const list = await this.model('comment')
      .field(field)
      .where({ topic_id: topicId, type: 1, is_show: 1 })
      .order('addtime DESC')
      .page(page, pageSize)
      .select();
    // 查询回复
    const parentIds = list.map(item => item.id);
    let replyList = [];
    if (parentIds.length) {
      replyList = await this.model('comment')
        .field(field)
        .where({
          is_show: 1,
          parent_id: ['IN', parentIds]
        })
        .order('addtime DESC')
        .select();
    }

    return [...list, ...replyList];
  }
}

提交评论的主要功能:

  • 长度限制
  • XSS过滤
  • 重复内容检测
  • 基于 IP 的发布评论频率限制
  • 发送邮件通知,用到下面的功能,通知网站管理员收到评论,而不是向评论的用户发邮件。

评论用词限制、IP黑名单 暂时还没有做

3.5.发送邮件

使用nodemailer以服务的形式实现

const nodemailer = require('nodemailer')

module.exports = class extends think.Service {
  constructor(config) {
    super()

    const transport = {
      host: config.email_smtp, // 邮件服务器地址
      port: config.email_port, // 邮件服务器端口
      secure: config.email_way, // true for 465, false for other ports
      auth: {
        user: config.email_usename, // 发送端用户名
        pass: config.email_auth // 发送端authorization code, 一般不用密码
      }
    }

    this.transporter = nodemailer.createTransport(transport)
  }

  /**
   * 发送电子邮件
   * @param {String} from 发送人
   * @param {String} to 收件人列表,逗号分隔
   * @param {String} subject 邮件主题
   * @param {String} text plain text 内容
   * @param {Document} html html 内容
   * @returns {Function} Promise
   */
  sendMail(options) {
    return this.transporter.sendMail(options)
  }
}

推荐阅读

恰饭区

评论区 (0)

0/500

还没有评论,快来抢第一吧