编程导航Nest话题讨论

Nest

6 参与
分享

快来分享你的内容吧~

点击登录,快来和大家讨论吧~
表情
图片
话题
打卡
综合
交流
文章
问答

Nest 实现天气预报查询服务

<html> <head></head> <body> <div class="content ql-editor"> <p>今天我们来实现一个查询城市天气预报的服务。</p> <p>使用的是和风天气的免费 api。</p> <p>免费的接口一天可以请求 1000 次,自己的项目足够用了:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/icdam3b7.jpeg" alt=""></p> <p>最多可以查询未来 7 天的天气预报。</p> <p>首先,登录<a href="https://id.qweather.com/#/login" target="_blank">和风天气</a>,</p> <p>然后在用户中心绑定邮箱和手机号:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/0vr0zmw0.jpeg" alt=""></p> <p>之后进入<a href="https://console.qweather.com/#/console" target="_blank">控制台</a>,点击创建项目:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/6j2rlwbg.jpeg" alt=""></p> <p>这里大家选择免费订阅(我别的项目用了,就没免费名额了),指定 key 的名字:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/h0ewg85b.jpeg" alt=""></p> <p>然后就可以看到你的 key 了:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/j1hdt4vz.jpeg" alt=""></p> <p>如果我们要查询青岛未来 7 天的天气。</p> <p>需要先查询青岛的 id:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/2bmtii4c.jpeg" alt=""></p> <p>location 是城市名字的拼音,然后带上刚刚的 key:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> https://devapi.qweather.com/v7/weather/7d?location=101120606&amp;key=aff40f07926348b9b06f3229d2b52e6a </div> </div> <p>返回了青岛市和各个区的信息。</p> <p>有了城市 id 之后就可以查询天气了:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> https://api.qweather.com/v7/weather/7d?location=101120201&amp;key=187d6c3dd15f4d2d99e2a7e0ee08ba04 </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/99o04wpw.jpeg" alt=""></p> <p>这里返回了从 2024-5-1 到 2024-5-7 的天气。</p> <p>具体字段的解释可以看<a href="https://dev.qweather.com/docs/api/weather/weather-daily-forecast/#%E8%BF%94%E5%9B%9E%E6%95%B0%E6%8D%AE" target="_blank">文档</a>:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/q02l6jjy.jpeg" alt=""></p> <p>这样我们就实现了查询某个城市为了 7 天的天气的功能。</p> <p>还有个问题,现在是先用城市的拼音查的 id,再用 id 查的天气。</p> <p>那直接让用户输入城市拼音么?</p> <p>这样也不好,我们可以用 pinyin 这个包:</p> <p>它可以拿到中文的拼音:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/k3oyul6q.jpeg" alt=""></p> <p>这样,整个流程就串起来了。</p> <p>当然,如果你想让用户直接选择城市,然后查询城市的天气,这种就要拿到所有城市的信息了。</p> <p>网上有挺多这种 <a href="https://blog.csdn.net/qq_32353771/article/details/82221604" target="_blank">JSON 数据</a>的:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/pqedn92h.jpeg" alt=""></p> <p>有所有城市名和它的 id。</p> <p>思路理清了,我们来写下代码:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install -g @nestjs/cli nest new city-weather </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/53mv6c22.jpeg" alt=""></p> <p>安装 pinyin 包和它的类型:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install --save pinyin@alpha npm install --save-dev @types/pinyin </div> </div> <p>然后我们加个接口测试下:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/z11sxhwl.jpeg" alt=""></p> <div class="ql-code-block-container"> <div class="ql-code-block"> @Get('pinyin') pinyin(@Query('text') text: string) { return pinyin(text).join('') } </div> </div> <p>把服务跑起来:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm run start:dev </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/zellxicj.jpeg" alt=""></p> <p>访问下试试:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/ycjabwqq.jpeg" alt=""></p> <p>可以看到,确实返回了拼音。</p> <p>但是我们不需要知道是几声。</p> <p>改下参数:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/7xkmuzw1.jpeg" alt=""></p> <p>这样就好了:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/b83s0tkk.jpeg" alt=""></p> <p>然后 nest 服务里怎么访问三方接口呢?</p> <p>直接用 axios 么?</p> <p>可以,但是我们希望统一配置 axios,然后各个模块都用同一个 axios 实例。</p> <p>所以用 @nestjs/axios 这个包:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install --save @nestjs/axios axios </div> </div> <p>在 AppModule 引入下 HttpModule:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/uidxvgxs.jpeg" alt=""></p> <p>这里可以填入各种请求配置,比如 baseURL 等,其实就是 new 了一个 Axios 实例:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/udndxdsu.jpeg" alt=""></p> <p>然后在用到的地方注入 httpService:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/2eb320is.jpeg" alt=""></p> <div class="ql-code-block-container"> <div class="ql-code-block"> @Inject(HttpService) private httpService: HttpService; @Get('weather/:city') async weather(@Param('city') city: string) { const cityPinyin = pinyin(city, { style: 'normal'}).join(''); const { data } = await firstValueFrom( this.httpService.get(`https://geoapi.qweather.com/v2/city/lookup?location=${cityPinyin}&amp;key=187d6c3dd15f4d2d99e2a7e0ee08ba04`) ) return data; } </div> </div> <p>用 @Param 取路径中的参数。</p> <p>然后用 pinyin 拿到 city 的拼音,然后调用和风天气的接口。</p> <p>这里为啥用 firstValueFrom 的 rxjs 操作符呢?</p> <p>因为 HttpModule 把 axios 的方法返回值封装成了 rxjs 的 Observerable。</p> <p>好处是你可以用 rxjs 的操作符了。</p> <p>坏处是转成 promise 还得加一层 firstValueFrom。</p> <p>它就是用来把 rxjs Observable 转成 promise 的:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/9zzzrpe4.jpeg" alt=""></p> <p>测试下:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/w2z86hss.jpeg" alt=""></p> <p>没啥问题。</p> <p>然后继续调用天气预报接口:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> @Get('weather/:city') async weather(@Param('city') city: string) { const cityPinyin = pinyin(city, { style: 'normal'}).join(''); const { data } = await firstValueFrom( this.httpService.get(`https://geoapi.qweather.com/v2/city/lookup?location=${cityPinyin}&amp;key=187d6c3dd15f4d2d99e2a7e0ee08ba04`) ) const location = data?.['location']?.[0]; if(!location) { throw new BadRequestException('没有对应的城市信息'); } const { data: weatherData } = await firstValueFrom( this.httpService.get(`https://api.qweather.com/v7/weather/7d?location=${location.id}&amp;key=187d6c3dd15f4d2d99e2a7e0ee08ba04`) ) return weatherData; } </div> </div> <p>如果没查到 location,返回 400 错误。</p> <p>否则用 location.id 查询该城市天气预报。</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/npwk867i.jpeg" alt=""></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/5u8mnkll.jpeg" alt=""></p> <p>这样,我们的城市天气预报服务就完成了。</p> <p>案例代码上传了<a href="https://github.com/QuarkGluonPlasma/nestjs-course-code/tree/main/city-weather" target="_blank">小册仓库</a></p> <h2>总结</h2> <p>我们基于和风天气的 api 实现了天气预报查询服务。</p> <p>主要用到了 pinyin 这个包来完成中文转拼音,然后用 pinyin 去请求和风天气的 api 查询城市 id。</p> <p>接下来用城市 id 请求天气数据。</p> <p>和风天气的 api 免费版一天可以调用 1000 次,足够用了。</p> <p>Nest 里发送 http 请求,我们用的是 @nestjs/axios 包的 HttpModule 来做的。</p> <p>它可以统一配置,然后注入 HttpService 到用到的地方,并且 httpService 方法的返回值封装成了 rxjs 的 Observerable,可以直接用 rxjs 的操作符。</p> <p>比如用 fistValueFrom 来把 rxjs 的 Observable 转为 Promise。</p> <p>这样,你就可以在你的应用中集成天气预报功能了。</p> #后端# </div> </body> </html>

Nest 实现 GraphQL 版 TodoList

