springcloud 封装 rocketmq 保证消息被发送、幂等、事务消息处理

前言

    在项目中使用到了rocketmq, 同时为了保证一些重要的消息不丢失,就算发送失败也能够溯源,于是在生产者端对mq的消息状态进行判断,如果发送失败或者发送后出现了异常,将错误消息存入redis,然后建立定时任务从reids中拿到错误消息,进行重试,建立了这样一套为了完全保证rocketmq生产者端不丢失运用流程。但是,在使用的过程中发现有一些重复的功能代码,导致项目中关于rocketmq的使用的代码非常的凌乱,于是把这些公共代码抽取为util类,设计优化了下。

代码

生产者端

   MqKeyGenerator

    MqKeyGenerator 主要用于一些业务键的生成,这里主要有3个键值

//生产者端消息业务key
private static final String BIZ_OPERATION_PREFIX = "mm_biz_";
//消费者端幂等处理key
private final static String PREFIX_IDEMPOTENT = "mm_idempotent_";
//事务key
private static final String TRANS_PREFIX = "mm_trans_";

代码如下: 

package com.iunicorn.mall.middle.starter.rocketmq.utils;

import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.iunicorn.mall.middle.starter.rocketmq.RocketMQProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

/**
 * @Title: MqKeyGenerator
 * @Description: 生成业务key
 * @date : 2020/11/4 15:31
 */
@Component
public class MqKeyGenerator {

    private volatile String bizCacheKey;
    private volatile String transCacheKey;
    private volatile String consumerCacheKey;

    private static final String BIZ_OPERATION_PREFIX = "mm_biz_";
    private final static String PREFIX_IDEMPOTENT = "mm_idempotent_";
    private static final String TRANS_PREFIX = "mm_trans_";
    private final String separator ="_";

    @Autowired
    private RedisTemplate redisTemplate;
    @Autowired
    private RocketMQProperties properties;
    @Value("${spring.application.name}")
    private String applicationName;

    private String getBizCacheKey(String producerGroup) {
        if (bizCacheKey == null) {
            synchronized (this) {
                if (bizCacheKey == null) {
                    this.bizCacheKey = BIZ_OPERATION_PREFIX + producerGroup + separator;
                }
            }
        }
        return bizCacheKey;
    }

    public String getConsumerCacheKey(String producerGroup){
        if(consumerCacheKey == null){
            synchronized (this){
                if(consumerCacheKey == null){
                    consumerCacheKey = PREFIX_IDEMPOTENT + producerGroup + separator;
                }
            }
        }
        return consumerCacheKey;
    }

    private String getTransCacheKey(String producerGroup) {
        if (transCacheKey == null) {
            synchronized (this) {
                if (transCacheKey == null) {
                    transCacheKey = TRANS_PREFIX + producerGroup + separator;
                }
            }
        }
        return transCacheKey;
    }

    /**
     * 业务操作的id,全局唯一,防止消费者重复消费
     *
     * @return
     */
    public String getBizOperationId() {
        String producerGroup = getDefaultProducerGroupName();
        String bizCacheKey = getBizCacheKey(producerGroup);
        return bizCacheKey + redisTemplate.opsForValue().increment(bizCacheKey);
    }

    /**
     * 获取消费者端锁的key
     * @param bizId
     * @return
     */
    public String getConsumerLockKey(String bizId) {
        String producerGroup = getDefaultProducerGroupName();
        return getConsumerCacheKey(producerGroup) + bizId;
    }

    /**
     * 在同一producer获得新的事务Id,用于检查事务状态全局唯一
     *
     * @return
     */
    private String getNewTransactionId() {
        String producerGroup = getDefaultProducerGroupName();
        String transCacheKey = getTransCacheKey(producerGroup);
        return transCacheKey + redisTemplate.opsForValue().increment(transCacheKey);
    }

    private String getDefaultProducerGroupName(){
        String producerGroup = properties.getProducerGroup();
        if(producerGroup == null){
            producerGroup = applicationName;
        }
        return producerGroup;
    }
}

MessageUtils

