JWT、自定义注解实现接口访问权限控制及SpringCloud Feign分布式服务调用信息传递

一、简述

通过JWT、自定义注解实现后台接口访问权限的控制:
1.通过PC端页面将用户(员工)、角色、菜单页面、按钮与权限标识绑定;
2.登录认证过程将登录人信息、权限标识与token绑定并存入redis中;
3.对接口进行自定义注解标识;
4.每次请求通过拦截器中进行权限校验(访问用户自身绑定的权限标识与后台接口的注解标识比对)

二、流程图

接口访问权限控制流程

三、用户访问接口具体逻辑时序图

用户访问接口具体逻辑时序图

四、简要代码

1.gateway-service网关服务(AuthFilter过滤器)
/**
 * 请求认证过滤
 */
@Component
@Slf4j
public class AuthFilter implements GlobalFilter, Ordered {

    /**
     * 基于LRU暂存token对应的用户信息和权限
     */
    public static final ConcurrentLinkedHashMap<String, TokenValueInMap> cacheMap =
            new ConcurrentLinkedHashMap.Builder<String, TokenValueInMap>()
                    .maximumWeightedCapacity(20000)
                    .weigher(Weighers.singleton())
                    .build();

    /**
     * 基于记录无效token,防止无效token多次,以提升系统性能
     */
    private static final ConcurrentLinkedHashMap<String, Long> cacheInValidToken =
            new ConcurrentLinkedHashMap.Builder<String, Long>()
                    .maximumWeightedCapacity(2000)
                    .weigher(Weighers.singleton())
                    .build();

    @Autowired
    RedisService<HashMap, String> redisService;

    @Autowired
    RedisProperties redisProperties;

    private static ThreadPoolExecutor threadPoolExecutorForSendToRedis;

    static {
        threadPoolExecutorForSendToRedis = new ThreadPoolExecutor(
                //线程池维护线程的最少数量
                100,
                //线程池维护线程的最大数量
                10000,
                //线程池维护线程所允许的空闲时间
                120, TimeUnit.SECONDS,
                //线程池所使用的缓冲队列
                new ArrayBlockingQueue<Runnable>(500),
                //加入失败,则在调用的主线程上执行
                new ThreadPoolExecutor.CallerRunsPolicy());
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        HttpResult httpResult = HttpStatus.Unauthorized;

        //备注:针对白名单请求,如果具有有效的token,则将有效的token(即:自身能够正常解析 + redis中未失效)也一并解析
        try {
            //从请求头部获取token
            String token = getTokenFromRequest(request);

            //是否是匿名请求,即:请求路径中是否含有unAuth
            boolean isHasUnAuth = false;

            //对于未携带token的请求,需要判断是否能够直接放行,通过PassConfig中的白名单进行控制
            AntPathMatcher antPathMatcher = new AntPathMatcher();
            long count = PassConfig.WITELIST.stream().filter(pattern -> antPathMatcher.match(pattern, request.getPath().value())).count();
            if (count > 0) {
                isHasUnAuth = true;
                if (StringUtils.isBlank(token)) {
                    //放行前校验请求中的签名
                    this.preChainFilterProcess(request, "");
                    //放行 未携带token的unAuth请求
                    return chain.filter(exchange);
                }
            }

            //访问需要授权的接口时未携带token
            if (!isHasUnAuth && StringUtils.isBlank(token)) {
                throw new Exception("请先登录系统");
            }

            String authorities = null;
            Map<String, Object> userMap = null;

            //获取token对应的用户信息
            //  tokenValueInMap为null,表示token无效,需要重新登录
            TokenValueInMap tokenValueInMap = getTokenValue(token);
            if (null != tokenValueInMap) {
                authorities = tokenValueInMap.tokenValueInRedis.getAuthority();
                userMap = tokenValueInMap.tokenValueInRedis.getClaims();
            }

            //非白名单内的请求,且携带自身解析有效的token,但是token在redis中不存在,直接抛出异常,需要用户重新登录
            if (!isHasUnAuth && null == tokenValueInMap) {
                throw new Exception("登录已超时, 请重新登录");
            }
            //白名单内的请求,且携带自身解析有效的token,但是token在redis中不存在,可以放行
            if (isHasUnAuth && null == tokenValueInMap) {
                this.preChainFilterProcess(request, "");
                return chain.filter(exchange);
            }

            //token自动续期
            final Map<String, Object> userMapTmp = userMap;
            threadPoolExecutorForSendToRedis.execute(() -> {
                this.resetTokenExpireTime(token, userMapTmp);
            });

            ServerHttpRequest httpRequest = request.mutate()
                    .header(User.CONTEXT_USER_ID, userMap.get(JwtTokenUtils.USERID).toString())
                    .header(User.CONTEXT_USER_NAME, userMap.get(JwtTokenUtils.USERNAME).toString())
                    .header(User.CONTEXT_USER_LOGIN_TYPE, userMap.get(JwtTokenUtils.USERLOGINTYPE).toString())
                    .header(User.CONTEXT_CLIENT_ID, userMap.get(JwtTokenUtils.CLIENTID).toString())

                    .header(Company.CONTEXT_COMPANY_ID, null == userMap.get(JwtTokenUtils.COMPANYID) ? ""
                            : userMap.get(JwtTokenUtils.COMPANYID).toString())
                    .header(Company.CONTEXT_COMPANY_NAME, null == userMap.get(JwtTokenUtils.COMPANYNAME) ? ""
                            : URLEncoder.encode(userMap.get(JwtTokenUtils.COMPANYNAME).toString(), "UTF-8"))

                    .header(Dept.CONTEXT_DEPT_ID, null == userMap.get(JwtTokenUtils.DEPTID) ? ""
                            : userMap.get(JwtTokenUtils.DEPTID).toString())
                    .header(Dept.CONTEXT_DEPT_NAME, null == userMap.get(JwtTokenUtils.DEPTNAME) ? ""
                            : URLEncoder.encode(userMap.get(JwtTokenUtils.DEPTNAME).toString(), "UTF-8"))

                    .header(User.CONTEXT_USER_AUTHORITIES, authorities)

                    .header(User.CONTEXT_INVITATION_CODE, null == userMap.get(JwtTokenUtils.INVITATIONCODE) ? ""
                            : userMap.get(JwtTokenUtils.INVITATIONCODE).toString()) //邀请码
                    .header(User.CONTEXT_USER_TOKEN, token).build();

            this.preChainFilterProcess(request, userMap.get(JwtTokenUtils.USERLOGINTYPE).toString());

            return chain.filter(exchange.mutate().request(httpRequest).build());
        } catch (BaseException baseException) {
            if (!BaseException.DEFAULT_CODE.equals(baseException.getCode())) {
                httpResult.setStatus(baseException.getCode());
            }
            return processException(httpResult, baseException, exchange);
        } catch (Exception ex) {
            return processException(httpResult, ex, exchange);
        }
    }

    /**
     * 认证时的异常处理
     *
     * @param httpResult
     * @param ex
     * @param response
     * @return
     */
    private Mono<Void> processException(HttpResult httpResult, Exception ex, ServerWebExchange exchange) {
        ServerHttpResponse response = exchange.getResponse();
        httpResult.setMessage(ex.getMessage());
        String result = JSONObject.toJSON(httpResult).toString();
        response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
        response.setStatusCode(org.springframework.http.HttpStatus.OK);
        return exchange.getResponse()
                .writeWith(Flux.just(exchange.getResponse().bufferFactory().wrap(result.getBytes())));
    }

    /**
     * 过滤器执行的优先级,值越小优先级越高
     *
     * @return
     */
    @Override
    public int getOrder() {
        return -5;
    }

    /**
     * 从请求总获取token
     *
     * @param request
     * @return
     */
    private String getTokenFromRequest(ServerHttpRequest request) {
        String token = request.getHeaders().getFirst(JwtTokenUtils.HEADER_AUTH);

        //由于websocket比较特殊,涉及http upgrade的过程
        //无法在header中携带token,只能在请求路径中携带token
        //如果请求以"/websocket-server/chat/"开头,就认为是websocket类型的请求,token放在请求的最后,通过"/"截取,以获取token
        if (request.getURI().getPath().startsWith("/websocket-server/chat/")) {
            token = request.getURI().getPath().substring(request.getURI().getPath().lastIndexOf("/") + 1);
        }
        return token;
    }