<html> <head></head> <body> <div class="content ql-editor"> <p>我们经常用 restful 的接口来开发业务。</p> <p>比如 GET 请求 /students 查询所有学生,/students/1 查询 id 为 1 的学生</p> <p>发送 POST、PUT、DETETE 请求分别代表增删改。</p> <p>其实也可以用 GraphQL 的方式来写接口:</p> <p>查询: <img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/24kwg2ul.jpeg" alt=""></p> <p>新增: <img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/bdfc9xv5.jpeg" alt=""></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/yrwfac6o.jpeg" alt=""></p> <p>增删改查都在一个接口里搞定,并且想要什么数据由前端自己取。</p> <p>今天我们就用 Nest + GrahQL 做一个 TodoList 的增删改查。</p> <p>数据存在 mysql 里,用 Prisma 作为 ORM 框架。</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install -g @nestjs/cli nest new graphql-todolist </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/cbvcx2fx.jpeg" alt=""></p> <p>创建个项目,然后我们首先来实现 restful 接口的增删改查。</p> <p>用 docker 把 mysql 跑起来:</p> <p>从 docker 官网下载 docker desktop,这个是 docker 的桌面端:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/3h2lqjrw.jpeg" alt=""></p> <p>跑起来后,搜索 mysql 镜像(这步需要科学上网),点击 run:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/zvf9d351.jpeg" alt=""></p> <p>输入容器名、端口映射、以及挂载的数据卷,还要指定一个环境变量:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/zwnymla3.jpeg" alt=""></p> <p>端口映射就是把宿主机的 3306 端口映射到容器里的 3306 端口,这样就可以在宿主机访问了。</p> <p>数据卷挂载就是把宿主机的某个目录映射到容器里的 /var/lib/mysql 目录,这样数据是保存在本地的,不会丢失。</p> <p>而 MYSQL_ROOT_PASSWORD 的密码则是 mysql 连接时候的密码。</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/zj1yuf25.jpeg" alt=""></p> <p>跑起来后,我们用 GUI 客户端连上,这里我们用的是 mysql workbench,这是 mysql 官方提供的免费客户端:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/yjvb4uo3.jpeg" alt=""></p> <p>连接上之后,点击创建 database:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/dlpyr7rq.jpeg" alt=""></p> <p>指定名字、字符集为 utf8mb4,然后点击右下角的 apply。</p> <p>创建成功之后在左侧就可以看到这个 database 了:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/ujj6abp9.jpeg" alt=""></p> <p>现在还没有表。</p> <p>我们在 Nest 里用 Prisma 连接 mysql。</p> <p>进入项目,安装 prisma</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install prisma --save-dev </div> </div> <p>执行 prisma init 创建 schema 文件:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npx prisma init </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/hhr8zd84.jpeg" alt=""></p> <p>生成了 schema 文件(用来定义 model 的),和 .env 文件:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/1kd10sxd.jpeg" alt=""></p> <p>改下 .env 的配置:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> DATABASE_URL="mysql://root:你的密码@localhost:3306/todolist" </div> </div> <p>并且修改下 schema 里的 datasource 部分:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> datasource db { provider = "mysql" url = env("DATABASE_URL") } </div> </div> <p>然后创建 model:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/8t4sit8r.jpeg" alt=""></p> <div class="ql-code-block-container"> <div class="ql-code-block"> generator client { provider = "prisma-client-js" } datasource db { provider = "mysql" url = env("DATABASE_URL") } model TodoItem { id Int @id @default(autoincrement()) content String @db.VarChar(50) createTime DateTime @default(now()) updateTime DateTime @updatedAt } </div> </div> <p>id 自增,content 是长度为 50 的字符串,还有创建时间 createTime、更新时间 updateTime。</p> <p>执行 prisma migrate dev,它会根据定义的 model 去创建表:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npx prisma migrate dev --name init </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/nfetsh8c.jpeg" alt=""></p> <p>它会生成 sql 文件,里面是这次执行的 sql。</p> <p>然后还会生成 client 代码,用来连接数据库操作这个表。</p> <p>可以看到,这次执行的 sql 就是 create table 建表语句:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/j7qmgvuy.jpeg" alt=""></p> <p>这时候数据库就就有这个表了:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/e5lp936t.jpeg" alt=""></p> <p>接下来我们就可以在代码里做 CRUD 了。</p> <p>生成一个 service:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> nest g service prisma --flat --no-spec </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/dvwy0m9j.jpeg" alt=""></p> <p>改下生成的 PrismaService,继承 PrismaClient,这样它就有 crud 的 api 了:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> import { Injectable, OnModuleInit } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit { constructor() { super({ log: [ { emit: 'stdout', level: 'query' } ] }) } async onModuleInit() { await this.$connect(); } } </div> </div> <p>在 constructor 里设置 PrismaClient 的 log 参数,也就是打印 sql 到控制台。</p> <p>在 onModuleInit 的生命周期方法里调用 $connect 来连接数据库。</p> <p>然后在 AppService 里注入 PrismaService,实现 CRUD:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> import { Inject, Injectable } from '@nestjs/common'; import { PrismaService } from './prisma.service'; import { CreateTodoList } from './todolist-create.dto'; import { UpdateTodoList } from './todolist-update.dto'; @Injectable() export class AppService { getHello(): string { return 'Hello World!'; } @Inject(PrismaService) private prismaService: PrismaService; async query() { return this.prismaService.todoItem.findMany({ select: { id: true, content: true, createTime: true } }); } async create(todoItem: CreateTodoList) { return this.prismaService.todoItem.create({ data: todoItem, select: { id: true, content: true, createTime: true } }); } async update(todoItem: UpdateTodoList) { return this.prismaService.todoItem.update({ where: { id: todoItem.id }, data: todoItem, select: { id: true, content: true, createTime: true } }); } async remove(id: number) { return this.prismaService.todoItem.delete({ where: { id } }) } } </div> </div> <p>@Inject 注入 PrismaService,用它来做 CRUD,where 是条件、data 是数据,select 是回显的字段:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/wubjb9jh.jpeg" alt=""></p> <p>然后创建用到的两个 dto 的 class</p> <p>todolist-create.dto.ts</p> <div class="ql-code-block-container"> <div class="ql-code-block"> export class CreateTodoList { content: string; } </div> </div> <p>todolist-update.dto.ts</p> <div class="ql-code-block-container"> <div class="ql-code-block"> export class UpdateTodoList { id: number; content: string; } </div> </div> <p>在 AppController 里引入下,添加几个路由:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> import { Body, Controller, Delete, Get, Post, Query } from '@nestjs/common'; import { AppService } from './app.service'; import { CreateTodoList } from './todolist-create.dto'; import { UpdateTodoList } from './todolist-update.dto'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() getHello(): string { return this.appService.getHello(); } @Post('create') async create(@Body() todoItem: CreateTodoList) { return this.appService.create(todoItem); } @Post('update') async update(@Body() todoItem: UpdateTodoList) { return this.appService.update(todoItem); } @Get('delete') async delete(@Query('id') id: number) { return this.appService.remove(+id); } @Get('list') async list() { return this.appService.query(); } } </div> </div> <p>添加增删改查 4 个路由,post 请求用 @Body() 注入请求体,@Query 拿路径中的参数:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/ebdqzlxq.jpeg" alt=""></p> <p>把服务跑起来试一下:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm run start:dev </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/eez8tfuc.jpeg" alt=""></p> <p>首先是 list,现在没有数据:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/ueswlnxg.jpeg" alt=""></p> <p>然后添加一个:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/x09olfbu.jpeg" alt=""></p> <p>服务端打印了 insert into 的 sql:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/p5158942.jpeg" alt=""></p> <p>数据库也有了这条记录:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/iiobdunl.jpeg" alt=""></p> <p>再加一个:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/d9dr5aqn.jpeg" alt=""></p> <p>然后查一下:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/84o66ar5.jpeg" alt=""></p> <p>接下来试下修改、删除:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/oqnstbp2.jpeg" alt=""></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/s3fm5mn4.jpeg" alt=""></p> <p>再查一下:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/vm6o4n0u.jpeg" alt=""></p> <p>没啥问题。</p> <p>这样,todolist 的 restful 版接口就完成了。</p> <p>接下来实现 graphql 版本:</p> <p>安装用到的包:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm i @nestjs/graphql @nestjs/apollo @apollo/server graphql </div> </div> <p>然后在 AppModule 里引入下:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { PrismaService } from './prisma.service'; import { GraphQLModule } from '@nestjs/graphql'; import { ApolloDriver } from '@nestjs/apollo'; @Module({ imports: [ GraphQLModule.forRoot({ driver: ApolloDriver, typePaths: ['./**/*.graphql'], }) ], controllers: [AppController], providers: [AppService, PrismaService], }) export class AppModule {} </div> </div> <p>typePaths 就是 schema 文件的路径:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/80k9pr54.jpeg" alt=""></p> <p>添加一个 todolist.graphql</p> <div class="ql-code-block-container"> <div class="ql-code-block"> type TodoItem { id: Int content: String } input CreateTodoItemInput { content: String } input UpdateTodoItemInput { id: Int! content: String } type Query { todolist: [TodoItem]! queryById(id: Int!): TodoItem } type Mutation { createTodoItem(todoItem: CreateTodoItemInput!): TodoItem! updateTodoItem(todoItem: UpdateTodoItemInput!): TodoItem! removeTodoItem(id: Int!): Int } </div> </div> <p>语法比较容易看懂,就是定义数据的结构。</p> <p>在 Query 下定义查询的接口,在 Mutation 下定义增删改的接口。</p> <p>然后实现 resolver,也就是这些接口的实现:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> nest g resolver todolist --no-spec --flat </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/1774pnsf.jpeg" alt=""></p> <div class="ql-code-block-container"> <div class="ql-code-block"> import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { PrismaService } from './prisma.service'; import { Inject } from '@nestjs/common'; import { CreateTodoList } from './todolist-create.dto'; import { UpdateTodoList } from './todolist-update.dto'; @Resolver() export class TodolistResolver { @Inject(PrismaService) private prismaService: PrismaService; @Query("todolist") async todolist() { return this.prismaService.todoItem.findMany(); } @Query("queryById") async queryById(@Args('id') id) { return this.prismaService.todoItem.findUnique({ where: { id } }) } @Mutation("createTodoItem") async createTodoItem(@Args("todoItem") todoItem: CreateTodoList) { return this.prismaService.todoItem.create({ data: todoItem, select: { id: true, content: true, createTime: true } }); } @Mutation("updateTodoItem") async updateTodoItem(@Args('todoItem') todoItem: UpdateTodoList) { return this.prismaService.todoItem.update({ where: { id: todoItem.id }, data: todoItem, select: { id: true, content: true, createTime: true } }); } @Mutation("removeTodoItem") async removeTodoItem(@Args('id') id: number) { await this.prismaService.todoItem.delete({ where: { id } }) return id; } } </div> </div> <p>用 @Resolver 声明 resolver,用 @Query 声明查询接口,@Mutation 声明增删改接口,@Args 取传入的参数。</p> <p>具体增删改查的实现和之前一样。</p> <p>浏览器访问 http://localhost:3000/graphql 就是 playground,可以在这里查询:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/9i4xosab.jpeg" alt=""></p> <p>左边输入查询语法,右边是执行后返回的结果。</p> <p>当然,对新手来说这个 playground 不够友好,没有提示。</p> <p>我们换一个:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/91e0u939.jpeg" alt=""></p> <div class="ql-code-block-container"> <div class="ql-code-block"> import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { PrismaService } from './prisma.service'; import { GraphQLModule } from '@nestjs/graphql'; import { ApolloDriver } from '@nestjs/apollo'; import { TodolistResolver } from './todolist.resolver'; import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default'; @Module({ imports: [ GraphQLModule.forRoot({ driver: ApolloDriver, typePaths: ['./**/*.graphql'], playground: false, plugins: [ApolloServerPluginLandingPageLocalDefault()], }) ], controllers: [AppController], providers: [AppService, PrismaService, TodolistResolver], }) export class AppModule {} </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/44s52rus.jpeg" alt=""></p> <p>试一下新增:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/ouil638z.jpeg" alt=""></p> <p>查询:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/ltyp72wf.jpeg" alt=""></p> <p>修改:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/y174ws8u.jpeg" alt=""></p> <p>单个查询:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/ynhz9n4r.jpeg" alt=""></p> <p>删除:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/92ciucpu.jpeg" alt=""></p> <p>查询:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/dkha0fkv.jpeg" alt=""></p> <p>基于 GraphQL 的增删改查都成功了!</p> <p>然后在 react 项目里调用下。</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npx create-vite </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/3rbdk5xn.jpeg" alt=""></p> <p>进入项目,安装 @apollo/client</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install npm install @apollo/client </div> </div> <p>改下 main.tsx</p> <div class="ql-code-block-container"> <div class="ql-code-block"> import * as ReactDOM from 'react-dom/client'; import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client'; import App from './App'; const client = new ApolloClient({ uri: 'http://localhost:3000/graphql', cache: new InMemoryCache(), }); const root = ReactDOM.createRoot(document.getElementById('root')!); root.render( &lt;ApolloProvider client={client}&gt; &lt;App /&gt; &lt;/ApolloProvider&gt;, ); </div> </div> <p>创建 ApolloClient 并设置到 ApolloProvider。</p> <p>然后在 App.tsx 里用 useQuery 发请求:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> import { gql, useQuery } from '@apollo/client'; const getTodoList = gql` query Query { todolist { content id } } `; type TodoItem = { id: number; content: string; } type TodoList = { todolist: Array&lt;TodoItem&gt;; } export default function App() { const { loading, error, data } = useQuery&lt;TodoList&gt;(getTodoList); if (loading) return 'Loading...'; if (error) return `Error! ${error.message}`; return ( &lt;ul&gt; { data?.todolist?.map(item =&gt; { return &lt;li key={item.id}&gt;{item.content}&lt;/li&gt; }) } &lt;/ul&gt; ); } </div> </div> <p>把服务跑起来:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm run dev </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/z4m9gn0w.jpeg" alt=""></p> <p>这里涉及到的跨域,现在后端服务里开启下跨域支持:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/2thiq75l.jpeg" alt=""></p> <p>可以看到,返回了查询结果:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/4md42z39.jpeg" alt=""></p> <p>然后加一下新增:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/yvouhbn8.jpeg" alt=""></p> <p>用 useMutation 的 hook,指定 refetchQueries 也就是修改完之后重新获取数据。</p> <p>调用的时候传入 content 数据。</p> <div class="ql-code-block-container"> <div class="ql-code-block"> import { gql, useMutation, useQuery } from '@apollo/client'; const getTodoList = gql` query Query { todolist { content id } } `; const createTodoItem = gql` mutation Mutation($todoItem: CreateTodoItemInput!) { createTodoItem(todoItem: $todoItem) { id content } } `; type TodoItem = { id: number; content: string; } type TodoList = { todolist: Array&lt;TodoItem&gt;; } export default function App() { const { loading, error, data } = useQuery&lt;TodoList&gt;(getTodoList); const [createTodo] = useMutation(createTodoItem, { refetchQueries: [getTodoList] }); async function onClick() { await createTodo({ variables: { todoItem: { content: Math.random().toString().slice(2, 10) } } }) } if (loading) return 'Loading...'; if (error) return `Error! ${error.message}`; return ( &lt;div&gt; &lt;button onClick={onClick}&gt;新增&lt;/button&gt; &lt;ul&gt; { data?.todolist?.map(item =&gt; { return &lt;li key={item.id}&gt;{item.content}&lt;/li&gt; }) } &lt;/ul&gt; &lt;/div&gt; ); } </div> </div> <p>测试下:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/pvf7qofc.jpeg" alt=""></p> <p>数据库里也可能看到新增的数据:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/dezwh8h9.jpeg" alt=""></p> <p>这样,我们就能在 react 项目里用 graphql 做 CRUD 了。</p> <p>案例代码上传了 github。</p> <p>后端代码: https://github.com/QuarkGluonPlasma/nestjs-course-code/tree/main/graphql-todolist</p> <p>前端代码:https://github.com/QuarkGluonPlasma/nestjs-course-code/tree/main/graphql-todolist-client</p> <h2>总结</h2> <p>我们实现了 Restful 和 GraphQL 版的 CRUD。</p> <p>前端用 React + @apollo/client。</p> <p>后端用 Nest + GraphQL + Prisma + MySQL。</p> <p>GraphQL 主要是定义 schema 和 resolver 两部分,schema 是 Query、Mutation 的结构,resolver 是它的实现。</p> <p>可以在 playground 里调用接口,也可以在 react 里用 @appolo/client 调用。</p> <p>相比 restful 的版本,graphql 只需要一个接口,然后用查询语言来查,需要什么数据取什么数据,更加灵活。</p> <p>业务开发中,你会选择用 GraphQL 开发接口么?</p> #前端# #后端# </div> </body> </html>

Nest 大文件上传尝试

