├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── nest-cli.json ├── package.json ├── pnpm-lock.yaml ├── src ├── app.module.ts ├── app │ ├── app.config.ts │ ├── app.decorator.ts │ ├── app.enums.ts │ ├── base.entity.ts │ └── utils.ts ├── main.ts ├── modules │ └── user │ │ ├── login.controller.ts │ │ ├── user.controller.ts │ │ ├── user.dto.ts │ │ ├── user.entity.ts │ │ └── user.http └── services │ ├── auth.service.ts │ ├── chat.gateway.ts │ └── task.schedule.ts ├── static └── socket │ ├── index.html │ ├── main.js │ └── style.css ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "endOfLine": "auto" 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 郭垒 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nest-starter 2 | 3 | > 实现什么? 4 | 5 | 1. 一个 RESTful API 程序,并对 传输的数据(_DTO_) 进行校验(_是否必要、类型_) 6 | 2. 支持跨域 7 | 3. 支持定时任务 8 | 4. 数据库(_MySQL、TypeORM_) 9 | 5. 基于 JWT 做接口鉴权 10 | 6. 使用 Swagger 自动生成接口文档 11 | 7. 基于 socket.io 支持实时通信。实现聊天室前端页面并作为静态文件挂载 12 | 13 | > 一些个人化的想法 14 | 15 | 1. 移除 service 中间层,会在 controller 中直接访问数据库 16 | 2. 不组合多个 `module` ,只留一个全局 `module`。所有的 `controller`、`service` 等都直接添加到全局 `module`。只在代码层面组织不同的模块 17 | 3. 日志交给系统管理,比如 `journalctl` 。不考虑将日志写到文件的需求,只使用框架自身提供的日志能力 18 | 4. 暂时只使用框架提供的异常类。比如:鉴权失败、资源未找到 19 | 20 | --- 21 | 22 | ## 零、初始化项目 23 | 24 | 步骤: 25 | 26 | 1. 创建起始项目 27 | 2. 结构简化、调整配置 28 | 3. 区分正式和开发环境 29 | 30 | 创建项目: 31 | 32 | ```bash 33 | npm i -g @nestjs/cli 34 | nest new project-name 35 | ``` 36 | 37 | 运行 `start` 脚本,打开浏览器地址访问 `http://localhost:3000` (_端口号在 `src/main.ts` 中设置_) 38 | 39 | 环境区分: 40 | 41 | 使用 `cross-env` 在 npm 脚本执行前定义 `NODE_ENV` 环境变量 42 | 43 | ```bash 44 | ni -D cross-env 45 | ``` 46 | 47 | > package.json 48 | 49 | ```js 50 | { 51 | // ... 52 | "scripts": { 53 | "start": "cross-env NODE_ENV=dev nest start --watch", 54 | "start:prod": "cross-env NODE_ENV=prod node dist/main", 55 | }, 56 | // ... 57 | } 58 | ``` 59 | 60 | --- 61 | 62 | ## 一、RESTful API 与 数据校验 63 | 64 | 步骤: 65 | 66 | 1. 创建 用户的查询、新增、删除接口。实现从 请求体、路由 上取参数 67 | 2. 数据[校验](https://docs.nestjs.cn/8/pipes?id=%e7%b1%bb%e9%aa%8c%e8%af%81%e5%99%a8)和[转换](https://docs.nestjs.cn/8/pipes?id=%e8%bd%ac%e6%8d%a2%e7%ae%a1%e9%81%93) 68 | 3. 根据情况返回不同的状态码和信息 69 | 4. [使用内置异常](https://docs.nestjs.cn/8/exceptionfilters?id=%e5%86%85%e7%bd%aehttp%e5%bc%82%e5%b8%b8)与[自定义异常处理逻辑](https://docs.nestjs.cn/8/exceptionfilters?id=%e5%bc%82%e5%b8%b8%e8%bf%87%e6%bb%a4%e5%99%a8-1) 70 | 71 | 创建组件类后,需要注册到 `app.module` 才能生效。也可以使用 `nest cli` 创建组件并自动注册到 `app.module`,参考[官方文档](https://docs.nestjs.cn/8/cli?id=nest-generate)。比如我创建 controller 的方式: 72 | 73 | ```bash 74 | # 安装类验证器和转换器 75 | ni class-validator class-transformer 76 | 77 | # 在 modules/${path} 路径下生成一个 ${name}.controller.ts 78 | # --flat 指示只创建文件,不加会多生成一个文件夹 79 | # --no-spec 指示不创建测试用例 80 | nest g controller ${name} modules/${path} --flat --no-spec 81 | 82 | # 例子 83 | # nest g controller login modules/user --flat --no-spec 84 | # nest g controller user modules/user --flat --no-spec 85 | ``` 86 | 87 | > user.dto.ts 88 | 89 | ```ts 90 | // 数据校验,这里用到的不能为空 91 | import { IsNotEmpty } from 'class-validator'; 92 | 93 | /** 94 | * 登录接口 DTO 95 | */ 96 | export class LoginDto { 97 | @IsNotEmpty() 98 | name: string; 99 | 100 | @IsNotEmpty() 101 | password: string; 102 | } 103 | ``` 104 | 105 | > user.controller.ts 106 | 107 | ```ts 108 | @Controller('user') 109 | export class UserController { 110 | @Get() 111 | async all() {} 112 | 113 | // 会对 dto 做数据校验 114 | @Post() 115 | async add(@Body() dto: LoginDto) {} 116 | 117 | // id 转换成 int 118 | @Delete(':id') 119 | async remove(@Param('id', ParseIntPipe) id: number) {} 120 | } 121 | ``` 122 | 123 | 全局启用校验: 124 | 125 | ```ts 126 | app.useGlobalPipes(new ValidationPipe()); 127 | ``` 128 | 129 | 自定义异常校验逻辑(_暂时不做_): 130 | 131 | 1. 写一个[`filter`](https://docs.nestjs.cn/8/exceptionfilters?id=%e5%bc%82%e5%b8%b8%e8%bf%87%e6%bb%a4%e5%99%a8-1) 132 | 1. 在 `src/main.ts` 中引入 `app.useGlobalFilters(new YourExceptionFilter());` 133 | 134 | --- 135 | 136 | ## 二、跨域配置 137 | 138 | 步骤: 139 | 140 | 1. 配置跨域 141 | 2. 写测试(_通过设置`Origin`然后检查`Access-Control-Allow-Origin`_) 142 | 143 | > main.ts 144 | 145 | ```ts 146 | async function bootstrap() { 147 | // ... 148 | 149 | // 配置参数 https://github.com/expressjs/cors#configuration-options 150 | app.enableCors({ 151 | origin: ['http://localhost:3002'], 152 | }); 153 | 154 | // ... 155 | } 156 | bootstrap(); 157 | ``` 158 | 159 | > app.e2e-spec.ts 160 | 161 | ```ts 162 | import * as request from 'supertest'; 163 | const SERVER_LOCATION = `http://localhost:3000`; 164 | 165 | // 直接在服务器启动的情况下测试 166 | 167 | describe('AppController (e2e)', () => { 168 | const origin = 'http://localhost:3002'; 169 | it('跨域测试', () => { 170 | return request(SERVER_LOCATION) 171 | .options('/') 172 | .set('Origin', origin) 173 | .expect('Access-Control-Allow-Origin', origin); 174 | }); 175 | }); 176 | ``` 177 | 178 | --- 179 | 180 | ## 三、定时任务 181 | 182 | 步骤: 183 | 184 | 1. 安装 185 | 1. 写定时任务 186 | 1. 在模块中注册(_注意需要使用`imports: [ScheduleModule.forRoot()]`_) 187 | 188 | 安装: 189 | 190 | ```bash 191 | ni @nestjs/schedule 192 | ``` 193 | 194 | [定时任务](https://docs.nestjs.cn/8/techniques?id=%e5%ae%9a%e6%97%b6%e4%bb%bb%e5%8a%a1)有三种方便的 Api: 195 | 196 | 1. `@Cron`,除了自己写 [cron 表达式](https://help.aliyun.com/document_detail/64769.html),也可以直接使用系统定义好的 `CronExpression` 枚举 197 | 2. `@Interval`,定时执行。传入 `ms` 为单位的数值 198 | 3. `@TimeOut`,启动后延时执行一次。传入 `ms` 为单位的数值 199 | 200 | > task.schedule.ts 201 | 202 | ```ts 203 | import { Injectable, Logger } from '@nestjs/common'; 204 | import { Cron, CronExpression, Interval, Timeout } from '@nestjs/schedule'; 205 | 206 | /** 207 | * @see [定时任务](https://docs.nestjs.cn/8/techniques?id=%e5%ae%9a%e6%97%b6%e4%bb%bb%e5%8a%a1) 208 | */ 209 | @Injectable() 210 | export class TasksSchedule { 211 | private readonly logger = new Logger(TasksSchedule.name); 212 | 213 | @Cron(CronExpression.EVERY_DAY_AT_6PM) 214 | task1() { 215 | this.logger.debug('task1 - 每天下午6点执行一次'); 216 | } 217 | 218 | @Cron(CronExpression.EVERY_2_HOURS) 219 | task2() { 220 | this.logger.debug('task2 - 每2小时执行一次'); 221 | } 222 | 223 | @Interval(30 * 60 * 1000) 224 | task3() { 225 | this.logger.debug('task3 - 每30分钟执行一次'); 226 | } 227 | 228 | @Timeout(5 * 1000) 229 | task4() { 230 | this.logger.debug('task4 - 启动5s后执行一次'); 231 | } 232 | } 233 | ``` 234 | 235 | --- 236 | 237 | ## 四、使用 TypeORM 连接 MySQL 数据库 238 | 239 | [官方文档](https://docs.nestjs.cn/8/techniques?id=%e6%95%b0%e6%8d%ae%e5%ba%93) 240 | 241 | 重点: 242 | 243 | 1. `TypeOrmModule.forRoot()` 用来配置数据库,导入数据库模块 244 | 2. `TypeOrmModule.forFeature()` 用来定义在当前范围中需要注册的数据库表 245 | 246 | ```bash 247 | ni @nestjs/typeorm typeorm mysql2 248 | ``` 249 | 250 | ```ts 251 | @Module({ 252 | imports: [ 253 | // 导入模块 254 | TypeOrmModule.forRoot({ 255 | type: 'mysql', 256 | host: 'localhost', 257 | port: 3306, 258 | username: 'root', 259 | password: 'password', 260 | database: 'test', 261 | autoLoadEntities: true, // 自动加载 forFeature 使用到的 entity 262 | synchronize: true, // 自动同步数据库和字段,会在数据库中创建没有的字段 263 | }), 264 | // 定义在当前范围中需要注册的存储库 265 | TypeOrmModule.forFeature([User]), 266 | ], 267 | controllers: [UserController, LoginController], 268 | }) 269 | export class AppModule {} 270 | ``` 271 | 272 | 使用(_详细 api 参考 [Repository 模式 API 文档](https://typeorm.bootcss.com/repository-api)_): 273 | 274 | > user.controller.ts 275 | 276 | ```ts 277 | @Controller('user') 278 | export class UserController { 279 | constructor( 280 | // 依赖注入 281 | // 注入后就可以使用 find()、save($user) 等方法 282 | @InjectRepository(User) 283 | private repository: Repository, 284 | ) {} 285 | } 286 | ``` 287 | 288 | [事务](https://docs.nestjs.cn/8/techniques?id=%e4%ba%8b%e5%8a%a1)使用: 289 | 290 | ```ts 291 | async createMany(users: User[]) { 292 | await this.connection.transaction(async manager => { 293 | await manager.save(users[0]); 294 | await manager.save(users[1]); 295 | }); 296 | } 297 | ``` 298 | 299 | --- 300 | 301 | ## 五、基于 JWT 做接口鉴权 302 | 303 | [官方文档](https://docs.nestjs.cn/8/security?id=%e8%ae%a4%e8%af%81%ef%bc%88authentication%ef%bc%89) 304 | 305 | 步骤: 306 | 307 | 1. 安装 `ni @nestjs/passport @nestjs/jwt passport passport-jwt`、`ni -D @types/passport-jwt` 308 | 1. 写认证模块(_`auth.service.ts`_)并全局引入 309 | 1. 使用`@NoAuth`配置无需认证的路由 310 | 311 | > auth.service.ts 312 | 313 | ```ts 314 | import { 315 | Injectable, 316 | CanActivate, 317 | ExecutionContext, 318 | SetMetadata, 319 | UnauthorizedException, 320 | } from '@nestjs/common'; 321 | import { Observable } from 'rxjs'; 322 | import { Reflector } from '@nestjs/core'; 323 | import { MD5 } from 'src/app/utils'; 324 | import { Repository } from 'typeorm'; 325 | import { User } from '../modules/user/user.entity'; 326 | import { AuthGuard, IAuthGuard, PassportStrategy } from '@nestjs/passport'; 327 | import { InjectRepository } from '@nestjs/typeorm'; 328 | import { JwtService } from '@nestjs/jwt'; 329 | import { AppConfig } from '../app/app.config'; 330 | import { ExtractJwt, Strategy } from 'passport-jwt'; 331 | 332 | /** 333 | * 无需认证的路由 334 | */ 335 | export const NoAuth = () => SetMetadata('no-auth', true); 336 | 337 | /** 338 | * 认证服务。校验登录信息、生成 token 339 | */ 340 | @Injectable() 341 | export class AuthService { 342 | constructor( 343 | @InjectRepository(User) private readonly userRepo: Repository, 344 | private readonly jwtService: JwtService, 345 | ) {} 346 | 347 | /** 348 | * 校验用户登录信息 349 | */ 350 | async validate(name: string, password: string): Promise { 351 | const user = await this.userRepo.findOneBy({ name }); 352 | 353 | if (!user || user.password !== (await MD5.encode(password))) { 354 | return null; 355 | } 356 | return user.removeSensitive(); 357 | } 358 | 359 | /** 360 | * 生成 token 361 | */ 362 | async generateToken(user: User) { 363 | const payload = user; 364 | return { 365 | access_token: this.jwtService.sign(payload), 366 | }; 367 | } 368 | 369 | parseToken(token: string): User { 370 | return this.jwtService.decode(token) as User; 371 | } 372 | } 373 | 374 | /** 375 | * 认证守卫 376 | * @description 如果未设置 `@NoAuth()`,则使用 JwtStrategy 进行校验。配合 app.module 做全局校验用 377 | */ 378 | @Injectable() 379 | export class MyAuthGuard implements CanActivate { 380 | constructor(private readonly reflector: Reflector) {} 381 | canActivate( 382 | context: ExecutionContext, 383 | ): boolean | Promise | Observable { 384 | // 在这里取metadata中的no-auth,得到的会是一个bool 385 | const noAuth = this.reflector.get('no-auth', context.getHandler()); 386 | const guard = MyAuthGuard.getAuthGuard(noAuth); 387 | if (guard) { 388 | return guard.canActivate(context); 389 | } 390 | return true; 391 | } 392 | 393 | // 根据NoAuth的t/f选择合适的策略Guard 394 | private static getAuthGuard(noAuth: boolean): IAuthGuard { 395 | if (noAuth) { 396 | return null; 397 | } else { 398 | return new JwtAuthGuard(); 399 | } 400 | } 401 | } 402 | 403 | /** 404 | * Jwt 校验策略 405 | */ 406 | @Injectable() 407 | export class JwtStrategy extends PassportStrategy(Strategy) { 408 | constructor() { 409 | super({ 410 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 411 | ignoreExpiration: false, 412 | secretOrKey: AppConfig.JWT_SECRET, 413 | }); 414 | } 415 | 416 | async validate(payload: any) { 417 | return payload; 418 | } 419 | } 420 | 421 | /** 422 | * Jwt 校验守卫 423 | * @description 主要为了自定义异常逻辑 424 | */ 425 | @Injectable() 426 | export class JwtAuthGuard extends AuthGuard('jwt') { 427 | handleRequest(err, user) { 428 | if (err || !user) { 429 | throw new UnauthorizedException('请登录后再访问'); 430 | } 431 | return user; 432 | } 433 | } 434 | ``` 435 | 436 | 全局启用: 437 | 438 | > app.module.ts 439 | 440 | ```ts 441 | @Module({ 442 | imports: [ 443 | PassportModule.register({ defaultStrategy: 'jwt' }), 444 | JwtModule.register({ 445 | secret: AppConfig.JWT_SECRET, 446 | // https://github.com/auth0/node-jsonwebtoken#usage 447 | signOptions: { expiresIn: AppConfig.JWT_EXPIRES_IN }, 448 | }), 449 | ], 450 | controllers: [UserController, LoginController], 451 | providers: [ 452 | AuthService, 453 | JwtStrategy, 454 | { 455 | // 全局启用 456 | provide: APP_GUARD, 457 | useClass: MyAuthGuard, 458 | }, 459 | ], 460 | }) 461 | export class AppModule {} 462 | ``` 463 | 464 | 为部分无需认证的路由设置`@NoAuth`: 465 | 466 | ```diff 467 | @Controller('login') 468 | export class LoginController { 469 | 470 | + @NoAuth() 471 | @Post() 472 | async login(@Body() { name, password }: LoginDto) {} 473 | } 474 | ``` 475 | 476 | --- 477 | 478 | ## 六、使用 Swagger 自动生成接口文档 479 | 480 | [参考文档](https://docs.nestjs.cn/8/openapi) 481 | 482 | 步骤: 483 | 484 | 1. 安装:`ni @nestjs/swagger swagger-ui-express` 485 | 1. `main.ts`中配置使用 486 | 1. `nest-cli.json`中[配置插件,以自动映射属性注释](https://docs.nestjs.cn/8/openapi?id=cli%e6%8f%92%e4%bb%b6) 487 | 1. 使用`@ApiTags`为接口分组,`@ApiOperation`为接口增加描述,`@ApiBearerAuth`为接口添加认证 488 | 489 | > main.ts 490 | 491 | ```ts 492 | // https://docs.nestjs.cn/8/openapi 493 | const config = new DocumentBuilder() 494 | .setTitle('NestJS API') 495 | .setDescription('API 文档') 496 | .setVersion('1.0') 497 | .addBearerAuth() // JWT 认证 498 | .build(); 499 | const document = SwaggerModule.createDocument(app, config); 500 | SwaggerModule.setup('swagger', app, document); // 挂载到 /swagger 路由下 501 | ``` 502 | 503 | 注释: 504 | 505 | 1. `@ApiTags('用户管理')` 分组 506 | 2. `@ApiOperation({ summary: '获取用户信息' })` 用户函数 507 | 3. `@ApiBearerAuth()` 需要 jwt 认证,用于函数 508 | 4. 属性的注释可以通过插件配置自动生成 509 | 510 | 也可以使用[装饰器聚合](https://docs.nestjs.cn/8/customdecorators?id=%e8%a3%85%e9%a5%b0%e5%99%a8%e8%81%9a%e5%90%88)来组合使用多个装饰器: 511 | 512 | > app.decorator.ts 513 | 514 | ```ts 515 | import { applyDecorators, Controller } from '@nestjs/common'; 516 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 517 | 518 | /** 519 | * 复合装饰器 520 | */ 521 | export function ApiController(route: string, name: string = route) { 522 | return applyDecorators( 523 | ApiBearerAuth(), // 524 | ApiTags(name), 525 | Controller(route), 526 | ); 527 | } 528 | ``` 529 | 530 | > user.controller.ts 531 | 532 | ```diff 533 | +@ApiController('user', '用户管理') 534 | -@Controller('user') 535 | -@ApiBearerAuth() 536 | -@ApiTags('用户管理') 537 | export class UserController { 538 | 539 | + @ApiOperation({ summary: '获取用户信息' }) 540 | @Get() 541 | async all() { 542 | 543 | } 544 | } 545 | ``` 546 | 547 | --- 548 | 549 | ## 七、基于 socket.io 实现聊天室,并挂载前端页面 550 | 551 | [中文文档](https://docs.nestjs.cn/8/websockets?id=websocket) 552 | 553 | 1. 安装依赖:`ni ni @nestjs/websockets @nestjs/platform-socket.io socket.io` 554 | 1. 实现后端功能 `chat.gateway.ts`,并注册到`app.module.ts`中的`providers`中 555 | 1. 实现前端页面,放在`static/socket`路径下,并在`main.ts`中配置静态文件访问 556 | 557 | > chat.gateway.ts 558 | 559 | ```ts 560 | import { 561 | SubscribeMessage, 562 | WebSocketGateway, 563 | OnGatewayInit, 564 | WebSocketServer, 565 | OnGatewayConnection, 566 | OnGatewayDisconnect, 567 | } from '@nestjs/websockets'; 568 | import { Logger, UseGuards } from '@nestjs/common'; 569 | import { Socket, Server } from 'socket.io'; 570 | import { AuthGuard } from '@nestjs/passport'; 571 | import { AuthService } from './auth.service'; 572 | 573 | enum SocketEvent { 574 | System = 'system', 575 | Message = 'message', 576 | Statistic = 'statistic', 577 | } 578 | 579 | @WebSocketGateway({ 580 | namespace: '/chat', 581 | }) 582 | export class ChatGateway 583 | implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 584 | { 585 | private clients: Map = new Map(); 586 | 587 | constructor(private readonly authService: AuthService) {} 588 | 589 | @WebSocketServer() server: Server; 590 | private logger: Logger = new Logger(ChatGateway.name); 591 | 592 | @SubscribeMessage(SocketEvent.Message) 593 | handleMessage(client: Socket, payload: string): void { 594 | this.server.emit(SocketEvent.Message, payload); 595 | } 596 | 597 | afterInit(_: Server) { 598 | this.logger.log('聊天室初始化'); 599 | } 600 | 601 | handleDisconnect(client: Socket) { 602 | this.logger.log(`WS 客户端断开连接: ${client.id}`); 603 | this.clients.delete(client.id); 604 | this.sendStatistics(); 605 | } 606 | 607 | @UseGuards(AuthGuard('jwt')) 608 | handleConnection(client: Socket) { 609 | // TOKEN 校验 610 | 611 | // const token = client.handshake.headers.authorization; 612 | // const user = this.authService.parseToken(token.split(' ')[1]); 613 | // if (!user) { 614 | // return client.disconnect(true); 615 | // } 616 | 617 | this.logger.log(`WS 客户端连接成功: ${client.id}`); 618 | this.clients.set(client.id, client); 619 | client.emit(SocketEvent.System, '聊天室连接成功'); 620 | this.sendStatistics(); 621 | } 622 | 623 | sendStatistics() { 624 | this.server.emit(SocketEvent.Statistic, this.clients.size); 625 | } 626 | } 627 | ``` 628 | 629 | > main.ts 630 | 631 | ```diff 632 | + import { NestExpressApplication } from '@nestjs/platform-express'; 633 | + import { join } from 'path'; 634 | 635 | async function bootstrap() { 636 | - const app = await NestFactory.create(AppModule); 637 | + const app = await NestFactory.create(AppModule); 638 | 639 | // ... 640 | 641 | // 提供静态文件访问 642 | + app.useStaticAssets(join(__dirname, '..', 'static')); 643 | 644 | await app.listen(3000); 645 | } 646 | bootstrap(); 647 | ``` 648 | 649 | _前端代码见提交记录_ 650 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "plugins": [ 7 | { 8 | "name": "@nestjs/swagger", 9 | "options": { 10 | "classValidatorShim": true, 11 | "introspectComments": true, 12 | "dtoFileNameSuffix": [ 13 | ".dto.ts", 14 | ".entity.ts" 15 | ] 16 | } 17 | } 18 | ] 19 | } 20 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-starter", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "start": "cross-env NODE_ENV=dev nest start --watch", 12 | "start:prod": "cross-env NODE_ENV=prod node dist/main", 13 | "test": "jest", 14 | "test:e2e": "jest --config ./test/jest-e2e.json" 15 | }, 16 | "dependencies": { 17 | "@nestjs/common": "^8.0.0", 18 | "@nestjs/core": "^8.0.0", 19 | "@nestjs/jwt": "^8.0.1", 20 | "@nestjs/passport": "^8.2.1", 21 | "@nestjs/platform-express": "^8.0.0", 22 | "@nestjs/platform-socket.io": "^8.4.5", 23 | "@nestjs/schedule": "^2.0.1", 24 | "@nestjs/swagger": "^5.2.1", 25 | "@nestjs/typeorm": "^8.0.4", 26 | "@nestjs/websockets": "^8.4.5", 27 | "bcrypt": "^5.0.1", 28 | "class-transformer": "^0.5.1", 29 | "class-validator": "^0.13.2", 30 | "mysql2": "^2.3.3", 31 | "passport": "^0.6.0", 32 | "passport-jwt": "^4.0.0", 33 | "reflect-metadata": "^0.1.13", 34 | "rimraf": "^3.0.2", 35 | "rxjs": "^7.2.0", 36 | "socket.io": "^4.5.1", 37 | "swagger-ui-express": "^4.4.0", 38 | "typeorm": "^0.3.6" 39 | }, 40 | "devDependencies": { 41 | "@nestjs/cli": "^8.0.0", 42 | "@nestjs/schematics": "^8.0.0", 43 | "@nestjs/testing": "^8.0.0", 44 | "@types/express": "^4.17.13", 45 | "@types/jest": "27.5.0", 46 | "@types/node": "^16.0.0", 47 | "@types/passport-jwt": "^3.0.6", 48 | "@types/supertest": "^2.0.11", 49 | "@typescript-eslint/eslint-plugin": "^5.0.0", 50 | "@typescript-eslint/parser": "^5.0.0", 51 | "cross-env": "^7.0.3", 52 | "eslint": "^8.0.1", 53 | "eslint-config-prettier": "^8.3.0", 54 | "eslint-plugin-prettier": "^4.0.0", 55 | "jest": "28.0.3", 56 | "prettier": "^2.3.2", 57 | "source-map-support": "^0.5.20", 58 | "supertest": "^6.1.3", 59 | "ts-jest": "28.0.1", 60 | "ts-loader": "^9.2.3", 61 | "ts-node": "^10.0.0", 62 | "tsconfig-paths": "4.0.0", 63 | "typescript": "^4.3.5" 64 | }, 65 | "jest": { 66 | "moduleFileExtensions": [ 67 | "js", 68 | "json", 69 | "ts" 70 | ], 71 | "rootDir": "src", 72 | "testRegex": ".*\\.spec\\.ts$", 73 | "transform": { 74 | "^.+\\.(t|j)s$": "ts-jest" 75 | }, 76 | "collectCoverageFrom": [ 77 | "**/*.(t|j)s" 78 | ], 79 | "coverageDirectory": "../coverage", 80 | "testEnvironment": "node" 81 | } 82 | } -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { APP_GUARD } from '@nestjs/core'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | import { PassportModule } from '@nestjs/passport'; 5 | import { ScheduleModule } from '@nestjs/schedule'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | import { AppConfig } from './app/app.config'; 8 | import { LoginController } from './modules/user/login.controller'; 9 | import { UserController } from './modules/user/user.controller'; 10 | import { User } from './modules/user/user.entity'; 11 | import { AuthService, JwtStrategy, MyAuthGuard } from './services/auth.service'; 12 | import { ChatGateway } from './services/chat.gateway'; 13 | import { TasksSchedule } from './services/task.schedule'; 14 | 15 | @Module({ 16 | imports: [ 17 | ScheduleModule.forRoot(), 18 | TypeOrmModule.forRoot(AppConfig.TypeOrmConfig), 19 | TypeOrmModule.forFeature([User]), 20 | PassportModule.register({ defaultStrategy: 'jwt' }), 21 | JwtModule.register({ 22 | secret: AppConfig.JWT_SECRET, 23 | // https://github.com/auth0/node-jsonwebtoken#usage 24 | signOptions: { expiresIn: AppConfig.JWT_EXPIRES_IN }, 25 | }), 26 | ], 27 | controllers: [LoginController, UserController], 28 | providers: [ 29 | TasksSchedule, 30 | AuthService, 31 | JwtStrategy, 32 | { 33 | // 全局启用 34 | provide: APP_GUARD, 35 | useClass: MyAuthGuard, 36 | }, 37 | ChatGateway, 38 | ], 39 | }) 40 | export class AppModule {} 41 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 2 | import { AppEnvironmentType } from './app.enums'; 3 | 4 | export class AppConfig { 5 | static get ENV(): AppEnvironmentType { 6 | return process.env.NODE_ENV as AppEnvironmentType; 7 | } 8 | 9 | static IS_DEV_MODE = AppConfig.ENV === AppEnvironmentType.DEV; 10 | 11 | static readonly JWT_SECRET = "F/c-L)=/fW_UAf@PECbdz,Z}{Afp,'"; 12 | static readonly JWT_EXPIRES_IN = '1h'; 13 | 14 | static get TypeOrmConfig(): TypeOrmModuleOptions { 15 | return { 16 | type: 'mysql', 17 | host: AppConfig.IS_DEV_MODE ? 'localhost' : 'your.remote.host', 18 | port: 3306, 19 | username: 'root', 20 | password: '12345678', 21 | database: 'test', 22 | autoLoadEntities: true, 23 | synchronize: true, 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/app.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, Controller } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 3 | 4 | /** 5 | * 复合装饰器 6 | */ 7 | export function ApiController(route: string, name: string = route) { 8 | return applyDecorators( 9 | ApiBearerAuth(), // 10 | ApiTags(name), 11 | Controller(route), 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/app/app.enums.ts: -------------------------------------------------------------------------------- 1 | export enum AppEnvironmentType { 2 | DEV = 'dev', 3 | PROD = 'prod', 4 | } 5 | -------------------------------------------------------------------------------- /src/app/base.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PrimaryGeneratedColumn, 3 | UpdateDateColumn, 4 | CreateDateColumn, 5 | } from 'typeorm'; 6 | 7 | export class BaseEntity { 8 | @PrimaryGeneratedColumn() 9 | id: number; 10 | 11 | @CreateDateColumn({ 12 | type: 'timestamp', 13 | default: () => 'CURRENT_TIMESTAMP(6)', 14 | }) 15 | public created_at: Date; 16 | 17 | @UpdateDateColumn({ 18 | type: 'timestamp', 19 | default: () => 'CURRENT_TIMESTAMP(6)', 20 | onUpdate: 'CURRENT_TIMESTAMP(6)', 21 | }) 22 | public updated_at: Date; 23 | } 24 | -------------------------------------------------------------------------------- /src/app/utils.ts: -------------------------------------------------------------------------------- 1 | import { hash } from 'bcrypt'; 2 | 3 | export class MD5 { 4 | private static SALT = '$2b$10$qWKATnl./4/OryuF/SOMhu'; 5 | 6 | /** 7 | * 散列处理 8 | * @see https://www.jianshu.com/p/2b131bfc2f10 9 | */ 10 | static async encode(str: string) { 11 | return await hash(str, this.SALT); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | import { AppConfig } from './app/app.config'; 5 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 6 | import { NestExpressApplication } from '@nestjs/platform-express'; 7 | import { join } from 'path'; 8 | 9 | console.log( 10 | `NODE_ENV: ${AppConfig.ENV}, IS_DEV_MODE: ${AppConfig.IS_DEV_MODE}`, 11 | ); 12 | 13 | async function bootstrap() { 14 | const app = await NestFactory.create(AppModule); 15 | app.useGlobalPipes(new ValidationPipe()); 16 | // 跨域配置参数 https://github.com/expressjs/cors#configuration-options 17 | app.enableCors({ 18 | origin: ['http://localhost:3002'], 19 | }); 20 | 21 | // https://docs.nestjs.cn/8/openapi 22 | const config = new DocumentBuilder() 23 | .setTitle('NestJS API') 24 | .setDescription('API 文档') 25 | .setVersion('1.0') 26 | .addBearerAuth() 27 | .build(); 28 | const document = SwaggerModule.createDocument(app, config); 29 | SwaggerModule.setup('swagger', app, document); 30 | 31 | // 提供静态文件访问 32 | app.useStaticAssets(join(__dirname, '..', 'static')); 33 | 34 | await app.listen(3000); 35 | } 36 | bootstrap(); 37 | -------------------------------------------------------------------------------- /src/modules/user/login.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Post, 4 | Request, 5 | UnauthorizedException, 6 | Get, 7 | } from '@nestjs/common'; 8 | import { ApiController } from 'src/app/app.decorator'; 9 | import { AuthService, NoAuth } from 'src/services/auth.service'; 10 | import { LoginDto } from './user.dto'; 11 | 12 | @ApiController('login', '登录') 13 | export class LoginController { 14 | constructor(private authService: AuthService) {} 15 | 16 | @NoAuth() 17 | @Post() 18 | async login(@Body() { name, password }: LoginDto) { 19 | const user = await this.authService.validate(name, password); 20 | 21 | if (!user) { 22 | throw new UnauthorizedException('用户名或密码错误'); 23 | } 24 | return await this.authService.generateToken(user); 25 | } 26 | 27 | @Get('status') 28 | async status(@Request() req) { 29 | return req.user; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Body, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseIntPipe, 8 | Post, 9 | } from '@nestjs/common'; 10 | import { InjectRepository } from '@nestjs/typeorm'; 11 | import { User } from './user.entity'; 12 | import { MD5 } from 'src/app/utils'; 13 | import { Repository } from 'typeorm'; 14 | import { ApiController } from 'src/app/app.decorator'; 15 | import { ApiOperation } from '@nestjs/swagger'; 16 | 17 | @ApiController('user', '用户管理') 18 | export class UserController { 19 | constructor( 20 | @InjectRepository(User) 21 | private repository: Repository, 22 | ) {} 23 | 24 | @ApiOperation({ summary: '获取用户信息' }) 25 | @Get() 26 | async all() { 27 | return (await this.repository.find()).map((item) => item.removeSensitive()); 28 | } 29 | 30 | @Post() 31 | async add(@Body() _user: User) { 32 | let user: User = _user; 33 | if (user.id) { 34 | const oldUser = await this.repository.findOneBy({ id: user.id }); 35 | user = Object.assign(oldUser, _user); 36 | console.log(user); 37 | } else { 38 | const namedUser = await this.repository.findOneBy({ name: user.name }); 39 | if (namedUser) throw new BadRequestException('用户名已存在'); 40 | user = User.fromDto(_user); 41 | user.password = await MD5.encode(user.password); 42 | } 43 | const entity = await this.repository.save(user); 44 | return entity.removeSensitive(); 45 | } 46 | 47 | @Delete(':id') 48 | async remove(@Param('id', ParseIntPipe) id: number) { 49 | await this.repository.delete(id); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/modules/user/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | 3 | /** 4 | * 登录接口 DTO 5 | */ 6 | export class LoginDto { 7 | @IsNotEmpty() 8 | name: string; 9 | 10 | @IsNotEmpty() 11 | password: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity } from 'src/app/base.entity'; 2 | import { Column, Entity } from 'typeorm'; 3 | 4 | @Entity() 5 | export class User extends BaseEntity { 6 | @Column() 7 | name: string; 8 | 9 | @Column() 10 | password: string; 11 | 12 | @Column('simple-json') 13 | followers: number[] = []; 14 | 15 | @Column('simple-json') 16 | records: Date[] = []; 17 | 18 | @Column('text') 19 | note = ''; 20 | 21 | removeSensitive() { 22 | return { ...this, password: undefined }; 23 | } 24 | 25 | static fromDto(user: User) { 26 | const newUser = new User(); 27 | Object.assign(newUser, user); 28 | return newUser; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/user/user.http: -------------------------------------------------------------------------------- 1 | @base = http://localhost:3000 2 | 3 | # 获取所有用户 4 | 5 | GET {{base}}/user 6 | 7 | ### 8 | 9 | # 删除单个用户 10 | DELETE {{base}}/user/1 11 | 12 | ### 13 | 14 | # 新增用户 15 | 16 | POST {{base}}/user 17 | Content-Type: application/json 18 | 19 | { 20 | "name": "test", 21 | "password": "123456" 22 | } 23 | 24 | ### 25 | 26 | # 登录 27 | POST {{base}}/login 28 | Content-Type: application/json 29 | 30 | { 31 | "name": "test", 32 | "password": "123456" 33 | } 34 | 35 | ### 36 | 37 | # 获取登录信息 38 | GET {{base}}/login/status 39 | Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb2xsb3dlcnMiOltdLCJyZWNvcmRzIjpbXSwibm90ZSI6IiIsImlkIjoyLCJjcmVhdGVkX2F0IjoiMjAyMi0wNS0yM1QxMzo1Njo1OC4xODJaIiwidXBkYXRlZF9hdCI6IjIwMjItMDUtMjNUMTM6NTY6NTguMTgyWiIsIm5hbWUiOiJ0ZXN0IiwiaWF0IjoxNjUzMzE2MTk2LCJleHAiOjE2NTMzMTk3OTZ9.jTp7OaMdJRSTWY0wAzGYjDJvZjJk_GaR2Kp5ups6OCI -------------------------------------------------------------------------------- /src/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | CanActivate, 4 | ExecutionContext, 5 | SetMetadata, 6 | UnauthorizedException, 7 | } from '@nestjs/common'; 8 | import { Observable } from 'rxjs'; 9 | import { Reflector } from '@nestjs/core'; 10 | import { MD5 } from 'src/app/utils'; 11 | import { Repository } from 'typeorm'; 12 | import { User } from '../modules/user/user.entity'; 13 | import { AuthGuard, IAuthGuard, PassportStrategy } from '@nestjs/passport'; 14 | import { InjectRepository } from '@nestjs/typeorm'; 15 | import { JwtService } from '@nestjs/jwt'; 16 | import { AppConfig } from '../app/app.config'; 17 | import { ExtractJwt, Strategy } from 'passport-jwt'; 18 | 19 | /** 20 | * 无需认证的路由 21 | */ 22 | export const NoAuth = () => SetMetadata('no-auth', true); 23 | 24 | /** 25 | * 认证服务。校验登录信息、生成 token 26 | */ 27 | @Injectable() 28 | export class AuthService { 29 | constructor( 30 | @InjectRepository(User) private readonly userRepo: Repository, 31 | private readonly jwtService: JwtService, 32 | ) {} 33 | 34 | /** 35 | * 校验用户登录信息 36 | */ 37 | async validate(name: string, password: string): Promise { 38 | const user = await this.userRepo.findOneBy({ name }); 39 | 40 | if (!user || user.password !== (await MD5.encode(password))) { 41 | return null; 42 | } 43 | return user.removeSensitive(); 44 | } 45 | 46 | /** 47 | * 生成 token 48 | */ 49 | async generateToken(user: User) { 50 | const payload = user; 51 | return { 52 | access_token: this.jwtService.sign(payload), 53 | }; 54 | } 55 | 56 | parseToken(token: string): User { 57 | return this.jwtService.decode(token) as User; 58 | } 59 | } 60 | 61 | /** 62 | * 认证守卫 63 | * @description 如果未设置 `@NoAuth()`,则使用 JwtStrategy 进行校验。配合 app.module 做全局校验用 64 | */ 65 | @Injectable() 66 | export class MyAuthGuard implements CanActivate { 67 | constructor(private readonly reflector: Reflector) {} 68 | canActivate( 69 | context: ExecutionContext, 70 | ): boolean | Promise | Observable { 71 | // 在这里取metadata中的no-auth,得到的会是一个bool 72 | const noAuth = this.reflector.get('no-auth', context.getHandler()); 73 | const guard = MyAuthGuard.getAuthGuard(noAuth); 74 | if (guard) { 75 | return guard.canActivate(context); 76 | } 77 | return true; 78 | } 79 | 80 | // 根据NoAuth的t/f选择合适的策略Guard 81 | private static getAuthGuard(noAuth: boolean): IAuthGuard { 82 | if (noAuth) { 83 | return null; 84 | } else { 85 | return new JwtAuthGuard(); 86 | } 87 | } 88 | } 89 | 90 | /** 91 | * Jwt 校验策略 92 | */ 93 | @Injectable() 94 | export class JwtStrategy extends PassportStrategy(Strategy) { 95 | constructor() { 96 | super({ 97 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 98 | ignoreExpiration: false, 99 | secretOrKey: AppConfig.JWT_SECRET, 100 | }); 101 | } 102 | 103 | async validate(payload: any) { 104 | return payload; 105 | } 106 | } 107 | 108 | /** 109 | * Jwt 校验守卫 110 | * @description 主要为了自定义异常逻辑 111 | */ 112 | @Injectable() 113 | export class JwtAuthGuard extends AuthGuard('jwt') { 114 | handleRequest(err, user) { 115 | if (err || !user) { 116 | throw new UnauthorizedException('请登录后再访问'); 117 | } 118 | return user; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/services/chat.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SubscribeMessage, 3 | WebSocketGateway, 4 | OnGatewayInit, 5 | WebSocketServer, 6 | OnGatewayConnection, 7 | OnGatewayDisconnect, 8 | } from '@nestjs/websockets'; 9 | import { Logger, UseGuards } from '@nestjs/common'; 10 | import { Socket, Server } from 'socket.io'; 11 | import { AuthGuard } from '@nestjs/passport'; 12 | import { AuthService } from './auth.service'; 13 | 14 | enum SocketEvent { 15 | System = 'system', 16 | Message = 'message', 17 | Statistic = 'statistic', 18 | } 19 | 20 | @WebSocketGateway({ 21 | namespace: '/chat', 22 | }) 23 | export class ChatGateway 24 | implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 25 | { 26 | private clients: Map = new Map(); 27 | 28 | constructor(private readonly authService: AuthService) {} 29 | 30 | @WebSocketServer() server: Server; 31 | private logger: Logger = new Logger(ChatGateway.name); 32 | 33 | @SubscribeMessage(SocketEvent.Message) 34 | handleMessage(client: Socket, payload: string): void { 35 | this.server.emit(SocketEvent.Message, payload); 36 | } 37 | 38 | afterInit(_: Server) { 39 | this.logger.log('聊天室初始化'); 40 | } 41 | 42 | handleDisconnect(client: Socket) { 43 | this.logger.log(`WS 客户端断开连接: ${client.id}`); 44 | this.clients.delete(client.id); 45 | this.sendStatistics(); 46 | } 47 | 48 | @UseGuards(AuthGuard('jwt')) 49 | handleConnection(client: Socket) { 50 | // TOKEN 校验 51 | 52 | // const token = client.handshake.headers.authorization; 53 | // const user = this.authService.parseToken(token.split(' ')[1]); 54 | // if (!user) { 55 | // return client.disconnect(true); 56 | // } 57 | 58 | this.logger.log(`WS 客户端连接成功: ${client.id}`); 59 | this.clients.set(client.id, client); 60 | client.emit(SocketEvent.System, '聊天室连接成功'); 61 | this.sendStatistics(); 62 | } 63 | 64 | sendStatistics() { 65 | this.server.emit(SocketEvent.Statistic, this.clients.size); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/services/task.schedule.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { Cron, CronExpression, Interval, Timeout } from '@nestjs/schedule'; 3 | 4 | /** 5 | * @see [定时任务](https://docs.nestjs.cn/8/techniques?id=%e5%ae%9a%e6%97%b6%e4%bb%bb%e5%8a%a1) 6 | */ 7 | @Injectable() 8 | export class TasksSchedule { 9 | private readonly logger = new Logger(TasksSchedule.name); 10 | 11 | @Cron(CronExpression.EVERY_DAY_AT_6PM) 12 | task1() { 13 | this.logger.debug('task1 - 每天下午6点执行一次'); 14 | } 15 | 16 | @Cron(CronExpression.EVERY_2_HOURS) 17 | task2() { 18 | this.logger.debug('task2 - 每2小时执行一次'); 19 | } 20 | 21 | @Interval(30 * 60 * 1000) 22 | task3() { 23 | this.logger.debug('task3 - 每30分钟执行一次'); 24 | } 25 | 26 | @Timeout(5 * 1000) 27 | task4() { 28 | this.logger.debug('task4 - 启动5s后执行一次'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /static/socket/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Nestjs SocketIO 聊天室 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |
18 |
{{message.from}}
19 |
{{message.text}}
20 |
21 |
22 |
23 | 24 | 25 |
26 |
发送
27 | {{tip}} 28 |
29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /static/socket/main.js: -------------------------------------------------------------------------------- 1 | new Vue({ 2 | el: '#app', 3 | data: { 4 | name: '', 5 | text: '', 6 | messages: [], 7 | socket: null, 8 | joined: false, 9 | tip: '', 10 | }, 11 | created() { 12 | this.socket = io('/chat', { 13 | transportOptions: { 14 | polling: { 15 | extraHeaders: { 16 | Authorization: 17 | 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb2xsb3dlcnMiOlsxLDIsMyw1XSwicmVjb3JkcyI6W10sIm5vdGUiOiIiLCJpZCI6MSwiY3JlYXRlZF9hdCI6IjIwMjItMDUtMTBUMTQ6Mzk6MjMuMzQ2WiIsInVwZGF0ZWRfYXQiOiIyMDIyLTA1LTEwVDE0Oj', 18 | }, 19 | }, 20 | }, 21 | }); 22 | this.socket.on('message', (message) => { 23 | this.onReceive(message); 24 | }); 25 | this.socket.on('system', (message) => { 26 | this.onReceive({ from: '系统', text: message, isSystem: true }); 27 | }); 28 | this.socket.on('statistic', (count) => { 29 | this.tip = count ? `当前在线人数:${count}` : ''; 30 | }); 31 | this.socket.on('exception', (exception) => { 32 | console.warn(exception); 33 | }); 34 | }, 35 | methods: { 36 | onSend() { 37 | const { name, text } = this; 38 | if (name && text) { 39 | this.socket.emit('message', { from: name, text }); 40 | this.joined = true; 41 | this.text = ''; 42 | } 43 | }, 44 | async onReceive(message) { 45 | this.messages.push(message); 46 | await this.$nextTick(); 47 | const msgs = this.$refs.msgs; 48 | const { scrollHeight, offsetHeight } = msgs; 49 | msgs.scrollTo(0, scrollHeight - offsetHeight); 50 | }, 51 | }, 52 | }); 53 | -------------------------------------------------------------------------------- /static/socket/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, 6 | Helvetica Neue, Arial, Noto Sans, sans-serif, apple color emoji, 7 | segoe ui emoji, Segoe UI Symbol, noto color emoji; 8 | background-color: #f6f6f6; 9 | } 10 | 11 | .container { 12 | display: flex; 13 | flex-direction: column; 14 | position: absolute; 15 | height: 90vh; 16 | width: 90vw; 17 | top: 3vh; 18 | left: 5vw; 19 | padding: 20px 0; 20 | border-radius: 10px; 21 | background-color: #f6f6f6; 22 | box-sizing: border-box; 23 | box-shadow: 3px 4px 20px 5px rgba(0, 0, 0, 0.1); 24 | } 25 | 26 | .tip { 27 | position: fixed; 28 | top: 93vh; 29 | right: 5vw; 30 | font-size: 14px; 31 | font-style: italic; 32 | margin-top: 10px; 33 | color: #999; 34 | } 35 | 36 | .msgs { 37 | flex: 1; 38 | overflow: auto; 39 | margin-bottom: 10px; 40 | padding: 0 20px; 41 | } 42 | 43 | .msg { 44 | display: flex; 45 | flex-direction: row; 46 | margin-bottom: 10px; 47 | line-height: 1.5em; 48 | align-items: flex-start; 49 | background-color: #fff; 50 | border-radius: 5px; 51 | padding: 5px; 52 | } 53 | 54 | .msg.system { 55 | color: #999; 56 | background-color: transparent; 57 | } 58 | 59 | .msg-from { 60 | padding: 0 8px; 61 | color: #333; 62 | border-radius: 5px; 63 | text-align: center; 64 | width: 100px; 65 | box-sizing: border-box; 66 | margin-right: 10px; 67 | overflow: hidden; 68 | text-overflow: ellipsis; 69 | white-space: nowrap; 70 | background-color: antiquewhite; 71 | } 72 | 73 | .system .msg-from { 74 | background-color: #000; 75 | color: white; 76 | } 77 | 78 | .msg-text { 79 | flex: 1; 80 | white-space: pre; 81 | } 82 | 83 | .inputs { 84 | display: flex; 85 | flex-direction: row; 86 | align-items: flex-start; 87 | border-top: 3px solid #eee; 88 | } 89 | 90 | .input-name { 91 | width: 100px; 92 | margin-right: 10px; 93 | text-align: center; 94 | } 95 | 96 | .input-content { 97 | flex: 1; 98 | height: 100px; 99 | resize: none; 100 | } 101 | 102 | .send, 103 | input, 104 | textarea { 105 | transition: all ease-in-out 0.3s; 106 | } 107 | 108 | input, 109 | textarea { 110 | outline-style: none; 111 | border: 1px solid #ced4da; 112 | border-radius: 0.25rem; 113 | padding: 0.25rem; 114 | } 115 | 116 | input:focus, 117 | textarea:focus { 118 | border: 1px solid #80bdff; 119 | box-shadow: 0 0 0 0.1rem rgb(0 123 255 / 25%); 120 | } 121 | 122 | .send { 123 | color: #333; 124 | display: inline-block; 125 | text-align: center; 126 | width: 50px; 127 | margin-right: 20px; 128 | border-radius: 5px; 129 | padding: 5px 10px; 130 | align-self: flex-end; 131 | cursor: pointer; 132 | background-color: rgba(0, 0, 0, 0.018); 133 | } 134 | .send:hover { 135 | background-color: rgba(0, 0, 0, 0.035); 136 | } 137 | .send.disable { 138 | color: #ccc; 139 | } 140 | 141 | /* 滚动槽 */ 142 | ::-webkit-scrollbar { 143 | width: 6px; 144 | } 145 | ::-webkit-scrollbar-track { 146 | border-radius: 3px; 147 | background: rgba(0, 0, 0, 0.06); 148 | box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.08); 149 | } 150 | /* 滚动条滑块 */ 151 | ::-webkit-scrollbar-thumb { 152 | border-radius: 3px; 153 | transition: background-color 1000ms ease-in-out; 154 | background-color: rgba(0, 0, 0, 0.08); 155 | box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.1); 156 | } 157 | 158 | ::-webkit-scrollbar-thumb:hover { 159 | border-radius: 3px; 160 | background-color: rgba(0, 0, 0, 0.15); 161 | box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.2); 162 | } 163 | 164 | @media screen and (max-width: 700px) { 165 | /* 滚动槽 */ 166 | ::-webkit-scrollbar { 167 | width: 3px; 168 | } 169 | ::-webkit-scrollbar-track { 170 | border-radius: 1.5px; 171 | background: rgba(0, 0, 0, 0.06); 172 | box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.08); 173 | } 174 | /* 滚动条滑块 */ 175 | ::-webkit-scrollbar-thumb { 176 | border-radius: 1.5px; 177 | background: rgba(#6daac9, 0.5); 178 | box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.2); 179 | } 180 | } 181 | 182 | .inputs { 183 | padding: 18px; 184 | } 185 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | const SERVER_LOCATION = `http://localhost:3000`; 3 | 4 | // 直接在服务器启动的情况下测试 5 | 6 | describe('AppController (e2e)', () => { 7 | const origin = 'http://localhost:3002'; 8 | it('跨域测试', () => { 9 | return request(SERVER_LOCATION) 10 | .options('/') 11 | .set('Origin', origin) 12 | .expect('Access-Control-Allow-Origin', origin); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /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 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | --------------------------------------------------------------------------------