├── id_rsa.enc ├── script └── deploy.sh ├── app ├── lib │ ├── helper.js │ ├── util.js │ ├── enums.js │ └── upload.js ├── api │ ├── blog │ │ ├── tag.js │ │ ├── blog.js │ │ ├── author.js │ │ ├── category.js │ │ ├── message.js │ │ └── article.js │ └── v1 │ │ ├── file.js │ │ ├── message.js │ │ ├── tag.js │ │ ├── blog.js │ │ ├── category.js │ │ ├── article.js │ │ └── author.js ├── validators │ ├── tag.js │ ├── message.js │ ├── blog.js │ ├── category.js │ ├── common.js │ ├── author.js │ └── article.js ├── models │ ├── articleTag.js │ ├── tag.js │ ├── articleAuthor.js │ ├── friend.js │ ├── category.js │ ├── message.js │ ├── comment.js │ ├── article.js │ ├── author.js │ └── index.js └── dao │ ├── message.js │ ├── blog.js │ ├── tag.js │ ├── articleTag.js │ ├── articleAuthor.js │ ├── category.js │ ├── comment.js │ ├── author.js │ └── article.js ├── .gitignore ├── app.js ├── .travis.yml ├── middleware ├── exception.js └── auth.js ├── core ├── init.js ├── util.js ├── db.js ├── http-exception.js ├── multipart.js └── lin-validator.js ├── config └── config.js.sample ├── README.md └── package.json /id_rsa.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smileShirmy/smile-blog-koa/HEAD/id_rsa.enc -------------------------------------------------------------------------------- /script/deploy.sh: -------------------------------------------------------------------------------- 1 | pm2 stop all 2 | 3 | npm install 4 | 5 | pm2 restart all 6 | 7 | echo 'success' 8 | -------------------------------------------------------------------------------- /app/lib/helper.js: -------------------------------------------------------------------------------- 1 | const { Success } = require('@exception') 2 | 3 | function success(msg, errorCode) { 4 | throw new Success(msg, errorCode) 5 | } 6 | 7 | module.exports = { 8 | success 9 | } -------------------------------------------------------------------------------- /app/api/blog/tag.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | 3 | const { TagDao } = require('@dao/tag') 4 | 5 | const tagApi = new Router({ 6 | prefix: '/v1/blog/tag' 7 | }) 8 | 9 | const TagDto = new TagDao() 10 | 11 | // 获取所有标签 12 | tagApi.get('/tags', async (ctx) => { 13 | const tags = await TagDto.getTags() 14 | ctx.body = tags 15 | }) 16 | 17 | module.exports = tagApi -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | # local env files 5 | .env.local 6 | .env.*.local 7 | 8 | # Log files 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # Editor directories and files 14 | .idea 15 | .vscode 16 | *.suo 17 | *.ntvs* 18 | *.njsproj 19 | *.sln 20 | *.sw? 21 | 22 | # others 23 | config/config.js 24 | package-lock.json 25 | yarn.lock 26 | config.js 27 | -------------------------------------------------------------------------------- /app/api/blog/blog.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | 3 | const { BlogDao } = require('@dao/blog') 4 | 5 | const blogApi = new Router({ 6 | prefix: '/v1/blog/blog' 7 | }) 8 | 9 | const blogDto = new BlogDao() 10 | 11 | // 获取友链 12 | blogApi.get('/friend/friends', async (ctx) => { 13 | const friends = await blogDto.getFriends() 14 | ctx.body = friends 15 | }) 16 | 17 | module.exports = blogApi -------------------------------------------------------------------------------- /app/lib/util.js: -------------------------------------------------------------------------------- 1 | const { toSafeInteger, isInteger } = require('lodash') 2 | const { ParametersException } = require('@exception') 3 | 4 | function getSafeParamId(ctx) { 5 | const id = toSafeInteger(ctx.get('query.id')) 6 | if (!isInteger(id)) { 7 | throw new ParametersException({ 8 | msg: '路由参数错误' 9 | }) 10 | } 11 | return id 12 | } 13 | 14 | module.exports = { 15 | getSafeParamId 16 | } 17 | -------------------------------------------------------------------------------- /app/validators/tag.js: -------------------------------------------------------------------------------- 1 | const { LinValidator, Rule } = require('../../core/lin-validator') 2 | 3 | class CreateOrUpdateTagValidator extends LinValidator { 4 | constructor() { 5 | super() 6 | this.name = [ 7 | new Rule('isLength', '标签名必须在1~64个字符之间', { 8 | min: 1, 9 | max: 64 10 | }) 11 | ] 12 | } 13 | } 14 | 15 | module.exports = { 16 | CreateOrUpdateTagValidator 17 | } 18 | -------------------------------------------------------------------------------- /app/lib/enums.js: -------------------------------------------------------------------------------- 1 | function isThisType(val){ 2 | for(let key in this){ 3 | if(this[key] === val){ 4 | return true 5 | } 6 | } 7 | return false 8 | } 9 | 10 | const AuthType = { 11 | USER: 8, 12 | ADMIN: 16, 13 | SUPER_ADMIN: 32, 14 | isThisType 15 | } 16 | 17 | const TokenType = { 18 | ACCESS: 'access', 19 | REFRESH: 'refresh' 20 | } 21 | 22 | 23 | module.exports = { 24 | AuthType, 25 | TokenType 26 | } 27 | -------------------------------------------------------------------------------- /app/models/articleTag.js: -------------------------------------------------------------------------------- 1 | const { sequelize } = require('../../core/db') 2 | const { Sequelize, Model } = require('sequelize') 3 | 4 | class ArticleTag extends Model { 5 | 6 | } 7 | 8 | ArticleTag.init({ 9 | article_id: { 10 | type: Sequelize.INTEGER, 11 | allowNull: false 12 | }, 13 | tag_id: { 14 | type: Sequelize.INTEGER, 15 | allowNull: false 16 | } 17 | }, { 18 | sequelize, 19 | tableName: 'articleTag' 20 | }) 21 | 22 | module.exports = { 23 | ArticleTag 24 | } -------------------------------------------------------------------------------- /app/api/v1/file.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | 3 | const { UpLoader } = require('../../lib/upload') 4 | const { Auth } = require('../../../middleware/auth') 5 | 6 | const fileApi = new Router({ 7 | prefix: '/v1/file' 8 | }) 9 | 10 | fileApi.post('/', new Auth().m, async (ctx) => { 11 | const files = await ctx.multipart() 12 | 13 | const upLoader = new UpLoader(`blog/`) 14 | const arr = await upLoader.upload(files) 15 | ctx.body = arr 16 | }) 17 | 18 | module.exports = fileApi -------------------------------------------------------------------------------- /app/models/tag.js: -------------------------------------------------------------------------------- 1 | const { sequelize } = require('../../core/db') 2 | const { Sequelize, Model } = require('sequelize') 3 | 4 | class Tag extends Model { 5 | toJSON() { 6 | let origin = { 7 | id: this.id, 8 | name: this.name 9 | } 10 | return origin 11 | } 12 | } 13 | 14 | Tag.init({ 15 | name: { 16 | type: Sequelize.STRING(64), 17 | allowNull: false 18 | } 19 | }, { 20 | sequelize, 21 | tableName: 'tag' 22 | }) 23 | 24 | module.exports = { 25 | Tag 26 | } -------------------------------------------------------------------------------- /app/models/articleAuthor.js: -------------------------------------------------------------------------------- 1 | const { sequelize } = require('../../core/db') 2 | const { Sequelize, Model } = require('sequelize') 3 | 4 | class ArticleAuthor extends Model { 5 | 6 | } 7 | 8 | ArticleAuthor.init({ 9 | article_id: { 10 | type: Sequelize.INTEGER, 11 | allowNull: false 12 | }, 13 | author_id: { 14 | type: Sequelize.INTEGER, 15 | allowNull: false 16 | } 17 | }, { 18 | sequelize, 19 | tableName: 'articleAuthor' 20 | }) 21 | 22 | module.exports = { 23 | ArticleAuthor 24 | } -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | require('module-alias/register') 2 | 3 | const Koa = require('koa') 4 | const parser = require('koa-bodyparser') 5 | const InitManager = require('./core/init') 6 | const catchError = require('./middleware/exception') 7 | const cors = require('koa2-cors'); 8 | const multipart = require('./core/multipart') 9 | 10 | const app = new Koa() 11 | 12 | app.use(cors()) 13 | app.use(catchError) 14 | app.use(parser()) 15 | multipart(app) 16 | 17 | InitManager.initCore(app) 18 | 19 | app.listen(3000, () => { 20 | console.log('listening port 3000') 21 | }) -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | directories: 4 | - node_modules 5 | node_js: stable 6 | branches: 7 | only: 8 | - master 9 | install: 10 | - npm install 11 | before_install: 12 | - openssl aes-256-cbc -K $encrypted_72aaa3a73d74_key -iv $encrypted_72aaa3a73d74_iv -in id_rsa.enc -out ~/.ssh/id_rsa -d 13 | - chmod 600 ~/.ssh/id_rsa 14 | after_success: 15 | - ssh "$DEPLOY_USER"@"$DEPLOY_HOST" -o StrictHostKeyChecking=no 'cd /data/smile-blog-koa && git pull && bash ./script/deploy.sh' 16 | addons: 17 | ssh_known_hosts: 18 | - "$DEPLOY_HOST" 19 | -------------------------------------------------------------------------------- /app/validators/message.js: -------------------------------------------------------------------------------- 1 | const { LinValidator, Rule } = require('../../core/lin-validator') 2 | 3 | class CreateMessageValidator extends LinValidator { 4 | constructor() { 5 | super() 6 | this.nickname = [ 7 | new Rule('isOptional'), 8 | new Rule('isLength', '昵称必须在1~32个字符之间', { 9 | min: 1, 10 | max: 32 11 | }) 12 | ], 13 | this.content = [ 14 | new Rule('isLength', '内容必须在1~1023个字符之间', { 15 | min: 1, 16 | max: 1023 17 | }) 18 | ] 19 | } 20 | } 21 | 22 | module.exports = { 23 | CreateMessageValidator 24 | } 25 | -------------------------------------------------------------------------------- /app/validators/blog.js: -------------------------------------------------------------------------------- 1 | const { LinValidator, Rule } = require('../../core/lin-validator') 2 | 3 | class CreateOrUpdateFriendValidator extends LinValidator { 4 | constructor() { 5 | super() 6 | this.name = [ 7 | new Rule('isLength', '友链必须在1~64个字符之间', { 8 | min: 1, 9 | max: 64 10 | }) 11 | ], 12 | this.link = [ 13 | new Rule('isURL', '不符合URL规范') 14 | ], 15 | this.avatar = [ 16 | new Rule('isOptional'), 17 | new Rule('isURL', '不符合URL规范') 18 | ] 19 | } 20 | } 21 | 22 | module.exports = { 23 | CreateOrUpdateFriendValidator 24 | } 25 | -------------------------------------------------------------------------------- /app/validators/category.js: -------------------------------------------------------------------------------- 1 | const { LinValidator, Rule } = require('../../core/lin-validator') 2 | 3 | class CreateOrUpdateCategoryValidator extends LinValidator { 4 | constructor() { 5 | super() 6 | this.name = [ 7 | new Rule('isLength', '分类名必须在1~64个字符之间', { 8 | min: 1, 9 | max: 64 10 | }) 11 | ] 12 | this.cover = [ 13 | new Rule('isURL', '不符合URL规范') 14 | ] 15 | this.description = [ 16 | new Rule('isLength', '分类描述必须在1~255个字符之间', { 17 | min: 1, 18 | max: 255 19 | }) 20 | ] 21 | } 22 | } 23 | 24 | module.exports = { 25 | CreateOrUpdateCategoryValidator 26 | } 27 | -------------------------------------------------------------------------------- /app/api/blog/author.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | 3 | const { PositiveIntegerValidator } = require('@validator/common') 4 | const { AuthorDao } = require('@dao/author') 5 | const AuthorDto = new AuthorDao() 6 | 7 | const authorApi = new Router({ 8 | prefix: '/v1/blog/author' 9 | }) 10 | 11 | // 获取作者详情 12 | authorApi.get('/detail', async (ctx) => { 13 | const v = await new PositiveIntegerValidator().validate(ctx) 14 | const id = v.get('query.id') 15 | 16 | const author = await AuthorDto.getAuthorDetail(id) 17 | ctx.body = author 18 | }) 19 | 20 | // 获取全部作者 21 | authorApi.get('/authors', async (ctx) => { 22 | const authors = await AuthorDto.getAuthors() 23 | ctx.body = authors 24 | }) 25 | 26 | module.exports = authorApi -------------------------------------------------------------------------------- /app/models/friend.js: -------------------------------------------------------------------------------- 1 | const { sequelize } = require('../../core/db') 2 | const { Sequelize, Model } = require('sequelize') 3 | 4 | class Friend extends Model { 5 | toJSON() { 6 | let origin = { 7 | id: this.id, 8 | name: this.name, 9 | link: this.link, 10 | avatar: this.avatar 11 | } 12 | return origin 13 | } 14 | } 15 | 16 | Friend.init({ 17 | name: { 18 | type: Sequelize.STRING(64), 19 | allowNull: false 20 | }, 21 | link: { 22 | type: Sequelize.STRING(255), 23 | allowNull: false 24 | }, 25 | avatar: { 26 | type: Sequelize.STRING(255), 27 | defaultValue: '' 28 | }, 29 | }, { 30 | sequelize, 31 | tableName: 'friend' 32 | }) 33 | 34 | module.exports = { 35 | Friend 36 | } 37 | -------------------------------------------------------------------------------- /app/api/blog/category.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | 3 | const { PositiveIntegerValidator } = require('@validator/common') 4 | const { CategoryDao } = require('@dao/category') 5 | 6 | const categoryApi = new Router({ 7 | prefix: '/v1/blog/category' 8 | }) 9 | 10 | const CategoryDto = new CategoryDao() 11 | 12 | // 获取所有分类 13 | categoryApi.get('/categories', async (ctx) => { 14 | const categories = await CategoryDto.getCategories() 15 | ctx.body = categories 16 | }) 17 | 18 | // 获取分类详情 19 | categoryApi.get('/', async (ctx) => { 20 | const v = await new PositiveIntegerValidator().validate(ctx) 21 | const id = v.get('query.id') 22 | const category = await CategoryDto.getCategory(id) 23 | ctx.body = category 24 | }) 25 | 26 | module.exports = categoryApi -------------------------------------------------------------------------------- /app/models/category.js: -------------------------------------------------------------------------------- 1 | const { sequelize } = require('../../core/db') 2 | const { Sequelize, Model } = require('sequelize') 3 | 4 | class Category extends Model { 5 | toJSON() { 6 | let origin = { 7 | id: this.id, 8 | name: this.name, 9 | description: this.description, 10 | cover: this.cover 11 | } 12 | return origin 13 | } 14 | } 15 | 16 | Category.init({ 17 | name: { 18 | type: Sequelize.STRING(64), 19 | allowNull: false 20 | }, 21 | description: { 22 | type: Sequelize.STRING(255), 23 | allowNull: false, 24 | }, 25 | cover: { 26 | type: Sequelize.STRING(255), 27 | allowNull: false 28 | }, 29 | }, { 30 | sequelize, 31 | tableName: 'category' 32 | }) 33 | 34 | module.exports = { 35 | Category 36 | } 37 | -------------------------------------------------------------------------------- /app/models/message.js: -------------------------------------------------------------------------------- 1 | const { sequelize } = require('../../core/db') 2 | const { Sequelize, Model } = require('sequelize') 3 | 4 | class Message extends Model { 5 | toJSON() { 6 | let origin = { 7 | id: this.id, 8 | nickname: this.nickname, 9 | content: this.content, 10 | createTime: this.createTime 11 | } 12 | return origin 13 | } 14 | } 15 | 16 | Message.init({ 17 | nickname: { 18 | type: Sequelize.STRING(32) 19 | }, 20 | content: { 21 | type: Sequelize.STRING(1023), 22 | allowNull: false 23 | } 24 | }, { 25 | sequelize, 26 | tableName: 'message', 27 | getterMethods: { 28 | createTime() { 29 | return this.getDataValue('created_at') 30 | } 31 | } 32 | }) 33 | 34 | module.exports = { 35 | Message 36 | } -------------------------------------------------------------------------------- /middleware/exception.js: -------------------------------------------------------------------------------- 1 | const { HttpException } = require('@exception') 2 | 3 | const catchError = async (ctx, next) => { 4 | try { 5 | await next() 6 | } catch (error) { 7 | const isHttpException = error instanceof HttpException 8 | const isDev = global.config.environment = 'dev' 9 | if (isDev && !isHttpException) { 10 | throw error 11 | } 12 | if (isHttpException) { 13 | ctx.body = { 14 | msg: error.msg, 15 | errorCode: error.errorCode, 16 | request: `${ctx.method}: ${ctx.path}` 17 | } 18 | ctx.status = error.code 19 | } 20 | else { 21 | ctx.body = { 22 | msg: 'we made a mistake O(∩_∩)O~~', 23 | errorCode: 999, 24 | request: `${ctx.method}: ${ctx.path}` 25 | } 26 | ctx.status = 500 27 | } 28 | } 29 | } 30 | 31 | module.exports = catchError -------------------------------------------------------------------------------- /core/init.js: -------------------------------------------------------------------------------- 1 | const requireDirectory = require('require-directory') 2 | const Router = require('koa-router') 3 | 4 | class InitManager { 5 | static initCore(app) { 6 | // 入口 7 | InitManager.app = app 8 | InitManager.initLoadRoutes() 9 | InitManager.loadConfig() 10 | } 11 | 12 | static loadConfig(path = '') { 13 | const configPath = path || process.cwd() + '/config/config.js' 14 | const config = require(configPath) 15 | global.config = config 16 | } 17 | 18 | static initLoadRoutes() { 19 | const appDirectory = `${process.cwd()}/app/api` 20 | requireDirectory(module, appDirectory, { 21 | visit: whenLoadingModule 22 | }) 23 | 24 | function whenLoadingModule(obj) { 25 | if (obj instanceof Router) { 26 | InitManager.app.use(obj.routes()) 27 | } 28 | } 29 | } 30 | } 31 | 32 | module.exports = InitManager -------------------------------------------------------------------------------- /app/api/blog/message.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | 3 | const { PaginateValidator } = require('@validator/common') 4 | const { CreateMessageValidator } = require('@validator/message') 5 | const { success } = require('../../lib/helper') 6 | const { MessageDao } = require('@dao/message') 7 | 8 | const MessageDto = new MessageDao() 9 | 10 | const messageApi = new Router({ 11 | prefix: '/v1/blog/message' 12 | }) 13 | 14 | // 创建留言 15 | messageApi.post('/', async (ctx) => { 16 | const v = await new CreateMessageValidator().validate(ctx) 17 | await MessageDto.createMessage(v) 18 | success('新建留言成功') 19 | }) 20 | 21 | // 获取所有留言 22 | messageApi.get('/messages', async (ctx) => { 23 | const v = await new PaginateValidator().validate(ctx) 24 | const { rows, total } = await MessageDto.getMessages(v) 25 | ctx.body = { 26 | collection: rows, 27 | total, 28 | } 29 | }) 30 | 31 | module.exports = messageApi 32 | -------------------------------------------------------------------------------- /app/models/comment.js: -------------------------------------------------------------------------------- 1 | const { sequelize } = require('../../core/db') 2 | const { Sequelize, Model } = require('sequelize') 3 | 4 | class Comment extends Model { 5 | 6 | } 7 | 8 | Comment.init({ 9 | parent_id: { 10 | type: Sequelize.INTEGER, 11 | defaultValue: 0, 12 | allowNull: false, 13 | }, 14 | nickname: { 15 | type: Sequelize.STRING(32), 16 | allowNull: false 17 | }, 18 | content: { 19 | type: Sequelize.STRING(1023), 20 | allowNull: false 21 | }, 22 | like: { 23 | type: Sequelize.INTEGER, 24 | defaultValue: 0, 25 | allowNull: false 26 | }, 27 | email: { 28 | type: Sequelize.STRING(320), 29 | allowNull: true 30 | }, 31 | website: { 32 | type: Sequelize.STRING(255), 33 | allowNull: true, 34 | }, 35 | article_id: { 36 | type: Sequelize.INTEGER, 37 | allowNull: false 38 | } 39 | }, { 40 | sequelize, 41 | tableName: 'comment' 42 | }) 43 | 44 | module.exports = { 45 | Comment 46 | } 47 | -------------------------------------------------------------------------------- /app/api/v1/message.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | 3 | const { PositiveIntegerValidator, PaginateValidator } = require('@validator/common') 4 | const { success } = require('../../lib/helper') 5 | const { Auth } = require('../../../middleware/auth') 6 | const { MessageDao } = require('@dao/message') 7 | 8 | const MessageDto = new MessageDao() 9 | 10 | const messageApi = new Router({ 11 | prefix: '/v1/message' 12 | }) 13 | 14 | // 获取所有留言 15 | messageApi.get('/messages', new Auth().m, async (ctx) => { 16 | const v = await new PaginateValidator().validate(ctx) 17 | const { rows, total } = await MessageDto.getMessages(v) 18 | ctx.body = { 19 | collection: rows, 20 | total, 21 | } 22 | }) 23 | 24 | // 删除留言,需要最高权限才能删除留言 25 | messageApi.delete('/', new Auth(32).m, async (ctx) => { 26 | const v = await new PositiveIntegerValidator().validate(ctx) 27 | const id = v.get('query.id') 28 | await MessageDto.deleteMessage(id) 29 | success('删除留言成功') 30 | }) 31 | 32 | module.exports = messageApi 33 | -------------------------------------------------------------------------------- /app/dao/message.js: -------------------------------------------------------------------------------- 1 | const { Message } = require('@models') 2 | 3 | class MessageDao { 4 | async createMessage(v) { 5 | return await Message.create({ 6 | nickname: v.get('body.nickname'), 7 | content: v.get('body.content') 8 | }) 9 | } 10 | 11 | async getMessages(v) { 12 | const start = v.get('query.page'); 13 | const pageCount = v.get('query.count'); 14 | 15 | const { rows, count } = await Message.findAndCountAll({ 16 | order: [ 17 | ['id', 'DESC'] 18 | ], 19 | offset: start * pageCount, 20 | limit: pageCount, 21 | }) 22 | return { 23 | rows, 24 | total: count 25 | } 26 | } 27 | 28 | async deleteMessage(id) { 29 | const message = await Message.findOne({ 30 | where: { 31 | id 32 | } 33 | }) 34 | if (!message) { 35 | throw new NotFound({ 36 | msg: '没有找到相关留言' 37 | }) 38 | } 39 | message.destroy() 40 | } 41 | } 42 | 43 | module.exports = { 44 | MessageDao 45 | } 46 | -------------------------------------------------------------------------------- /config/config.js.sample: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | environment: 'dev', 3 | database: { 4 | dbName: 'blog', 5 | host: 'localhost', 6 | port: 3306, 7 | user: '', 8 | password: '', 9 | logging: false, 10 | timezone: '+08:00' 11 | }, 12 | paginate: { 13 | pageDefault: 0, // 默认页码 14 | countDefault: 10 // 默认一页的数量 15 | }, 16 | // JWT 17 | security: { 18 | // secretKey 需要比较复杂的字符串 19 | secretKey: 'secretKey', 20 | accessExp: 60 * 60, // 1h 21 | // accessExp: 20, // 20s 测试令牌过期 22 | // refreshExp 设置refresh_token的过期时间,默认一个月 23 | refreshExp: 60 * 60 * 24 * 30, 24 | }, 25 | // 文件上传 26 | file: { 27 | singleLimit: 1024 * 1024 * 2, // 单个文件大小限制 28 | totalLimit: 1024 * 1024 * 20, // 多个文件大小限制 29 | count: 10, // 单次最大上传数量 30 | exclude: [] // 禁止上传格式 31 | // include:[] 32 | }, 33 | // 七牛相关配置 34 | qiniu: { 35 | accessKey: '', 36 | secretKey: '', 37 | bucket: '', 38 | siteDomain: '' 39 | }, 40 | host: 'http://localhost:3000' 41 | } 42 | -------------------------------------------------------------------------------- /core/util.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken') 2 | /*** 3 | * 4 | */ 5 | const findMembers = function (instance, { 6 | prefix, 7 | specifiedType, 8 | filter 9 | }) { 10 | // 递归函数 11 | function _find(instance) { 12 | //基线条件(跳出递归) 13 | if (instance.__proto__ === null) 14 | return [] 15 | 16 | let names = Reflect.ownKeys(instance) 17 | names = names.filter((name) => { 18 | // 过滤掉不满足条件的属性或方法名 19 | return _shouldKeep(name) 20 | }) 21 | 22 | return [...names, ..._find(instance.__proto__)] 23 | } 24 | 25 | function _shouldKeep(value) { 26 | if (filter) { 27 | if (filter(value)) { 28 | return true 29 | } 30 | } 31 | if (prefix) 32 | if (value.startsWith(prefix)) 33 | return true 34 | if (specifiedType) 35 | if (instance[value] instanceof specifiedType) 36 | return true 37 | } 38 | 39 | return _find(instance) 40 | } 41 | 42 | module.exports = { 43 | findMembers 44 | } 45 | -------------------------------------------------------------------------------- /app/validators/common.js: -------------------------------------------------------------------------------- 1 | const { LinValidator, Rule } = require('../../core/lin-validator') 2 | 3 | class PositiveIntegerValidator extends LinValidator { 4 | constructor() { 5 | super() 6 | this.id = [ 7 | new Rule('isInt', '需要是正整数', { 8 | min: 1 9 | }) 10 | ] 11 | } 12 | } 13 | 14 | class PaginateValidator extends LinValidator { 15 | constructor() { 16 | super() 17 | this.page = [ 18 | new Rule('isOptional', '', global.config.paginate.pageDefault), 19 | new Rule('isInt', 'page必须为整数,且大于等于0', { 20 | min: 0 21 | }) 22 | ] 23 | this.count = [ 24 | new Rule('isOptional', '', global.config.paginate.countDefault), 25 | new Rule('isInt', 'count必须为正整数', { 26 | min: 1 27 | }) 28 | ] 29 | } 30 | } 31 | 32 | class NotEmptyValidator extends LinValidator { 33 | constructor() { 34 | super() 35 | this.token = [ 36 | new Rule('isLength', '不允许为空', { 37 | min: 1 38 | }) 39 | ] 40 | } 41 | } 42 | 43 | 44 | module.exports = { 45 | PositiveIntegerValidator, 46 | PaginateValidator, 47 | NotEmptyValidator 48 | } -------------------------------------------------------------------------------- /app/models/article.js: -------------------------------------------------------------------------------- 1 | const { sequelize } = require('../../core/db') 2 | const { Sequelize, Model } = require('sequelize') 3 | 4 | class Article extends Model { 5 | 6 | } 7 | 8 | Article.init({ 9 | title: { 10 | type: Sequelize.STRING, 11 | allowNull: false 12 | }, 13 | content: { 14 | type: Sequelize.TEXT, 15 | allowNull: false 16 | }, 17 | cover: { 18 | type: Sequelize.STRING(255), 19 | defaultValue: '' 20 | }, 21 | description: { 22 | type: Sequelize.STRING(255), 23 | allowNull: false 24 | }, 25 | category_id: { 26 | type: Sequelize.INTEGER, 27 | allowNull: false 28 | }, 29 | created_date: { 30 | type: Sequelize.DATE, 31 | allowNull: false 32 | }, 33 | public: { 34 | type: Sequelize.INTEGER, 35 | allowNull: false 36 | }, 37 | status: { 38 | type: Sequelize.INTEGER, 39 | allowNull: false 40 | }, 41 | like: { 42 | type: Sequelize.INTEGER, 43 | allowNull: false, 44 | defaultValue: 0 45 | }, 46 | star: { 47 | type: Sequelize.INTEGER, 48 | allowNull: false, 49 | defaultValue: 0 50 | }, 51 | views: { 52 | type: Sequelize.INTEGER, 53 | allowNull: false, 54 | defaultValue: 0 55 | } 56 | }, { 57 | sequelize, 58 | tableName: 'article' 59 | }) 60 | 61 | module.exports = { 62 | Article 63 | } 64 | -------------------------------------------------------------------------------- /app/api/v1/tag.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | 3 | const { success } = require('../../lib/helper') 4 | const { CreateOrUpdateTagValidator } = require('@validator/tag') 5 | const { getSafeParamId } = require('../../lib/util') 6 | const { PositiveIntegerValidator } = require('@validator/common') 7 | const { Auth } = require('../../../middleware/auth') 8 | 9 | const { TagDao } = require('@dao/tag') 10 | 11 | const tagApi = new Router({ 12 | prefix: '/v1/tag' 13 | }) 14 | 15 | const TagDto = new TagDao() 16 | 17 | tagApi.get('/tags', new Auth().m, async (ctx) => { 18 | const tags = await TagDto.getTags() 19 | ctx.body = tags 20 | }) 21 | 22 | tagApi.post('/', new Auth().m, async (ctx) => { 23 | const v = await new CreateOrUpdateTagValidator().validate(ctx) 24 | await TagDto.createTag(v) 25 | success('新建标签成功') 26 | }) 27 | 28 | tagApi.put('/', new Auth().m, async (ctx) => { 29 | const v = await new CreateOrUpdateTagValidator().validate(ctx) 30 | const id = getSafeParamId(v) 31 | await TagDto.updateTag(v, id) 32 | success('更新标签成功') 33 | }) 34 | 35 | // 删除标签,需要最高权限才能删除留言 36 | tagApi.delete('/', new Auth(32).m, async (ctx) => { 37 | const v = await new PositiveIntegerValidator().validate(ctx) 38 | const id = v.get('query.id') 39 | await TagDto.deleteTag(id) 40 | success('删除标签成功') 41 | }) 42 | 43 | module.exports = tagApi -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## smile-blog-koa 2 | 3 | [![Build Status](https://www.travis-ci.org/smileShirmy/smile-blog-koa.svg?branch=master)](https://www.travis-ci.org/smileShirmy/smile-blog-koa) 4 | 5 | - 权限控制 6 | - 无感知Token刷新 7 | - 支持七牛云文件上传 8 | - HTTPS反向代理 9 | - Koa2 + Sequelize 10 | - MySQL 11 | 12 | 该项目为服务端部分,其它部分可点击下面的链接 13 | 14 | - 展示前端 [smile-blog-nuxt](https://github.com/smileShirmy/smile-blog-nuxt) 15 | - 管理后台 [smile-blog-admin](https://github.com/smileShirmy/smile-blog-admin) 16 | - 服务端 [smile-blog-koa](https://github.com/smileShirmy/smile-blog-koa) 17 | 18 | 19 | ## Setup 20 | 21 | - 需要把`config`目录下的`config.js.sample`重命名为`config.js`,然后进行相关参数的配置 22 | - 开始需要关闭权限校验中间件,通过`Postman`创建一个超级管理员(看最下面) 23 | - 启动该项目前需要全局安装`nodemon`和`pm2` 24 | 25 | ```bash 26 | npm install -g nodemon 27 | npm install -g pm2 28 | ``` 29 | 30 | ```bash 31 | # install 32 | npm install 33 | 34 | # development 35 | nodemon 36 | 37 | # production 38 | pm2 start app 39 | ``` 40 | 41 | ### 创建超级管理员 42 | 43 | 1. 打开`app/api/v1/article.js`,找到`authorApi.post('/')`接口,去掉`new Auth().m`中间件 44 | 2. 打开`Postman`发送`POST`请求,`Content-Type`设置为`application/json`,`body`输入以下内容: 45 | 46 | ```javascript 47 | { 48 | name: '用户名', 49 | avatar: '填图片地址', 50 | email: '填email', 51 | description: '用户描述信息', 52 | auth: '32', // 32代表超级管理员权限 53 | password: '', // 密码 英文+数字组合,至少六位 54 | } 55 | ``` 56 | 57 | 3. 再把刚刚去掉的中间加回去 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smile-blog-koa", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "start:dev": "nodemon --inspect-brk", 8 | "start:prod": "node app.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/smileShirmy/smile-blog-koa.git" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/smileShirmy/smile-blog-koa/issues" 19 | }, 20 | "homepage": "https://github.com/smileShirmy/smile-blog-koa#readme", 21 | "dependencies": { 22 | "axios": "^0.19.0", 23 | "bcryptjs": "^2.4.3", 24 | "co-busboy": "^1.4.0", 25 | "jsonwebtoken": "^8.5.1", 26 | "koa": "^2.7.0", 27 | "koa-bodyparser": "^4.2.1", 28 | "koa-router": "^7.4.0", 29 | "koa-static": "^5.0.0", 30 | "koa2-cors": "^2.0.6", 31 | "lodash": "^4.17.14", 32 | "module-alias": "^2.2.0", 33 | "mysql2": "^1.6.5", 34 | "qiniu": "^7.2.2", 35 | "require-directory": "^2.1.1", 36 | "sequelize": "^5.10.1", 37 | "stream-wormhole": "^1.1.0", 38 | "validator": "^11.1.0" 39 | }, 40 | "_moduleAliases": { 41 | "@root": ".", 42 | "@models": "app/models", 43 | "@dao": "app/dao", 44 | "@validator": "app/validators", 45 | "@exception": "core/http-exception" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /core/db.js: -------------------------------------------------------------------------------- 1 | const { Sequelize, Model } = require('sequelize') 2 | const { unset, clone, isArray } = require('lodash') 3 | 4 | const { 5 | dbName, 6 | host, 7 | port, 8 | user, 9 | password, 10 | logging, 11 | timezone 12 | } = require('../config/config').database 13 | 14 | const sequelize = new Sequelize(dbName, user, password, { 15 | dialect: 'mysql', 16 | host, 17 | port, 18 | logging, 19 | timezone, 20 | define: { 21 | timestamps: true, 22 | paranoid: true, 23 | createdAt: 'created_at', 24 | updatedAt: 'updated_at', 25 | deletedAt: 'deleted_at', 26 | underscored: true, 27 | scopes: { 28 | bh: { 29 | attributes: { 30 | exclude: ['updated_at', 'deleted_at', 'created_at'] 31 | }, 32 | }, 33 | frontShow: { 34 | where: { 35 | public: 1, 36 | status: 1 37 | } 38 | } 39 | } 40 | } 41 | }) 42 | 43 | // 设为 true 会重新创建数据表 44 | sequelize.sync({ 45 | force: false 46 | }) 47 | 48 | // 全局序列化 49 | Model.prototype.toJSON = function () { 50 | let data = clone(this.dataValues) 51 | unset(data, 'updated_at') 52 | unset(data, 'created_at') 53 | unset(data, 'deleted_at') 54 | 55 | if (isArray(this.exclude)) { 56 | this.exclude.forEach(value => { 57 | unset(data, value) 58 | }) 59 | } 60 | return data 61 | } 62 | 63 | module.exports = { 64 | sequelize 65 | } 66 | -------------------------------------------------------------------------------- /app/dao/blog.js: -------------------------------------------------------------------------------- 1 | const { NotFound, Forbidden } = require('@exception') 2 | const { Friend } = require('@models') 3 | 4 | class BlogDao { 5 | // 创建友链 6 | async createFriend(v) { 7 | const friend = await Friend.findOne({ 8 | where: { 9 | name: v.get('body.name') 10 | } 11 | }) 12 | if (friend) { 13 | throw new Forbidden({ 14 | msg: '已经存在该友链名' 15 | }) 16 | } 17 | return await Friend.create({ 18 | name: v.get('body.name'), 19 | link: v.get('body.link'), 20 | avatar: v.get('body.avatar') 21 | }) 22 | } 23 | 24 | // 修改友链 25 | async updateFriend(v, id) { 26 | const friend = await Friend.findByPk(id) 27 | if (!friend) { 28 | throw new NotFound({ 29 | msg: '没有找到相关友链' 30 | }) 31 | } 32 | friend.name = v.get('body.name'), 33 | friend.link = v.get('body.link'), 34 | friend.avatar = v.get('body.avatar') 35 | friend.save() 36 | } 37 | 38 | // 获取友链 39 | async getFriends() { 40 | const friends = Friend.findAll() 41 | return friends 42 | } 43 | 44 | // 删除友链 45 | async deleteFriend(id) { 46 | const friend = await Friend.findOne({ 47 | where: { 48 | id 49 | } 50 | }) 51 | if (!friend) { 52 | throw new NotFound({ 53 | msg: '没有找到相关友链' 54 | }) 55 | } 56 | friend.destroy() 57 | } 58 | } 59 | 60 | module.exports = { 61 | BlogDao 62 | } -------------------------------------------------------------------------------- /app/api/v1/blog.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | 3 | const { Auth } = require('../../../middleware/auth') 4 | const { CreateOrUpdateFriendValidator } = require('@validator/blog') 5 | const { PositiveIntegerValidator } = require('@validator/common') 6 | 7 | const { getSafeParamId } = require('../../lib/util') 8 | const { success } = require('../../lib/helper') 9 | 10 | const { BlogDao } = require('@dao/blog') 11 | 12 | const blogApi = new Router({ 13 | prefix: '/v1/blog' 14 | }) 15 | 16 | const blogDto = new BlogDao() 17 | 18 | // 获取友链 19 | blogApi.get('/friend/friends', new Auth().m, async (ctx) => { 20 | const friends = await blogDto.getFriends() 21 | ctx.body = friends 22 | }) 23 | 24 | // 添加友链 25 | blogApi.post('/friend', new Auth().m, async (ctx) => { 26 | const v = await new CreateOrUpdateFriendValidator().validate(ctx) 27 | await blogDto.createFriend(v) 28 | success('新建友链成功') 29 | }) 30 | 31 | // 修改友链 32 | blogApi.put('/friend', new Auth().m, async (ctx) => { 33 | const v = await new CreateOrUpdateFriendValidator().validate(ctx) 34 | const id = getSafeParamId(v) 35 | await blogDto.updateFriend(v, id) 36 | success('修改友链成功') 37 | }) 38 | 39 | // 删除友链 40 | blogApi.delete('/friend', new Auth().m, async (ctx) => { 41 | const v = await new PositiveIntegerValidator().validate(ctx) 42 | const id = v.get('query.id') 43 | await blogDto.deleteFriend(id) 44 | success('删除标签成功') 45 | }) 46 | 47 | module.exports = blogApi -------------------------------------------------------------------------------- /app/models/author.js: -------------------------------------------------------------------------------- 1 | const { Sequelize, Model } = require('sequelize') 2 | const { sequelize } = require('../../core/db') 3 | const bcrypt = require('bcryptjs') 4 | const { AuthType } = require('../lib/enums') 5 | 6 | class Author extends Model { 7 | toJSON() { 8 | let origin = { 9 | id: this.id, 10 | name: this.name, 11 | avatar: this.avatar, 12 | email: this.email, 13 | description: this.description, 14 | auth: this.auth 15 | } 16 | return origin 17 | } 18 | } 19 | 20 | Author.init({ 21 | name: { 22 | type: Sequelize.STRING(32), 23 | allowNull: false 24 | }, 25 | avatar: { 26 | type: Sequelize.STRING(255), 27 | allowNull: false, 28 | defaultValue: '', 29 | }, 30 | email: { 31 | type: Sequelize.STRING(320), 32 | allowNull: false 33 | }, 34 | description: { 35 | type: Sequelize.STRING(255), 36 | allowNull: false, 37 | }, 38 | auth: { 39 | type: Sequelize.TINYINT, 40 | allowNull: false, 41 | defaultValue: AuthType.USER 42 | }, 43 | password: { 44 | type: Sequelize.STRING(127), 45 | allowNull: false, 46 | set(origin) { 47 | const salt = bcrypt.genSaltSync(10) 48 | const val = bcrypt.hashSync(origin, salt) 49 | this.setDataValue('password', val) 50 | }, 51 | get() { 52 | return this.getDataValue('password'); 53 | } 54 | } 55 | }, { 56 | sequelize, 57 | tableName: 'author' 58 | }) 59 | 60 | module.exports = { 61 | Author 62 | } -------------------------------------------------------------------------------- /app/api/v1/category.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | 3 | const { CreateOrUpdateCategoryValidator } = require('@validator/category') 4 | const { PositiveIntegerValidator } = require('@validator/common') 5 | const { success } = require('../../lib/helper') 6 | const { getSafeParamId } = require('../../lib/util') 7 | const { Auth } = require('../../../middleware/auth') 8 | 9 | const { CategoryDao } = require('@dao/category') 10 | 11 | const categoryApi = new Router({ 12 | prefix: '/v1/category' 13 | }) 14 | 15 | const CategoryDto = new CategoryDao() 16 | 17 | // 获取所有分类 18 | categoryApi.get('/categories', new Auth().m, async (ctx) => { 19 | const categories = await CategoryDto.getCategories() 20 | ctx.body = categories 21 | }) 22 | 23 | categoryApi.post('/', new Auth().m, async (ctx) => { 24 | const v = await new CreateOrUpdateCategoryValidator().validate(ctx) 25 | await CategoryDto.createCategory(v) 26 | success('新建分类成功') 27 | }) 28 | 29 | categoryApi.put('/', new Auth().m, async (ctx) => { 30 | const v = await new CreateOrUpdateCategoryValidator().validate(ctx) 31 | const id = getSafeParamId(v) 32 | await CategoryDto.updateCategory(v, id) 33 | success('更新分类成功') 34 | }) 35 | 36 | // 删除分类 需要最高权限才能删除分诶 37 | categoryApi.delete('/', new Auth(32).m, async (ctx) => { 38 | const v = await new PositiveIntegerValidator().validate(ctx) 39 | const id = v.get('query.id') 40 | await CategoryDto.deleteCategory(id) 41 | success('删除分类成功') 42 | }) 43 | 44 | module.exports = categoryApi -------------------------------------------------------------------------------- /app/dao/tag.js: -------------------------------------------------------------------------------- 1 | const { NotFound, Forbidden } = require('@exception') 2 | const { Tag, ArticleTag } = require('@models') 3 | 4 | class TagDao { 5 | async createTag(v) { 6 | const tag = await Tag.findOne({ 7 | where: { 8 | name: v.get('body.name') 9 | } 10 | }) 11 | if (tag) { 12 | throw new Forbidden({ 13 | msg: '标签已存在' 14 | }) 15 | } 16 | return await Tag.create({ 17 | name: v.get('body.name') 18 | }) 19 | } 20 | 21 | async getTag(id) { 22 | const tag = Tag.findOne({ 23 | where: { 24 | id 25 | } 26 | }) 27 | return tag 28 | } 29 | 30 | async getTags() { 31 | const tags = Tag.findAll() 32 | return tags 33 | } 34 | 35 | async updateTag(v, id) { 36 | const tag = await Tag.findByPk(id) 37 | if (!tag) { 38 | throw new NotFound({ 39 | msg: '没有找到相关标签' 40 | }) 41 | } 42 | tag.name = v.get('body.name') 43 | tag.save() 44 | } 45 | 46 | async deleteTag(id) { 47 | const tag = await Tag.findOne({ 48 | where: { 49 | id 50 | } 51 | }) 52 | if (!tag) { 53 | throw new NotFound({ 54 | msg: '没有找到相关标签' 55 | }) 56 | } 57 | const result = await ArticleTag.findOne({ 58 | where: { 59 | tag_id: id 60 | } 61 | }) 62 | if (result) { 63 | throw new Forbidden({ 64 | msg: '该标签下有文章,禁止删除' 65 | }) 66 | } 67 | tag.destroy() 68 | } 69 | } 70 | 71 | module.exports = { 72 | TagDao 73 | } -------------------------------------------------------------------------------- /app/dao/articleTag.js: -------------------------------------------------------------------------------- 1 | const { Op } = require('sequelize') 2 | 3 | const { Tag, ArticleTag } = require('@models') 4 | 5 | class ArticleTagDao { 6 | async createArticleTag(articleId, tags, options = {}) { 7 | const arr = typeof tags === 'string' ? JSON.parse(tags) : tags 8 | for (let i = 0; i < arr.length; i++) { 9 | await ArticleTag.create({ 10 | article_id: articleId, 11 | tag_id: arr[i] 12 | }, {...options}) 13 | } 14 | } 15 | 16 | async getArticleTag(articleId) { 17 | const result = await ArticleTag.findAll({ 18 | where: { 19 | article_id: articleId 20 | } 21 | }) 22 | let ids = result.map(v => v.tag_id) 23 | return await Tag.findAll({ 24 | where: { 25 | id: { 26 | [Op.in]: ids 27 | } 28 | } 29 | }) 30 | } 31 | 32 | async getArticleIds(tagId) { 33 | const result = await ArticleTag.findAll({ 34 | where: { 35 | tag_id: tagId 36 | } 37 | }) 38 | return result.map(v => v.article_id) 39 | } 40 | 41 | async deleteArticleTag(articleId, tags = []) { 42 | const result = await ArticleTag.findAll({ 43 | where: { 44 | article_id: articleId 45 | } 46 | }) 47 | // 如果 id 相同则不再需要删除 48 | if (tags.length && result.map(v => v.tag_id).join('') === tags.join('')) { 49 | return false 50 | } else { 51 | for (let i = 0; i < result.length; i++) { 52 | await result[i].destroy() 53 | } 54 | return true 55 | } 56 | } 57 | } 58 | 59 | module.exports = { 60 | ArticleTagDao 61 | } -------------------------------------------------------------------------------- /app/models/index.js: -------------------------------------------------------------------------------- 1 | const { Article } = require('./article') 2 | const { ArticleAuthor } = require('./articleAuthor') 3 | const { ArticleTag } = require('./articleTag') 4 | const { Author } = require('./author') 5 | const { Category } = require('./category') 6 | const { Comment } = require('./comment') 7 | const { Message } = require('./message') 8 | const { Tag } = require('./tag') 9 | const { Friend } = require('./friend') 10 | 11 | // 关联作者和评论 12 | Article.hasMany(Comment, { 13 | as: 'comments', 14 | constraints: false, 15 | }) 16 | 17 | // 关联作者和分类 18 | Article.belongsTo(Category, { 19 | foreignKey: 'category_id', 20 | as: 'category', 21 | constraints: false, 22 | }) 23 | 24 | // 关联文章和作者 25 | Article.belongsToMany(Author, { 26 | through: { 27 | model: ArticleAuthor, 28 | unique: false 29 | }, 30 | foreignKey: 'article_id', 31 | constraints: false, 32 | as: 'authors' 33 | }) 34 | 35 | Author.belongsToMany(Article, { 36 | through: { 37 | model: ArticleAuthor, 38 | unique: false 39 | }, 40 | foreignKey: 'author_id', 41 | constraints: false 42 | }) 43 | 44 | // 关联文章和标签 45 | Article.belongsToMany(Tag, { 46 | through: { 47 | model: ArticleTag, 48 | unique: false 49 | }, 50 | foreignKey: 'article_id', 51 | constraints: false, 52 | as: 'tags' 53 | }) 54 | 55 | Tag.belongsToMany(Article, { 56 | through: { 57 | model: ArticleTag, 58 | unique: false 59 | }, 60 | foreignKey: 'tag_id', 61 | constraints: false 62 | }) 63 | 64 | module.exports = { 65 | Article, 66 | ArticleAuthor, 67 | ArticleTag, 68 | Author, 69 | Category, 70 | Comment, 71 | Message, 72 | Tag, 73 | Friend 74 | } 75 | -------------------------------------------------------------------------------- /app/dao/articleAuthor.js: -------------------------------------------------------------------------------- 1 | const { Op } = require('sequelize') 2 | 3 | const { Author, ArticleAuthor } = require('@models') 4 | 5 | class ArticleAuthorDao { 6 | async createArticleAuthor(articleId, authors, options = {}) { 7 | const arr = typeof authors === 'string' ? JSON.parse(authors) : authors 8 | for (let i = 0; i < arr.length; i++) { 9 | await ArticleAuthor.create({ 10 | article_id: articleId, 11 | author_id: arr[i] 12 | }, {...options}) 13 | } 14 | } 15 | 16 | async getArticleAuthor(articleId, options = {}) { 17 | const result = await ArticleAuthor.findAll({ 18 | where: { 19 | article_id: articleId 20 | } 21 | }) 22 | let ids = result.map(v => v.author_id) 23 | return await Author.findAll({ 24 | where: { 25 | id: { 26 | [Op.in]: ids 27 | } 28 | }, 29 | ...options 30 | }) 31 | } 32 | 33 | async getArticleIds(authorId) { 34 | const result = await ArticleAuthor.findAll({ 35 | where: { 36 | author_id: authorId 37 | } 38 | }) 39 | return result.map(v => v.article_id) 40 | } 41 | 42 | async deleteArticleAuthor(articleId, authors = []) { 43 | const result = await ArticleAuthor.findAll({ 44 | where: { 45 | article_id: articleId 46 | } 47 | }) 48 | // 如果 id 相同则不再需要删除 49 | if (authors.length && result.map(v => v.author_id).join('') === authors.join('')) { 50 | return false 51 | } else { 52 | for (let i = 0; i < result.length; i++) { 53 | await result[i].destroy() 54 | } 55 | return true 56 | } 57 | } 58 | } 59 | 60 | module.exports = { 61 | ArticleAuthorDao 62 | } -------------------------------------------------------------------------------- /app/lib/upload.js: -------------------------------------------------------------------------------- 1 | const qiniu = require('qiniu') 2 | 3 | class UpLoader { 4 | constructor(prefix) { 5 | this.prefix = prefix || '' 6 | } 7 | 8 | async upload(files) { 9 | // 上传凭证 10 | const accessKey = global.config.qiniu.accessKey 11 | const secretKey = global.config.qiniu.secretKey 12 | const bucket = global.config.qiniu.bucket 13 | const siteDomain = global.config.qiniu.siteDomain 14 | 15 | let promises = [] 16 | 17 | for (const file of files) { 18 | const key = this.prefix + file.filename 19 | // 文件覆盖 20 | const putPolicy = new qiniu.rs.PutPolicy({ 21 | scope: `${bucket}:${key}` 22 | }) 23 | const mac = new qiniu.auth.digest.Mac(accessKey, secretKey) 24 | const uploadToken = putPolicy.uploadToken(mac) 25 | 26 | // ReadableStream 对象的上传 27 | const config = new qiniu.conf.Config() 28 | config.zone = qiniu.zone.Zone_z2 29 | config.useHttpsDomain = true 30 | 31 | const formUploader = new qiniu.form_up.FormUploader(config) 32 | const putExtra = new qiniu.form_up.PutExtra() 33 | const readableStream = file 34 | 35 | const promise = new Promise((resolve, reject) => { 36 | formUploader.putStream(uploadToken, key, readableStream, putExtra, (respErr, respBody, respInfo) => { 37 | if (respErr) { 38 | reject(respErr) 39 | } 40 | 41 | if (respInfo.statusCode === 200) { 42 | const url = siteDomain + respBody.key 43 | resolve(url) 44 | } else { 45 | // 614 文件已存在 46 | reject(respInfo) 47 | } 48 | }) 49 | }) 50 | promises.push(promise) 51 | } 52 | 53 | try { 54 | return Promise.all(promises) 55 | } catch (error) { 56 | throw new Error('文件上传失败') 57 | } 58 | } 59 | } 60 | 61 | module.exports = { 62 | UpLoader 63 | } -------------------------------------------------------------------------------- /app/dao/category.js: -------------------------------------------------------------------------------- 1 | const { NotFound, Forbidden } = require('@exception') 2 | const { Category, Article } = require('@models') 3 | 4 | class CategoryDao { 5 | async createCategory(v) { 6 | const category = await Category.findOne({ 7 | where: { 8 | name: v.get('body.name') 9 | } 10 | }) 11 | if (category) { 12 | throw new Forbidden({ 13 | msg: '分类已存在' 14 | }) 15 | } 16 | return await Category.create({ 17 | name: v.get('body.name'), 18 | cover: v.get('body.cover'), 19 | description: v.get('body.description') 20 | }) 21 | } 22 | 23 | async getCategory(id, options = {}) { 24 | const category = await Category.findOne({ 25 | where: { 26 | id 27 | }, 28 | ...options 29 | }) 30 | return category 31 | } 32 | 33 | async getCategories() { 34 | const categories = await Category.findAll() 35 | return categories 36 | } 37 | 38 | async updateCategory(v, id) { 39 | const category = await Category.findByPk(id) 40 | if (!category) { 41 | throw new NotFound({ 42 | msg: '没有找到相关分类' 43 | }) 44 | } 45 | category.name = v.get('body.name') 46 | category.description = v.get('body.description') 47 | category.cover = v.get('body.cover') 48 | category.save() 49 | } 50 | 51 | async deleteCategory(id) { 52 | const category = await Category.findOne({ 53 | where: { 54 | id 55 | } 56 | }) 57 | if (!category) { 58 | throw new NotFound({ 59 | msg: '没有找到相关分类' 60 | }) 61 | } 62 | const article = await Article.findOne({ 63 | where: { 64 | category_id: id 65 | } 66 | }) 67 | if (article) { 68 | throw new Forbidden({ 69 | msg: '该分类下有文章,禁止删除' 70 | }) 71 | } 72 | category.destroy() 73 | } 74 | } 75 | 76 | module.exports = { 77 | CategoryDao 78 | } -------------------------------------------------------------------------------- /app/dao/comment.js: -------------------------------------------------------------------------------- 1 | const { NotFound } = require('@exception') 2 | const { Comment, Article } = require('@models') 3 | 4 | class CommentDao { 5 | async createComment(v, articleId) { 6 | const article = await Article.findByPk(articleId) 7 | if (!article) { 8 | throw new NotFound({ 9 | msg: '没有找到相关文章' 10 | }) 11 | } 12 | return await Comment.create({ 13 | nickname: v.get('body.nickname'), 14 | content: v.get('body.content'), 15 | email: v.get('body.email'), 16 | website: v.get('body.website'), 17 | article_id: articleId 18 | }) 19 | } 20 | 21 | async getComments(articleId) { 22 | let comments = await Comment.findAll({ 23 | where: { 24 | article_id: articleId 25 | }, 26 | order: [ 27 | ['created_at', 'DESC'] 28 | ], 29 | attributes: { exclude: ['article_id', 'ArticleId'] } 30 | }) 31 | comments.forEach(v => { 32 | v.setDataValue('created_date', v.created_at) 33 | }) 34 | return comments 35 | } 36 | 37 | async deleteComment(id) { 38 | const comment = await Comment.findOne({ 39 | where: { 40 | id 41 | } 42 | }) 43 | if (!comment) { 44 | throw new NotFound({ 45 | msg: '没有找到相关评论' 46 | }) 47 | } 48 | comment.destroy() 49 | } 50 | 51 | async likeComment(id) { 52 | const comment = await Comment.findByPk(id) 53 | if (!comment) { 54 | throw new NotFound({ 55 | msg: '没有找到相关评论' 56 | }) 57 | } 58 | await comment.increment('like', { by: 1 }) 59 | } 60 | 61 | async replyComment(v, articleId, parentId) { 62 | const article = await Article.findByPk(articleId) 63 | if (!article) { 64 | throw new NotFound({ 65 | msg: '没有找到相关文章' 66 | }) 67 | } 68 | const comment = await Comment.findByPk(parentId) 69 | if (!comment) { 70 | throw new NotFound({ 71 | msg: '没有找到相关评论' 72 | }) 73 | } 74 | return await Comment.create({ 75 | parent_id: parentId, 76 | article_id: articleId, 77 | nickname: v.get('body.nickname'), 78 | content: v.get('body.content'), 79 | email: v.get('body.email'), 80 | website: v.get('body.website'), 81 | }) 82 | } 83 | } 84 | 85 | module.exports = { 86 | CommentDao 87 | } -------------------------------------------------------------------------------- /app/validators/author.js: -------------------------------------------------------------------------------- 1 | const { LinValidator, Rule } = require('../../core/lin-validator') 2 | const { AuthType } = require('../lib/enums') 3 | 4 | class UpdateAuthorValidator extends LinValidator { 5 | constructor() { 6 | super() 7 | this.email = [ 8 | new Rule('isEmail', '不符合Email规范') 9 | ] 10 | this.avatar = [ 11 | new Rule('isURL', '不符合URL规范') 12 | ], 13 | this.description = [ 14 | new Rule('isLength', '描述长度为1~255个字符', { 15 | min: 1, 16 | max: 255 17 | }) 18 | ] 19 | this.validateAuth = checkAuth 20 | } 21 | } 22 | 23 | function checkAuth(val) { 24 | let auth = val.body.auth 25 | if (!auth) { 26 | throw new Error('auth是必须参数') 27 | } 28 | auth = parseInt(auth) 29 | if (!AuthType.isThisType(auth)) { 30 | throw new Error('auth参数不合法') 31 | } 32 | } 33 | 34 | class CreateAuthorValidator extends UpdateAuthorValidator { 35 | constructor() { 36 | super() 37 | this.name = [ 38 | new Rule('isLength', '昵称长度为4~32个字符', { 39 | min: 4, 40 | max: 32 41 | }) 42 | ] 43 | this.password = [ 44 | new Rule('isLength', '密码长度为6~32个字符', { 45 | min: 6, 46 | max: 32 47 | }), 48 | new Rule('matches', '密码不符合规范', '^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]') 49 | ] 50 | } 51 | } 52 | 53 | class PasswordValidator extends LinValidator { 54 | constructor() { 55 | super() 56 | this.password = [ 57 | new Rule('isLength', '密码长度为6~32个字符', { 58 | min: 6, 59 | max: 32 60 | }), 61 | new Rule('matches', '密码不符合规范', '^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]') 62 | ] 63 | } 64 | } 65 | 66 | class SelfPasswordValidator extends PasswordValidator { 67 | constructor() { 68 | super() 69 | this.oldPassword = [ 70 | new Rule('isLength', '密码长度为6~32个字符', { 71 | min: 6, 72 | max: 32 73 | }), 74 | new Rule('matches', '密码不符合规范', '^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]') 75 | ] 76 | } 77 | } 78 | 79 | class LoginValidator extends LinValidator { 80 | constructor() { 81 | super() 82 | this.nickname = new Rule('isNotEmpty', '昵称不可为空'); 83 | this.password = new Rule('isNotEmpty', '密码不可为空'); 84 | } 85 | } 86 | 87 | class AvatarUpdateValidator extends LinValidator { 88 | constructor() { 89 | super() 90 | this.avatar = new Rule('isNotEmpty', '必须传入头像的url链接'); 91 | } 92 | } 93 | 94 | module.exports = { 95 | SelfPasswordValidator, 96 | CreateAuthorValidator, 97 | LoginValidator, 98 | UpdateAuthorValidator, 99 | PasswordValidator, 100 | AvatarUpdateValidator 101 | } -------------------------------------------------------------------------------- /core/http-exception.js: -------------------------------------------------------------------------------- 1 | class HttpException extends Error { 2 | constructor(msg = '服务器异常', errorCode = 10000, code = 400) { 3 | super() 4 | this.msg = msg 5 | this.errorCode = errorCode 6 | this.code = code 7 | } 8 | } 9 | 10 | class ParameterException extends HttpException { 11 | constructor(msg, errorCode) { 12 | super() 13 | this.msg = msg || '参数错误' 14 | this.errorCode = errorCode || 10000 15 | this.code = 400 16 | } 17 | } 18 | 19 | class Success extends HttpException { 20 | constructor(msg, errorCode) { 21 | super() 22 | this.msg = msg || 'ok' 23 | this.errorCode = errorCode || 0 24 | this.code = 201 25 | } 26 | } 27 | 28 | class NotFound extends HttpException { 29 | constructor(msg, errorCode) { 30 | super() 31 | this.msg = msg || '资源不存在' 32 | this.errorCode = errorCode || 10004 33 | this.code = 404 34 | } 35 | } 36 | 37 | class Forbidden extends HttpException { 38 | constructor(msg, errorCode) { 39 | super() 40 | this.msg = msg || '禁止访问' 41 | this.errorCode = errorCode || 10003 42 | this.code = 403 43 | } 44 | } 45 | 46 | class AuthFailed extends HttpException { 47 | constructor(msg, errorCode) { 48 | super() 49 | this.msg = msg || '认证失败' 50 | this.errorCode = errorCode || 10010 51 | this.code = 401 52 | } 53 | } 54 | 55 | class InvalidToken extends HttpException { 56 | constructor(msg, errorCode) { 57 | super() 58 | this.msg = msg || '令牌失效' 59 | this.errorCode = errorCode || 10020 60 | this.code = 401 61 | } 62 | } 63 | 64 | class ExpiredToken extends HttpException { 65 | constructor(msg, errorCode) { 66 | super() 67 | this.msg = msg || '令牌过期' 68 | this.errorCode = errorCode || 10030 69 | this.code = 422 70 | } 71 | } 72 | 73 | class RefreshException extends HttpException { 74 | constructor(msg, errorCode) { 75 | super() 76 | this.msg = msg || 'refresh token 获取失败' 77 | this.errorCode = errorCode || 10100 78 | this.code = 401 79 | } 80 | } 81 | 82 | class FileTooLargeException extends HttpException { 83 | constructor(msg, errorCode) { 84 | super() 85 | this.msg = msg || '文件体积过大' 86 | this.errorCode = errorCode || 10110 87 | this.code = 413 88 | } 89 | } 90 | 91 | class FileTooManyException extends HttpException { 92 | constructor(msg, errorCode) { 93 | super() 94 | this.msg = msg || '文件数量过多' 95 | this.errorCode = errorCode || 10120 96 | this.code = 413 97 | } 98 | } 99 | 100 | class FileExtensionException extends HttpException { 101 | constructor(msg, errorCode) { 102 | super() 103 | this.msg = msg || '文件扩展名不符合规范' 104 | this.errorCode = errorCode || 10130 105 | this.code = 401 106 | } 107 | } 108 | 109 | module.exports = { 110 | HttpException, 111 | ParameterException, 112 | Success, 113 | NotFound, 114 | Forbidden, 115 | AuthFailed, 116 | InvalidToken, 117 | ExpiredToken, 118 | RefreshException, 119 | FileTooLargeException, 120 | FileTooManyException, 121 | FileExtensionException, 122 | } -------------------------------------------------------------------------------- /app/api/v1/article.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | 3 | const { PositiveIntegerValidator } = require('@validator/common') 4 | const { 5 | CreateOrUpdateArticleValidator, 6 | GetArticlesValidator, 7 | SetPublicValidator, 8 | SetStarValidator, 9 | } = require('@validator/article') 10 | const { success } = require('../../lib/helper') 11 | const { Auth } = require('../../../middleware/auth') 12 | 13 | const { ArticleDao } = require('@dao/article') 14 | const { CommentDao } = require('@dao/comment') 15 | 16 | const articleApi = new Router({ 17 | prefix: '/v1/article' 18 | }) 19 | 20 | // 实例图片:https://resource.shirmy.me/lighthouse.jpeg 21 | 22 | const ArticleDto = new ArticleDao() 23 | const CommentDto = new CommentDao() 24 | 25 | // 创建文章 26 | articleApi.post('/', new Auth().m, async (ctx) => { 27 | const v = await new CreateOrUpdateArticleValidator().validate(ctx) 28 | await ArticleDto.createArticle(v) 29 | success('新建文章成功') 30 | }) 31 | 32 | // 更新文章 33 | articleApi.put('/', new Auth().m, async (ctx) => { 34 | const v = await new CreateOrUpdateArticleValidator().validate(ctx) 35 | await ArticleDto.updateArticle(v) 36 | success('更新文章成功') 37 | }) 38 | 39 | // 获取文章详情 40 | articleApi.get('/', new Auth().m, async (ctx) => { 41 | const v = await new PositiveIntegerValidator().validate(ctx) 42 | const article = await ArticleDto.getArticle(v.get('query.id')) 43 | 44 | ctx.body = article 45 | }) 46 | 47 | // 管理后台 获取全部文章 48 | articleApi.get('/articles', new Auth().m, async (ctx) => { 49 | const v = await new GetArticlesValidator().validate(ctx) 50 | 51 | const result = await ArticleDto.getArticles(v) 52 | ctx.body = result 53 | }) 54 | 55 | // 删除某篇文章,需要最高权限 56 | articleApi.delete('/', new Auth(32).m, async (ctx) => { 57 | const v = await new PositiveIntegerValidator().validate(ctx) 58 | const id = v.get('query.id') 59 | await ArticleDto.deleteArticle(id) 60 | success('删除文章成功') 61 | }) 62 | 63 | // 设置某篇文章为 公开 或 私密 64 | articleApi.put('/public', new Auth().m, async (ctx) => { 65 | const v = await new SetPublicValidator().validate(ctx) 66 | const id = v.get('query.id') 67 | const publicId = v.get('body.publicId') 68 | 69 | await ArticleDto.updateArticlePublic(id, publicId) 70 | success(`设为${publicId === 1 ? '公开' : '私密'}成功`) 71 | }) 72 | 73 | // 设置某篇文章为 精选 或 非精选 74 | articleApi.put('/star', new Auth().m, async (ctx) => { 75 | const v = await new SetStarValidator().validate(ctx) 76 | const id = v.get('query.id') 77 | const starId = v.get('body.starId') 78 | 79 | await ArticleDto.updateArticleStar(id, starId) 80 | success(`设为${starId === 2 ? '精选' : '非精选'}成功`) 81 | }) 82 | 83 | // 获取文章下的全部评论 84 | articleApi.get('/get/comment', new Auth().m, async (ctx) => { 85 | const v = await new PositiveIntegerValidator().validate(ctx, { 86 | id: 'articleId' 87 | }) 88 | const articleId = v.get('query.articleId') 89 | const comments = await CommentDto.getComments(articleId) 90 | ctx.body = comments 91 | }) 92 | 93 | // 删除某条评论 需要最高权限 94 | articleApi.delete('/del/comment', new Auth(32).m, async (ctx) => { 95 | const v = await new PositiveIntegerValidator().validate(ctx) 96 | const id = v.get('query.id') 97 | await CommentDto.deleteComment(id) 98 | success('删除评论成功') 99 | }) 100 | 101 | module.exports = articleApi -------------------------------------------------------------------------------- /app/api/blog/article.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | 3 | const { PositiveIntegerValidator } = require('@validator/common') 4 | const { 5 | CreateCommentValidator, 6 | ReplyCommentValidator, 7 | GetArticlesValidator, 8 | SearchArticlesValidator 9 | } = require('@validator/article') 10 | const { success } = require('../../lib/helper') 11 | 12 | const { ArticleDao } = require('@dao/article') 13 | const { CommentDao } = require('@dao/comment') 14 | 15 | const articleApi = new Router({ 16 | prefix: '/v1/blog/article' 17 | }) 18 | 19 | const ArticleDto = new ArticleDao() 20 | const CommentDto = new CommentDao() 21 | 22 | // 获取文章详情 23 | articleApi.get('/', async (ctx) => { 24 | const v = await new PositiveIntegerValidator().validate(ctx) 25 | const article = await ArticleDto.getArticle(v.get('query.id')) 26 | 27 | ctx.body = article 28 | }) 29 | 30 | // 点赞某篇文章 31 | articleApi.put('/like', async (ctx) => { 32 | const v = await new PositiveIntegerValidator().validate(ctx) 33 | const id = v.get('body.id') 34 | await ArticleDto.likeArticle(id) 35 | success('点赞文章成功') 36 | }) 37 | 38 | // 获取全部文章 39 | articleApi.get('/blog/articles', async (ctx) => { 40 | // 文章必须是公开的 1 公开 2 私密 41 | ctx.query.publicId = 1 42 | // 文章必须是已发布的 1 已发布 2 草稿 43 | ctx.query.statusId = 1 44 | // 文章包括非精选和精选 45 | ctx.query.starId = '0' 46 | const v = await new GetArticlesValidator().validate(ctx) 47 | 48 | const result = await ArticleDto.getArticles(v, true) 49 | ctx.body = result 50 | }) 51 | 52 | // 搜索文章 53 | articleApi.get('/search/articles', async (ctx) => { 54 | const v = await new SearchArticlesValidator().validate(ctx) 55 | 56 | const result = await ArticleDto.searchArticles(v) 57 | ctx.body = result 58 | }) 59 | 60 | // 获取归档 61 | articleApi.get('/archive', async (ctx) => { 62 | const archive = await ArticleDto.getArchive() 63 | ctx.body = archive 64 | }) 65 | 66 | // 获取所有精选文章 67 | articleApi.get('/star/articles', async (ctx) => { 68 | const articles = await ArticleDto.getStarArticles() 69 | ctx.body = articles 70 | }) 71 | 72 | // 添加评论 73 | articleApi.post('/add/comment', async (ctx) => { 74 | const v = await new CreateCommentValidator().validate(ctx, { 75 | id: 'articleId' 76 | }) 77 | const articleId = v.get('body.articleId') 78 | await CommentDto.createComment(v, articleId) 79 | success('添加评论成功') 80 | }) 81 | 82 | // 获取文章下的全部评论 83 | articleApi.get('/get/comment', async (ctx) => { 84 | const v = await new PositiveIntegerValidator().validate(ctx, { 85 | id: 'articleId' 86 | }) 87 | const articleId = v.get('query.articleId') 88 | const comments = await CommentDto.getComments(articleId) 89 | ctx.body = comments 90 | }) 91 | 92 | // 点赞某条评论 93 | articleApi.put('/like/comment', async (ctx) => { 94 | const v = await new PositiveIntegerValidator().validate(ctx) 95 | const id = v.get('body.id') 96 | await CommentDto.likeComment(id) 97 | success('点赞评论成功') 98 | }) 99 | 100 | // 回复某条评论 101 | articleApi.post('/reply/comment', async (ctx) => { 102 | const v = await new ReplyCommentValidator().validate(ctx, { 103 | id: 'articleId' 104 | }) 105 | const articleId = v.get('body.articleId') 106 | const parentId = v.get('body.parentId') 107 | await CommentDto.replyComment(v, articleId, parentId) 108 | success('回复成功') 109 | }) 110 | 111 | module.exports = articleApi -------------------------------------------------------------------------------- /middleware/auth.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken') 2 | const { Author } = require('@models') 3 | const { TokenType } = require('../app/lib/enums') 4 | 5 | const { Forbidden, AuthFailed, NotFound, InvalidToken, ExpiredToken, RefreshException } = require('@exception') 6 | 7 | /** 8 | * 解析请求头 9 | * @param ctx koa 的context 10 | * @param type 令牌的类型 11 | */ 12 | async function parseHeader(ctx, type = TokenType.ACCESS) { 13 | if (!ctx.header || !ctx.header.authorization) { 14 | throw new AuthFailed({ msg: '认证失败,请检查请求令牌是否正确' }) 15 | } 16 | const parts = ctx.header.authorization.split(' ') 17 | if (parts.length === 2) { 18 | // Bearer 字段 19 | const schema = parts[0] 20 | // token 字段 21 | const token = parts[1] 22 | if (/^Bearer$/i.test(schema)) { 23 | let decode 24 | try { 25 | decode = jwt.verify(token, global.config.security.secretKey) 26 | } catch (error) { 27 | // 需要重新刷新令牌 28 | if (error.name === 'TokenExpiredError') { 29 | throw new ExpiredToken({ msg: '认证失败,token已过期' }) 30 | } else { 31 | throw new InvalidToken({ msg: '认证失败,令牌失效'}) 32 | } 33 | } 34 | 35 | if (!decode.type || decode.type !== type) { 36 | throw new AuthFailed({ msg: '请使用正确类型的令牌' }) 37 | } 38 | if (!decode.scope || decode.scope < ctx.level) { 39 | throw new Forbidden({ msg: '权限不足' }) 40 | } 41 | 42 | const author = await Author.findByPk(decode.uid) 43 | if (!author) { 44 | throw new NotFound({ msg: '没有找到相关作者' }) 45 | } 46 | 47 | // 把 author 挂在 ctx 上 48 | ctx.currentAuthor = author 49 | 50 | // 往令牌中保存数据 51 | ctx.auth = { 52 | uid: decode.uid, 53 | scope: decode.scope 54 | } 55 | } 56 | } else { 57 | throw new AuthFailed() 58 | } 59 | } 60 | 61 | /** 62 | * 生成令牌 63 | * @param {number} uid 64 | * @param {number} scope 65 | * @param {TokenType} type 66 | * @param {Object} options 67 | */ 68 | const generateToken = function (uid, scope, type = TokenType.ACCESS, options) { 69 | const secretKey = global.config.security.secretKey 70 | const token = jwt.sign({ 71 | uid, 72 | scope, 73 | type 74 | }, secretKey, { 75 | expiresIn: options.expiresIn 76 | }) 77 | return token 78 | } 79 | 80 | /** 81 | * 守卫函数,用户登陆即可访问 82 | */ 83 | const loginRequired = async function (ctx, next) { 84 | if (ctx.request.method !== 'OPTIONS') { 85 | await parseHeader(ctx, TokenType.ACCESS) 86 | await next() 87 | } else { 88 | await next() 89 | } 90 | } 91 | 92 | /** 93 | * 守卫函数,用户刷新令牌,统一异常 94 | */ 95 | const refreshTokenRequiredWithUnifyException = async function (ctx, next) { 96 | if (ctx.request.method !== 'OPTIONS') { 97 | try { 98 | await parseHeader(ctx, TokenType.REFRESH) 99 | } catch (e) { 100 | throw new RefreshException() 101 | } 102 | await next() 103 | } else { 104 | await next() 105 | } 106 | } 107 | 108 | class Auth { 109 | constructor(level) { 110 | this.level = level || 1 111 | } 112 | 113 | get m() { 114 | return async (ctx, next) => { 115 | ctx.level = this.level 116 | return await loginRequired(ctx, next) 117 | } 118 | } 119 | } 120 | 121 | class RefreshAuth { 122 | constructor(level) { 123 | this.level = level || 1 124 | } 125 | 126 | get m() { 127 | return async (ctx, next) => { 128 | ctx.level = this.level 129 | return await refreshTokenRequiredWithUnifyException(ctx, next) 130 | } 131 | } 132 | } 133 | 134 | 135 | module.exports = { 136 | Auth, 137 | RefreshAuth, 138 | generateToken 139 | } 140 | -------------------------------------------------------------------------------- /core/multipart.js: -------------------------------------------------------------------------------- 1 | const busboy = require('co-busboy') 2 | const streamWormhole = require('stream-wormhole') 3 | const path = require('path') 4 | const { FileExtensionException, FileTooLargeException, FileTooManyException } = require('@exception') 5 | const { cloneDeep } = require('lodash') 6 | 7 | const multipart = (app) => { 8 | app.context.multipart = async function (opts) { 9 | // multipart/form-data 10 | if (!this.is('multipart')) { 11 | throw new Error('Content-Type must be multipart/*') 12 | } 13 | // field指表单中的非文件 14 | const parts = busboy(this, { autoFields: opts && opts.autoFields }) 15 | let part 16 | let totalSize = 0 17 | const files = [] 18 | while ((part = await parts()) != null ) { 19 | if (part.length) { 20 | 21 | } else { 22 | if (!part.filename) { 23 | await streamWormhole(part) 24 | } 25 | // 检查 extension 26 | const ext = path.extname(part.filename) 27 | if (!checkFileExtension(ext, opts && opts.include, opts && opts.exclude)) { 28 | throw new FileExtensionException({ 29 | msg: `不支持类型为${ext}的文件` 30 | }) 31 | } 32 | const { valid, conf } = checkSingleFileSize(part._readableState.length, opts && opts.singleLimit) 33 | if (!valid) { 34 | throw new FileTooLargeException({ 35 | msg: `文件单个大小不能超过${conf}b` 36 | }) 37 | } 38 | // 计算那总大小 39 | totalSize += part._readableState.length 40 | const tmp = cloneDeep(part) 41 | files.push(tmp) 42 | // 恢复再次接受 data 43 | part.resume() 44 | } 45 | } 46 | const { valid, conf } = checkFileCount(files.length, opts && opts.fileCount) 47 | if (!valid) { 48 | throw new FileTooManyException({ 49 | msg: `上传文件数量不能超过${conf}` 50 | }) 51 | } 52 | const { valid: valid1, conf: conf1 } = checkTotalFileSize(totalSize, opts && opts.totalLimit) 53 | if (!valid1) { 54 | throw new FileTooLargeException({ 55 | msg: `总文件体积不能超过${conf1}` 56 | }) 57 | } 58 | return files 59 | } 60 | } 61 | 62 | function checkSingleFileSize(size, singleLimit) { 63 | // 默认 2M 64 | const confSize = singleLimit ? singleLimit : global.config.file.singleLimit || 1024 * 1024 * 2 65 | return { 66 | valid: confSize > size, 67 | conf: confSize 68 | } 69 | } 70 | 71 | function checkTotalFileSize(size, totalLimit) { 72 | // 默认 20M 73 | const confSize = totalLimit ? totalLimit : global.config.file.totalLimit || 1024 * 1024 * 20 74 | return { 75 | valid: confSize > size, 76 | conf: confSize 77 | }; 78 | } 79 | 80 | function checkFileExtension(ext, include, exclude) { 81 | const fileInclude = include ? include : global.config.file.include 82 | const fileExclude = exclude ? exclude : global.config.file.exclude 83 | 84 | // 如果两者都有取fileInclude,有一者则用一者 85 | if (fileInclude && fileExclude) { 86 | if (!Array.isArray(fileInclude)) { 87 | throw new Error('file_include must an array!') 88 | } 89 | return fileInclude.includes(ext) 90 | } 91 | else if (fileInclude && !fileExclude) { 92 | // 有include,无exclude 93 | if (!Array.isArray(fileInclude)) { 94 | throw new Error('file_include must an array!') 95 | } 96 | return fileInclude.includes(ext) 97 | } 98 | else if (fileExclude && !fileInclude) { 99 | // 有exclude,无include 100 | if (!Array.isArray(fileExclude)) { 101 | throw new Error('file_exclude must an array!') 102 | } 103 | return !fileExclude.includes(ext) 104 | } 105 | else { 106 | // 二者都没有 107 | return true 108 | } 109 | } 110 | 111 | function checkFileCount(count, fileCount) { 112 | // 默认 10 113 | const confCount = fileCount ? fileCount : global.config.file.fileCount || 10 114 | return { 115 | valid: confCount > count, 116 | conf: confCount 117 | }; 118 | } 119 | 120 | module.exports = multipart -------------------------------------------------------------------------------- /app/dao/author.js: -------------------------------------------------------------------------------- 1 | const { Op } = require('sequelize') 2 | 3 | const { Author, ArticleAuthor } = require('@models') 4 | const { Forbidden, NotFound, ParameterException } = require('@exception') 5 | const { AuthType } = require('../lib/enums') 6 | const bcrypt = require('bcryptjs') 7 | 8 | class AuthorDao { 9 | async createAuthor(v) { 10 | const name = v.get('body.name') 11 | const author = await Author.findOne({ 12 | where: { 13 | name 14 | } 15 | }) 16 | if (author) { 17 | throw new Forbidden({ 18 | msg: '已存在该作者名' 19 | }) 20 | } 21 | await Author.create({ 22 | name: v.get('body.name'), 23 | avatar: v.get('body.avatar'), 24 | email: v.get('body.email'), 25 | description: v.get('body.description'), 26 | password: v.get('body.password'), 27 | auth: v.get('body.auth') 28 | }) 29 | } 30 | 31 | async getAuthorDetail(id) { 32 | const author = await Author.findOne({ 33 | where: { 34 | id, 35 | }, 36 | attributes: { exclude: ['auth'] } 37 | }) 38 | return author 39 | } 40 | 41 | async updateAuthor(v, id) { 42 | const author = await Author.findByPk(id) 43 | if (!author) { 44 | throw new NotFound({ 45 | msg: '没有找到相关作者' 46 | }) 47 | } 48 | author.avatar = v.get('body.avatar') 49 | author.email = v.get('body.email') 50 | author.description = v.get('body.description') 51 | author.auth = v.get('body.auth') 52 | author.save() 53 | } 54 | 55 | async updateAvatar(avatar, id) { 56 | const author = await Author.findByPk(id) 57 | if (!author) { 58 | throw new NotFound({ 59 | msg: '没有找到相关作者' 60 | }) 61 | } 62 | author.avatar = avatar 63 | author.save() 64 | return author 65 | } 66 | 67 | async deleteAuthor(id) { 68 | const author = await Author.findOne({ 69 | where: { 70 | id 71 | } 72 | }) 73 | if (!author) { 74 | throw new NotFound({ 75 | msg: '没有找到相关作者' 76 | }) 77 | } 78 | const result = await ArticleAuthor.findOne({ 79 | where: { 80 | author_id: id 81 | } 82 | }) 83 | if (result) { 84 | throw new Forbidden({ 85 | msg: '该作者下有文章,禁止删除' 86 | }) 87 | } 88 | author.destroy() 89 | } 90 | 91 | async changePassword(v, id) { 92 | const author = await Author.findByPk(id) 93 | if (!author) { 94 | throw new NotFound({ 95 | msg: '没有找到相关作者' 96 | }) 97 | } 98 | author.password = v.get('body.password') 99 | author.save() 100 | } 101 | 102 | async changeSelfPassword(v, id) { 103 | const author = await Author.findByPk(id) 104 | if (!author) { 105 | throw new NotFound({ 106 | msg: '没有找到相关作者' 107 | }) 108 | } 109 | const correct = bcrypt.compareSync(v.get('body.oldPassword'), author.password) 110 | if (!correct) { 111 | throw new ParameterException('原始密码不正确') 112 | } 113 | author.password = v.get('body.password') 114 | author.save() 115 | } 116 | 117 | async verifyPassword(ctx, name, password) { 118 | const author = await Author.findOne({ 119 | where: { 120 | name 121 | } 122 | }) 123 | if (!author) { 124 | throw new NotFound('作者不存在') 125 | } 126 | const correct = bcrypt.compareSync(password, author.password) 127 | if (!correct) { 128 | throw new ParameterException('密码不正确') 129 | } 130 | 131 | return author 132 | } 133 | 134 | async getAuthors() { 135 | const authors = await Author.findAll({ 136 | attributes: { exclude: ['auth'] } 137 | }) 138 | return authors 139 | } 140 | 141 | // 获取除了管理员之外的全部作者 142 | async getAdminAuthors() { 143 | const authors = await Author.findAll({ 144 | where: { 145 | auth: { 146 | [Op.ne]: AuthType.SUPER_ADMIN 147 | } 148 | }, 149 | attributes: { exclude: ['auth'] } 150 | }) 151 | return authors 152 | } 153 | } 154 | 155 | module.exports = { 156 | AuthorDao 157 | } -------------------------------------------------------------------------------- /app/api/v1/author.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | 3 | const { success } = require('../../lib/helper') 4 | const { CreateAuthorValidator, UpdateAuthorValidator, LoginValidator, PasswordValidator, SelfPasswordValidator, AvatarUpdateValidator } = require('@validator/author') 5 | const { PositiveIntegerValidator } = require('@validator/common') 6 | const { Auth, RefreshAuth, generateToken } = require('../../../middleware/auth') 7 | const { getSafeParamId } = require('../../lib/util') 8 | const { Forbidden } = require('@exception') 9 | const { TokenType } = require('../../lib/enums') 10 | 11 | const { AuthorDao } = require('@dao/author') 12 | const { ArticleAuthorDao } = require('@dao/articleAuthor') 13 | 14 | const AuthorDto = new AuthorDao() 15 | const ArticleAuthorDto = new ArticleAuthorDao() 16 | 17 | const authorApi = new Router({ 18 | prefix: '/v1/author' 19 | }) 20 | 21 | // 创建作者 22 | // 如果需要创建超级管理员 请先去掉 new Auth().m 23 | authorApi.post('/', new Auth().m, async (ctx) => { 24 | const v = await new CreateAuthorValidator().validate(ctx) 25 | 26 | await AuthorDto.createAuthor(v) 27 | success('创建用户成功') 28 | }) 29 | 30 | // 更新作者信息 31 | authorApi.put('/info', new Auth().m, async (ctx) => { 32 | const v = await new UpdateAuthorValidator().validate(ctx) 33 | const id = getSafeParamId(v) 34 | 35 | await AuthorDto.updateAuthor(v, id) 36 | success('更新用户成功') 37 | }) 38 | 39 | // 修改用户头像 40 | authorApi.put('/avatar', new Auth().m, async (ctx) => { 41 | const v = await new AvatarUpdateValidator().validate(ctx) 42 | const avatar = v.get('body.avatar') 43 | const id = ctx.currentAuthor.id 44 | 45 | await AuthorDto.updateAvatar(avatar, id) 46 | success('更新头像成功') 47 | }) 48 | 49 | // 超级管理员修改作者的密码 50 | authorApi.put('/password', new Auth(32).m, async (ctx) => { 51 | const v = await new PasswordValidator().validate(ctx) 52 | const id = getSafeParamId(v) 53 | 54 | await AuthorDto.changePassword(v, id) 55 | success('修改作者密码成功') 56 | }) 57 | 58 | // 修改自己的密码 59 | authorApi.put('/password/self', new Auth().m, new Auth().m, async (ctx) => { 60 | const v = await new SelfPasswordValidator().validate(ctx) 61 | const id = ctx.currentAuthor.id 62 | 63 | await AuthorDto.changeSelfPassword(v, id) 64 | success('修改密码成功') 65 | }) 66 | 67 | // 删除作者,需要最高权限 32 才能删除 68 | authorApi.delete('/', new Auth(32).m, async (ctx) => { 69 | const v = await new PositiveIntegerValidator().validate(ctx) 70 | const id = getSafeParamId(v) 71 | 72 | const authorId = ctx.currentAuthor.id 73 | if (id === authorId) { 74 | throw new Forbidden({ 75 | msg: '不能删除自己' 76 | }) 77 | } 78 | await AuthorDto.deleteAuthor(id) 79 | success('删除作者成功') 80 | }) 81 | 82 | // 登录 83 | authorApi.post('/login', async (ctx) => { 84 | const v = await new LoginValidator().validate(ctx) 85 | const name = v.get('body.name') 86 | const password = v.get('body.password') 87 | 88 | const author = await AuthorDto.verifyPassword(ctx, name, password) 89 | 90 | const accessToken = generateToken(author.id, author.auth, TokenType.ACCESS, { expiresIn: global.config.security.accessExp }) 91 | const refreshToken = generateToken(author.id, author.auth, TokenType.REFRESH, { expiresIn: global.config.security.refreshExp }) 92 | ctx.body = { 93 | accessToken, 94 | refreshToken 95 | } 96 | }) 97 | 98 | /** 99 | * 守卫函数,用户刷新令牌,统一异常 100 | */ 101 | authorApi.get('/refresh', new RefreshAuth().m, async (ctx) => { 102 | const author = ctx.currentAuthor 103 | 104 | const accessToken = generateToken(author.id, author.auth, TokenType.ACCESS, { expiresIn: global.config.security.accessExp }) 105 | const refreshToken = generateToken(author.id, author.auth, TokenType.REFRESH, { expiresIn: global.config.security.refreshExp }) 106 | 107 | ctx.body = { 108 | accessToken, 109 | refreshToken 110 | } 111 | }) 112 | 113 | // 获取除了管理员之外的全部作者 114 | authorApi.get('/authors/admin', new Auth().m, async (ctx) => { 115 | const authors = await AuthorDto.getAdminAuthors() 116 | ctx.body = authors 117 | }) 118 | 119 | // 获取全部作者 120 | authorApi.get('/authors', new Auth().m, async (ctx) => { 121 | const authors = await AuthorDto.getAuthors() 122 | ctx.body = authors 123 | }) 124 | 125 | authorApi.get('/info', new Auth().m, async (ctx) => { 126 | ctx.body = ctx.currentAuthor 127 | }) 128 | 129 | module.exports = authorApi -------------------------------------------------------------------------------- /app/validators/article.js: -------------------------------------------------------------------------------- 1 | const { LinValidator, Rule } = require('../../core/lin-validator') 2 | const { PositiveIntegerValidator, PaginateValidator } = require('./common') 3 | 4 | class CreateOrUpdateArticleValidator extends LinValidator { 5 | constructor() { 6 | super() 7 | this.validateTags = checkTags 8 | this.validateAuthors = checkAuthors 9 | this.title = [ 10 | new Rule('isLength', '标题必须在1~64个字符之间', { 11 | min: 1, 12 | max: 64 13 | }) 14 | ] 15 | this.content = [ 16 | new Rule('isLength', '文章内容不能为空', { 17 | min: 1 18 | }) 19 | ] 20 | this.cover = [ 21 | new Rule('isOptional'), 22 | new Rule('isURL', '不符合URL规范') 23 | ] 24 | this.createdDate = new Rule('isNotEmpty', '创建时间不能为空'); 25 | this.categoryId = [ 26 | new Rule('isInt', '分类ID需要是正整数', { 27 | min: 1 28 | }) 29 | ], 30 | this.status = [ 31 | new Rule('isInt', '文章状态需要为正整数', { 32 | min: 1 33 | }) 34 | ], 35 | this.public = [ 36 | new Rule('isInt', '文章公开需要为正整数', { 37 | min: 1 38 | }) 39 | ] 40 | this.star = [ 41 | new Rule('isInt', '文章精选需要为正整数', { 42 | min: 1 43 | }) 44 | ] 45 | } 46 | } 47 | 48 | function checkTags(val) { 49 | let tags = val.body.tags 50 | if (!tags) { 51 | throw new Error('tags是必须参数') 52 | } 53 | try { 54 | if (typeof tags === 'string') { 55 | tags = JSON.parse(tags) 56 | } 57 | } catch (error) { 58 | throw new Error('tags参数不合法') 59 | } 60 | if (!Array.isArray(tags)) { 61 | throw new Error('tags必须是元素都为正整数的数组') 62 | } 63 | } 64 | 65 | function checkAuthors(val) { 66 | let authors = val.body.authors 67 | if (!authors) { 68 | throw new Error('authors是必须参数') 69 | } 70 | try { 71 | if (typeof tags === 'string') { 72 | authors = JSON.parse(authors) 73 | } 74 | } catch (error) { 75 | throw new Error('authors参数不合法') 76 | } 77 | if (!Array.isArray(authors)) { 78 | throw new Error('authors必须是元素都为正整数的数组') 79 | } 80 | } 81 | 82 | class GetArticlesValidator extends PaginateValidator { 83 | constructor() { 84 | super() 85 | this.categoryId = [ 86 | new Rule('isInt', '需要是整数', { 87 | min: 0 88 | }) 89 | ] 90 | this.authorId = [ 91 | new Rule('isInt', '需要是整数', { 92 | min: 0 93 | }) 94 | ] 95 | this.tagId = [ 96 | new Rule('isInt', '需要是整数', { 97 | min: 0 98 | }) 99 | ] 100 | this.publicId = [ 101 | new Rule('isInt', '需要是整数', { 102 | min: 0 103 | }) 104 | ] 105 | this.statusId = [ 106 | new Rule('isInt', '需要是整数', { 107 | min: 0 108 | }) 109 | ] 110 | this.starId = [ 111 | new Rule('isInt', '需要是整数', { 112 | min: 0 113 | }) 114 | ] 115 | this.search = [ 116 | new Rule('isOptional'), 117 | new Rule('isLength', '关键词必须在1~10个字符之间', { 118 | min: 1, 119 | max: 10 120 | }) 121 | ] 122 | } 123 | } 124 | 125 | class SearchArticlesValidator extends PaginateValidator { 126 | constructor() { 127 | super() 128 | this.search = [ 129 | new Rule('isLength', '关键词必须在1~10个字符之间', { 130 | min: 1, 131 | max: 10 132 | }) 133 | ] 134 | } 135 | } 136 | 137 | class SetPublicValidator extends PositiveIntegerValidator { 138 | constructor() { 139 | super() 140 | this.publicId = [ 141 | new Rule('isInt', '需要是整数', { 142 | min: 0 143 | }) 144 | ] 145 | } 146 | } 147 | 148 | class SetStarValidator extends PositiveIntegerValidator { 149 | constructor() { 150 | super() 151 | this.starId = [ 152 | new Rule('isInt', '需要是整数', { 153 | min: 0 154 | }) 155 | ] 156 | } 157 | } 158 | 159 | class CreateCommentValidator extends PositiveIntegerValidator { 160 | constructor() { 161 | super() 162 | this.nickname = [ 163 | new Rule('isLength', '昵称必须在1~32个字符之间', { 164 | min: 1, 165 | max: 32 166 | }) 167 | ], 168 | this.content = [ 169 | new Rule('isLength', '内容必须在1~1023个字符之间', { 170 | min: 1, 171 | max: 1023 172 | }) 173 | ] 174 | this.email = [ 175 | new Rule('isOptional'), 176 | new Rule('isEmail', '不符合Email规范') 177 | ] 178 | this.website = [ 179 | new Rule('isOptional'), 180 | new Rule('isURL', '不符合URL规范') 181 | ] 182 | } 183 | } 184 | 185 | class ReplyCommentValidator extends CreateCommentValidator { 186 | constructor() { 187 | super() 188 | this.parentId = [ 189 | new Rule('isInt', '需要是正整数', { 190 | min: 1 191 | }) 192 | ] 193 | } 194 | } 195 | 196 | 197 | module.exports = { 198 | CreateOrUpdateArticleValidator, 199 | CreateCommentValidator, 200 | ReplyCommentValidator, 201 | GetArticlesValidator, 202 | SetPublicValidator, 203 | SetStarValidator, 204 | SearchArticlesValidator 205 | } -------------------------------------------------------------------------------- /core/lin-validator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Lin-Validator v2 3 | */ 4 | 5 | const validator = require('validator') 6 | const { 7 | ParameterException 8 | } = require('./http-exception') 9 | const { 10 | get, 11 | last, 12 | set, 13 | cloneDeep 14 | } = require("lodash") 15 | const { 16 | findMembers 17 | } = require('./util') 18 | 19 | 20 | class LinValidator { 21 | constructor() { 22 | this.data = {} 23 | this.parsed = {} 24 | } 25 | 26 | 27 | _assembleAllParams(ctx) { 28 | return { 29 | body: ctx.request.body, 30 | query: ctx.request.query, 31 | path: ctx.params, 32 | header: ctx.request.header 33 | } 34 | } 35 | 36 | get(path, parsed = true) { 37 | if (parsed) { 38 | const value = get(this.parsed, path, null) 39 | if (value == null) { 40 | const keys = path.split('.') 41 | const key = last(keys) 42 | return get(this.parsed.default, key) 43 | } 44 | return value 45 | } else { 46 | return get(this.data, path) 47 | } 48 | } 49 | 50 | _findMembersFilter(key) { 51 | if (/validate([A-Z])\w+/g.test(key)) { 52 | return true 53 | } 54 | if (this[key] instanceof Array) { 55 | this[key].forEach(value => { 56 | const isRuleType = value instanceof Rule 57 | if (!isRuleType) { 58 | throw new Error('验证数组必须全部为Rule类型') 59 | } 60 | }) 61 | return true 62 | } 63 | return false 64 | } 65 | 66 | async validate(ctx, alias = {}) { 67 | this.alias = alias 68 | let params = this._assembleAllParams(ctx) 69 | this.data = cloneDeep(params) 70 | this.parsed = cloneDeep(params) 71 | 72 | const memberKeys = findMembers(this, { 73 | filter: this._findMembersFilter.bind(this) 74 | }) 75 | 76 | const errorMsgs = [] 77 | // const map = new Map(memberKeys) 78 | for (let key of memberKeys) { 79 | const result = await this._check(key, alias) 80 | if (!result.success) { 81 | errorMsgs.push(result.msg) 82 | } 83 | } 84 | if (errorMsgs.length != 0) { 85 | throw new ParameterException(errorMsgs) 86 | } 87 | ctx.v = this 88 | return this 89 | } 90 | 91 | async _check(key, alias = {}) { 92 | const isCustomFunc = typeof (this[key]) == 'function' ? true : false 93 | let result; 94 | if (isCustomFunc) { 95 | try { 96 | await this[key](this.data) 97 | result = new RuleResult(true) 98 | } catch (error) { 99 | result = new RuleResult(false, error.msg || error.message || '参数错误') 100 | } 101 | // 函数验证 102 | } else { 103 | // 属性验证, 数组,内有一组Rule 104 | const rules = this[key] 105 | const ruleField = new RuleField(rules) 106 | // 别名替换 107 | key = alias[key] ? alias[key] : key 108 | const param = this._findParam(key) 109 | 110 | result = ruleField.validate(param.value) 111 | 112 | if (result.pass) { 113 | // 如果参数路径不存在,往往是因为用户传了空值,而又设置了默认值 114 | if (param.path.length == 0) { 115 | set(this.parsed, ['default', key], result.legalValue) 116 | } else { 117 | set(this.parsed, param.path, result.legalValue) 118 | } 119 | } 120 | } 121 | if (!result.pass) { 122 | const msg = `${isCustomFunc ? '' : key}${result.msg}` 123 | return { 124 | msg: msg, 125 | success: false 126 | } 127 | } 128 | return { 129 | msg: 'ok', 130 | success: true 131 | } 132 | } 133 | 134 | _findParam(key) { 135 | let value 136 | value = get(this.data, ['query', key]) 137 | if (value) { 138 | return { 139 | value, 140 | path: ['query', key] 141 | } 142 | } 143 | value = get(this.data, ['body', key]) 144 | if (value) { 145 | return { 146 | value, 147 | path: ['body', key] 148 | } 149 | } 150 | value = get(this.data, ['path', key]) 151 | if (value) { 152 | return { 153 | value, 154 | path: ['path', key] 155 | } 156 | } 157 | value = get(this.data, ['header', key]) 158 | if (value) { 159 | return { 160 | value, 161 | path: ['header', key] 162 | } 163 | } 164 | return { 165 | value: null, 166 | path: [] 167 | } 168 | } 169 | } 170 | 171 | class RuleResult { 172 | constructor(pass, msg = '') { 173 | Object.assign(this, { 174 | pass, 175 | msg 176 | }) 177 | } 178 | } 179 | 180 | class RuleFieldResult extends RuleResult { 181 | constructor(pass, msg = '', legalValue = null) { 182 | super(pass, msg) 183 | this.legalValue = legalValue 184 | } 185 | } 186 | 187 | class Rule { 188 | constructor(name, msg, ...params) { 189 | Object.assign(this, { 190 | name, 191 | msg, 192 | params 193 | }) 194 | } 195 | 196 | validate(field) { 197 | if (this.name == 'isOptional') 198 | return new RuleResult(true) 199 | if (!validator[this.name](field + '', ...this.params)) { 200 | return new RuleResult(false, this.msg || this.message || '参数错误') 201 | } 202 | return new RuleResult(true, '') 203 | } 204 | } 205 | 206 | class RuleField { 207 | constructor(rules) { 208 | this.rules = rules 209 | } 210 | 211 | validate(field) { 212 | if (field == null) { 213 | // 如果字段为空 214 | const allowEmpty = this._allowEmpty() 215 | const defaultValue = this._hasDefault() 216 | if (allowEmpty) { 217 | return new RuleFieldResult(true, '', defaultValue) 218 | } else { 219 | return new RuleFieldResult(false, '字段是必填参数') 220 | } 221 | } 222 | 223 | const filedResult = new RuleFieldResult(false) 224 | for (let rule of this.rules) { 225 | let result = rule.validate(field) 226 | if (!result.pass) { 227 | filedResult.msg = result.msg 228 | filedResult.legalValue = null 229 | // 一旦一条校验规则不通过,则立即终止这个字段的验证 230 | return filedResult 231 | } 232 | } 233 | return new RuleFieldResult(true, '', this._convert(field)) 234 | } 235 | 236 | _convert(value) { 237 | for (let rule of this.rules) { 238 | if (rule.name == 'isInt') { 239 | return parseInt(value) 240 | } 241 | if (rule.name == 'isFloat') { 242 | return parseFloat(value) 243 | } 244 | if (rule.name == 'isBoolean') { 245 | return value ? true : false 246 | } 247 | } 248 | return value 249 | } 250 | 251 | _allowEmpty() { 252 | for (let rule of this.rules) { 253 | if (rule.name == 'isOptional') { 254 | return true 255 | } 256 | } 257 | return false 258 | } 259 | 260 | _hasDefault() { 261 | for (let rule of this.rules) { 262 | const defaultValue = rule.params[0] 263 | if (rule.name == 'isOptional') { 264 | return defaultValue 265 | } 266 | } 267 | } 268 | } 269 | 270 | 271 | 272 | module.exports = { 273 | Rule, 274 | LinValidator 275 | } -------------------------------------------------------------------------------- /app/dao/article.js: -------------------------------------------------------------------------------- 1 | const { sequelize } = require('../../core/db') 2 | const { omitBy, isUndefined, intersection, unset } = require('lodash') 3 | const { Op } = require('sequelize') 4 | 5 | const { NotFound, Forbidden } = require('@exception') 6 | 7 | const { Article, Tag, Author, Comment, Category } = require('@models') 8 | const { ArticleTagDao } = require('@dao/articleTag') 9 | const { ArticleAuthorDao } = require('@dao/articleAuthor') 10 | const { CategoryDao } = require('@dao/category') 11 | 12 | const ArticleTagDto = new ArticleTagDao() 13 | const ArticleAuthorDto = new ArticleAuthorDao() 14 | const CategoryDto = new CategoryDao() 15 | 16 | class ArticleDao { 17 | async createArticle(v) { 18 | const article = await Article.findOne({ 19 | where: { 20 | title: v.get('body.title') 21 | } 22 | }) 23 | if (article) { 24 | throw new Forbidden({ 25 | msg: '存在同名文章' 26 | }) 27 | } 28 | const categoryId = v.get('body.categoryId') 29 | const category = await CategoryDto.getCategory(categoryId) 30 | if (!category) { 31 | throw new Forbidden({ 32 | msg: '未能找到相关分类' 33 | }) 34 | } 35 | return sequelize.transaction(async t => { 36 | const result = await Article.create({ 37 | title: v.get('body.title'), 38 | content: v.get('body.content'), 39 | description: v.get('body.description'), 40 | cover: v.get('body.cover'), 41 | created_date: v.get('body.createdDate'), 42 | category_id: categoryId, 43 | public: v.get('body.public'), 44 | status: v.get('body.status'), 45 | star: v.get('body.star'), 46 | like: 0 47 | }, { transaction: t }) 48 | 49 | const articleId = result.getDataValue('id') 50 | await ArticleTagDto.createArticleTag(articleId, v.get('body.tags'), { transaction: t }) 51 | await ArticleAuthorDto.createArticleAuthor(articleId, v.get('body.authors'), { transaction: t }) 52 | }) 53 | } 54 | 55 | // 编辑某篇文章 56 | async updateArticle(v) { 57 | const id = v.get('body.id') 58 | const article = await Article.findByPk(id) 59 | if (!article) { 60 | throw new NotFound({ 61 | msg: '没有找到相关文章' 62 | }) 63 | } 64 | const tags = v.get('body.tags') 65 | const authors = v.get('body.authors') 66 | 67 | // step1: 先删除相关关联 68 | const isDeleteAuthor = await ArticleAuthorDto.deleteArticleAuthor(id, authors) 69 | const isDeleteTag = await ArticleTagDto.deleteArticleTag(id, tags) 70 | 71 | // step2: 再创建关联 72 | if (isDeleteAuthor) { 73 | await ArticleAuthorDto.createArticleAuthor(id, authors) 74 | } 75 | if (isDeleteTag) { 76 | await ArticleTagDto.createArticleTag(id, tags) 77 | } 78 | 79 | // step3: 更新文章 80 | article.title = v.get('body.title') 81 | article.content = v.get('body.content'), 82 | article.description = v.get('body.description'), 83 | article.cover = v.get('body.cover') 84 | article.created_date = v.get('body.createdDate') 85 | article.category_id = v.get('body.categoryId') 86 | article.public = v.get('body.public') 87 | article.status = v.get('body.status') 88 | article.star = v.get('body.star') 89 | article.save() 90 | } 91 | 92 | // 获取文章详情 93 | async getArticle(id) { 94 | const article = await Article.scope('frontShow').findOne({ 95 | where: { 96 | id 97 | }, 98 | include: [ 99 | { 100 | model: Tag, 101 | as: 'tags', 102 | attributes: ['id', 'name'] 103 | }, 104 | { 105 | model: Author, 106 | as: 'authors', 107 | attributes: ['id', 'name'] 108 | }, 109 | { 110 | model: Category, 111 | as: 'category', 112 | attributes: ['id', 'name'] 113 | } 114 | ], 115 | attributes: { 116 | exclude: ['public', 'status', 'description'] 117 | } 118 | }) 119 | if (!article) { 120 | throw new NotFound({ 121 | msg: '没有找到相关文章' 122 | }) 123 | } 124 | // 获取这篇文章相关分类下的文章列表(除了自己) 125 | const categoryArticles = await Article.scope('frontShow').findAll({ 126 | limit: 10, 127 | order: [ 128 | ['created_date', 'DESC'] 129 | ], 130 | where: { 131 | category_id: article.category_id, 132 | id: { 133 | [Op.not]: id 134 | } 135 | }, 136 | attributes: ['id', 'created_date', 'title'] 137 | }) 138 | article.exclude = ['category_id'] 139 | 140 | await article.increment('views', { by: 1 }) 141 | 142 | article.setDataValue('categoryArticles', categoryArticles) 143 | 144 | return article 145 | } 146 | 147 | async likeArticle(id) { 148 | const article = await Article.findByPk(id) 149 | if (!article) { 150 | throw new NotFound({ 151 | msg: '没有找到相关文章' 152 | }) 153 | } 154 | await article.increment('like', { by: 1 }) 155 | } 156 | 157 | // 把文章设为私密或公开 158 | async updateArticlePublic(id, publicId) { 159 | const article = await Article.findByPk(id) 160 | if (!article) { 161 | throw new NotFound({ 162 | msg: '没有找到相关文章' 163 | }) 164 | } 165 | article.public = publicId 166 | article.save() 167 | } 168 | 169 | // 把文章设为精选(2)或非精选(1) 170 | async updateArticleStar(id, starId) { 171 | if (starId === 2) { 172 | const articles = await Article.findAll({ 173 | attributes: ['id'] 174 | }) 175 | if (articles.length === 10) { 176 | throw new Forbidden({ 177 | msg: '最多只能设置10篇精选文章' 178 | }) 179 | } 180 | } 181 | const article = await Article.findByPk(id) 182 | if (!article) { 183 | throw new NotFound({ 184 | msg: '没有找到相关文章' 185 | }) 186 | } 187 | article.star = starId 188 | article.save() 189 | } 190 | 191 | // 获取所有精选文章 192 | async getStarArticles() { 193 | const articles = await Article.scope('frontShow').findAll({ 194 | where: { 195 | star: 2, // 精选 196 | }, 197 | include: [ 198 | { 199 | model: Author, 200 | as: 'authors', 201 | attributes: ['id', 'name'] 202 | }, 203 | { 204 | model: Category, 205 | as: 'category', 206 | attributes: ['id', 'name', 'cover'] 207 | } 208 | ], 209 | attributes: ['id', 'title', 'cover', 'created_date'], 210 | }) 211 | return articles 212 | } 213 | 214 | // 获取历史归档 215 | async getArchive() { 216 | const articles = await Article.scope('frontShow').findAll({ 217 | order: [ 218 | ['created_date', 'DESC'] 219 | ], 220 | include: [ 221 | { 222 | model: Author, 223 | as: 'authors', 224 | attributes: ['id', 'name', 'avatar'] 225 | } 226 | ], 227 | attributes: ['id', 'title', 'created_date'] 228 | }) 229 | return articles 230 | } 231 | 232 | /** 233 | * 获取所有文章 234 | * @param {Object} v 操作对象 235 | * @param {Boolean} isFont 是否展示端 236 | */ 237 | async getArticles(v, isFont = false) { 238 | const categoryId = v.get('query.categoryId') 239 | const authorId = v.get('query.authorId') 240 | const tagId = v.get('query.tagId') 241 | const publicId = v.get('query.publicId') 242 | const statusId = v.get('query.statusId') 243 | const starId = v.get('query.starId') 244 | const search = v.get('query.search') 245 | const start = v.get('query.page'); 246 | const pageCount = v.get('query.count'); 247 | 248 | // step1: 获取关联表的文章 id 交集 249 | let ids = [] 250 | if (authorId !== 0 || tagId !== 0) { 251 | // 求交集 252 | if (authorId !== 0 && tagId !== 0) { 253 | const arr1 = await ArticleAuthorDto.getArticleIds(authorId) 254 | const arr2 = await ArticleTagDto.getArticleIds(tagId) 255 | ids = intersection(arr1, arr2) 256 | } 257 | 258 | // 查询该标签下是否有文章 259 | if (tagId !== 0 && authorId === 0) { 260 | ids = await ArticleTagDto.getArticleIds(tagId) 261 | } 262 | 263 | // 查询该作者下是否有文章 264 | if (authorId !== 0 && tagId === 0) { 265 | ids = await ArticleAuthorDto.getArticleIds(authorId) 266 | } 267 | 268 | // 如果作者和标签都没有查询到文章 269 | if (!ids.length) { 270 | return [] 271 | } 272 | } 273 | 274 | // step2: 获取筛选条件 275 | let query = { 276 | category_id: categoryId === 0 ? undefined : categoryId, 277 | status: statusId === 0 ? undefined : statusId, 278 | public: publicId === 0 ? undefined : publicId, 279 | star: starId === 0 ? undefined : starId 280 | } 281 | 282 | // 忽略值为空的key 283 | let target = omitBy(query, isUndefined) 284 | let opIn = ids.length ? { 285 | id: { 286 | [Op.in]: ids 287 | } 288 | } : {} 289 | let like = search ? { 290 | [Op.or]: [ 291 | { 292 | title: { 293 | [Op.like]: `${search}%`, 294 | } 295 | }, 296 | { 297 | content: { 298 | [Op.like]: `${search}%`, 299 | } 300 | } 301 | ] 302 | } : {} 303 | 304 | // step3: 构建查询条件 305 | const where = { 306 | ...target, 307 | ...opIn, 308 | ...like 309 | } 310 | 311 | const { rows, count } = await Article.findAndCountAll({ 312 | where, 313 | distinct: true, 314 | offset: start * pageCount, 315 | limit: pageCount, 316 | order: [ 317 | ['created_date', 'DESC'] 318 | ], 319 | include: [ 320 | { 321 | model: Author, 322 | attributes: ['id', 'name', 'avatar'], 323 | as: 'authors' 324 | }, 325 | { 326 | model: Tag, 327 | as: 'tags' 328 | }, 329 | { 330 | model: Category, 331 | attributes: ['id', 'name'], 332 | as: 'category', 333 | }, 334 | { 335 | model: Comment, 336 | as: 'comments', 337 | attributes: ['id'] 338 | }, 339 | ], 340 | attributes: { 341 | exclude: isFont 342 | ? ['content', 'public', 'status'] 343 | : ['content'] 344 | }, 345 | }) 346 | 347 | const articles = JSON.parse(JSON.stringify(rows)) 348 | articles.forEach(v => { 349 | v.comment_count = v.comments.length 350 | unset(v, 'category_id') 351 | unset(v, 'comments') 352 | }) 353 | 354 | return { 355 | articles, 356 | total: count 357 | } 358 | } 359 | 360 | // 前端展示搜索文章 361 | async searchArticles(v) { 362 | const search = v.get('query.search') 363 | const start = v.get('query.page'); 364 | const pageCount = v.get('query.count'); 365 | 366 | const { rows, count } = await Article.scope('frontShow').findAndCountAll({ 367 | where: { 368 | [Op.or]: [ 369 | { 370 | title: { 371 | [Op.like]: `${search}%`, 372 | } 373 | }, 374 | { 375 | content: { 376 | [Op.like]: `${search}%`, 377 | } 378 | } 379 | ] 380 | }, 381 | order: [ 382 | ['created_date', 'DESC'] 383 | ], 384 | attributes: ['id', 'title', 'created_date', 'star'], 385 | offset: start * pageCount, 386 | limit: pageCount, 387 | }) 388 | 389 | return { 390 | articles: rows, 391 | total: count 392 | } 393 | } 394 | 395 | async deleteArticle(id) { 396 | const article = await Article.findOne({ 397 | where: { 398 | id 399 | } 400 | }) 401 | if (!article) { 402 | throw new NotFound({ 403 | msg: '没有找到相关文章' 404 | }) 405 | } 406 | 407 | // 删除相关关联 408 | await ArticleAuthorDto.deleteArticleAuthor(id) 409 | await ArticleTagDto.deleteArticleTag(id) 410 | article.destroy() 411 | } 412 | 413 | // 获取谋篇文章内容 414 | async getContent(id) { 415 | const content = await Article.findOne({ 416 | where: { 417 | id 418 | }, 419 | attributes: ['content'] 420 | }) 421 | return content 422 | } 423 | } 424 | 425 | module.exports = { 426 | ArticleDao 427 | } 428 | --------------------------------------------------------------------------------