<html> <head></head> <body> <div class="content ql-editor"> <p>大家圣诞节快乐呀,我是晨光,上周五看到 @神说要有光 光哥发的文章,原文见,<a href="https://articles.zsxq.com/id_4iqlbn0nqofl.html" target="_blank">https://articles.zsxq.com/id_4iqlbn0nqofl.html</a></p> <p><br></p> <p>出于好奇,跟着光哥的文章敲了一遍,自己遇到了一些困惑,并尝试解决,过程还是蛮有趣的,分享给大家</p> <p><br></p> <p><strong>1、分片太多,合并时文件无法还原</strong></p> <p><br></p> <p>我随手截图一张,大概有 7.2 M,上传后合并的图片,发现只有很小的一部分</p> <p><br></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1639205986650562562/514khbj7.jpeg" style="" width="458"></p> <p><br></p> <p>我限制分片 20 次,合并后的图片如下,只有头部的一小部分,不清楚问题出在哪里,需要去定位问题。</p> <p><br></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1639205986650562562/h0k0feqg.jpeg" style="" width="562"></p> <p><br></p> <p>做实验,发现 size 比较小的图片,分片的次数少,合并后没有问题,所以我尝试调整分片的次数,比如,调整为 10 次,测试后发现,合并后的图片正常了!!</p> <p><br></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1639205986650562562/56l0a1gq.jpeg" style="" width="562"></p> <p><br></p> <p><strong>2、怎么保证分片的次数是 小于等于 10 的呢?</strong></p> <p><br></p> <p>之前分享的分段请求里面有提到过,不是无限制的分段,需设置一个边界值,也就是不让它分片的次数太多,需要在某个范围内,那应用到这里,最大分片次数为 10</p> <p><br></p> <p>根据上传的文件的大小,除以分片的 size,和 10 来比较,根据比较的结果,得到最终真实的分片的 size。</p> <p><br></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1639205986650562562/1xusiym7.jpeg" style="" width="566"></p> <p><br></p> <p>附上代码</p> <div class="ql-code-block-container"> <div class="ql-code-block"> const fileInput = document.querySelector('#fileInput'); </div> <div class="ql-code-block"><br> </div> <div class="ql-code-block"> const chunkSize = 20 * 1024; // 默认单次分片 size </div> <div class="ql-code-block"> const maxChunk = 10; // 最大分片次数 </div> <div class="ql-code-block"><br> </div> <div class="ql-code-block"> fileInput.onchange = async function () { </div> <div class="ql-code-block"> const file = fileInput.files[0]; </div> <div class="ql-code-block"> console.log(file); </div> <div class="ql-code-block"><br> </div> <div class="ql-code-block"> const chunks = []; </div> <div class="ql-code-block"><br> </div> <div class="ql-code-block"> let startPos = 0; </div> <div class="ql-code-block"><br> </div> <div class="ql-code-block"> // 最终实际的分片 size </div> <div class="ql-code-block"> const relChunkSize = </div> <div class="ql-code-block"> Math.ceil(file.size / chunkSize) &gt; maxChunk </div> <div class="ql-code-block"> ? Math.ceil(file.size / maxChunk) </div> <div class="ql-code-block"> : chunkSize; </div> <div class="ql-code-block"><br> </div> <div class="ql-code-block"> while (startPos &lt; file.size) { </div> <div class="ql-code-block"> chunks.push(file.slice(startPos, startPos + relChunkSize)); </div> <div class="ql-code-block"> startPos += relChunkSize; </div> <div class="ql-code-block"> } </div> <div class="ql-code-block"> // 其他代码省略 </div> <div class="ql-code-block"> } </div> </div> <p><br></p> <p>这样做有个好处,我们无法控制上传的图片到底有多大,但是能够保证最大的分片次数是 10,那最终合并的文件就不会有问题。</p> <p><br></p> <p><br></p> <p><strong>3、为什么要保证分片次数 &lt;=10 ?</strong></p> <p><br></p> <p>查了很多资料,也有很多猜想</p> <ol> <li data-list="bullet"><span class="ql-ui"></span>浏览器并发限制,但是请求都在本地,而且都发送成功,所以应该不是这个原因</li> <li data-list="bullet"><span class="ql-ui"></span>在 <a href="http://Node.js" target="_blank">Node.js</a> 中,EventEmitter 是一个用于处理事件的基类。当一个 EventEmitter 实例被创建时,它会默认添加一个最大监听器数量为 10 的限制;这个原因也不太像</li> <li data-list="bullet"><span class="ql-ui"></span>分片顺序不正确:如果分片上传的顺序不正确,合并后的文件可能会损坏。<strong>确保在合并分片之前按照正确的顺序进行合并</strong>。感觉有点像这个</li> </ol> <p><br></p> <p>说干就干, debug 了一下代码,发现读取到的文件顺序,1 后面竟然是 10,照这样合并确实会有问题。</p> <p>解决办法:尝试给文件排个序,从而保证分片读取的顺序正常。</p> <p><br></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1639205986650562562/ej4tva46.jpeg" style="" width="502"></p> <p><br></p> <p>按照上面的文件名格式,我们写个排序方法把文件的顺序给调过来</p> <p><br></p> <div class="ql-code-block-container"> <div class="ql-code-block"> files.sort((a, b) =&gt; { </div> <div class="ql-code-block"> const aLastDigit = parseInt(a.split('-')[1]); // 获取 a 的末尾数字 </div> <div class="ql-code-block"> const bLastDigit = parseInt(b.split('-')[1]); // 获取 b 的末尾数字 </div> <div class="ql-code-block"><br> </div> <div class="ql-code-block"> return aLastDigit - bLastDigit; // 按末尾数字从小到大排序 </div> <div class="ql-code-block"> }); </div> </div> <p><br></p> <p>我们重新上传一次,分片还是 20 ,有了排序方法,文件的读取顺序正常了,</p> <p><br></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1639205986650562562/f8xucc01.jpeg" style="" width="559"></p> <p><br></p> <p>最终合并出来的图片也正常了!!</p> <p><br></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1639205986650562562/4s7ch9fe.jpeg" style="" width="564"></p> <p><br></p> <p>印证了之前的猜想,确实是因为文件读取顺序导致合并异常的,再修最大批次数为 30,发现依旧可以合并正常。</p> <p><br></p> <p><strong>4、优化文件名排序</strong></p> <p><br></p> <p>基于上面的代码,我们给上传的文件重命名,多加个短横线,</p> <p><br></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1639205986650562562/ewzko54k.jpeg"></p> <p><br></p> <p>发现上传后合并又出现了之前的问题,合并的图片又只能展示出一小部分了,推测可能是排序出了问题,排查发现,现在的文件名 <a href="http://932769_test2-name.png-8" target="_blank">932769_test2-name.png-8</a> 有多个短横线</p> <p><br></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1639205986650562562/46e74pmp.jpeg"></p> <p>我们优化一下排序方法,不管名称中有多少个短横线,都只取最后一个值来进行排序</p> <p><br></p> <div class="ql-code-block-container"> <div class="ql-code-block"> files.sort((a, b) =&gt; { </div> <div class="ql-code-block"> const arrA = a.split('-'); </div> <div class="ql-code-block"> const arrB = b.split('-'); </div> <div class="ql-code-block"> const aLastDigit = parseInt(arrA[arrA.length - 1]); // 获取 a 的末尾数字 </div> <div class="ql-code-block"> const bLastDigit = parseInt(arrB[arrB.length - 1]); // 获取 b 的末尾数字 </div> <div class="ql-code-block"><br> </div> <div class="ql-code-block"> return aLastDigit - bLastDigit; // 按末尾数字从小到大排序 </div> <div class="ql-code-block"> }); </div> </div> <p><br></p> <p>修改后,重新上传,合并后的文件也能正常展示了。至此,我的探索结束</p> <p><br></p> <p><br></p> <p><strong>5、总结</strong></p> <p><br></p> <ol> <li data-list="bullet"><span class="ql-ui"></span>遇到问题不要退缩,大胆假设并验证</li> <li data-list="bullet"><span class="ql-ui"></span>限制最大批次为 10 的操作看起来有点多余,不是必须的,只能算是锦上添花啦</li> <li data-list="bullet"><span class="ql-ui"></span>分片合并的时候,文件读取顺序很重要</li> <li data-list="bullet"><span class="ql-ui"></span>为了确保文件读取顺序,需要进行排序,因此在文件分片上传时就需要有标识位能够确保文件的顺序</li> </ol> <p><br></p> <p>以上,全文完,Nest 还挺有意思的,以后多研究研究,大家有收获点个赞呀~</p> </div> </body> </html>

Nest 实现大文件分片上传

<html> <head></head> <body> <div class="content ql-editor"> <p>文件上传是常见需求,只要指定 content-type 为 multipart/form-data,内容就会以这种格式被传递到服务端:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/cgh4rtil.jpeg" alt=""></p> <p>服务端再按照 multipart/form-data 的格式提取数据,就能拿到其中的文件。</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/y4swm9gg.jpeg" alt=""></p> <p>但当文件很大的时候,事情就变得不一样了。</p> <p>假设传一个 100M 的文件需要 3 分钟,那传一个 1G 的文件就需要 30 分钟。</p> <p>这样是能完成功能,但是产品的体验会很不好。</p> <p>所以大文件上传的场景,需要做专门的优化。</p> <p>把 1G 的大文件分割成 10 个 100M 的小文件,然后这些文件并行上传,不就快了?</p> <p>然后等 10 个小文件都传完之后,再发一个请求把这 10 个小文件合并成原来的大文件。</p> <p>这就是大文件分片上传的方案。</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/a1ewz0wg.jpeg" alt=""></p> <p>那如何拆分和合并呢?</p> <p>浏览器里 Blob 有 slice 方法,可以截取某个范围的数据,而 File 就是一种 Blob:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/d1u7xaqp.jpeg" alt=""></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/6kpqeojb.jpeg" alt=""></p> <p>所以可以在 input 里选择了 file 之后,通过 slice 对 File 分片。</p> <p>那合并呢?</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/lo0msoyh.jpeg" alt=""></p> <p>fs 的 createWriteStream 方法支持指定 start,也就是从什么位置开始写入。</p> <p>这样把每个分片按照不同位置写入文件里,不就完成合并了么。</p> <p>思路理清了,接下来我们实现一下。</p> <p>创建个 Nest 项目:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install -g @nestjs/cli nest new large-file-sharding-upload </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/gjjxwg98.jpeg" alt=""></p> <p>在 AppController 添加一个路由:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/adotix6i.jpeg" alt=""></p> <div class="ql-code-block-container"> <div class="ql-code-block"> @Post('upload') @UseInterceptors(FilesInterceptor('files', 20, { dest: 'uploads' })) uploadFiles(@UploadedFiles() files: Array&lt;Express.Multer.File&gt;, @Body() body) { console.log('body', body); console.log('files', files); } </div> </div> <p>这是一个 post 接口,会读取请求体里的 files 文件字段传入该方法。</p> <p>这里还需要安装用到的 multer 包的类型:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install -D @types/multer </div> </div> <p>然后我们在网页里试一下:</p> <p>首先在 main.ts 里开启跨域支持:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/oyhxc6k9.jpeg" alt=""></p> <p>然后添加一个 index.html:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> &lt;!DOCTYPE html&gt; &lt;html lang="en"&gt; &lt;head&gt; &lt;meta charset="UTF-8"&gt; &lt;meta http-equiv="X-UA-Compatible" content="IE=edge"&gt; &lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt; &lt;title&gt;Document&lt;/title&gt; &lt;script src="https://unpkg.com/axios@0.24.0/dist/axios.min.js"&gt;&lt;/script&gt; &lt;/head&gt; &lt;body&gt; &lt;input id="fileInput" type="file" multiple/&gt; &lt;script&gt; const fileInput = document.querySelector('#fileInput'); fileInput.onchange = async function () { const data = new FormData(); data.set('name','光'); data.set('age', 20); [...fileInput.files].forEach(item =&gt; { data.append('files', item) }) const res = await axios.post('http://localhost:3000/upload', data); console.log(res); } &lt;/script&gt; &lt;/body&gt; &lt;/html&gt; </div> </div> <p>input 指定 multiple,可以选择多个文件。</p> <p>选择文件之后,通过 post 请求 upload 接口,携带 FormData。FormData 里保存着 files 和其它字段。</p> <p>起个静态服务:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npx http-server . </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/teub02gs.jpeg" alt=""></p> <p>浏览器访问下:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/5pgqwsmj.jpeg" alt=""></p> <p>选择几个文件:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/eqc6x8f1.jpeg" alt=""></p> <p>这时候,Nest 服务端就接收到了上传的文件和其他字段:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/lqtgw9qj.jpeg" alt=""></p> <p>当然,我们并不是想上传多个文件,而是一个大文件的多个分片。</p> <p>所以是这样写:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> &lt;!DOCTYPE html&gt; &lt;html lang="en"&gt; &lt;head&gt; &lt;meta charset="UTF-8"&gt; &lt;meta http-equiv="X-UA-Compatible" content="IE=edge"&gt; &lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt; &lt;title&gt;Document&lt;/title&gt; &lt;script src="https://unpkg.com/axios@0.24.0/dist/axios.min.js"&gt;&lt;/script&gt; &lt;/head&gt; &lt;body&gt; &lt;input id="fileInput" type="file"/&gt; &lt;script&gt; const fileInput = document.querySelector('#fileInput'); const chunkSize = 20 * 1024; fileInput.onchange = async function () { const file = fileInput.files[0]; console.log(file); const chunks = []; let startPos = 0; while(startPos &lt; file.size) { chunks.push(file.slice(startPos, startPos + chunkSize)); startPos += chunkSize; } chunks.map((chunk, index) =&gt; { const data = new FormData(); data.set('name', file.name + '-' + index) data.append('files', chunk); axios.post('http://localhost:3000/upload', data); }) } &lt;/script&gt; &lt;/body&gt; &lt;/html&gt; </div> </div> <p>对拿到的文件进行分片,然后单独上传每个分片,分片名字为文件名 + index。</p> <p>这里我们测试用的图片是 80k:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/n6pwl0md.jpeg" alt=""></p> <p>所以每 20k 一个分片,一共是 4 个分片。</p> <p>测试下:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/o2hir3un.jpeg" alt=""></p> <p>服务端接收到了这 4 个分片:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/fw6z8n4c.jpeg" alt=""></p> <p>然后我们把它们移动到单独的目录:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> @Post('upload') @UseInterceptors(FilesInterceptor('files', 20, { dest: 'uploads' })) uploadFiles(@UploadedFiles() files: Array&lt;Express.Multer.File&gt;, @Body() body: { name: string }) { console.log('body', body); console.log('files', files); const fileName = body.name.match(/(.+)\-\d+$/)[1]; const chunkDir = 'uploads/chunks_'+ fileName; if(!fs.existsSync(chunkDir)){ fs.mkdirSync(chunkDir); } fs.cpSync(files[0].path, chunkDir + '/' + body.name); fs.rmSync(files[0].path); } </div> </div> <p>用正则匹配出文件名:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/bpt4wvzm.jpeg" alt=""></p> <p>在 uploads 下创建 chunks_文件名 的目录,把文件复制过去,然后删掉原始文件。</p> <p>测试下:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/rtnn0bjr.jpeg" alt=""></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/84ilc424.jpeg" alt=""></p> <p>分片文件移动成功了。</p> <p>不过直接以 chunks_文件名 做为目录名,太容易冲突了。</p> <p>我们可以在上传文件的时候给文件名加一个随机的字符串。</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/m3hn13ka.jpeg" alt=""></p> <p>这样就不会冲突了:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/i69kwsui.jpeg" alt=""></p> <p>接下来,就是在全部分片上传完之后,发送合并分片的请求。</p> <p>添加一个 merge 的接口:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> @Get('merge') merge(@Query('name') name: string) { const chunkDir = 'uploads/chunks_'+ name; const files = fs.readdirSync(chunkDir); let startPos = 0; files.map(file =&gt; { const filePath = chunkDir + '/' + file; const stream = fs.createReadStream(filePath); stream.pipe(fs.createWriteStream('uploads/' + name, { start: startPos })) startPos += fs.statSync(filePath).size; }) } </div> </div> <p>接收文件名,然后查找对应的 chunks 目录,把下面的文件读取出来,按照不同的 start 位置写入到同一个文件里。</p> <p>浏览器访问下这个接口:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/6nny3tq5.jpeg" alt=""></p> <p>可以看到,合并成功了:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/6mnck94s.jpeg" alt=""></p> <p>再测试一个:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/8pxgfel5.jpeg" alt=""></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/87tx4vmi.jpeg" alt=""></p> <p>也没啥问题。</p> <p>然后我们在合并完成之后把 chunks 目录删掉。</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/mhbawvqm.jpeg" alt=""></p> <div class="ql-code-block-container"> <div class="ql-code-block"> @Get('merge') merge(@Query('name') name: string) { const chunkDir = 'uploads/chunks_'+ name; const files = fs.readdirSync(chunkDir); let count = 0; let startPos = 0; files.map(file =&gt; { const filePath = chunkDir + '/' + file; const stream = fs.createReadStream(filePath); stream.pipe(fs.createWriteStream('uploads/' + name, { start: startPos })).on('finish', () =&gt; { count ++; if(count === files.length) { fs.rm(chunkDir, { recursive: true }, () =&gt;{}); } }) startPos += fs.statSync(filePath).size; }); } </div> </div> <p>然后在前端代码里,当分片全部上传完之后,调用 merge 接口:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/sbuy033y.jpeg" alt=""></p> <div class="ql-code-block-container"> <div class="ql-code-block"> const tasks = []; chunks.map((chunk, index) =&gt; { const data = new FormData(); data.set('name', randomStr + '_' + file.name + '-' + index) data.append('files', chunk); tasks.push(axios.post('http://localhost:3000/upload', data)); }) await Promise.all(tasks); axios.get('http://localhost:3000/merge?name=' + randomStr + '_' + file.name); </div> </div> <p>连起来测试下:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/r7i4zgcn.jpeg" alt=""></p> <p>因为文件比较小,开启 network 的 slow 3g 网速来测。</p> <p>可以看到,分片上传和最后的合并都没问题。</p> <p>当然,你还可以加一个进度条,这个用 axios 很容易实现:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/7rvwuprm.jpeg" alt=""></p> <p>至此,大文件分片上传就完成了。</p> <p>案例代码上传里 github:https://github.com/QuarkGluonPlasma/nestjs-course-code/tree/main/large-file-sharding-upload</p> <h2>总结</h2> <p>当文件比较大的时候,文件上传会很慢,这时候一般我们会通过分片的方式来优化。</p> <p>原理就是浏览器里通过 slice 来把文件分成多个分片,并发上传。</p> <p>服务端把这些分片文件保存在一个目录下。</p> <p>当所有分片传输完成时,发送一个合并请求,服务端通过 fs.createWriteStream 指定 start 位置,来把这些分片文件写入到同一个文件里,完成合并。</p> <p>这样,我们就实现了大文件分片上传。</p> #后端# #前端# #Nest# </div> </body> </html>

