Sign签名计算、验签实例(对外提供接口)

本文详细介绍了对外接口的签名设计与验签流程,包括签名的生成规则、参数排序、MD5加密以及验签过程。还提供了生成签名的代码示例和网关服务过滤器中对token的验证逻辑,确保接口安全性和数据完整性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

对外提供接口签名sign计算、验签实例

一、简述

1、对外提供接口,为保护参数不被修改,保护数据的安全性,需要在客户端调用时接口添加签名、服务端对接口进行验签;
2、本实例中authKey是不参与通信,整个过程中authKey是不参与通信的,所以只要保证authKey不泄露,即使请求参数被其他原因泄露,请求也不会被伪造。

二、签名设计及计算过程

1.己方系统提供
clientId: 由于己方系统可能会对外不同角色方提供接口(第三方系统app、第三方系统PC、定时任务调用等),所以针对不同角色类型的外部系统做了配置化处理,
每个clientId对应某一外部角色类型方,并在后台库中存储了该clientId对应的authKey、authorization及其具有的权限信息,配置信息写入redis中,后期从redis中获取这些信息;
authKey: 某一clientId对应的authKey,专门进行加签、验签操作,为接口安全考虑,不参与通信;
authorization: 接口header中携带的token,因为系统中接口权限验证需要校验token,所以为了匹配整个系统中的访问权限验证需要这个参数;
2.url参数如下:
signTimestamp 签名失效时间,时间戳,单位毫秒,13位【必填】
sign 签名
3.签名的计算规则如下:
a、对所有入参【注意:参数值非空的才会参与签名的计算】clientId、authKey、authorization、signTimestamp 按照字段名的 ASCII 码从小到大排序(字典序)后,
使用 URL 键值对的格式(即 key1=value1&key2=value2…)拼接成字符串 str;
b、对 str 进行 md5 运算,再将得到的字符串所有字符转换为大写,得到 sign 值
4.下面定义了一段生成 sign 字符串的示范过程:
1)、POST请求

URL:http://localhost:7777/xxxxx-service/testResource/test?param3=456

header: key :Authorization value :201295823105949696

body:

{
“param1”:“参数1”,
“param2”:“参数2”,
“param5”:[“www”,“wfawefwe”,“jagjergre”]
}

2)、获取当前时间戳

signTimestamp:1615458960605

3)、按照a规则,排序后的字符串 str:

authKey=303e6bd7-472d-11ea-a802-fa163ecd8c7a&authorization=201295823105949696&param1=参数1&param2=参数2&param3=456&param5=[“哈哈哈”,“呜呜呜”,“急急急”]&signTimestamp=1615458960605

4)、按照b规则得到签名 sign:

DigestUtils.md5DigestAsHex(“authKey=303e6bd7-472d-11ea-a802-fa163ecd8c7a&authorization=201295823105949696&param1=参数1&param2=参数2&param3=456&param5=[“哈哈哈”,“呜呜呜”,“急急急”]&signTimestamp=1615458960605”).toUpperCase()
输出:“EBD4B596A4DDDFB6ACBCFAF3E5C6BE6A”

5)、最终请求如下:
URL:http://localhost:7777/xxxxx-service/testResource/test?param3=456&signTimestamp=1615458960605&sign=EBD4B596A4DDDFB6ACBCFAF3E5C6BE6A

header: key :Authorization value :201295823105949696

body:

{
“param1”:“参数1”,
“param2”:“参数2”,
“param5”:[“www”,“wfawefwe”,“jagjergre”]
}

三、UML图

外部接口验签时序图

四、代码实例

1、生成签名

String signTimestamp = SignUtil.getSignTimestamp();
        String getTestDataUrlStr = "/xxxxx-service/roomTest/getTestData?signTimestamp="+signTimestamp+"&sign="+SignUtil.getSign(token,authKey,signTimestamp);
/**
 * @author
 * @Description 生成签名
 * @Date
 */
public class SignUtil {

    private static final Logger LOGGER = LoggerFactory.getLogger(SignUtil.class);

