SpringCloud系列—Spring Cloud实战之统一认证与授权

老老老JR老北
发布于 2022-7-25 17:31
浏览
0收藏

作者 | 宇木木兮
来源 |今日头条

学习目标

  1. 了解用户认证随着架构演进的发展
  2. 理解认证与授权的核心思想
  3. 理解微服务架构下的统一认证JWT+API的使用
    第1章 背景
    1.1 单体应用架构的用户认证

SpringCloud系列—Spring Cloud实战之统一认证与授权-鸿蒙开发者社区

在了解微服务架构的用户认证之前,先回到传统单体应用的授权方式,如上图所示。

http协议是一种无状态协议,也就是说,同一个客户端发起多次请求时,服务端并没有保存客户端的状态,因此服务端并不知道这两次请求属于同一个用户。但是在实际应用中,对于一些敏感和交易类操作,我们必须要知道用户是谁,但是在无状态协议中,无法实现这样的功能。

为了解决这个的问题,分别从服务器端和客户端层面着手,为http协议提供了状态保存的支持。

  1. 服务器端提供了session机制
  2. 客户端提供了cookie机制
    客户端发起请求到服务端,如果这个客户端是第一次访问,那么在服务端会为这个请求创建一个session,并采用唯一的编号sessionid标记保存到session容器中。同时服务端会把该sessionid写入到客户端浏览器的cookie中。

有了这样的一个实现机制后,这个客户端每次向服务器端发起请求时,都会携带sessionid,服务器端收到该id后,就可以找到该id对应的session。

总的来说,基于sessionid的设计,使得客户端和服务端分别存储了当前会话的请求信息,从而使得后续的每次访问,服务端能够通过该sessionid识别客户端身份。

那么session和用户认证有什么关系呢?

我们说的用户认证,一般是指用户通过帐号密码、手机号、邮箱等具备唯一标识的数据提交到网站上,服务端通过这些信息来判断当前访问系统的用户是谁?同时还可以基于这个身份,来设置对应的权限!当用户认证成功后,登录到网站上,那么他在这个网站上的所有行为以及这个用户能做什么操作,服务端是能清晰的知道并作出行为干预等操作。而这一切的实现,都基于用户会话状态的保存,不管是用户登录状态,还是当前会话中的用户其他信息。

1.2 集群架构下的会话保存SpringCloud系列—Spring Cloud实战之统一认证与授权-鸿蒙开发者社区随着业务大规模的发展,服务端的架构也随之调整,引入了集群部署的形式,如上图所示。

在这样一个架构中,基于容器对象SESSION来保存会话信息,就出现了比较明显的问题,主要体现在会话丢失的问题,原因是:

  1. 客户端请求到服务端时,会被负载均衡设备基于负载均衡算法分发到集群的任意一台服务器。
  2. 服务器端的会话信息保存在session,而session又属于当前容器对象,无法实现多服务器共享。
    这就导致:假设第一次请求落到A服务器上,创建sessionid并且保存了当前会话的状态。第二次请求却落到了B节点,此时B节点是不知道当前这个请求是已经记录了状态的,B服务器发现本地并没有存储当前请求的sessionid,会认为当前是第一次请求。围绕集群架构下的会话保存问题,下面是提出的几个解决方法。

1.2.1 Session Sticky
session sticky(粘性) , 保证同一个会话的请求都在同一个web服务器上处理,这样的话,就完全不需要考虑到会话的问题了。比如前面说的负载均衡算法中,一致性哈希算法就是一个典型的实现手段。

1.2.2 Session Replication
session复制,通过相关技术实现session复制,使得集群中的各个服务器相互保存各自节点存储的session数据。tomcat可以基于IP组播放方式实现session复制的功能,这种实现方式的问题:

  1. 同步session数据会造成网络开销,随着集群规模越大,同步session带来的带宽影响也越大
  2. 每个节点需要保存集群中所有节点的session数据,就需要比较大的内存来存储。

在节点持续增多的情况下,session复制带来的性能损失会快速增加.特别是当session中保存了较大的对象,而且对象变化较快时,性能下降更加显著.这种特性使得web应用的水平扩展受到了限制.

1.2.3 分布式session
分布式session的原理是把用户认证的信息存储在一个统一的容器中,并且使用用户会话作为key进行存储。当用户访问某个节点时,可以从统一的共享存储中,根据会话key去获取当前会话的数据,如下图所示。 SpringCloud系列—Spring Cloud实战之统一认证与授权-鸿蒙开发者社区spring提供了一个spring-session-data-redis的jar包,可以实现统一会话存储的功能。

这种方案有一个缺点,就是对会话状态做了统一的存储,如果存储会话的服务只有一个节点的话,那么随着业务扩展和用户量增加时,会出现性能瓶颈,而且后续在数据迁移方面也比较麻烦。

1.2.4 客户端Token方案

SpringCloud系列—Spring Cloud实战之统一认证与授权-鸿蒙开发者社区

客户端token方案,是指在客户端浏览器去维护一个访问token。这个token是用户在登录成功后生成并给到当前客户端的,后续这个客户端的每次请求,都需要携带这个访问token。服务端拿到token后,对token进行验证和解码,可以校验token的合法性以及过期状态等。只要这个token被正常校验通过,服务端则认为这个用户是登录态。

这种方式相比分布式session来说,在客户端Token方案中,服务端不需要记录用户的登录状态,也就是不需要再去维护一个统一的session。

其实这种实现方式和sessionid类似,都是服务端生成token来存储,只是客户端Token方案不需要把token信息存储到服务端,由客户端来保存。

但是同样也有缺点,一旦token交给了客户端保存,服务端就无法控制这个toekn,如果想强行下线某个用户,在无状态模式下就比较难实现。

OK,到这里必须小小的总结一下,要不然基础不好的同学有点晕啊,实际上介绍了这么多,做认证和授权的逻辑无非就是在后台产生一个唯一识别的东西,然后给到浏览器去保存,然后每次请求过来的时候我在根据唯一识别的东西进行校验,而这个唯一识别的东西有两种实现方式:1.通过Session去实现。2.通过Token去实现。这才是真正实现认证的底层逻辑。

而我们所知道的框架spring Security或者shiro或者Gateway实际上你们没学过的同学可能有点懵,不知道是什么,其实你们就可以理解成给我们提供一些过滤器,当请求要进到后台服务器的时候,这仨兄弟会将请求根据一些配置的规则进行拦截过滤,不让我们直接请求,而是先去做认证和授权,同时这几个框架有写给我们实现了Session的逻辑。有些给我们实现了Token的逻辑而已。而我们后面要讲的JWT其实就是给我们提供了Token的创建,解析,验证的功能,它没有过滤器,所以它要实现认证还得找一个搭档这个搭档可以是Gateway,也可以是spring Security。

1.3 微服务架构下的会话保存
同样,再回到微服务架构中,会话信息存储的问题仍然存在,如下图所示。SpringCloud系列—Spring Cloud实战之统一认证与授权-鸿蒙开发者社区在微服务架构下,一个应用被拆分成若干个微服务应用,每个微服务应用都需要对访问进行授权。

那同样的问题是,这个用户状态信息如何存储?如何在每个微服务应用中进行识别和传输?基于上述的分析,再回到实际应用中,微服务常见的统一认证解决方案有两种。

  1. JWT+API网关的认证方案。
  2. OAuth2.0的认证方案,典型的实现框架Spring Security。
    第2章 JWT+API
    这里我们先来了解JWT这个方案。

JWT全称是JSON Web Tokens,它是一种简洁的并且在两个计算机之间安全传递信息的表述性声明规范。JWT的声明一般被用来在客户端和服务端之间传递被认证的用户身份信息,以便于从资源服务器获取资源,比如用在用户登录上。

在微服务架构中,一般常见的解决方法,是在api网关层,增加统一的用户认证机制,再集合客户端的token机制来完成用户身份信息的识别,具体如下图所示。SpringCloud系列—Spring Cloud实战之统一认证与授权-鸿蒙开发者社区客户端请求到api网关时,服务端基于jwt机制给这个客户端分配一个令牌,保存到cookie或者header中。后续这个用户每次携带这个token来访问,api网关收到这个token后,拿到token进行用户身份认证,认证通过后把这个用户的信息保存到请求上下文往下传递。而对于内部的微服务通信,由于都是基于内网的可信任通信方式,因此一般不会在这些节点上再做用户认证与授权。

如果每个微服务节点都增加用户认证,会给整个架构带来的成本和复杂性,以及多次认证带来的性能问题。

2.1 JWT详解
JWT全称是JSON Web Tokens。简单理解就是它能生成一个具有认证能力的访问token。

它的工作原理如下图所示。
它和传统的cookie+session认证方式不同,JWT强调的是服务端不对token进行存储,而是直接通过签名算法验证并解密token得到相应数据进行处理。SpringCloud系列—Spring Cloud实战之统一认证与授权-鸿蒙开发者社区2.1.1 token的组成
JWT token由三个部分组成,头部(header)、有效载荷(payload)、签名(signature)。

官网: https://jwt.io/
打开官网,在官网中提供了编码和解码的演示,咱们来分别说一下header、payload、signature。

在下面这个图中,Encoded表示生成的token数据,Decoded表示针对这个token的解码。SpringCloud系列—Spring Cloud实战之统一认证与授权-鸿蒙开发者社区2.1.2 header
header部分由typ和alg组成,typ的全称是(type,类型)、alg全称(algorithm,算法),类型可以自己定义,没有限制。而alg: HS256,表示当前的token是使用HS256算法来进行加密的。

HS256实际上是一种签名算法,这个地方简单给大家讲讲签名算法相关的知识。首先我们要了解几个名词:

  • 数字签名,数字签名和我们平常在文件中签上自己的名字是一样的,数字签名是为了防止伪造。
  • 数字摘要/数据指纹: 一般来说指的就是数据的hash值,比如SHA1/SHA256/SHA512/MD5等,这些都是常见的摘要算法,最简单的例子是我们在网上下载一个软件时,软件会有一个md5的编码,一般为了安全起见,都会让我们去验证下载下来的软件的md5和官方的md5是否匹配,如果匹配就表示没有被串改过
  • 加密算法,这个就比较容易理解了,就是直接对数据进行加密,加密算法和摘要算法最大的区别就是,加密是可逆的。常见的加密算法有对称加密和非对称加密等。
    数字签名一般的实现是,先对一个原始数据进行一次HASH摘要,然后再使用非对称加密算法(RSA、ECC等)对这个摘要进行加密,这样得到的结果就是原始数据的一个签名。

那么用户在验证数据的时候,只需要使用公钥对签名进行解密,然后得到一组hash的摘要,用这个摘要和要比较的目标数据的摘要再进行比较,如果这两个摘要相等,说明验证成功,否则则验证失败。

而在JWT中,提供了好几种签名算法的支持,分别是:

  • HS256, 这种签名算法是表示采用同一个[secret-key]进行签名与验证,这种就是属于对称加密的验证方式,一旦[secret_key]泄露,就无法保证安全性。
  • RS256,RS256采用的是RSA非对称加密算法来进行加密,使用公钥进行验证,通过私钥进行加密,公钥即使泄露也不会有影响,只需要确保私钥的安全即可。

ES256,ES256和RS256是一样的,都是使用私钥签名、公钥验证,算法速度上的差距也不大,但是ES算法的长度相对来说要短一些。

对于单体应用来说,HS256和RS256的安全性相差不大,如果是在微服务架构中,需要多方验证的场景,使用RS256/ES256的安全性会更高。