短链服务?自己用 Nest 写一个

<html> <head></head> <body> <div class="content ql-editor"> <p>生活中我们经常遇到需要短链的场景。</p> <p>比如一段很长的 url:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/7n1rn2zw.jpeg" alt=""></p> <p>分享出去很不方便。</p> <p>这时候就可以通过短链服务把它缩短:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/jumy1h7x.jpeg" alt=""></p> <p>点击短链会跳转到原链接:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/jt1zrg6v.jpeg" alt=""></p> <p>这种在短信里很常见:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/w5y294qd.jpeg" alt=""></p> <p>因为短信是按照字数收费的,太长不但阅读体验不好,费用也高。</p> <p>所以都会生成短链之后再加到短信里。</p> <p>那短链是怎么实现的呢?</p> <p>很容易想到的思路是这样的:</p> <p>用 0、1、2、3、4、5 的递增 id 标识每个 url,把映射关系存到数据库里。</p> <p>这样访问短链的时候从数据库中查出对应的长链接,返回 302 重定向即可。</p> <p>比如刚才的短链服务就是通过 302 把短链重定向到长链:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/hk2oaxff.jpeg" alt=""></p> <p>这里也可以用 301。</p> <p>301 是永久重定向,就是重定向一次之后,下次浏览器就不会再访问短链,会直接访问长链接。</p> <p>302 是临时重定向,下次访问短链依然会先访问短链服务,返回 302 后再重定向到长链。</p> <p>这两种都可以,301 的话,短链服务压力小,不过 302 每次都会先访问短链服务,这样可以记录链接的访问次数等数据。</p> <p>比如刚才我们用的短链服务,就会记录这个链接的访问记录:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/wsyuko7t.jpeg" alt=""></p> <p>还可以做一些分析:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/35am550t.jpeg" alt=""></p> <p>所以访问记录也挺有价值的。</p> <p>一般短链服务都是用 302 来重定向。</p> <p>每个 url 的 id 我们会用 Base64 或者 Base62 编码:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> const data = '123456'; const buff = Buffer.from(data); const base64data = buff.toString('base64'); console.log(base64data); </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/d1tbtjnh.jpeg" alt=""></p> <p>base64 就是 26 个大写字母、26 个小写字母、10 个数字、2 个特殊字符,一共 64 个字符。</p> <p>而 base62 则是去掉了两个特殊字符,一共 62 个字符。</p> <p>做短链的话,我们用 base62 比较多。</p> <p>安装用到的包:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install base62 </div> </div> <p>测试下:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> const base62 = require("base62/lib/ascii"); const res = base62.encode(123456); console.log(res); </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/urk4uqjc.jpeg" alt=""></p> <p>按照这个思路,我们就能实现一个短链服务。</p> <p>在 mysql 里创建压缩码和长链接的对应关系的表,用 mysql 的自增 id 然后进行 base62 之后作为压缩码。</p> <p>访问短链的时候,根据压缩码查询这个表,找到长链接,通过 302 重定向到这个链接,并且记录短链访问记录。</p> <p>这样是可以的,但有个问题:</p> <p>用自增 id 作为压缩码,那别人很容易拿到上一个、下一个压缩码,从而拿到别的短链,万一这个短链是用来兑奖之类的呢?</p> <p>这样就会有安全问题。</p> <p>所以自增 id 的方案不太好。</p> <p>那如果我们对 url 做 hash 呢?</p> <p>也就是这样:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> const crypto = require('crypto'); function md5(str) { const hash = crypto.createHash('md5'); hash.update(str); return hash.digest('hex'); } console.log(md5('111222')) </div> </div> <p>这样太长了,有 32 位呢:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/gy6kgx7c.jpeg" alt=""></p> <p>倒是可以每 4 位取一个数字,然后组成一个 8 位的压缩码。</p> <p>但是,这样是有碰撞的可能的。</p> <p>也就是两个不同的 url 生成的压缩码一样。</p> <p>所以,hash 的方案也不行。</p> <p>还有一种方案,就是通过随机数的方式生成压缩码。</p> <p>比如这样:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> const base62 = require("base62/lib/ascii"); function generateRandomStr(len) { let str = ''; for(let i = 0; i &lt; len; i++) { const num = Math.floor(Math.random() * 62); str += base62.encode(num); } return str; } console.log(generateRandomStr(6)); </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/7suz7rm2.jpeg" alt=""></p> <p>随机生成 0-61 的数字,然后转成字符。</p> <p>62 的 6 次方,范围有 580 亿,足够用了:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/yl6y6qqy.jpeg" alt=""></p> <p>当然,随机数也是有碰撞的可能的,这个可以在生成之后查下表,看下是否有重复的,有的话就再重新生成。</p> <p>不过每次生成都查表的话性能会不好,那有啥优化的方案呢?</p> <p>我们可以提前生成一批压缩码,用的时候直接取!</p> <p>可以用个定时任务来跑,每天凌晨 4 点生成一批。</p> <p>这样,生成压缩码的方案就完美了。</p> <p>小结下:</p> <p><strong>用递增 id + base62 作为压缩码,可以保证唯一,但是容易被人拿到其它短码,不安全。</strong></p> <p><strong>用 url 做 hash 之后取一部分然后 base62 做为压缩码,有碰撞的可能,不唯一。</strong></p> <p><strong>随机生成字符串再查表检测是否重复,可以保证唯一且不连续,但是性能不好。用提前批量生成的方式可以解决。</strong></p> <p>有的同学可能提到 uuid、雪花 id 之类的,那些都太长了,不适合用来做压缩码:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/2jd4loyn.jpeg" alt=""></p> <p>思路理清了,我们来写下代码。</p> <p>创建个 nest 项目:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install -g @nestjs/cli nest new short-url </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/73mvz27m.jpeg" alt=""></p> <p>先进入项目,把它跑起来:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm run start:dev </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/poxrtajo.jpeg" alt=""></p> <p>浏览器看到 hello world,代表 nest 服务跑成功了:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/2695r0oa.jpeg" alt=""></p> <p>然后我们用 docker 把 mysql 跑起来:</p> <p>从 docker 官网下载 docker desktop,这个是 docker 的桌面端:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/1adswj4u.jpeg" alt=""></p> <p>跑起来后,搜索 mysql 镜像(这步需要科学上网),点击 run:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/s1jvjkli.jpeg" alt=""></p> <p>输入容器名、端口映射、以及挂载的数据卷,还要指定一个环境变量:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/6x2w0r73.jpeg" alt=""></p> <p>端口映射就是把宿主机的 3306 端口映射到容器里的 3306 端口,这样就可以在宿主机访问了。</p> <p>数据卷挂载就是把宿主机的某个目录映射到容器里的 /var/lib/mysql 目录,这样数据是保存在本地的,不会丢失。</p> <p>而 MYSQL_ROOT_PASSWORD 的密码则是 mysql 连接时候的密码。</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/1x22i8y3.jpeg" alt=""></p> <p>跑起来后,我们用 GUI 客户端连上,这里我们用的是 mysql workbench,这是 mysql 官方提供的免费客户端:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/hwptz6mp.jpeg" alt=""></p> <p>连接上之后,点击创建 database:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/v2pgik24.jpeg" alt=""></p> <p>指定名字、字符集为 utf8mb4,然后点击右下角的 apply。</p> <p>创建成功之后在左侧就可以看到这个 database 了:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/0lhsadav.jpeg" alt=""></p> <p>当然,现在还没有表。</p> <p>我们在 Nest 里用 TypeORM 连接 mysql。</p> <p>安装用到的包:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install --save @nestjs/typeorm typeorm mysql2 </div> </div> <p>mysql2 是数据库驱动,typeorm 是我们用的 orm 框架,而 @nestjs/tyeporm 是 nest 集成 typeorm 用的。</p> <p>在 AppModule 里引入 TypeORM,指定数据库连接配置:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/fmkpp5eh.jpeg" alt=""></p> <p>然后创建个 entity:</p> <p>src/entities/UniqueCode.ts</p> <div class="ql-code-block-container"> <div class="ql-code-block"> import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; @Entity() export class UniqueCode { @PrimaryGeneratedColumn() id: number; @Column({ length: 10, comment: '压缩码' }) code: string; @Column({ comment: '状态, 0 未使用、1 已使用' }) status: number; } </div> </div> <p>在 AppModule 引入:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/esavcp1c.jpeg" alt=""></p> <p>保存之后,TypeORM会自动建表:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/fsqpex1z.jpeg" alt=""></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/piqu8jw1.jpeg" alt=""></p> <p>表创建好了,接下来插入一些数据:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> nest g service unique-code --flat --no-spec </div> </div> <p>生成 service 类,--flat 是不生成目录 --no-spec 是不生成测试代码:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/a1l8wgao.jpeg" alt=""></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/7vumrzre.jpeg" alt=""></p> <p>然后创建 src/utils.ts 来放生成随机压缩码的代码:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> import * as base62 from "base62/lib/ascii"; export function generateRandomStr(len: number) { let str = ''; for(let i = 0; i &lt; len; i++) { const num = Math.floor(Math.random() * 62); str += base62.encode(num); } return str; } </div> </div> <p>安装用到的包:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install base62 </div> </div> <p>然后在 UniqueCodeService 添加下插入压缩码的方法:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> import { Injectable } from '@nestjs/common'; import { InjectEntityManager } from '@nestjs/typeorm'; import { EntityManager } from 'typeorm'; import { generateRandomStr } from './utils'; import { UniqueCode } from './entities/UniqueCode'; @Injectable() export class UniqueCodeService { @InjectEntityManager() private entityManager: EntityManager; async generateCode() { let str = generateRandomStr(6); const uniqueCode = await this.entityManager.findOneBy(UniqueCode, { code: str }); if(!uniqueCode) { const code = new UniqueCode(); code.code = str; code.status = 0; return await this.entityManager.insert(UniqueCode, code); } else { return this.generateCode(); } } } </div> </div> <p>就是生成随机的长度为 6 的字符串,查下数据库,如果没查到,就插入数据,否则重新生成。</p> <p>我们用定时任务的方式来跑:</p> <p>安装用到的包:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install --save @nestjs/schedule </div> </div> <p>在 AppModule 注册下:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/g6at7gw2.jpeg" alt=""></p> <p>然后在 service 方法上声明,每 5s 执行一次:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/6upr078m.jpeg" alt=""></p> <div class="ql-code-block-container"> <div class="ql-code-block"> @Cron(CronExpression.EVERY_5_SECONDS) </div> </div> <p>然后就可以看到一直在打印 insert 语句:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/j8zhtdo3.jpeg" alt=""></p> <p>数据库中也可以看到插入的未使用的压缩码:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/6zp50716.jpeg" alt=""></p> <p>当然,一个个这么插入可太费劲了。</p> <p>我们一般是在凌晨 4 点左右批量插入一堆,比如一次性插入 10000 个。</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/z6dgp0ag.jpeg" alt=""></p> <div class="ql-code-block-container"> <div class="ql-code-block"> @Cron(CronExpression.EVERY_DAY_AT_4AM) async batchGenerateCode() { for(let i = 0; i&lt; 10000; i++) { this.generateCode(); } } </div> </div> <p>这里我们是每次 insert 一个,你也可以每次 insert 10 个 20 个这种。</p> <p>批量插入性能会好,因为执行的 sql 语句少。这里我们就先不优化了。</p> <p>压缩码有了,接下来生成 url 和压缩码的对应关系就好了。</p> <p>同样需要创建 entity:</p> <p>src/entities/ShortLongMap.ts</p> <div class="ql-code-block-container"> <div class="ql-code-block"> import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm"; @Entity() export class ShortLongMap { @PrimaryGeneratedColumn() id: number; @Column({ length: 10, comment: '压缩码' }) shortUrl: string; @Column({ length: 200, comment: '原始 url' }) longUrl: string; @CreateDateColumn() createTime: Date; } </div> </div> <p>在 entities 引入:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/cw84x57v.jpeg" alt=""></p> <p>同样会自动建表:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/ob1m944u.jpeg" alt=""></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/3ulv2ajd.jpeg" alt=""></p> <p>生成短链就是往这个表里添加记录。</p> <p>我们加一个生成短链的 service:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> nest g service short-long-map --flat --no-spec </div> </div> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/1aigccab.jpeg" alt=""></p> <p>实现生成短链的方法:</p> <div class="ql-code-block-container"> <div class="ql-code-block"> import { UniqueCodeService } from './unique-code.service'; import { Inject, Injectable } from '@nestjs/common'; import { InjectEntityManager } from '@nestjs/typeorm'; import { EntityManager } from 'typeorm'; import { ShortLongMap } from './entities/ShortLongMap'; import { UniqueCode } from './entities/UniqueCode'; @Injectable() export class ShortLongMapService { @InjectEntityManager() private entityManager: EntityManager; @Inject(UniqueCodeService) private uniqueCodeService: UniqueCodeService; async generate(longUrl: string) { let uniqueCode = await this.entityManager.findOneBy(UniqueCode, { status: 0 }) if(!uniqueCode) { uniqueCode = await this.uniqueCodeService.generateCode(); } const map = new ShortLongMap(); map.shortUrl = uniqueCode.code; map.longUrl = longUrl; await this.entityManager.insert(ShortLongMap, map); await this.entityManager.update(UniqueCode, { id: uniqueCode.id }, { status: 1 }); return uniqueCode.code; } } </div> </div> <p>这里就是先从 unique-code 表里取一个压缩码来用,如果没有可用压缩码,那就生成一个。</p> <p>然后在 short-long-map 表里插入这条新的短链映射,并且把用到的压缩码状态改为 1。</p> <p>我们在 AppController 里添加一个接口:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/z3ukvivb.jpeg" alt=""></p> <div class="ql-code-block-container"> <div class="ql-code-block"> import { ShortLongMapService } from './short-long-map.service'; import { Controller, Get, Inject, Query } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Inject(ShortLongMapService) private shortLongMapService: ShortLongMapService; @Get() getHello(): string { return this.appService.getHello(); } @Get('short-url') async generateShortUrl(@Query('url') longUrl) { return this.shortLongMapService.generate(longUrl); } } </div> </div> <p>在浏览器里测试下:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/026h3xbw.jpeg" alt=""></p> <p>可以看到,打印了 4 条 sql:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/46aor4lw.jpeg" alt=""></p> <p>首先 select 查出一个压缩码来,然后 insert 插入压缩码和 url 的映射,之后再把它 select 出来返回。</p> <p>最后 update 更新压缩码状态。</p> <p>看 sql 来说,是符合我们的预期的。</p> <p>然后看下数据:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/tyi84x63.jpeg" alt=""></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/wqaz6ggk.jpeg" alt=""></p> <p>也是对的。</p> <p>那剩下的事情就很简单了,只要加一个重定向就好:</p> <p>首先在 service 里添加根据压缩码查询 longUrl 的方法:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/w6kcrcqd.jpeg" alt=""></p> <div class="ql-code-block-container"> <div class="ql-code-block"> async getLongUrl(code: string) { const map = await this.entityManager.findOneBy(ShortLongMap, { shortUrl: code }); if(!map) { return null; } return map.longUrl; } </div> </div> <p>然后在 AppController 里添加一个重定向的接口:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/59qjkeit.jpeg" alt="image.png"></p> <div class="ql-code-block-container"> <div class="ql-code-block"> @Get(':code') @Redirect() async jump(@Param('code') code) { const longUrl = await this.shortLongMapService.getLongUrl(code); if(!longUrl) { throw new BadRequestException('短链不存在'); } return { url: longUrl, statusCode: 302 } } </div> </div> <p>测试下:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/l17k6e28.jpeg" alt="image.png"></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/mupgrve4.jpeg" alt="2023-12-01 14.32.03.gif"></p> <p>这样,我们的短链服务就完成了。</p> <p>其他的非核心功能,比如记录每次访问记录,做一些分析:</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/isyniaea.jpeg" alt=""></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/e2zxe8jc.jpeg" alt=""></p> <p>这些比较简单,就不实现了。</p> <p>案例代码上传了 github: <a href="https://github.com/QuarkGluonPlasma/nestjs-course-code/tree/main/short-url" target="_blank">https://github.com/QuarkGluonPlasma/nestjs-course-code/tree/main/short-url</a></p> <h2>总结</h2> <p>我们经常用短链服务把长的 url 缩短,在短信里的链接一般都是这种。</p> <p>我们用 Nest 自己实现了一个。</p> <p>核心是压缩码的生成,我们分析了自增 id + base62,这样容易被人拿到其它短链,不安全。hash + base62 会有冲突的可能,所以最终用的是自己生成随机数 + base62 的方案。</p> <p>当然,这个随机字符串最好是提前生成,比如用定时任务在低峰期批量生成一堆,之后直接用就好了。</p> <p>短链的重定向使用 302 临时重定向,这样可以记录短链访问记录,做一些分析。</p> <p>市面上的短链服务,基本都是这样实现的。</p> #后端# </div> </body> </html>

