├── .dockerignore ├── .editorconfig ├── .gitignore ├── .huskyrc.js ├── .nestcli.json ├── .nvmrc ├── .prettierrc ├── .travis.yml ├── .vscode ├── launch.json └── settings.json ├── Dockerfile ├── README.md ├── commitlint.config.js ├── docker-compose.dev.yml ├── jest-unit.config.js ├── nodemon-debug.json ├── nodemon.json ├── package-lock.json ├── package.json ├── script ├── performance.js └── type.js ├── src ├── app.module.ts ├── common │ ├── decorator │ │ └── Permissions.decorator.ts │ ├── dto │ │ └── state.dto.ts │ ├── enum │ │ └── state.ts │ ├── filters │ │ └── http-exception.filter.ts │ ├── guards │ │ └── AccessGuard.ts │ ├── interceptors │ │ ├── httpCache.interceptor.ts │ │ ├── logger.interceptor.ts │ │ └── timeout.interceptor.ts │ ├── middleware │ │ └── auth.middleware.ts │ └── utils │ │ ├── __test__ │ │ ├── __snapshots__ │ │ │ └── untils.spec.ts.snap │ │ └── untils.spec.ts │ │ └── index.ts ├── config │ ├── .env.development │ ├── .env.testError │ ├── __test__ │ │ └── configService.spec.ts │ └── index.ts ├── global.graphql ├── main.hmr.ts ├── main.ts ├── module │ ├── articles │ │ ├── __test__ │ │ │ ├── article.resolver.spec.ts │ │ │ └── article.services.spec.ts │ │ ├── articles.graphql │ │ ├── articles.module.ts │ │ ├── articles.resolver.ts │ │ ├── articles.service.ts │ │ ├── dto │ │ │ └── article.dto.ts │ │ ├── interface │ │ │ └── articles.interface.ts │ │ └── schema │ │ │ └── articles.schema.ts │ ├── auth │ │ ├── __test__ │ │ │ ├── auth.resolvers.spec.ts │ │ │ └── auth.service.spec.ts │ │ ├── auth.module.ts │ │ ├── auth.resolvers.ts │ │ ├── auth.service.ts │ │ ├── auths.graphql │ │ ├── decorators │ │ │ └── auth.ts │ │ ├── dto │ │ │ └── auth.dto.ts │ │ ├── interface │ │ │ └── auth.interface.ts │ │ └── schema │ │ │ └── auth.schema.ts │ ├── comments │ │ ├── __test__ │ │ │ ├── comments.resolver.spec.ts │ │ │ └── comments.service.spec.ts │ │ ├── comments.graphql │ │ ├── comments.module.ts │ │ ├── comments.resolver.ts │ │ ├── comments.service.ts │ │ ├── dto │ │ │ └── comments.dto.ts │ │ ├── interface │ │ │ └── comments.interface.ts │ │ └── schema │ │ │ └── comments.schema.ts │ ├── common │ │ ├── db │ │ │ └── index.ts │ │ ├── email │ │ │ ├── email.constants.ts │ │ │ ├── email.module.ts │ │ │ └── email.service.ts │ │ ├── logger │ │ │ ├── logger.module.ts │ │ │ └── logger.ts │ │ └── redis │ │ │ ├── redis.constant.ts │ │ │ ├── redis.module.ts │ │ │ └── redis.service.ts │ ├── heros │ │ ├── __test__ │ │ │ ├── heros.resolver.spec.ts │ │ │ └── heros.service.spec.ts │ │ ├── dto │ │ │ └── heros.dto.ts │ │ ├── heros.graphql │ │ ├── heros.module.ts │ │ ├── heros.resolvers.ts │ │ ├── heros.service.ts │ │ ├── interface │ │ │ └── heros.interface.ts │ │ └── schema │ │ │ └── heros.schema.ts │ ├── like │ │ ├── dto │ │ │ └── like.dto.ts │ │ ├── like.graphql │ │ ├── like.module.ts │ │ ├── like.resolver.ts │ │ └── like.service.ts │ ├── links │ │ ├── __test__ │ │ │ ├── link.resolver.spec.ts │ │ │ └── link.service.spec.ts │ │ ├── dto │ │ │ └── links.dto.ts │ │ ├── interface │ │ │ └── links.interface.ts │ │ ├── links.graphql │ │ ├── links.module.ts │ │ ├── links.resolvers.ts │ │ ├── links.service.ts │ │ └── schema │ │ │ └── links.schema.ts │ ├── options │ │ ├── __test__ │ │ │ ├── options.resolver.spec.ts │ │ │ └── options.service.spec.ts │ │ ├── interface │ │ │ └── options.interface.ts │ │ ├── options.graphql │ │ ├── options.module.ts │ │ ├── options.resolvers.ts │ │ ├── options.service.ts │ │ └── schema │ │ │ └── options.shema.ts │ ├── qiniu │ │ ├── __test__ │ │ │ ├── qiniu.resolvers.spec.ts │ │ │ └── qiniu.service.spec.ts │ │ ├── qiniu.graphql │ │ ├── qiniu.module.ts │ │ ├── qiniu.resolvers.ts │ │ └── qiniu.service.ts │ └── tags │ │ ├── __test__ │ │ ├── tags.resolver.spec.ts │ │ └── tags.service.spec.ts │ │ ├── dto │ │ └── tag.dto.ts │ │ ├── interface │ │ └── tags.interface.ts │ │ ├── schema │ │ └── tags.schema.ts │ │ ├── tags.graphql │ │ ├── tags.module.ts │ │ ├── tags.resolver.ts │ │ └── tags.service.ts └── typings │ ├── express-rate-limit.d.ts │ └── global.d.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig-paths-bootstrap.js ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.spec.json ├── tslint.json └── webpack.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .git/ 3 | coverage/ 4 | logs/ -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | 3 | .DS_Store 4 | 5 | *.log 6 | /logs 7 | 8 | /dist 9 | 10 | yarn-error.log* 11 | 12 | coverage/ 13 | -------------------------------------------------------------------------------- /.huskyrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | 'commit-msg': 'commitlint -E HUSKY_GIT_PARAMS', 4 | 'pre-commit': 'lint-staged' 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /.nestcli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics" 4 | } 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v10.13.0 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": true, 4 | "singleQuote": true, 5 | "trailingComma": "none", 6 | "bracketSpacing": true, 7 | "jsxBracketSameLine": false, 8 | "arrowParens": "avoid", 9 | "requirePragma": false, 10 | "proseWrap": "preserve" 11 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | cache: 4 | directories: 5 | - node_modules 6 | 7 | node_js: 8 | - "8" 9 | 10 | branches: 11 | only: 12 | - nest 13 | 14 | install: 15 | - npm install 16 | 17 | before_script: 18 | - mongo my_blog --eval 'db.createUser({user:"blog-server",pwd:"blog-server",roles:["readWrite"]});' 19 | 20 | script: 21 | - npm run test:unit 22 | - npm run codecov 23 | 24 | services: 25 | - mongodb -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Jest All", 8 | "program": "${workspaceFolder}/node_modules/.bin/jest", 9 | "args": ["--runInBand"], 10 | "console": "integratedTerminal", 11 | "internalConsoleOptions": "neverOpen", 12 | "windows": { 13 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 14 | } 15 | }, 16 | { 17 | "type": "node", 18 | "request": "launch", 19 | "name": "Jest Current File", 20 | "program": "${workspaceFolder}/node_modules/.bin/jest", 21 | "args": ["--config", "./jest-unit.config.js", "${relativeFile}", "--coverage"], 22 | "env": { 23 | "NODE_ENV": "development" 24 | }, 25 | "console": "integratedTerminal", 26 | "internalConsoleOptions": "neverOpen", 27 | "windows": { 28 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 29 | } 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "debug.node.autoAttach": "on" 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | FROM node:8.9.4-alpine 3 | 4 | RUN mkdir -p /usr/src/blog_service 5 | 6 | ADD . /usr/src/blog_service 7 | 8 | RUN npm install -g yarn 9 | 10 | RUN yarn config set registry 'https://registry.npm.taobao.org' 11 | 12 | RUN yarn install 13 | 14 | WORKDIR /usr/src/blog_service 15 | 16 | EXPOSE 8000 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # blog service 2 | 3 | [![Build Status](https://travis-ci.org/jkchao/blog-service.svg?branch=nest)](https://travis-ci.org/jkchao/blog-service) 4 | [![coverage](https://codecov.io/gh/jkchao/blog-service/branch/nest/graph/badge.svg)](https://codecov.io/gh/jkchao/blog-service) 5 | [![GitHub forks](https://img.shields.io/github/forks/jkchao/blog-service.svg?style=flat-square)](https://github.com/jkchao/blog-service/network) [![GitHub stars](https://img.shields.io/github/stars/jkchao/blog-service.svg?style=flat-square)](https://github.com/jkchao/blog-service/stargazers) [![GitHub issues](https://img.shields.io/github/issues/jkchao/blog-service.svg?style=flat-square)](https://github.com/jkchao/blog-service/issues) 6 | [![GitHub last commit](https://img.shields.io/github/last-commit/jkchao/blog-service.svg?style=flat-square)](https://github.com/jkchao/blog-service/commits/master) 7 | 8 | 此分支是使用 nest 重构分支。 9 | 10 | NestJS + MongoDB + Redis + Docker + GraphQL 11 | 12 | ## start 13 | 14 | ### install 15 | 16 | ```bash 17 | # Setup mongodb and redis 18 | 19 | # start 20 | docker-compose -f docker-compose.dev.yml up -d 21 | 22 | # stop 23 | # docker-compose -f docker-compose.dev.yml down 24 | 25 | # remove volume/cache 26 | # docker-compose -f docker-compose.dev.yml down -v 27 | 28 | # install 29 | $ npm install 30 | 31 | $ npm run dev 32 | ``` 33 | 34 | ### test 35 | 36 | ```bast 37 | $ npm run test:unit 38 | $ npm run test:e2e 39 | ``` 40 | 41 | ### debug 42 | 43 | ```bash 44 | $ npm run debug 45 | ``` 46 | 47 | ### deploy 48 | 49 | ... 50 | 51 | ## 性能调优 52 | 53 | ```bash 54 | # 安装 clinic 55 | $ npm i -g clinic 56 | 57 | # 安装压力测试工具 58 | $ npm i -g autocannon 59 | 60 | # 在检查之前,先 build 出来 61 | $ npm run build:stage 62 | 63 | # run 64 | $ npm run performance 65 | ``` 66 | 67 | 你可以选择 doctor/IO/flame 选项,然后输入需要检查的接口; 68 | 69 | 稍等片刻,会生成新的报告。 70 | 71 | 参考: 72 | 73 | - [node-clinic](https://github.com/nearform/node-clinic); 74 | - [autocannon](https://github.com/mcollina/autocannon); 75 | - 数据分析文档:[clinic](https://clinicjs.org/documentation); 76 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-angular'] 3 | }; 4 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | # web: 4 | # build: . 5 | # command: npm run dev 6 | # ports: 7 | # - 8000:8000 8 | # volumes: 9 | # - .:/usr/src/blog_service 10 | # networks: 11 | # - docker-blog-server 12 | 13 | # redis: 14 | # image: redis:4.0.11-alpine 15 | # command: redis-server --appendonly yes --requirepass node-server 16 | # volumes: 17 | # - egg-redis:/data 18 | # networks: 19 | # - docker-node-server 20 | # ports: 21 | # - 6378:6379 22 | 23 | mongodb: 24 | image: mongo:3.6.7 25 | restart: always 26 | environment: 27 | MONGO_INITDB_DATABASE: my_blog 28 | volumes: 29 | - /data/db:/data/db 30 | networks: 31 | - docker-blog-server 32 | ports: 33 | - 27017:27017 34 | 35 | networks: 36 | docker-blog-server: 37 | driver: bridge 38 | -------------------------------------------------------------------------------- /jest-unit.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['ts', 'js', 'json'], 3 | rootDir: 'src', 4 | transform: { 5 | '^.+\\.ts$': 'ts-jest' 6 | }, 7 | testMatch: ['**/__test__/**/*.spec.(ts|js)'], 8 | testURL: 'http://localhost/', 9 | coverageDirectory: '../coverage', 10 | moduleNameMapper: { 11 | '^@/(.*)$': '/$1' 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /nodemon-debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "node --inspect-brk -r ts-node/register -r tsconfig-paths/register src/main.ts" 6 | } 7 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src/**/*.*"], 3 | "ignore": ["src/**/*.spec.ts"], 4 | "exec": "ts-node -r tsconfig-paths/register src/main.ts" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog-service", 3 | "version": "1.0.0", 4 | "description": "Blog", 5 | "author": "jkchao", 6 | "license": "MIT", 7 | "scripts": { 8 | "format": "prettier --write \"**/*.ts\"", 9 | "start": "cross-env NODE_ENV=development nodemon --config nodemon.json", 10 | "debug": "cross-env NODE_ENV=development nodemon --config nodemon-debug.json", 11 | "prestart:prod": "rm -rf dist && tsc -p './tsconfig.build.json'", 12 | "start:prod": "node -r ./tsconfig-paths-bootstrap.js dist/main.js", 13 | "start:hmr": "node dist/server", 14 | "test": "jest", 15 | "test:watch": "jest --watch", 16 | "test:unit": "cross-env NODE_ENV=development jest --coverage --config ./jest-unit.config.js", 17 | "webpack": "webpack --config webpack.config.js", 18 | "lint": "tslint -c tslint.json -p tsconfig.json", 19 | "precommit": "lint-staged", 20 | "codecov": "codecov" 21 | }, 22 | "lint-staged": { 23 | "*.{js,json,ts,css,md}": [ 24 | "prettier --write", 25 | "git add" 26 | ] 27 | }, 28 | "dependencies": { 29 | "@nestjs/common": "^5.0.0", 30 | "@nestjs/core": "^5.0.0", 31 | "@nestjs/graphql": "^5.4.0", 32 | "@nestjs/mongoose": "^5.2.2", 33 | "apollo-server-express": "^2.2.0", 34 | "axios": "^0.18.0", 35 | "body-parser": "^1.18.3", 36 | "cache-manager": "^2.9.0", 37 | "cache-manager-redis-store": "^1.5.0", 38 | "chalk": "^2.4.1", 39 | "class-transformer": "^0.1.9", 40 | "class-validator": "^0.9.1", 41 | "cli-spinner": "^0.2.8", 42 | "commander": "^2.19.0", 43 | "compression": "^1.7.3", 44 | "csurf": "^1.9.0", 45 | "dotenv": "^6.0.0", 46 | "express": "^4.16.3", 47 | "express-rate-limit": "^3.1.0", 48 | "fastify-formbody": "^2.0.0", 49 | "geoip-lite": "^1.3.5", 50 | "graphql": "^14.0.2", 51 | "graphql-tools": "^4.0.3", 52 | "helmet": "^3.13.0", 53 | "inquirer": "^6.2.1", 54 | "joi": "^13.6.0", 55 | "jsonwebtoken": "^8.3.0", 56 | "log4js": "^3.0.5", 57 | "mongoose": "^5.3.2", 58 | "mongoose-auto-increment": "^5.0.1", 59 | "mongoose-paginate": "^5.0.3", 60 | "nodemailer": "^4.7.0", 61 | "nodemailer-smtp-transport": "^2.7.4", 62 | "passport-jwt": "^4.0.0", 63 | "qn": "^1.3.0", 64 | "redis": "^2.8.0", 65 | "reflect-metadata": "^0.1.12", 66 | "rxjs": "^6.3.2", 67 | "rxjs-compat": "^6.3.2" 68 | }, 69 | "devDependencies": { 70 | "@commitlint/cli": "^7.5.2", 71 | "@commitlint/config-angular": "^7.5.0", 72 | "@nestjs/testing": "^5.3.1", 73 | "@types/compression": "0.0.36", 74 | "@types/dotenv": "^4.0.3", 75 | "@types/express": "^4.0.39", 76 | "@types/geoip-lite": "^1.1.29", 77 | "@types/graphql": "^14.0.3", 78 | "@types/helmet": "0.0.40", 79 | "@types/jest": "^21.1.8", 80 | "@types/joi": "^13.4.4", 81 | "@types/jsonwebtoken": "^7.2.8", 82 | "@types/mongoose": "^5.2.18", 83 | "@types/mongoose-auto-increment": "^5.0.30", 84 | "@types/mongoose-paginate": "^5.0.6", 85 | "@types/node": "^9.3.0", 86 | "@types/nodemailer": "^4.6.5", 87 | "@types/nodemailer-smtp-transport": "^2.7.4", 88 | "@types/redis": "^2.8.6", 89 | "@types/supertest": "^2.0.4", 90 | "codecov": "^3.1.0", 91 | "cross-env": "^5.2.0", 92 | "husky": "^1.0.0-rc.13", 93 | "istanbul": "^0.4.5", 94 | "jest": "^23.6.0", 95 | "lint-staged": "^7.2.2", 96 | "mockgoose-fix": "^7.3.6", 97 | "nodemon": "^1.14.1", 98 | "prettier": "^1.14.2", 99 | "supertest": "^3.3.0", 100 | "ts-jest": "^21.2.4", 101 | "ts-loader": "^4.1.0", 102 | "ts-node": "^4.1.0", 103 | "tsconfig-paths": "^3.6.0", 104 | "tslint": "5.3.2", 105 | "typescript": "^2.9.2", 106 | "webpack": "^4.2.0", 107 | "webpack-cli": "^2.0.13", 108 | "webpack-node-externals": "^1.6.0" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /script/performance.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const program = require('commander'); 4 | const appInfo = require('../package.json'); 5 | const chalk = require('chalk'); 6 | const inquirer = require('inquirer'); 7 | const util = require('util'); 8 | const exec = util.promisify(require('child_process').exec); 9 | 10 | program.version(appInfo.version).usage(`进行性能检查的工具,利用 ${chalk.cyan.bold.underline('clinic')}`); 11 | 12 | program 13 | .command('type') 14 | .alias('t') 15 | .description('select the type you want to check') 16 | .action(checkType); 17 | 18 | program.parse(process.argv); 19 | 20 | async function checkType() { 21 | const types = await require('./type'); 22 | const { checkType } = await inquirer.prompt(types); 23 | 24 | const { endPort = 'localhost:8000/api' } = await inquirer.prompt({ 25 | type: 'input', 26 | name: 'endPort', 27 | message: 'Which endPort you want to check (default localhost:8000/api)' 28 | }); 29 | 30 | const command = `clinic ${checkType} --on-port 'autocannon ${endPort}'`; 31 | const shell = `${command} -- node -r ./tsconfig-paths-bootstrap.js ./dist/main.js`; 32 | 33 | console.log(`The check EndPort is ${chalk.red(endPort)}`); 34 | console.log(`The check type is ${chalk.red(checkType)}`); 35 | 36 | console.log('Wait a moment, and you can see the report'); 37 | 38 | // loading 39 | const { Spinner } = require('cli-spinner'); 40 | const spinner = new Spinner('processing.. %s'); 41 | spinner.setSpinnerString('|/-\\'); 42 | spinner.start(); 43 | 44 | const { error, stdout } = await exec(shell); 45 | 46 | if (error) { 47 | console.log(`${chalk.red(error)}`); 48 | } 49 | 50 | console.log(stdout); 51 | 52 | spinner.stop(); 53 | 54 | console.log(chalk.green('done ...')); 55 | } 56 | -------------------------------------------------------------------------------- /script/type.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | type: 'list', 4 | name: 'checkType', 5 | message: 'Select the type to check', 6 | default: 'Event Loop', 7 | choices: [ 8 | { 9 | name: 'doctor -- Event Loop、GC、I/O、Sync I/O', 10 | value: 'doctor' 11 | }, 12 | { 13 | name: 'IO', 14 | value: 'bubbleprof' 15 | }, 16 | { 17 | name: 'flame -- 火焰图', 18 | value: 'flame' 19 | } 20 | ] 21 | } 22 | ]; 23 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, CacheModule, NestModule, MiddlewareConsumer } from '@nestjs/common'; 2 | 3 | import { AuthModule } from './module/auth/auth.module'; 4 | import { OptionsModule } from './module/options/options.module'; 5 | import { MongooseModule } from '@nestjs/mongoose'; 6 | import { config } from './config'; 7 | import { GraphQLModule } from '@nestjs/graphql'; 8 | import { QiniuModule } from './module/qiniu/qiniu.module'; 9 | import { BlogLoggerModule } from './module/common/logger/logger.module'; 10 | import { BlogLogger } from './module/common/logger/logger'; 11 | import { GraphQLError } from 'graphql'; 12 | import { LinksModule } from './module/links/links.module'; 13 | import { Request, Response } from 'express'; 14 | import { HerosModule } from './module/heros/heros.module'; 15 | import { APP_GUARD } from '@nestjs/core'; 16 | import { AccessGuard } from './common/guards/AccessGuard'; 17 | import { CommentsModule } from './module/comments/comments.module'; 18 | import { TagsModule } from './module/tags/tags.module'; 19 | import { ArticlesModule } from './module/articles/articles.module'; 20 | import { LikeModule } from './module/like/like.module'; 21 | 22 | @Module({ 23 | imports: [ 24 | CacheModule.register({ 25 | max: 5, 26 | ttl: 5 27 | }), 28 | GraphQLModule.forRootAsync({ 29 | imports: [BlogLoggerModule], 30 | useFactory: async (logger: BlogLogger) => ({ 31 | typePaths: ['./**/*.graphql'], 32 | path: '/api/v2', 33 | context: ({ req, res }: { req: Request; res: Response }) => ({ 34 | request: req 35 | }), 36 | formatError: (error: GraphQLError) => { 37 | logger.error( 38 | JSON.stringify({ 39 | message: error.message, 40 | location: error.locations, 41 | stack: error.stack ? error.stack.split('\n') : [], 42 | path: error.path 43 | }) 44 | ); 45 | return error; 46 | } 47 | }), 48 | inject: [BlogLogger] 49 | }), 50 | MongooseModule.forRoot(config.MONGO_URL), 51 | AuthModule, 52 | OptionsModule, 53 | QiniuModule, 54 | LinksModule, 55 | HerosModule, 56 | CommentsModule, 57 | TagsModule, 58 | ArticlesModule, 59 | LikeModule 60 | ], 61 | providers: [ 62 | // { 63 | // provide: APP_INTERCEPTOR, 64 | // useClass: HttpCacheInterceptor 65 | // } 66 | { 67 | provide: APP_GUARD, 68 | useClass: AccessGuard 69 | } 70 | ] 71 | }) 72 | export class AppModule implements NestModule { 73 | public configure(consumer: MiddlewareConsumer) { 74 | // consumer 75 | // .apply(AuthMiddleware) 76 | // .forRoutes( 77 | // { path: '*', method: RequestMethod.POST } 78 | // ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/common/decorator/Permissions.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ReflectMetadata } from '@nestjs/common'; 2 | 3 | export const Permissions = (...permissions: string[]) => ReflectMetadata('permissions', permissions); 4 | -------------------------------------------------------------------------------- /src/common/dto/state.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { StateEnum } from '../enum/state'; 3 | 4 | export class StateDto { 5 | @Transform(v => StateEnum[v]) 6 | public state: number; 7 | } 8 | -------------------------------------------------------------------------------- /src/common/enum/state.ts: -------------------------------------------------------------------------------- 1 | export enum StateEnum { 2 | TODO, 3 | SUCCESS, 4 | FAIL 5 | } 6 | -------------------------------------------------------------------------------- /src/common/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 捕获 HttpException 异常 3 | * 4 | */ 5 | 6 | import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from '@nestjs/common'; 7 | 8 | const logger = new Logger(); 9 | 10 | @Catch() 11 | export class HttpExceptionFilter implements ExceptionFilter { 12 | public catch(exception: HttpException, host: ArgumentsHost) { 13 | console.log(host); 14 | const ctx = host.switchToHttp(); 15 | const response = ctx.getResponse(); 16 | const request = ctx.getRequest(); 17 | const code = exception.getStatus() || '500'; 18 | let message = exception.message || '阿西吧 Error'; 19 | if (message.message) { 20 | message = message.message; 21 | } 22 | 23 | logger.error( 24 | JSON.stringify({ 25 | message, 26 | time: new Date().toLocaleString(), 27 | path: request.url 28 | }) 29 | ); 30 | 31 | response.status(code).json({ 32 | message, 33 | time: new Date().toLocaleString(), 34 | path: request.url 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/common/guards/AccessGuard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | import { GqlExecutionContext } from '@nestjs/graphql'; 4 | import { Request, Response } from 'express'; 5 | import jwt from 'jsonwebtoken'; 6 | 7 | import { config } from '@/config'; 8 | import { Reflector } from '@nestjs/core'; 9 | 10 | @Injectable() 11 | export class AccessGuard implements CanActivate { 12 | constructor(private readonly reflector: Reflector) {} 13 | 14 | private getToken(req: Request): false | string { 15 | if (req.headers && req.headers.authorization) { 16 | const parts = req.headers.authorization.split(' '); 17 | if (Object.is(parts.length, 2) && Object.is(parts[0], 'Bearer')) { 18 | return parts[1]; 19 | } 20 | } 21 | return false; 22 | } 23 | 24 | public canActivate(context: ExecutionContext): boolean | Promise | Observable { 25 | if (config.ENV === 'development') return true; 26 | 27 | const ctx = GqlExecutionContext.create(context); 28 | 29 | const permissions = this.reflector.get('permissions', context.getHandler()); 30 | 31 | if (permissions === undefined) { 32 | return true; 33 | } 34 | 35 | const request: Request = ctx.getContext(); 36 | 37 | // if (request.url.includes('auth')) return true; 38 | 39 | const token = this.getToken(request); 40 | if (token) { 41 | try { 42 | const decodedToken = jwt.verify(token, config.JWTKEY) as { 43 | ext: number; 44 | }; 45 | if (decodedToken.ext > Math.floor(Date.now() / 1000)) { 46 | return true; 47 | } 48 | } catch (err) { 49 | throw new UnauthorizedException('用户信息已过期'); 50 | } 51 | } 52 | 53 | return false; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/common/interceptors/httpCache.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CacheInterceptor, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { BlogLogger } from '@/module/common/logger/logger'; 3 | 4 | const logger = new BlogLogger(); 5 | 6 | @Injectable() 7 | export class HttpCacheInterceptor extends CacheInterceptor { 8 | // constructor( 9 | // private readonly logger: BlogLogger 10 | // ) { 11 | // super(); 12 | // } 13 | public trackBy(context: ExecutionContext): string | undefined { 14 | const request = context.switchToHttp().getRequest(); 15 | if (this.httpServer.getRequestMethod && this.httpServer.getRequestUrl) { 16 | const isGetRequest = this.httpServer.getRequestMethod(request); 17 | const excludePaths: string[] = []; 18 | if (!isGetRequest || (isGetRequest && excludePaths.includes(this.httpServer.getRequestUrl(request)))) { 19 | return undefined; 20 | } 21 | 22 | logger.log('Url Cached: ' + this.httpServer.getRequestUrl(request)); 23 | return this.httpServer.getRequestUrl(request); 24 | } 25 | return undefined; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/common/interceptors/logger.interceptor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 响应时间拦截器 3 | */ 4 | 5 | import { Injectable, NestInterceptor, ExecutionContext } from '@nestjs/common'; 6 | import { Observable } from 'rxjs'; 7 | import { tap } from 'rxjs/operators'; 8 | import { GqlExecutionContext } from '@nestjs/graphql'; 9 | import { BlogLogger } from '@/module/common/logger/logger'; 10 | 11 | // const logger = new Logger(); 12 | 13 | @Injectable() 14 | export class LoggingInterceptor implements NestInterceptor { 15 | constructor(private readonly logger: BlogLogger) {} 16 | public intercept(context: ExecutionContext, call$: Observable): Observable { 17 | const ctx = GqlExecutionContext.create(context); 18 | const { fieldName } = ctx.getInfo(); 19 | 20 | const now = Date.now(); 21 | return call$.pipe( 22 | tap( 23 | () => { 24 | this.logger.log(`${fieldName} SUCCESS ---- ${Date.now() - now}ms`); 25 | }, 26 | () => { 27 | this.logger.error(`${fieldName} ERROR ---- ${Date.now() - now}ms`); 28 | } 29 | ) 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/common/interceptors/timeout.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestInterceptor, ExecutionContext, HttpException, HttpStatus } from '@nestjs/common'; 2 | import { Observable, throwError, never } from 'rxjs'; 3 | import { timeout, catchError } from 'rxjs/operators'; 4 | 5 | @Injectable() 6 | export class TimeoutInterceptor implements NestInterceptor { 7 | public intercept(context: ExecutionContext, call$: Observable): Observable { 8 | return call$.pipe( 9 | timeout(5000), 10 | catchError(error => { 11 | if (error.name === 'TimeoutError') { 12 | return [throwError(new HttpException('Timeout', HttpStatus.GATEWAY_TIMEOUT))]; 13 | } 14 | throw error; 15 | }) 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/common/middleware/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { Request, Response, NextFunction } from 'express'; 5 | import jwt from 'jsonwebtoken'; 6 | 7 | import { config } from '@/config'; 8 | 9 | @Injectable() 10 | export class AuthMiddleware { 11 | private getToken(req: Request): false | string { 12 | if (req.headers && req.headers.authorization) { 13 | const parts = req.headers.authorization.split(' '); 14 | if (Object.is(parts.length, 2) && Object.is(parts[0], 'Bearer')) { 15 | return parts[1]; 16 | } 17 | } 18 | return false; 19 | } 20 | 21 | public resolve(...args: any[]) { 22 | return async (req: Request, res: Response, next: NextFunction) => { 23 | if (config.ENV === 'dev') return next(); 24 | 25 | const token = this.getToken(req); 26 | if (!token) { 27 | throw new UnauthorizedException('Authorization cannot be empty'); 28 | } 29 | try { 30 | const decodedToken = jwt.verify(token, config.JWTKEY) as { 31 | ext: number; 32 | }; 33 | if (decodedToken.ext > Math.floor(Date.now() / 1000)) { 34 | return true; 35 | } 36 | } catch (err) { 37 | throw new UnauthorizedException('用户信息已过期'); 38 | } 39 | next(); 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/common/utils/__test__/__snapshots__/untils.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`utils md5Decode 1`] = `"e10adc3949ba59abbe56e057f20f883e"`; 4 | -------------------------------------------------------------------------------- /src/common/utils/__test__/untils.spec.ts: -------------------------------------------------------------------------------- 1 | import { createToken, md5Decode } from '..'; 2 | import crypto from 'crypto'; 3 | import jwt from 'jsonwebtoken'; 4 | 5 | jest.mock('jsonwebtoken', () => { 6 | const sign = () => ''; 7 | return { sign }; 8 | }); 9 | 10 | describe('utils', () => { 11 | it('createToken', () => { 12 | expect(createToken({ username: '' })).toBe(''); 13 | }); 14 | 15 | it('md5Decode', () => { 16 | expect(md5Decode('123456')).toMatchSnapshot(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/common/utils/index.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import jwt from 'jsonwebtoken'; 3 | import { config } from '@/config'; 4 | 5 | /** 6 | * 解密 7 | * @param pwd 解密的密码 8 | */ 9 | export function md5Decode(pwd: string | Buffer | DataView) { 10 | return crypto 11 | .createHash('md5') 12 | .update(pwd) 13 | .digest('hex'); 14 | } 15 | 16 | export function createToken(params: { username: string }) { 17 | const toekn = jwt.sign( 18 | { 19 | ...params, 20 | ext: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7 21 | }, 22 | config.JWTKEY 23 | ); 24 | return toekn; 25 | } 26 | -------------------------------------------------------------------------------- /src/config/.env.development: -------------------------------------------------------------------------------- 1 | # app 2 | APP_NAME = BLOG_SERVICE 3 | APP_PORT = 8000 4 | APP_PATH = /api 5 | APP_LIMIT = 16 6 | 7 | # log 8 | LOG_PATH = ./logs 9 | LOG_LEVEL = debug 10 | 11 | # email, you EMAIL_PASSWORD 12 | EMAIL_HOST = smtp.qq.com 13 | EMAIL_ACCOUNT = 419027396@qq.com 14 | EMAIL_PASSWORD = EMAIL_PASSWORD 15 | 16 | # qiniu, you QINNIU_ACCESSKEY QINNIU_TOKEN 17 | QINNIU_ACCESSKEY = QINNIU_ACCESSKEY 18 | QINNIU_TOKEN = QINNIU_TOKEN 19 | QINNIU_BUCKET = blog 20 | QINNIU_ORIGIN = http://blog.u.qiniudn.com 21 | QINIU_UPLOADURL = http://up.qiniu.com/ 22 | 23 | # baidu 24 | BAIDU_SITE = https://jkchao.cn 25 | BAIDU_TOKEN = phhdOEtkGgVKToH5 26 | 27 | # mongod 28 | MONGO_URL = mongodb://blog-server:blog-server@127.0.0.1:27017/my_blog 29 | 30 | # auth 31 | JWTKEY = BLOGJWT 32 | DEFAULT_USERNAME = jkchao 33 | DEFAULT_PASSWORD = 123456 34 | 35 | # env 36 | NODE_ENV = development 37 | 38 | # redis 39 | 40 | 41 | # site 42 | FRONT_SITE=https://jkchao.cn 43 | -------------------------------------------------------------------------------- /src/config/.env.testError: -------------------------------------------------------------------------------- 1 | APP_NAME = CMS -------------------------------------------------------------------------------- /src/config/__test__/configService.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '../index'; 2 | import path from 'path'; 3 | 4 | describe('HttpService', () => { 5 | let configService: ConfigService; 6 | 7 | beforeEach(() => { 8 | // configService = new ConfigService('../test.env'); 9 | }); 10 | 11 | it('all config', () => { 12 | configService = new ConfigService(path.resolve(process.cwd(), `src/config/.env.development`)); 13 | 14 | expect(configService.APP_PORT).toBe(8000); 15 | expect(configService.APP_LIMIT).toBe(16); 16 | expect(configService.APP_PATH).toBe('/api'); 17 | 18 | expect(configService.ENV).toBe('development'); 19 | expect(configService.EMAIL_HOST).toBe('smtp.qq.com'); 20 | expect(configService.QINIU_UPLOADURL).toBe('http://up.qiniu.com/'); 21 | expect(configService.AXIOS_CONFIG).toMatchObject({ 22 | baseURL: '/api', 23 | timeout: 5000 24 | }); 25 | expect(configService.APP_INFO).toMatchObject({ 26 | name: 'by_blog', 27 | version: '1.0.0', 28 | author: 'jkchao', 29 | site: 'https://jkchao.cn', 30 | powered: ['Vue2', 'Nuxt.js', 'Node.js', 'MongoDB', 'koa', 'Nginx'] 31 | }); 32 | }); 33 | 34 | it('thorw error', () => { 35 | // configService = new ConfigService(path.resolve(process.cwd(), `src/config/testError.env`)); 36 | function test() { 37 | new ConfigService(path.resolve(process.cwd(), `src/config/.env.testError`)); 38 | } 39 | 40 | expect(test).toThrow(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | import fs from 'fs'; 3 | import dotenv from 'dotenv'; 4 | import { ConflictException, Injectable } from '@nestjs/common'; 5 | import path from 'path'; 6 | import { AxiosRequestConfig } from 'axios'; 7 | import { Configuration } from 'log4js'; 8 | 9 | const LOGO = ` 10 | ......................................&&......................... 11 | ....................................&&&.......................... 12 | .................................&&&&............................ 13 | ...............................&&&&.............................. 14 | .............................&&&&&&.............................. 15 | ...........................&&&&&&....&&&..&&&&&&&&&&&&&&&........ 16 | ..................&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&.............. 17 | ................&...&&&&&&&&&&&&&&&&&&&&&&&&&&&&................. 18 | .......................&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&......... 19 | ...................&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&............... 20 | ..................&&& &&&&&&&&&&&&&&&&&&&&&&&&&&&&&............ 21 | ...............&&&&&@ &&&&&&&&&&..&&&&&&&&&&&&&&&&&&&........... 22 | ..............&&&&&&&&&&&&&&&.&&....&&&&&&&&&&&&&..&&&&&......... 23 | ..........&&&&&&&&&&&&&&&&&&...&.....&&&&&&&&&&&&&...&&&&........ 24 | ........&&&&&&&&&&&&&&&&&&&.........&&&&&&&&&&&&&&&....&&&....... 25 | .......&&&&&&&&.....................&&&&&&&&&&&&&&&&.....&&...... 26 | ........&&&&&.....................&&&&&&&&&&&&&&&&&&............. 27 | ..........&...................&&&&&&&&&&&&&&&&&&&&&&&............ 28 | ................&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&............ 29 | ..................&&&&&&&&&&&&&&&&&&&&&&&&&&&&..&&&&&............ 30 | ..............&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&....&&&&&............ 31 | ...........&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&......&&&&............ 32 | .........&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&.........&&&&............ 33 | .......&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&...........&&&&............ 34 | ......&&&&&&&&&&&&&&&&&&&...&&&&&&...............&&&............. 35 | .....&&&&&&&&&&&&&&&&............................&&.............. 36 | ....&&&&&&&&&&&&&&&.................&&........................... 37 | ...&&&&&&&&&&&&&&&.....................&&&&...................... 38 | ...&&&&&&&&&&.&&&........................&&&&&................... 39 | ..&&&&&&&&&&&..&&..........................&&&&&&&............... 40 | ..&&&&&&&&&&&&...&............&&&.....&&&&...&&&&&&&............. 41 | ..&&&&&&&&&&&&&.................&&&.....&&&&&&&&&&&&&&........... 42 | ..&&&&&&&&&&&&&&&&..............&&&&&&&&&&&&&&&&&&&&&&&&......... 43 | ..&&.&&&&&&&&&&&&&&&&&.........&&&&&&&&&&&&&&&&&&&&&&&&&&&....... 44 | ...&&..&&&&&&&&&&&&.........&&&&&&&&&&&&&&&&...&&&&&&&&&&&&...... 45 | ....&..&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&...........&&&&&&&&..... 46 | .......&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&..............&&&&&&&.... 47 | .......&&&&&.&&&&&&&&&&&&&&&&&&..&&&&&&&&...&..........&&&&&&.... 48 | ........&&&.....&&&&&&&&&&&&&.....&&&&&&&&&&...........&..&&&&... 49 | .......&&&........&&&.&&&&&&&&&.....&&&&&.................&&&&... 50 | .......&&&...............&&&&&&&.......&&&&&&&&............&&&... 51 | ........&&...................&&&&&&.........................&&&.. 52 | .........&.....................&&&&........................&&.... 53 | ...............................&&&.......................&&...... 54 | ................................&&......................&&....... 55 | .................................&&.............................. 56 | ..................................&.............................. 57 | `; 58 | 59 | export interface EnvConfig { 60 | [prop: string]: string; 61 | } 62 | 63 | @Injectable() 64 | export class ConfigService { 65 | private readonly envConfig: EnvConfig; 66 | 67 | constructor(filePath: string) { 68 | const config = dotenv.parse(fs.readFileSync(filePath)); 69 | this.envConfig = this.validateInput(config); 70 | } 71 | 72 | private validateInput(envConfig: EnvConfig): EnvConfig { 73 | const envVarsSchema: Joi.ObjectSchema = Joi.object({ 74 | APP_NAME: Joi.string().required(), 75 | APP_PATH: Joi.string().required(), 76 | APP_PORT: Joi.number().required(), 77 | APP_LIMIT: Joi.number().required(), 78 | 79 | LOG_PATH: Joi.string().required(), 80 | LOG_LEVEL: Joi.string().required(), 81 | 82 | EMAIL_HOST: Joi.string().required(), 83 | EMAIL_ACCOUNT: Joi.string().required(), 84 | EMAIL_PASSWORD: Joi.string().required(), 85 | 86 | QINNIU_ACCESSKEY: Joi.string().required(), 87 | QINNIU_TOKEN: Joi.string().required(), 88 | QINNIU_BUCKET: Joi.string().required(), 89 | QINNIU_ORIGIN: Joi.string().required(), 90 | QINIU_UPLOADURL: Joi.string().required(), 91 | 92 | BAIDU_SITE: Joi.string().required(), 93 | BAIDU_TOKEN: Joi.string().required(), 94 | 95 | MONGO_URL: Joi.string().required(), 96 | 97 | JWTKEY: Joi.string().required(), 98 | DEFAULT_USERNAME: Joi.string().required(), 99 | DEFAULT_PASSWORD: Joi.string().required(), 100 | 101 | NODE_ENV: Joi.string().required(), 102 | 103 | FRONT_SITE: Joi.string().required() 104 | }); 105 | 106 | const { error, value: validatedEnvConfig } = Joi.validate(envConfig, envVarsSchema); 107 | if (error) { 108 | throw new ConflictException(`Config validation`); 109 | } 110 | return validatedEnvConfig; 111 | } 112 | 113 | public get APP_NAME() { 114 | return this.envConfig.APP_NAME; 115 | } 116 | public get APP_PORT(): number { 117 | return Number(this.envConfig.APP_PORT); 118 | } 119 | public get APP_PATH(): string { 120 | return this.envConfig.APP_PATH; 121 | } 122 | public get APP_LIMIT(): number { 123 | return Number(this.envConfig.APP_LIMIT); 124 | } 125 | 126 | public get LOG_PATH(): string { 127 | return this.envConfig.LOG_PATH; 128 | } 129 | public get LOG_LEVEL(): string { 130 | return this.envConfig.LOG_LEVEL; 131 | } 132 | 133 | public get EMAIL_HOST(): string { 134 | return this.envConfig.EMAIL_HOST; 135 | } 136 | public get EMAIL_ACCOUNT(): string { 137 | return this.envConfig.EMAIL_ACCOUNT; 138 | } 139 | public get EMAIL_PASSWORD(): string { 140 | return this.envConfig.EMAIL_PASSWORD; 141 | } 142 | 143 | public get QINNIU_ACCESSKEY(): string { 144 | return this.envConfig.QINNIU_ACCESSKEY; 145 | } 146 | public get QINNIU_TOKEN(): string { 147 | return this.envConfig.QINNIU_TOKEN; 148 | } 149 | public get QINNIU_BUCKET(): string { 150 | return this.envConfig.QINNIU_BUCKET; 151 | } 152 | public get QINNIU_ORIGIN(): string { 153 | return this.envConfig.QINNIU_ORIGIN; 154 | } 155 | public get QINIU_UPLOADURL(): string { 156 | return this.envConfig.QINIU_UPLOADURL; 157 | } 158 | 159 | public get BAIDU_SITE(): string { 160 | return this.envConfig.BAIDU_SITE; 161 | } 162 | public get BAIDU_TOKEN(): string { 163 | return this.envConfig.BAIDU_TOKEN; 164 | } 165 | 166 | public get MONGO_URL(): string { 167 | return this.envConfig.MONGO_URL; 168 | } 169 | 170 | public get JWTKEY(): string { 171 | return this.envConfig.JWTKEY; 172 | } 173 | public get DEFAULT_USERNAME(): string { 174 | return this.envConfig.DEFAULT_USERNAME; 175 | } 176 | public get DEFAULT_PASSWORD(): string { 177 | return this.envConfig.DEFAULT_PASSWORD; 178 | } 179 | 180 | public get LOG4CONFI(): Configuration { 181 | return { 182 | appenders: { 183 | out: { type: 'console' }, 184 | app: { 185 | type: 'dateFile', 186 | filename: path.join(this.LOG_PATH, 'BLOG_LOGGER'), 187 | pattern: '-yyyy-MM-dd.log', 188 | alwaysIncludePattern: true, 189 | appender: { 190 | type: 'console' 191 | } 192 | } 193 | }, 194 | categories: { 195 | default: { 196 | appenders: ['out', 'app'], 197 | level: this.LOG_LEVEL 198 | } 199 | } 200 | }; 201 | } 202 | public get AXIOS_CONFIG(): AxiosRequestConfig { 203 | return { 204 | baseURL: this.APP_PATH, 205 | timeout: 5000 206 | }; 207 | } 208 | public get APP_INFO() { 209 | return { 210 | name: 'by_blog', 211 | version: '1.0.0', 212 | author: 'jkchao', 213 | site: 'https://jkchao.cn', 214 | powered: ['Vue2', 'Nuxt.js', 'Node.js', 'MongoDB', 'koa', 'Nginx'] 215 | }; 216 | } 217 | public get ENV() { 218 | return this.envConfig.NODE_ENV; 219 | } 220 | 221 | public get SITE() { 222 | return this.envConfig.FRONT_SITE; 223 | } 224 | } 225 | 226 | const config = new ConfigService(path.resolve(__dirname, `.env.${process.env.NODE_ENV}`)); 227 | 228 | export { config }; 229 | -------------------------------------------------------------------------------- /src/global.graphql: -------------------------------------------------------------------------------- 1 | 2 | # TODO: email scalar 3 | 4 | 5 | enum State { 6 | TODO 7 | SUCCESS 8 | FAIL 9 | } 10 | 11 | type Message { 12 | message: String 13 | } -------------------------------------------------------------------------------- /src/main.hmr.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | 4 | declare const module: any; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | await app.listen(3000); 9 | 10 | if (module.hot) { 11 | module.hot.accept(); 12 | module.hot.dispose(() => app.close()); 13 | } 14 | } 15 | bootstrap(); 16 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | 3 | import helmet from 'helmet'; 4 | import bodyParser from 'body-parser'; 5 | import rateLimit from 'express-rate-limit'; 6 | import compression from 'compression'; 7 | 8 | import { AppModule } from './app.module'; 9 | 10 | import { HttpExceptionFilter } from './common/filters/http-exception.filter'; 11 | 12 | import { LoggingInterceptor } from './common/interceptors/logger.interceptor'; 13 | import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor'; 14 | import { config } from '@/config'; 15 | import { BlogLogger } from './module/common/logger/logger'; 16 | import { ValidationPipe } from '@nestjs/common'; 17 | 18 | export async function bootstrap() { 19 | const app = await NestFactory.create(AppModule, { 20 | logger: false 21 | }); 22 | 23 | const logger = app.get(BlogLogger); 24 | 25 | app.useLogger(logger); 26 | 27 | logger.log(config.APP_NAME + ' start...'); 28 | 29 | app.useGlobalFilters(new HttpExceptionFilter()); 30 | 31 | app.useGlobalInterceptors(new LoggingInterceptor(logger), new TimeoutInterceptor()); 32 | 33 | app.useGlobalPipes( 34 | new ValidationPipe({ 35 | transform: true 36 | }) 37 | ); 38 | 39 | // app.useGlobalGuards(new AuthIsVerifiedGuard()); 40 | 41 | // 支持 CORS 42 | app.enableCors({ 43 | credentials: true 44 | }); 45 | app.use(helmet()); 46 | app.use(bodyParser()); 47 | app.use( 48 | rateLimit({ 49 | windowMs: 15 * 60 * 1000, // 15 minutes 50 | max: 100 51 | }) 52 | ); 53 | app.use(compression()); 54 | 55 | await app.listen(config.APP_PORT, '0.0.0.0', () => { 56 | logger.log(config.APP_NAME + ' start: 0.0.0.0:' + config.APP_PORT); 57 | }); 58 | } 59 | 60 | bootstrap(); 61 | -------------------------------------------------------------------------------- /src/module/articles/__test__/article.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | 5 | import { ArticlesModule } from '../articles.module'; 6 | import { ArticlesSercice } from '../articles.service'; 7 | 8 | import { MongooseModule } from '@nestjs/mongoose'; 9 | import { config } from '../../../config'; 10 | 11 | import mongoose from 'mongoose'; 12 | import { GraphQLModule } from '@nestjs/graphql'; 13 | 14 | describe('article', () => { 15 | let app: INestApplication; 16 | 17 | describe('success', () => { 18 | const articleService = { 19 | searchArticle: () => ({}), 20 | deleteArticle: () => ({}), 21 | createArticle: () => ({}), 22 | updateArticleWidthId: () => ({}), 23 | getArticleById: () => ({}) 24 | }; 25 | 26 | beforeAll(async () => { 27 | const module = await Test.createTestingModule({ 28 | imports: [ 29 | MongooseModule.forRoot(config.MONGO_URL), 30 | ArticlesModule, 31 | GraphQLModule.forRoot({ 32 | typePaths: ['./**/*.graphql'], 33 | path: '/api/v2', 34 | context: ({ req, res }: { req: Request; res: Response }) => ({ 35 | request: req 36 | }) 37 | }) 38 | ] 39 | }) 40 | .overrideProvider(ArticlesSercice) 41 | .useValue(articleService) 42 | .compile(); 43 | 44 | app = await module.createNestApplication().init(); 45 | }); 46 | 47 | it('getArticles should success', () => { 48 | return request(app.getHttpServer()) 49 | .post('/api/v2') 50 | .send({ 51 | query: ` 52 | { 53 | getArticles { 54 | total 55 | } 56 | } 57 | ` 58 | }) 59 | .expect(200); 60 | }); 61 | it('getArticleById should success', () => { 62 | return request(app.getHttpServer()) 63 | .post('/api/v2') 64 | .send({ 65 | query: ` 66 | { 67 | getArticleById(_id: "1234567") { 68 | _id 69 | } 70 | } 71 | ` 72 | }) 73 | .expect(200); 74 | }); 75 | 76 | it('deleteArticle success', () => { 77 | return request(app.getHttpServer()) 78 | .post('/api/v2') 79 | .send({ 80 | query: ` 81 | mutation { 82 | deleteArticle(_id: "59ef13f0a3ad094f5d294da3") { 83 | message 84 | } 85 | } 86 | ` 87 | }) 88 | .expect(200); 89 | }); 90 | 91 | it('createArticle success', () => { 92 | return request(app.getHttpServer()) 93 | .post('/api/v2') 94 | .send({ 95 | query: ` 96 | mutation { 97 | createArticle(articleInfo: { title: "hha", keyword: "hah", tag: ["123"]}) { 98 | content 99 | } 100 | } 101 | ` 102 | }) 103 | .expect({ data: { createArticle: { content: null } } }); 104 | }); 105 | 106 | it('updateArticle success', () => { 107 | return request(app.getHttpServer()) 108 | .post('/api/v2') 109 | .send({ 110 | query: ` 111 | mutation { 112 | updateArticle( 113 | articleInfo: { 114 | title: "z", 115 | keyword: "z", 116 | state: DRAFT, 117 | _id: "5ae7d66df92cf8122fc0ca89", 118 | } 119 | ) { 120 | title 121 | } 122 | } 123 | ` 124 | }) 125 | .expect(200); 126 | }); 127 | 128 | afterAll(async () => { 129 | await app.close(); 130 | await mongoose.disconnect(); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/module/articles/__test__/article.services.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { ArticlesModule } from '../articles.module'; 4 | import { ArticlesSercice } from '../articles.service'; 5 | import { getModelToken } from '@nestjs/mongoose'; 6 | 7 | import mongoose from 'mongoose'; 8 | import { ArticleInfoDto, QueryArticleDto } from '../dto/article.dto'; 9 | import { HttpService } from '@nestjs/common'; 10 | 11 | describe('articles', () => { 12 | let articlesService: ArticlesSercice; 13 | 14 | // tslint:disable-next-line:class-name 15 | class mockRepository { 16 | public static paginate() { 17 | return { 18 | docs: [] 19 | }; 20 | } 21 | public static aggregate() { 22 | return {}; 23 | } 24 | public static update() { 25 | return {}; 26 | } 27 | public static findOne() { 28 | return {}; 29 | } 30 | public static findById() { 31 | return { 32 | populate() { 33 | return { 34 | meta: { views: 2 }, 35 | save: () => ({}) 36 | }; 37 | } 38 | }; 39 | } 40 | public static findOneAndUpdate() { 41 | return {}; 42 | } 43 | public static findOneAndRemove() { 44 | return {}; 45 | } 46 | 47 | public save() { 48 | return {}; 49 | } 50 | } 51 | 52 | mockRepository.prototype.save = () => ({}); 53 | 54 | beforeAll(async () => { 55 | const module = await Test.createTestingModule({ 56 | imports: [ArticlesModule] 57 | }) 58 | .overrideProvider(getModelToken('Articles')) 59 | .useValue(mockRepository) 60 | .overrideProvider(HttpService) 61 | .useValue({ 62 | get() { 63 | return {}; 64 | }, 65 | post() { 66 | return { 67 | toPromise() { 68 | return new Promise(resolve => { 69 | // .. 70 | }); 71 | } 72 | }; 73 | } 74 | }) 75 | .compile(); 76 | 77 | articlesService = module.get(ArticlesSercice); 78 | }); 79 | 80 | it('createArticle', async () => { 81 | const obj = {} as ArticleInfoDto; 82 | const res = await articlesService.createArticle(obj); 83 | expect(res).toMatchObject(new mockRepository().save()); 84 | }); 85 | 86 | it('searchArticle', async () => { 87 | const obj = ({ 88 | keyword: 'hah', 89 | hot: true, 90 | date: 1545641754197, 91 | type: 2, 92 | tag: '123456' 93 | } as unknown) as QueryArticleDto; 94 | const res = await articlesService.searchArticle(obj); 95 | expect(res).toMatchObject(mockRepository.paginate()); 96 | }); 97 | 98 | it('updateArticle', async () => { 99 | const obj = {} as ArticleInfoDto; 100 | const res = await articlesService.updateArticle(obj); 101 | expect(res).toMatchObject({}); 102 | }); 103 | 104 | it('updateArticleWidthId', async () => { 105 | const obj = {} as ArticleInfoDto; 106 | const res = await articlesService.updateArticleWidthId(obj); 107 | expect(res).toMatchObject({}); 108 | }); 109 | 110 | it('getArticleById', async () => { 111 | const obj = {} as ArticleInfoDto; 112 | const res = await articlesService.getArticleById(''); 113 | expect(res).toMatchObject({}); 114 | }); 115 | 116 | it('findOneArticle', async () => { 117 | const obj = {} as ArticleInfoDto; 118 | const res = await articlesService.findOneArticle(''); 119 | expect(res).toMatchObject({}); 120 | }); 121 | 122 | it('deleteArticle', async () => { 123 | const res = await articlesService.deleteArticle('12345'); 124 | expect(res).toMatchObject({}); 125 | }); 126 | 127 | afterAll(async () => { 128 | await mongoose.disconnect(); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/module/articles/articles.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | getArticles( 3 | offset: Float 4 | limit: Float 5 | keyword: String 6 | state: ArticleState 7 | publish: Publish 8 | type: ArticleType 9 | data: Date 10 | hot: Float 11 | tag: String 12 | ): Articles 13 | getArticleById(_id: ObjectID): ArticlesItem 14 | } 15 | 16 | type Mutation { 17 | createArticle(articleInfo: CreateArticleInput!): ArticlesItem 18 | updateArticle(articleInfo: UpdateArticleInput!): ArticlesItem 19 | deleteArticle(_id: ObjectID!): Message 20 | } 21 | 22 | input CreateArticleInput { 23 | title: String! 24 | keyword: String! 25 | content: String 26 | descript: String 27 | state: ArticleState 28 | publish: Publish 29 | thumb: String 30 | type: ArticleType 31 | tag: [String]! 32 | } 33 | 34 | input UpdateArticleInput { 35 | _id: ObjectID! 36 | title: String! 37 | keyword: String! 38 | content: String 39 | descript: String 40 | state: ArticleState 41 | publish: Publish 42 | thumb: String 43 | type: ArticleType 44 | tag: [String!] 45 | } 46 | 47 | type Articles { 48 | total: Float 49 | offset: Float! 50 | limit: Float! 51 | docs: [ArticlesItem] 52 | } 53 | 54 | type ArticlesItem { 55 | id: Float 56 | _id: ObjectID! 57 | title: String 58 | keyword: String 59 | content: String 60 | state: ArticleState 61 | publish: Publish 62 | thumb: String 63 | type: ArticleType 64 | meta: ArticleMeta 65 | tag: [TagsItem] 66 | update_at: Date 67 | create_at: Date 68 | } 69 | 70 | type ArticleMeta { 71 | views: Float 72 | likes: Float 73 | comments: Float 74 | } 75 | 76 | enum Publish { 77 | PUBLIC 78 | PRIVATE 79 | } 80 | 81 | enum ArticleType { 82 | CODE 83 | THINK 84 | FOLK 85 | } 86 | 87 | enum ArticleState { 88 | RELEASE 89 | DRAFT 90 | } 91 | 92 | scalar ObjectID 93 | -------------------------------------------------------------------------------- /src/module/articles/articles.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, HttpModule } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { ArticleSchema } from './schema/articles.schema'; 4 | import { ArticlesResolver } from './articles.resolver'; 5 | import { ArticlesSercice } from './articles.service'; 6 | import { BlogLoggerModule } from '../common/logger/logger.module'; 7 | 8 | @Module({ 9 | imports: [MongooseModule.forFeature([{ name: 'Articles', schema: ArticleSchema }]), HttpModule, BlogLoggerModule], 10 | providers: [ArticlesResolver, ArticlesSercice], 11 | exports: [ArticlesSercice] 12 | }) 13 | export class ArticlesModule {} 14 | -------------------------------------------------------------------------------- /src/module/articles/articles.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, Args, Mutation, Context } from '@nestjs/graphql'; 2 | import { ArticlesSercice } from './articles.service'; 3 | import { QueryArticleDto, ArticleInfoDto } from './dto/article.dto'; 4 | import { Request } from 'express'; 5 | import { UseInterceptors, ClassSerializerInterceptor } from '@nestjs/common'; 6 | import { Permissions } from '@/common/decorator/Permissions.decorator'; 7 | 8 | @Resolver() 9 | export class ArticlesResolver { 10 | constructor(private readonly articleService: ArticlesSercice) {} 11 | 12 | @UseInterceptors(ClassSerializerInterceptor) 13 | @Query() 14 | public getArticleById(@Args('_id') _id: string) { 15 | return this.articleService.getArticleById(_id); 16 | } 17 | 18 | @Query() 19 | public getArticles(@Args() query: QueryArticleDto, @Context('request') request: Request) { 20 | const token = request.headers.authorization; 21 | 22 | if (!token) { 23 | query.state = 1; 24 | query.publish = 1; 25 | } 26 | return this.articleService.searchArticle(query); 27 | } 28 | 29 | @Mutation() 30 | @Permissions() 31 | public createArticle(@Args('articleInfo') info: ArticleInfoDto) { 32 | return this.articleService.createArticle(info); 33 | } 34 | 35 | @Mutation() 36 | @Permissions() 37 | public updateArticle(@Args('articleInfo') info: ArticleInfoDto) { 38 | return this.articleService.updateArticleWidthId(info); 39 | } 40 | 41 | @Mutation() 42 | @Permissions() 43 | public async deleteArticle(@Args('_id') _id: string) { 44 | await this.articleService.deleteArticle(_id); 45 | return { message: 'success' }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/module/articles/articles.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, HttpService } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { PaginateModel } from 'mongoose'; 4 | import { ArticleMongo, Publish, ArticleType, ArticleState } from './interface/articles.interface'; 5 | import { QueryArticleDto, ArticleInfoDto } from './dto/article.dto'; 6 | import { config } from '@/config'; 7 | import { BlogLogger } from '../common/logger/logger'; 8 | 9 | @Injectable() 10 | export class ArticlesSercice { 11 | constructor( 12 | @InjectModel('Articles') private readonly articlesModel: PaginateModel, 13 | private readonly logger: BlogLogger, 14 | private readonly httpService: HttpService 15 | ) {} 16 | 17 | public async searchArticle({ 18 | limit = 10, 19 | offset = 0, 20 | keyword = '', 21 | state = 1, 22 | publish = 1, 23 | tag, 24 | type, 25 | date, 26 | hot 27 | }: QueryArticleDto) { 28 | const options: { 29 | sort: any; 30 | limit: number; 31 | offset: number; 32 | populate: string[]; 33 | select?: string; 34 | } = { 35 | sort: { create_at: -1 }, 36 | offset, 37 | limit, 38 | populate: ['tag'] 39 | }; 40 | 41 | const querys: { 42 | $or?: any; 43 | state?: number; 44 | publish?: number; 45 | type?: number; 46 | create_at?: any; 47 | tag?: any; 48 | } = { 49 | state, 50 | publish 51 | }; 52 | 53 | if (keyword) { 54 | const keywordReg = new RegExp(keyword); 55 | querys.$or = [{ title: keywordReg }, { content: keywordReg }, { description: keywordReg }]; 56 | } 57 | 58 | if (hot) { 59 | options.sort = { 60 | 'meta.views': -1, 61 | 'meta.likes': -1, 62 | 'meta.comments': -1 63 | }; 64 | } 65 | 66 | if (date) { 67 | const getDate = new Date(date).getTime(); 68 | if (!Object.is(getDate.toString(), 'Invalid Date')) { 69 | querys.create_at = { 70 | $gte: new Date((getDate / 1000 - 60 * 60 * 8) * 1000), 71 | $lt: new Date((getDate / 1000 + 60 * 60 * 16) * 1000) 72 | }; 73 | } 74 | } 75 | 76 | if (type) { 77 | querys.type = type; 78 | } 79 | 80 | if (tag) querys.tag = tag; 81 | 82 | const res = await this.articlesModel.paginate(querys, options); 83 | return { 84 | ...res, 85 | docs: res.docs.map(doc => { 86 | return { 87 | ...doc._doc, 88 | publish: Publish[doc.publish], 89 | type: ArticleType[doc.type], 90 | state: ArticleState[doc.state] 91 | }; 92 | }) 93 | }; 94 | } 95 | 96 | // 创建 97 | public async createArticle(info: ArticleInfoDto) { 98 | const res = await new this.articlesModel(info).save(); 99 | this.httpService 100 | .post( 101 | `http://data.zz.baidu.com/urls?site=${config.BAIDU_SITE}&token=${config.BAIDU_TOKEN}`, 102 | `${config.SITE}/article/${res._id}`, 103 | { 104 | headers: { 105 | 'Content-Type': 'text/plain' 106 | } 107 | } 108 | ) 109 | .toPromise() 110 | .then(res => this.logger.log); 111 | return ( 112 | res && { 113 | ...res._doc, 114 | publish: Publish[res.publish], 115 | type: ArticleType[res.type], 116 | state: ArticleState[res.state] 117 | } 118 | ); 119 | } 120 | 121 | // 修改 122 | public async updateArticleWidthId(info: ArticleInfoDto) { 123 | this.httpService 124 | .post( 125 | `http://data.zz.baidu.com/urls?site=${config.BAIDU_SITE}&token=${config.BAIDU_TOKEN}`, 126 | `${config.SITE}/article/${info._id}`, 127 | { 128 | headers: { 129 | 'Content-Type': 'text/plain' 130 | } 131 | } 132 | ) 133 | .toPromise() 134 | .then(res => { 135 | this.logger.log(res.data); 136 | }); 137 | const res = await this.articlesModel.findOneAndUpdate({ _id: info._id }, info, { new: true }); 138 | return ( 139 | res && { 140 | ...res._doc, 141 | publish: Publish[res.publish], 142 | type: ArticleType[res.type], 143 | state: ArticleState[res.state] 144 | } 145 | ); 146 | } 147 | 148 | // 修改,没有返回 149 | public updateArticle(condition: any, doc?: any) { 150 | return this.articlesModel.update(condition, doc); 151 | } 152 | 153 | // 根据 id 获取 154 | public async getArticleById(id: string) { 155 | const res = await this.articlesModel.findById(id).populate('tag'); 156 | if (res) { 157 | res.meta.views += 1; 158 | res.save(); 159 | } 160 | 161 | return ( 162 | res && { 163 | ...res._doc, 164 | publish: Publish[res.publish], 165 | type: ArticleType[res.type], 166 | state: ArticleState[res.state] 167 | } 168 | ); 169 | } 170 | 171 | // 删除 172 | public deleteArticle(_id: string) { 173 | this.httpService 174 | .post( 175 | `http://data.zz.baidu.com/del?site=${config.BAIDU_SITE}&token=${config.BAIDU_TOKEN}`, 176 | `${config.SITE}/article/${_id}`, 177 | { 178 | headers: { 179 | 'Content-Type': 'text/plain' 180 | } 181 | } 182 | ) 183 | .toPromise() 184 | .then(res => { 185 | this.logger.log(res.data); 186 | }); 187 | return this.articlesModel.findOneAndRemove({ _id }); 188 | } 189 | 190 | // 查找 191 | public async findOneArticle(info: Partial) { 192 | const res = await this.articlesModel.findOne(info); 193 | return ( 194 | res && { 195 | ...res._doc, 196 | publish: Publish[res.publish], 197 | type: ArticleType[res.type], 198 | state: ArticleState[res.state] 199 | } 200 | ); 201 | } 202 | 203 | // 聚合 204 | public aggregate(aggregations: any[]) { 205 | return this.articlesModel.aggregate(aggregations); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/module/articles/dto/article.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform, Type } from 'class-transformer'; 2 | import { ArticleState, Publish, ArticleType } from '../interface/articles.interface'; 3 | 4 | export class ArticleTransformDto { 5 | @Transform(v => ArticleState[v]) 6 | public state: number; 7 | 8 | @Transform(v => Publish[v]) 9 | public publish: number; 10 | 11 | @Transform(v => ArticleType[v]) 12 | public type: number; 13 | } 14 | 15 | export class ArticleInfoDto extends ArticleTransformDto { 16 | public _id: string; 17 | public keyword: string; 18 | public title: string; 19 | public content: string; 20 | public thumb: string; 21 | public name: string; 22 | public tag: string; 23 | public meta: ArticleMetaDto; 24 | } 25 | 26 | export class ArticleMetaDto { 27 | public views: number; 28 | public likes: number; 29 | public comments: number; 30 | } 31 | 32 | export class QueryArticleDto extends ArticleTransformDto { 33 | public offset?: number; 34 | public limit?: number; 35 | public keyword?: string; 36 | public date: string; 37 | public tag?: string; 38 | public hot?: boolean; 39 | } 40 | -------------------------------------------------------------------------------- /src/module/articles/interface/articles.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | import { ArticleInfoDto } from '../dto/article.dto'; 3 | 4 | export interface ArticleMongo extends ArticleInfoDto, Document { 5 | _doc: ArticleMongo; 6 | _id: string; 7 | } 8 | 9 | export enum Publish { 10 | PUBLIC = 1, 11 | PRIVATE 12 | } 13 | 14 | export enum ArticleType { 15 | CODE = 1, 16 | THINK, 17 | FOLK 18 | } 19 | 20 | export enum ArticleState { 21 | RELEASE = 1, 22 | DRAFT 23 | } 24 | -------------------------------------------------------------------------------- /src/module/articles/schema/articles.schema.ts: -------------------------------------------------------------------------------- 1 | import Mongoose from 'mongoose'; 2 | import mongoosePaginate from 'mongoose-paginate'; 3 | import autoIncrement from 'mongoose-auto-increment'; 4 | import { connection } from '@/module/common/db'; 5 | 6 | autoIncrement.initialize(connection); 7 | 8 | export const ArticleSchema = new Mongoose.Schema({ 9 | // 文章标题 10 | title: { type: String, required: true }, 11 | 12 | // 关键字 13 | keyword: { type: String, required: true }, 14 | 15 | // 描述 16 | descript: { type: String, required: false }, 17 | 18 | // 标签 19 | tag: [{ type: Mongoose.Schema.Types.ObjectId, ref: 'Tags' }], 20 | 21 | // 内容 22 | content: { type: String, required: true }, 23 | 24 | // 状态 1 发布 2 草稿 25 | state: { type: Number, default: 1 }, 26 | 27 | // 文章公开状态 1 公开 2 私密 28 | publish: { type: Number, default: 1 }, 29 | 30 | // 缩略图 31 | thumb: String, 32 | 33 | // 文章分类 1 code 2 think 3 民谣 34 | type: { type: Number }, 35 | 36 | // 发布日期 37 | create_at: { type: Date, default: Date.now }, 38 | 39 | // 最后修改日期 40 | update_at: { type: Date, default: Date.now }, 41 | 42 | // 其他元信息 43 | meta: { 44 | views: { type: Number, default: 0 }, 45 | likes: { type: Number, default: 0 }, 46 | comments: { type: Number, default: 0 } 47 | } 48 | }); 49 | 50 | ArticleSchema.set('toObject', { getters: true }); 51 | 52 | // 翻页 + 自增ID插件配置 53 | ArticleSchema.plugin(mongoosePaginate); 54 | ArticleSchema.plugin(autoIncrement.plugin, { 55 | model: 'Article', 56 | field: 'id', 57 | startAt: 1, 58 | incrementBy: 1 59 | }); 60 | 61 | // 时间更新 62 | ArticleSchema.pre('findOneAndUpdate', function(next) { 63 | this.findOneAndUpdate({}, { update_at: Date.now() }); 64 | next(); 65 | }); 66 | -------------------------------------------------------------------------------- /src/module/auth/__test__/auth.resolvers.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | 5 | import { AuthModule } from '../auth.module'; 6 | import { AuthService } from '../auth.service'; 7 | 8 | import { MongooseModule } from '@nestjs/mongoose'; 9 | import { config } from '../../../config'; 10 | 11 | import mongoose from 'mongoose'; 12 | import { GraphQLModule } from '@nestjs/graphql'; 13 | 14 | // const mock = jest.mock(); 15 | 16 | // mock.re 17 | 18 | jest.mock('@/common/utils', () => { 19 | const md5Decode = password => password; 20 | const createToken = _ => '123456'; 21 | return { md5Decode, createToken }; 22 | }); 23 | 24 | describe('auth', () => { 25 | let app: INestApplication; 26 | 27 | describe('success', () => { 28 | const authService = { 29 | findOne() { 30 | return { username: 'jkchao', password: '123456' }; 31 | }, 32 | create() { 33 | return { username: 'jkchao' }; 34 | }, 35 | update() { 36 | return { username: 'jkchao' }; 37 | } 38 | }; 39 | 40 | beforeAll(async () => { 41 | const module = await Test.createTestingModule({ 42 | imports: [ 43 | MongooseModule.forRoot(config.MONGO_URL), 44 | AuthModule, 45 | GraphQLModule.forRoot({ 46 | typePaths: ['./**/*.graphql'], 47 | path: '/api/v2' 48 | }) 49 | ] 50 | }) 51 | .overrideProvider(AuthService) 52 | .useValue(authService) 53 | .compile(); 54 | 55 | app = await module.createNestApplication().init(); 56 | }); 57 | 58 | it('login should success', () => { 59 | return request(app.getHttpServer()) 60 | .post('/api/v2') 61 | .send({ 62 | query: ` 63 | { 64 | login(username: "jkchao", password: "123456") { 65 | token 66 | } 67 | } 68 | ` 69 | }) 70 | .expect(200); 71 | }); 72 | 73 | it('login should passwordword', () => { 74 | return request(app.getHttpServer()) 75 | .post('/api/v2') 76 | .send({ 77 | query: ` 78 | { 79 | login(username: "jkchao", password: "1234567") { 80 | token 81 | } 82 | } 83 | ` 84 | }) 85 | .expect(200); 86 | }); 87 | 88 | it('login should account does not exit', () => { 89 | return request(app.getHttpServer()) 90 | .post('/api/v2') 91 | .send({ 92 | query: ` 93 | { 94 | login(username: "jkchaos", password: "1234567") { 95 | token 96 | } 97 | } 98 | ` 99 | }) 100 | .expect(200); 101 | }); 102 | 103 | it('getInfo', () => { 104 | return request(app.getHttpServer()) 105 | .post('/api/v2') 106 | .send({ 107 | query: ` 108 | { 109 | getInfo { 110 | name 111 | } 112 | } 113 | ` 114 | }) 115 | .expect(200); 116 | }); 117 | 118 | it('getInfo success', () => { 119 | return request(app.getHttpServer()) 120 | .post('/api/v2') 121 | .send({ 122 | query: ` 123 | mutation Auth { 124 | updateUserInfo(userInfo: {_id: "59ef13f0a3ad094f5d294da3", oldPassword: "123456", name: "4"}) { 125 | name 126 | gravatar 127 | slogan 128 | username 129 | password 130 | } 131 | } 132 | ` 133 | }) 134 | .expect(200); 135 | }); 136 | 137 | it('updateUserInfo success', () => { 138 | return request(app.getHttpServer()) 139 | .post('/api/v2') 140 | .send({ 141 | query: ` 142 | mutation Auth { 143 | updateUserInfo(userInfo: {_id: "59ef13f0a3ad094f5d294da3", oldPassword: "1234567", name: "4"}) { 144 | name 145 | gravatar 146 | slogan 147 | username 148 | password 149 | } 150 | } 151 | ` 152 | }) 153 | .expect(200); 154 | }); 155 | 156 | afterAll(async () => { 157 | await app.close(); 158 | await mongoose.disconnect(); 159 | }); 160 | }); 161 | 162 | describe('error', () => { 163 | const authService = { 164 | findOne() { 165 | return null; 166 | }, 167 | create() { 168 | return { username: 'jkchao' }; 169 | }, 170 | update() { 171 | return { username: 'jkchao' }; 172 | } 173 | }; 174 | 175 | beforeAll(async () => { 176 | const module = await Test.createTestingModule({ 177 | imports: [ 178 | MongooseModule.forRoot(config.MONGO_URL), 179 | AuthModule, 180 | GraphQLModule.forRoot({ 181 | typePaths: ['./**/*.graphql'], 182 | path: '/api/v2' 183 | }) 184 | ] 185 | }) 186 | .overrideProvider(AuthService) 187 | .useValue(authService) 188 | .compile(); 189 | 190 | app = await module.createNestApplication().init(); 191 | }); 192 | 193 | it('login error', () => { 194 | return request(app.getHttpServer()) 195 | .post('/api/v2') 196 | .send({ 197 | query: ` 198 | { 199 | login(username: "jkchao", password: "1234567") { 200 | token 201 | } 202 | } 203 | ` 204 | }) 205 | .expect(200); 206 | }); 207 | 208 | it('updateUserInfo error', () => { 209 | return request(app.getHttpServer()) 210 | .post('/api/v2') 211 | .send({ 212 | query: ` 213 | mutation Auth { 214 | updateUserInfo(userInfo: {_id: "59ef13f0a3ad094f5d294da3", oldPassword: "1234567", name: "4"}) { 215 | name 216 | gravatar 217 | slogan 218 | username 219 | password 220 | } 221 | } 222 | ` 223 | }) 224 | .expect(200); 225 | }); 226 | 227 | afterAll(async () => { 228 | await app.close(); 229 | await mongoose.disconnect(); 230 | }); 231 | }); 232 | }); 233 | -------------------------------------------------------------------------------- /src/module/auth/__test__/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { AuthModule } from '../auth.module'; 4 | import { AuthService } from '../auth.service'; 5 | import { getModelToken } from '@nestjs/mongoose'; 6 | 7 | import mongoose from 'mongoose'; 8 | 9 | describe('auth', () => { 10 | let authService: AuthService; 11 | 12 | const mockRepository = { 13 | findOne() { 14 | return { username: 'jkchao' }; 15 | }, 16 | create() { 17 | return { username: 'jkchao' }; 18 | }, 19 | findOneAndUpdate() { 20 | return { username: 'jkchao' }; 21 | } 22 | }; 23 | 24 | beforeAll(async () => { 25 | const module = await Test.createTestingModule({ 26 | imports: [AuthModule] 27 | }) 28 | .overrideProvider(getModelToken('Auth')) 29 | .useValue(mockRepository) 30 | .compile(); 31 | 32 | authService = module.get(AuthService); 33 | }); 34 | 35 | it('findOne', async () => { 36 | const res = await authService.findOne(); 37 | expect(res).toMatchObject(mockRepository.findOne()); 38 | }); 39 | 40 | it('findOne', async () => { 41 | const res = await authService.create({ username: '', password: '' }); 42 | expect(res).toMatchObject(mockRepository.create()); 43 | }); 44 | 45 | it('findOne', async () => { 46 | const res = await authService.update({}); 47 | expect(res).toMatchObject(mockRepository.findOne()); 48 | }); 49 | 50 | afterAll(async () => { 51 | await mongoose.disconnect(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/module/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, OnModuleInit } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { AuthResolvers } from './auth.resolvers'; 4 | import { MongooseModule } from '@nestjs/mongoose'; 5 | import { AuthSchema } from './schema/auth.schema'; 6 | import { config } from '@/config'; 7 | import { md5Decode } from '@/common/utils'; 8 | @Module({ 9 | imports: [MongooseModule.forFeature([{ name: 'Auth', schema: AuthSchema }])], 10 | providers: [AuthService, AuthResolvers] 11 | }) 12 | export class AuthModule implements OnModuleInit { 13 | constructor(private readonly authService: AuthService) {} 14 | 15 | /** 16 | * 初始化创建用户 17 | */ 18 | private async initUser() { 19 | const auth = await this.authService.findOne({ username: config.DEFAULT_USERNAME }); 20 | if (!auth) { 21 | const password = md5Decode(config.DEFAULT_PASSWORD); 22 | 23 | await this.authService.create({ 24 | username: config.DEFAULT_USERNAME, 25 | password 26 | }); 27 | } 28 | } 29 | 30 | public async onModuleInit() { 31 | await this.initUser(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/module/auth/auth.resolvers.ts: -------------------------------------------------------------------------------- 1 | import { UnauthorizedException, NotFoundException } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | 4 | import { md5Decode, createToken } from '@/common/utils'; 5 | import { Resolver, Query, Args, Mutation } from '@nestjs/graphql'; 6 | import { AuthDto, InfoRequredDto } from './dto/auth.dto'; 7 | import { Info } from './decorators/auth'; 8 | import { Permissions } from '@/common/decorator/Permissions.decorator'; 9 | 10 | @Resolver('Auth') 11 | export class AuthResolvers { 12 | constructor(private readonly authService: AuthService) {} 13 | 14 | /** 15 | * 登录 16 | * @param res RESPONSE 17 | * @param body BODY 18 | */ 19 | @Query() 20 | public async login(@Args() args: AuthDto) { 21 | const auth = await this.authService.findOne({ username: args.username }); 22 | if (auth) { 23 | if (auth.password === md5Decode(args.password)) { 24 | const token = createToken({ username: args.username }); 25 | return { token, lifeTime: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7 }; 26 | } else { 27 | throw new UnauthorizedException('Password wrong'); 28 | } 29 | } else { 30 | throw new UnauthorizedException('Account does not exist'); 31 | } 32 | } 33 | 34 | @Query() 35 | public async getInfo() { 36 | return await this.authService.findOne(); 37 | } 38 | 39 | @Mutation() 40 | @Permissions() 41 | public async updateUserInfo(@Info() userInfo: InfoRequredDto) { 42 | const auth = await this.authService.findOne({ _id: userInfo._id }); 43 | if (auth) { 44 | if (auth.password !== md5Decode(userInfo.oldPassword)) { 45 | throw new UnauthorizedException('Password wrong'); 46 | } 47 | const password = userInfo.password || userInfo.oldPassword; 48 | return this.authService.update({ 49 | ...userInfo, 50 | password: md5Decode(password) 51 | }); 52 | } else { 53 | throw new NotFoundException(); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/module/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | import { Model } from 'mongoose'; 4 | import { AuthMongo } from './interface/auth.interface'; 5 | import { InjectModel } from '@nestjs/mongoose'; 6 | import { AuthInfoDto, AuthDto } from './dto/auth.dto'; 7 | 8 | @Injectable() 9 | export class AuthService { 10 | constructor(@InjectModel('Auth') private readonly authModel: Model) {} 11 | 12 | /** 13 | * 根据用户名查找用户 14 | * @param username 用户名 15 | */ 16 | public async findOne(info?: AuthInfoDto) { 17 | return await this.authModel.findOne({ ...info }); 18 | } 19 | 20 | /** 21 | * 初始化创建用户 22 | * @param auth { username password } 23 | */ 24 | public async create(auth: AuthDto) { 25 | return await this.authModel.create(auth); 26 | } 27 | 28 | public async update(auth: AuthInfoDto) { 29 | return this.authModel.findOneAndUpdate(auth._id, auth, { new: true }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/module/auth/auths.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | login(username: String!, password: String!): Token 3 | getInfo: UserInfo 4 | } 5 | 6 | type Mutation { 7 | updateUserInfo(userInfo: InfoInput!): UserInfo 8 | } 9 | 10 | type Token { 11 | token: String 12 | lifeTime: Date 13 | } 14 | 15 | input InfoInput { 16 | _id: ObjectId! 17 | name: String 18 | slogan: String 19 | gravatar: String 20 | password: String 21 | oldPassword: String! 22 | } 23 | 24 | type UserInfo { 25 | _id: ObjectId! 26 | name: String 27 | username: String 28 | slogan: String 29 | gravatar: String 30 | password: String 31 | } 32 | 33 | scalar Date 34 | scalar ObjectId 35 | -------------------------------------------------------------------------------- /src/module/auth/decorators/auth.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator } from '@nestjs/common'; 2 | 3 | export const Info = createParamDecorator((data, [root, args, ctx, info]) => { 4 | return args.userInfo; 5 | }); 6 | -------------------------------------------------------------------------------- /src/module/auth/dto/auth.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, MinLength } from 'class-validator'; 2 | 3 | export class AuthDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | @MinLength(6) 7 | public username: string; 8 | 9 | @IsString() 10 | @IsNotEmpty() 11 | @MinLength(6) 12 | public password: string; 13 | } 14 | 15 | export class AuthInfoDto { 16 | public _id?: string; 17 | public name?: string; 18 | public username?: string; 19 | public slogan?: string; 20 | public gravatar?: string; 21 | public password?: string; 22 | public oldPassword?: string; 23 | } 24 | 25 | export class InfoRequredDto extends AuthInfoDto { 26 | public oldPassword: string; 27 | } 28 | -------------------------------------------------------------------------------- /src/module/auth/interface/auth.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | 3 | export interface AuthMongo extends Document { 4 | username: string; 5 | password: string; 6 | slogan: string; 7 | gravatar: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/module/auth/schema/auth.schema.ts: -------------------------------------------------------------------------------- 1 | import Mongoose from 'mongoose'; 2 | import { config } from '@/config'; 3 | import crypto from 'crypto'; 4 | 5 | export const AuthSchema = new Mongoose.Schema({ 6 | // 名字 7 | name: { type: String, default: '' }, 8 | 9 | // 登录用户名 10 | username: { 11 | type: String, 12 | default: config.DEFAULT_USERNAME 13 | }, 14 | 15 | // 签名 16 | slogan: { type: String, default: '' }, 17 | 18 | // 头像 19 | gravatar: { type: String, default: '' }, 20 | 21 | // 密码 22 | password: { 23 | type: String, 24 | default: crypto 25 | .createHash('md5') 26 | .update(config.DEFAULT_PASSWORD) 27 | .digest('hex') 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /src/module/comments/__test__/comments.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | 5 | import { CommentsModule } from '../comments.module'; 6 | import { CommentsService } from '../comments.service'; 7 | 8 | import { MongooseModule } from '@nestjs/mongoose'; 9 | import { config } from '../../../config'; 10 | 11 | import mongoose from 'mongoose'; 12 | import { GraphQLModule } from '@nestjs/graphql'; 13 | import { ArticlesSercice } from '../../articles/articles.service'; 14 | 15 | describe('comments', () => { 16 | let app: INestApplication; 17 | 18 | describe('success', () => { 19 | const commentsService = { 20 | searchComments: () => ({}), 21 | deleteComment: () => ({}), 22 | createComment: () => ({}), 23 | sendEmail: () => ({}), 24 | updateArticleCommentCount: () => ({}), 25 | updateComment: () => ({}) 26 | }; 27 | 28 | beforeAll(async () => { 29 | const module = await Test.createTestingModule({ 30 | imports: [ 31 | MongooseModule.forRoot(config.MONGO_URL), 32 | CommentsModule, 33 | GraphQLModule.forRoot({ 34 | typePaths: ['./**/*.graphql'], 35 | path: '/api/v2', 36 | context: ({ req, res }: { req: Request; res: Response }) => ({ 37 | request: req 38 | }) 39 | }) 40 | ] 41 | }) 42 | .overrideProvider(CommentsService) 43 | .useValue(commentsService) 44 | .overrideProvider(ArticlesSercice) 45 | .useValue({ 46 | findOneArticle() { 47 | return { _id: '123' }; 48 | } 49 | }) 50 | .compile(); 51 | 52 | app = await module.createNestApplication().init(); 53 | }); 54 | 55 | it('getComments should success', () => { 56 | return request(app.getHttpServer()) 57 | .post('/api/v2') 58 | .send({ 59 | query: ` 60 | { 61 | getComments { 62 | total 63 | } 64 | } 65 | ` 66 | }) 67 | .expect(200); 68 | }); 69 | 70 | it('deleteComment success', () => { 71 | return request(app.getHttpServer()) 72 | .post('/api/v2') 73 | .send({ 74 | query: ` 75 | mutation { 76 | deleteComment(_id: "59ef13f0a3ad094f5d294da3", post_id: 1) { 77 | message 78 | } 79 | } 80 | ` 81 | }) 82 | .expect(200); 83 | }); 84 | 85 | it('createComment success', () => { 86 | return request(app.getHttpServer()) 87 | .post('/api/v2') 88 | .send({ 89 | query: ` 90 | mutation { 91 | createComment( 92 | commentInfo: { 93 | post_id: 0 94 | content: "jaja" 95 | author: { 96 | name: "jkchao", 97 | email: "jkchaom@gmail.com" 98 | } 99 | } 100 | ){ 101 | _id 102 | } 103 | } 104 | ` 105 | }) 106 | .expect(200); 107 | }); 108 | 109 | it('updateComment success', () => { 110 | return request(app.getHttpServer()) 111 | .post('/api/v2') 112 | .send({ 113 | query: ` 114 | mutation { 115 | updateComment( 116 | commentInfo: { 117 | _id: "5ac8a0082780d4345de4f927" 118 | } 119 | ){ 120 | id 121 | } 122 | } 123 | ` 124 | }) 125 | .expect(200); 126 | }); 127 | 128 | afterAll(async () => { 129 | await app.close(); 130 | await mongoose.disconnect(); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/module/comments/__test__/comments.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { CommentsModule } from '../comments.module'; 4 | import { CommentsService } from '../comments.service'; 5 | import { getModelToken } from '@nestjs/mongoose'; 6 | 7 | import mongoose from 'mongoose'; 8 | import { ArticlesSercice } from '../../articles/articles.service'; 9 | import { EmailService } from '../../common/email/email.service'; 10 | import { CommentInfoDto, QueryCommentDto, UpdateCommentDto } from '../dto/comments.dto'; 11 | 12 | describe('comments', () => { 13 | let commentsService: CommentsService; 14 | const mock = jest.fn(); 15 | mock.mockReturnValueOnce([]).mockReturnValueOnce([1, 2]); 16 | // tslint:disable-next-line:class-name 17 | class mockRepository { 18 | public static paginate() { 19 | return { docs: [] }; 20 | } 21 | public static findOneAndRemove() { 22 | return {}; 23 | } 24 | public static findOneAndUpdate() { 25 | return {}; 26 | } 27 | public static findOne() { 28 | return { author: { name: '' } }; 29 | } 30 | public static aggregate() { 31 | return []; 32 | } 33 | 34 | public save() { 35 | return {}; 36 | } 37 | } 38 | 39 | const articlesService = { 40 | updateArticle() { 41 | return {}; 42 | } 43 | }; 44 | 45 | const emailService = { 46 | sendEmail() { 47 | return {}; 48 | } 49 | }; 50 | 51 | mockRepository.prototype.save = () => ({}); 52 | 53 | beforeAll(async () => { 54 | const module = await Test.createTestingModule({ 55 | imports: [CommentsModule] 56 | }) 57 | .overrideProvider(getModelToken('Comments')) 58 | .useValue(mockRepository) 59 | .overrideProvider(getModelToken('Articles')) 60 | .useValue({}) 61 | .overrideProvider(ArticlesSercice) 62 | .useValue(articlesService) 63 | .overrideProvider(EmailService) 64 | .useValue(emailService) 65 | .compile(); 66 | 67 | commentsService = module.get(CommentsService); 68 | }); 69 | 70 | it('createComments', async () => { 71 | const obj = { 72 | ip: '95.179.198.236' 73 | } as CommentInfoDto & { ip: string }; 74 | const res = await commentsService.createComment(obj); 75 | expect(res).toMatchObject(new mockRepository().save()); 76 | }); 77 | 78 | it('updateArticleCommentCount', async () => { 79 | const res = await commentsService.updateArticleCommentCount([1, 2, 3]); 80 | expect(res); 81 | }); 82 | 83 | it('sendEmail', async () => { 84 | const obj = { 85 | pid: 1, 86 | author: { 87 | name: '' 88 | } 89 | } as CommentInfoDto; 90 | const res = await commentsService.sendEmail(obj, ''); 91 | expect(res); 92 | }); 93 | 94 | it('updateArticleCommentCount return counts', async () => { 95 | const res = await commentsService.updateArticleCommentCount([1, 2, 3]); 96 | expect(res); 97 | }); 98 | 99 | it('searchComments sort 2', async () => { 100 | const obj = { 101 | sort: 2, 102 | post_id: 1, 103 | keyword: 'hh' 104 | } as QueryCommentDto; 105 | const res = await commentsService.searchComments(obj); 106 | expect(res).toMatchObject(mockRepository.paginate()); 107 | }); 108 | 109 | it('searchComments sort 1', async () => { 110 | const obj = {} as QueryCommentDto; 111 | const res = await commentsService.searchComments(obj); 112 | expect(res).toMatchObject(mockRepository.paginate()); 113 | }); 114 | 115 | it('updateComments', async () => { 116 | const obj = {} as UpdateCommentDto; 117 | const res = await commentsService.updateComment(obj); 118 | expect(res).toMatchObject(mockRepository.findOneAndUpdate()); 119 | }); 120 | 121 | it('deleteComments', async () => { 122 | const res = await commentsService.deleteComment('12345'); 123 | expect(res).toMatchObject(mockRepository.findOneAndRemove()); 124 | }); 125 | 126 | it('findComment', async () => { 127 | const res = await commentsService.findComment('12345'); 128 | expect(res).toMatchObject({ state: undefined }); 129 | }); 130 | 131 | afterAll(async () => { 132 | await mongoose.disconnect(); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /src/module/comments/comments.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | getComments( 3 | offset: Float 4 | limit: Float 5 | keyword: String 6 | state: State 7 | post_id: Float, 8 | sort: Float 9 | ): Comments 10 | } 11 | 12 | 13 | type Mutation { 14 | createComment(commentInfo: CreateCommentInput!): CommentsItem 15 | updateComment(commentInfo: UpdateCommentInput!): CommentsItem 16 | deleteComment(_id: ObjectID! post_id: Float!): Message 17 | } 18 | 19 | input CreateCommentInput { 20 | post_id: Float! 21 | pid: Float 22 | content: String! 23 | agent: String 24 | author: AuthorInput! 25 | } 26 | 27 | input UpdateCommentInput { 28 | _id: ObjectID! 29 | name: String 30 | content: String 31 | state: State 32 | author: AuthorInput 33 | } 34 | 35 | type Comments { 36 | total: Float 37 | offset: Float! 38 | limit: Float! 39 | docs: [CommentsItem] 40 | } 41 | 42 | type CommentsItem { 43 | id: Float 44 | _id: ObjectID 45 | post_id: ObjectID 46 | pid: Float 47 | content: String 48 | state: State 49 | likes: Float 50 | ip: String 51 | city: String 52 | range: String 53 | country: String 54 | agent: String 55 | author: Author 56 | update_at: Date 57 | create_at: Date 58 | } 59 | 60 | 61 | type Author { 62 | name: String! 63 | email: String! 64 | site: String 65 | } 66 | 67 | input AuthorInput { 68 | name: String! 69 | email: String! 70 | site: String 71 | } 72 | 73 | scalar ObjectID -------------------------------------------------------------------------------- /src/module/comments/comments.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { CommentsSchema } from './schema/comments.schema'; 4 | import { CommentsResolver } from './comments.resolver'; 5 | import { CommentsService } from './comments.service'; 6 | import { EmailModule } from '../common/email/email.module'; 7 | import { ArticlesModule } from '../articles/articles.module'; 8 | 9 | @Module({ 10 | imports: [MongooseModule.forFeature([{ name: 'Comments', schema: CommentsSchema }]), EmailModule, ArticlesModule], 11 | providers: [CommentsResolver, CommentsService], 12 | exports: [CommentsService] 13 | }) 14 | export class CommentsModule {} 15 | -------------------------------------------------------------------------------- /src/module/comments/comments.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, Mutation, Args, Context } from '@nestjs/graphql'; 2 | import { Permissions } from '@/common/decorator/Permissions.decorator'; 3 | import { CommentsService } from './comments.service'; 4 | import { CommentInfoDto, QueryCommentDto, UpdateCommentDto } from './dto/comments.dto'; 5 | import { Request } from 'express'; 6 | import { BadRequestException } from '@nestjs/common'; 7 | import { ArticlesSercice } from '../articles/articles.service'; 8 | 9 | @Resolver() 10 | export class CommentsResolver { 11 | constructor(private readonly commentsServer: CommentsService, private readonly articlesService: ArticlesSercice) {} 12 | 13 | @Query() 14 | public getComments(@Args() args: QueryCommentDto, @Context('request') request: Request) { 15 | const token = request.headers.authorization; 16 | if (!token) { 17 | args.state = 1; 18 | } 19 | return this.commentsServer.searchComments(args); 20 | } 21 | 22 | @Mutation() 23 | public async createComment(@Args('commentInfo') info: CommentInfoDto, @Context('request') request: Request) { 24 | const ip = ((request.headers['x-forwarded-for'] || 25 | request.headers['x-real-ip'] || 26 | request.connection.remoteAddress || 27 | request.socket.remoteAddress || 28 | request.ip || 29 | request.ips[0]) as string).replace('::ffff:', ''); 30 | 31 | info.ip = ip; 32 | info.agent = request.headers['user-agent'] || info.agent; 33 | 34 | const result = await this.commentsServer.createComment({ ...info, ip }); 35 | 36 | const article = await this.articlesService.findOneArticle({ id: info.post_id }); 37 | 38 | if (article) { 39 | this.commentsServer.sendEmail(info, article._id); 40 | } 41 | 42 | this.commentsServer.updateArticleCommentCount([info.post_id]); 43 | 44 | return result; 45 | } 46 | 47 | @Mutation() 48 | @Permissions() 49 | public updateComment(@Args('commentInfo') info: UpdateCommentDto) { 50 | return this.commentsServer.updateComment(info); 51 | } 52 | 53 | @Mutation() 54 | @Permissions() 55 | public async deleteComment(@Args('_id') _id: string, @Args('post_id') postIds: number) { 56 | await this.commentsServer.deleteComment(_id); 57 | const ids = Array.of(postIds); 58 | await this.commentsServer.updateArticleCommentCount(ids); 59 | return { message: 'success' }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/module/comments/comments.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { PaginateModel } from 'mongoose'; 4 | import { CommentMongo } from './interface/comments.interface'; 5 | import { CommentInfoDto, QueryCommentDto, UpdateCommentDto } from './dto/comments.dto'; 6 | import geoip from 'geoip-lite'; 7 | import { EmailService } from '../common/email/email.service'; 8 | import { ArticlesSercice } from '../articles/articles.service'; 9 | import { StateEnum } from '@/common/enum/state'; 10 | 11 | @Injectable() 12 | export class CommentsService { 13 | constructor( 14 | @InjectModel('Comments') private readonly commentsModel: PaginateModel, 15 | private readonly articlesService: ArticlesSercice, 16 | private readonly emailService: EmailService 17 | ) {} 18 | 19 | // 更新当前所受影响的文章的评论聚合数据 20 | public async updateArticleCommentCount(ids: number[] = []) { 21 | const postIds = [...new Set(ids)].filter(id => !!id); 22 | if (postIds.length) { 23 | const counts = await this.commentsModel.aggregate([ 24 | { $match: { state: 1, post_id: { $in: postIds } } }, 25 | { $group: { _id: '$post_id', num_tutorial: { $sum: 1 } } } 26 | ]); 27 | if (counts.length === 0) { 28 | this.articlesService.updateArticle({ id: postIds[0] }, { $set: { 'meta.comments': 0 } }); 29 | } else { 30 | counts.forEach(async count => { 31 | // tslint:disable-next-line:max-line-length 32 | await this.articlesService.updateArticle( 33 | { id: count._id }, 34 | { $set: { 'meta.comments': count.num_tutorial } } 35 | ); 36 | }); 37 | } 38 | } 39 | } 40 | 41 | // 列表 42 | public async searchComments({ 43 | offset = 0, 44 | limit = 10, 45 | keyword = '', 46 | state = 0, 47 | sort = -1, 48 | post_id 49 | }: QueryCommentDto) { 50 | const options: { 51 | sort: { _id?: number; likes?: number }; 52 | offset: number; 53 | limit: number; 54 | } = { 55 | sort: { _id: sort }, 56 | offset, 57 | limit 58 | }; 59 | 60 | // 排序字段 61 | if ([1, -1].includes(sort)) { 62 | options.sort = { _id: sort }; 63 | } else if (Object.is(sort, 2)) { 64 | options.sort = { likes: -1 }; 65 | } 66 | 67 | const querys: { 68 | state?: number; 69 | $or?: any; 70 | post_id?: number; 71 | } = { 72 | state 73 | }; 74 | 75 | // 通过post-id过滤 76 | if (!Object.is(post_id, undefined)) { 77 | querys.post_id = post_id; 78 | } 79 | 80 | if (keyword) { 81 | const keywordReg = new RegExp(keyword); 82 | querys.$or = [{ content: keywordReg }, { 'author.name': keywordReg }, { 'author.email': keywordReg }]; 83 | } 84 | const res = await this.commentsModel.paginate(querys, options); 85 | return { 86 | ...res, 87 | docs: res.docs.map(doc => { 88 | return { 89 | ...doc._doc, 90 | state: StateEnum[doc.state] 91 | }; 92 | }) 93 | }; 94 | } 95 | 96 | // 创建 97 | public async createComment(comment: CommentInfoDto & { ip: string }) { 98 | const ipLocation = geoip.lookup(comment.ip); 99 | if (ipLocation) { 100 | comment.city = ipLocation.city; 101 | comment.range = ipLocation.range; 102 | comment.country = ipLocation.country; 103 | } 104 | comment.likes = 0; 105 | 106 | const res = await new this.commentsModel({ ...comment, state: 0 }).save(); 107 | 108 | return ( 109 | res && { 110 | ...res._doc, 111 | state: StateEnum[res.state] 112 | } 113 | ); 114 | } 115 | 116 | // 删除 117 | public deleteComment(_id: string) { 118 | return this.commentsModel.findOneAndRemove({ _id }); 119 | } 120 | 121 | // 发邮件 122 | public async sendEmail(comment: CommentInfoDto, link: string) { 123 | this.emailService.sendEmail({ 124 | to: 'jkchao@foxmail.com', 125 | subject: '博客有新的留言', 126 | text: `来自 ${comment.author.name} 的留言:${comment.content}`, 127 | html: ` 128 |

来自 ${comment.author.name} 的留言:${comment.content}

129 |
130 | [ 点击查看 ] 131 | ` 132 | }); 133 | 134 | if (!!comment.pid) { 135 | const parentComment = await this.commentsModel.findOne({ id: comment.pid }); 136 | if (parentComment) { 137 | this.emailService.sendEmail({ 138 | to: parentComment.author.email, 139 | subject: '你在jkchao.cn有新的评论回复', 140 | text: `来自 ${comment.author.name} 的评论回复:${comment.content}`, 141 | // tslint:disable-next-line:max-line-length 142 | html: ` 143 |

来自${comment.author.name} 的评论回复:${comment.content}

144 |
145 | [ 点击查看 ] 146 | ` 147 | }); 148 | } 149 | } 150 | } 151 | 152 | // 更新 153 | public async updateComment(comment: UpdateCommentDto) { 154 | const res = await this.commentsModel.findOneAndUpdate({ _id: comment._id }, comment, { new: true }); 155 | 156 | return ( 157 | res && { 158 | ...res._doc, 159 | state: StateEnum[res.state] 160 | } 161 | ); 162 | } 163 | 164 | // 获取单个 comment 165 | public async findComment(comment: Partial) { 166 | const res = await this.commentsModel.findOne(comment); 167 | return ( 168 | res && { 169 | ...res._doc, 170 | state: StateEnum[res.state] 171 | } 172 | ); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/module/comments/dto/comments.dto.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | import { Transform } from 'class-transformer'; 3 | import { StateDto } from '@/common/dto/state.dto'; 4 | 5 | export class CommentInfoDto extends StateDto { 6 | // tslint:disable-next-line:variable-name 7 | public post_id: number; 8 | public pid: number; 9 | public content: string; 10 | public author: Author; 11 | public ip: string; 12 | public agent: string; 13 | public city: string; 14 | public range: number[]; 15 | public country: string; 16 | public likes: number; 17 | } 18 | 19 | export class Author { 20 | public name: string; 21 | public email: string; 22 | public site: string; 23 | } 24 | 25 | export class QueryCommentDto extends StateDto { 26 | public offset?: number; 27 | public limit?: number; 28 | public keyword?: string; 29 | // tslint:disable-next-line:variable-name 30 | public post_id: number; 31 | public sort?: number; 32 | } 33 | 34 | export class UpdateCommentDto extends StateDto { 35 | public _id: string; 36 | public name?: string; 37 | public content?: string; 38 | public likes?: number; 39 | } 40 | -------------------------------------------------------------------------------- /src/module/comments/interface/comments.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | import { CommentInfoDto } from '../dto/comments.dto'; 3 | 4 | export interface CommentMongo extends Document, CommentInfoDto { 5 | _doc: CommentMongo; 6 | } 7 | -------------------------------------------------------------------------------- /src/module/comments/schema/comments.schema.ts: -------------------------------------------------------------------------------- 1 | import Mongoose from 'mongoose'; 2 | import mongoosePaginate from 'mongoose-paginate'; 3 | import autoIncrement from 'mongoose-auto-increment'; 4 | import { connection } from '@/module/common/db'; 5 | 6 | autoIncrement.initialize(connection); 7 | 8 | export const CommentsSchema = new Mongoose.Schema({ 9 | // 评论所在的文章_id,0代表系统留言板 10 | post_id: { type: Number, required: true }, 11 | 12 | // pid,0代表默认留言 13 | pid: { type: Number, default: 0 }, 14 | 15 | // content 16 | content: { type: String, required: true, validate: /\S+/ }, 17 | 18 | // 被赞数 19 | likes: { type: Number, default: 0 }, 20 | 21 | // ip 22 | ip: { type: String }, 23 | 24 | // ip 物理地址 25 | city: { type: String }, 26 | range: { type: String }, 27 | country: { type: String }, 28 | 29 | // 用户ua 30 | agent: { type: String, validate: /\S+/ }, 31 | 32 | // 评论产生者 33 | author: { 34 | name: { type: String, required: true, validate: /\S+/ }, 35 | email: { type: String, required: true, validate: /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/ }, 36 | // tslint:disable-next-line:max-line-length 37 | site: { 38 | type: String, 39 | validate: /^((https|http):\/\/)+[A-Za-z0-9]+\.[A-Za-z0-9]+[\/=\?%\-&_~`@[\]\':+!]*([^<>\"\"])*$/ 40 | } 41 | }, 42 | 43 | // 状态 0待审核 1通过正常 2不通过 44 | state: { type: Number, default: 1 }, 45 | 46 | // 发布日期 47 | create_at: { type: Date, default: Date.now() }, 48 | 49 | // 最后修改日期 50 | update_at: { type: Date, default: Date.now() } 51 | }); 52 | 53 | CommentsSchema.plugin(mongoosePaginate); 54 | 55 | CommentsSchema.plugin(autoIncrement.plugin, { 56 | model: 'Comments', 57 | field: 'id', 58 | startAt: 1, 59 | incrementBy: 1 60 | }); 61 | 62 | CommentsSchema.pre('findOneAndUpdate', function(next) { 63 | this.findOneAndUpdate({}, { update_at: Date.now() }); 64 | next(); 65 | }); 66 | -------------------------------------------------------------------------------- /src/module/common/db/index.ts: -------------------------------------------------------------------------------- 1 | import Mongoose from 'mongoose'; 2 | import { config } from '@/config'; 3 | 4 | // TODO: 因为 autoIncrement 插件需要引用 连接后的 connection,而 nestjs 没有把 mongoose 暴露出来 5 | 6 | export const connection = Mongoose.createConnection(config.MONGO_URL); 7 | -------------------------------------------------------------------------------- /src/module/common/email/email.constants.ts: -------------------------------------------------------------------------------- 1 | export const EMAIL_TOKEN = 'EMAIL_TOKEN'; 2 | -------------------------------------------------------------------------------- /src/module/common/email/email.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, OnModuleInit } from '@nestjs/common'; 2 | import { EmailService } from './email.service'; 3 | import { EMAIL_TOKEN } from './email.constants'; 4 | import nodemailer from 'nodemailer'; 5 | import smtpTransport from 'nodemailer-smtp-transport'; 6 | import { config } from '@/config'; 7 | import { BlogLoggerModule } from '../logger/logger.module'; 8 | 9 | @Module({ 10 | imports: [BlogLoggerModule], 11 | providers: [ 12 | EmailService, 13 | { 14 | provide: EMAIL_TOKEN, 15 | useValue: nodemailer.createTransport( 16 | smtpTransport({ 17 | host: 'smtp.qq.com', 18 | secure: true, 19 | port: 465, 20 | auth: { 21 | user: config.EMAIL_ACCOUNT, 22 | pass: config.EMAIL_PASSWORD 23 | } 24 | }) 25 | ) 26 | } 27 | ], 28 | exports: [EmailService] 29 | }) 30 | export class EmailModule implements OnModuleInit { 31 | constructor(private readonly emailService: EmailService) {} 32 | 33 | public onModuleInit() { 34 | this.emailService.verifyClient(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/module/common/email/email.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@nestjs/common'; 2 | import { Transporter } from 'nodemailer'; 3 | import { EMAIL_TOKEN } from './email.constants'; 4 | import { BlogLogger } from '../logger/logger'; 5 | import { MailOptions } from 'nodemailer/lib/stream-transport'; 6 | 7 | @Injectable() 8 | export class EmailService { 9 | private clientIsValid = false; 10 | 11 | constructor( 12 | private readonly loggerService: BlogLogger, 13 | @Inject(EMAIL_TOKEN) private readonly transporter: Transporter 14 | ) {} 15 | 16 | public verifyClient() { 17 | this.transporter.verify((error, success) => { 18 | if (error) { 19 | this.clientIsValid = false; 20 | this.loggerService.error('邮件客户端初始化连接失败,将在一小时后重试'); 21 | setTimeout(this.verifyClient, 1000 * 60 * 60); 22 | } else { 23 | this.clientIsValid = true; 24 | this.loggerService.warn('邮件客户端初始化连接成功,随时可发送邮件'); 25 | } 26 | }); 27 | } 28 | 29 | public sendEmail(options: MailOptions) { 30 | if (!this.clientIsValid) { 31 | console.warn('由于未初始化成功,邮件客户端发送被拒绝'); 32 | return false; 33 | } 34 | 35 | options.from = '"jkchao" '; 36 | 37 | this.transporter.sendMail(options, (error, info) => { 38 | if (error) return this.loggerService.warn('邮件发送失败'); 39 | this.loggerService.log( 40 | JSON.stringify({ 41 | message: '邮件发送成功', 42 | id: info.messageId, 43 | response: info.response 44 | }) 45 | ); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/module/common/logger/logger.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { BlogLogger } from './logger'; 3 | 4 | @Module({ 5 | providers: [BlogLogger], 6 | exports: [BlogLogger] 7 | }) 8 | export class BlogLoggerModule {} 9 | -------------------------------------------------------------------------------- /src/module/common/logger/logger.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, LoggerService } from '@nestjs/common'; 2 | import log4js, { Logger } from 'log4js'; 3 | import { config } from '@/config'; 4 | 5 | @Injectable() 6 | export class BlogLogger implements LoggerService { 7 | private readonly logger: Logger; 8 | 9 | constructor() { 10 | log4js.configure(config.LOG4CONFI); 11 | this.logger = log4js.getLogger(`${config.APP_NAME}: APP`); 12 | } 13 | 14 | public error(message: string, trace?: string) { 15 | this.logger.error(message); 16 | } 17 | public log(message: string) { 18 | this.logger.info(message); 19 | } 20 | public warn(message: string) { 21 | this.logger.warn(message); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/module/common/redis/redis.constant.ts: -------------------------------------------------------------------------------- 1 | export const REDIS_CACHE = 'REDIS_CACHE'; 2 | -------------------------------------------------------------------------------- /src/module/common/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Global } from '@nestjs/common'; 2 | import { RedisService } from './redis.service'; 3 | import { REDIS_CACHE } from './redis.constant'; 4 | import redis from 'redis'; 5 | import { BlogLoggerModule } from '@/module/common/logger/logger.module'; 6 | 7 | @Global() 8 | @Module({ 9 | imports: [BlogLoggerModule], 10 | providers: [ 11 | RedisService, 12 | { 13 | provide: REDIS_CACHE, 14 | useValue: redis 15 | } 16 | ], 17 | exports: [RedisService] 18 | }) 19 | export class RedisModule {} 20 | -------------------------------------------------------------------------------- /src/module/common/redis/redis.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject, Logger } from '@nestjs/common'; 2 | import { REDIS_CACHE } from './redis.constant'; 3 | import redis, { RedisClient } from 'redis'; 4 | import { promisify } from 'util'; 5 | import { BlogLogger } from '@/module/common/logger/logger'; 6 | 7 | @Injectable() 8 | export class RedisService { 9 | // private readonly logger: Logger; 10 | 11 | constructor(@Inject(REDIS_CACHE) private client: RedisClient, private readonly logger: BlogLogger) { 12 | // this.logger = new Logger(); 13 | this.initRedis(); 14 | } 15 | 16 | private initRedis() { 17 | this.logger.log('Redis 连接中...'); 18 | 19 | this.client = redis.createClient(); 20 | 21 | this.client.on('error', _ => { 22 | this.logger.error('Redis 连接失败'); 23 | }); 24 | 25 | this.client.on('ready', _ => { 26 | this.logger.log('Redis 连接成功'); 27 | }); 28 | 29 | this.client.on('reconnecting', _ => { 30 | this.logger.warn('Redis 正在重连'); 31 | }); 32 | } 33 | 34 | public set(key: string, value: any, expire?: number) { 35 | this.logger.log('Redis set Key: ' + key); 36 | return this.client.set(key, JSON.stringify(value)); 37 | } 38 | 39 | public async get(key: string) { 40 | this.logger.log('Redis get Key: ' + key); 41 | const getAsync = promisify(this.client.get).bind(this.client); 42 | return getAsync(key).then((res: string) => JSON.parse(res)); 43 | } 44 | 45 | public remove(key: string) { 46 | this.logger.warn('Redis remove Key: ' + key); 47 | const getAsync = promisify(this.client.del).bind(this.client); 48 | // @ts-ignore 49 | return getAsync(key); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/module/heros/__test__/heros.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | 5 | import { HerosModule } from '../heros.module'; 6 | import { HerosService } from '../heros.service'; 7 | 8 | import { MongooseModule } from '@nestjs/mongoose'; 9 | import { config } from '../../../config'; 10 | 11 | import mongoose from 'mongoose'; 12 | import { GraphQLModule } from '@nestjs/graphql'; 13 | import { EmailService } from '../../common/email/email.service'; 14 | 15 | describe('hero', () => { 16 | let app: INestApplication; 17 | 18 | describe('success', () => { 19 | const heroService = { 20 | searchHero: () => ({}), 21 | deleteHero: () => ({}), 22 | createHero: () => ({}), 23 | updateHero: () => ({}) 24 | }; 25 | 26 | const emailService = { 27 | sendEmail: () => ({}), 28 | verifyClient: () => ({}) 29 | }; 30 | 31 | beforeAll(async () => { 32 | const module = await Test.createTestingModule({ 33 | imports: [ 34 | MongooseModule.forRoot(config.MONGO_URL), 35 | HerosModule, 36 | GraphQLModule.forRoot({ 37 | typePaths: ['./**/*.graphql'], 38 | path: '/api/v2', 39 | context: ({ req, res }: { req: Request; res: Response }) => ({ 40 | request: req 41 | }) 42 | }) 43 | ] 44 | }) 45 | .overrideProvider(HerosService) 46 | .useValue(heroService) 47 | .overrideProvider(EmailService) 48 | .useValue(emailService) 49 | .compile(); 50 | 51 | app = await module.createNestApplication().init(); 52 | }); 53 | 54 | it('getHeros should success', () => { 55 | return request(app.getHttpServer()) 56 | .post('/api/v2') 57 | .send({ 58 | query: ` 59 | { 60 | getHeros { 61 | total 62 | } 63 | } 64 | ` 65 | }) 66 | .expect(200); 67 | }); 68 | 69 | it('deleteHero success', () => { 70 | return request(app.getHttpServer()) 71 | .post('/api/v2') 72 | .send({ 73 | query: ` 74 | mutation { 75 | deleteHero(_id: "59ef13f0a3ad094f5d294da3") { 76 | message 77 | } 78 | } 79 | ` 80 | }) 81 | .expect(200); 82 | }); 83 | 84 | it('createHero success', () => { 85 | return request(app.getHttpServer()) 86 | .post('/api/v2') 87 | .send({ 88 | query: ` 89 | mutation { 90 | createHero(heroInfo: { name: "hha", content: "hah"}) { 91 | content 92 | } 93 | } 94 | ` 95 | }) 96 | .expect({ data: { createHero: { content: null } } }); 97 | }); 98 | 99 | it('updateHero success', () => { 100 | return request(app.getHttpServer()) 101 | .post('/api/v2') 102 | .send({ 103 | query: ` 104 | mutation { 105 | updateHero(heroInfo: {_id: "59ef13f0a3ad094f5d294da3"}) { 106 | content 107 | } 108 | } 109 | ` 110 | }) 111 | .expect(200); 112 | }); 113 | 114 | afterAll(async () => { 115 | await app.close(); 116 | await mongoose.disconnect(); 117 | }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/module/heros/__test__/heros.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { HerosModule } from '../heros.module'; 4 | import { HerosService } from '../heros.service'; 5 | import { getModelToken } from '@nestjs/mongoose'; 6 | 7 | import mongoose from 'mongoose'; 8 | import { HerosInfoDto, QueryHerosDto, UpdateInfoDto } from '../dto/heros.dto'; 9 | 10 | describe('hero', () => { 11 | let heorsService: HerosService; 12 | 13 | // tslint:disable-next-line:class-name 14 | class mockRepository { 15 | public static paginate() { 16 | return { 17 | docs: [] 18 | }; 19 | } 20 | public static findOneAndUpdate() { 21 | return {}; 22 | } 23 | public static findOneAndRemove() { 24 | return {}; 25 | } 26 | 27 | public save() { 28 | return {}; 29 | } 30 | } 31 | 32 | mockRepository.prototype.save = () => ({}); 33 | 34 | beforeAll(async () => { 35 | const module = await Test.createTestingModule({ 36 | imports: [HerosModule] 37 | }) 38 | .overrideProvider(getModelToken('Heros')) 39 | .useValue(mockRepository) 40 | .compile(); 41 | 42 | heorsService = module.get(HerosService); 43 | }); 44 | 45 | it('createHero', async () => { 46 | const obj = { 47 | ip: '95.179.198.236' 48 | } as HerosInfoDto & { ip: string }; 49 | const res = await heorsService.createHero(obj); 50 | expect(res).toMatchObject(new mockRepository().save()); 51 | }); 52 | 53 | it('searchHero', async () => { 54 | const obj = {} as QueryHerosDto; 55 | const res = await heorsService.searchHero(obj); 56 | expect(res).toMatchObject(mockRepository.paginate()); 57 | }); 58 | 59 | it('updateHero', async () => { 60 | const obj = {} as UpdateInfoDto; 61 | const res = await heorsService.updateHero(obj); 62 | expect(res).toMatchObject({}); 63 | }); 64 | 65 | it('deleteHero', async () => { 66 | const res = await heorsService.deleteHero('12345'); 67 | expect(res).toMatchObject({}); 68 | }); 69 | 70 | afterAll(async () => { 71 | await mongoose.disconnect(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/module/heros/dto/heros.dto.ts: -------------------------------------------------------------------------------- 1 | import { StateDto } from '@/common/dto/state.dto'; 2 | 3 | export class QueryHerosDto extends StateDto { 4 | public offset?: number; 5 | public limit?: number; 6 | public keyword?: string; 7 | } 8 | 9 | export class HerosInfoDto extends StateDto { 10 | public name?: string; 11 | public content?: string; 12 | public ip?: string; 13 | public city?: string; 14 | public range?: number[]; 15 | public country?: string; 16 | public agent?: string; 17 | } 18 | 19 | export class UpdateInfoDto extends HerosInfoDto { 20 | public _id: number; 21 | } 22 | -------------------------------------------------------------------------------- /src/module/heros/heros.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | getHeros( 3 | offset: Float 4 | limit: Float 5 | keyword: String 6 | state: State 7 | ): Heros 8 | } 9 | 10 | 11 | type Mutation { 12 | createHero(heroInfo: CreateHeroInput!): HerosItem 13 | updateHero(heroInfo: UpdateHeroInput!): HerosItem 14 | deleteHero(_id: ObjectID!): Message 15 | } 16 | 17 | input CreateHeroInput { 18 | name: String! 19 | content: String! 20 | } 21 | 22 | input UpdateHeroInput { 23 | _id: ObjectID! 24 | name: String 25 | content: String 26 | state: State 27 | } 28 | 29 | type Heros { 30 | pages: Float 31 | total: Float 32 | offset: Float! 33 | limit: Float! 34 | docs: [HerosItem] 35 | } 36 | 37 | type HerosItem { 38 | _id: ObjectID 39 | state: State 40 | content: String 41 | name: String 42 | message: String 43 | ip: String 44 | agent: String 45 | city: String 46 | country: String 47 | range: String 48 | update_at: Date 49 | create_at: Date 50 | } 51 | 52 | scalar ObjectID -------------------------------------------------------------------------------- /src/module/heros/heros.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { HerosService } from './heros.service'; 3 | import { HerosResolver } from './heros.resolvers'; 4 | import { MongooseModule } from '@nestjs/mongoose'; 5 | import { HerosSchema } from './schema/heros.schema'; 6 | import { EmailModule } from '../common/email/email.module'; 7 | 8 | @Module({ 9 | imports: [MongooseModule.forFeature([{ name: 'Heros', schema: HerosSchema }]), EmailModule], 10 | providers: [HerosService, HerosResolver] 11 | }) 12 | export class HerosModule {} 13 | -------------------------------------------------------------------------------- /src/module/heros/heros.resolvers.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, Args, Mutation, Context } from '@nestjs/graphql'; 2 | import { QueryHerosDto, HerosInfoDto, UpdateInfoDto } from './dto/heros.dto'; 3 | import { HerosService } from './heros.service'; 4 | import { Request } from 'express'; 5 | import { EmailService } from '../common/email/email.service'; 6 | import { BadRequestException } from '@nestjs/common'; 7 | import { Permissions } from '@/common/decorator/Permissions.decorator'; 8 | 9 | @Resolver() 10 | export class HerosResolver { 11 | constructor(private readonly herosService: HerosService, private readonly emailService: EmailService) {} 12 | 13 | @Query() 14 | public getHeros(@Args() args: QueryHerosDto, @Context('request') request: Request) { 15 | const token = request.headers.authorization; 16 | if (!token) { 17 | args.state = 1; 18 | } 19 | return this.herosService.searchHero(args); 20 | } 21 | 22 | @Mutation() 23 | @Permissions() 24 | public async deleteHero(@Args('_id') _id: string) { 25 | await this.herosService.deleteHero(_id); 26 | return { message: 'success' }; 27 | } 28 | 29 | @Mutation() 30 | @Permissions() 31 | public async createHero(@Args('heroInfo') info: HerosInfoDto, @Context('request') request: Request) { 32 | // 获取ip地址以及物理地理地址 33 | const ip = ((request.headers['x-forwarded-for'] || 34 | request.headers['x-real-ip'] || 35 | request.connection.remoteAddress || 36 | request.socket.remoteAddress || 37 | request.ip || 38 | request.ips[0]) as string).replace('::ffff:', ''); 39 | 40 | info.ip = ip; 41 | info.agent = request.headers['user-agent'] || info.agent; 42 | 43 | const result = await this.herosService.createHero({ ...info, ip }); 44 | 45 | this.emailService.sendEmail({ 46 | to: 'jkchao@foxmail.com', 47 | subject: '博客有新的留言墙', 48 | text: `来自 ${info.name} 的留言墙:${info.content}`, 49 | html: `

来自 ${info.name} 的留言墙:${info.content}

` 50 | }); 51 | 52 | return { ...result, message: '数据提交成功,请等待审核' }; 53 | } 54 | 55 | @Mutation() 56 | @Permissions() 57 | public updateHero(@Args('heroInfo') info: UpdateInfoDto) { 58 | return this.herosService.updateHero(info); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/module/heros/heros.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { HerosHasId } from './interface/heros.interface'; 3 | import { InjectModel } from '@nestjs/mongoose'; 4 | import { PaginateModel, PaginateOptions } from 'mongoose'; 5 | import geoip from 'geoip-lite'; 6 | import { QueryHerosDto, HerosInfoDto, UpdateInfoDto } from './dto/heros.dto'; 7 | import { StateEnum } from '@/common/enum/state'; 8 | 9 | @Injectable() 10 | export class HerosService { 11 | constructor(@InjectModel('Heros') private readonly herosModel: PaginateModel) {} 12 | 13 | // 添加 14 | public async createHero(hero: HerosInfoDto & { ip: string }) { 15 | const ipLocation = geoip.lookup(hero.ip); 16 | if (ipLocation) { 17 | hero.city = ipLocation.city; 18 | hero.range = ipLocation.range; 19 | hero.country = ipLocation.country; 20 | } 21 | 22 | const res = await new this.herosModel({ ...hero, state: 0 }).save(); 23 | 24 | return ( 25 | res && { 26 | ...res._doc, 27 | state: StateEnum[res.state] 28 | } 29 | ); 30 | } 31 | 32 | // 查 33 | public async searchHero({ offset = 0, limit = 10, keyword = '', state = 0 }: QueryHerosDto) { 34 | // 过滤条件 35 | const options: PaginateOptions = { 36 | sort: { id: -1 }, 37 | offset, 38 | limit 39 | }; 40 | 41 | // 参数 42 | const querys = { 43 | name: new RegExp(keyword || ''), 44 | state 45 | }; 46 | const res = await this.herosModel.paginate(querys, options); 47 | 48 | return { 49 | ...res, 50 | docs: res.docs.map(doc => { 51 | return { 52 | ...doc._doc, 53 | state: StateEnum[doc.state] 54 | }; 55 | }) 56 | }; 57 | } 58 | 59 | // 修改 60 | public async updateHero(hero: UpdateInfoDto) { 61 | const res = await this.herosModel.findOneAndUpdate({ _id: hero._id }, hero, { new: true }); 62 | return ( 63 | res && { 64 | ...res._doc, 65 | state: StateEnum[res.state] 66 | } 67 | ); 68 | } 69 | 70 | // 删除 71 | public deleteHero(_id: string) { 72 | return this.herosModel.findOneAndRemove({ _id }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/module/heros/interface/heros.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | import { HerosInfoDto } from '../dto/heros.dto'; 3 | 4 | export interface HerosHasId extends HerosInfoDto, Document { 5 | _doc: any; 6 | _id: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/module/heros/schema/heros.schema.ts: -------------------------------------------------------------------------------- 1 | import Mongoose from 'mongoose'; 2 | import mongoosePaginate from 'mongoose-paginate'; 3 | import autoIncrement from 'mongoose-auto-increment'; 4 | import { connection } from '@/module/common/db'; 5 | 6 | autoIncrement.initialize(connection); 7 | 8 | export const HerosSchema = new Mongoose.Schema({ 9 | // 名称 10 | name: { type: String }, 11 | 12 | // 内容 13 | content: { type: String, required: true, validate: /\S+/ }, 14 | 15 | // 状态 0 待审核,1 审核通过, 2 审核不通过 16 | state: { type: Number, default: 0 }, 17 | 18 | // ip 19 | ip: { type: String }, 20 | 21 | // ip 物理地址 22 | city: { type: String }, 23 | range: { type: String }, 24 | country: { type: String }, 25 | 26 | // 用户ua 27 | agent: { type: String, validate: /\S+/ }, 28 | 29 | // 发布日期 30 | create_at: { type: Date, default: Date.now() }, 31 | 32 | // 发布日期 33 | update_at: { type: Date, default: Date.now() } 34 | }); 35 | 36 | HerosSchema.plugin(mongoosePaginate); 37 | 38 | HerosSchema.plugin(autoIncrement.plugin, { 39 | model: 'Heros', 40 | field: 'id', 41 | startAt: 1, 42 | incrementBy: 1 43 | }); 44 | 45 | // 时间更新 46 | HerosSchema.pre('findOneAndUpdate', function(next) { 47 | this.findOneAndUpdate({}, { update_at: Date.now() }); 48 | next(); 49 | }); 50 | -------------------------------------------------------------------------------- /src/module/like/dto/like.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | 3 | export enum LikeType { 4 | Article = 1, 5 | Comment 6 | } 7 | 8 | export class LikeDto { 9 | public _id: string; 10 | 11 | @Transform(v => LikeType[v]) 12 | public type: number; 13 | } 14 | -------------------------------------------------------------------------------- /src/module/like/like.graphql: -------------------------------------------------------------------------------- 1 | type Mutation { 2 | postLike(likeInfo: LikeInput!): Message 3 | } 4 | 5 | input LikeInput { 6 | _id: ObjectId! 7 | type: LikeType! 8 | } 9 | 10 | enum LikeType { 11 | Article 12 | Comment 13 | } 14 | 15 | scalar ObjectId -------------------------------------------------------------------------------- /src/module/like/like.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { LikeResolver } from './like.resolver'; 3 | import { LikeService } from './like.service'; 4 | import { ArticlesModule } from '../articles/articles.module'; 5 | import { CommentsModule } from '../comments/comments.module'; 6 | 7 | @Module({ 8 | imports: [ArticlesModule, CommentsModule], 9 | providers: [LikeResolver, LikeService] 10 | }) 11 | export class LikeModule {} 12 | -------------------------------------------------------------------------------- /src/module/like/like.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Mutation, Args } from '@nestjs/graphql'; 2 | import { LikeService } from './like.service'; 3 | import { LikeDto } from './dto/like.dto'; 4 | 5 | @Resolver() 6 | export class LikeResolver { 7 | constructor(private readonly likeService: LikeService) {} 8 | 9 | @Mutation() 10 | public async postLike(@Args('likeInfo') likeInfo: LikeDto) { 11 | await this.likeService.createLike(likeInfo); 12 | return { message: 'success' }; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/module/like/like.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Document } from 'mongoose'; 3 | import { ArticleMongo } from '../articles/interface/articles.interface'; 4 | import { CommentMongo } from '../comments/interface/comments.interface'; 5 | import { ArticlesSercice } from '../articles/articles.service'; 6 | import { CommentsService } from '../comments/comments.service'; 7 | import { LikeDto } from './dto/like.dto'; 8 | 9 | @Injectable() 10 | export class LikeService { 11 | constructor(private readonly articlesService: ArticlesSercice, private readonly commentsService: CommentsService) {} 12 | 13 | public async createLike(info: LikeDto) { 14 | let result: ArticleMongo | CommentMongo | null; 15 | if (info.type === 1) { 16 | result = (await this.articlesService.findOneArticle({ _id: info._id })) as ArticleMongo | null; 17 | if (result) { 18 | result.meta.likes += 1; 19 | await this.articlesService.updateArticle({ _id: result._id }, { $set: { meta: result.meta } }); 20 | } 21 | } else { 22 | result = (await this.commentsService.findComment({ _id: info._id })) as CommentMongo | null; 23 | if (result) { 24 | result.likes += 1; 25 | // @ts-ignore 26 | await this.commentsService.updateComment({ _id: info._id, likes: result.likes }); 27 | } 28 | } 29 | return result; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/module/links/__test__/link.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | 5 | import { LinksModule } from '../links.module'; 6 | import { LinksService } from '../links.service'; 7 | 8 | import { MongooseModule } from '@nestjs/mongoose'; 9 | import { config } from '../../../config'; 10 | 11 | import mongoose from 'mongoose'; 12 | import { GraphQLModule } from '@nestjs/graphql'; 13 | 14 | describe('links', () => { 15 | let app: INestApplication; 16 | 17 | describe('success', () => { 18 | const linksService = { 19 | searchLink: () => ({}), 20 | deleteLink: () => ({}), 21 | createLink: () => ({}), 22 | updateLink: () => ({}) 23 | }; 24 | 25 | beforeAll(async () => { 26 | const module = await Test.createTestingModule({ 27 | imports: [ 28 | MongooseModule.forRoot(config.MONGO_URL), 29 | LinksModule, 30 | GraphQLModule.forRoot({ 31 | typePaths: ['./**/*.graphql'], 32 | path: '/api/v2' 33 | }) 34 | ] 35 | }) 36 | .overrideProvider(LinksService) 37 | .useValue(linksService) 38 | .compile(); 39 | 40 | app = await module.createNestApplication().init(); 41 | }); 42 | 43 | it('getLinks should success', () => { 44 | return request(app.getHttpServer()) 45 | .post('/api/v2') 46 | .send({ 47 | query: ` 48 | { 49 | getLinks { 50 | total 51 | } 52 | } 53 | ` 54 | }) 55 | .expect(200); 56 | }); 57 | 58 | it('deleteLink success', () => { 59 | return request(app.getHttpServer()) 60 | .post('/api/v2') 61 | .send({ 62 | query: ` 63 | mutation { 64 | deleteLink(_id: "59ef13f0a3ad094f5d294da3") { 65 | message 66 | } 67 | } 68 | ` 69 | }) 70 | .expect(200); 71 | }); 72 | 73 | it('createLink success', () => { 74 | return request(app.getHttpServer()) 75 | .post('/api/v2') 76 | .send({ 77 | query: ` 78 | mutation { 79 | createLink(linkInfo: { name: "hha", url: "hah"}) { 80 | url 81 | } 82 | } 83 | ` 84 | }) 85 | .expect(200); 86 | }); 87 | 88 | it('updateLink success', () => { 89 | return request(app.getHttpServer()) 90 | .post('/api/v2') 91 | .send({ 92 | query: ` 93 | mutation { 94 | updateLink(linkInfo: {_id: "59ef13f0a3ad094f5d294da3"}) { 95 | url 96 | } 97 | } 98 | ` 99 | }) 100 | .expect(200); 101 | }); 102 | 103 | afterAll(async () => { 104 | await app.close(); 105 | await mongoose.disconnect(); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /src/module/links/__test__/link.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { LinksModule } from '../links.module'; 4 | import { LinksService } from '../links.service'; 5 | import { getModelToken } from '@nestjs/mongoose'; 6 | 7 | import mongoose from 'mongoose'; 8 | import { LinksMongo, Links, LinksQuery } from '../interface/links.interface'; 9 | 10 | describe('link', () => { 11 | let linksService: LinksService; 12 | 13 | // tslint:disable-next-line:class-name 14 | class mockRepository { 15 | public static paginate() { 16 | return {}; 17 | } 18 | public static findOneAndUpdate() { 19 | return {}; 20 | } 21 | public static findOneAndRemove() { 22 | return {}; 23 | } 24 | 25 | public save() { 26 | return {}; 27 | } 28 | } 29 | 30 | mockRepository.prototype.save = () => ({}); 31 | 32 | // const mockRepository = { 33 | 34 | // }; 35 | beforeAll(async () => { 36 | const module = await Test.createTestingModule({ 37 | imports: [LinksModule] 38 | }) 39 | .overrideProvider(getModelToken('Links')) 40 | .useValue(mockRepository) 41 | .compile(); 42 | 43 | linksService = module.get(LinksService); 44 | }); 45 | 46 | it('createLink', async () => { 47 | const obj = {} as Links; 48 | const res = await linksService.createLink(obj); 49 | expect(res).toMatchObject(new mockRepository().save()); 50 | }); 51 | 52 | it('searchLink', async () => { 53 | const obj = {} as LinksQuery; 54 | const res = await linksService.searchLink(obj); 55 | expect(res).toMatchObject(mockRepository.paginate()); 56 | }); 57 | 58 | it('updateLink', async () => { 59 | const obj = {} as LinksMongo; 60 | const res = await linksService.updateLink(obj); 61 | expect(res).toMatchObject({}); 62 | }); 63 | 64 | it('deleteLink', async () => { 65 | const res = await linksService.deleteLink('12345'); 66 | expect(res).toMatchObject({}); 67 | }); 68 | 69 | afterAll(async () => { 70 | await mongoose.disconnect(); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/module/links/dto/links.dto.ts: -------------------------------------------------------------------------------- 1 | export class QueryLinksDto { 2 | public offset: number; 3 | public limit: number; 4 | public keyword: string; 5 | } 6 | 7 | export class LinksInfoDto { 8 | public _id: string; 9 | public name: string; 10 | public url: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/module/links/interface/links.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | 3 | export interface Links { 4 | name: string; 5 | url: string; 6 | } 7 | 8 | export interface LinksMongo extends Links, Document { 9 | _id: string; 10 | } 11 | 12 | export interface LinksQuery { 13 | offset?: string; 14 | limit?: string; 15 | keyword?: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/module/links/links.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | getLinks( 3 | offset: Float 4 | limit: Float 5 | keyword: String 6 | ): Links 7 | } 8 | 9 | 10 | type Mutation { 11 | createLink(linkInfo: CreateInfoInput!): LinksItem 12 | updateLink(linkInfo: UpdateInfoInput!): LinksItem 13 | deleteLink(_id: ObjectID): Message 14 | } 15 | 16 | input CreateInfoInput { 17 | name: String! 18 | url: String! 19 | } 20 | 21 | input UpdateInfoInput { 22 | _id: ObjectID! 23 | name: String 24 | url: String 25 | } 26 | 27 | type Links { 28 | total: Float 29 | offset: Float! 30 | limit: Float! 31 | docs: [LinksItem] 32 | } 33 | 34 | type LinksItem { 35 | _id: ObjectID 36 | url: String 37 | name: String 38 | } 39 | 40 | scalar ObjectID -------------------------------------------------------------------------------- /src/module/links/links.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { LinksService } from './links.service'; 3 | import { LinksResolver } from './links.resolvers'; 4 | import { MongooseModule } from '@nestjs/mongoose'; 5 | import { LinksSchema } from './schema/links.schema'; 6 | 7 | @Module({ 8 | imports: [MongooseModule.forFeature([{ name: 'Links', schema: LinksSchema }])], 9 | providers: [LinksService, LinksResolver] 10 | }) 11 | export class LinksModule {} 12 | -------------------------------------------------------------------------------- /src/module/links/links.resolvers.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, Args, Mutation, Context } from '@nestjs/graphql'; 2 | import { LinksInfoDto } from './dto/links.dto'; 3 | import { LinksService } from './links.service'; 4 | import { LinksMongo, LinksQuery } from './interface/links.interface'; 5 | import { Permissions } from '@/common/decorator/Permissions.decorator'; 6 | 7 | @Resolver() 8 | export class LinksResolver { 9 | constructor(private linksService: LinksService) {} 10 | 11 | @Query() 12 | public getLinks(@Args() args: LinksQuery) { 13 | return this.linksService.searchLink(args); 14 | } 15 | 16 | @Mutation() 17 | @Permissions() 18 | public async deleteLink(@Args('_id') _id: string) { 19 | await this.linksService.deleteLink(_id); 20 | return { message: 'success' }; 21 | } 22 | 23 | @Mutation() 24 | @Permissions() 25 | public createLink(@Args('linkInfo') info: LinksInfoDto) { 26 | return this.linksService.createLink(info); 27 | } 28 | 29 | @Mutation() 30 | @Permissions() 31 | public updateLink(@Args('linkInfo') info: LinksMongo) { 32 | return this.linksService.updateLink(info); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/module/links/links.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Links, LinksMongo, LinksQuery } from './interface/links.interface'; 3 | import { InjectModel } from '@nestjs/mongoose'; 4 | import { PaginateModel } from 'mongoose'; 5 | 6 | @Injectable() 7 | export class LinksService { 8 | constructor(@InjectModel('Links') private readonly linksModel: PaginateModel) {} 9 | 10 | // 添加 11 | public async createLink(link: Links & { state?: string }) { 12 | return new this.linksModel(link).save(); 13 | } 14 | 15 | // 查 16 | public searchLink(query: LinksQuery) { 17 | // 过滤条件 18 | const options = { 19 | sort: { id: -1 }, 20 | offset: Number(query.offset || 0), 21 | limit: Number(query.limit || 10) 22 | }; 23 | 24 | // 参数 25 | const querys = { name: new RegExp(query.keyword || '') }; 26 | 27 | return this.linksModel.paginate(querys, options); 28 | } 29 | 30 | // 修改 31 | public updateLink(link: LinksMongo) { 32 | return this.linksModel.findOneAndUpdate({ _id: link._id }, link, { new: true }); 33 | } 34 | 35 | // 删除 36 | public deleteLink(_id: string) { 37 | return this.linksModel.findOneAndRemove({ _id }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/module/links/schema/links.schema.ts: -------------------------------------------------------------------------------- 1 | import Mongoose from 'mongoose'; 2 | import mongoosePaginate from 'mongoose-paginate'; 3 | import autoIncrement from 'mongoose-auto-increment'; 4 | import { connection } from '@/module/common/db'; 5 | 6 | autoIncrement.initialize(connection); 7 | 8 | export const LinksSchema = new Mongoose.Schema({ 9 | name: { type: String, required: true, validate: /\S+/ }, 10 | 11 | url: { type: String, required: true }, 12 | 13 | create_at: { type: Date, default: Date.now }, 14 | 15 | update_at: { type: Date, default: Date.now } 16 | }); 17 | 18 | LinksSchema.plugin(mongoosePaginate); 19 | 20 | LinksSchema.plugin(autoIncrement.plugin, { 21 | model: 'Links', 22 | field: 'id', 23 | startAt: 1, 24 | incrementBy: 1 25 | }); 26 | 27 | LinksSchema.pre('findOneAndUpdate', function(next) { 28 | this.findOneAndUpdate({}, { update_at: Date.now() }); 29 | next(); 30 | }); 31 | -------------------------------------------------------------------------------- /src/module/options/__test__/options.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | 5 | import { OptionsModule } from '../options.module'; 6 | import { OptionsService } from '../options.service'; 7 | 8 | import { MongooseModule } from '@nestjs/mongoose'; 9 | import { config } from '../../../config'; 10 | 11 | import mongoose from 'mongoose'; 12 | import { GraphQLModule } from '@nestjs/graphql'; 13 | 14 | describe('options', () => { 15 | let app: INestApplication; 16 | 17 | describe('success', () => { 18 | const optionsService = { 19 | getOptions() { 20 | return { username: 'jkchao', password: '123456' }; 21 | }, 22 | updateOptions() { 23 | return { username: 'jkchao' }; 24 | } 25 | }; 26 | 27 | beforeAll(async () => { 28 | const module = await Test.createTestingModule({ 29 | imports: [ 30 | MongooseModule.forRoot(config.MONGO_URL), 31 | OptionsModule, 32 | GraphQLModule.forRoot({ 33 | typePaths: ['./**/*.graphql'], 34 | path: '/api/v2' 35 | }) 36 | ] 37 | }) 38 | .overrideProvider(OptionsService) 39 | .useValue(optionsService) 40 | .compile(); 41 | 42 | app = await module.createNestApplication().init(); 43 | }); 44 | 45 | it('getOptions should success', () => { 46 | return request(app.getHttpServer()) 47 | .post('/api/v2') 48 | .send({ 49 | query: ` 50 | { 51 | getOptions { 52 | url 53 | } 54 | } 55 | ` 56 | }) 57 | .expect(200); 58 | }); 59 | 60 | it('updateUserInfo success', () => { 61 | return request(app.getHttpServer()) 62 | .post('/api/v2') 63 | .send({ 64 | query: ` 65 | mutation Options { 66 | updateOptions(optionsInfo: {_id: "59ef13f0a3ad094f5d294da3"}) { 67 | url 68 | } 69 | } 70 | ` 71 | }) 72 | .expect(200); 73 | }); 74 | 75 | afterAll(async () => { 76 | await app.close(); 77 | await mongoose.disconnect(); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/module/options/__test__/options.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { OptionsModule } from '../options.module'; 4 | import { OptionsService } from '../options.service'; 5 | import { getModelToken } from '@nestjs/mongoose'; 6 | 7 | import mongoose from 'mongoose'; 8 | 9 | describe('options', () => { 10 | let optionsService: OptionsService; 11 | 12 | describe('create', () => { 13 | const mockRepository = { 14 | findOne() { 15 | return { username: 'jkchao' }; 16 | }, 17 | findByIdAndUpdate() { 18 | return { username: 'jkchao' }; 19 | } 20 | }; 21 | 22 | beforeAll(async () => { 23 | const module = await Test.createTestingModule({ 24 | imports: [OptionsModule] 25 | }) 26 | .overrideProvider(getModelToken('Options')) 27 | .useValue(mockRepository) 28 | .compile(); 29 | 30 | optionsService = module.get(OptionsService); 31 | }); 32 | 33 | it('getOptions', async () => { 34 | const res = await optionsService.getOptions(); 35 | expect(res).toMatchObject(mockRepository.findOne()); 36 | }); 37 | 38 | it('updateOptions', async () => { 39 | const res = await optionsService.updateOptions({ _id: '12345' }); 40 | expect(res).toMatchObject(mockRepository.findByIdAndUpdate()); 41 | }); 42 | 43 | afterAll(async () => { 44 | await mongoose.disconnect(); 45 | }); 46 | }); 47 | 48 | describe('update', () => { 49 | class MockRepository { 50 | public save() { 51 | return { username: 'jkchao' }; 52 | } 53 | } 54 | 55 | beforeAll(async () => { 56 | const module = await Test.createTestingModule({ 57 | imports: [OptionsModule] 58 | }) 59 | .overrideProvider(getModelToken('Options')) 60 | .useValue(MockRepository) 61 | .compile(); 62 | 63 | optionsService = module.get(OptionsService); 64 | }); 65 | 66 | it('updateOptions', async () => { 67 | const res = await optionsService.updateOptions({ url: '12345' }); 68 | expect(res).toMatchObject({ username: 'jkchao' }); 69 | }); 70 | 71 | afterAll(async () => { 72 | await mongoose.disconnect(); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/module/options/interface/options.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | 3 | export interface OptionsBase { 4 | title?: string; 5 | 6 | // 网站副标题 7 | sub_title?: string; 8 | 9 | // 关键字 10 | keyword?: string; 11 | 12 | // 网站描述 13 | descript?: string; 14 | 15 | // 站点地址 16 | url?: string; 17 | 18 | // 网站官邮 19 | email?: string; 20 | 21 | // 备案号 22 | icp?: string; 23 | 24 | // 其他元信息 25 | meta?: { 26 | // 被喜欢次数 27 | likes: number; 28 | }; 29 | } 30 | 31 | export interface OptionsMongo extends Document, OptionsBase {} 32 | 33 | export interface OptionsInfo extends OptionsBase { 34 | _id?: string; 35 | } 36 | -------------------------------------------------------------------------------- /src/module/options/options.graphql: -------------------------------------------------------------------------------- 1 | 2 | 3 | type Query { 4 | getOptions: Options 5 | } 6 | 7 | type Mutation { 8 | updateOptions(optionsInfo: OptionsInput): Options 9 | } 10 | 11 | type Options { 12 | _id: ObjectId 13 | title: String 14 | sub_title: String 15 | keyword: String 16 | descript: String 17 | url: String 18 | email: String 19 | icp: String 20 | meta: Meta 21 | update_at: Date 22 | } 23 | 24 | input OptionsInput { 25 | _id: ObjectId! 26 | title: String 27 | sub_title: String 28 | keyword: String 29 | descript: String 30 | url: String 31 | email: String 32 | icp: String 33 | } 34 | 35 | type Meta { 36 | likes: Int 37 | } 38 | 39 | 40 | scalar ObjectId -------------------------------------------------------------------------------- /src/module/options/options.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { OptionsService } from './options.service'; 3 | import { OptionsResolver } from './options.resolvers'; 4 | import { MongooseModule } from '@nestjs/mongoose'; 5 | import { OptionsSchema } from './schema/options.shema'; 6 | 7 | @Module({ 8 | imports: [MongooseModule.forFeature([{ name: 'Options', schema: OptionsSchema }])], 9 | providers: [OptionsService, OptionsResolver] 10 | }) 11 | export class OptionsModule {} 12 | -------------------------------------------------------------------------------- /src/module/options/options.resolvers.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, Mutation, Args } from '@nestjs/graphql'; 2 | import { OptionsService } from './options.service'; 3 | import { OptionsInfo } from './interface/options.interface'; 4 | import { Permissions } from '@/common/decorator/Permissions.decorator'; 5 | 6 | @Resolver('Options') 7 | export class OptionsResolver { 8 | constructor(private readonly optionsService: OptionsService) {} 9 | 10 | @Query() 11 | public getOptions() { 12 | return this.optionsService.getOptions(); 13 | } 14 | 15 | @Mutation() 16 | @Permissions() 17 | public updateOptions(@Args('optionsInfo') optionsInfo: OptionsInfo) { 18 | return this.optionsService.updateOptions(optionsInfo); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/module/options/options.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model } from 'mongoose'; 4 | import { OptionsMongo, OptionsInfo } from './interface/options.interface'; 5 | 6 | @Injectable() 7 | export class OptionsService { 8 | constructor(@InjectModel('Options') private readonly optionsModel: Model) {} 9 | 10 | public getOptions() { 11 | return this.optionsModel.findOne(); 12 | } 13 | 14 | public updateOptions(options: OptionsInfo) { 15 | if (options._id) { 16 | return this.optionsModel.findByIdAndUpdate(options._id, options, { new: true }); 17 | } 18 | return new this.optionsModel(options).save(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/module/options/schema/options.shema.ts: -------------------------------------------------------------------------------- 1 | import Mongoose from 'mongoose'; 2 | 3 | export const OptionsSchema = new Mongoose.Schema({ 4 | // 网站标题 5 | title: { type: String, required: true }, 6 | 7 | // 网站副标题 8 | sub_title: { type: String, required: true }, 9 | 10 | // 关键字 11 | keyword: { type: String }, 12 | 13 | // 网站描述 14 | descript: String, 15 | 16 | // 站点地址 17 | url: { type: String, required: true }, 18 | 19 | // 网站官邮 20 | email: String, 21 | 22 | // 备案号 23 | icp: String, 24 | 25 | // 其他元信息 26 | meta: { 27 | // 被喜欢次数 28 | likes: { type: Number, default: 0 } 29 | }, 30 | 31 | update_at: { type: Date, default: Date.now } 32 | }); 33 | 34 | OptionsSchema.pre('findOneAndUpdate', function(next) { 35 | this.findOneAndUpdate({}, { update_at: Date.now() }); 36 | next(); 37 | }); 38 | -------------------------------------------------------------------------------- /src/module/qiniu/__test__/qiniu.resolvers.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | 5 | import { QiniuModule } from '../qiniu.module'; 6 | import { QiniuService } from '../qiniu.service'; 7 | 8 | import { MongooseModule } from '@nestjs/mongoose'; 9 | import { config } from '../../../config'; 10 | 11 | import mongoose from 'mongoose'; 12 | import { GraphQLModule } from '@nestjs/graphql'; 13 | 14 | describe('Qiniu', () => { 15 | let app: INestApplication; 16 | 17 | describe('success', () => { 18 | const qiniuService = { 19 | getToken() { 20 | return { token: '1234' }; 21 | } 22 | }; 23 | 24 | beforeAll(async () => { 25 | const module = await Test.createTestingModule({ 26 | imports: [ 27 | MongooseModule.forRoot(config.MONGO_URL), 28 | QiniuModule, 29 | GraphQLModule.forRoot({ 30 | typePaths: ['./**/*.graphql'], 31 | path: '/api/v2' 32 | }) 33 | ] 34 | }) 35 | .overrideProvider(QiniuService) 36 | .useValue(qiniuService) 37 | .compile(); 38 | 39 | app = await module.createNestApplication().init(); 40 | }); 41 | 42 | it('getToken should success', () => { 43 | return request(app.getHttpServer()) 44 | .post('/api/v2') 45 | .send({ 46 | query: ` 47 | { 48 | getQiniu { 49 | token 50 | } 51 | } 52 | ` 53 | }) 54 | .expect(200); 55 | }); 56 | 57 | afterAll(async () => { 58 | await app.close(); 59 | await mongoose.disconnect(); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/module/qiniu/__test__/qiniu.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { QiniuModule } from '../qiniu.module'; 4 | import { QiniuService } from '../qiniu.service'; 5 | 6 | import qn from 'qn'; 7 | 8 | jest.mock('qn', () => { 9 | const create = () => { 10 | return { 11 | uploadToken() { 12 | return ''; 13 | } 14 | }; 15 | }; 16 | return { create }; 17 | }); 18 | 19 | describe('options', () => { 20 | let qiniuService: QiniuService; 21 | 22 | describe('object', () => { 23 | beforeAll(async () => { 24 | const module = await Test.createTestingModule({ 25 | imports: [QiniuModule] 26 | }).compile(); 27 | qiniuService = module.get(QiniuService); 28 | }); 29 | 30 | it('getToken', async () => { 31 | const res = await qiniuService.getToken(); 32 | expect(res).toMatchObject({ token: '' }); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/module/qiniu/qiniu.graphql: -------------------------------------------------------------------------------- 1 | 2 | 3 | type Query { 4 | getQiniu: Token 5 | } 6 | 7 | type Token { 8 | token: String 9 | } -------------------------------------------------------------------------------- /src/module/qiniu/qiniu.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { QiniuResolvers } from './qiniu.resolvers'; 3 | import { QiniuService } from './qiniu.service'; 4 | 5 | @Module({ 6 | providers: [QiniuResolvers, QiniuService] 7 | }) 8 | export class QiniuModule {} 9 | -------------------------------------------------------------------------------- /src/module/qiniu/qiniu.resolvers.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query } from '@nestjs/graphql'; 2 | import { QiniuService } from './qiniu.service'; 3 | 4 | @Resolver('Qiniu') 5 | export class QiniuResolvers { 6 | constructor(private readonly qiniuService: QiniuService) {} 7 | 8 | @Query() 9 | public getQiniu() { 10 | return this.qiniuService.getToken(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/module/qiniu/qiniu.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { config } from '@/config'; 3 | import qn from 'qn'; 4 | 5 | @Injectable() 6 | export class QiniuService { 7 | public getToken() { 8 | const { QINNIU_ACCESSKEY, QINNIU_TOKEN, QINNIU_BUCKET, QINNIU_ORIGIN } = config; 9 | const client = qn.create({ 10 | accessKey: QINNIU_ACCESSKEY, 11 | secretKey: QINNIU_TOKEN, 12 | bucket: QINNIU_BUCKET, 13 | origin: QINNIU_ORIGIN 14 | }); 15 | return { token: client.uploadToken() }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/module/tags/__test__/tags.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | 5 | import { TagsModule } from '../tags.module'; 6 | import { TagsService } from '../tags.service'; 7 | 8 | import { MongooseModule } from '@nestjs/mongoose'; 9 | import { config } from '../../../config'; 10 | 11 | import mongoose from 'mongoose'; 12 | import { GraphQLModule } from '@nestjs/graphql'; 13 | 14 | describe('tags', () => { 15 | let app: INestApplication; 16 | 17 | describe('success', () => { 18 | const tagsService = { 19 | searchTags: () => ({}), 20 | deleteTag: () => ({}), 21 | createTag: () => ({}), 22 | updateTag: () => ({}), 23 | sortTag: () => ({}) 24 | }; 25 | 26 | beforeAll(async () => { 27 | const module = await Test.createTestingModule({ 28 | imports: [ 29 | MongooseModule.forRoot(config.MONGO_URL), 30 | TagsModule, 31 | GraphQLModule.forRoot({ 32 | typePaths: ['./**/*.graphql'], 33 | path: '/api/v2', 34 | context: ({ req, res }: { req: Request; res: Response }) => ({ 35 | request: req 36 | }) 37 | }) 38 | ] 39 | }) 40 | .overrideProvider(TagsService) 41 | .useValue(tagsService) 42 | .compile(); 43 | 44 | app = await module.createNestApplication().init(); 45 | }); 46 | 47 | it('getTags should success', () => { 48 | return request(app.getHttpServer()) 49 | .post('/api/v2') 50 | .set('Authorization', '') 51 | .send({ 52 | query: ` 53 | { 54 | getTags { 55 | total 56 | } 57 | } 58 | ` 59 | }) 60 | .expect(200); 61 | }); 62 | 63 | it('getTags should success with auth', () => { 64 | const token = 65 | // tslint:disable-next-line:max-line-length 66 | 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0aWNrZXQiOiJTVC1zQUdZVU56V3pKV0RGQXZESXJUcm1UUVVoa0FMd2tBT0FwamJDcnFtck1tR1pnS0dKalhHSGxmUEN6VWhoeFJlU0lpWFBRWkJQQVZxaERUalpBeFdVa3l5d0JmWHFBQldQZGhNblNiV0N3bE1LRnlpeHh1QXJlbVJVd3JyUG1BcCIsInVzZXJOYW1lIjoiamFja193Y2d1byIsInVzZXJJZCI6ImYwNGQ0YTdmLTM0YWEtNGEwYi05NzZkLWIxOWNiYWRjZTlkZCIsInByb2ZpbGVzIjpbeyJicmFuZElkIjoiYTI5ZmEyYTctN2UwYS00MDI3LThhM2ItYWM4ZTExYmFkODU5Iiwicm9sZSI6Ik1hc3RlciJ9XSwiZXh0IjoxNTQ1NjMxODczLCJpYXQiOjE1NDUwMjcwNzN9.IVnnszu5_fp_dqt2lOXk0eEESEFE56xmalHgmyhlMnU'; 67 | return request(app.getHttpServer()) 68 | .post('/api/v2') 69 | .set('Authorization', token) 70 | .send({ 71 | query: ` 72 | { 73 | getTags { 74 | total 75 | } 76 | } 77 | ` 78 | }) 79 | .expect(200); 80 | }); 81 | 82 | it('deleteTag success', () => { 83 | return request(app.getHttpServer()) 84 | .post('/api/v2') 85 | .send({ 86 | query: ` 87 | mutation { 88 | deleteTag(_id: "59ef13f0a3ad094f5d294da3") { 89 | message 90 | } 91 | } 92 | ` 93 | }) 94 | .expect({ data: { deleteTag: { message: 'success' } } }); 95 | }); 96 | 97 | it('createTag success', () => { 98 | return request(app.getHttpServer()) 99 | .post('/api/v2') 100 | .send({ 101 | query: ` 102 | mutation { 103 | createTag(tagInfo: { name: "hha"}) { 104 | name 105 | } 106 | } 107 | ` 108 | }) 109 | .expect(200); 110 | }); 111 | 112 | it('sortTag success', () => { 113 | return request(app.getHttpServer()) 114 | .post('/api/v2') 115 | .send({ 116 | query: ` 117 | mutation { 118 | sortTag(ids: ["1", "2"]) { 119 | message 120 | } 121 | } 122 | ` 123 | }) 124 | .expect({ data: { sortTag: { message: 'success' } } }); 125 | }); 126 | 127 | it('updateTag success', () => { 128 | return request(app.getHttpServer()) 129 | .post('/api/v2') 130 | .send({ 131 | query: ` 132 | mutation { 133 | updateTag( 134 | tagInfo: { 135 | _id: "5c1b34bc467e60088e3948db" 136 | } 137 | ) { 138 | name 139 | } 140 | } 141 | ` 142 | }) 143 | .expect(200); 144 | }); 145 | 146 | afterAll(async () => { 147 | await app.close(); 148 | await mongoose.disconnect(); 149 | }); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /src/module/tags/__test__/tags.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { TagsModule } from '../tags.module'; 4 | import { TagsService } from '../tags.service'; 5 | import { getModelToken } from '@nestjs/mongoose'; 6 | 7 | import mongoose from 'mongoose'; 8 | import { ArticlesSercice } from '../../articles/articles.service'; 9 | import { CreateTagDto, TagInfoDto, QueryTagsDto } from '../dto/tag.dto'; 10 | 11 | describe('tag', () => { 12 | let tagsService: TagsService; 13 | // tslint:disable-next-line:class-name 14 | class mockRepository { 15 | public static paginate() { 16 | return { 17 | docs: [{ _id: '123' }] 18 | }; 19 | } 20 | public static findOneAndUpdate() { 21 | return {}; 22 | } 23 | public static findOneAndRemove() { 24 | return {}; 25 | } 26 | 27 | public save() { 28 | return {}; 29 | } 30 | } 31 | 32 | mockRepository.prototype.save = () => ({}); 33 | 34 | beforeAll(async () => { 35 | const module = await Test.createTestingModule({ 36 | imports: [TagsModule] 37 | }) 38 | .overrideProvider(getModelToken('Tags')) 39 | .useValue(mockRepository) 40 | .overrideProvider(getModelToken('Articles')) 41 | .useValue({}) 42 | .overrideProvider(ArticlesSercice) 43 | .useValue({ 44 | aggregate() { 45 | return [ 46 | { 47 | _id: '123' 48 | } 49 | ]; 50 | } 51 | }) 52 | .compile(); 53 | 54 | tagsService = module.get(TagsService); 55 | }); 56 | 57 | it('createTag', async () => { 58 | const obj = {} as CreateTagDto; 59 | const res = await tagsService.createTag(obj); 60 | expect(res).toMatchObject(new mockRepository().save()); 61 | }); 62 | 63 | it('sortTag', async () => { 64 | const res = await tagsService.sortTag(['1', '2']); 65 | expect(res); 66 | }); 67 | 68 | it('updateTag', async () => { 69 | const obj = {} as TagInfoDto; 70 | const res = await tagsService.updateTag(obj); 71 | expect(res).toMatchObject({}); 72 | }); 73 | 74 | it('searchTags', async () => { 75 | const obj = {} as QueryTagsDto; 76 | const res = await tagsService.searchTags(obj, false); 77 | expect(res).toMatchObject({}); 78 | }); 79 | 80 | it('deleteTag', async () => { 81 | const res = await tagsService.deleteTag('12345'); 82 | expect(res).toMatchObject({}); 83 | }); 84 | 85 | afterAll(async () => { 86 | await mongoose.disconnect(); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/module/tags/dto/tag.dto.ts: -------------------------------------------------------------------------------- 1 | export class TagInfoDto { 2 | public _id: string; 3 | public name: string; 4 | public descript: string; 5 | public count: number; 6 | public sort?: number; 7 | } 8 | 9 | export class CreateTagDto { 10 | public name: string; 11 | public descript: string; 12 | } 13 | 14 | export class QueryTagsDto { 15 | public offset?: number; 16 | public limit?: number; 17 | public keyword?: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/module/tags/interface/tags.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | import { TagInfoDto } from '../dto/tag.dto'; 3 | 4 | export interface TagMongo extends TagInfoDto, Document { 5 | _id: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/module/tags/schema/tags.schema.ts: -------------------------------------------------------------------------------- 1 | import Mongoose from 'mongoose'; 2 | import mongoosePaginate from 'mongoose-paginate'; 3 | import autoIncrement from 'mongoose-auto-increment'; 4 | import { connection } from '@/module/common/db'; 5 | 6 | autoIncrement.initialize(connection); 7 | 8 | export const TagSchema = new Mongoose.Schema({ 9 | name: { type: String, required: true, validate: /\S+/ }, 10 | 11 | descript: String, 12 | 13 | create_at: { type: Date, default: Date.now() }, 14 | 15 | update_at: { type: Date, default: Date.now() }, 16 | 17 | sort: { type: Number, default: 0 } 18 | }); 19 | 20 | TagSchema.plugin(mongoosePaginate); 21 | 22 | TagSchema.plugin(autoIncrement.plugin, { 23 | model: 'Tags', 24 | field: 'id', 25 | startAt: 1, 26 | incrementBy: 1 27 | }); 28 | 29 | // 时间更新 30 | TagSchema.pre('findOneAndUpdate', function(next) { 31 | this.findOneAndUpdate({}, { update_at: Date.now() }); 32 | next(); 33 | }); 34 | -------------------------------------------------------------------------------- /src/module/tags/tags.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | getTags( 3 | offset: Float 4 | limit: Float 5 | keyword: String 6 | state: Float 7 | ): Tags 8 | } 9 | 10 | type Mutation { 11 | sortTag(ids: [ObjectID]!): Message 12 | createTag(tagInfo: CreateTagInput!): TagsItem 13 | updateTag(tagInfo: UpdateTagInput!): TagsItem 14 | deleteTag(_id: ObjectID!): Message 15 | } 16 | 17 | input CreateTagInput { 18 | name: String! 19 | descript: String 20 | } 21 | 22 | input UpdateTagInput { 23 | _id: ObjectID! 24 | name: String 25 | descript: String 26 | } 27 | 28 | type Tags { 29 | total: Float 30 | offset: Float! 31 | limit: Float! 32 | docs: [TagsItem] 33 | } 34 | 35 | type TagsItem { 36 | _id: ObjectID! 37 | id: Float 38 | name: String 39 | descript: String 40 | sort: Float 41 | count: Float 42 | update_at: Date 43 | create_at: Date 44 | } 45 | 46 | scalar ObjectID -------------------------------------------------------------------------------- /src/module/tags/tags.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { TagSchema } from './schema/tags.schema'; 4 | import { TagsResolver } from './tags.resolver'; 5 | import { TagsService } from './tags.service'; 6 | import { ArticlesModule } from '../articles/articles.module'; 7 | 8 | @Module({ 9 | imports: [MongooseModule.forFeature([{ name: 'Tags', schema: TagSchema }]), ArticlesModule], 10 | providers: [TagsResolver, TagsService] 11 | }) 12 | export class TagsModule {} 13 | -------------------------------------------------------------------------------- /src/module/tags/tags.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, Mutation, Args, Context } from '@nestjs/graphql'; 2 | import { TagsService } from './tags.service'; 3 | import { CreateTagDto, TagInfoDto, QueryTagsDto } from './dto/tag.dto'; 4 | import { Request } from 'express'; 5 | 6 | @Resolver() 7 | export class TagsResolver { 8 | constructor(private readonly tagService: TagsService) {} 9 | 10 | @Query() 11 | public async getTags(@Args() query: QueryTagsDto, @Context('request') request: Request) { 12 | const token = request.headers.authorization; 13 | let isAuth = true; 14 | if (!token) { 15 | isAuth = false; 16 | } 17 | return this.tagService.searchTags(query, isAuth); 18 | } 19 | 20 | @Mutation() 21 | public createTag(@Args('tagInfo') info: CreateTagDto) { 22 | return this.tagService.createTag(info); 23 | } 24 | 25 | @Mutation() 26 | public updateTag(@Args('tagInfo') info: TagInfoDto) { 27 | return this.tagService.updateTag(info); 28 | } 29 | 30 | @Mutation() 31 | public async sortTag(@Args('ids') ids: string[]) { 32 | await this.tagService.sortTag(ids); 33 | return { message: 'success' }; 34 | // .. 35 | } 36 | 37 | @Mutation() 38 | public async deleteTag(@Args('_id') _id: string) { 39 | await this.tagService.deleteTag(_id); 40 | return { message: 'success' }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/module/tags/tags.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { PaginateModel, PaginateResult } from 'mongoose'; 4 | import { TagMongo } from './interface/tags.interface'; 5 | import { TagInfoDto, CreateTagDto, QueryTagsDto } from './dto/tag.dto'; 6 | import { ArticlesSercice } from '../articles/articles.service'; 7 | 8 | @Injectable() 9 | export class TagsService { 10 | constructor( 11 | @InjectModel('Tags') private readonly tagsModel: PaginateModel, 12 | private readonly articlesService: ArticlesSercice 13 | ) {} 14 | 15 | public async searchTags({ limit = 10, offset = 0, keyword = '' }: QueryTagsDto, isAuth: boolean) { 16 | const options = { 17 | sort: { sort: 1 }, 18 | offset, 19 | limit 20 | }; 21 | 22 | const querys = { 23 | name: new RegExp(keyword) 24 | }; 25 | 26 | const tags = await this.tagsModel.paginate(querys, options); 27 | 28 | const tagClone: PaginateResult = JSON.parse(JSON.stringify(tags)); 29 | 30 | // 查找文章中标签聚合 31 | let $match = {}; 32 | 33 | // 前台请求时,只有已经发布的和公开 34 | if (!isAuth) $match = { state: 1, publish: 1 }; 35 | 36 | const article = await this.articlesService.aggregate([ 37 | { $match }, 38 | { $unwind: '$tag' }, 39 | { 40 | $group: { 41 | _id: '$tag', 42 | num_tutorial: { $sum: 1 } 43 | } 44 | } 45 | ]); 46 | if (article) { 47 | tagClone.docs.forEach(t => { 48 | const finded = article.find(c => String(c._id) === String(t._id)); 49 | t.count = finded ? finded.num_tutorial : 0; 50 | }); 51 | } 52 | 53 | return tagClone; 54 | } 55 | 56 | public createTag(tag: CreateTagDto) { 57 | return new this.tagsModel(tag).save(); 58 | } 59 | 60 | public async sortTag(ids: string[]) { 61 | for (let i = 0; i < ids.length; i++) { 62 | await this.updateTag({ 63 | _id: ids[i], 64 | sort: i + 1 65 | }); 66 | } 67 | } 68 | 69 | public updateTag(tag: Partial) { 70 | return this.tagsModel.findOneAndUpdate({ _id: tag._id }, tag, { new: true }); 71 | } 72 | 73 | public deleteTag(_id: string) { 74 | return this.tagsModel.findOneAndRemove({ _id }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/typings/express-rate-limit.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'express-rate-limit' { 2 | import { RequestHandler, Request, Response } from 'express'; 3 | namespace limit { 4 | interface Store { 5 | /** 6 | * Increments the value in the underlying store for the given key. 7 | */ 8 | incr(key: string, cb: (err?: Error, hits?: number) => void): void; 9 | /** 10 | * Decrements the value in the underlying store for the given key. 11 | * Used only and must be implemented when skipFailedRequests is true. 12 | */ 13 | decrement?(key: string): void; 14 | /** 15 | * Resets a value with the given key. 16 | */ 17 | resetKey(key: string): void; 18 | } 19 | 20 | interface RateLimitHandler extends RequestHandler { 21 | /** 22 | * Proxy function that calls `Store.resetKey` 23 | */ 24 | resetKey(key: string): void; 25 | } 26 | 27 | interface RateLimitOptions { 28 | /** 29 | * how long to keep records of requests in memory. 30 | * Is not used for actually checking anything if `store` is set. 31 | * Will be used when `headers` is enabled. 32 | */ 33 | windowMs?: number; 34 | /** 35 | * how many requests to allow through before starting to delay responses 36 | */ 37 | delayAfter?: number; 38 | /** 39 | * base delay applied to the response - multiplied by number of recent hits for the same key. 40 | */ 41 | delayMs?: number; 42 | /** 43 | * max number of recent connections during `window` milliseconds before sending a 429 response 44 | */ 45 | max?: number; 46 | /** 47 | * Message to be send on ratelimit hit. 48 | * Ignored if `handler` is set. 49 | * If request is `json`, the message will be send as `{ message: MSGOBJECT }` 50 | * If request is `html`, the message will not be altered. 51 | * Default:'Too many requests, please try again later.', 52 | */ 53 | message?: string | object; 54 | /** 55 | * Status code to be send on ratelimit hit. 56 | * Ignored if `handler` is set. 57 | * Defaults to 429 (RFC) 58 | */ 59 | statusCode?: number; 60 | /** 61 | * Send custom rate limit header with limit and remaining. 62 | * Ignored if `handler` is set. 63 | * Defaults to true. 64 | */ 65 | headers?: boolean; 66 | /** 67 | * Do not count failed requests (status >= 400). 68 | * Store must implement `decrement` method for this to work. 69 | * Defaults to false. 70 | */ 71 | skipFailedRequests?: boolean; 72 | /** 73 | * allows to create custom keys. 74 | * Defaults to request ip. 75 | */ 76 | keyGenerator?(req: Request, res: Response): string; 77 | /** 78 | * allows to skip certain requests. 79 | * Defaults to skipping none. 80 | */ 81 | skip?(req: Request, res: Response): boolean; 82 | /** 83 | * allows to skip certain requests. 84 | * Defaults to skipping none. 85 | */ 86 | skip?(req: Request, res: Response): boolean; 87 | /** 88 | * Can be overriden to implement custom response behaviour. 89 | * See other options to see what this does. 90 | */ 91 | handler?: RequestHandler; 92 | /** 93 | * Called each time the limit is reached. 94 | * You can use it to debug/log. 95 | * Defaults to doing nothing. 96 | */ 97 | onLimitReached?(req: Request, res: Response, opts: RateLimitOptions): void; 98 | 99 | /** 100 | * Store to use for ratelimiting. 101 | * Defaults to in memory. 102 | */ 103 | store?: Store; 104 | } 105 | } 106 | function limit(options?: limit.RateLimitOptions): limit.RateLimitHandler; 107 | 108 | export = limit; 109 | } 110 | -------------------------------------------------------------------------------- /src/typings/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cache-manager-redis-store'; 2 | 3 | declare module 'qn'; 4 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppModule } from './../src/app.module'; 4 | import { INestApplication } from '@nestjs/common'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeAll(async () => { 10 | const moduleFixture = await Test.createTestingModule({ 11 | imports: [AppModule] 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig-paths-bootstrap.js: -------------------------------------------------------------------------------- 1 | const tsConfig = require('./tsconfig.json'); 2 | const tsConfigPaths = require('tsconfig-paths'); 3 | 4 | const baseUrl = './dist'; // Either absolute or relative path. If relative it's resolved to current working directory. 5 | tsConfigPaths.register({ 6 | baseUrl, 7 | paths: tsConfig.compilerOptions.paths 8 | }); 9 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noImplicitAny": false, 5 | "removeComments": true, 6 | "lib": ["dom", "es5", "es6", "es7", "es2015.promise", "esnext.asynciterable"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "exclude": ["node_modules", "**/*.spec.ts"], 4 | "compilerOptions": { 5 | "esModuleInterop": true, 6 | "rootDir": "src", 7 | "baseUrl": "./src", 8 | "paths": { 9 | "@/*": ["./*"] 10 | }, 11 | 12 | "outDir": "./dist", 13 | "types": ["node"], 14 | "typeRoots": ["node_modules/@types"], 15 | "strictPropertyInitialization": false, 16 | "emitDecoratorMetadata": true, 17 | "strict": true, 18 | "allowSyntheticDefaultImports": true, 19 | "experimentalDecorators": true, 20 | "allowJs": true, 21 | "module": "commonjs", 22 | "target": "es6", 23 | "moduleResolution": "node", 24 | "noImplicitAny": true, 25 | "lib": ["dom", "es5", "es6", "es7", "es2015.promise"], 26 | "sourceMap": true, 27 | "pretty": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["jest", "node"] 5 | }, 6 | "include": ["**/*.spec.ts", "**/*.d.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "linterOptions": { 5 | "exclude": ["node_modules/**"] 6 | }, 7 | "jsRules": {}, 8 | "rules": { 9 | // 允许使用 console 10 | "no-console": false, 11 | 12 | // 代码块的内容不能为空,禁止空代码块 13 | "no-empty": true, 14 | 15 | // 单引号 16 | "quotemark": false, 17 | 18 | "one-variable-per-declaration": false, 19 | 20 | // 不加分号 21 | "semicolon": [true, "always"], 22 | 23 | // 如果一个变量声明后不再被修改,那么应使用 const 来声明该变量 24 | "prefer-const": true, 25 | 26 | // 设置成员对象的访问权限(public,private,protect) 27 | "member-access": true, 28 | 29 | // 设置修饰符顺序 30 | "member-ordering": [ 31 | true, 32 | { 33 | "order": [ 34 | "private-static-field", 35 | "private-static-method", 36 | "protected-static-field", 37 | "protected-static-method", 38 | "public-static-field", 39 | "public-static-method", 40 | "private-instance-field", 41 | "protected-instance-field", 42 | "public-instance-field", 43 | "private-constructor", 44 | "protected-constructor", 45 | "public-constructor", 46 | "private-instance-method", 47 | "protected-instance-method", 48 | "public-instance-method" 49 | ] 50 | } 51 | ], 52 | 53 | // 不允许空接口 54 | "no-empty-interface": false, 55 | 56 | // 不允许修改方法输入参数 57 | "no-parameter-reassignment": false, 58 | 59 | // 如果for循环中没有使用索引,建议是使用for-of 60 | "prefer-for-of": false, 61 | 62 | // 不允许没有Promise的情况下使用await 63 | "await-promise": true, 64 | 65 | // if/for/do/while强制使用大括号 同行除外 66 | "curly": [true, "ignore-same-line"], 67 | 68 | // f使用for in语句时,强制进行hasOwnProperty检查 69 | "forin": true, 70 | 71 | // 不允许使用arguments.callee, 72 | "no-arg": true, 73 | 74 | // 不允许使用特殊运算符 &, &=, |, |=, ^, ^=, <<, <<=, >>, >>=, >>>, >>>=, ~ 75 | "no-bitwise": true, 76 | 77 | // do while/for/if/while 语句中将会对例如if(a=b)进行检查 78 | "no-conditional-assignment": true, 79 | 80 | // no-eval 81 | "no-eval": true, 82 | 83 | // 不允许使用 var 84 | "no-var-keyword": true, 85 | 86 | // 不允许 debugger 87 | "no-debugger": true, 88 | 89 | // 不允许super() 两次使用在构造函数中 90 | "no-duplicate-super": true, 91 | 92 | // 只允许在模板字符串中使用${ 93 | "no-invalid-template-strings": true, 94 | 95 | // 不允许使用null,使用undefined代替null,指代空指针对象 96 | // "no-null-keyword": true, 97 | 98 | // 不允许 array 中有空元素 99 | "no-sparse-arrays": true, 100 | 101 | // 不允许throw一个字符串 102 | "no-string-throw": true, 103 | 104 | // 不允许在finally语句中使用return/continue/break/throw 105 | "no-unsafe-finally": true, 106 | 107 | // 不允许使用未使用的表达式 108 | "no-unused-expression": false, 109 | 110 | // 在使用前必须声明 111 | "no-use-before-declare": true, 112 | 113 | // parseInt时,必须输入radix精度参数 114 | "radix": true, 115 | 116 | // 不允许自动类型转换,如果已设置不允许使用关键字var该设置无效 117 | "restrict-plus-operands": true, 118 | 119 | // 必须使用恒等号,进行等于比较 120 | "triple-equals": true, 121 | 122 | // 只允许使用isNaN方法检查数字是否有效 123 | "use-isnan": true, 124 | 125 | // 每行开始以4个空格符开始 126 | "indent": [true, "spaces", 2], 127 | 128 | // 禁止在一个文件内,多次引用同一module 129 | "no-duplicate-imports": true, 130 | 131 | // 建议使用T[]方式声明一个数组对象 132 | "array-type": [true, "array"], 133 | 134 | // 类名以大驼峰格式命名 135 | "class-name": true, 136 | 137 | // 定义注释格式 138 | "comment-format": [true, "check-space"], 139 | 140 | // import关键字后加空格 141 | "import-spacing": true, 142 | 143 | // interface必须以I开头 144 | "interface-name": false, 145 | 146 | // 注释基于jsdoc风格 147 | "jsdoc-format": true, 148 | 149 | // 调用构造函数时需要用括号 150 | "new-parens": true, 151 | 152 | // 不允许有空行 153 | "no-consecutive-blank-lines": [true, 2], 154 | 155 | "whitespace": [ 156 | false, 157 | "check-branch", 158 | // "check-decl", 159 | "check-module", 160 | "check-separator", 161 | "check-rest-spread", 162 | "check-type", 163 | "check-type-operator" 164 | ], 165 | 166 | // 不允许空格结尾 167 | "no-trailing-whitespace": true, 168 | 169 | // 不允许没有必要的初始化 170 | "no-unnecessary-initializer": true, 171 | 172 | // 定义变量命名规则 173 | "variable-name": [ 174 | true, 175 | "check-format", 176 | "allow-leading-underscore", 177 | "allow-trailing-underscore", 178 | "allow-pascal-case", 179 | "ban-keywords" 180 | ], 181 | 182 | // 句尾逗号 183 | "trailing-comma": [ 184 | true, 185 | { 186 | "multiline": "never", 187 | "singleline": "never" 188 | } 189 | ], 190 | 191 | "max-classes-per-file": false, 192 | 193 | // 不允许case段落中在没有使用breack的情况下,在新启一段case逻辑 194 | "no-switch-case-fall-through": true, 195 | 196 | "no-angle-bracket-type-assertion": false, 197 | 198 | "one-line": [false], 199 | 200 | // 可以对象字面量断言 201 | "no-object-literal-type-assertion": false, 202 | 203 | // function paren 前需要有空格 204 | "space-before-function-paren": false, 205 | 206 | // 可以导入子包 207 | "no-submodule-imports": false, 208 | 209 | // 可以覆盖变量命名 210 | "no-shadowed-variable": false, 211 | 212 | // 导入包时,不需要按字母排序 213 | "ordered-imports": false, 214 | 215 | "object-literal-key-quotes": false, 216 | 217 | "object-literal-sort-keys": false, 218 | 219 | // 允许带一个参数的箭头函数省去括号 220 | "arrow-parens": [true, "ban-single-arg-parens"], 221 | 222 | // 对齐方式 223 | "align": false, 224 | 225 | // 允许使用 namespace 226 | "no-namespace": false 227 | }, 228 | "rulesDirectory": [] 229 | } 230 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | 5 | module.exports = { 6 | entry: ['webpack/hot/poll?1000', './src/main.hmr.ts'], 7 | watch: true, 8 | target: 'node', 9 | externals: [ 10 | nodeExternals({ 11 | whitelist: ['webpack/hot/poll?1000'] 12 | }) 13 | ], 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.tsx?$/, 18 | use: 'ts-loader', 19 | exclude: /node_modules/ 20 | } 21 | ] 22 | }, 23 | mode: 'development', 24 | resolve: { 25 | extensions: ['.tsx', '.ts', '.js'] 26 | }, 27 | plugins: [new webpack.HotModuleReplacementPlugin()], 28 | output: { 29 | path: path.join(__dirname, 'dist'), 30 | filename: 'server.js' 31 | } 32 | }; 33 | --------------------------------------------------------------------------------