Header部分的数据,是通过base64位进行编码SpringCloud系列—Spring Cloud实战之统一认证与授权-鸿蒙开发者社区

2.1.3 payLoad

Payload 里面是 Token 的具体内容,也是一个json字符串,这些内容里面有一些是标准字段,你也可以添加其它需要的内容;payload的json结构并不像header那么简单,payload用来承载要传递的数据,它的json结构实际上是对JWT要传递的数据的一组声明,这些声明被JWT标准称为claims , JWT默认提供了一些标准的Claim,具体内容如下。

  • iss(Issuser):代表这个JWT的签发主体;
  • sub(Subject):代表这个JWT的主体,即它的所有人;
  • aud(Audience):代表这个JWT的接收对象;
  • exp(Expiration time):是一个时间戳,代表这个JWT的过期时间;
  • nbf(Not Before):是一个时间戳,代表这个JWT生效的开始时间,意味着在这个时间之前验证JWT是会失败的;
  • iat(Issued at):是一个时间戳,代表这个JWT的签发时间;
  • jti(JWT ID):是JWT的唯一标识。
    按照JWT标准的说明:标准的claims都是可选的,在生成playload不强制用上面的那些claim,你可以完全按照自己的想法来定义payload的结构,不过这样做根本没必要:
  • 第一是,如果把JWT用于认证, 那么JWT标准内规定的几个claim就足够用了,甚至只需要其中一两个就可以了,假如想往JWT里多存一些用户业务信息,比如角色和用户名等,这倒是用自定义的claim来添加;
  • 第二是,JWT标准里面针对它自己规定的claim都提供了有详细的验证规则描述,每个实现库都会参照这个描述来提供JWT的验证实现,所以如果是自定义的claim名称,那么你用到的实现库就不会主动去验证这些claim。

同样,payLoad中的数据,也是拼接好之后,通过base64进行编码,得到一个目标字符串

2.1.4 signature
signature表示签名,它的组成是。

signature=HS256(base64(header)+"."+base64(payload),secret_key)

签名的组成是,把header、payload分别通过base64进行编码,然后拼接在一起,使用.作为分隔符。再通过header中声明的签名方法进行整体签名,其中HS256是一种对称加密方法,需要指定一个secret_key。最终得到的signature就成为了jwt中的第三个部分。最后将这3个部分组成一个完整的字符串构成了JWT:base64(header)+”.”+base64(payload)+”.”+sinature 。 这就是JWT的核心。

secret_key是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它在任何场景都不应该流露出去。一旦客户端得知这个secret_key, 那就意味着客户端是可以自我签发jwt了

2.1.5 总结
其实大家会发现,jwt实际上就是定义了一套数据加密以及验签的算法的规范,根据这个规范来实现服务端的token生成,以及数据传输及验签功能。

2.2 JWT的基本应用
下面使用jwt来实现一个简单的案例,对JWT做更进一步的了解

1.引入第三方依赖

jwt有很多第三方的jar包,我们可以采用下面这个jar

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.18.2</version>
</dependency>
<dependency>
    <groupId>joda-time</groupId>
    <artifactId>joda-time</artifactId>
    <version>2.10.13</version>
</dependency>

2.JwtTokenUtil

@Slf4j
public class JwtTokenUtil {
    private static AESUtil aesUtil=new AESUtil();
    //密钥
    private static final String secret_key="0e509946-7e41-4dee-9ee1-1c300f6e4d35";
    private static final Algorithm ALGORITHM = Algorithm.HMAC256(secret_key);
    private static final String ISSUER="user-service";
    /**
     * 生成token
     * @param content
     * @return
     */
    public static String generateToken(String content){
        if(StringUtils.isEmpty(content)){
            System.out.println("token请求内容为空");
        }
        //ISSUER,表示token的颁发者身份标识,它可以用来验证token发行人信息是否一致,一般是一个http网址。
        //在 Token 的验证过程中,会将它作为验证的一个阶段,如无法匹配将会造成验证失败,最后返回 HTTP 401。
        String token= JWT.create().withIssuer(ISSUER)
                //设置token的有效期为1天
                .withExpiresAt(DateTime.now().plusDays(1).toDate())
                .withClaim("user",aesUtil.encrypt(content)) //把内容加密后保存到token中
                .sign(ALGORITHM); //设置签名算法
        return token;
    }
    /**
     * 验证并解析token
     * @param token
     * @return
     */
    public static String verifyAndParseToken(String token){
        if(StringUtils.isEmpty(token)){
            System.out.println("token不能为空");
            return "token不能为空";
        }
        try {
            //构建JWT验证器
            JWTVerifier verifier = JWT.require(ALGORITHM).withIssuer(ISSUER).build();
            DecodedJWT decodedJWT = verifier.verify(token); //验证token并解析
            log.info("[JwtTokenUtil.verifyAndParseToken()] 签发人:{},加密方式:{}, 携带的主题内容:{}", decodedJWT.getIssuer(), decodedJWT.getAlgorithm(), decodedJWT.getClaim("user").asString());
            return aesUtil.decrypt(decodedJWT.getClaim("user").asString());
        }catch (Exception e){
            log.error("[JwtTokenUtil.verifyAndParseToken()] Occur Exception ", e);
            System.out.println(e.getMessage());
            return e.getMessage();
        }
    }
}

3.AESUtil

这个是采用对称加密算法,实现对content内容的加密

@Slf4j
public class AESUtil {
    //加密或解密内容
  /*  @Setter
    private String content;*/
    //加密密钥
    private String secret="10372b6f-865d-402c-b8f2-eb16a578018c";

    public AESUtil(){}

    public AESUtil(String secret){
        this.secret=secret;
    }

