查看原文
其他

Nest.js 实战系列第二篇-实现注册、扫码登陆、jwt认证等

koala 程序员成长指北 2023-11-16

大厂技术  高级前端  Node进阶

点击上方 程序员成长指北,关注公众号

回复1,加入高级Node交流群

大家好我是考拉🐨,这是 Nest.js 实战系列第二篇,我要用最真实的场景让你学会使用 Node 主流框架。

先对最近催更的几个小伙伴,说一句 sorry,最近工作中 Node 后端内容做的不多,一直在做 低代码平台 相关,所以延迟了一些,不知道截图中这个小伙伴还关注我没,嘻嘻🐨,你若还在便是铁粉无疑了!


上一篇中 【Nest.js入门之基本项目搭建】 带大家入门了Nest.js, 接下来在之前的代码上继续进行开发, 主要两个任务:实现用户的注册与登录。

在实现登录注册之前,需要先整理一下需求, 我们希望用户有两种方式可以登录进入网站来写文章, 一种是账号密码登录,另一种是微信扫码登录。文章内容大纲

接着上章内容开始...

前面我们创建文件都是一个个创建的, 其实还有一个快速创建ContollerServiceModule以及DTO文件的方式:

nest g resouce user

这样我们就快速的创建了一个REST API的模块,里面简单的CRUD代码都已经实现了,哈哈,发现我们前面一章学习的一半的内容,可以一句命令就搞定~

用户注册

在注册功能中,当用户是通过用户名和密码进行注册,密码我们不能直接存明文在数据库中,所以采用bcryptjs实现加密, 然后再存入数据库。

实现注册之前,先了解一下加密方案bcryptjs,安装一下依赖包:

npm install bcryptjs

bcryptjs 是nodejs中比较好的一款加盐(salt)加密的包, 我们处理密码加密、校验要使用到的两个方法:

/**
 * 加密处理 - 同步方法
 * bcryptjs.hashSync(data, salt)
 *    - data  要加密的数据
 *    - slat  用于哈希密码的盐。如果指定为数字,则将使用指定的轮数生成盐并将其使用。推荐 10
 */

const hashPassword = bcryptjs.hashSync(password, 10)


/**
 * 校验 - 使用同步方法
 * bcryptjs.compareSync(data, encrypted)
 *    - data        要比较的数据, 使用登录时传递过来的密码
 *    - encrypted   要比较的数据, 使用从数据库中查询出来的加密过的密码
 */

const isOk = bcryptjs.compareSync(password, encryptPassword)

接下来设计用户实体:

// use/entities/user.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity('user')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: number;

  @Column({ length: 100 })
  username: string// 用户名

  @Column({ length: 100 })
  nickname: string;  //昵称

  @Column()
  password: string;  // 密码

  @Column()
  avatar: string;   //头像

  @Column()
  email: string;

  @Column('simple-enum', { enum: ['root''author''visitor'] })
  role: string;   // 用户角色

  @Column({
    name: 'create_time',
    type'timestamp',
    default() => 'CURRENT_TIMESTAMP',
  })
  createTime: Date;

  @Column({
    name: 'update_time',
    type'timestamp',
    default() => 'CURRENT_TIMESTAMP',
  })
  updateTime: Date;
  
  @BeforeInsert() 
  async encryptPwd() { 
    this.password = await bcrypt.hashSync(this.password); 
  } 
}
  1. 在创建User实体, 使用@PrimaryGeneratedColumn('uuid')创建一个主列id,该值将使用uuid自动生成。Uuid 是一个独特的字符串;
  2. 实现字段名驼峰转下划线命名, createTimeupdateTime字段转为下划线命名方式存入数据库, 只需要在@Column装饰器中指定name属性;
  3. 我们使用了装饰器@BeforeInsert来装饰encryptPwd方法,表示该方法在数据插入之前调用,这样就能保证插入数据库的密码都是加密后的。
  4. 给博客系统设置了三种角色rootautorvisitor, root有所以权限,author有写文章权限,visitor只能阅读文章, 注册的用户默认是visitor,root权限的账号可以修改用户角色。

接下来实现注册用户的业务逻辑

register 注册用户

实现user.service.ts逻辑:

import { User } from './entities/user.entity';
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { CreateUserDto } from './dto/create-user.dto';
import { Repository } from 'typeorm';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
  
) {}
  async register(createUser: CreateUserDto) {
    const { username } = createUser;

    const existUser = await this.userRepository.findOne({
      where: { username },
    });
    if(existUser){
        throw new HttpException("用户名已存在", HttpStatus.BAD_REQUEST)
    }

    const newUser = await this.userRepository.create(createUser)
    return await this.userRepository.save(newUser);
  }
}

犹记当时,写向数据库插入数据时,没仔细看文档,直接调用了create,结果发现数据并没有插入数据库, 后来发现save方法才是执行插入数据。

this.userRepository.create(createUser)
// 相当于
new User(createUser)  // 只是创建了一个新的用户对象

到这里就实现了注册用户的业务逻辑, Controller比较简单, 后面登录等业务实现,不再一一呈现Controller代码:

// user.controller.ts
 @ApiOperation({ summary: '注册用户' })
 @ApiResponse({ status: 201type: [User] })
 @Post('register')
 register(@Body() createUser: CreateUserDto) {
    return this.userService.register(createUser);
  }

执行上面代码, 返回的数据内容如下:

{
  "data": {
    "username""admin",
    "password""$2a$10$vrgqi356K00XY6Q9wrSYyuBpOIVf2E.Vu6Eu.HQcUJP.hDTuclSEW",
    "nickname"null,
    "avatar"null,
    "email"null,
    "id""5c240dcc-a9b1-4262-8212-d5ceb2815ef8",
    "createTime""2021-11-16T03:00:16.000Z",
    "updateTime""2021-11-16T03:00:16.000Z"
  },
  "code"0,
  "msg""请求成功"
}

可以发现密码也被返回了,这个接口的风险不言而喻,如何处理呢?可以思考一下~

从两方面考虑, 一个是数据层面,从数据库就不返回password字段,另一种方式是在返回数据给用户时,处理数据,不返回给前端。我们分别看一下这两种方式:

方法1

TypeORM提供的列属性select进行查询时是否默认隐藏此列。但是这只能用于查询时, 比如save方法的返回的数据就仍然会包含password

// user.entity.ts
 @Column({ select: false})    // 表示隐藏此列
 password: string;  // 密码

使用这种方式,我们user.service.ts中的代码可以做如下修改:

// user.service.ts
 async register(createUser: CreateUserDto) {
  ...
  await this.userRepository.save(newUser);
  return await this.userRepository.findOne({where:{username}})
 }

方法2

使用class-transformer提供的Exclude来序列化,对返回的数据实现过滤掉password字段的效果。首先在user.entity.ts中使用@Exclude装饰:

// user.entity.ts
...
import { Exclude } from 'class-transformer';

@Exclude()
@Column() 
password: string;  // 密码

接着在对应请求的地方标记使用ClassSerializerInterceptor,此时,POST /api/user/register这个请求返回的数据中,就不会包含password这个字段。

  @UseInterceptors(ClassSerializerInterceptor)
  @Post('register')
  register(@Body() createUser: CreateUserDto) {...}

此时可以不用像方法1那样,修改user.service.ts中的逻辑。如果你想让该Controller中所有的请求都不包含password字段, 那可以直接用ClassSerializerInterceptor标记类。

其实这两种方式结合使用也完全可以的。

用户登录

用户登录这块,前面也提到了打算使用两种方式,一种是本地身份验证(用户名&密码),另一种是使用微信扫码登录。先来看一下本地身份验证登录如何实现。

passport.js

首先介绍有个专门做身份认证的Nodejs中间件:Passport.js,它功能单一,只能做登录验证,但非常强大,支持本地账号验证和第三方账号登录验证(OAuth和OpenID等),支持大多数Web网站和服务。

