• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

JWT双令牌双token实现登录验证

武飞扬头像
yygs!
帮助1

目录

一、引入依赖

二、在service层下创建utils工具类包

三、颁发令牌

1. UserController类的login方法

2. UserService类中的login方法(刷新令牌)

3. UserService类中的loginForDts方法(双令牌)

4. 刷新accessToken

5. 删除refreshToken

6. 登录进行业务需验证短期令牌

四、总结


双令牌(Token)的原因:

如果用户在退出登录之后,token仍然处在有效期之内的话,那么用户仍然可以使用这个token去访问系统的资源,那这样的话,显然它不是一个正确的操作。所以退出登录的时候必须清除token。

用户退出登录之后,按理说他就不应该再访问到系统的任何跟用户相关的这种资源了。所以说为了防止这种情况呢,我们引入一个双token的机制,同时这种机制还可以用于对用户进行一个无感的这种有效时间的刷新,什么意思呢?比如说咱们这种单token的情况。可接入的,那么这个token呢它是一个有效期的一般会设置在一到两个小时,或者2~3个小时之内。那么当这个token快要过期的时候,如果没有我们所说的这个刷新token的话,那么这时候系统呢会直接提示用户,您已经您的投票已经过期,请重新登录。那这样的话,其实对用户造成一个不是很好的用户体验,那可能我正在看视频,或者说正在刷一些这种最新的资讯。系统突然跟我说,哎呀,你已经退出登录了,请重新登录,这样的话是不是会让我们很反感?

所以说为了应对这种情况呢,我们引入了这个refresh token,那么这个工作的原理是什么呢?其实也非常简单,就是在我们用户登录成功之后啊,我们除了access token也就是这个接入token啊。或者说资源访问的这个之外,我们加一个这个刷新token,那么这个刷新token它的有效时长一般会比我们的接入token的时间长很多。就比如说刚才说到了这个接入token有效时长一般是两到三个小时,但是这个刷新token可以一般是七天,也就是一周或者说是两周左右。这样一个有效的时长,那么只要我们的这个refresh token,也就是刷新token。它一直有效,那么我就可以在这个时间段之内用refresh token对我们用户的access token进行一个刷新,也就是延长我们这个token的有效期。虽然用户退出的时候短期token不一定被清除,但是相对于频繁弹出token过期的消息,无感延长短期token的做法是更好的。

一、引入依赖

  1.  
    <!--JWT-->
  2.  
    <dependency>
  3.  
    <groupId>com.auth0</groupId>
  4.  
    <artifactId>java-jwt</artifactId>
  5.  
    <version>3.19.0</version>
  6.  
    </dependency>

二、在service层下创建utils工具类包

1. 创建MD5Util类

  1.  
    import org.apache.commons.codec.digest.DigestUtils;
  2.  
     
  3.  
    import java.io.UnsupportedEncodingException;
  4.  
     
  5.  
    /**
  6.  
    * MD5加密
  7.  
    * 单向加密算法
  8.  
    * 特点:加密速度快,不需要秘钥,但是安全性不高,需要搭配随机盐值使用
  9.  
    *
  10.  
    */
  11.  
    public class MD5Util {
  12.  
     
  13.  
    // 加密
  14.  
    public static String sign(String content, String salt, String charset) {
  15.  
    content = content salt;
  16.  
    return DigestUtils.md5Hex(getContentBytes(content, charset));
  17.  
    }
  18.  
     
  19.  
    public static boolean verify(String content, String sign, String salt, String charset) {
  20.  
    content = content salt;
  21.  
    String mysign = DigestUtils.md5Hex(getContentBytes(content, charset));
  22.  
    return mysign.equals(sign);
  23.  
    }
  24.  
     
  25.  
    private static byte[] getContentBytes(String content, String charset) {
  26.  
    if (!"".equals(charset)) {
  27.  
    try {
  28.  
    return content.getBytes(charset);
  29.  
    } catch (UnsupportedEncodingException var3) {
  30.  
    throw new RuntimeException("MD5签名过程中出现错误,指定的编码集错误");
  31.  
    }
  32.  
    } else {
  33.  
    return content.getBytes();
  34.  
    }
  35.  
    }
  36.  
    }