    /**
     * 加密
     * @return 加密后内容
     */
    public String encrypt (String content) {
        Key key = getKey();
        byte[] result = null;
        try{
            //创建密码器
            Cipher cipher = Cipher.getInstance("AES");
            //初始化为加密模式
            cipher.init(Cipher.ENCRYPT_MODE,key);
            //加密
            result = cipher.doFinal(content.getBytes("UTF-8"));
        } catch (Exception e) {
            log.info("aes加密出错:"+e);
        }
        //将二进制转换成16进制
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < result.length; i++) {
            String hex = Integer.toHexString(result[i] & 0xFF);
            if (hex.length() == 1) {
                hex = '0' + hex;
            }
            sb.append(hex.toUpperCase());
        }
        return  sb.toString();
    }

    /**
     * 解密
     * @return 解密后内容
     */
    public String decrypt (String content) {
        //将16进制转为二进制
        if (content.length() < 1)
            return null;
        byte[] result = new byte[content.length()/2];
        for (int i = 0;i< content.length()/2; i++) {
            int high = Integer.parseInt(content.substring(i*2, i*2+1), 16);
            int low = Integer.parseInt(content.substring(i*2+1, i*2+2), 16);
            result[i] = (byte) (high * 16 + low);
        }

        Key key = getKey();
        byte[] decrypt = null;
        try{
            //创建密码器
            Cipher cipher = Cipher.getInstance("AES");
            //初始化为解密模式
            cipher.init(Cipher.DECRYPT_MODE,key);
            //解密
            decrypt = cipher.doFinal(result);
        } catch (Exception e) {
            log.info("aes解密出错:"+e);
        }
        assert decrypt != null;
        return new String(decrypt);
    }

    /**
     * 根据私钥内容获得私钥
     */
    private Key getKey () {
        SecretKey key = null;
        try {
            //创建密钥生成器
            KeyGenerator generator = KeyGenerator.getInstance("AES");
            //初始化密钥
            SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
            random.setSeed(secret.getBytes());
            generator.init(128,random);
            //生成密钥
            key = generator.generateKey();
        } catch (NoSuchAlgorithmException e) {
            log.error("[AESUtil.getKey()], Occur Exception ",e);
            e.printStackTrace();
        }
        return key;
    }
}

4.测试代码

public static void main(String[] args) {
    String content="Hello ,Mic";
    String token=JwtTokenUtil.generateToken(content);
    System.out.println(token);
    String rs=JwtTokenUtil.verifyAndParseToken(token);
    System.out.println(rs);
}

2.3 项目用应用

SpringCloud系列—Spring Cloud实战之统一认证与授权-鸿蒙开发者社区

2.3.1 项目框架搭建
1.创建Maven父项目mall,并配置pom

<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.example</groupId>
  <artifactId>mall</artifactId>
  <packaging>pom</packaging>
  <version>1.0-SNAPSHOT</version>
  <modules>
    <module>mall-api</module>
    <module>mall-service</module>
    <module>mall-commons</module>
    <module>mall-eureka-server</module>
    <module>mall-portal</module>
    <module>mall-gateway</module>
  </modules>

  <name>mall</name>
  <!-- FIXME change it to the project's website -->
  <url>http://www.example.com</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <spring-boot.version>2.3.2.RELEASE</spring-boot.version>
    <spring-cloud-alibaba.version>2.2.6.RELEASE</spring-cloud-alibaba.version>
    <spring-cloud.version>Hoxton.SR9</spring-cloud.version>
    <druid-starter.version>1.2.9</druid-starter.version>
    <mybatis-plus-starter.version>3.4.2</mybatis-plus-starter.version>
    <mybatis-plus-generator.version>3.4.0</mybatis-plus-generator.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.11</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-dependencies</artifactId>
        <version>${spring-boot.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-alibaba-dependencies</artifactId>
        <version>${spring-cloud-alibaba.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-dependencies</artifactId>
        <version>${spring-cloud.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid-spring-boot-starter</artifactId>
        <version>${druid-starter.version}</version>
      </dependency>
      <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>${mybatis-plus-starter.version}</version>
      </dependency>
      <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-generator</artifactId>
        <version>${mybatis-plus-generator.version}</version>
      </dependency>
      <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>1.5.1.Final</version>
      </dependency>
      <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct-processor</artifactId>
        <version>1.5.1.Final</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>

2.创建Maven的quick-start项目:mall-commons(提供各种共享工具)、mall-api(针对不同的服务提供接口)、mall-service(不同的服务接口的具体实现)三个项目,这三个项目后面会根据具体的业务再创建子项目。再创建mall-eureka-server项目用来做注册中心,具体的配置参考之前的文档;创建springboot项目mall-portal作为前端项目,用来模拟请求;创建springboot项目mall-gateway作为网关项目,具体配置参照前面的文章。三个空项目的pom文件具体见代码。

3.在mall-commons中创建maven-quick-start子项目common-core项目,pom配置见代码

4.在mall-commons中创建maven-quick-start子项目common-core项目,并定义请求、相应、异常的工具类(具体代码文章中不再贴出)

5.在mall-api中创建maven-quick-start子项目user-api,pom配置见代码

6.在mall-service中创建springboot子项目user-service,pom配置见代码

7.最终的框架图如下SpringCloud系列—Spring Cloud实战之统一认证与授权-鸿蒙开发者社区2.3.2 表结构

-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `username` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户名',
  `password` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '密码,加密存储',
  `phone` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '注册手机号',
  `email` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '注册邮箱',
  `sex` varchar(2) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '性别',
  `state` int NULL DEFAULT 0 COMMENT '状态(1,有效,0,无效)',
  `file` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '头像',
  `points` int NULL DEFAULT 0 COMMENT '积分',
  `balance` decimal(10, 2) NULL DEFAULT 0.00 COMMENT '余额',
  `description` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '个人简介',
  `created` datetime(0) NOT NULL,
  `updated` datetime(0) NOT NULL,
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `username`(`username`) USING BTREE,
  UNIQUE INDEX `phone`(`phone`) USING BTREE,
  UNIQUE INDEX `email`(`email`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 67 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of tb_member
-- ----------------------------
INSERT INTO `user` VALUES (62, 'test', '098f6bcd4621d373cade4e832627b4f6', NULL, NULL, NULL, 1, 'https://www.baidu.com', NULL, NULL, NULL, '2017-09-05 21:27:54', '2017-10-08 18:13:51');
INSERT INTO `user` VALUES (66, 'zhangsan', '4eea1e5de59fbc61cb3ab480dbbf6a5f', NULL, NULL, NULL, 1, 'https://www.baidu.com', NULL, NULL, NULL, '2019-08-06 00:15:48', '2019-08-06 00:15:48');
SET FOREIGN_KEY_CHECKS = 1;

2.3.3 common-core
1.定义请求相应包装类

package com.example.core;
import lombok.Data;
@Data
public class AbstractRequest {
}

//----------------------------
package com.example.core;
import lombok.Data;
import java.io.Serializable;
@Data
public abstract class AbstractResponse implements Serializable {
    private String code;
    private String msg;
}

//----------------------------
package com.example.core;

import com.example.enums.CommonRetCodeEnums;
import lombok.Data;
@Data
public class CommonResponse<T> {
    private int code;
    private String message;
    private boolean success;
    private T data;

    public static <T> CommonResponse<T> error(AbstractResponse response){
        return error(CommonRetCodeEnums.FAILED.getCode(),response.getMsg());
    }
    public static <T> CommonResponse<T> error(int code,String msg){
        CommonResponse<T> response=new CommonResponse<>();
        response.setCode(code);
        response.setMessage(msg);
        response.setSuccess(false);
        return response;
    }
    public static <T> CommonResponse<T> success(T data){
        CommonResponse<T> response=new CommonResponse<>();
        response.setCode(CommonRetCodeEnums.SUCCESS.getCode());
        response.setMessage(CommonRetCodeEnums.SUCCESS.getMsg());
        response.setData(data);
        response.setSuccess(true);
        return response;
    }
}

2.定义枚举

package com.example.enums;
public enum CommonRetCodeEnums {
    SUCCESS(200,"成功"),
    FAILED(500,"失败");
    private int code;
    private String msg;
    CommonRetCodeEnums(int code,String msg){
        this.code=code;
        this.msg=msg;
    }
    public int getCode() {
        return code;
    }
    public String getMsg() {
        return msg;
    }
}

3.定义异常

package com.example.exception;
import lombok.Data;
@Data
public class BizException extends RuntimeException{
    /**返回码*/
    private String errorCode;
    /**信息*/
    private String errorMessage;
    public BizException() {
        super();
    }
    public BizException(String errorCode) {
        super(errorCode);
    }
    public BizException(Throwable cause) {
        super(cause);
    }
    public BizException(String errorCode, Throwable cause) {
        super(cause);
        this.errorCode = errorCode;
    }
    public BizException(String errorCode, String message) {
        super();
        this.errorCode = errorCode;
        this.errorMessage = message;
    }
    public BizException(String errorCode, String message, Throwable cause) {
        super(cause);
        this.errorCode = errorCode;
        this.errorMessage = message;
    }
}
//----------------------------
package com.example.exception;
import lombok.Data;
@Data
public class ValidException extends RuntimeException{
    /**返回码*/
    private String errorCode;
    /**信息*/
    private String errorMessage;
    public ValidException() {
        super();
    }
    public ValidException(String errorCode) {
        super(errorCode);
    }
    public ValidException(Throwable cause) {
        super(cause);
    }
    public ValidException(String errorCode, Throwable cause) {
        super(cause);
        this.errorCode = errorCode;
    }
    public ValidException(String errorCode, String message) {
        super();
        this.errorCode = errorCode;
        this.errorMessage = message;
    }
    public ValidException(String errorCode, String message, Throwable cause) {
        super(cause);
        this.errorCode = errorCode;
        this.errorMessage = message;
    }
}

2.3.4 user-api
1.创建请求和响应对象

@Data
public class LoginRequest extends AbstractRequest {

    private String userName;
    private String password;

    public void requestCheck() {
        if(StringUtils.isBlank(userName)||StringUtils.isBlank(password)){
            throw new ValidException(
                    UmsResCodeEnum.REQUEST_CHECK_FAILURE.getCode(),
                    UmsResCodeEnum.REQUEST_CHECK_FAILURE.getMsg("用户名或密码不能为空"));
        }
    }
}
@Data
public class LoginResponse extends AbstractResponse {
    private Long id;
    private String username;
    private String phone;
    private String email;
    private String sex;
    private String file;
    private String description;
    private Integer points;
    private Long balance;
    private String accessToken;
}

2.创建验证响应对象

@Data
public class ValidTokenResponse extends AbstractResponse {
    private String uid;
}

3.创建状态的枚举

public enum ResCodeEnum {
    //004000~004030 为系统公共异常
    SYS_SUCCESS("000000", "成功"),
    SYS_PARAM_NOT_RIGHT("004001", "传入参数值不合法"),
    SYS_PARAM_NOT_NULL("004002", "必要参数不能为空"),
    UPDATE_DATA_FAIL("004003","更新数据失败"),
    SYS_UPDATE_DATA_FAIL("004004", "更新数据失败"),
    QUERY_DATA_NOT_EXIST("004005", "查询数据不存在"),
    REQUEST_CHECK_FAILURE("004006", "请求数据校验失败"),
    STATUS_NOT_RIGHT("004007", "数据状态校验不通过"),
    REQUEST_DATA_NOT_EXIST("004008", "请求提交的数据不存在"),
    USERORPASSWORD_ERRROR("004009","用户名或密码不正确"),
    TOKEN_VERIFY_FAILURE("004010","访问Token验证失败"),
    SYSTEM_EXCEPTION("004999","系统繁忙,请稍候重试");
    private final String code;
    private final String msg;
    ResCodeEnum(String code, String msg) {
        this.code = code;
        this.msg = msg;
    }
    public String getCode() {
        return code;
    }
    public String getMsg() {
        return msg;
    }
    /**
     * 用来描述详细错误信息
     * @param detailedDesc
     * @return
     */
    public String getMsg(String detailedDesc) {
        return msg + ":" + detailedDesc;
    }
}

4.定义登录授权接口

@FeignClient("user-service")
@RequestMapping("/auth")
public interface IAuthService {
    /**
     * 帐号密码登录
     * @param request
     * @return
     */
    @PostMapping
    LoginResponse login(@RequestBody LoginRequest request);
    /**
     * token验证
     * @param token
     * @return
     */
    @GetMapping
    ValidTokenResponse validToken(@RequestParam String token);
}

2.3.5 user-service
1.使用mybatis-plus生成数据库层面的代码

参考mybatis-plus自动生成代码

2.修改application.properties文件

server.port=9090
spring.application.name=user-service

spring.datasource.druid.url=jdbc:mysql://192.168.8.74:3306/test1?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.druid.username=root
spring.datasource.druid.password=123456
spring.datasource.druid.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.druid.initial-size=30
spring.datasource.druid.max-active=100
spring.datasource.druid.min-idle=10
spring.datasource.druid.max-wait=60000
spring.datasource.druid.time-between-eviction-runs-millis=60000
spring.datasource.druid.min-evictable-idle-time-millis=300000
spring.datasource.druid.validation-query=SELECT 1 FROM DUAL
spring.datasource.druid.test-while-idle=true
spring.datasource.druid.test-on-borrow=false
spring.datasource.druid.test-on-return=false
spring.datasource.druid.filters=stat,wall
mybatis-plus.configuration.map-underscore-to-camel-case=true
mybatis-plus.configuration.auto-mapping-behavior=full
mybatis-plus.mapper-locations=classpath*:mapper/**/*.Mapper.xml
eureka.client.service-url.defaultZone=http://localhost:8761/eureka
eureka.instance.hostname=localhost
#Endpoint
management.endpoints.web.exposure.include=*

3.在user-service模块定义接口实现

@Slf4j
@RestController
public class AuthService implements IAuthService {
    @Autowired
    private UserMapper userMapper;

    @Autowired
    UserConverter userConverter;

    @Override
    public LoginResponse login(LoginRequest request) {
        log.info("[AuthService.login()],begin execute!");
        LoginResponse response=new LoginResponse();
        try {
            request.requestCheck();
            response=doLogin(request);
        }catch (Exception e){
            log.error("[ContentService.content()], occur Exception",e);
            UserServiceExceptionWrapper.handlerException4biz(response,e);
        }
        return response;
    }

    @Override
    public ValidTokenResponse validToken(String token) {
        ValidTokenResponse response=new ValidTokenResponse();
        try{
            if(StringUtils.isBlank(token)){
                throw new ValidException(ResCodeEnum.SYS_PARAM_NOT_NULL.getCode(), ResCodeEnum.SYS_PARAM_NOT_NULL.getMsg());
            }
            String content=JwtTokenUtil.verifyAndParseToken(token);
            response.setUid(content);
            response.setCode(ResCodeEnum.SYS_SUCCESS.getCode());
            response.setMsg(ResCodeEnum.SYS_SUCCESS.getMsg());
        }catch (Exception e){
            log.error("[ContentService.validToken()], occur Exception",e);
            UserServiceExceptionWrapper.handlerException4biz(response,e);
        }
        return response;
    }

    private LoginResponse doLogin(LoginRequest request){
        LoginResponse response=new LoginResponse();
        QueryWrapper<User> queryWrapper=new QueryWrapper<>();
        queryWrapper.eq("username",request.getUserName()).eq("state", UserServiceConstants.USER_STATE_ENABLE);
        User user=userMapper.selectOne(queryWrapper);
        if(user==null){
            throw new ValidException(ResCodeEnum.USERORPASSWORD_ERRROR.getCode(),ResCodeEnum.USERORPASSWORD_ERRROR.getMsg());
        }
        if(!DigestUtils.md5DigestAsHex(request.getPassword().getBytes()).equals(user.getPassword())){
            throw new ValidException(ResCodeEnum.USERORPASSWORD_ERRROR.getCode(),ResCodeEnum.USERORPASSWORD_ERRROR.getMsg());
        }
        Map<String,Object> tokenContent=new HashMap<>();
        tokenContent.put("uid",user.getId());
        response=userConverter.user2Res(user);
        response.setAccessToken(JwtTokenUtil.generateToken(JSONUtils.toJSONString(tokenContent)));
        response.setCode(ResCodeEnum.SYS_SUCCESS.getCode());
        response.setMsg(ResCodeEnum.SYS_SUCCESS.getMsg());
        return response;
    }
}

4.定义常量表

public class UserServiceConstants {
    //用户状态,1表示可用,0表示不可用
    public static final int USER_STATE_ENABLE=1;
    public static final int USER_STATE_DISABLE=0;
}

5.定义异常的包装

@Slf4j
public class UserServiceExceptionWrapper implements Serializable {

    /**
     * 将下层抛出的异常转换为resp返回码
     *
     * @param e  Exception
     * @param response  AbstractResponse
     * @return
     */
    public static Exception handlerException4biz(AbstractResponse response, Exception e) {
        Exception ex = null;
        if (!(e instanceof Exception) ) {
            return null;
        }
        if (e instanceof BizException) {
            response.setCode(((BizException) e).getErrorCode());
            response.setMsg(((BizException) e).getErrorMessage());
        }else if(e instanceof ValidException){
            response.setCode(((ValidException) e).getErrorCode());
            response.setMsg(((ValidException) e).getErrorMessage());
        } else if (e instanceof Exception) {
            response.setCode(ResCodeEnum.SYSTEM_EXCEPTION.getCode());
            response.setMsg(ResCodeEnum.SYSTEM_EXCEPTION.getMsg());
        }
        log.error("UmsExceptionWrapper.handlerException4biz,Exception="  + e.getMessage(), e);
        return ex;
    }
}

6.定义转换器

@Mapper(componentModel = "spring")
public interface UserConverter {
    @Mappings({})
    LoginResponse user2Res(User user);

}

7.定义Jwt的工具类

@Slf4j
public class JwtTokenUtil {
    private static AESUtil aesUtil=new AESUtil();
    //密钥
    private static final String secret_key="0e509946-7e41-4dee-9ee1-1c300f6e4d35";
    private static final Algorithm ALGORITHM = Algorithm.HMAC256(secret_key);
    private static final String ISSUER="user-service";
    /**
     * 生成token
     * @param content
     * @return
     */
    public static String generateToken(String content){
        if(StringUtils.isEmpty(content)){
            throw new ValidException(ResCodeEnum.REQUEST_CHECK_FAILURE.getCode(),ResCodeEnum.REQUEST_CHECK_FAILURE.getMsg("token请求内容为空"));
        }
        //ISSUER,表示token的颁发者身份标识,它可以用来验证token发行人信息是否一致,一般是一个http网址。
        //在 Token 的验证过程中,会将它作为验证的一个阶段,如无法匹配将会造成验证失败,最后返回 HTTP 401。
        String token= JWT.create().withIssuer(ISSUER)
                //设置token的有效期为1天
                .withExpiresAt(DateTime.now().plusDays(1).toDate())
                .withClaim("user",aesUtil.encrypt(content)) //把内容加密后保存到token中
                .sign(ALGORITHM); //设置签名算法
        return token;
    }
    /**
     * 验证并解析token
     * @param token
     * @return
     */
    public static String verifyAndParseToken(String token){
        if(StringUtils.isEmpty(token)){
            throw new ValidException(ResCodeEnum.REQUEST_CHECK_FAILURE.getCode(),ResCodeEnum.REQUEST_CHECK_FAILURE.getMsg("token不能为空"));
        }
        try {
            //构建JWT验证器
            JWTVerifier verifier = JWT.require(ALGORITHM).withIssuer(ISSUER).build();

            DecodedJWT decodedJWT = verifier.verify(token); //验证token并解析
            log.info("[JwtTokenUtil.verifyAndParseToken()] 签发人:{},加密方式:{}, 携带的主题内容:{}", decodedJWT.getIssuer(), decodedJWT.getAlgorithm(), decodedJWT.getClaim("user").asString());
            return aesUtil.decrypt(decodedJWT.getClaim("user").asString());
        }catch (Exception e){
            log.error("[JwtTokenUtil.verifyAndParseToken()] Occur Exception ", e);
            throw new ValidException(ResCodeEnum.TOKEN_VERIFY_FAILURE.getCode(),ResCodeEnum.TOKEN_VERIFY_FAILURE.getMsg(e.getMessage()));
        }
    }
}
@Slf4j
public class AESUtil {
    //加密或解密内容
  /*  @Setter
    private String content;*/
    //加密密钥
    private String secret="10372b6f-865d-402c-b8f2-eb16a578018c";
    public AESUtil(){}
    public AESUtil(String secret){
        this.secret=secret;
    }
    /**
     * 加密
     * @return 加密后内容
     */
    public String encrypt (String content) {
        Key key = getKey();
        byte[] result = null;
        try{
            //创建密码器
            Cipher cipher = Cipher.getInstance("AES");
            //初始化为加密模式
            cipher.init(Cipher.ENCRYPT_MODE,key);
            //加密
            result = cipher.doFinal(content.getBytes("UTF-8"));
        } catch (Exception e) {
            log.info("aes加密出错:"+e);
        }
        //将二进制转换成16进制
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < result.length; i++) {
            String hex = Integer.toHexString(result[i] & 0xFF);
            if (hex.length() == 1) {
                hex = '0' + hex;
            }
            sb.append(hex.toUpperCase());
        }
        return  sb.toString();
    }
    /**
     * 解密
     * @return 解密后内容
     */
    public String decrypt (String content) {
        //将16进制转为二进制
        if (content.length() < 1)
            return null;
        byte[] result = new byte[content.length()/2];
        for (int i = 0;i< content.length()/2; i++) {
            int high = Integer.parseInt(content.substring(i*2, i*2+1), 16);
            int low = Integer.parseInt(content.substring(i*2+1, i*2+2), 16);
            result[i] = (byte) (high * 16 + low);
        }
        Key key = getKey();
        byte[] decrypt = null;
        try{
            //创建密码器
            Cipher cipher = Cipher.getInstance("AES");
            //初始化为解密模式
            cipher.init(Cipher.DECRYPT_MODE,key);
            //解密
            decrypt = cipher.doFinal(result);
        } catch (Exception e) {
            log.info("aes解密出错:"+e);
        }
        assert decrypt != null;
        return new String(decrypt);
    }
    /**
     * 根据私钥内容获得私钥
     */
    private Key getKey () {
        SecretKey key = null;
        try {
            //创建密钥生成器
            KeyGenerator generator = KeyGenerator.getInstance("AES");
            //初始化密钥
            SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
            random.setSeed(secret.getBytes());
            generator.init(128,random);
            //生成密钥
            key = generator.generateKey();
        } catch (NoSuchAlgorithmException e) {
            log.error("[AESUtil.getKey()], Occur Exception ",e);
            e.printStackTrace();
        }
        return key;
    }
}

8.启动类

@SpringBootApplication
@MapperScan(basePackages = "com.example.userservice.login.mapper")
public class UserServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserServiceApplication.class, args);
    }
}

2.3.6 mall-portal
在mall-portal模块中,增加对外访问的api接口。

1.引入user模块的依赖包

<dependency>
    <groupId>com.example</groupId>
    <artifactId>user-api</artifactId>
</dependency>

2.AuthController

编写登录授权的接口。

@RestController
@RequestMapping("/user/auth")
public class AuthController {
    @Autowired
    IAuthService authServiceFeignClient;
    @PostMapping
    public CommonResponse<LoginResponse> login(@RequestBody Map<String,String> map){
        LoginRequest request=new LoginRequest();
        request.setUserName(map.get("userName"));
        request.setPassword(map.get("userPwd"));
        LoginResponse response=authServiceFeignClient.login(request);
        if(ResCodeEnum.SYS_SUCCESS.getCode().equals(response.getCode())){
            return CommonResponse.success(response);
        }
        return CommonResponse.error(response);
    }
}

3.配置

server.port=8080
spring.application.name=mall-portal
eureka.instance.hostname=localhost
eureka.client.register-with-eureka=true
eureka.client.fetch-registry=true
eureka.client.service-url.defaultZone=http://localhost:8761/eureka
# 允许多个Feign调用相同Service的接口
spring.main.allow-bean-definition-overriding=true

4.登录接口测试

使用postman,请求上述接口,并传递请求参数如下。

{
    "userName":"test",
    "userPwd":"test"
}

2.3.7 mall-gateway
1.添加user服务的模块依赖

<dependency>
    <groupId>com.example</groupId>
    <artifactId>user-api</artifactId>
</dependency>

2.创建一个全局过滤器

@Slf4j
@Component
public class LoginAuthGatewayFilter implements GlobalFilter {
    @Autowired
    IAuthService authServiceFeignClient;
    @Autowired
    IgnoredUrlsProperties ignoredUrlsProperties;
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request=exchange.getRequest();
        String access_token=request.getHeaders().getFirst("access_token");
        //TODO 判断当前的url是否需要被拦截
        if(ignoredUrlsProperties.getUrls().contains(request.getURI().getPath())){
            return chain.filter(exchange);
        }
        if (StringUtils.isEmpty(access_token)) {
            return onError(exchange, "尚未登录");
        }
        ValidTokenResponse response=authServiceFeignClient.validToken(access_token);
        if(ResCodeEnum.SYS_SUCCESS.getCode().equals(response.getCode())){
            ServerHttpRequest nre=request.mutate().header("uid", response.getUid()).build();
            return chain.filter(exchange.mutate().request(nre).build());
        }else{
            return onError(exchange,response.getMsg());
        }
    }
    private Mono<Void> onError(ServerWebExchange exchange,String msg){
        ServerHttpResponse response=exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        response.getHeaders().add("Content-Type","application/json;charset=UTF-8");
        CommonResponse res=CommonResponse.error(HttpStatus.UNAUTHORIZED.value(),msg);
        ObjectMapper objectMapper=new ObjectMapper();
        String resStr= null;
        try {
            resStr = objectMapper.writeValueAsString(res);
        } catch (JsonProcessingException e) {
            log.error("LoginAuthGatewayFilter Occur Exception:" +e);
        }
        DataBuffer buffer=response.bufferFactory().wrap(resStr.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Flux.just(buffer));
    }
}

3.添加路由配置

server:
  port: 80
spring:
  application:
    name: mall-gateway
  cloud:
    gateway:
      routes:
        - id: request_ratelimiter_route
          uri: lb://mall-portal
          predicates:
            - Path=/api/**
          filters:
            - StripPrefix=1
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true
    redis:
      host: 192.168.8.74
      port: 6379
eureka:
  instance:
    hostname: mall-gateway
  client:
    service-url:
      register-with-eureka: true
      fetch-registry: true
      defaultZone: http://127.0.0.1:8761/eureka      
management:
  endpoints:
    web:
      exposure:
        include: "*"

4.修改main,增加开启Feign注解

@SpringBootApplication
@EnableFeignClients(basePackages = "com.example.service")
public class MallGatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(MallGatewayApplication.class, args);
    }

}

5.注意,所有api中的依赖,尽量用optional选项,避免传递依赖导致问题。

6.增加不需要拦截的uri

在application.yaml文件中,增加url配置项

ignored:
  urls:
    - /user/auth

增加配置类

@Data
@Configuration
@ConfigurationProperties(prefix = "ignored")
public class IgnoredUrlsProperties {
    private List<String> urls=new ArrayList<>();
}

7.gateway中集成feign,会报错,因为feign响应数据需要解析,缺少依赖对象,增加下面的代码即可。

@Configuration
public class OpenFeignConfig {
    @Bean
    public Decoder feignDecoder() {
        return new ResponseEntityDecoder(new SpringDecoder(feignHttpMessageConverter()));
    }
    public ObjectFactory<HttpMessageConverters> feignHttpMessageConverter() {
        final HttpMessageConverters httpMessageConverters = new HttpMessageConverters(new MappingJackson2HttpMessageConverter());
        return () -> httpMessageConverters;
    }
}

8.访问测试

首先调用登录接口,获取token
接着访问home接口,此时提示未登录
把token复制到home接口的header中,在此访问即可
2.4 Token登出
由于Access_Token是一种完全无状态的验证方式,所以如果想要实现用户登出功能,或者被挤下线。那么JWT是无法实现的。

如果一定要实现这个功能,只能让JWT具备状态,也就是把JWT生成的token保存一份在服务端的redis或者db中。

当在其他地方触发登录时,修改redis中该用户token的版本号。 在filter中比较版本号的变化,来判断登录状态

分类
已于2022-7-25 17:31:12修改
1
收藏
回复
举报
1条回复
按时间正序
/
按时间倒序
呃呃呃呃呃额额
呃呃呃呃呃额额

文章都不错 就是没有附上源码链接

回复
2023-2-7 17:35:03
回复
    相关推荐