JWT and user-defined annotations realize interface access control and information transfer of spring cloud feign distributed service invocation

1, Brief description

Control the access authority of background interface through JWT and user-defined annotation:
1. Bind the user (employee), role, menu page and button with the permission ID through the PC side page;
2. In the login authentication process, the login information and permission ID are bound with the token and stored in redis;
3. User defined annotation and identification of the interface;
4. Each request is checked through the interceptor (the permission ID bound by the user is compared with the annotation ID of the background interface)

2, Flow chart

3, Specific logic sequence diagram of user access interface

4, Brief code

1. Gateway service (AuthFilter filter)

/**
 * Request authentication filtering
 */
@Component
@Slf4j
public class AuthFilter implements GlobalFilter, Ordered {

    /**
     * The user information and authority corresponding to the token are temporarily stored based on LRU
     */
    public static final ConcurrentLinkedHashMap<String, TokenValueInMap> cacheMap =
            new ConcurrentLinkedHashMap.Builder<String, TokenValueInMap>()
                    .maximumWeightedCapacity(20000)
                    .weigher(Weighers.singleton())
                    .build();

    /**
     * Based on recording invalid tokens, prevent invalid tokens from multiple times to improve system performance
     */
    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(
                //The thread pool maintains the minimum number of threads
                100,
                //The maximum number of threads maintained by the thread pool
                10000,
                //The thread pool maintains the idle time allowed by threads
                120, TimeUnit.SECONDS,
                //Buffer queue used by thread pool
                new ArrayBlockingQueue<Runnable>(500),
                //If the join fails, it will be executed on the calling main thread
                new ThreadPoolExecutor.CallerRunsPolicy());
    }

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

        //Note: for the whitelist request, if there is a valid token, the valid token (i.e. it can be parsed normally + it is not invalid in redis) will also be parsed
        try {
            //Get token from request header
            String token = getTokenFromRequest(request);

            //Whether it is an anonymous request, that is, whether the request path contains unauthentic
            boolean isHasUnAuth = false;

            //For requests that do not carry a token, you need to judge whether they can be released directly and control them through the white list in 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)) {
                    //Verify the signature in the request before release
                    this.preChainFilterProcess(request, "");
                    //Release unauthentic request without token
                    return chain.filter(exchange);
                }
            }

            //No token is carried when accessing the interface requiring authorization
            if (!isHasUnAuth && StringUtils.isBlank(token)) {
                throw new Exception("Please log in to the system first");
            }

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

            //Get the user information corresponding to the token
            //  tokenValueInMap is null, which means the token is invalid and needs to be logged in again
            TokenValueInMap tokenValueInMap = getTokenValue(token);
            if (null != tokenValueInMap) {
                authorities = tokenValueInMap.tokenValueInRedis.getAuthority();
                userMap = tokenValueInMap.tokenValueInRedis.getClaims();
            }

            //The request is not in the white list and carries a valid token that is parsed by itself, but the token does not exist in redis. An exception is thrown directly and the user needs to log in again
            if (!isHasUnAuth && null == tokenValueInMap) {
                throw new Exception("Login timed out, Please login again");
            }
            //The request in the white list carries a token that is valid for its own resolution, but the token does not exist in redis and can be released
            if (isHasUnAuth && null == tokenValueInMap) {
                this.preChainFilterProcess(request, "");
                return chain.filter(exchange);
            }

            //Automatic renewal of 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()) //Invitation code
                    .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);
        }
    }

    /**
     * Exception handling during authentication
     *
     * @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())));
    }

    /**
     * The priority of filter execution. The smaller the value, the higher the priority
     *
     * @return
     */
    @Override
    public int getOrder() {
        return -5;
    }

    /**
     * Get token from total request
     *
     * @param request
     * @return
     */
    private String getTokenFromRequest(ServerHttpRequest request) {
        String token = request.getHeaders().getFirst(JwtTokenUtils.HEADER_AUTH);

        //Because websocket is special, it involves the process of http upgrade
        //The token cannot be carried in the header, but only in the request path
        //If the request starts with "/ websocket server / chat /", it is considered to be a request of websocket type. The token is placed at the end of the request and intercepted through "/" to obtain the token
        if (request.getURI().getPath().startsWith("/websocket-server/chat/")) {
            token = request.getURI().getPath().substring(request.getURI().getPath().lastIndexOf("/") + 1);
        }
        return token;
    }

    /**
     * automatic renewal 
     * Reset the expiration time of 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();

        //For the sake of system security, the token generated through the employee's super password will not be automatically renewed and expires after 12 hours by default
        if (UserLoginTypeConstants.EmployeeLoginPCBySuperPassword.equals(userLoginType)) {
            return;
        }

        if (LoginDeviceTypeConstants.APP.equals(LoginRelatedHelper.getLoginDeviceType(userLoginType))) {
            //The login method is app, which is automatically renewed for 12 days
            boolean isOK = redisService.expire(token, redisProperties.getAppTokenExpireMilliSeconds());
            if (isOK) { //In terms of performance, the next step will be executed only if the previous step is executed successfully
                redisService.expire(userLoginType + userId, redisProperties.getAppTokenExpireMilliSeconds());
            }
        } else if (LoginDeviceTypeConstants.PC.equals(LoginRelatedHelper.getLoginDeviceType(userLoginType))) {
            //The login mode is pc or third-party simulated Login, which is automatically renewed for 8 hours
            boolean isOK = redisService.expire(token, redisProperties.getPcTokenExpireMilliSeconds());
            if (isOK) { //In terms of performance, the next step will be executed only if the previous step is executed successfully
                redisService.expire(userLoginType + userId, redisProperties.getPcTokenExpireMilliSeconds());
            }
        } else {
            //For virtual user calls, the token does not expire, so it does not need to be renewed
        }
    }

    /**
     * Execute chain Pretreatment before filter
     *
     * @param request
     * @param userLoginType
     */
    private void preChainFilterProcess(ServerHttpRequest request, String userLoginType) {
        this.veritySign(request, userLoginType);
    }

    /**
     * Verify the signature in the request
     *
     * @param request
     * @param userLoginType
     */
    private void veritySign(ServerHttpRequest request, String userLoginType) {
        userLoginType = null == userLoginType ? "" : userLoginType;

        //It is temporarily determined that the request for virtual user login through the third-party system must carry sign and signTimestamp
        //[to be developed] after the front-end transformation, the signature verification requested by all users can be opened
        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(), "Signature in request sign Cannot be empty");
            }

            if (StringUtils.isBlank(signTimestamp)) {
                throw new BaseException(HttpStatus.SignError.getStatus(), "Timestamp of the signature in the request signTimestamp Cannot be empty");
            }
        }
    }

    /**
     * First obtain the user and permission corresponding to the token from the cache. If it exists and has not expired, it will be returned directly. Otherwise, it needs to be obtained in redis and then written to the map
     * Since the token is cached here, in the distributed scenario, after the customer logs out, the token is still cached, so the single point of control cannot be realized immediately. However, considering the tps of the server, this compromise is adopted temporarily
     *
     * @param token
     * @return
     */
    private TokenValueInMap getTokenValue(String token) {
        // First obtain the permission corresponding to the token from the cache. If it exists and has not expired, it will be returned directly. Otherwise, it needs to be obtained in redis and then written to the map
        TokenValueInMap authorityInMapValue = cacheMap.get(token);
        // 60 * 60 * 8 * 1000 = 28800 * 1000 = 28800000 (the value in the map expires in 8 hours)
        if (null == authorityInMapValue || (authorityInMapValue.timeMillis + 28800000) < System.currentTimeMillis()) {

            //If it is determined that the token is invalid, it will be returned directly
            if (cacheInValidToken.containsKey(token)) {
                Long timeMillis = cacheInValidToken.get(token);
                // 60 * 60 * 24 * 1000 = 86400 * 1000 = 86400000 (24 hours)
                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 service (VerifySignatureInterceptor interceptor)

/**
 * Verification signature interceptor
 * Put it at the front of all interceptors
 */
@Slf4j
@Component
@Setter
public class VerifySignatureInterceptor extends HandlerInterceptorAdapter {

    /**
     * The VerifySignatureInterceptor object is instantiated externally and then passed in
     */
    private RedisService redisService;

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

    /**
     * Signature verification
     *
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws IOException
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
        try {
            //If it is to access internal resource files, it will be released directly
            if (!(handler instanceof HandlerMethod)) {
                return true;
            }

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

            RequestWrapper requestWrapper = (RequestWrapper) request;

            //The correctness of the signature will be verified only when the request contains the signature parameters sign and 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)) {
                //Get all the parameters from the request and assemble the map needed to verify the signature
                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;
        }
    }

    /**
     * According to the validity of the signature verification request, the signature is calculated according to all input parameters
     *
     * @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)) {
            // Write token
            String token = request.getHeader(Authorization);
            allParams.put(Authorization.toLowerCase(), null == token ? "" : token);

            //Get the key for signature from redis and put it into 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(), "In request parameters or token Missing in ClientID Value of");
            }
            log.info("==allParams====allParams={}"+JSON.toJSONString(allParams));
            // Signature verification of parameters
            boolean isSigned = SignUtils.verifySign(allParams, 30);
            if (!isSigned) {
                throw new BaseException(HttpStatus.SignError.getStatus(), "The signature of the request is incorrect, which may be due to the expiration of the request or the tampering of parameters");
            }
        }
    }

3. Auth client service (RequestContextInterceptor interceptor)

/**
 * User context interceptor
 * Obtain the user permission from the request header and judge whether the user has access permission
 * Unauthorized: throw exception httpstatus AuthorizationError
 * Permission: put the user information into the user context UserContextHolder to facilitate the business system to obtain the user information
 */
@Slf4j
public class RequestContextInterceptor extends HandlerInterceptorAdapter {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        try {
            //If it is to access internal resource files, it will be released directly
            if (!(handler instanceof HandlerMethod)) {
                return true;
            }

            //Non normal http requests and non file upload requests can be released directly
            if (!(request instanceof HttpServletRequestWrapper)) {
                return true;
            }

            //If the current user is set in the previous interception, the following authentication logic is skipped
            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(), "Missing in message header userid or username");
            }

            if (null != user && null == user.getUserLoginType()) {
                throwException(request, HttpStatus.Unauthorized.getStatus(), "Missing in message header userlogintype");
            }

            //Employees must have institutions and departments to log in
            if (null != user && user.isEmployee()) {
                if (null == dept || null == dept.getDeptId() || null == dept.getDeptName()) {
                    throwException(request, HttpStatus.Unauthorized.getStatus(), "Missing in message header deptid or deptname");
                }
                if (null == company || null == company.getCompanyId() || null == company.getCompanyName()) {
                    throwException(request, HttpStatus.Unauthorized.getStatus(), "Missing in message header companyid or companyname");
                }
            }

            if (null != user && !UserPermissionUtil.verify(user, (HandlerMethod) handler)) {
                throwException(request, HttpStatus.Forbidden.getStatus(), "Requested URL No permission -> " + request.getRequestURI());
            }

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

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

    /**
     * Operation after successful execution of interception request
     *
     * @throws Exception
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse respone, Object arg2, ModelAndView arg3)
            throws Exception {
        // DOING NOTHING
    }

    /**
     * Final operation after successful execution of interception request
     *
     * @throws Exception
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse respone, Object arg2, Exception exception)
            throws Exception {
        UserContextHolder.shutdown();
        DeptContextHolder.shutdown();
        CompanyContextHolder.shutdown();
    }

    /**
     * Get user information from request 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); //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();
    }

    /**
     * Get department information from request 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 = "Parsing error";
        }

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

    /**
     * Get organization information from request 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 = "Parsing error";
        }

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

    /**
     * Throw exception
     * If the url of the current request is in the white list, there is no need to throw an exception
     *
     * @param request
     */
    private void throwException(HttpServletRequest request, String code, String message) {
        if (!AuthPassList.isSkipAuthCheck(request)) {
            throw new BaseException(code, message);
        }
    }
}

5, Pay attention and knock on the blackboard

In this sample code:
1. In the gateway token verification, after the verification is passed, the user information, permission ID and other information in Redis will be put into the request and carried to the next link;
2. Premise: since auth client is a public dependent package of other services, signature verification is omitted... In the permission verification interceptor, the user related information and permission ID will be taken out from the request for access permission verification, and these information will be put into the Threadlocal and bound with the thread, because the following business code needs to take the user information from the thread for use;
3. This example code is based on distributed services. When calling other services, because the user information is stored in Threadlocal and only bound to the thread in a JVM, other services cannot obtain the user information. Therefore, in order to enable other services to use the user information, an interceptor FeignRequestContextInterceptor is added to call Feign service based on spring cloud, In this interceptor, put the user related information into the RequestTemplate object and bring it into other services;
The code is as follows:

/**
 * @Description When calling the service through feign, all header information in the current request is passed
 */
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()); //Invitation code
        }

        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();
            }
        }
    }
}

Tags: Distribution Spring Cloud

Posted by jwwceo on Thu, 14 Apr 2022 20:24:34 +0930