    //生成签名;
    public static String getSign(String token,String authKey,String signTimestamp){

    	//获取签名;
        SortedMap<String, String> allParams = new TreeMap<>();
        allParams.put("authorization",token);
        allParams.put("authKey",authKey);

        //String signTimestampStr = signTimestamp;
        allParams.put("signTimestamp",signTimestamp);

        StringBuilder stringBuilder = new StringBuilder(150);
        for (Map.Entry<String, String> entry : allParams.entrySet()) {
            if (!StringUtils.isEmpty(entry.getValue())) {
                stringBuilder.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
            }
        }
        String paramsStr = stringBuilder.toString();
        if (StringUtils.isNotBlank(paramsStr)) {
            paramsStr = paramsStr.substring(0, paramsStr.length() - 1);
        }

        return DigestUtils.md5DigestAsHex(paramsStr.getBytes()).toUpperCase();
    }

    public static String getSignTimestamp(){
        //取得指定时区的时间(东八区)
        TimeZone zone = TimeZone.getTimeZone("GMT-8:00");
        Calendar cal = Calendar.getInstance(zone);
        long currentSecond = cal.getTime().getTime();
        String currentTimestamp = String.valueOf(currentSecond);
        return currentTimestamp;
    }


2、网关服务 过滤器校验token是否存在及有效性

/**
 * 请求认证过滤
 */
@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;
    }
}

3.authclient服务拦截器进行验签

/**
 * 校验签名拦截器
 * 放在所有拦截器的最前面
 */
@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(), "请求的签名有误,可能是由于请求过期或参数被篡改");
            }
        }
    }
}




/**
 * 签名工具类
 */
@Slf4j
public class SignUtils {
    /**
     * 根据入参校验
     *
     * @param params
     * @return
     */
    public static boolean verifySign(SortedMap<String, String> params, int timeoutSecond) {
        String urlSign = SignUtils.getSign(params);
        String signTimestamp = SignUtils.getSignTimestamp(params);

        //如果请求中没有签名和时间戳,则直接放行,用于开发内部调测,或各环境中服务间的RPC调用
        if (StringUtils.isBlank(urlSign) || StringUtils.isBlank(signTimestamp)) {
            return true;
        }
        log.info("请求中的签名 : {}", urlSign);

        // 把参数加密
        String paramsSign = getParamsSign(params);
        log.info("==paramsSign====paramsSign={}" + JSON.toJSONString(paramsSign));
        log.info("计算后的签名 : {}", paramsSign);
        return !StringUtils.isBlank(paramsSign)
                && urlSign.equals(paramsSign)
                && checkRequestUrlIsValid(params, timeoutSecond);
    }

    /**
     * 获取签名数据
     *
     * @param params
     * @return
     */
    public static String getSign(SortedMap<String, String> params) {
        return params.get("sign");
    }

    /**
     * 获取签名的时间戳
     *
     * @param params
     * @return
     */
    public static String getSignTimestamp(SortedMap<String, String> params) {
        return params.get("signTimestamp");
    }

    /**
     * 将请求的参数进行MD5加密
     *
     * @param params
     * @return
     */
    public static String getParamsSign(SortedMap<String, String> params) {
        // 要先去掉 Url 里的 Sign
        params.remove("sign");
        StringBuilder stringBuilder = new StringBuilder(150);

        for (Map.Entry<String, String> entry : params.entrySet()) {
            //value非空,才会参与签名计算
            if (!org.springframework.util.StringUtils.isEmpty(params.get(entry.getKey()))) {
                stringBuilder.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
            }
        }

        //清除最后一位的符号:&
        String paramsStr = stringBuilder.toString();
        if (StringUtils.isNotBlank(paramsStr)) {
            paramsStr = paramsStr.substring(0, paramsStr.length() - 1);
        }

        return DigestUtils.md5DigestAsHex(paramsStr.getBytes()).toUpperCase();
    }

    /**
     * 校验请求中的时间戳是否过期
     *
     * @param params
     * @return
     */
    public static boolean checkRequestUrlIsValid(SortedMap<String, String> params, int timeoutSecond) {
        String signTimestamp = SignUtils.getSignTimestamp(params);
        if (StringUtils.isBlank(signTimestamp)) {
            return false;
        }
        Long signTimestampLong = 0L;
        try {
            signTimestampLong = Long.valueOf(signTimestamp);
        } catch (Exception ex) {
            return false;
        }

        //取得指定时区的时间(东八区)
        TimeZone zone = TimeZone.getTimeZone("GMT-8:00");
        Calendar cal = Calendar.getInstance(zone);
        long currentTimestamp = cal.getTime().getTime();

        long internalSecond = ((currentTimestamp - signTimestampLong) / 1000);
        log.info("internalSecond:" + internalSecond);

        //超出时间间隔,则认定请求过期
        if (internalSecond > timeoutSecond) {
            return false;
        }

        return true;
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值