学新通

2.创建RSAUtil类

  1.  
    import org.apache.commons.codec.binary.Base64;
  2.  
     
  3.  
    import javax.crypto.Cipher;
  4.  
    import java.nio.charset.StandardCharsets;
  5.  
    import java.security.*;
  6.  
    import java.security.interfaces.RSAPrivateKey;
  7.  
    import java.security.interfaces.RSAPublicKey;
  8.  
    import java.security.spec.PKCS8EncodedKeySpec;
  9.  
    import java.security.spec.X509EncodedKeySpec;
  10.  
     
  11.  
    /**
  12.  
    * RSA加密
  13.  
    * 非对称加密,有公钥和私钥之分,公钥用于数据加密,私钥用于数据解密。加密结果可逆
  14.  
    * 公钥一般提供给外部进行使用,私钥需要放置在服务器端保证安全性。
  15.  
    * 特点:加密安全性很高,但是加密速度较慢
  16.  
    *
  17.  
    */
  18.  
    public class RSAUtil {
  19.  
     
  20.  
    private static final String PUBLIC_KEY = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCQk33iNdA8Iey7J6XrBsidqn6u8EDLWPHsfEUgLQ3qiTikhPKDTzZkpAfU/O0x6NvSKa7Dp0 uqWT3vnW1De0 3u8mCYdVfOdH94VG4xg5U5UrRJei8HhPiXuvKQ 6NBtebCCW5adZ4pBgOiU14cJLhVmm dYiLo3IDD5LqrlomQIDAQAB";
  21.  
     
  22.  
    private static final String PRIVATE_KEY = "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAJCTfeI10Dwh7LsnpesGyJ2qfq7wQMtY8ex8RSAtDeqJOKSE8oNPNmSkB9T87THo29IprsOnT66pZPe dbUN7T7e7yYJh1V850f3hUbjGDlTlStEl6LweE Je68pD7o0G15sIJblp1nikGA6JTXhwkuFWab51iIujcgMPkuquWiZAgMBAAECgYA1UT9mciQWWQh9yNRmhXzssFjB2TZ8B5RIe1fe0t7D9NEf0yvAgzDzEo8U3CX5dv/CVL7vxr8bEbt7phCwsa8hJiLEOr7hLZaJzXVTbvfqb91oCZGNkqDQ3NJfGBMVgUmltEYF2Bbk3U0NDyat Gu54tRd2OH adJYKsD0XYeDBQJBAN5FE8E04A4FA1q8mQbVTSVJDYIEJwOrdC0r3iZ7za5CyXGk br8pFalRePFaksRGdN32 mYhDKVNrNHspAObVMCQQCmhBsD xiWrmpnrzeIfCW1cX8qRC3/RMkq0ACw3l6YedNFdN2Tb5WsRHmcbCI9y8mfLHiG/X1R zHZKG67EKjjAkAmvAkGSY2mQ89i160fWLq5/bIh71FRPWbgnF15fWfJr4/lgyeWI4MMKn80g2nTrSZACQpE jRHkGNY OywWCNLAkEAli5nvztkfeJpDYK2b16pE/B9ZL2BTs3XMcnQFbU5VAPsTKSOgz8MmwZXOIE kMWP3wPY4McXlC0eVGFnHUh1SQJAeAl3RPk XbZDMYfPkStRJwocG9Ap 88mwTgR1I7uPzZ1aM84/WsQskiVMXv2SZLmMWvYtnhIKosL6IACp2AcDA==";
  23.  
     
  24.  
    public static void main(String[] args) throws Exception{
  25.  
    String str = RSAUtil.encrypt("123456");
  26.  
    System.out.println(str);
  27.  
    }
  28.  
     
  29.  
    public static String getPublicKeyStr(){
  30.  
    return PUBLIC_KEY;
  31.  
    }
  32.  
     
  33.  
    public static RSAPublicKey getPublicKey() throws Exception {
  34.  
    byte[] decoded = Base64.decodeBase64(PUBLIC_KEY);
  35.  
    return (RSAPublicKey) KeyFactory.getInstance("RSA")
  36.  
    .generatePublic(new X509EncodedKeySpec(decoded));
  37.  
    }
  38.  
     
  39.  
    public static RSAPrivateKey getPrivateKey() throws Exception {
  40.  
    byte[] decoded = Base64.decodeBase64(PRIVATE_KEY);
  41.  
    return (RSAPrivateKey) KeyFactory.getInstance("RSA")
  42.  
    .generatePrivate(new PKCS8EncodedKeySpec(decoded));
  43.  
    }
  44.  
     
  45.  
    // 生成公钥和私钥
  46.  
    public static RSAKey generateKeyPair() throws NoSuchAlgorithmException {
  47.  
    KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
  48.  
    keyPairGen.initialize(1024, new SecureRandom());
  49.  
    KeyPair keyPair = keyPairGen.generateKeyPair();
  50.  
    RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
  51.  
    RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
  52.  
    String publicKeyString = new String(Base64.encodeBase64(publicKey.getEncoded()));
  53.  
    String privateKeyString = new String(Base64.encodeBase64(privateKey.getEncoded()));
  54.  
    return new RSAKey(privateKey, privateKeyString, publicKey, publicKeyString);
  55.  
    }
  56.  
     
  57.  
    public static String encrypt(String source) throws Exception {
  58.  
    byte[] decoded = Base64.decodeBase64(PUBLIC_KEY);
  59.  
    RSAPublicKey rsaPublicKey = (RSAPublicKey) KeyFactory.getInstance("RSA")
  60.  
    .generatePublic(new X509EncodedKeySpec(decoded));
  61.  
    Cipher cipher = Cipher.getInstance("RSA");
  62.  
    cipher.init(1, rsaPublicKey);
  63.  
    return Base64.encodeBase64String(cipher.doFinal(source.getBytes(StandardCharsets.UTF_8)));
  64.  
    }
  65.  
     
  66.  
    public static Cipher getCipher() throws Exception {
  67.  
    byte[] decoded = Base64.decodeBase64(PRIVATE_KEY);
  68.  
    RSAPrivateKey rsaPrivateKey = (RSAPrivateKey) KeyFactory.getInstance("RSA")
  69.  
    .generatePrivate(new PKCS8EncodedKeySpec(decoded));
  70.  
    Cipher cipher = Cipher.getInstance("RSA");
  71.  
    cipher.init(2, rsaPrivateKey);
  72.  
    return cipher;
  73.  
    }
  74.  
     
  75.  
    public static String decrypt(String text) throws Exception {
  76.  
    Cipher cipher = getCipher();
  77.  
    byte[] inputByte = Base64.decodeBase64(text.getBytes(StandardCharsets.UTF_8));
  78.  
    return new String(cipher.doFinal(inputByte));
  79.  
    }
  80.  
     
  81.  
    public static class RSAKey {
  82.  
    private RSAPrivateKey privateKey;
  83.  
    private String privateKeyString;
  84.  
    private RSAPublicKey publicKey;
  85.  
    public String publicKeyString;
  86.  
     
  87.  
    public RSAKey(RSAPrivateKey privateKey, String privateKeyString, RSAPublicKey publicKey, String publicKeyString) {
  88.  
    this.privateKey = privateKey;
  89.  
    this.privateKeyString = privateKeyString;
  90.  
    this.publicKey = publicKey;
  91.  
    this.publicKeyString = publicKeyString;
  92.  
    }
  93.  
     
  94.  
    public RSAPrivateKey getPrivateKey() {
  95.  
    return this.privateKey;
  96.  
    }
  97.  
     
  98.  
    public void setPrivateKey(RSAPrivateKey privateKey) {
  99.  
    this.privateKey = privateKey;
  100.  
    }
  101.  
     
  102.  
    public String getPrivateKeyString() {
  103.  
    return this.privateKeyString;
  104.  
    }
  105.  
     
  106.  
    public void setPrivateKeyString(String privateKeyString) {
  107.  
    this.privateKeyString = privateKeyString;
  108.  
    }
  109.  
     
  110.  
    public RSAPublicKey getPublicKey() {
  111.  
    return this.publicKey;
  112.  
    }
  113.  
     
  114.  
    public void setPublicKey(RSAPublicKey publicKey) {
  115.  
    this.publicKey = publicKey;
  116.  
    }
  117.  
     
  118.  
    public String getPublicKeyString() {
  119.  
    return this.publicKeyString;
  120.  
    }
  121.  
     
  122.  
    public void setPublicKeyString(String publicKeyString) {
  123.  
    this.publicKeyString = publicKeyString;
  124.  
    }
  125.  
    }
  126.  
    }
学新通

3.创建TokenUtil类

  1.  
    import com.auth0.jwt.JWT;
  2.  
    import com.auth0.jwt.JWTVerifier;
  3.  
    import com.auth0.jwt.algorithms.Algorithm;
  4.  
    import com.auth0.jwt.exceptions.TokenExpiredException;
  5.  
    import com.auth0.jwt.interfaces.DecodedJWT;
  6.  
    import com.yygs.bilibili.domain.constant.CodeConstant;
  7.  
    import com.yygs.bilibili.domain.constant.TokenConstant;
  8.  
    import com.yygs.bilibili.exception.ConditionException;
  9.  
     
  10.  
    import java.util.Calendar;
  11.  
    import java.util.Date;
  12.  
     
  13.  
    public class TokenUtil {
  14.  
     
  15.  
    public static String generateToken(Long userId) throws Exception {
  16.  
    Algorithm algorithm = Algorithm.RSA256(RSAUtil.getPublicKey(), RSAUtil.getPrivateKey());
  17.  
    Calendar calendar = Calendar.getInstance();
  18.  
    calendar.setTime(new Date());
  19.  
     
  20.  
    //第二个参数为自定义的常量Constant,token过期时间,为60*60=1小时
  21.  
    calendar.add(Calendar.SECOND, TokenConstant.EXPIRE_TIME_60_60);
  22.  
     
  23.  
    // 生成含userId的token,以后登录都只通过Header携带的token解析出userId进行业务
  24.  
    return JWT.create().withKeyId(String.valueOf(userId))
  25.  
    .withIssuer(TokenConstant.ISSUER)
  26.  
    .withExpiresAt(calendar.getTime())
  27.  
    .sign(algorithm);
  28.  
    }
  29.  
     
  30.  
    public static Long verifyToken(String token) {
  31.  
    Algorithm algorithm = null;
  32.  
    try {
  33.  
    algorithm = Algorithm.RSA256(RSAUtil.getPublicKey(), RSAUtil.getPrivateKey());
  34.  
    JWTVerifier verifier = JWT.require(algorithm).build();
  35.  
    DecodedJWT jwt = verifier.verify(token);
  36.  
    String userId = jwt.getKeyId();
  37.  
    return Long.valueOf(userId);
  38.  
    } catch (TokenExpiredException e) {
  39.  
    throw new ConditionException(CodeConstant.TOKEN_OVERDUE,"token已过期!");
  40.  
    } catch (Exception e) {
  41.  
    throw new ConditionException("非法用户token!");
  42.  
    }
  43.  
    }
  44.  
     
  45.  
    public static String generateRefreshToken(Long userId) throws Exception {
  46.  
    Algorithm algorithm = Algorithm.RSA256(RSAUtil.getPublicKey(),RSAUtil.getPrivateKey());
  47.  
    Calendar calendar = Calendar.getInstance();
  48.  
    calendar.setTime(new Date());
  49.  
     
  50.  
    // refreshToken的过期时间持续比较长,为60*60*24*7=7天
  51.  
    calendar.add(Calendar.SECOND, TokenConstant.EXPIRE_TIME_60_60_24_7);
  52.  
    return JWT.create().withKeyId(String.valueOf(userId))
  53.  
    .withIssuer(TokenConstant.ISSUER)
  54.  
    .withExpiresAt(calendar.getTime())
  55.  
    .sign(algorithm);
  56.  
    }
  57.  
    }
学新通

三、颁发令牌

1. UserController类的login方法

在login中,登陆成功则给用户发单个token

  1.  
    @PostMapping("/user-tokens")
  2.  
    public JsonResponse<String> login(@RequestBody User user) throws Exception {
  3.  
    String token = userService.login(user);
  4.  
    return new JsonResponse<>(token);
  5.  
    }

2. UserService类中的login方法(刷新令牌)

  1.  
    // 加密算法的盐值,新增用户的时候和解析密码的时候用到
  2.  
    String salt = String.valueOf(now.getTime());
  1.  
    public String login(User user) throws Exception {
  2.  
     
  3.  
    ..
  4.  
    登录需要的条件判断
  5.  
    ..
  6.  
     
  7.  
    String password = user.getPassword();
  8.  
    String rawPassword;
  9.  
    try {
  10.  
    rawPassword = RSAUtil.decrypt(password);
  11.  
    } catch (Exception e) {
  12.  
    throw new ConditionException("密码解析失败!");
  13.  
    }
  14.  
    String salt = dbUser.getSalt();
  15.  
    String md5Password = MD5Util.sign(rawPassword, salt, "UTF-8");
  16.  
    if (!md5Password.equals(dbUser.getPassword())) {
  17.  
    throw new ConditionException("密码错误!");
  18.  
    }
  19.  
     
  20.  
    //验证通过所有条件,则颁发携带userId的token给前端
  21.  
    String Token = TokenUtil.generateToken(dbUser.getId());
  22.  
    return accessToken;
  23.  
    }
学新通

3. UserService类中的loginForDts方法(双令牌)

用这个方法生成时间一长一短的两个令牌返回给前端,同时将长时间的token存到数据库中。短的令牌accessToken过期的时候,前端可以再次通过长时间的令牌refreshToken访问后端来刷新短时间的令牌。

  1.  
    public Map<String, Object> loginForDts(User user) throws Exception {
  2.  
     
  3.  
    ..
  4.  
    登录的条件验证
  5.  
    ..
  6.  
     
  7.  
    String password = user.getPassword();
  8.  
    String rawPassword;
  9.  
    try {
  10.  
    rawPassword = RSAUtil.decrypt(password);
  11.  
    } catch (Exception e) {
  12.  
    throw new ConditionException("密码解析失败!");
  13.  
    }
  14.  
    String salt = dbUser.getSalt();
  15.  
    String md5Password = MD5Util.sign(rawPassword, salt, "UTF-8");
  16.  
    if (!md5Password.equals(dbUser.getPassword())) {
  17.  
    throw new ConditionException("密码错误!");
  18.  
    }
  19.  
    Long userId = dbUser.getId();
  20.  
     
  21.  
    // 生成双Token,access和refresh
  22.  
    String accessToken = TokenUtil.generateToken(userId);
  23.  
    String refreshToken = TokenUtil.generateRefreshToken(userId);
  24.  
     
  25.  
    // 保存refresh token到数据库
  26.  
    userDao.deleteRefreshToken(refreshToken, userId);
  27.  
    userDao.addRefreshToken(refreshToken, userId, new Date());
  28.  
    Map<String, Object> result =new HashMap<>();
  29.  
     
  30.  
    // 将双token返回给前端
  31.  
    result.put("accessToken", accessToken);
  32.  
    result.put("refreshToken", refreshToken);
  33.  
    return result;
  34.  
    }
学新通

4. 刷新accessToken

当短时间的token过期,前端需要通过长时间的token来访问后端,并且生成一个短时间的token返回给前端,也就是刷新短时间的token。

在UserController类创建刷新token的方法

  1.  
    @PostMapping("/access-tokens")
  2.  
    public JsonResponse<String> refreshAccessToken(HttpServletRequest request) throws Exception {
  3.  
    String refreshToken = request.getHeader("refreshToken");
  4.  
    String accessToken = userService.refreshAccessToken(refreshToken);
  5.  
    return new JsonResponse<>(accessToken);
  6.  
    }

UserService类中实现刷新方法

同时也可以在刷新短期令牌的时候对refreshToken进行一个刷新,此时需要给前台发短期令牌的同时也发一个长期令牌,而且此时也必须要对数据库的长期令牌进行更新!!!

  1.  
    public String refreshAccessToken(String refreshToken) throws Exception {
  2.  
     
  3.  
    // 从数据库中查询refreshToken
  4.  
    RefreshTokenDetail refreshTokenDetail = userDao.getRefreshAccessToken(refreshToken);
  5.  
    if (refreshTokenDetail == null) {
  6.  
    throw new ConditionException(CodeConstant.TOKEN_OVERDUE,"token已过期!");
  7.  
    }
  8.  
     
  9.  
    // 没过期则生成一个新的token返回给前端
  10.  
    Long userId = refreshTokenDetail.getUserId();
  11.  
    String accessToken = TokenUtil.generateToken(userId);
  12.  
     
  13.  
    /**
  14.  
    *同时也可以在刷新短期令牌的时候对refreshToken进行一个刷新
  15.  
    *此时需要给前台发短期令牌的同时也发一个长期令牌
  16.  
    *而且此时必须要对数据库的长期令牌进行更新!!!
  17.  
    */
  18.  
     
  19.  
    return accessToken;
  20.  
    }
学新通

5. 删除refreshToken

当用户退出的时候我们需要删除数据库中的refreshToken。

UserController类中的logout方法

  1.  
    @DeleteMapping("/refresh-tokens")
  2.  
    public JsonResponse<String> logout(HttpServletRequest request) {
  3.  
    String refreshToken = request.getHeader("refreshToken");
  4.  
    Long userId = userSupport.getCurrentUserId();
  5.  
    userService.logout(refreshToken, userId);
  6.  
    return JsonResponse.success();
  7.  
    }

UserService类实现删除数据库中的refreshToken操作

  1.  
    public void logout(String refreshToken, Long userId) {
  2.  
    userDao.deleteRefreshToken(refreshToken, userId);
  3.  
    }

6. 登录进行业务需验证短期令牌

除了刷新短期令牌需要用到refreshToken之外,其他的业务访问都先需要通过下面的UserSupport的getCurrentUserId()方法来通过短期令牌来获取userId,再进行业务操作。

  1.  
    import com.yygs.bilibili.exception.ConditionException;
  2.  
    import com.yygs.bilibili.service.utils.TokenUtil;
  3.  
    import org.springframework.stereotype.Component;
  4.  
    import org.springframework.web.context.request.RequestContextHolder;
  5.  
    import org.springframework.web.context.request.ServletRequestAttributes;
  6.  
     
  7.  
    @Component
  8.  
    public class UserSupport {
  9.  
     
  10.  
    public Long getCurrentUserId() {
  11.  
    ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  12.  
    String token = requestAttributes.getRequest().getHeader("accessToken");
  13.  
    Long userId = TokenUtil.verifyToken(token);
  14.  
    if (userId < 0) {
  15.  
    throw new ConditionException("非法用户!");
  16.  
    }
  17.  
    return userId;
  18.  
    }
  19.  
    }
学新通

四、总结

以上的双令牌验证也有跟网上的有些许差别,有的是所有的访问都同时携带双令牌,如果是短期令牌过期,又再去验证长期令牌,那么短期令牌就没有存在的意义。

也有些是说,每次刷新的时候,长期令牌也刷新,这种看个人的应用场景,这种场景只需要在刷新短期令牌的时候对给前端也返回一个新生成的长期令牌,而且必须更新数据库中的令牌!

再说回来以上的个人理解的双令牌可能存在一些小问题,如果是颁发长短期双令牌,而到了短期令牌过期的时候,前端需要重新去后端刷新短期令牌,而这个时候用户刚好进行业务操作需要访问后端,如果此时后端还没颁发短期令牌给前端,那么用户的这次访问就没有携带短期令牌,前端就会弹出token已过期!的消息,如果用户基数大的话,那么出现这种情况的概率也会变大,那么双令牌的作用也就消失了,用户体验也会有可能变差,但是这也远比单令牌的场景体验要好的多。

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhgkabfi
系列文章
更多 icon
同类精品
更多 icon
继续加载