Nest 实现扫码登录

<html> <head></head> <body> <div class="content ql-editor"> <p>扫码登录是常见的功能,基本各种网站都支持。</p> <p></p> <p>比如掘金的登录就支持 APP 扫码的方式:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/1nrui0ya.jpeg" alt=""></p> <p></p> <p>如果你 APP 没登录,扫码后会跳到登录页面:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/0sst6o86.jpeg" alt=""></p> <p></p> <p>登录之后,会进入确认界面,你可以选择授权登录或者取消:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/nik2jum5.jpeg" alt=""></p> <p></p> <p>这边确认之后,pc 网站就登录了。</p> <p></p> <p>知乎,b 站等也是这样的:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/3prmirnt.jpeg" alt=""></p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/bpfscqgw.jpeg" alt=""></p> <p></p> <p>有没有感觉很神奇,为什么一扫二维码,然后确认下,那边就自动登录了呢?</p> <p></p> <p>其实原理也很简单。</p> <p></p> <p>我们先用解析工具解码下二维码的内容:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/9v3rd07p.jpeg" alt=""></p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/4u323b0j.jpeg" alt=""></p> <p></p> <p>可以看到,二维码的内容是一个 url,如果在手机浏览器打开,是这样的:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/n0wd3trq.jpeg" alt=""></p> <p></p> <p>会让你下载 APP。</p> <p></p> <p>而在 APP 里打开,就是登录确认界面了:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/yrw1drzi.jpeg" alt=""></p> <p></p> <p>那确认的是哪个二维码呢?</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/3vmpau2i.jpeg" alt=""></p> <p></p> <p>二维码这里是有个唯一 id 的,通过这个 id 就知道是哪个二维码。</p> <p></p> <p>这个二维码有 5 个状态:</p> <p></p> <ol> <li data-list="bullet"><span class="ql-ui"></span>未扫描</li> <li data-list="bullet"><span class="ql-ui"></span>已扫描,等待用户确认</li> <li data-list="bullet"><span class="ql-ui"></span>已扫描,用户同意授权</li> <li data-list="bullet"><span class="ql-ui"></span>已扫描,用户取消授权</li> <li data-list="bullet"><span class="ql-ui"></span>已过期</li> </ol> <p></p> <p>最开始是未扫描状态:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/1w0be09w.jpeg" alt=""></p> <p></p> <p>扫码后会进入等待用户确认状态:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/c1y7hkio.jpeg" alt=""></p> <p></p> <p>确认后会进入同意授权状态:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/81wkuw37.jpeg" alt=""></p> <p></p> <p>取消的话会进入取消授权状态:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/woxu40ey.jpeg" alt=""></p> <p></p> <p>长时间不操作会进入过期状态:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/ks1lstjo.jpeg" alt=""></p> <p></p> <p>也就是说,扫码后进行不同的操作就是修改这个 id 对应的二维码的状态。</p> <p></p> <p>另一边修改了状态,这边是怎么知道二维码状态变了呢?</p> <p></p> <p>websocket 么?</p> <p></p> <p>不用,一般都是轮询来做。</p> <p></p> <p>比如掘金:</p> <p></p> <p>二维码出现后,会有一个每秒一次的轮询请求来查询二维码状态:</p> <p></p> <p>最开始是 new:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/bqzrsho0.jpeg" alt=""></p> <p></p> <p>扫码后会变成 scanned:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/or7deysu.jpeg" alt=""></p> <p></p> <p>知乎也是一样:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/7b5lyo9q.jpeg" alt=""></p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/7lsjcw5d.jpeg" alt=""></p> <p></p> <p>这时候,手机会进入登录确认页面:</p> <p></p> <p>bilibili 的登录确认页面:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/dhkvowoz.jpeg" alt=""></p> <p></p> <p>知乎的登录确认页面:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/s82v6koe.jpeg" alt=""></p> <p></p> <p>这边点击确认登录或者取消之后,会发请求修改 id 对应的二维码的状态。</p> <p></p> <p>那边一直在轮询,自然就知道了二维码状态的变更。</p> <p></p> <p>也就是这样的:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/f6rkm3ph.jpeg" alt=""></p> <p></p> <p>服务端有个 qrcode/generate 接口,会生成一个随机的二维码 id,存到 redis 里,并返回二维码。</p> <p></p> <p>还有个 qrcode/check 接口,会返回 redis 里的二维码状态,浏览器里可以轮询这个接口拿到二维码状态。</p> <p></p> <p>然后手机 APP 扫码之后,如果没登录,会先跳转到登录页面,登录之后会进入登录确认页面。</p> <p></p> <p>这个时候就从二维码中拿到了 id,然后调用 qrcode/scan、qrcode/cancel、qrcode/confirm 就是修改二维码为不同的状态。</p> <p></p> <p>这时候用户是登录了的,jwt 的登录认证方式会携带 token,服务端只要从 token 中取出用户信息,存入 redis 即可。</p> <p></p> <p>然后另一边的轮询接口发现是确认状态,会根据用户信息生成 jwt 返回。</p> <p></p> <p>这样,手机 APP 里确认之后,pc 的浏览器就自动登录了该用户账号。</p> <p></p> <p>这里的 jwt 是保存登录状态的一种方案,会把用户信息放在 token 里返回,然后每次访问接口带上 authorization 的 header,携带 token。</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/ifaabd8r.jpeg" alt=""></p> <p></p> <p>思路理清了,我们来实现一下吧!</p> <p></p> <p>创建个 nest 项目:</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install -g <span class="ql-token hljs-meta">@nestjs</span>/cli nest <span class="ql-token hljs-keyword">new</span> <span class="ql-token hljs-title">qrcode</span>-login </div> </div> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/sn2eiivi.jpeg" alt=""></p> <p></p> <p>把它跑起来:</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm run start:dev </div> </div> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/huqpklny.jpeg" alt=""></p> <p></p> <p>浏览器访问 http://localhost:3000 就可以可以看到 hello world,就代表服务跑起来了:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/bh2bcfzi.jpeg" alt=""></p> <p></p> <p>然后我们实现下生成二维码的接口:</p> <p></p> <p>安装下用到的包:</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install qrcode <span class="ql-token hljs-meta">@types</span>/qrcode </div> </div> <p></p> <p>添加一个路由:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/gwtirs7o.jpeg" alt=""></p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"><span class="ql-token hljs-keyword">import</span> { randomUUID } from <span class="ql-token hljs-string">'crypto'</span>; <span class="ql-token hljs-keyword">import</span> * as qrcode from <span class="ql-token hljs-string">'qrcode'</span>; </div> </div> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"><span class="ql-token hljs-meta">@Get('qrcode/generate')</span> async <span class="ql-token hljs-title">generate()</span> { <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">uuid</span> <span class="ql-token hljs-operator">=</span> randomUUID(); <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">dataUrl</span> <span class="ql-token hljs-operator">=</span> await qrcode.toDataURL(uuid); <span class="ql-token hljs-keyword">return</span> { qrcode_id: uuid, img: dataUrl } } </div> </div> <p></p> <p>这里用 node 的 crypto 模块生成一个随机的 uuid。</p> <p></p> <p>然后用 qrcode 生成二维码,只不过转成 base64 返回。</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/qrfca8kp.jpeg" alt=""></p> <p></p> <p>我们在 html 里把它渲染出来看一下:</p> <p></p> <p>新建 static/index.html</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"> &lt;!DOCTYPE html&gt; &lt;html lang=<span class="ql-token hljs-string">"en"</span>&gt; &lt;head&gt; &lt;meta charset=<span class="ql-token hljs-string">"UTF-8"</span>&gt; &lt;title&gt;扫码登录&lt;/title&gt; &lt;meta name=<span class="ql-token hljs-string">"viewport"</span> content=<span class="ql-token hljs-string">"width=device-width, initial-scale=1.0"</span>&gt; &lt;/head&gt; &lt;body&gt; &lt;img src=<span class="ql-token hljs-string">"data:image/png;base64,这里填入你生成的 url"</span> alt=<span class="ql-token hljs-string">""</span>/&gt; &lt;/body&gt; &lt;/html&gt; </div> </div> <p></p> <p>然后在 main.ts 里支持这个目录下静态资源的访问,用 pages 作为前缀:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/qoky5xc5.jpeg" alt=""></p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"><span class="ql-token hljs-keyword">import</span> { NestFactory } from <span class="ql-token hljs-string">'@nestjs/core'</span>; <span class="ql-token hljs-keyword">import</span> { AppModule } from <span class="ql-token hljs-string">'./app.module'</span>; <span class="ql-token hljs-keyword">import</span> { NestExpressApplication } from <span class="ql-token hljs-string">'@nestjs/platform-express'</span>; async function <span class="ql-token hljs-title">bootstrap()</span> { <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">app</span> <span class="ql-token hljs-operator">=</span> await NestFactory.create&lt;NestExpressApplication&gt;(AppModule); app.useStaticAssets(<span class="ql-token hljs-string">'static'</span>, { prefix: <span class="ql-token hljs-string">'/pages'</span>}); await app.listen(<span class="ql-token hljs-number">3000</span>); } bootstrap(); </div> </div> <p></p> <p>这样你访问 http://localhost:3000/pages/index.html 就可以看到二维码了:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/nq8xik9i.jpeg" alt=""></p> <p></p> <p>我们用在线解码工具解码下看看:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/c9peyd34.jpeg" alt=""></p> <p></p> <p>确实,内容就是生成的 uuid。</p> <p></p> <p>然后,其实这个二维码扫出的应该是个网址。</p> <p></p> <p>比如掘金的二维码解析出的内容:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/a67gkxah.jpeg" alt=""></p> <p></p> <p>如果用手机浏览器扫这个码的话,打开的就是下载 APP 的页面:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/4whd86vy.jpeg" alt=""></p> <p></p> <p>而如果用掘金 APP 扫码,扫出的就是登录确认页面了:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/9q0kb46i.jpeg" alt=""></p> <p></p> <p>这个很正常,因为如果随便一个浏览器都能扫码打开登录确认页面,那谁还下载掘金 APP 呢?</p> <p></p> <p>所以掘金的二维码只能掘金 APP 扫,微信的二维码只能用微信 APP 扫。</p> <p></p> <p>这个页面做了检查,判断是 APP 打开的还是其他方式打开的,分别会显示不同的内容。</p> <p></p> <p>这里我们也改成一个 url:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/y0pbeblx.jpeg" alt=""></p> <p></p> <p>扫码就会打开这个页面,而这个页面就是登录确认页面。</p> <p></p> <p>我们写一下这个页面:</p> <p></p> <p>新建 src/static/confirm.html</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"> &lt;!DOCTYPE html&gt; &lt;html lang=<span class="ql-token hljs-string">"en"</span>&gt; &lt;head&gt; &lt;meta charset=<span class="ql-token hljs-string">"UTF-8"</span>&gt; &lt;meta name=<span class="ql-token hljs-string">"viewport"</span> content=<span class="ql-token hljs-string">"width=device-width, initial-scale=1.0"</span>&gt; &lt;title&gt;扫码登录确认&lt;/title&gt; &lt;/head&gt; &lt;body&gt; &lt;button&gt;确认登录&lt;/button&gt; &lt;button&gt;取消&lt;/button&gt; &lt;/body&gt; &lt;/html&gt; </div> </div> <p></p> <p>但这里有个问题,开发服务是在电脑上的,手机怎么访问呢?</p> <p></p> <p>这里需要用 charles 来做代理:</p> <p></p> <p><a href="https://www.charlesproxy.com/download/" target="_blank">下载 charles</a>,打开</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/tsypdrlu.jpeg" alt=""></p> <p></p> <p>让电脑和手机连接同一个 wifi,然后在手机的 wifi 设置那里设置代理:</p> <p></p> <p>代理的 ip 是电脑 ip,端口号就是 charles 代理服务的默认端口 8888</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/d4t7q8nj.jpeg" alt=""></p> <p></p> <p>这时候电脑上会收到连接提醒,同意下就好了:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/doem0tsz.jpeg" alt=""></p> <p></p> <p>然后手机就可以访问电脑上的 nest 服务:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/20k9ls6o.jpeg" alt=""></p> <p></p> <p>看到这个 hello world 了没?</p> <p></p> <p>这就是电脑上的这个 nest 服务返回的:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/rhc5bz7m.jpeg" alt=""></p> <p></p> <p>那个登录确认页面在电脑访问是这样的:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/upe0l9jx.jpeg" alt=""></p> <p></p> <p>然后我把二维码的内容改为这个:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/i2crir6m.jpeg" alt=""></p> <p></p> <p>修改下展示二维码的页面:</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"> &lt;!DOCTYPE html&gt; &lt;html lang=<span class="ql-token hljs-string">"en"</span>&gt; &lt;head&gt; &lt;meta charset=<span class="ql-token hljs-string">"UTF-8"</span>&gt; &lt;title&gt;扫码登录&lt;/title&gt; &lt;meta name=<span class="ql-token hljs-string">"viewport"</span> content=<span class="ql-token hljs-string">"width=device-width, initial-scale=1.0"</span>&gt; &lt;script src=<span class="ql-token hljs-string">"https://unpkg.com/axios@1.5.0/dist/axios.min.js"</span>&gt;&lt;/script&gt; &lt;/head&gt; &lt;body&gt; &lt;img id=<span class="ql-token hljs-string">"img"</span> src=<span class="ql-token hljs-string">""</span> alt=<span class="ql-token hljs-string">""</span>/&gt; &lt;script&gt; axios.get(<span class="ql-token hljs-string">'http://localhost:3000/qrcode/generate'</span>).then(res =&gt; { document.getElementById(<span class="ql-token hljs-string">'img'</span>).src = res.data.img; }) &lt;/script&gt; &lt;/body&gt; &lt;/html&gt; </div> </div> <p></p> <p>用 axios 请求生成二维码的接口,然后修改图片 src。</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/wftwyymf.jpeg" alt=""></p> <p></p> <p>用手机扫码下:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/9c4rew0w.jpeg" alt=""></p> <p></p> <p>用微信扫码,可以看到,打开了登录确认页面。</p> <p></p> <p>按钮有点小,我们设置下样式。</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"> &lt;!DOCTYPE html&gt; &lt;html lang=<span class="ql-token hljs-string">"en"</span>&gt; &lt;head&gt; &lt;meta charset=<span class="ql-token hljs-string">"UTF-8"</span>&gt; &lt;meta name=<span class="ql-token hljs-string">"viewport"</span> content=<span class="ql-token hljs-string">"width=device-width, initial-scale=1.0"</span>&gt; &lt;title&gt;扫码登录确认&lt;/title&gt; &lt;style&gt; #info { height: 400px; line-height: 400px; font-size: 20px; padding: 20px; } #confirm, #cancel{ display: block; width: <span class="ql-token hljs-number">80</span>%; line-height: 40px; font-size: 20px; margin-bottom: 20px; } #confirm { background: skyblue; } &lt;/style&gt; &lt;/head&gt; &lt;body&gt; &lt;div id=<span class="ql-token hljs-string">"info"</span>&gt; 是否确认登录 xxx 网站? &lt;/div&gt; &lt;button id=<span class="ql-token hljs-string">"confirm"</span>&gt;确认登录&lt;/button&gt; &lt;button id=<span class="ql-token hljs-string">"cancel"</span>&gt;取消&lt;/button&gt; &lt;/body&gt; &lt;/html&gt; </div> </div> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/y8khpcpa.jpeg" alt=""></p> <p></p> <p>好看多了。</p> <p></p> <p>二维码的内容解码后是这样的:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/vg3cb0qw.jpeg" alt=""></p> <p></p> <p>然后我们来实现剩下的接口:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/49tdlfl3.jpeg" alt=""></p> <p></p> <p>生成二维码之后,要在 redis 里保存一份,这里我们简化一下,直接用个 map 保存吧。</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/5aclf22l.jpeg" alt=""></p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"><span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">map</span> <span class="ql-token hljs-operator">=</span> <span class="ql-token hljs-keyword">new</span> <span class="ql-token hljs-title">Map</span>&lt;string, QrCodeInfo&gt;(); <span class="ql-token hljs-keyword">interface</span> <span class="ql-token hljs-title">QrCodeInfo</span>{ status: <span class="ql-token hljs-string">'noscan'</span> | <span class="ql-token hljs-string">'scan-wait-confirm'</span> | <span class="ql-token hljs-string">'scan-confirm'</span> | <span class="ql-token hljs-string">'scan-cancel'</span> | <span class="ql-token hljs-string">'expired'</span>, userInfo?: { userId: number; } } <span class="ql-token hljs-comment">// noscan 未扫描</span> <span class="ql-token hljs-comment">// scan-wait-confirm -已扫描,等待用户确认</span> <span class="ql-token hljs-comment">// scan-confirm 已扫描,用户同意授权</span> <span class="ql-token hljs-comment">// scan-cancel 已扫描,用户取消授权</span> <span class="ql-token hljs-comment">// expired 已过期</span> </div> </div> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"> map.set(`qrcode_${uuid}`, { status: <span class="ql-token hljs-string">'noscan'</span> }); </div> </div> <p></p> <p>然后加一个 qrcode/check 接口,用来查询二维码状态:</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"><span class="ql-token hljs-meta">@Get('qrcode/check')</span> async <span class="ql-token hljs-title">check(@Query('id') id: string)</span> { <span class="ql-token hljs-keyword">return</span> map.get(`qrcode_${id}`); } </div> </div> <p></p> <p>测试下:</p> <p></p> <p>访问 /qrcode/generate 生成二维码和 id</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/pv86ohit.jpeg" alt=""></p> <p></p> <p>然后访问 /qrcode/check 拿到这个 id 的状态:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/btuqhuxt.jpeg" alt=""></p> <p></p> <p>然后再实现 /qrcode/confirm、/qrcode/cancel、/qrcode/scan 这三个接口:</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"><span class="ql-token hljs-meta">@Get('qrcode/scan')</span> async <span class="ql-token hljs-title">scan(@Query('id') id: string)</span> { <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">info</span> <span class="ql-token hljs-operator">=</span> map.get(`qrcode_${id}`); <span class="ql-token hljs-keyword">if</span>(!info) { <span class="ql-token hljs-keyword">throw</span> <span class="ql-token hljs-keyword">new</span> <span class="ql-token hljs-title">BadRequestException</span>(<span class="ql-token hljs-string">'二维码已过期'</span>); } info.status = <span class="ql-token hljs-string">'scan-wait-confirm'</span>; <span class="ql-token hljs-keyword">return</span> <span class="ql-token hljs-string">'success'</span>; } <span class="ql-token hljs-meta">@Get('qrcode/confirm')</span> async <span class="ql-token hljs-title">confirm(@Query('id') id: string)</span> { <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">info</span> <span class="ql-token hljs-operator">=</span> map.get(`qrcode_${id}`); <span class="ql-token hljs-keyword">if</span>(!info) { <span class="ql-token hljs-keyword">throw</span> <span class="ql-token hljs-keyword">new</span> <span class="ql-token hljs-title">BadRequestException</span>(<span class="ql-token hljs-string">'二维码已过期'</span>); } info.status = <span class="ql-token hljs-string">'scan-confirm'</span>; <span class="ql-token hljs-keyword">return</span> <span class="ql-token hljs-string">'success'</span>; } <span class="ql-token hljs-meta">@Get('qrcode/cancel')</span> async <span class="ql-token hljs-title">cancel(@Query('id') id: string)</span> { <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">info</span> <span class="ql-token hljs-operator">=</span> map.get(`qrcode_${id}`); <span class="ql-token hljs-keyword">if</span>(!info) { <span class="ql-token hljs-keyword">throw</span> <span class="ql-token hljs-keyword">new</span> <span class="ql-token hljs-title">BadRequestException</span>(<span class="ql-token hljs-string">'二维码已过期'</span>); } info.status = <span class="ql-token hljs-string">'scan-cancel'</span>; <span class="ql-token hljs-keyword">return</span> <span class="ql-token hljs-string">'success'</span>; } </div> </div> <p></p> <p>测试下:</p> <p></p> <p>先 qrcode/generate 生成二维码,拿到 id:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/uvop3yh2.jpeg" alt=""></p> <p></p> <p>然后调用 qrcode/scan 修改状态,之后调用 qrcode/check 查询下状态:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/gu4oz8d7.jpeg" alt=""></p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/a3ru62cc.jpeg" alt=""></p> <p></p> <p>同样的方式测试 qrcode/cancel 和 qrcode/confirm 接口:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/fddiq2z6.jpeg" alt=""></p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/fwr562qn.jpeg" alt=""></p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/fy4rhl62.jpeg" alt=""></p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/jcszpxej.jpeg" alt=""></p> <p></p> <p>如果 id 不存在,会返回 400 的状态码:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/irriwlvm.jpeg" alt=""></p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/xr0x04p1.jpeg" alt=""></p> <p></p> <p>然后就可以在 static/index.html 里加上 qrcode/check 接口来轮询二维码状态了。</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"> &lt;!DOCTYPE html&gt; &lt;html lang=<span class="ql-token hljs-string">"en"</span>&gt; &lt;head&gt; &lt;meta charset=<span class="ql-token hljs-string">"UTF-8"</span>&gt; &lt;title&gt;扫码登录&lt;/title&gt; &lt;meta name=<span class="ql-token hljs-string">"viewport"</span> content=<span class="ql-token hljs-string">"width=device-width, initial-scale=1.0"</span>&gt; &lt;script src=<span class="ql-token hljs-string">"https://unpkg.com/axios@1.5.0/dist/axios.min.js"</span>&gt;&lt;/script&gt; &lt;/head&gt; &lt;body&gt; &lt;img id=<span class="ql-token hljs-string">"img"</span> src=<span class="ql-token hljs-string">""</span> alt=<span class="ql-token hljs-string">""</span>/&gt; &lt;div id=<span class="ql-token hljs-string">"info"</span>&gt;&lt;/div&gt; &lt;script&gt; axios.get(<span class="ql-token hljs-string">'http://localhost:3000/qrcode/generate'</span>).then(res =&gt; { document.getElementById(<span class="ql-token hljs-string">'img'</span>).src = res.data.img; queryStatus(res.data.qrcode_id); }) function <span class="ql-token hljs-title">queryStatus(id)</span> { axios.get(<span class="ql-token hljs-string">'http://localhost:3000/qrcode/check?id='</span> + id).then(res =&gt; { <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">status</span> <span class="ql-token hljs-operator">=</span> res.data.status; <span class="ql-token hljs-type">let</span> <span class="ql-token hljs-variable">content</span> <span class="ql-token hljs-operator">=</span> <span class="ql-token hljs-string">''</span>; <span class="ql-token hljs-keyword">switch</span>(status) { <span class="ql-token hljs-keyword">case</span> <span class="ql-token hljs-string">'noscan'</span>: content = <span class="ql-token hljs-string">'未扫码'</span>; <span class="ql-token hljs-keyword">break</span>; <span class="ql-token hljs-keyword">case</span> <span class="ql-token hljs-string">'scan-wait-confirm'</span>: content = <span class="ql-token hljs-string">'已扫码,等待确认'</span>; <span class="ql-token hljs-keyword">break</span>; <span class="ql-token hljs-keyword">case</span> <span class="ql-token hljs-string">'scan-confirm'</span>: content = <span class="ql-token hljs-string">'已确认'</span>; <span class="ql-token hljs-keyword">break</span>; <span class="ql-token hljs-keyword">case</span> <span class="ql-token hljs-string">'scan-cancel'</span>: content = <span class="ql-token hljs-string">'已取消'</span>; <span class="ql-token hljs-keyword">break</span>; } document.getElementById(<span class="ql-token hljs-string">'info'</span>).textContent = content; <span class="ql-token hljs-keyword">if</span>(status === <span class="ql-token hljs-string">'noscan'</span> || status === <span class="ql-token hljs-string">'scan-wait-confirm'</span>) { setTimeout(() =&gt; queryStatus(id), <span class="ql-token hljs-number">1000</span>); } }) } &lt;/script&gt; &lt;/body&gt; &lt;/html&gt; </div> </div> <p></p> <p>生成二维码之后,就开始轮询状态了。</p> <p></p> <p>根据状态分别显示不同的文字,如果不是已确认或者已取消就在一秒后继续下次轮询。</p> <p></p> <p>然后,在登录确认页面也加上接口调用:</p> <p></p> <p>改下 static/confirm.html</p> <p></p> <p>使用 URLSearchParams 的 api 拿到 url 中的 id:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/atab0jfn.jpeg" alt=""></p> <p></p> <p>然后修改这个 id 对应的二维码的状态。</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"> &lt;!DOCTYPE html&gt; &lt;html lang=<span class="ql-token hljs-string">"en"</span>&gt; &lt;head&gt; &lt;meta charset=<span class="ql-token hljs-string">"UTF-8"</span>&gt; &lt;meta name=<span class="ql-token hljs-string">"viewport"</span> content=<span class="ql-token hljs-string">"width=device-width, initial-scale=1.0"</span>&gt; &lt;title&gt;扫码登录确认&lt;/title&gt; &lt;script src=<span class="ql-token hljs-string">"https://unpkg.com/axios@1.5.0/dist/axios.min.js"</span>&gt;&lt;/script&gt; &lt;style&gt; #info { height: 400px; line-height: 400px; font-size: 20px; padding: 20px; } #confirm, #cancel{ display: block; width: <span class="ql-token hljs-number">80</span>%; line-height: 40px; font-size: 20px; margin-bottom: 20px; } #confirm { background: skyblue; } &lt;/style&gt; &lt;/head&gt; &lt;body&gt; &lt;div id=<span class="ql-token hljs-string">"info"</span>&gt; 是否确认登录 xxx 网站? &lt;/div&gt; &lt;button id=<span class="ql-token hljs-string">"confirm"</span>&gt;确认登录&lt;/button&gt; &lt;button id=<span class="ql-token hljs-string">"cancel"</span>&gt;取消&lt;/button&gt; &lt;script&gt; <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">params</span> <span class="ql-token hljs-operator">=</span> <span class="ql-token hljs-keyword">new</span> <span class="ql-token hljs-title">URLSearchParams</span>(window.location.search.slice(<span class="ql-token hljs-number">1</span>)); <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">id</span> <span class="ql-token hljs-operator">=</span> params.get(<span class="ql-token hljs-string">'id'</span>); axios.get(<span class="ql-token hljs-string">'http://192.168.31.56:3000/qrcode/scan?id='</span> + id).<span class="ql-token hljs-keyword">catch</span>(e =&gt; { alert(<span class="ql-token hljs-string">'二维码已过期'</span>); }); document.getElementById(<span class="ql-token hljs-string">'confirm'</span>).addEventListener(<span class="ql-token hljs-string">'click'</span>, () =&gt; { axios.get(<span class="ql-token hljs-string">'http://192.168.31.56:3000/qrcode/confirm?id='</span> + id).<span class="ql-token hljs-keyword">catch</span>(e =&gt; { alert(<span class="ql-token hljs-string">'二维码已过期'</span>); }); }); document.getElementById(<span class="ql-token hljs-string">'cancel'</span>).addEventListener(<span class="ql-token hljs-string">'click'</span>, () =&gt; { axios.get(<span class="ql-token hljs-string">'http://192.168.31.56:3000/qrcode/cancel?id='</span> + id).<span class="ql-token hljs-keyword">catch</span>(e =&gt; { alert(<span class="ql-token hljs-string">'二维码已过期'</span>); }); }); &lt;/script&gt; &lt;/body&gt; &lt;/html&gt; </div> </div> <p></p> <p>进入这个页面,就访问 qrcode/scan 接口,来把 id 对应的二维码改为已扫描状态。</p> <p></p> <p>点击确认或者取消按钮也分别修改状态为确认和取消。</p> <p></p> <p>注意,这个页面是在手机打开的,需要通过 ip 的方式访问接口。</p> <p></p> <p>测试下:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/l1b7rgt9.jpeg" alt=""></p> <p></p> <p>打开页面后,生成二维码,这时候就开始轮询二维码状态。</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/i6hdoakb.jpeg" alt=""></p> <p></p> <p>手机扫码,进入登录确认页面。</p> <p></p> <p>这时候二维码页面的状态变为了等待确认:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/vlicf604.jpeg" alt=""></p> <p></p> <p>然后确认登录,这时候 pc 页面变为了已确认状态:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/v1cj10aq.jpeg" alt=""></p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/rh6uhxy4.jpeg" alt=""></p> <p></p> <p>如果点击取消,那就会变为已取消状态:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/o805aacv.jpeg" alt=""></p> <p></p> <p>这就是扫码之后,pc 上的二维码同步改变状态的原理。</p> <p></p> <p>当然,最终我们是要做登录的。</p> <p></p> <p>确认之后,就要拿到这边的登录状态,从中取出用户信息。</p> <p></p> <p>当然,现在我们还没做登录。</p> <p></p> <p>我们通过 jwt 搞一下:</p> <p></p> <p>引入 jwt 的 包:</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"> npm install <span class="ql-token hljs-meta">@nestjs</span>/jwt </div> </div> <p></p> <p>在 AppModule 里引入它:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/7l07de5i.jpeg" alt=""></p> <p></p> <p>然后实现登录接口:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/2qjewpuu.jpeg" alt=""></p> <p></p> <p>这里我们没有用数据库,只有 2 个用户,如果信息匹配,就返回 jwt 的 token:</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"><span class="ql-token hljs-meta">@Inject(JwtService)</span> <span class="ql-token hljs-keyword">private</span> jwtService: JwtService; <span class="ql-token hljs-type">private</span> <span class="ql-token hljs-variable">users</span> <span class="ql-token hljs-operator">=</span> [ {id: <span class="ql-token hljs-number">1</span>, username: <span class="ql-token hljs-string">'dong'</span>, password: <span class="ql-token hljs-string">'111'</span>}, {id: <span class="ql-token hljs-number">2</span>, username: <span class="ql-token hljs-string">'guang'</span>, password: <span class="ql-token hljs-string">'222'</span>}, ]; <span class="ql-token hljs-meta">@Get('login')</span> async <span class="ql-token hljs-title">login(@Query('username') username: string, @Query('password') password: string)</span> { <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">user</span> <span class="ql-token hljs-operator">=</span> <span class="ql-token hljs-built_in">this</span>.users.find(item =&gt; item.username === username); <span class="ql-token hljs-keyword">if</span>(!user) { <span class="ql-token hljs-keyword">throw</span> <span class="ql-token hljs-keyword">new</span> <span class="ql-token hljs-title">UnauthorizedException</span>(<span class="ql-token hljs-string">'用户不存在'</span>); } <span class="ql-token hljs-keyword">if</span>(user.password !== password) { <span class="ql-token hljs-keyword">throw</span> <span class="ql-token hljs-keyword">new</span> <span class="ql-token hljs-title">UnauthorizedException</span>(<span class="ql-token hljs-string">'密码错误'</span>); } <span class="ql-token hljs-keyword">return</span> { token: await <span class="ql-token hljs-built_in">this</span>.jwtService.sign({ userId: user.id }) } </div> </div> <p></p> <p>postman 里测试下:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/pj9nwnha.jpeg" alt=""></p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/s5jdha1x.jpeg" alt=""></p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/p1cu044e.jpeg" alt=""></p> <p></p> <p>登录成功后,会返回 token。</p> <p></p> <p>这个 token 一般都是在访问接口的时候放在 authorization 的 header 里,通过 Bearer xxx 的方式。</p> <p></p> <p>我们添加一个 userInfo 的接口来拿到用户信息:</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"><span class="ql-token hljs-meta">@Get('userInfo')</span> async <span class="ql-token hljs-title">userInfo(@Headers('Authorization') auth: string)</span> { <span class="ql-token hljs-keyword">try</span>{ const [, token] = auth.split(<span class="ql-token hljs-string">' '</span>); <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">info</span> <span class="ql-token hljs-operator">=</span> await <span class="ql-token hljs-built_in">this</span>.jwtService.verify(token); <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">user</span> <span class="ql-token hljs-operator">=</span> <span class="ql-token hljs-built_in">this</span>.users.find(item =&gt; item.id == info.userId); <span class="ql-token hljs-keyword">return</span> user; } <span class="ql-token hljs-keyword">catch</span>(e) { <span class="ql-token hljs-keyword">throw</span> <span class="ql-token hljs-keyword">new</span> <span class="ql-token hljs-title">UnauthorizedException</span>(<span class="ql-token hljs-string">'token 过期,请重新登录'</span>); } } </div> </div> <p></p> <p>它会从 header 中取出 token,解析出其中的信息,从而拿到 userId,然后查询 id 对应的用户信息返回。</p> <p></p> <p>我们加上 authorization 的 header,访问下 userInfo 接口:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/s2kqlcb0.jpeg" alt=""></p> <p></p> <p>可以看到,拿到了用户的信息。</p> <p></p> <p>然后我们在登录确认页面加上登录:</p> <p></p> <p>添加两个按钮:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/2xqz9ryl.jpeg" alt=""></p> <p></p> <p>这俩按钮分别是登录不同的账号,拿到 token:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/0lfm3b61.jpeg" alt=""></p> <p></p> <p>访问 confirm 接口时带上这个 token:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/aat1oz8y.jpeg" alt=""></p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"> &lt;!DOCTYPE html&gt; &lt;html lang=<span class="ql-token hljs-string">"en"</span>&gt; &lt;head&gt; &lt;meta charset=<span class="ql-token hljs-string">"UTF-8"</span>&gt; &lt;meta name=<span class="ql-token hljs-string">"viewport"</span> content=<span class="ql-token hljs-string">"width=device-width, initial-scale=1.0"</span>&gt; &lt;title&gt;扫码登录确认&lt;/title&gt; &lt;script src=<span class="ql-token hljs-string">"https://unpkg.com/axios@1.5.0/dist/axios.min.js"</span>&gt;&lt;/script&gt; &lt;style&gt; #info { height: 400px; line-height: 400px; font-size: 20px; padding: 20px; } #confirm, #cancel{ display: block; width: <span class="ql-token hljs-number">80</span>%; line-height: 40px; font-size: 20px; margin-bottom: 20px; } #confirm { background: skyblue; } &lt;/style&gt; &lt;/head&gt; &lt;body&gt; &lt;button id=<span class="ql-token hljs-string">"guang"</span>&gt;登录光光账号&lt;/button&gt; &lt;button id=<span class="ql-token hljs-string">"dong"</span>&gt;登录东东账号&lt;/button&gt; &lt;div id=<span class="ql-token hljs-string">"info"</span>&gt; 是否确认登录 xxx 网站? &lt;/div&gt; &lt;button id=<span class="ql-token hljs-string">"confirm"</span>&gt;确认登录&lt;/button&gt; &lt;button id=<span class="ql-token hljs-string">"cancel"</span>&gt;取消&lt;/button&gt; &lt;script&gt; <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">params</span> <span class="ql-token hljs-operator">=</span> <span class="ql-token hljs-keyword">new</span> <span class="ql-token hljs-title">URLSearchParams</span>(window.location.search.slice(<span class="ql-token hljs-number">1</span>)); <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">id</span> <span class="ql-token hljs-operator">=</span> params.get(<span class="ql-token hljs-string">'id'</span>); <span class="ql-token hljs-type">let</span> <span class="ql-token hljs-variable">token</span> <span class="ql-token hljs-operator">=</span> <span class="ql-token hljs-string">''</span>; document.getElementById(<span class="ql-token hljs-string">'dong'</span>).addEventListener(<span class="ql-token hljs-string">'click'</span>, () =&gt; { axios.get(<span class="ql-token hljs-string">'http://192.168.31.56:3000/login'</span>, { params: { username: <span class="ql-token hljs-string">'dong'</span>, password: <span class="ql-token hljs-string">'111'</span> } }).then(res =&gt; { token = res.data.token; }); }); document.getElementById(<span class="ql-token hljs-string">'guang'</span>).addEventListener(<span class="ql-token hljs-string">'click'</span>, () =&gt; { axios.get(<span class="ql-token hljs-string">'http://192.168.31.56:3000/login'</span>, { params: { username: <span class="ql-token hljs-string">'guang'</span>, password: <span class="ql-token hljs-string">'222'</span> } }).then(res =&gt; { token = res.data.token; }); }); axios.get(<span class="ql-token hljs-string">'http://192.168.31.56:3000/qrcode/scan?id='</span> + id).<span class="ql-token hljs-keyword">catch</span>(e =&gt; { alert(<span class="ql-token hljs-string">'二维码已过期'</span>); }); document.getElementById(<span class="ql-token hljs-string">'confirm'</span>).addEventListener(<span class="ql-token hljs-string">'click'</span>, () =&gt; { axios.get(<span class="ql-token hljs-string">'http://192.168.31.56:3000/qrcode/confirm?id='</span> + id, { headers: { authorization: <span class="ql-token hljs-string">'Bearer '</span> + token } }).<span class="ql-token hljs-keyword">catch</span>(e =&gt; { alert(<span class="ql-token hljs-string">'二维码已过期'</span>); }); }); document.getElementById(<span class="ql-token hljs-string">'cancel'</span>).addEventListener(<span class="ql-token hljs-string">'click'</span>, () =&gt; { axios.get(<span class="ql-token hljs-string">'http://192.168.31.56:3000/qrcode/cancel?id='</span> + id).<span class="ql-token hljs-keyword">catch</span>(e =&gt; { alert(<span class="ql-token hljs-string">'二维码已过期'</span>); }); }); &lt;/script&gt; &lt;/body&gt; &lt;/html&gt; </div> </div> <p></p> <p>然后我们在 qrcode/confirm 接口里把 token 取出来,拿到其中的用户信息,保存到 map 里:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/y7exwbew.jpeg" alt=""></p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"><span class="ql-token hljs-meta">@Get('qrcode/scan')</span> async <span class="ql-token hljs-title">scan(@Query('id') id: string, @Headers('Authorization') auth: string)</span> { let user; <span class="ql-token hljs-keyword">try</span>{ const [, token] = auth.split(<span class="ql-token hljs-string">' '</span>); <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">info</span> <span class="ql-token hljs-operator">=</span> await <span class="ql-token hljs-built_in">this</span>.jwtService.verify(token); user = <span class="ql-token hljs-built_in">this</span>.users.find(item =&gt; item.id == info.userId); } <span class="ql-token hljs-keyword">catch</span>(e) { <span class="ql-token hljs-keyword">throw</span> <span class="ql-token hljs-keyword">new</span> <span class="ql-token hljs-title">UnauthorizedException</span>(<span class="ql-token hljs-string">'token 过期,请重新登录'</span>); } <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">info</span> <span class="ql-token hljs-operator">=</span> map.get(`qrcode_${id}`); <span class="ql-token hljs-keyword">if</span>(!info) { <span class="ql-token hljs-keyword">throw</span> <span class="ql-token hljs-keyword">new</span> <span class="ql-token hljs-title">BadRequestException</span>(<span class="ql-token hljs-string">'二维码已过期'</span>); } info.status = <span class="ql-token hljs-string">'scan-wait-confirm'</span>; info.userInfo = user; <span class="ql-token hljs-keyword">return</span> <span class="ql-token hljs-string">'success'</span>; } </div> </div> <p></p> <p>这样,当扫码确认后,那边就能拿到用户信息:</p> <p></p> <p>我们改下 static/index.html</p> <p></p> <p>确认的时候展示下登录用户的信息:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/pgnt39tn.jpeg" alt=""></p> <p></p> <p>加上登录之后,我们再测试下:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/4oo6ou00.jpeg" alt=""></p> <p></p> <p>手机扫码,点击登录光的账号,然后确认登录:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/dv981f2t.jpeg" alt=""></p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/ztutctne.jpeg" alt=""></p> <p></p> <p>刷新页面,重新扫码,然后登录东东账号。</p> <p></p> <p>这时候 pc 网站就显示了当前登录用户是 dong</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/dmgfh6y3.jpeg" alt=""></p> <p></p> <p>当然,登录状态需要一个 jwt,我们返回下就好了。</p> <p></p> <div class="ql-code-block-container"> <div class="ql-code-block"><span class="ql-token hljs-meta">@Get('qrcode/check')</span> async <span class="ql-token hljs-title">check(@Query('id') id: string)</span> { <span class="ql-token hljs-type">const</span> <span class="ql-token hljs-variable">info</span> <span class="ql-token hljs-operator">=</span> map.get(`qrcode_${id}`); <span class="ql-token hljs-keyword">if</span>(info.status === <span class="ql-token hljs-string">'scan-confirm'</span>) { <span class="ql-token hljs-keyword">return</span> { token: await <span class="ql-token hljs-built_in">this</span>.jwtService.sign({ userId: info.userInfo.userId }), ...info } } <span class="ql-token hljs-keyword">return</span> info; } </div> </div> <p></p> <p>这样,当那边确认登录之后,这边就拿到了 jwt 的 token,也就是完成了登录了:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/wel1z262.jpeg" alt=""></p> <p></p> <p>对比下掘金的扫码登录流程:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/8m4karj8.jpeg" alt=""></p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/c8zpwbnu.jpeg"></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/myzvwlwt.jpeg"></p> <p><br></p> <p></p> <p>是不是一摸一样?</p> <p></p> <p>扫码登录就是这样实现的。</p> <p></p> <p>案例代码上传了 github: https://github.com/QuarkGluonPlasma/nestjs-course-code/tree/main/qrcode-login</p> <p></p> <h2>总结</h2> <p></p> <p>扫码登录是常用的功能,掘金、知乎、b 站等各大网站都有。</p> <p></p> <p>流程是在 pc 选择扫码登录的方式,用 APP 扫码,在 app 上登录之后进入登录确认页面。</p> <p></p> <p>可以点击确认登录或者取消,如果确认登录,那 pc 网站就会自动登录该账号。</p> <p></p> <p>它的实现原理是这样的:</p> <p></p> <p><img src="https://pic.code-nav.cn/planet_post_image/1797461877294809089/u1jjkif3.jpeg" alt=""></p> <p></p> <p>pc 端生成二维码,然后不断轮询二维码状态。</p> <p></p> <p>APP 里扫码拿到 qrcode_id,然后分别调用 scan、confirm、cancel 来修改二维码状态。</p> <p></p> <p>并且登录之后会把 token 带过去。</p> <p></p> <p>在 redis 里保存着二维码的状态和用户信息,然后这边确认之后,另一边就可以用 userInfo 生成 jwt 的 token,从而实现登录。</p> <p></p> <p>这就是扫码登录的实现原理。</p> <p>#前端# #后端#</p> </div> </body> </html>

下载 APP