MessageUtils:封装构建消息
/**
 * @Title: MessageUtils
 * @Description: 消息工具类
 * @date : 2020/11/4 15:15
 */
@Component
public class MessageUtils {

    @Autowired
    private MqKeyGenerator keyGenerator;

    public Message buildMessage(String msg){
        Message<String> message = MessageBuilder.withPayload(msg).build();
        return message;
    }

    public Message buildMessage(BasePayload payload) {
        payload.setBizId(keyGenerator.getBizOperationId());
        Message msg = MessageBuilder.withPayload(payload).build();
        return msg;
    }

    public Message buildMessage(BasePayload payload, String keys) {
        payload.setBizId(keyGenerator.getBizOperationId());
        MessageBuilder builder = getBuilder(payload, keys);

        return builder.build();
    }

    public Message buildTransactionMessage(BasePayload payload, String keys, String transactionId) {
        payload.setBizId(keyGenerator.getBizOperationId());

        MessageBuilder builder = getBuilder(payload, keys);
        builder.setHeader(RocketMQHeaders.TRANSACTION_ID, transactionId);

        return builder.build();
    }

    private MessageBuilder getBuilder(BasePayload payload, String keys){
        MessageBuilder<BasePayload> builder = MessageBuilder.withPayload(payload);
        if(StringUtils.isNotBlank(keys)){
            builder.setHeader(RocketMQHeaders.KEYS, keys);
        }
        return builder;
    }
}

RocketMQProductUtils

RocketMQProductUtils:生产者工具类,包装rocketmq发送消息方法
/**
 * @Title: RocketMQProductUtils
 * @Description: rocketMq 生产者工具类
 * @date : 2020/11/4 15:05
 */

@Slf4j
public class RocketMQProductUtils {

    public static String SEPARATOR_COLON = ":";

    @Autowired
    private RocketMQTemplate template;
    @Autowired
    private MessageUtils messageUtils;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;


    /**
     * 单向发送, 消息无应答
     * @param topic
     * @param message
     */
    public void sendOneWay(String topic, String message){
        template.sendOneWay(topic, messageUtils.buildMessage(message));
    }

    public void sendOneWay(String topic, BasePayload payload){
        template.sendOneWay(topic, messageUtils.buildMessage(payload));
    }

    public void syncSend(String topic, BasePayload payload){
        Message message = messageUtils.buildMessage(payload);
        log.info("topic_" + topic + "  send msg: " + JSON.toJSONString(message));
        SendResult sendResult = template.syncSend(topic, message);
        syncResultHandle(topic, payload, sendResult);
    }

    public void syncSend(String topic, String keys, BasePayload payload){
        Message message = messageUtils.buildMessage(payload, keys);
        log.info("topic_" + topic + "  send msg: " + JSON.toJSONString(message));
        SendResult sendResult = template.syncSend(topic, message);
        syncResultHandle(topic, payload, sendResult);
    }

    public void syncSend(String topic, String keys, String tags, BasePayload payload){
        Message message = messageUtils.buildMessage(payload, keys);
        log.info("topic_" + topic + "  send msg: " + JSON.toJSONString(message));
        SendResult sendResult = template.syncSend(topic + SEPARATOR_COLON + tags, message);
        syncResultHandle(topic, payload, sendResult);
    }

    private void syncResultHandle(String topic, BasePayload payload, SendResult sendResult){
        log.info("topic_" + topic + "  response received msg: " + JSON.toJSONString(sendResult));
        if(SendStatus.SEND_OK != sendResult.getSendStatus()){
            stringRedisTemplate.opsForHash().put(MqConstant.TOPIC_PREFIX + topic, payload.getBizId(), JSON.toJSONString(payload));
        }
    }

    public void asyncSend(String topic, BasePayload payload){
        Message message = messageUtils.buildMessage(payload);
        log.info("topic_" + topic + "  send msg: " + JSON.toJSONString(message));
        template.asyncSend(topic, message, getSendCallback(topic, payload));
    }

    public void asyncSend(String topic, String keys, BasePayload payload){
        Message message = messageUtils.buildMessage(payload, keys);
        log.info("topic_" + topic + "  send msg: " + JSON.toJSONString(message));
        template.asyncSend(topic, message, getSendCallback(topic, payload));
    }

    public void asyncSend(String topic, String keys, String tags, BasePayload payload){
        Message message = messageUtils.buildMessage(payload, keys);
        log.info("topic_" + topic + "  send msg: " + JSON.toJSONString(message));
        template.asyncSend(topic + SEPARATOR_COLON + tags, messageUtils.buildMessage(payload, keys), getSendCallback(topic, payload));
    }

    private SendCallback getSendCallback(String topic, BasePayload payload){
        return new SendCallback() {
            @Override
            public void onSuccess(SendResult sendResult) {
                log.info("topic_" + topic + "  response received msg: " + JSON.toJSONString(sendResult));
            }

            @Override
            public void onException(Throwable e) {
                e.printStackTrace();
                stringRedisTemplate.opsForHash().put(MqConstant.TOPIC_PREFIX + topic, payload.getBizId(), JSON.toJSONString(payload));
            }
        };
    }

    public TransactionSendResult sendMessageInTransaction(String txProducerGroup, String topic, String keys, String tags, BasePayload payload, Object arg){
        String transactionId = IdWorker.get32UUID();
        Message message = messageUtils.buildTransactionMessage(payload, keys, transactionId);
        log.info("topic_" + topic + "  send transaction msg: " + JSON.toJSONString(message));
        String destination = topic;
        if(StringUtils.isNotBlank(tags)){
            destination = topic + SEPARATOR_COLON + tags;
        }
        TransactionSendResult transactionSendResult = template.sendMessageInTransaction(txProducerGroup, destination, message, arg);
        log.info("topic_" + topic + "  response received localTransactionState={}, transactionId={}, msg: {}", transactionSendResult.getLocalTransactionState().toString(), transactionId, JSON.toJSONString(transactionSendResult));
        return transactionSendResult;
    }

}

MqScheduleTaskTemplate

MqScheduleTaskTemplate: 生产者端定时任务模板,实现对在redis缓存中消息的重试。
@Slf4j
@Async
public abstract class MqScheduleTaskTemplate {
    /**
     * 需要检查的topic名称
     */
    public List<String> topicList = new ArrayList<>();
    /**
     * 默认加锁的key值
     */
    public String lockKey;
    /**
     * 默认加锁30分钟
     */
    public long LOCK_TIME = RedisConfig.DEFAULT_LOCK_EXPIRED_TIME;
    private static String MQ_TASK_LOCK_PREFIX = "mq_task_lock_";


    @Autowired
    private RedisLock redisLock;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Autowired
    private RocketMQTemplate template;

    public void failMessageRetry() {

        if(StringUtils.isBlank(lockKey)){
            throw new RuntimeException("please set lockKey value");
        }
        log.info("fail mq message retry start!");
        String value = IdWorker.getIdStr();
        boolean lock = redisLock.lock(MQ_TASK_LOCK_PREFIX + lockKey, value, LOCK_TIME, RedisConfig.DEFAULT_TRY_LOCK_TIMEOUT);
        if (!lock) {
            log.error("fail to get mqLockKey");
            return;
        }

        try {
            this.reTry();
        } catch (Exception e) {
            log.error("message retry error, errMsg:{}", e.getMessage(), e);
        } finally {
            redisLock.unlock(MQ_TASK_LOCK_PREFIX + lockKey, value);
        }
        log.info("fail mq message retry success!");
    }

    private void reTry() {
        for(String topic : topicList){
            List<Object> failMsgList = stringRedisTemplate.opsForHash().values(MqConstant.TOPIC_PREFIX +topic);
            log.info("fail mq message : " + JSON.toJSONString(failMsgList));
            if(!CollectionUtils.isEmpty(failMsgList)) {
                for(Object failMsg : failMsgList){
                    BasePayload basePayload = JSON.parseObject(failMsg.toString(), BasePayload.class);
                    SendResult sendResult = template.syncSend(topic, basePayload);
                    if(SendStatus.SEND_OK == sendResult.getSendStatus()){
                        stringRedisTemplate.opsForHash().delete(MqConstant.TOPIC_PREFIX +topic, basePayload.getBizId());
                    }
                }
            }
        }
    }

}