passport中最重要的概念是策略,passport模块本身不能做认证,所有的认证方法都以策略模式封装为插件,需要某种认证时将其添加到package.json即可, 这里我不会详细去讲passport实现原理这些, 如果感兴趣可以留言,我单独准备一篇文章来分享登录认证相关的一些内容(Nodejs不止可以用passport,还有其他不错的包)。

local 本地认证

首先安装一下依赖包,前面说了passport本身不做认证, 所以我们至少要安装一个passport策略, 这里先实现本地身份验证,所以先安装passport-local:

npm install @nestjs/passport passport passport-local
npm install @types/passport @types/passport-local

我们还安装了一个类型提示,因为passport是纯js的包,不装也不会影响程序运行,只是写的过程中没有代码提示。

创建一个auth模块,用于处理认证相关的代码,Controllerservice等这些文件夹创建方式就不重复了。我们还需要创建一个local.strategy.ts文件来写本地验证策略代码:

// local.strategy.ts
...
import { compareSync } from 'bcryptjs';
import { PassportStrategy } from '@nestjs/passport';
import { IStrategyOptions, Strategy } from 'passport-local';
import { User } from 'src/user/entities/user.entity';

export class LocalStorage extends PassportStrategy(Strategy) {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  
) {
   
    super({
      usernameField: 'username',
      passwordField: 'password',
    } as IStrategyOptions);
  }

  async validate(username: string, password: string) {
    const user = await this.userRepository
      .createQueryBuilder('user')
      .addSelect('user.password')
      .where('user.username=:username', { username })
      .getOne();

    if (!user) {
      throw new BadRequestException('用户名不正确!');
    }

    if (!compareSync(password, user.password)) {
      throw new BadRequestException('密码错误!');
    }

    return user;
  }
}

我们从上至下的分析一下代码实现:

  • 首先定义了一个LocalStorage继承至@nestjs/passport提供的PassportStrategy类, 接受两个参数

    • 第一个参数: Strategy,你要用的策略,这里是passport-local
    • 第二个参数:是策略别名,上面是passport-local,默认就是local
  • 接着调用super传递策略参数, 这里如果传入的就是usernamepassword,可以不用写,使用默认的参数就是,比如我们是用邮箱进行验证,传入的参数是email, 那usernameField对应的value就是email

  • validateLocalStrategy的内置方法, 主要实现了用户查询以及密码对比,因为存的密码是加密后的,没办法直接对比用户名密码,只能先根据用户名查出用户,再比对密码。

    • 这里还有一个注意点, 通过addSelect添加password查询, 否则无法做密码对比。

有了这个策略,我们现在就可以实现一个简单的 /auth/login 路由,并应用Nest.js内置的守卫AuthGuard来进行验证。打开 app.controller.ts 文件,并将其内容替换为以下内容:

...
import { AuthGuard } from '@nestjs/passport';

@ApiTags('验证')
@Controller('auth')
export class AuthController {
  @UseGuards(AuthGuard('local'))
  @UseInterceptors(ClassSerializerInterceptor)
  @Post('login')
  async login(@Body() user: LoginDto, @Req() req) {
    return req.user;
  }
}

同时不要忘记在auth.module.ts导入PassportModule和实体User,并且将LocalStorage注入,提供给其模块内共享使用。

// auth.module.ts
... 
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from 'src/user/entities/user.entity';
import { LocalStorage } from './local.strategy';

@Module({
  imports: [TypeOrmModule.forFeature([User]), PassportModule],
  controllers: [AuthController],
  providers: [AuthService, LocalStorage],
})

接口返回的数据如下,这是我们所需要的吗?

开发中登录完,不是应该返回一个可以识别用户token这样的吗?

是的,客户端使用用户名和密码进行身份验证,服务器验证成功后应该签发一个身份标识的东西给客户端,这样以后客户端就拿着这个标识来证明自己的身份。而标识用户身份的方式有多种,这里我们采用jwt方式(关于身份认证可以看这篇文章 前端鉴权必须了解的5种方式:cookie、session、token、jwt与单点登录)。

jwt 生成token