    /**
     * 自动续期
     * 重新设置token的过期时间
     *
     * @param token
     * @param userMap
     * @return
     */
    private void resetTokenExpireTime(String token, Map<String, Object> userMap) {
        String userId = userMap.get(JwtTokenUtils.USERID).toString();
        String userLoginType = userMap.get(JwtTokenUtils.USERLOGINTYPE).toString();

        //为了系统安全,通过员工超级密码生成的token不会自动续期,默认12小时后过期
        if (UserLoginTypeConstants.EmployeeLoginPCBySuperPassword.equals(userLoginType)) {
            return;
        }

        if (LoginDeviceTypeConstants.APP.equals(LoginRelatedHelper.getLoginDeviceType(userLoginType))) {
            //登录方式为app,自动续期12天
            boolean isOK = redisService.expire(token, redisProperties.getAppTokenExpireMilliSeconds());
            if (isOK) { //从性能上考虑,只有上一步执行成功,才会执行下一步
                redisService.expire(userLoginType + userId, redisProperties.getAppTokenExpireMilliSeconds());
            }
        } else if (LoginDeviceTypeConstants.PC.equals(LoginRelatedHelper.getLoginDeviceType(userLoginType))) {
            //登录方式为pc或第三方模拟登录,自动续期8小时ø
            boolean isOK = redisService.expire(token, redisProperties.getPcTokenExpireMilliSeconds());
            if (isOK) { //从性能上考虑,只有上一步执行成功,才会执行下一步
                redisService.expire(userLoginType + userId, redisProperties.getPcTokenExpireMilliSeconds());
            }
        } else {
            //针对虚拟用户的调用,由于token是不失效的,所以不用续期
        }
    }

    /**
     * 执行chain.filter之前进行的预处理
     *
     * @param request
     * @param userLoginType
     */
    private void preChainFilterProcess(ServerHttpRequest request, String userLoginType) {
        this.veritySign(request, userLoginType);
    }

    /**
     * 校验请求中的签名
     *
     * @param request
     * @param userLoginType
     */
    private void veritySign(ServerHttpRequest request, String userLoginType) {
        userLoginType = null == userLoginType ? "" : userLoginType;

        //暂时认定通过第三方系统 虚拟用户登录 的请求中必须要携带sign和signTimestamp
        //【待开发】等前端改造完后再开放对所有用户请求的签名校验
        if (userLoginType.startsWith(UserTypeConstants.VirtualUser)) {

            String sign = request.getQueryParams().getFirst("sign");
            String signTimestamp = request.getQueryParams().getFirst("signTimestamp");

            if (StringUtils.isBlank(sign)) {
                throw new BaseException(HttpStatus.SignError.getStatus(), "请求中的签名 sign 不能为空");
            }

            if (StringUtils.isBlank(signTimestamp)) {
                throw new BaseException(HttpStatus.SignError.getStatus(), "请求中签名的时间戳 signTimestamp 不能为空");
            }
        }
    }

    /**
     * 先从缓存中获取token对应的用户和权限,如果存在 且 没有过期,则直接返回,否则需要到redis中去获取,再写入map
     * 由于这里对token进行了缓存,所以在分布式场景中,客户退出登录后,token还在缓存中,这样就无法做到立即实现单点控制,但是考虑到服务器的tps,目前暂时采取这种折中的方式
     *
     * @param token
     * @return
     */
    private TokenValueInMap getTokenValue(String token) {
        // 先从缓存中获取token对应的权限,如果存在 且 没有过期,则直接返回,否则需要到redis中去获取,再写入map
        TokenValueInMap authorityInMapValue = cacheMap.get(token);
        // 60*60*8 * 1000 = 28800 * 1000 = 28800000 (map中的值8小时后过期)
        if (null == authorityInMapValue || (authorityInMapValue.timeMillis + 28800000) < System.currentTimeMillis()) {

            //如果判定是无效token,则直接返回
            if (cacheInValidToken.containsKey(token)) {
                Long timeMillis = cacheInValidToken.get(token);
                // 60*60*24 * 1000 = 86400 * 1000 = 86400000 (24小时)
                if (timeMillis + 86400000 > System.currentTimeMillis()) {
                    return null;
                }
            }

            String tokenValueInRedis = redisService.getValue(token);
            if (StringUtils.isNotBlank(tokenValueInRedis)) {
                TokenValueInRedis tokenValue = JSON.parseObject(tokenValueInRedis, new TypeReference<TokenValueInRedis>() {
                });
                authorityInMapValue = new TokenValueInMap(tokenValue, System.currentTimeMillis());
                cacheMap.put(token, authorityInMapValue);
            } else {
                cacheInValidToken.put(token, System.currentTimeMillis());
            }
        }
        return authorityInMapValue;
    }

    
}

