前言
此 blog-node 项目是基于 express 搭建,采用了主流的前后端分离思想,提供符合 RESTful 风格的 API 接口(后续部分功能在基于koa中版本实现)
实现功能
- 文章管理
- 评论管理
- 评论回复管理
- 菜单管理
- 功能管理
- 友情链接管理
- 留言管理
- 项目管理
- 角色管理
- 励志语言管理
- 统计管理
- 标签管理
- 上传资源管理
- 用户管理
技术库依赖
express: ~4.16.1,
http-errors: ~1.6.3,
bcryptjs: ^2.4.3, (密码加密)
body-parser: ^1.19.0, (解析请求json和表单数据)
connect-history-api-fallback: ^1.6.0, (支持前端单页面history模式)
jsonwebtoken: ^8.5.1, (token生成与解析)
log4js: ^6.2.0, (日志记录)
moment: ^2.24.0, (日期处理)
mongoose: ^5.8.1, (mongodb对象模型)
multer: ^1.4.2, (文件上传)
nodemailer: ^6.4.2, (邮件发送)
svg-captcha: ^1.4.0 (随机验证码)
目录结构
- config
- connect mongodb数据库连接
- constant 常量数据
- globalHandle cors允许跨域以及路由拦截验证token
- jwt token生成以及校验封装
- logConfig 日志提示封装
- utils 响应回复、数据库操作请求、发送邮件、验证码和时间格式化等一些常用方法封装
- routes
- article 文章增删改查、详情和点赞
- comment 一级评论增删改查和置顶评论
- functionOper 功能列表增删改查
- index 引入所有路由接口
- link 友情链接增删改查
- menu 菜单功能增删改查以及权限列表树形结构数据
- message 留言增删改查
- project 项目增删改查
- replyComment 回复评论增删改查
- role 角色增删改查、获取和设置角色权限列表、批量导入和移除用户
- statement 前端博客显示励志语句增改查
- statistics 访客、用户、文章、留言按年月日周或时间段统计以及排名
- tag 文章标签增删改查
- upload 上传资源增删查以及下载
- user 用户增删改查、登录注册和邮件发送、验证码获取
- models 模式类型,定义文档的字段属性以及校验
- mongodb mongodb数据集合备份(初始化恢复数据,包括菜单、角色和test用户)
- static 图片和资源
- app.js 初始化以及配置
主文件 app.js
let createError = require('http-errors');
let express = require('express');
let path = require('path');
const bodyParser = require("body-parser");
const HTTP_CODE = require("./config/constant").HTTP_CODE;
const utils = require('./config/utils');
let history = require('connect-history-api-fallback');
let app = express();
// 前端支持history模式,上线之后使用的nginx
app.use(history({ htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'] }))
//设置静态文件托管放于全局接口拦截之前,避免验证token,可放置html文件
app.use(express.static(path.join(__dirname, 'views')));
// 静态文件资源static
app.use('/static',express.static(path.join(__dirname,"/static")))
// 请求数据中间件
app.use(bodyParser.json({limit: '30mb'})); //处理json数据
app.use(bodyParser.urlencoded({limit: '30mb', extended:true})); //处理 form 表单数据
//记录日志
const log = require('./config/logConfig.js')
log.use(app)
// 配置
require("./config/globalHandle")(app); // 全局接口拦截来设置cors跨域和检查token
require("./config/connect"); // MongoDB数据库连接
//将路由文件引入
const route = require('./routes/index');
//初始化所有路由 route(app);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(HTTP_CODE.notFound));
});
// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
console.log(err)
utils.severErr(err, res)
// res.status(err.status || HTTP_CODE.severError);
// res.render('error');
});
module.exports = app;
config 配置文件
connect.js mongodb 数据库连接,当 mongodb 开启了权限验证,需要加入账号和密码,注意项目更换数据库账号,密码和表名
constant.js 常量静态数据,如http状态码,错误状态码
globalHandle.js 设置cros允许跨域,token拦截验证以及解析token获取用户信息
jwt.js token生成和校验解析
logConfig.js 请求日志和错误日志输出文件到logs文件
utils.js 公共函数封装
const nodemailer = require('nodemailer');
const moment = require('moment');
const CONSTANT = require('./constant');
const svgCaptcha = require("svg-captcha");
const RES_CODE = CONSTANT.RES_CODE;
const HTTP_CODE = CONSTANT.HTTP_CODE;
const logger = require('./logConfig').logger;
// 响应客户端
function responseClient(res, code = RES_CODE.dataFail, msg = "服务器错误", data = null,httpCode = HTTP_CODE.ok) {
let responseData = {};
responseData.code = code;
responseData.msg = msg;
responseData.data = data;
res.status(httpCode).json(responseData);
}
// 错误请求返回
function severErr(err, res) {
console.log(err)
logger.error(err)
let errObj = {
msg: '服务器错误',
code: HTTP_CODE.severError,
data: err
}
res.status(HTTP_CODE.severError).json(errObj);
}
// 分页,排序判断
let ignoreAttr = ['currentPage', 'pageSize', 'sortBy', 'sortOrders']
// 列表查询,循环对象判断字段属性是否存在,模糊查询
function blurSelect(obj){
let conditions = {}
if(JSON.stringify(obj) !== '{}'){
for(let attr in obj){
if(!ignoreAttr.includes(attr)){
if(attr === 'id'){
obj[attr]? conditions['_id'] = obj[attr] : null
}else if(attr === 'createTime' || attr === 'startTime' || attr === 'endTime'){
let arr = obj[attr].split(',')
if(arr.length === 2){
conditions[attr] = {
"$gte": arr[0],
"$lte": arr[1]
}
}
}else{
if(obj[attr]){
if(obj[attr].length === 24){
conditions[attr] = obj[attr]
}else{
let reg = new RegExp(obj[attr], 'i')
conditions[attr] = {
$regex: reg
}
}
}
}
}
}
}
return conditions
}
// 循环对象判断字段属性是否存在,精准匹配
function completeSelect(obj){
let conditions = {}
if(JSON.stringify(obj) !== '{}'){
for(let attr in obj){
if(!ignoreAttr.includes(attr)){
if(attr === 'id'){
// obj[attr]? conditions['_id'] = obj[attr] : null
}else{
obj[attr]? conditions[attr] = obj[attr] : null
}
}
}
}
return conditions
}
//返回分页查询对象
function pageSelect(obj){
let pageSize = Math.max(parseInt(obj.pageSize),1) || 10;
let currentPage = Math.max(parseInt(obj.currentPage),1) || 1;
let pageObj = {
limit: pageSize,
skip: (currentPage-1) * pageSize
}
if(obj.sortBy){
let sortBy = obj.sortBy;
let sortOrders = obj.sortOrders;
let order = 'desc';
if(sortOrders === '1' || sortOrders === 1 || sortOrders === 'asc' || sortOrders === 'ascending'){
order = 'asc';
}
pageObj.sort = {
[sortBy]: order
}
}
return pageObj
}
// 当前时间
function currentDayDate(type = 'time'){
if(type === 'day'){
return moment().format('YYYY-MM-DD')
}else{
return moment().format('YYYY-MM-DD HH:mm:ss')
}
}
// 获取本周星期一和星期天
function weekFirstLast(date){
let week = moment(date).format('E')
let arr = [moment(date).subtract(week-1, 'days').format('YYYY-MM-DD'),moment(date).add(7-week, 'days').format('YYYY-MM-DD')]
return arr
}
// 获取本月第一天和最后一天
function monthFirstLast(date){
let arr = [moment(date).startOf('month').format('YYYY-MM-DD'),moment(date).endOf('month').format('YYYY-MM-DD')]
return arr
}
// 获取本年第一天和最后一天
function yearFirstLast(date){
let arr = [moment(date).startOf('year').format('YYYY-MM-DD'),moment(date).endOf('year').format('YYYY-MM-DD')]
return arr
}
// 获取传递时间当前周数组7个日期
function weekArry(date){
let week = moment(date).format('E')
let arr = []
for(let i = 1; i < 8; i++){
let data = moment(date).subtract(week-i, 'days').format('YYYY-MM-DD')
arr.push(data)
}
return arr
}
// 时间差
function timeDiff(startTime, endTime, type = 'minute'){
let sTime = moment(startTime)
let eTime = moment(endTime)
return eTime.diff(sTime, type)
}
// 获取文章字数
function getPostWordCount(text) {
let len = 0;
// 先将回车换行符做特殊处理
text = text.replace(/(rn+|s+| +)/g,"蓓"); // 书 = 云
// 处理英文字符数字,连续字母、数字、英文符号视为一个单词
text = text.replace(/[x00-xff]/g,"m");
// 合并字符m,连续字母、数字、英文符号视为一个单词
text = text.replace(/m+/g,"*");
// 去掉回车换行符
text = text.replace(/蓓+/g,"");
// 返回字数
len = text.length
return len;
}
// 邮件发送
//创建发送邮件的请求对象
let transporter = nodemailer.createTransport({
host: 'smtp.163.com', //发送端邮箱类型(163邮箱)
port: 465, //端口号
secure: true,
auth: {
user: 'test@163.com', // 发送方的邮箱地址(自己的)
pass: 'sdj527' // mtp 验证码
}
});
function sendEmail(mail,code) {
let mailObj = {
from: '"test" <test@163.com>', // 邮件名称和发件人邮箱地址
to: 'test@163.com, ' + mail, //收件人邮箱地址(这里的mail是封装后方法的参数,代表收件人的邮箱地址),出现504.可以抄送一份给自己
// to: mail, //收件人邮箱地址(这里的mail是封装后方法的参数,代表收件人的邮箱地址)
subject: '邮箱验证码', //邮件标题
// html: '',
text: '您的验证码:'+ code + ' ( 有效期十分钟 )'
}
// 发送邮件(封装成一个promise对象),方便后面调用该方法
return new Promise((resolve, reject)=>{
transporter.sendMail(mailObj, (err, data) => {
if(err){
reject(err) //出错
}else{
resolve(data) //成功
}
})
})
}
// 随机验证码svg图片
function createCode() {
return svgCaptcha.create({
size: 4,
ignoreChars: "0o1iIl",
noise: 1,
color: true,
background: "transparent",
fontSize: 60
});
}
module.exports = {
responseClient,
blurSelect,
completeSelect,
pageSelect,
sendEmail,
currentDayDate,
timeDiff,
weekArry,
weekFirstLast,
monthFirstLast,
yearFirstLast,
getPostWordCount,
objProp,
createCode,
severErr
};
models Mongoose数据模型
user用户模型,模型有设置默认值default,校验required、validate和match,枚举enum,
const mongoose = require('mongoose');
const moment = require('moment');
const Schema = mongoose.Schema;
const userSchema = new Schema({
name: {
type: String,
required: true,
validate: (val)=> {
return val.length < 10
}
},
email: {
type: String,
required: true,
match: /^[a-z0-9]+([._\\-]*[a-z0-9])*@([a-z0-9]+[-a-z0-9]*[a-z0-9]+.){1,63}[a-z0-9]+$/i
},
phone: {
type: String,
match: /^1[3|4|5|6|7|8|9][0-9]\d{4,8}$/ //正则校验
},
password: {
type: String,
select: false, // 返回对象数据中不会显示这个字段信息
required: true
},
info: {
type: String,
validate: (val)=> {
return val.length < 40
}
},
status: {
type: String,
default: '1',
enum: ['0', '1']
},
avatarId: {
type: String,
default: ''
},
mark: {
type: String,
default: 'xxxxxx4xxxyxxxxxx'.replace(/[xy]/g, function (c) {
let r = Math.random() * 16 | 0,
v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
})
},
roleId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Role', //角色对象ID类型
},
createTime: {
type: String,
default: () => moment().format('YYYY-MM-DD HH:mm:ss')
},
updateTime: {
type: String,
default: () => moment().format('YYYY-MM-DD HH:mm:ss')
}
}, {
versionKey: false,
collection: 'user' //生成 collection 的自定义名称,默认会以复数形式
// timestamps: { createdAt: 'createTime', updatedAt: 'updateTime' }
})
module.exports = mongoose.model('User', userSchema);
routes 路由
index.js总路由,如引入用户模块
const user = require('./user');
module.exports = app => {
let baseUrl = '/blogAdmin'
// 用户模块
app.get(baseUrl + '/user/list', user.userList)
app.get(baseUrl + '/user/accessList', user.accessUserList)
app.post(baseUrl + '/user/register', user.register)
app.post(baseUrl + '/user/add', user.userAdd)
app.put(baseUrl + '/user/update', user.userUpdate)
app.post(baseUrl + '/user/login', user.login)
app.post(baseUrl + '/user/resetPwd', user.resetPwd)
app.post(baseUrl + '/user/modifyPwd', user.modifyPwd)
app.post(baseUrl + '/user/sendEmail', user.sendEmail)
app.get(baseUrl + '/user/getCode', user.getCode)
app.delete(baseUrl + '/user/del/:id', user.userDel)
}
用户管理
1、使用map结构来缓存随机验证码和邮箱验证码,设置有效期为十分钟
//随机验证码验证
function randomCodeFind(req) {
let { randomCode } = req.body;
let result = true;
if (randomCode) {
let getRandomCode = randomCodeList.get(randomCode.toUpperCase())
if (getRandomCode && utils.timeDiff(getRandomCode.createTime, utils.currentDayDate()) <= 10) {
result = false
} else {
randomCodeList.delete(randomCode.toUpperCase())
}
}
return result
}
2、使用bcrypt对密码进行加密
function pwdBcrypt(password) {
return new Promise((resolve, reject) => {
bcrypt.genSalt(10, function (error, salt) {
bcrypt.hash(password, salt, function (err, hashPassword) {
//store hash in your password DB.
if (err) {
reject(err)
throw new Error('加密失败');
}
resolve(hashPassword)
});
})
})
}
3、用户注册判断用户名和邮箱重复
User.findOne({ $or: [{ name }, { email }] }).exec(function (err, user) {
if (err) {
return utils.severErr(err, res)
}
if (user) {
if (user.name === name) {
utils.responseClient(res, RES_CODE.dataAlready, "用户名已存在")
} else if (user.email === email) {
utils.responseClient(res, RES_CODE.statusFail, "邮箱已存在")
}
}
})
4、用户列表,根据参数进行模糊匹配,分页和排序,如果角色不是超级用户则只能获取属于该账号下用户列表
exports.userList = (req, res) => {
let conditions = utils.blurSelect(req.query)
let pageObj = utils.pageSelect(req.query)
let userMessage = req.tokenMessage.userMessage
if (userMessage.roleId !== ROLE_TYPE.superRole) {
conditions.mark = userMessage.mark
}
User.countDocuments(conditions, function (err, count) {
if (err) {
return utils.severErr(err, res)
}
User.find(conditions, null, pageObj, function (error, docs) {
if (error) {
return utils.severErr(error, res)
}
if (docs) {
let data = {
count,
data: docs
}
utils.responseClient(res, RES_CODE.reqSuccess, "获取用户列表成功", data)
} else {
utils.responseClient(res, RES_CODE.dataFail, "获取用户列表失败")
}
}).populate([
{ path: 'roleId' }
])
})
}
文章管理
1、使用populate填充文章标签和创建用户对象
Article.find(conditions, fields, pageObj, function (error, docs){
if(error){
return utils.severErr(error, res)
}
if (docs) {
let data = {
count,
data: docs
}
utils.responseClient(res, RES_CODE.reqSuccess, "获取文章列表成功", data)
} else {
utils.responseClient(res, RES_CODE.dataFail, "获取文章列表失败")
}
}).populate([
{ path: 'tags', select: '_id name bgColor' },
{ path: 'createUser', select: '_id name mark' }
])
2、使用$inc来减少或增加数量,$pull或$push来对数组元素进行删除或添加,如增加文章一级评论时,如要在文章模型中修改对应数据
let newComment = new Comment(conditions)
newComment.save(function (err, addResult) {
if (err) {
return utils.severErr(err, res)
}
if (addResult) {
if(addResult.status === '1'){
Article.findByIdAndUpdate(articleId, {$inc: { 'meta.commentTotal': 1}, $push: {commentList: addResult._id }}, { new: true }, function (errs, updateResult) {
if (errs) {
return utils.severErr(errs, res)
}
updateResult ? utils.responseClient(res, RES_CODE.reqSuccess, "文章评论新增成功", updateResult) : utils.responseClient(res, RES_CODE.dataFail, "文章新增评论失败")
})
}else{
utils.responseClient(res, RES_CODE.reqSuccess, "文章评论新增成功", addResult)
}
} else {
utils.responseClient(res, RES_CODE.dataFail, "文章新增评论失败")
}
})
}
3、文章评论置顶,通过isTop(Bool类型)和topUpdateTime排序,默认置顶排序
let pageObj = utils.pageSelect(req.query)
if(!pageObj.sort){
pageObj.sort = {
isTop: '-1',
topUpdateTime: '-1'
}
}
菜单功能
1、菜单功能扁平数据结构转化成树形数据结构返回
function treeData(source){
let cloneData = JSON.parse(JSON.stringify(source)) // 对源数据深度克隆
return cloneData.filter(father=>{
let branchArr = cloneData.filter(child=>father._id == child.parentId) //返回每一项的子级数组
branchArr.length>0 ? father.children = branchArr : '' //如果存在子级,则给父级添加一个children属性,并赋值
return father.parentId=='0'; //返回第一层
})
}
2、对用户权限进行接口拦截处理,暂时通过写死数据,后续通过中间件进行处理
let userMessage = req.tokenMessage.userMessage
if(!userMessage.functionList.includes('5e834ff2fb69305aa091e836')){
return utils.responseClient(res, RES_CODE.dataFail, "无该功能权限")
}
3、通过update批量导入或移除角色用户
User.update(conditions,doc,{multi: true},function (error, docs){})
数据统计
1、按日、周、月、年或自定义时间段统计数据
exports.articleStatistics = (req, res) => {
let { type, startTime, endTime } = req.query
startTime = startTime || utils.currentDayDate('day')
let options = {}
if (type === 'day') {
options.startTime = startTime
options.endTime = startTime
options.substrData = ["$createTime", 11, 2]
} else if (type === 'week') {
let weekArr = utils.weekFirstLast(startTime)
options.startTime = weekArr[0]
options.endTime = weekArr[1]
options.substrData = ["$createTime", 0, 10]
} else if (type === 'month') {
let monthArr = utils.monthFirstLast(startTime)
options.startTime = monthArr[0]
options.endTime = monthArr[1]
options.substrData = ["$createTime", 0, 10]
} else if (type === 'year') {
let yearArr = utils.yearFirstLast(startTime)
options.startTime = yearArr[0]
options.endTime = yearArr[1]
options.substrData = ["$createTime", 5, 2]
} else {
options.startTime = startTime
options.endTime = endTime
options.substrData = ["$createTime", 0, 10]
}
Article.aggregate([
{
$match: {
status: '1',
createTime: { '$gte': options.startTime + ' 00:00:00', '$lt': options.endTime + ' 23:59:59' }
}
},
{
$project: {
hour: {
$substr: options.substrData
}
},
},
{
$group: {
_id: "$hour",
count: { $sum: 1 }
}
},
{
$sort: { _id: 1 }
}
]).exec(function (err, data) {
if (err) {
return utils.severErr(err, res)
}
utils.responseClient(res, RES_CODE.reqSuccess, "获取文章统计", data)
})
}
2、按排名最多排序,时间字符串转换时间格式,使用new Date("$time")全部会转换成1970-01-01T00:00:00.000Z
exports.articleList = (req, res) => {
let { type } = req.query
let date = null
if(type === 'day'){
date = {
$substr: ["$createTime", 11, 2]
}
}else if(type === 'month'){
date = {
$substr: ["$createTime", 5, 2]
}
}else{
date = {
$isoDayOfWeek: {
$dateFromString: {
dateString: "$createTime"
}
}
}
}
Article.aggregate([
{
$match: {
status: '1'
}
},
{
$project: {
date,
createTime: 1
}
},
{
$group: {
_id: "$date",
count: { $sum: 1 }
}
},
{
$sort: { count: -1 }
}
]).exec(function (err, data) {
if (err) {
return utils.severErr(err, res)
}
utils.responseClient(res, RES_CODE.reqSuccess, "获取文章排名统计", data)
})
}
3、$lookup联表查询,$group分组字段需要放到_id中
exports.tagList = (req, res) => {
Article.aggregate([
{
$match: {
status: '1'
}
},
{
$lookup: {
from: 'tag',
localField: 'tags',
foreignField: '_id',
as: 'articleTag'
}
},
{
$unwind: "$articleTag"
},
{
$project: {
title: 1,
'articleTag._id': 1,
'articleTag.name': 1,
'articleTag.bgColor': 1
}
},
{
$group: {
_id: {
"id": "$articleTag._id",
"name": "$articleTag.name",
"bgColor": "$articleTag.bgColor"
},
count: { $sum: 1 }
}
},
{
$sort: { count: -1 }
}
]).exec(function (err, data) {
if (err) {
return utils.severErr(err, res)
}
utils.responseClient(res, RES_CODE.reqSuccess, "获取文章标签统计", data)
})
}
文件上传和下载
1、通过multer模块来进行上传管理
app.post(baseUrl + '/file/upload',multer({
//设置文件存储路径
dest: './static/img' //img文件如果不存在则会自己创建一个,single上传单个文件
}).single('file'), upload.uploadFile);
2、先fs.readFile读取文件,然后fs.writeFile写入文件,最后数据库保存路径
exports.uploadFile = (req, res) => {
if(req.file){
fs.readFile(req.file.path, (err, data) => {
//读取失败,说明没有上传成功
if (err) {
return utils.severErr(err, res)
}
let imgAddress = path.join(__dirname, `../${req.file.destination}/` + req.file.filename + '.' + req.file.mimetype.split('/')[1])
let imgUrl = req.protocol + '://'+ req.headers.host + '/blogAdmin/file/down?downId=' + req.file.filename
fs.writeFile(imgAddress, data, (err) => {
if (err) {
return utils.severErr(err, res)
}
const newSource = new Source({
sourceId: req.file.filename,
name: req.file.originalname,
type: req.file.mimetype,
url: imgUrl
})
newSource.save(function (err, source){
if(err){
return utils.severErr(err, res)
}
utils.responseClient(res, RES_CODE.reqSuccess, "上传成功", source)
// 删除二进制文件
fs.unlink(path.join(__dirname, '../static/img/' + req.file.filename), function(unErr){
// console.log(unErr)
})
})
})
})
}else{
utils.responseClient(res, RES_CODE.dataFail, "获取文件失败")
}
}
3、文件下载,通过文件id到数据库获取资源,然后拼接下载路径返回
res.download(path.join(__dirname, imgUrl), function(err){
if (err) {
return utils.severErr(err, res)
}
})
说明
-
默认超级管理员,账户:test,密码:123456
-
开发环境使用 nodeman,一旦报错,程序断开,生产环境使用 pm2,把 node 设置为进程,不会因报错而断开服务
Build Setup ( 建立安装 )
数据库mongodb安装,mongodb按教程安装下载,然后配置:
1、下载mongodb在D:\mongodb位置(自定义),data文件夹下新建db文件夹
2、在D:\mongodb\bin中执行.\mongod --dbpath D:\mongodb\data\db,查看是否安装成功
3、配置文件安装服务,mongod -config " D:\mongodb\bin\mongod.cfg" -install -serviceName "MongoDB"
4、在D:\mongodb\bin中执行./mongo或配置系统变量使用mongo来创建超级用户:
use admin
db.createUser({user:"admin",pwd:"123456",roles:["root"]})
5、新建数据库:
use blogNode
db.createUser({user:"admin",pwd:"123456",roles:[{role:"dbOwner",db:"blogNode"}]})
(dbOwner:该数据库的所有者,具有该数据库的全部权限)
6、在mongod.cfg中配置需要权限认证,重启服务
security:
authorization: enabled
7、安装navicat for mongodb 可视化数据库,导入恢复mongodb的数据
8、全局安装npm install -g nodemon来监听重启
9、安装依赖,npm install
10、启动服务,npm run dev,默认端口3000
项目地址:
项目系列文章: