├── .dockerignore ├── .env ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .yarnrc ├── Dockerfile ├── README.md ├── docker-compose.yml ├── files └── .gitkeep ├── nest-cli.json ├── package.json ├── prisma ├── schema.prisma └── seed.ts ├── src ├── app.module.ts ├── constants │ ├── auth.ts │ └── list.ts ├── core │ ├── filters │ │ └── exceptions.filter.ts │ ├── guards │ │ ├── jwt-auth.guard.ts │ │ └── role-auth.guard.ts │ ├── interceptors │ │ └── transform.interceptor.ts │ └── pipes │ │ └── validation.pipe.ts ├── database │ ├── database.module.ts │ ├── database.service.ts │ ├── elasticsearch.service.ts │ └── redis.service.ts ├── gateways │ ├── events.gateway.ts │ └── gateways.module.ts ├── main.ts ├── modules │ ├── auth │ │ ├── auth.controller.ts │ │ ├── auth.module.ts │ │ ├── auth.service.ts │ │ ├── dto │ │ │ ├── index.ts │ │ │ ├── login.dto.ts │ │ │ └── payload.dto.ts │ │ └── strategies │ │ │ └── jwt.strategy.ts │ ├── files │ │ ├── files.controller.ts │ │ └── files.module.ts │ ├── health │ │ ├── health.controller.ts │ │ ├── health.indicator.ts │ │ └── health.module.ts │ └── users │ │ ├── dto │ │ ├── create-user.dto.ts │ │ ├── index.ts │ │ └── update-user.dto.ts │ │ ├── users.controller.ts │ │ ├── users.module.ts │ │ └── users.service.ts ├── tasks │ ├── tasks.module.ts │ └── tasks.service.ts ├── types │ └── global.d.ts └── utils │ └── encrypt.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | dist 6 | logs 7 | .env -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL="mysql://root:root@127.0.0.1:3006/nest_starter" 2 | SERVER_PORT=3000 3 | REDIS_HOST="127.0.0.1" 4 | REDIS_PORT=3001 5 | REDIS_PASSWORD="123456" -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | 'max-len': [ 20 | 'error', 21 | { 22 | code: 150 23 | } 24 | ], 25 | '@typescript-eslint/interface-name-prefix': 'off', 26 | '@typescript-eslint/explicit-function-return-type': 'off', 27 | '@typescript-eslint/explicit-module-boundary-types': 'off', 28 | '@typescript-eslint/no-explicit-any': 'off', 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.log 4 | logs 5 | .data 6 | .cache -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 150 5 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach NestJS WS", 11 | "port": 9229, 12 | "restart": true, 13 | "stopOnEntry": false, 14 | "protocol": "inspector" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [ 3 | 150 4 | ], 5 | "explorer.confirmDragAndDrop": false, 6 | "html.format.wrapAttributes": "auto", 7 | "typescript.updateImportsOnFileMove.enabled": "always", 8 | "javascript.updateImportsOnFileMove.enabled": "always", 9 | "explorer.confirmDelete": true, 10 | "git.ignoreLegacyWarning": true, 11 | "terminal.integrated.shell.osx": "/bin/zsh", 12 | "editor.suggestSelection": "first", 13 | "editor.tabSize": 2, 14 | "files.watcherExclude": { 15 | "**/.git/objects/**": true, 16 | "**/.git/subtree-cache/**": true, 17 | "**/node_modules/*/**": true, 18 | "**/dist/*/**": true, 19 | "**/coverage/*/**": true 20 | }, 21 | "editor.formatOnSave": true, 22 | "editor.codeActionsOnSave": { 23 | "source.fixAll.eslint": true, 24 | "source.fixAll.tslint": true, 25 | "source.fixAll.stylelint": true, 26 | }, 27 | "[markdown]": { 28 | "editor.formatOnSave": false 29 | }, 30 | "[javascript]": { 31 | "editor.formatOnSave": false 32 | }, 33 | "[json]": { 34 | "editor.formatOnSave": false 35 | }, 36 | "[jsonc]": { 37 | "editor.formatOnSave": false 38 | }, 39 | "files.associations": { 40 | "*.conf": "shellscript", 41 | "*.json": "jsonc", 42 | ".prettierrc": "json", 43 | ".stylelintrc": "json", 44 | ".htmlhintrc": "json" 45 | }, 46 | "cSpell.words": [ 47 | "metatype", 48 | "nestjs", 49 | "prebuild", 50 | "setex", 51 | "typeorm", 52 | "websockets" 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "start:dev", 7 | "problemMatcher": [], 8 | "label": "npm: start:dev", 9 | "detail": "nest start --watch" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.npmmirror.com/" 2 | disturl "https://npmmirror.com/mirrors/node/" 3 | sass_binary_site "https://npmmirror.com/mirrors/node-sass/" 4 | phantomjs_cdnurl "https://npmmirror.com/mirrors/phantomjs/" 5 | electron_mirror "https://npmmirror.com/mirrors/electron/" 6 | chromedriver_cdnurl "https://npmmirror.com/mirrors/chromedriver/" 7 | operadriver_cdnurl "https://npmmirror.com/mirrors/operadriver/" 8 | selenium_cdnurl "https://npmmirror.com/mirrors/selenium/" 9 | node_inspector_cdnurl "https://npmmirror.com/mirrors/node-inspector/" 10 | fsevents_binary_host_mirror "http://npmmirror.com/mirrors/fsevents/" 11 | puppeteer_download_host "https://npmmirror.com/mirrors/" 12 | sentrycli_cdnurl "https://npmmirror.com/mirrors/sentry-cli/" 13 | sharp_binary_host "https://npmmirror.com/mirrors/sharp/" 14 | sharp_libvips_binary_host "https://npmmirror.com/mirrors/sharp-libvips/" 15 | sqlite3_binary_site "https://npmmirror.com/mirrors/sqlite3/" 16 | python_mirror "https://npmmirror.com/mirrors/python/" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package.json ./ 6 | COPY yarn.lock ./ 7 | 8 | RUN yarn 9 | 10 | COPY . . 11 | 12 | RUN npm run build 13 | 14 | CMD [ "node", "./dist/src/main.js" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nest Starter 2 | 3 | ## 功能清单 4 | - [x] 通用功能 5 | - [x] 请求参数校验 6 | - [x] 响应结构包装 7 | - [x] 异常统一处理 8 | - [x] 接口文档生成:访问 `/docs` 即可看到 Swagger 文档 9 | - [x] 统一日志收集:使用 [winston](https://github.com/winstonjs/winston),每日切割保存 10 | - [x] 响应安全处理:基于 [helmet](https://helmetjs.github.io/) 11 | - [x] 代码风格校验 12 | - [x] 数据存储 13 | - [x] MySQL + [Prisma](https://www.prisma.io/docs/concepts/components/prisma-client) 14 | - [x] Redis:默认启用,仅用在令牌缓存,可以轻松去除 15 | - [x] 权限验证 16 | - [x] 登录校验:基于 JWT 17 | - [x] 角色校验 18 | - [x] 通用接口 19 | - [x] 用户模块 20 | - [x] 新建用户 21 | - [x] 查找用户 22 | - [x] 删除用户 23 | - [x] 修改用户 24 | - [x] 登录接口 25 | - [x] 文件模块 26 | - [x] 文件上传 27 | - [x] 文件删除 28 | - [x] 文件访问 29 | - [x] 健康检查 30 | - [x] 接口健康 31 | - [x] 数据库健康 32 | - [x] 消息推送:基于 [socket.io](https://socket.io/) 33 | - [x] 定时任务:理论上定时任务应该单独拆分服务,这里演示方便和业务模块放在一起 34 | 35 | 36 | ## 安装依赖 37 | ```bash 38 | $ yarn 39 | ``` 40 | 41 | ## 环境变量 42 | 43 | | 变量名 | 用处| 示例| 44 | | ---- | ---- | ---- | 45 | | DATABASE_URL | MYSQL 连接 | `mysql://root:root@127.0.0.1:3006/nest_starter` 46 | | SERVER_PORT | 服务端口 | `3000` | 47 | | REDIS_HOST | Redis 地址 | `127.0.0.1` | 48 | | REDIS_PORT | Redis 端口 | `31003`| 49 | | REDIS_PASSWORD| Redis 密码 | `123456` | 50 | 51 | ## 开发环境 52 | 53 | ```bash 54 | $ yarn dev 55 | ``` 56 | 57 | > 请提前准备好 MySQL 和 Redis,可以使用 docker-compose.yaml 直接启动,并使用 [Prisma](https://www.prisma.io/docs/reference/api-reference/command-reference#prisma-migrate) 初始化 MySQL 表结构 58 | 59 | 60 | ## 生产部署 61 | 直接使用 [docker-compose.yml](./docker-compose.yml) -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | # api: 5 | # restart: always 6 | # image: [换成你的镜像名] 7 | # container_name: nest_starter_api 8 | # volumes: 9 | # - ./files:/usr/src/app/files 10 | # - ./logs:/usr/src/app/logs 11 | # environment: 12 | # SERVER_PORT: 3000 13 | # DATABASE_URL: mysql://root:root@mysql:3306/nest_starter 14 | # REDIS_HOST: redis 15 | # REDIS_PORT: 6379 16 | # REDIS_PASSWORD: eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81 17 | # depends_on: 18 | # - mysql 19 | # ports: 20 | # - 31000:3000 21 | 22 | mysql: 23 | restart: always 24 | image: mysql:5.7.18 25 | container_name: nest_starter_mysql 26 | volumes: 27 | - ./.data:/var/lib/mysql 28 | environment: 29 | MYSQL_DATABASE: nest_starter 30 | MYSQL_ROOT_PASSWORD: root 31 | TZ: Asia/Shanghai 32 | command: ['mysqld', '--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci', '--skip-character-set-client-handshake'] 33 | ports: 34 | - 31001:3306 35 | 36 | redis: 37 | restart: always 38 | image: redis:6.2-alpine 39 | container_name: nest_starter_redis 40 | ports: 41 | - 31003:6379 42 | command: redis-server --save 20 1 --loglevel warning --requirepass eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81 43 | volumes: 44 | - ./.cache:/data 45 | 46 | # elasticsearch: 47 | # image: elasticsearch:7.8.0 48 | # container_name: nest_starter_elasticsearch 49 | # ports: 50 | # - 31004:9200 51 | # - 31005:9300 52 | # environment: 53 | # # 开启内存锁定 54 | # - bootstrap.memory_lock=true 55 | # - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 56 | # # 指定单节点启动 57 | # - discovery.type=single-node 58 | # ulimits: 59 | # # 取消内存相关限制 用于开启内存锁定 60 | # memlock: 61 | # soft: -1 62 | # hard: -1 63 | # volumes: 64 | # - ./elasticsearch/data:/usr/share/elasticsearch/data 65 | # - ./elasticsearch/logs:/usr/share/elasticsearch/logs 66 | # - ./elasticsearch/plugins:/usr/share/elasticsearch/plugins 67 | 68 | # kibana: 69 | # image: kibana:7.8.0 70 | # container_name: nest_starter_kibana 71 | # depends_on: 72 | # - elasticsearch 73 | # volumes: 74 | # - ./kibana/kibana.yml:/usr/share/kibana/config/kibana.yml 75 | 76 | # ports: 77 | # - 31006:5601 78 | -------------------------------------------------------------------------------- /files/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olivewind/nest-starter/1f19920d65aa6d5927e7a5ca661db1cae5c2a985/files/.gitkeep -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-starter", 3 | "version": "0.0.1", 4 | "private": true, 5 | "license": "UNLICENSED", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "dev": "prisma generate && nest start --watch", 9 | "start": "prisma generate && nest start", 10 | "start:debug": "prisma generate && nest start --debug --watch", 11 | "build": "prisma generate && nest build", 12 | "start:prod": "node dist/src/main", 13 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 14 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "test:cov": "jest --coverage", 18 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 19 | "test:e2e": "jest --config ./test/jest-e2e.json" 20 | }, 21 | "dependencies": { 22 | "@elastic/elasticsearch": "^7.8.0", 23 | "@nestjs/axios": "^0.1.0", 24 | "@nestjs/common": "^9.1.4", 25 | "@nestjs/config": "^2.2.0", 26 | "@nestjs/core": "^9.1.4", 27 | "@nestjs/jwt": "^9.0.0", 28 | "@nestjs/mapped-types": "^1.2.0", 29 | "@nestjs/passport": "^9.0.0", 30 | "@nestjs/platform-express": "^9.1.4", 31 | "@nestjs/platform-socket.io": "^9.1.4", 32 | "@nestjs/schedule": "^2.1.0", 33 | "@nestjs/serve-static": "^3.0.0", 34 | "@nestjs/swagger": "^6.1.2", 35 | "@nestjs/terminus": "^9.1.2", 36 | "@nestjs/websockets": "^9.1.4", 37 | "@prisma/client": "^4.4.0", 38 | "class-transformer": "^0.5.1", 39 | "class-validator": "^0.13.2", 40 | "crypto": "^1.0.1", 41 | "dayjs": "^1.11.5", 42 | "fs-extra": "^10.0.0", 43 | "helmet": "^6.0.0", 44 | "ioredis": "^5.2.3", 45 | "joi": "^17.6.3", 46 | "nest-winston": "^1.7.1", 47 | "passport": "^0.6.0", 48 | "passport-jwt": "^4.0.0", 49 | "prisma": "^4.4.0", 50 | "reflect-metadata": "^0.1.13", 51 | "rimraf": "^3.0.2", 52 | "rxjs": "^7.5.7", 53 | "swagger-ui-express": "^4.5.0", 54 | "winston": "^3.8.2", 55 | "winston-daily-rotate-file": "^4.7.1" 56 | }, 57 | "devDependencies": { 58 | "@nestjs/cli": "^9.1.4", 59 | "@nestjs/schematics": "^9.0.3", 60 | "@nestjs/testing": "^9.1.4", 61 | "@types/express": "^4.17.14", 62 | "@types/fs-extra": "^9.0.13", 63 | "@types/jest": "^29.1.2", 64 | "@types/joi": "^17.2.3", 65 | "@types/lodash": "^4.14.178", 66 | "@types/multer": "^1.4.7", 67 | "@types/node": "^18.11.0", 68 | "@types/passport-jwt": "^3.0.6", 69 | "@types/socket.io": "^3.0.2", 70 | "@types/supertest": "^2.0.12", 71 | "@typescript-eslint/eslint-plugin": "^5.40.0", 72 | "@typescript-eslint/parser": "^5.40.0", 73 | "eslint": "^8.25.0", 74 | "eslint-config-prettier": "^8.5.0", 75 | "eslint-plugin-prettier": "^4.2.1", 76 | "jest": "^29.2.0", 77 | "prettier": "^2.7.1", 78 | "supertest": "^6.3.0", 79 | "ts-jest": "^29.0.3", 80 | "ts-loader": "^9.4.1", 81 | "ts-node": "^10.9.1", 82 | "tsconfig-paths": "^4.1.0", 83 | "typescript": "^4.8.4" 84 | }, 85 | "jest": { 86 | "moduleFileExtensions": [ 87 | "js", 88 | "json", 89 | "ts" 90 | ], 91 | "rootDir": "src", 92 | "testRegex": ".*\\.spec\\.ts$", 93 | "transform": { 94 | "^.+\\.(t|j)s$": "ts-jest" 95 | }, 96 | "collectCoverageFrom": [ 97 | "**/*.(t|j)s" 98 | ], 99 | "coverageDirectory": "../coverage", 100 | "testEnvironment": "node" 101 | }, 102 | "prisma": { 103 | "seed": "ts-node prisma/seed.ts" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "mysql" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | enum UserRole { 11 | ADMIN 12 | USER 13 | } 14 | 15 | model User { 16 | id Int @id @default(autoincrement()) 17 | username String @unique @db.VarChar(200) 18 | password String @db.VarChar(200) 19 | nickname String? @db.VarChar(200) 20 | role UserRole @default(USER) 21 | createdAt DateTime @default(now()) 22 | updatedAt DateTime @updatedAt 23 | 24 | @@map("users") 25 | } 26 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | const prisma = new PrismaClient(); 3 | 4 | async function main() { 5 | await prisma.user.create({ 6 | data: { 7 | username: 'admin', 8 | password: '21232f297a57a5a743894a0e4a801fc3', 9 | nickname: '超级管理员', 10 | role: 'ADMIN', 11 | }, 12 | }); 13 | } 14 | 15 | main() 16 | .catch((e) => { 17 | console.error(e); 18 | process.exit(1); 19 | }) 20 | .finally(async () => { 21 | await prisma.$disconnect(); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { Module } from '@nestjs/common'; 3 | import { APP_GUARD } from '@nestjs/core'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | import { ScheduleModule } from '@nestjs/schedule'; 6 | import { ServeStaticModule } from '@nestjs/serve-static'; 7 | import { WinstonModule } from 'nest-winston'; 8 | import * as winston from 'winston'; 9 | import 'winston-daily-rotate-file'; 10 | import { UsersModule } from '@modules/users/users.module'; 11 | import { AuthModule } from '@modules/auth/auth.module'; 12 | import { JwtAuthGuard } from '@core/guards/jwt-auth.guard'; 13 | import { RoleGuard } from '@core/guards/role-auth.guard'; 14 | import { HealthModule } from '@modules/health/health.module'; 15 | import { FilesModule } from '@modules/files/files.module'; 16 | import { DatabaseModule } from '@database/database.module'; 17 | import { GatewaysModule } from '@gateways/gateways.module'; 18 | import { TasksModule } from '@tasks/tasks.module'; 19 | 20 | @Module({ 21 | imports: [ 22 | UsersModule, 23 | AuthModule, 24 | FilesModule, 25 | HealthModule, 26 | GatewaysModule, 27 | DatabaseModule, 28 | ConfigModule.forRoot(), 29 | ScheduleModule.forRoot(), 30 | TasksModule, 31 | ServeStaticModule.forRoot({ 32 | rootPath: resolve(process.cwd(), 'files'), 33 | serveRoot: '/files', 34 | }), 35 | WinstonModule.forRoot({ 36 | exitOnError: false, 37 | format: winston.format.combine( 38 | winston.format.colorize(), 39 | winston.format.timestamp({ 40 | format: 'YYYY-MM-DD HH:mm:ss', 41 | }), 42 | winston.format.splat(), 43 | winston.format.printf((info) => { 44 | const { level, timestamp, context, message } = info; 45 | return `[Nest] ${level} ${timestamp}:${context ? '[' + context + ']' : ''} ${message}`; 46 | }), 47 | ), 48 | transports: [ 49 | new winston.transports.Console(), 50 | new winston.transports.DailyRotateFile({ 51 | filename: 'logs/nest-%DATE%.log', 52 | datePattern: 'YYYY-MM-DD', 53 | zippedArchive: true, 54 | maxSize: '20m', 55 | maxFiles: '14d', 56 | }), 57 | ], 58 | }), 59 | ], 60 | providers: [ 61 | { 62 | provide: APP_GUARD, 63 | useClass: JwtAuthGuard, 64 | }, 65 | { 66 | provide: APP_GUARD, 67 | useClass: RoleGuard, 68 | }, 69 | ], 70 | }) 71 | export class AppModule {} 72 | -------------------------------------------------------------------------------- /src/constants/auth.ts: -------------------------------------------------------------------------------- 1 | export const AUTH_SECRET = 'nest_starter'; 2 | export const AUTH_TOKEN_EXPIRED_TIME = 60 * 60 * 24 * 30; 3 | export enum ROLE { 4 | ADMIN = 'ADMIN', 5 | USER = 'USER', 6 | } 7 | -------------------------------------------------------------------------------- /src/constants/list.ts: -------------------------------------------------------------------------------- 1 | export enum ListOrder { 2 | Desc = 'desc', 3 | Asc = 'asc', 4 | } 5 | -------------------------------------------------------------------------------- /src/core/filters/exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; 2 | import { HttpAdapterHost } from '@nestjs/core'; 3 | import { Logger } from 'winston'; 4 | import { get, pick } from 'lodash'; 5 | 6 | @Catch() 7 | export class ExceptionsFilter implements ExceptionFilter { 8 | constructor(private readonly httpAdapterHost: HttpAdapterHost, private readonly logger: Logger) {} 9 | 10 | catch(exception: any, host: ArgumentsHost): void { 11 | // In certain situations `httpAdapter` might not be available in the 12 | // constructor method, thus we should resolve it here. 13 | const { httpAdapter } = this.httpAdapterHost; 14 | 15 | const ctx = host.switchToHttp(); 16 | const isHttpHttpException = exception instanceof HttpException; 17 | const responseBody = { 18 | code: HttpStatus.INTERNAL_SERVER_ERROR, 19 | message: 'INTERNAL SERVER ERROR', 20 | }; 21 | 22 | // http error 23 | if (isHttpHttpException) { 24 | const httpStatus = exception.getStatus(); 25 | const message = exception.message || 'UNKNOWN ERROR'; 26 | const req: Request = ctx.getRequest(); 27 | const user = pick(get(req, 'user'), ['id', 'username']); 28 | this.logger.error(`{${httpStatus}:${req.method}:${req.url}} ${user ? JSON.stringify(user) : ''} -> ${message}`, user); 29 | responseBody.code = httpStatus; 30 | responseBody.message = exception.message || 'UNKNOWN ERROR'; 31 | httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus); 32 | return; 33 | } else { 34 | // other error 35 | this.logger.error(exception); 36 | httpAdapter.reply(ctx.getResponse(), responseBody, HttpStatus.INTERNAL_SERVER_ERROR); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/core/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | import { SetMetadata } from '@nestjs/common'; 5 | 6 | export const IS_PUBLIC_KEY = 'is_public'; 7 | export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); 8 | 9 | @Injectable() 10 | export class JwtAuthGuard extends AuthGuard('jwt') { 11 | constructor(private reflector: Reflector) { 12 | super(); 13 | } 14 | 15 | canActivate(context: ExecutionContext) { 16 | const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]); 17 | if (isPublic) { 18 | return true; 19 | } 20 | return super.canActivate(context); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/core/guards/role-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { UserRole } from '@prisma/client'; 4 | 5 | import { SetMetadata } from '@nestjs/common'; 6 | 7 | export const USER_ROLE_KEY = 'user_role'; 8 | export const RequiredUserRole = (role: UserRole) => SetMetadata(USER_ROLE_KEY, role); 9 | export const IsAdmin = () => SetMetadata(USER_ROLE_KEY, 'ADMIN'); 10 | 11 | @Injectable() 12 | export class RoleGuard implements CanActivate { 13 | constructor(private reflector: Reflector) {} 14 | 15 | canActivate(context: ExecutionContext): boolean { 16 | const requiredUserRole = this.reflector.getAllAndOverride(USER_ROLE_KEY, [context.getHandler(), context.getClass()]); 17 | 18 | if (!requiredUserRole) { 19 | return true; 20 | } 21 | const { user } = context.switchToHttp().getRequest(); 22 | return requiredUserRole === user.role; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/core/interceptors/transform.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | import { map } from 'rxjs/operators'; 4 | 5 | export interface Response { 6 | data: T; 7 | } 8 | 9 | @Injectable() 10 | export class TransformInterceptor implements NestInterceptor> { 11 | intercept(context: ExecutionContext, next: CallHandler): Observable> { 12 | return next.handle().pipe(map((data) => data)); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/core/pipes/validation.pipe.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'; 3 | import { validate } from 'class-validator'; 4 | import { plainToClass } from 'class-transformer'; 5 | import { values } from 'lodash'; 6 | 7 | @Injectable() 8 | export class ValidationPipe implements PipeTransform { 9 | async transform(value: any, { metatype }: ArgumentMetadata) { 10 | if (!metatype || !this.toValidate(metatype)) { 11 | return value; 12 | } 13 | const object = plainToClass(metatype, value); 14 | const errors = await validate(object); 15 | if (errors.length > 0) { 16 | throw new BadRequestException(errors.map((e) => values(e.constraints)).toString()); 17 | } 18 | return value; 19 | } 20 | 21 | private toValidate(metatype: Function): boolean { 22 | const types: Function[] = [String, Boolean, Number, Array, Object]; 23 | return !types.includes(metatype); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { DatabaseService } from './database.service'; 3 | // import { ElasticsearchService } from './elasticsearch.service'; 4 | import { RedisService } from './redis.service'; 5 | 6 | @Global() 7 | @Module({ 8 | imports: [], 9 | providers: [DatabaseService, RedisService], 10 | exports: [DatabaseService, RedisService], 11 | }) 12 | export class DatabaseModule {} 13 | -------------------------------------------------------------------------------- /src/database/database.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit, INestApplication } from '@nestjs/common'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | // const SOFT_DELETE_MODELS = ['Project', 'User']; 5 | 6 | @Injectable() 7 | export class DatabaseService extends PrismaClient implements OnModuleInit { 8 | async onModuleInit() { 9 | await this.$connect(); 10 | // Soft Delete Middleware 11 | // this.$use(async (params, next) => { 12 | // if (SOFT_DELETE_MODELS.includes(params.model)) { 13 | // if (params.action == 'delete') { 14 | // // Change action to an update 15 | // params.action = 'update'; 16 | // params.args['data'] = { deleted: true }; 17 | // } 18 | // if (params.action == 'deleteMany') { 19 | // // Delete many queries 20 | // params.action = 'updateMany'; 21 | // if (params.args.data != undefined) { 22 | // params.args.data['deleted'] = true; 23 | // } else { 24 | // params.args['data'] = { deleted: true }; 25 | // } 26 | // } 27 | 28 | // if (params.action == 'findUnique') { 29 | // // Change to findFirst - you cannot filter 30 | // // by anything except ID / unique with findUnique 31 | // params.action = 'findFirst'; 32 | // // Add 'deleted' filter 33 | // // ID filter maintained 34 | // params.args.where['deleted'] = false; 35 | // } 36 | // if (params.action == 'update') { 37 | // // Change to updateMany - you cannot filter 38 | // // by anything except ID / unique with findUnique 39 | // params.action = 'updateMany'; 40 | // // Add 'deleted' filter 41 | // // ID filter maintained 42 | // params.args.where['deleted'] = false; 43 | // } 44 | // if ( 45 | // ['count', 'aggregate', 'updateMany', 'findMany'].includes( 46 | // params.action, 47 | // ) 48 | // ) { 49 | // if (params.args.where != undefined) { 50 | // if (params.args.where.deleted === undefined) { 51 | // // Exclude deleted records if they have not been explicitly requested 52 | // params.args.where['deleted'] = false; 53 | // } 54 | // } else { 55 | // params.args['where'] = { deleted: false }; 56 | // } 57 | // } 58 | // } 59 | // return next(params); 60 | // }); 61 | } 62 | 63 | async enableShutdownHooks(app: INestApplication) { 64 | this.$on('beforeExit', async () => { 65 | await app.close(); 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/database/elasticsearch.service.ts: -------------------------------------------------------------------------------- 1 | // import { Injectable } from '@nestjs/common'; 2 | // import { Client } from '@elastic/elasticsearch'; 3 | 4 | // class ElasticsearchClient extends Client { 5 | // constructor() { 6 | // super({ 7 | // node: `${process.env.ELASTICSEARCH_HOST}:${process.env.ELASTICSEARCH_PORT}`, 8 | // }); 9 | // } 10 | // } 11 | 12 | // @Injectable() 13 | // export class ElasticsearchService extends ElasticsearchClient {} 14 | -------------------------------------------------------------------------------- /src/database/redis.service.ts: -------------------------------------------------------------------------------- 1 | import { AUTH_TOKEN_EXPIRED_TIME } from '@constants/auth'; 2 | import { Injectable } from '@nestjs/common'; 3 | import Redis from 'ioredis'; 4 | 5 | class RedisClient extends Redis { 6 | constructor() { 7 | super({ 8 | host: process.env.REDIS_HOST, 9 | port: process.env.REDIS_PORT as any as number, 10 | password: process.env.REDIS_PASSWORD, 11 | }); 12 | } 13 | } 14 | 15 | @Injectable() 16 | export class RedisService extends RedisClient { 17 | async setAuthToken(userId: number, token: string) { 18 | return this.setex(`access-token:${userId}`, AUTH_TOKEN_EXPIRED_TIME, token); 19 | } 20 | async getAuthToken(userId: number) { 21 | return this.get(`access-token:${userId}`); 22 | } 23 | async removeAuthToken(userId: number) { 24 | return this.del(`access-token:${userId}`); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/gateways/events.gateway.ts: -------------------------------------------------------------------------------- 1 | import { Global } from '@nestjs/common'; 2 | import { SubscribeMessage, WebSocketGateway, WebSocketServer, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'; 3 | import { Server, Socket } from 'socket.io'; 4 | 5 | @Global() 6 | @WebSocketGateway() 7 | export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { 8 | @WebSocketServer() 9 | server: Server; 10 | 11 | handleConnection() { 12 | console.log('handleConnection'); 13 | } 14 | 15 | handleDisconnect(client: Socket) { 16 | console.log('handleDisconnect'); 17 | } 18 | 19 | @SubscribeMessage('joinRoom') 20 | joinRoom(client: Socket, roomId: string): string { 21 | client.join(roomId); 22 | return 'ok'; 23 | } 24 | 25 | @SubscribeMessage('leaveRoom') 26 | leaveRoom(client: Socket, roomId: string): string { 27 | client.leave(roomId); 28 | return 'ok'; 29 | } 30 | 31 | refresh() { 32 | this.server.emit('message', { 33 | data: 'refresh', 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/gateways/gateways.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Global } from '@nestjs/common'; 2 | import { EventsGateway } from './events.gateway'; 3 | 4 | @Global() 5 | @Module({ 6 | providers: [EventsGateway], 7 | exports: [EventsGateway], 8 | }) 9 | export class GatewaysModule {} 10 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { HttpAdapterHost, NestFactory } from '@nestjs/core'; 2 | import { NestExpressApplication } from '@nestjs/platform-express'; 3 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 4 | import helmet from 'helmet'; 5 | import { toNumber } from 'lodash'; 6 | import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; 7 | import { AppModule } from './app.module'; 8 | import { ExceptionsFilter } from './core/filters/exceptions.filter'; 9 | import { TransformInterceptor } from './core/interceptors/transform.interceptor'; 10 | 11 | async function bootstrap() { 12 | const app = await NestFactory.create(AppModule, { 13 | logger: false, 14 | }); 15 | app.use(helmet()); 16 | const logger = app.get(WINSTON_MODULE_NEST_PROVIDER); 17 | app.useLogger(logger); 18 | app.useGlobalFilters(new ExceptionsFilter(app.get(HttpAdapterHost), logger)); 19 | app.useGlobalInterceptors(new TransformInterceptor()); 20 | app.setGlobalPrefix('api'); 21 | 22 | const config = new DocumentBuilder() 23 | .setTitle('NEST STARTER') 24 | .setDescription('NEST STARTER POWERED BY NEST.JS') 25 | .setVersion('1.0') 26 | .addTag('latest') 27 | .build(); 28 | 29 | const document = SwaggerModule.createDocument(app, config); 30 | SwaggerModule.setup('docs', app, document); 31 | await app.listen(toNumber(process.env.SERVER_PORT || 3000)); 32 | } 33 | bootstrap(); 34 | -------------------------------------------------------------------------------- /src/modules/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Body } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { LoginDto } from './dto'; 4 | import { Public } from '../../core/guards/jwt-auth.guard'; 5 | import { ValidationPipe } from '@core/pipes/validation.pipe'; 6 | 7 | @Controller('auth') 8 | export class AuthController { 9 | constructor(private readonly authService: AuthService) {} 10 | 11 | @Public() 12 | @Post('login') 13 | async login(@Body(new ValidationPipe()) loginDto: LoginDto) { 14 | return this.authService.login(loginDto); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { AUTH_SECRET, AUTH_TOKEN_EXPIRED_TIME } from '@constants/auth'; 2 | import { Module } from '@nestjs/common'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | import { PassportModule } from '@nestjs/passport'; 5 | import { AuthController } from './auth.controller'; 6 | import { AuthService } from './auth.service'; 7 | import { JwtStrategy } from './strategies/jwt.strategy'; 8 | 9 | @Module({ 10 | imports: [ 11 | PassportModule, 12 | JwtModule.register({ 13 | secret: AUTH_SECRET, 14 | // 30 天过期 15 | signOptions: { expiresIn: `${AUTH_TOKEN_EXPIRED_TIME}s` }, 16 | }), 17 | ], 18 | controllers: [AuthController], 19 | providers: [AuthService, JwtStrategy], 20 | exports: [AuthService], 21 | }) 22 | export class AuthModule {} 23 | -------------------------------------------------------------------------------- /src/modules/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | import { DatabaseService } from '@database/database.service'; 4 | import { encrypt } from '@utils/encrypt'; 5 | import { LoginDto } from './dto'; 6 | import { RedisService } from '@database/redis.service'; 7 | 8 | @Injectable() 9 | export class AuthService { 10 | constructor(private readonly jwtService: JwtService, private databaseService: DatabaseService, private redisService: RedisService) {} 11 | 12 | async login(params: LoginDto) { 13 | const user = await this.databaseService.user.findFirst({ 14 | where: { 15 | username: params.username, 16 | password: encrypt(params.password), 17 | }, 18 | }); 19 | if (!user) { 20 | throw new HttpException(`username or password incorrect`, HttpStatus.FORBIDDEN); 21 | } 22 | const accessToken = this.jwtService.sign({ 23 | username: user.username, 24 | nickname: user.nickname, 25 | id: user.id, 26 | role: user.role, 27 | }); 28 | 29 | this.redisService.setAuthToken(user.id, accessToken); 30 | return { 31 | accessToken, 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/auth/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './login.dto'; 2 | -------------------------------------------------------------------------------- /src/modules/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty } from 'class-validator'; 3 | 4 | export class LoginDto { 5 | @ApiProperty({ 6 | description: '登录账号', 7 | required: true, 8 | }) 9 | @IsNotEmpty() 10 | username: string; 11 | @ApiProperty({ 12 | description: '登录密码', 13 | required: true, 14 | }) 15 | @IsNotEmpty() 16 | password: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/auth/dto/payload.dto.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@prisma/client'; 2 | 3 | export class PayloadDto { 4 | id: number; 5 | username: string; 6 | nickname: string; 7 | role: UserRole; 8 | iat: number; 9 | exp: number; 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { ExtractJwt, Strategy } from 'passport-jwt'; 4 | import { AUTH_SECRET } from '@constants/auth'; 5 | import { PayloadDto } from '../dto/payload.dto'; 6 | import { JwtService } from '@nestjs/jwt'; 7 | import { RedisService } from '@database/redis.service'; 8 | 9 | @Injectable() 10 | export class JwtStrategy extends PassportStrategy(Strategy) { 11 | constructor(private redisService: RedisService, private jwtService: JwtService) { 12 | super({ 13 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 14 | ignoreExpiration: false, 15 | secretOrKey: AUTH_SECRET, 16 | }); 17 | } 18 | 19 | async validate(payload: PayloadDto) { 20 | const cacheToken = await this.redisService.getAuthToken(payload.id); 21 | if (!cacheToken) { 22 | throw new HttpException(`token invalid`, HttpStatus.UNAUTHORIZED); 23 | } 24 | const cachePayload: PayloadDto = this.jwtService.decode(cacheToken) as PayloadDto; 25 | if (cachePayload.iat !== payload.iat || cachePayload.exp !== payload.exp) { 26 | throw new HttpException(`token expired`, HttpStatus.UNAUTHORIZED); 27 | } 28 | // const user = await this.databaseService.user.findUnique({ 29 | // where: { 30 | // id: toNumber(payload.id), 31 | // }, 32 | // }); 33 | // if (!user) { 34 | // throw new HttpException(`token invalid, user which id is ${payload.id} not exist`, HttpStatus.UNAUTHORIZED); 35 | // } 36 | return { 37 | id: payload.id, 38 | username: payload.username, 39 | nickname: payload.nickname, 40 | role: payload.role, 41 | }; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/files/files.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, HttpException, HttpStatus, Inject, Post, UploadedFile, UseInterceptors } from '@nestjs/common'; 2 | import { FileInterceptor } from '@nestjs/platform-express'; 3 | import { diskStorage } from 'multer'; 4 | import { extname, resolve } from 'path'; 5 | import { uniqueId } from 'lodash'; 6 | import { remove } from 'fs-extra'; 7 | import { IsAdmin } from '@core/guards/role-auth.guard'; 8 | import * as dayjs from 'dayjs'; 9 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 10 | import { Logger } from 'winston'; 11 | 12 | @Controller('files') 13 | export class FilesController { 14 | constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {} 15 | @Post() 16 | @UseInterceptors( 17 | FileInterceptor('file', { 18 | storage: diskStorage({ 19 | destination: './files', 20 | filename: (req, file, cb) => { 21 | cb(null, `${dayjs().format('YYYYMMDDHHmmss')}_${uniqueId()}${extname(file.originalname)}`); 22 | }, 23 | }), 24 | }), 25 | ) 26 | async uploadFile(@UploadedFile() file: Express.Multer.File) { 27 | if (!file) { 28 | throw new HttpException(`'file' was required`, HttpStatus.BAD_REQUEST); 29 | } 30 | const response = { 31 | name: file.filename, 32 | url: `files/${file.filename}`, 33 | size: file.size, 34 | }; 35 | return response; 36 | } 37 | 38 | @IsAdmin() 39 | @Delete() 40 | async removeFile(@Body() params: { file: string }) { 41 | await remove(resolve(process.cwd(), 'files', params.file)); 42 | return 'ok'; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/modules/files/files.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MulterModule } from '@nestjs/platform-express'; 3 | import { FilesController } from './files.controller'; 4 | 5 | @Module({ 6 | imports: [ 7 | MulterModule.register({ 8 | dest: './files', 9 | }), 10 | ], 11 | controllers: [FilesController], 12 | }) 13 | export class FilesModule {} 14 | -------------------------------------------------------------------------------- /src/modules/health/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Public } from '@core/guards/jwt-auth.guard'; 2 | import { Controller, Get } from '@nestjs/common'; 3 | import { HealthCheckService, HealthCheck } from '@nestjs/terminus'; 4 | import { ApiHealthIndicator, DatabaseHealthIndicator, RedisHealthIndicator } from './health.indicator'; 5 | 6 | @Controller('health') 7 | export class HealthController { 8 | constructor( 9 | private health: HealthCheckService, 10 | private apiHealthIndicator: ApiHealthIndicator, 11 | private databaseHealthIndicator: DatabaseHealthIndicator, 12 | private redisHealthIndicator: RedisHealthIndicator, // private elasticsearchHealthIndicator: ElasticsearchHealthIndicator, 13 | ) {} 14 | 15 | @Public() 16 | @Get() 17 | @HealthCheck() 18 | check() { 19 | return this.health.check([ 20 | () => this.apiHealthIndicator.isHealthy(), 21 | () => this.databaseHealthIndicator.isHealthy(), 22 | () => this.redisHealthIndicator.isHealthy(), 23 | // () => this.elasticsearchHealthIndicator.isHealthy(), 24 | ]); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/health/health.indicator.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { HealthIndicator, HealthIndicatorResult } from '@nestjs/terminus'; 3 | import { DatabaseService } from '@database/database.service'; 4 | import { RedisService } from '@database/redis.service'; 5 | // import { ElasticsearchService } from '@database/elasticsearch.service'; 6 | 7 | @Injectable() 8 | export class ApiHealthIndicator extends HealthIndicator { 9 | async isHealthy(): Promise { 10 | return this.getStatus('api', true, {}); 11 | } 12 | } 13 | 14 | @Injectable() 15 | export class DatabaseHealthIndicator extends HealthIndicator { 16 | constructor(private databaseService: DatabaseService) { 17 | super(); 18 | } 19 | async isHealthy(): Promise { 20 | try { 21 | const admin = await this.databaseService.user.findUnique({ 22 | where: { 23 | username: 'admin', 24 | }, 25 | }); 26 | return this.getStatus('database', !!admin, {}); 27 | } catch (error) { 28 | return this.getStatus('database', false, {}); 29 | } 30 | } 31 | } 32 | 33 | @Injectable() 34 | export class RedisHealthIndicator extends HealthIndicator { 35 | constructor(private redisService: RedisService) { 36 | super(); 37 | } 38 | async isHealthy(): Promise { 39 | const status = this.redisService.status; 40 | return this.getStatus('redis', status === 'ready', { 41 | message: `current status is ${status}`, 42 | }); 43 | } 44 | } 45 | 46 | // @Injectable() 47 | // export class ElasticsearchHealthIndicator extends HealthIndicator { 48 | // constructor(private elasticsearchService: ElasticsearchService) { 49 | // super(); 50 | // } 51 | // async isHealthy(): Promise { 52 | // return this.getStatus('elasticsearch', true, {}); 53 | // } 54 | // } 55 | -------------------------------------------------------------------------------- /src/modules/health/health.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpModule } from '@nestjs/axios'; 2 | import { Module } from '@nestjs/common'; 3 | import { TerminusModule } from '@nestjs/terminus'; 4 | import { HealthController } from './health.controller'; 5 | import { DatabaseHealthIndicator, ApiHealthIndicator, RedisHealthIndicator } from './health.indicator'; 6 | 7 | @Module({ 8 | imports: [HttpModule, TerminusModule], 9 | controllers: [HealthController], 10 | providers: [ApiHealthIndicator, DatabaseHealthIndicator, RedisHealthIndicator], 11 | }) 12 | export class HealthModule {} 13 | -------------------------------------------------------------------------------- /src/modules/users/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty } from 'class-validator'; 3 | 4 | export class CreateUserDto { 5 | @ApiProperty({ 6 | description: '登录账号', 7 | }) 8 | @IsNotEmpty() 9 | username: string; 10 | 11 | @ApiProperty({ 12 | description: '登录密码', 13 | }) 14 | @IsNotEmpty() 15 | password: string; 16 | 17 | @ApiProperty({ 18 | description: '用户昵称', 19 | }) 20 | @IsNotEmpty() 21 | nickname: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/users/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-user.dto'; 2 | export * from './update-user.dto'; 3 | -------------------------------------------------------------------------------- /src/modules/users/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class UpdateUserDto { 4 | @ApiProperty({ 5 | description: '用户昵称', 6 | required: false, 7 | }) 8 | nickname?: string; 9 | 10 | @ApiProperty({ 11 | description: '登录密码', 12 | required: false, 13 | }) 14 | password?: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, Query, DefaultValuePipe } from '@nestjs/common'; 2 | import { UsersService } from './users.service'; 3 | import { CreateUserDto, UpdateUserDto } from './dto'; 4 | import { ValidationPipe } from '@core/pipes/validation.pipe'; 5 | import { IsAdmin } from '@core/guards/role-auth.guard'; 6 | 7 | @Controller('users') 8 | export class UsersController { 9 | constructor(private readonly usersService: UsersService) {} 10 | 11 | @IsAdmin() 12 | @Post() 13 | createUser(@Body(new ValidationPipe()) createUserDto: CreateUserDto) { 14 | return this.usersService.createUser(createUserDto); 15 | } 16 | 17 | @IsAdmin() 18 | @Get() 19 | async getUsers( 20 | @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, 21 | @Query('pageSize', new DefaultValuePipe(10), ParseIntPipe) pageSize: number, 22 | @Query('keyword', new DefaultValuePipe('')) keyword: string, 23 | ) { 24 | return this.usersService.getUsers({ page, pageSize }, keyword); 25 | } 26 | 27 | @IsAdmin() 28 | @Get(':id') 29 | async getUser(@Param('id', ParseIntPipe) id: number) { 30 | return this.usersService.getUser(id); 31 | } 32 | 33 | @IsAdmin() 34 | @Patch(':id') 35 | updateUser(@Param('id', ParseIntPipe) id: number, @Body(new ValidationPipe()) updateUserDto: UpdateUserDto) { 36 | return this.usersService.updateUser(id, updateUserDto); 37 | } 38 | 39 | @IsAdmin() 40 | @Delete(':id') 41 | async removeUser(@Param('id', ParseIntPipe) id: number) { 42 | return this.usersService.removeUser(id); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/modules/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersService } from './users.service'; 3 | import { UsersController } from './users.controller'; 4 | 5 | @Module({ 6 | imports: [], 7 | controllers: [UsersController], 8 | providers: [UsersService], 9 | }) 10 | export class UsersModule {} 11 | -------------------------------------------------------------------------------- /src/modules/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; 2 | import { DatabaseService } from '@database/database.service'; 3 | import { ListOrder } from '@constants/list'; 4 | import { encrypt } from '@utils/encrypt'; 5 | import { IPagination } from 'src/types/global'; 6 | import { Prisma } from '@prisma/client'; 7 | import { EventsGateway } from '@gateways/events.gateway'; 8 | import { CreateUserDto, UpdateUserDto } from './dto'; 9 | import { RedisService } from '@database/redis.service'; 10 | 11 | const USER_SELECT = Prisma.validator()({ 12 | id: true, 13 | username: true, 14 | nickname: true, 15 | role: true, 16 | createdAt: true, 17 | updatedAt: true, 18 | }); 19 | 20 | @Injectable() 21 | export class UsersService { 22 | constructor(private databaseService: DatabaseService, private eventsGateway: EventsGateway, private redisService: RedisService) {} 23 | 24 | async createUser(data: CreateUserDto) { 25 | const user = await this.databaseService.user.findUnique({ 26 | where: { 27 | username: data.username, 28 | }, 29 | }); 30 | if (user) { 31 | throw new HttpException(`user which username is '${data.username}' was exist`, HttpStatus.FORBIDDEN); 32 | } 33 | this.eventsGateway.refresh(); 34 | const res = await this.databaseService.user.create({ 35 | data: { 36 | username: data.username, 37 | nickname: data.nickname, 38 | password: encrypt(data.password), 39 | }, 40 | select: USER_SELECT, 41 | }); 42 | return res; 43 | } 44 | 45 | async getUsers(pagination: IPagination, keyword: string) { 46 | const [items, total] = await this.databaseService.$transaction([ 47 | this.databaseService.user.findMany({ 48 | where: { 49 | nickname: { 50 | contains: keyword, 51 | }, 52 | }, 53 | orderBy: { 54 | updatedAt: ListOrder.Desc, 55 | }, 56 | select: USER_SELECT, 57 | take: pagination.pageSize, 58 | skip: (pagination.page - 1) * pagination.pageSize, 59 | }), 60 | this.databaseService.user.count({ 61 | where: { 62 | nickname: { 63 | contains: keyword, 64 | }, 65 | }, 66 | }), 67 | ]); 68 | return { 69 | items, 70 | total, 71 | }; 72 | } 73 | 74 | async getUser(id: number) { 75 | const user = await this.databaseService.user.findUnique({ 76 | where: { 77 | id, 78 | }, 79 | select: USER_SELECT, 80 | }); 81 | if (!user) { 82 | throw new HttpException(`user which id is '${id}' was not found`, HttpStatus.NOT_FOUND); 83 | } 84 | return user; 85 | } 86 | 87 | async updateUser(id: number, data: UpdateUserDto) { 88 | const oldUser = await this.getUser(id); 89 | const newUser: UpdateUserDto = { 90 | nickname: data.nickname || oldUser.nickname, 91 | }; 92 | if (data.password) { 93 | newUser.password = encrypt(data.password); 94 | } 95 | return await this.databaseService.user.update({ 96 | where: { 97 | id, 98 | }, 99 | data: newUser, 100 | select: USER_SELECT, 101 | }); 102 | } 103 | 104 | async removeUser(id: number) { 105 | await this.redisService.removeAuthToken(id); 106 | await this.databaseService.user.delete({ 107 | where: { 108 | id, 109 | }, 110 | }); 111 | return 'ok'; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/tasks/tasks.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TasksService } from './tasks.service'; 3 | 4 | @Module({ 5 | providers: [TasksService], 6 | }) 7 | export class TasksModule {} 8 | -------------------------------------------------------------------------------- /src/tasks/tasks.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { Cron, CronExpression } from '@nestjs/schedule'; 3 | 4 | @Injectable() 5 | export class TasksService { 6 | private readonly logger = new Logger(TasksService.name); 7 | 8 | @Cron(CronExpression.EVERY_DAY_AT_1AM) 9 | handleCron() { 10 | this.logger.warn('Called EVERY_DAY_AT_1AM'); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { UserRole } from '@prisma/client'; 3 | 4 | interface IJwtRequest extends Request { 5 | user: { 6 | id: number; 7 | username: string; 8 | nickname: string; 9 | role: UserRole; 10 | }; 11 | } 12 | 13 | interface IPagination { 14 | page: number; 15 | pageSize: number; 16 | } 17 | 18 | interface IResponseList { 19 | items: T[]; 20 | total: number; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/encrypt.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | 3 | export function encrypt(str: string) { 4 | return crypto.createHash('md5').update(str).digest('hex'); 5 | } 6 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = 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()).get('/').expect(200).expect('Hello World!'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /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.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "paths": { 15 | "@modules/*": ["src/modules/*"], 16 | "@tasks/*": ["src/tasks/*"], 17 | "@core/*": ["src/core/*"], 18 | "@cache/*": ["src/cache/*"], 19 | "@database/*": ["src/database/*"], 20 | "@gateways/*": ["src/gateways/*"], 21 | "@constants/*": ["src/constants/*"], 22 | "@utils/*": ["src/utils/*"], 23 | "@typings/*": ["src/types/*"] 24 | } 25 | } 26 | } 27 | --------------------------------------------------------------------------------