2.auth-client服务(VerifySignatureInterceptor 拦截器)
/**
 * 校验签名拦截器
 * 放在所有拦截器的最前面
 */
@Slf4j
@Component
@Setter
public class VerifySignatureInterceptor extends HandlerInterceptorAdapter {

    /**
     * 由外部实例化VerifySignatureInterceptor对象后传入
     */
    private RedisService redisService;

    public static final String Authorization = "Authorization";
    public static final String AuthKey = "authKey";

    /**
     * 签名校验
     *
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws IOException
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
        try {
            //如果是访问内部资源文件,则直接放行
            if (!(handler instanceof HandlerMethod)) {
                return true;
            }

            if (!(request instanceof RequestWrapper)) {
                return true;
            }

            RequestWrapper requestWrapper = (RequestWrapper) request;

            //请求中包含签名参数 sign 和 signTimestamp 时,才会校验签名的正确性
            SortedMap<String, String> urlParams = HttpUtils.getUrlParams(requestWrapper);
            String sign = SignUtils.getSign(urlParams);
            String signTimestamp = SignUtils.getSignTimestamp(urlParams);

            if (!StringUtils.isEmpty(sign) && !StringUtils.isEmpty(signTimestamp)) {
                //从请求中获取所有参数,并组装成验证签名所需要的map
                SortedMap<String, String> allParams = HttpUtils.getAllParams(requestWrapper);
                this.verifyRequestBySign(requestWrapper, allParams);
            }

            return true;
        } catch (Exception ex) {
            ex.printStackTrace();
//            this.returnJson(response, ex);
            CommonExceptionHandler.sendErrorByResponse(response, ex);
            return false;
        }
    }

    /**
     * 根据签名校验请求的有效性,根据所有入参进行签名计算
     *
     * @param allParams
     */
    private void verifyRequestBySign(HttpServletRequest request, SortedMap<String, String> allParams) {
        String sign = SignUtils.getSign(allParams);
        String signTimestamp = SignUtils.getSignTimestamp(allParams);

        if (!StringUtils.isEmpty(sign) && !StringUtils.isEmpty(signTimestamp)) {
            // 写入token
            String token = request.getHeader(Authorization);
            allParams.put(Authorization.toLowerCase(), null == token ? "" : token);

            //从redis中获取签名用的key,并放入SortedMap中
            boolean isHasClientID = false;
            String clientId = request.getHeader(User.CONTEXT_CLIENT_ID);
            if (StringUtils.isEmpty(clientId)) {
                clientId = allParams.get("clientId");
            }
            if (!StringUtils.isEmpty(clientId)) {
                Object authClientObj = this.redisService.hashGet(AuthConstants.PREFIX_AUTH_CLIENT, clientId);
                if (!StringUtils.isEmpty(authClientObj)) {
                    JSONObject jsonObject = JSON.parseObject(authClientObj.toString());
                    allParams.put(AuthKey, jsonObject.getString(AuthKey));
                    isHasClientID = true;
                }
            }
            if (!isHasClientID) {
                throw new BaseException(HttpStatus.SignError.getStatus(), "请求参数中或token中缺少ClientID的值");
            }
            log.info("==allParams====allParams={}"+JSON.toJSONString(allParams));
            // 对参数进行签名验证
            boolean isSigned = SignUtils.verifySign(allParams, 30);
            if (!isSigned) {
                throw new BaseException(HttpStatus.SignError.getStatus(), "请求的签名有误,可能是由于请求过期或参数被篡改");
            }
        }
    }
3.auth-client服务(RequestContextInterceptor 拦截器)
/**
 * 用户上下文拦截器
 * 从请求头header中获取用户权限,判断用户是否具有访问权限
 * 未授权:抛出异常 HttpStatus.AuthorizationError
 * 有权限:将用户信息放入用户上下文UserContextHolder,以方便业务系统获取用户信息
 */
@Slf4j
public class RequestContextInterceptor extends HandlerInterceptorAdapter {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        try {
            //如果是访问内部资源文件,则直接放行
            if (!(handler instanceof HandlerMethod)) {
                return true;
            }