消费者端

RocketMQListenerAwareNew

RocketMQListenerAwareNew:对消费者端消息的幂等处理
@Slf4j
public abstract class RocketMQListenerAwareNew<E> implements RocketMQListener<MessageExt>, InitializingBean {

    private Class messageType;
    private final String charset = "UTF-8";

    @Autowired
    private MqKeyGenerator keyGenerator;
    @Autowired
    private RedisLock redisLock;

    @Override
    public void onMessage(MessageExt message) {

        Object resultObj = JSON.parseObject(new String(message.getBody(), Charset.forName(charset)), messageType);

        //多实例时实现消息幂等
        String bizId = ((BasePayload) resultObj).getBizId();
        String lockKey = keyGenerator.getConsumerLockKey(bizId);
        String value = IdWorker.getIdStr();

        boolean lock = redisLock.lock(lockKey, value, RedisConfig.DEFAULT_LOCK_EXPIRED_TIME, 0L);
        if(!lock){
            throw new DuplicateConsumptionException("Duplicate consumption: "+bizId);
        }
        try{
            consumerMessage((E) resultObj);
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            redisLock.unlock(lockKey, value);
        }
    }

    /**
     * 消费消息
     * 注意: 一定要自己实现消息的幂等,rocketMQ只保证消息一定会被投递,不能保证消息只被投递一次
     * 1、建议通过message keys实现 2、数据库对业务key设置唯一索引
     * @param message
     */
    public abstract void consumerMessage(E message);

    @Override
    public void afterPropertiesSet() {
        this.messageType = getMessageType();
        log.debug("RocketMQ messageType: {}", messageType.getName());
    }

    private Class getMessageType() {
        return (Class) ((ParameterizedType) this.getClass().getGenericSuperclass()).getActualTypeArguments()[0];
    }
}

生产者事务消息

RocketMQLocalTransactionListenerAware

/**
 * RocketMq本地事务监听器实现,监听一个txProducerGroup,不同的txProducerGroup需要自己实现不同的监听器
 * 实现了以下功能:
 * 1.MQ 在消息状态异常情况下对本地事务执行状态进行检查
 * 2.在同一producer获得新的全局唯一
 */
@Slf4j
public abstract class RocketMQLocalTransactionListenerAware<E> implements RocketMQLocalTransactionListener, InitializingBean{

    private Class messageType;

    @Autowired
    RedisTemplate<String, Object> redisTemplate;

    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {

        MessageHeaders headers = msg.getHeaders();
        String transId = (String) headers.get(RocketMQHeaders.TRANSACTION_ID);
        Object statusObj = redisTemplate.opsForValue().get(transId);
        int status = 0;
        if (statusObj != null) {
            status = Integer.parseInt(statusObj.toString());
        }

        RocketMQLocalTransactionState transactionState;
        switch (status) {
            case 1:
                transactionState = RocketMQLocalTransactionState.COMMIT;
                break;
            case 2:
                transactionState = RocketMQLocalTransactionState.ROLLBACK;
                break;
            default:
                transactionState = RocketMQLocalTransactionState.UNKNOWN;
                break;
        }
        log.info("--- The local transaction was executed once, transactionId={}, status={}, transactionState={} ---", transId, status, transactionState);
        return transactionState;
    }


    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(final Message msg, final Object arg) {
        MessageHeaders headers = msg.getHeaders();
        String transId = (String) headers.get(RocketMQHeaders.TRANSACTION_ID);

        try {
            //执行本地事务(业务逻辑代码)
            byte[] array  = (byte[])msg.getPayload();
            Object message = JSON.parseObject(new String(array), messageType);
            execute((E)message, arg);
            redisTemplate.opsForValue().set(transId, 1,30, TimeUnit.MINUTES);
            log.info("local transaction was successfully executed, transactionId={}, msg={}", transId, msg.getPayload());
            return RocketMQLocalTransactionState.COMMIT;

        } catch (Exception e) {
            log.error("execute local transactionId={}, error msg[{}]", transId, e.getMessage());
            redisTemplate.opsForValue().set(transId, 2,30, TimeUnit.MINUTES);
            return RocketMQLocalTransactionState.ROLLBACK;
        }

    }

    /**
     * 执行本地事务
     *
     * @param msg
     * @param arg
     */
    protected abstract void execute(final E msg, final Object arg);

    @Override
    public void afterPropertiesSet() {
        this.messageType = getMessageType();
        log.debug("RocketMQ messageType: {}", messageType.getName());
    }

    private Class getMessageType() {
        return (Class) ((ParameterizedType) this.getClass().getGenericSuperclass()).getActualTypeArguments()[0];
    }

}

 RedisLock

RedisLock:redis锁的简单实现
@Slf4j
public class RedisLock implements InitializingBean {

    private static final String LUA_LOCK = "  local key=KEYS[1] "
            + "local expireTime =tonumber(ARGV[2]) "
            + "local value = ARGV[1] "
            + "local result = tonumber(redis.call('setnx',key ,value)) "
            + "if result == 1 then "
            + "  redis.call('pexpire',key,expireTime) "
            + "end "
            + "return result";


    private static final String LUA_UNLOCK = " if redis.pcall('get', KEYS[1]) == ARGV[1] then "
            + " return redis.pcall('del', KEYS[1]) "
            + " else return 0 end";

    /*
     * *
     * 限流
     */
    private static final String LUA_PRECISE_LIMIT = " local timeNow = tonumber(ARGV[3]) "
            + " if tonumber(redis.call('llen',KEYS[1]))>= tonumber(ARGV[1]) then "
            + " local tiemOld = tonumber(redis.call('lpop',KEYS[1])) "
            + " redis.call('rpush',KEYS[1],timeNow) "
            + " if timeNow - tonumber(tiemOld) < tonumber(ARGV[2]) then "
            + "    return 0 "
            + " else "
            + "   return 1 "
            + " end "
            + " return 1"
            + " end "
            + " redis.call('rpush',KEYS[1],timeNow) "
            + " return 1 ";


    private static final String IP_RATE_LIMIT = " local key = KEYS[1] "
            + " local limit = tonumber(ARGV[1]) "
            + " local expireTime = ARGV[2] "
            + " local is_exists = redis.call('EXISTS', key) "
            + " if is_exists == 1 then "
            + "   if redis.call('INCR', key) > limit then "
            + "     return 0 "
            + "   else "
            + "     return 1 "
            + "   end "
            + " else "
            + "  redis.call('SET', key, 1) "
            + "  redis.call('EXPIRE', key, expireTime) "
            + "  return 1 "
            + " end ";


    private DefaultRedisScript<Long> lockRedisScript;

    private DefaultRedisScript<Long> unlockRedisScript;

    private DefaultRedisScript<Long> limitRedisScript;

    private DefaultRedisScript<Long> ipLimitRedisScript;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public void afterPropertiesSet() throws Exception {
        lockRedisScript = new DefaultRedisScript<Long>();
        lockRedisScript.setScriptText(LUA_LOCK);
        lockRedisScript.setResultType(Long.class);

        unlockRedisScript = new DefaultRedisScript<Long>();
        unlockRedisScript.setScriptText(LUA_UNLOCK);
        unlockRedisScript.setResultType(Long.class);

        limitRedisScript = new DefaultRedisScript<Long>();
        limitRedisScript.setScriptText(LUA_PRECISE_LIMIT);
        limitRedisScript.setResultType(Long.class);

        ipLimitRedisScript = new DefaultRedisScript<Long>();
        ipLimitRedisScript.setScriptText(IP_RATE_LIMIT);
        ipLimitRedisScript.setResultType(Long.class);

    }


    /**
     * 获取redis分布式锁
     **/

