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)
}
}
还没有评论,快来抢第一吧