            //非正常http请求,并且 非文件上传类请求,可以直接放行
            if (!(request instanceof HttpServletRequestWrapper)) {
                return true;
            }

            //如果在之前的拦截中设置了当前用户,则跳过如下的认证逻辑
            if (null != UserContextHolder.currentUser()) {
                return true;
            }

            User user = getUser(request);
            Dept dept = getDept(request);
            Company company = getCompany(request);

            if (null == user || null == user.getUserId() || null == user.getUserName()) {
                throwException(request, HttpStatus.Unauthorized.getStatus(), "消息头中缺少userid或username");
            }

            if (null != user && null == user.getUserLoginType()) {
                throwException(request, HttpStatus.Unauthorized.getStatus(), "消息头中缺少userlogintype");
            }

            //员工登录必须有机构和科室
            if (null != user && user.isEmployee()) {
                if (null == dept || null == dept.getDeptId() || null == dept.getDeptName()) {
                    throwException(request, HttpStatus.Unauthorized.getStatus(), "消息头中缺少deptid或deptname");
                }
                if (null == company || null == company.getCompanyId() || null == company.getCompanyName()) {
                    throwException(request, HttpStatus.Unauthorized.getStatus(), "消息头中缺少companyid或companyname");
                }
            }

            if (null != user && !UserPermissionUtil.verify(user, (HandlerMethod) handler)) {
                throwException(request, HttpStatus.Forbidden.getStatus(), "请求的URL没有权限 -> " + request.getRequestURI());
            }

            UserContextHolder.set(user);
            DeptContextHolder.set(dept);
            CompanyContextHolder.set(company);

            return true;
        } catch (Exception ex) {
            ex.printStackTrace();
            CommonExceptionHandler.sendErrorByResponse(response, ex);
            return false;
        }
    }

    /**
     * 拦截请求成功执行后的操作
     *
     * @throws Exception
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse respone, Object arg2, ModelAndView arg3)
            throws Exception {
        // DOING NOTHING
    }

    /**
     * 拦截请求成功执行后的最终操作
     *
     * @throws Exception
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse respone, Object arg2, Exception exception)
            throws Exception {
        UserContextHolder.shutdown();
        DeptContextHolder.shutdown();
        CompanyContextHolder.shutdown();
    }

    /**
     * 从请求头header中获取用户信息
     *
     * @param request
     * @return
     */
    private User getUser(HttpServletRequest request) {
        String userId = request.getHeader(User.CONTEXT_USER_ID);
        String userName = request.getHeader(User.CONTEXT_USER_NAME);
        String authoritiesStr = request.getHeader(User.CONTEXT_USER_AUTHORITIES);
        String userLoginType = request.getHeader(User.CONTEXT_USER_LOGIN_TYPE);
        String token = request.getHeader(User.CONTEXT_USER_TOKEN);
        String clientId = request.getHeader(User.CONTEXT_CLIENT_ID);
        String invitationCode = request.getHeader(User.CONTEXT_INVITATION_CODE); //邀请码

        if (null == userId || null == userName) {
            return null;
        }

        List<String> authorities = CollectionConvertUtil.stringToList(null == authoritiesStr ? "" : authoritiesStr, ";");

        return User.builder()
                .token(token)
                .userId(userId)
                .userName(userName)
                .userLoginType(userLoginType)
                .clientId(clientId)
                .invitationCode(invitationCode)
                .authorities(authorities).build();
    }