接着我们要实现的就是,验证成功后,生成一个token字符串返回去。而jwt是一种成熟的生成token字符串的方案,它生成的token内容是这种形式:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImQyZTZkNjRlLWU1YTAtNDhhYi05ZjU2LWMyMjY3ZjRkZGMyNyIsInVzZXJuYW1lIjoiYWRtaW4xIiwicm9sZSI6InZpc2l0b3IiLCJpYXQiOjE2Mzc1NjMzNjUsImV4cCI6MTYzNzU3Nzc2NX0.NZl4qLA2B4C9qsjMjaXmZoFUyNjt2FH4C-zGSlviiXA

这种东西怎么生成的呢?

通过上图可以看出JWT token由三个部分组成,头部(header)、有效载荷(payload)、签名(signature)。实践一下

npm install @nestjs/jwt

首先注册一下JwtModule, 在auth.module.ts中实现:

...
import { JwtModule } from '@nestjs/jwt';

const jwtModule = JwtModule.register({
    secret:"test123456",
    signOptions: { expiresIn: '4h' },
})

@Module({
  imports: [
    ...
    jwtModule,
  ],
  exports: [jwtModule],
})

上面代码中,是通过将secret写死在代码中实现的,这种方案实际开发中是不推荐的,secret这种私密的配置,应该像数据库配置那样,从环境变量中获取,不然secret泄露了,别人一样可以生成相应的的token,随意获取你的数据, 我们采用下面这种异步获取方式:

...
const jwtModule = JwtModule.registerAsync({
  inject: [ConfigService],
  useFactory: async (configService: ConfigService) => {
    return {
      secret: configService.get('SECRET''test123456'),
      signOptions: { expiresIn: '4h' },
    };
  },
});
...

注意不要忘记在.env文件中设置SECRET配置信息。

最后我们在auth.service.ts中实现业务逻辑:

//auth.service.ts
...
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private jwtService: JwtService,
  
) {}

 // 生成token
  createToken(user: Partial<User>) {
    return this.jwtService.sign(user);
  }

  async login(user: Partial<User>) {
    const token = this.createToken({
      id: user.id,
      username: user.username,
      role: user.role,
    });

    return { token };
  }
}

到目前为止, 我们已经通过passport-local结合jwt实现了给用户返回一个token, 接下来就是用户携带token请求数据时,我们要验证携带的token是否正确,比如获取用户信息接口。

如果对 jwt 内容感觉看的不过瘾,可以看下我之前写的这篇 jwt 完整讲解。    搞懂 JWT 这个知识点

获取用户信息接口实现

实现token认证,passport也给我们提供了对应的passport-jwt策略,实现起来也是非常的方便,废话不多,直接Q代码:

首先安装:

npm install passport-jwt @types/passport-jwt

其实jwt 策略主要实现分两步

  • 第一步: 如何取出token
  • 第二步: 根据token拿到用户信息

我们看一下实现:

//jwt.strategy.ts
...
import { ConfigService } from '@nestjs/config';
import { UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { StrategyOptions, Strategy, ExtractJwt } from 'passport-jwt';

export class JwtStorage extends PassportStrategy(Strategy) {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    private readonly configService: ConfigService,
    private readonly authService: AuthService,
  
) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get('SECRET'),
    } as StrategyOptions);
  }

  async validate(user: User) {
    const existUser = await this.authService.getUser(user);
    if (!existUser) {
      throw new UnauthorizedException('token不正确');
    }
    return existUser;
  }
}

在上面策略中的ExtractJwt提供多种方式从请求中提取JWT,常见的方式有以下几种:

  • fromHeader:在Http 请求头中查找JWT
  • fromBodyField: 在请求的Body字段中查找JWT
  • fromAuthHeaderAsBearerToken:在授权标头带有Bearer方案中查找JWT我们采用的是fromAuthHeaderAsBearerToken,后面请求操作演示中可以看到,发送的请求头中需要带上,这种方案也是现在很多后端比较青睐的:
'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImQyZTZkNjRlLWU1YTAtNDhhYi05ZjU2LWMyMjY3ZjRkZGMyNyIsInVzZXJuYW1lIjoiYWRtaW4xIiwicm9sZSI6InZpc2l0b3IiLCJpYXQiOjE2Mzc1NzUxMzMsImV4cCI6MTYzNzU4OTUzM30._-v8V2YG8hZWpL1Jq3puxBlETeSuWg8DBEPCL2X-h5c'

不要忘记在auth.module.ts中注入JwtStorage

...
import { JwtStorage } from './jwt.strategy';

@Module({
  ...
  providers: [AuthService, LocalStorage, JwtStorage],
  ...
})

最后只需要在Controller中使用绑定jwt授权守卫:

// user.controller.ts

@ApiOperation({ summary: '获取用户信息' })
@ApiBearerAuth() // swagger文档设置token
@UseGuards(AuthGuard('jwt'))
@Get()
getUserInfo(@Req() req) {
    return req.user;
}

到这里获取用户信息接口就告一段落, 最后为了可以顺畅的使用Swagger来测试传递bearer token接口,需要添加一个addBearerAuth:

// main.ts
...
  const config = new DocumentBuilder()
    .setTitle('管理后台')
    .setDescription('管理后台接口文档')
    .addBearerAuth()
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('docs', app, document);
  await app.listen(9080);
  ...


微信扫码登录

到这里本地验证登录就完成了,通过上面的学习,关于登录这块的流程相信大家都已经掌握了, 接下来我再分享一下开发过程中我是如何实现微信扫码登录的。

注意:这块需要有微信开放平台的账号,如果没有也可以通过公众平台测试账号系统申请,具体流程这里就不说了。

需要准备什么

首先需要申请一个应用,拿到AppIDAppSecret

image.png

其次需要配置授权回到域名,也就是扫码成功后跳转的网站的域名。

image.png

假如你设置的是www.baidu.com,那么http://www.baidu.com/aaa?code=xxx是可以成功的,但是扫码成功后你要跳转http://lms.baidu.com/aaa?code=xxx, 那就不行,会提示:redirect_uri 参数错误。

准备好账号后,再看看我们要做的需求是什么样的。

扫码登录功能长什么样?

微信扫码登录时非常常见的需求,让用户使用微信登录第三方应用或者网站,一般就两种展现方式:

  • 第一种:重定向到微信指定的扫码页面
  • 第二种:将微信登录二维码内嵌到我们的网站页面中

这里采用的是第一种,直接重定向的方式,重定向后页面展示这样的:

用一张图来展示整个流程:

从图中可以看出微信登录需要网站页面,微信客户端,网站服务端和微信开放平台服务的参与,上面这些流程微信官方文档也有,就不详细的解释了。下面我们会以代码来实现一下, 后端分为以下四个步骤:

  1. 获取授权登录二维码
  2. 使用code换取微信接口调用凭证access_token
  3. 使用access_token获取用户信息
  4. 通过用户信息完成登录/注册,返回token给前端

代码实现

首先实现重定向到微信扫码登录页面,这部分可以前端来完成,也可以后端来进行重定向。如果后端来做重定向也是比较简单, 只需要使用AppIdredirectUri回调地址就能拼接出来,代码如下:

// auth.controller.ts
  @ApiOperation({ summary: '微信登录跳转' })
  @Get('wechatLogin')
  async wechatLogin(@Headers() header, @Res() res) {
    const APPID = process.env.APPID;
    const redirectUri = urlencode('http://lms.siyuanren.com/web/login_front.html');
    res.redirect(
      `https://open.weixin.qq.com/connect/qrconnect?appid=${APPID}&redirect_uri=${header.refere}&response_type=code&scope=snsapi_login&state=STATE#wechat_redirect`,
    );
  }

通过微信客户端扫码登录后,会重定向redirect_uri传递的地址,并且带上code参数的,此时前端将code传给后端, 后端就可以完成接下来的2,3,4步骤了。

auth.controller.ts中继续写微信登录接口:

//auth.controller.ts
 @ApiOperation({ summary: '微信登录' })
 @ApiBody({ type: WechatLoginDto, required: true })
 @Post('wechat')
 async loginWithWechat(@Body('code') code: string) {
    return this.authService.loginWithWechat(code);
 }

接着在auth.service.ts中实现获取access_token具体的逻辑:

// auth.service.ts
...
import {AccessTokenInfo, AccessConfig, WechatError, WechatUserInfo} from './auth.interface';
import { lastValueFrom } from 'rxjs';
import { AxiosResponse } from 'axios';

  constructor(
    ...
    private userService: UserService,
    private httpService: HttpService,
  
) {}
    
  // 获取access_token
   async getAccessToken(code) {
    const { APPID, APPSECRET } = process.env;
    if (!APPSECRET) {
      throw new BadRequestException('[getAccessToken]必须有appSecret');
    }
    if (
      !this.accessTokenInfo ||
      (this.accessTokenInfo && this.isExpires(this.accessTokenInfo))
    ) {
      // 使用httpService请求accessToken数据
      const res: AxiosResponse<WechatError & AccessConfig, any> =
        await lastValueFrom(
          this.httpService.get(
            `${this.apiServer}/sns/oauth2/access_token?appid=${APPID}&secret=${APPSECRET}&code=${code}&grant_type=authorization_code`,
          ),
        );

      if (res.data.errcode) {
        throw new BadRequestException(
          `[getAccessToken] errcode:${res.data.errcode}, errmsg:${res.data.errmsg}`,
        );
      }
      this.accessTokenInfo = {
        accessToken: res.data.access_token,
        expiresIn: res.data.expires_in,
        getTime: Date.now(),
        openid: res.data.openid,
      };
    }

    return this.accessTokenInfo.accessToken;
  }

获取到access_token, 其实这个接口中除了access_token还有几个参数,我们也是需要使用到的,这里简单说明一下:

参数版本
access_token接口调用凭证
expires_inaccess_token 接口调用凭证超时时间,单位(秒)
refresh_token用户刷新 access_token
openid授权用户唯一标识
scope用户授权的作用域,使用逗号(,)分隔

openid就是我们对于微信注册的用户的唯一标识, 那么此时就可以去数据库中查找用户是否存在,如果不存在就注册一个新用户:

// auth.service.ts
async loginWithWechat(code) {
    if (!code) {
      throw new BadRequestException('请输入微信授权码');
    }
    await this.getAccessToken(code);

    // 查找用户是否存在
    const user = await this.getUserByOpenid();
    if (!user) {
      // 获取微信用户信息,注册新用户
      const userInfo: WechatUserInfo = await this.getUserInfo();
      return this.userService.registerByWechat(userInfo);
    }
    return this.login(user);
}

async getUserByOpenid() {
    return await this.userService.findByOpenid(this.accessTokenInfo.openid);
}

这里实现的代码比较长,就不全部展示,请求微信开放平台接口都类似,就省略了使用access_token获取用户信息,需要源码可以自行获取。

如果你有兴趣,可以将微信登录这块封装成一个模块,这样微信公众平台的请求就不用都混杂在auth模块中。

最后给大家演示一下成果:

微信扫码登录实现起来还是比较简单的,登录注册这块文章介绍的比较详细,内容比较长,就单独一章吧,将完善文章模块以及上传文件功能放在下一篇文章中,希望对大家的学习能提供一点帮助。

总结

项目实战 git 地址:https://github.com/koala-coding/

文章实现了实现了注册、以及JWT本地认证登录和微信扫码登录,总体看起来可以, 实际上埋了两个坑。

  • 其一,本地认证登录的token没有设置过期时间,这样风险极大;
  • 其二,微信扫码登录的access_token是都时效性的,如何实现在有效期内多次使用,而不是每次扫码都去获取access_token

这两个问题可以结合Redis来解决, 在后面Redis讲解中, 会针对这两个问题给出解决方案,小伙伴们可以先思考一下,我们下一篇见🐨。

参考文章:

  • passport.js学习笔记
Node 社群


我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。


   “分享、点赞在看” 支持一波👍

继续滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存