    public boolean lock(String key, String owner, long lockExpireTime, long tryLockTimeout) {
        long timestamp = System.currentTimeMillis();
        // 在超时之前,循环尝试拿锁
        while (tryLockTimeout == 0 || ((System.currentTimeMillis() - timestamp) < tryLockTimeout)) {
            Long result = redisTemplate.execute(new RedisCallback<Long>() {
                @Override
                public Long doInRedis(RedisConnection connection) throws DataAccessException {
                    Long evalResult = connection.eval(LUA_LOCK.getBytes(), ReturnType.INTEGER, 1, key.getBytes(), owner.getBytes(), String.valueOf(lockExpireTime).getBytes());
                    log.debug("lock eval lua result [{}] ", evalResult);
                    return evalResult;
                }

            });
            if (result == 1) {
                return true;
            } else {
                try {
                    // 获取锁失败,睡眠50毫秒继续重试(自旋锁)
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }

        return false;
    }


    /**
     * 释放redis分布式锁
     *
     * @param key   锁名
     * @param owner 锁的拥有者
     **/

    public void unlock(String key, String owner) {

        redisTemplate.execute(new RedisCallback<Long>() {
            @Override
            public Long doInRedis(RedisConnection connection) throws DataAccessException {
                Long evalResult = connection.eval(LUA_UNLOCK.getBytes(), ReturnType.INTEGER, 1, key.getBytes(), owner.getBytes());
                log.debug("unlock eval lua result [{}] ", evalResult);
                return evalResult;
            }
        });

    }


    /**
     * 限制接口在时间范围调用次数
     *
     * @param key
     * @param limit      访问次数
     * @param expireTime 时间范围(单位为毫秒)
     * @return boolean
     */
    @Deprecated
    public boolean limit(String key, long limit, long expireTime) {

        long result = redisTemplate.execute(new RedisCallback<Long>() {
            @Override
            public Long doInRedis(RedisConnection connection) throws DataAccessException {
                Long evalResult = connection.eval(LUA_PRECISE_LIMIT.getBytes(), ReturnType.INTEGER, 1, key.getBytes(), String.valueOf(limit).getBytes(), String.valueOf(expireTime).getBytes(), String.valueOf(System.currentTimeMillis()).getBytes());
                return evalResult;
            }
        });
        return result == 1;
    }


    /**
     * 限制接口在时间范围调用次数
     *
     * @param key
     * @param limit      访问次数
     * @param expireTime 时间范围(单位为毫秒)
     * @return boolean
     */
    public boolean ipLimit(String key, long limit, long expireTime) {

        long result = redisTemplate.execute(new RedisCallback<Long>() {
            @Override
            public Long doInRedis(RedisConnection connection) throws DataAccessException {
                Long evalResult = connection.eval(IP_RATE_LIMIT.getBytes(), ReturnType.INTEGER, 1, key.getBytes(), String.valueOf(limit).getBytes(), String.valueOf(expireTime).getBytes());
                log.debug("ipLimit eval lua result [{}] ", evalResult);
                return evalResult;
            }
        });
        return result == 1;
    }

}

至此,就是关于rocketmq的简单封装!!!

 

示例

一般不重要的消息,如日志消息,直接使用单向发送就可以了。封装后使用示例:

生产者端:

第一步:注入RocketMQProductUtils

第二步:调用相关封装方法 

第三步:启用定时任务去检查redis,并将失败消息进行重试

以下这些值都可以被自定义

 

 

 消费者端:

消费消息只需要继承 RocketMQListenerAwareNew 类,并实现 一下方法就行:

示例:

生产者端事务消息

第一步:调用  sendMessageInTransaction()方法,这里需要定义一个事务生产者组

productUtils.sendMessageInTransaction("test_rule_group", "topic_rule", ruleCode, "111TAG", ruleMsg, ruleRequest);

第二步:继承 RocketMQLocalTransactionListenerAware 类, 并指定与上面同样的事务生产者组,再在其中执行自己的业务方法,注意:需要保证自己的业务方法有事务

 

以上所有代码都经过测试,请放心使用。如果觉得有帮助的话,请点个赞再走,你的鼓励是对我最大的支持!!!

  • 6
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值