    /**
     * 从请求头header中获取科室信息
     *
     * @param request
     * @return
     */
    private Dept getDept(HttpServletRequest request) {
        String deptid = request.getHeader(Dept.CONTEXT_DEPT_ID);
        String deptname;

        if (null == deptid) {
            return null;
        }

        try {
            deptname = URLDecoder.decode(request.getHeader(Dept.CONTEXT_DEPT_NAME), "UTF-8");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
            deptname = "解析错误";
        }

        return Dept.builder().deptId(deptid).deptName(deptname).build();
    }

    /**
     * 从请求头header中获取机构信息
     *
     * @param request
     * @return
     */
    private Company getCompany(HttpServletRequest request) {
        String companyid = request.getHeader(Company.CONTEXT_COMPANY_ID);

        if (null == companyid) {
            return null;
        }

        String companyname;
        try {
            companyname = URLDecoder.decode(request.getHeader(Company.CONTEXT_COMPANY_NAME), "UTF-8");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
            companyname = "解析错误";
        }

        Company company = new Company();
        company.setCompanyId(companyid);
        company.setCompanyName(companyname);
        return company;
    }

    /**
     * 抛出异常
     * 如果当前请求的url是白名单内的,可以不用抛出异常
     *
     * @param request
     */
    private void throwException(HttpServletRequest request, String code, String message) {
        if (!AuthPassList.isSkipAuthCheck(request)) {
            throw new BaseException(code, message);
        }
    }
}

五、注意点 敲黑板

在此示例代码中:
1、在gateway网关token校验中,校验通过后会将Redis中该用户信息、权限标识等信息放入request中带入下一环节;
2、前提由于auth-client是其他服务的公共依赖包,略过验签。。。在权限校验拦截器中,会从request中取出用户相关信息、权限标识进行访问权限校验,并在结束后将这些信息放入Threadlocal中,与线程绑定,因为下面业务代码中需要从该线程中取出该用户信息进行使用;
3、此实例代码是基于分布式服务,当调用其他服务时,由于用户信息是存入Threadlocal中只与某一JVM中线程绑定,其他服务是无法获取该用户信息的,所以为了使其他服务能够使用该用户信息,加了一个拦截器FeignRequestContextInterceptor,基于SpringCloud的Feign服务调用,在此拦截器中将用户相关信息放入RequestTemplate对象中并带入其他服务;
代码如下:

/**
 * @Description 在通过feign调用服务时,传递当前请求中所有header信息
 */
public class FeignRequestContextInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate requestTemplate) {
        User user = UserContextHolder.currentUser();
        Dept dept = DeptContextHolder.currentDept();
        Company company = CompanyContextHolder.currentCompany();

        if (null != user) {
            requestTemplate.header(User.CONTEXT_USER_TOKEN, user.getToken());
            requestTemplate.header(User.CONTEXT_USER_ID, user.getUserId());
            requestTemplate.header(User.CONTEXT_USER_NAME, user.getUserName());
            requestTemplate.header(User.CONTEXT_USER_LOGIN_TYPE, user.getUserLoginType());
            requestTemplate.header(User.CONTEXT_USER_AUTHORITIES, CollectionConvertUtil.listToString(user.getAuthorities(), ";"));
            requestTemplate.header(User.CONTEXT_CLIENT_ID, user.getClientId());
            requestTemplate.header(User.CONTEXT_INVITATION_CODE, user.getInvitationCode()); //邀请码
        }

        if (null != dept) {
            requestTemplate.header(Dept.CONTEXT_DEPT_ID, dept.getDeptId());
            try {
                requestTemplate.header(Dept.CONTEXT_DEPT_NAME, URLEncoder.encode(dept.getDeptName(), "UTF-8"));
            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
            }
        }

        if (null != company) {
            requestTemplate.header(Company.CONTEXT_COMPANY_ID, company.getCompanyId());
            try {
                requestTemplate.header(Company.CONTEXT_COMPANY_NAME, URLEncoder.encode(company.getCompanyName(), "UTF-8"));
            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
            }
        }
    }
}

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值