基于RocketMQ的项目高可用优化改造

1. 项目优化改造背景

1.1 项目业务流转现状

原来的项目业务流转基于调度任务触发,调度服务器单点,具体的执行依托于调度统一处理平台。这样的架构主要弊端如下:

  • 调度处理的机制导致项目瞬时处理数据过大;
  • 在没有实现分布式调度的情况下,单点服务器处理,业务吞吐量过少,完全受制于数据库的处理;
  • 业务流转强依赖调度平台;
    在这里插入图片描述

1.2 改造后的总体业务处理架构

基于RocketMQ消息处理中间件,完成业务模块内或者跨模块的业务流转处理,提升业务总体处理吞吐量,实现高可用的总体架构。
在这里插入图片描述

2. RocketMQ的发送端封装

RocketMQ的消息发送接口DefaultMQProducer提供了完善的同步/异步处理机制,项目中主要配置如下参数:

  • Name Server的地址(namesrvAddr);
  • 项目消息群组(producerGroup);
  • 异常消息是否进行Broker的迁移(retryAnotherBrokerWhenNotStoreOK);
  • 异常消息的重试次数(retryTimesWhenSendAsyncFailed);

2.1 RocketMQ消息序列化

在项目整体的微服务架构下,考虑到不同模块间和模块内部的消息处理,DefaultMQProducer消息内容使用字节数组,不能实现跨模块的消息内容处理,有鉴于此,需要对消息的内容进行约定的统一序列化封装。

2.1.1 消息载体基础类

/**
 * 业务消息的容器类
 *
 * <p>
 * 该类主要用于集成消息驱动,屏蔽业务对象的差异。<br>
 *
 * </p>
 * @param <T> 该参数{@code bizMsgObj} 承载业务消息对象的数据
 */
public class BaseMQBO<T> {

    private T bizMsgObj;

    public T getBizMsgObj() {
        return bizMsgObj;
    }

    public void setBizMsgObj(T bizMsgObj) {
        this.bizMsgObj = bizMsgObj;
    }
}

2.1.2 MQ消息的序列化和反序列化

import com.alibaba.fastjson.JSON;

public class JSONUtils {

	/**
	 * 基于阿里巴巴的组件,把对象转化为JSON字符串 
	 * @param obj
	 * @return
	 */
	public static String toJson( Object obj ) {
		return JSON.toJSONString( obj );
	}

    /**
     * 根据对象的引用类型,反序列化为业务对象
     *
     * <p>
     * 根据业务数据的字节流和类型引用的泛型,反序列化出该业务数据的对象。
     * 用法:反序列化对象{@code BaseMQBO<ClassA>}
     * <pre>
     *          JSONUtils.toObject(msgBody, new TypeReference<BaseMQBO<ClassA>>() {});
     *     </pre>
     * </p>
     *
     * @param bytes         序列化的业务对象字节数组
     * @param typeReference 被序列化的对象类型引用对象
     * @param <T>           被序列化的对象类型泛型
     * @return 反序列化后的业务对象
     */
    public static <T> T toObject(byte[] bytes, TypeReference<T> typeReference) {
        try {
            ObjectMapper objectMapper = new ObjectMapper();
            return bytes != null ? (T) objectMapper.readValue(bytes, 0, bytes.length, typeReference) : null;
        } catch (Exception var4) {
            throw new RuntimeException("Could not read JSON: " + var4.getMessage(), var4);
        }
    }
}

2.2 RocketMQ消息发送

RocketMQ的消息发送,主要需要完成消息的发送端配置,统一发送端的消息处理接口,业务端调用统一的消息发送接口方法。

2.2.1 RocketMQ发送端配置

    <!--配置RocketMQ消息生产BEAN-->
    <bean id="bizMessageProducer" class="com.XXXX.framework.message.producer.YYYYMQProducer">
        <!-- Name Server地址-->
        <property name="namesrvAddr" value="${rocketmq.namesrvAddr}"/>
        <!-- 消息发布群组名称-->
        <property name="producerGroup" value="${rocketmq.producerGroup}"/>
        <!-- 异常消息是否跨Broker处理-->
        <property name="retryAnotherBrokerWhenNotStoreOK" value="true"/>
    </bean>

2.2.2 统一发送端的消息处理接口

考虑到DefaultMQProducer提供了多样化的消息发送接口方法, 而实际项目中,为了统一接口调用方式,只需要提供符合项目实际的接口接口。

/**
 * 系统消息驱动发送端触发类
 *
 * <p>
 * <pre>
 *     
 *     对系统开放两个消息发送的接口
 *     <ul>
 *         <li>严格顺序消息的发送</li>
 *         <li>普通顺序消息的发送</li>
 *     </ul>
 *     如果系统功能需要保证消息的严谨发布和消费的序列,则调用{@code YYYYDefaultMQProducer#sendOnSequence(String , BaseMQBO<T>)};
 *     正常情况下可以保证完全的顺序消息,但是一旦发生通信异常,Broker重启,由于队列总数发生变化,哈希取模后定位的队列会变化,产生短暂的消息顺序不一致。
 *   如果业务能够容忍在集群异常(如某个Broker宕机或者重启)下,消息短暂乱序,使用普通顺序方式比较合适。
 *
 *     如果系统功能需要保证消息的无需保证服务器宕机的情况下的序列严谨性,则调用{@code YYYYDefaultMQProducer#send(String , BaseMQBO<T>)};
 *     无论正常异常都保证顺序,但是牺牲了分布式Failover特性,即Broker集群中只要有一台机器不可用,则整个集群都不可用,服务可用性大大降低。
 *
 *     {@link com.alibaba.rocketmq.client.producer.DefaultMQProducer}#retryTimesWhenSendFailed + 1,默认retryTimesWhenSendFailed是2,所以除了正常调用一次外,发送消息如果失败了会重试2次。
 *     总共会发送3次,如果3次都失败则返回发送失败的消息。
 *
 *     【注意】:业务系统大部分场景,只需要调用{@code YYYYDefaultMQProducer#send(String , BaseMQBO<T>)}
 *     </pre>
 * </p>
 */
@Component("yyyyDefaultMQProducer")
public class YYYYDefaultMQProducer implements InitializingBean {

    @Autowired
    @Qualifier("bizMessageProducer")
    private YYYYMQProducer messageProducer;

    @Value("${rocketmq.exp.YYYY_MQ_EXCEPTION_SWITCH}")
    private Integer exceptionSwitch;

    @Autowired
    private YYYYMQExceptionHandler mqExceptionHandler;

    /**
     * 严格顺序消息的发送
     *
     * @param messageTopic 消息队列的topic
     * @param messageBody  业务对象消息
     * @param <T>          业务对象泛型 该泛型对象必须实现序列化{@link java.io.Serializable}
     * @return 消息发送的状态{@link MQConstants.MQSendStatus}#SEND_SUCCESS 则成功;
     * {@link MQConstants.MQSendStatus#SEND_FAIL} 则失败;
     */
    public <T> int sendOnSequence(String messageTopic, BaseMQBO<T> messageBody) {
        int sendResult = MQConstants.MQSendStatus.SEND_FAIL;
        SendResult sr = null;
        String msg = null;
        try {
            if (StringUtils.isBlank(messageTopic) || messageBody == null) {
                throw new IllegalArgumentException("消息Topic不能为空");
            }
            msg = JSONUtils.toJson(messageBody);
            if (StringUtils.isBlank(msg)) {
                throw new IllegalArgumentException("消息内容不能为空");
            }
            logger.info("开始发送消息:{}-{}", messageTopic, msg);
            sr = messageProducer.send(new Message(messageTopic, msg.getBytes()), new MessageQueueSelector() {
                @Override
                public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
                    Integer id = arg.hashCode();
                    int index = id % mqs.size();
                    return mqs.get(index);
                }
            }, 1);

            sendResult = sr.getSendStatus().equals(SendStatus.SEND_OK) ? MQConstants.MQSendStatus.SEND_SUCCESS : MQConstants.MQSendStatus.SEND_FAIL;
            logger.info("开始发送消息完成:{}-{}-{}", messageTopic, msg, sendResult);
        } catch (MQClientException | RemotingException | MQBrokerException | InterruptedException e) {
            logger.error(String.format("发送%s严格顺序消息发送MQ内部异常,投递失败.", messageTopic), e);
            sendResult = MQConstants.MQSendStatus.SEND_FAIL;
        } catch (Exception e) {
            logger.error(String.format("发送%s严格顺序消息失败.", messageTopic), e);
            sendResult = MQConstants.MQSendStatus.SEND_FAIL;
        }

        //如果消息发送失败并且消息异常开关开启,则做异常消息处理
        if (sendResult == MQConstants.MQSendStatus.SEND_FAIL
                && this.exceptionSwitch == MQConstants.MessageExceptionSwitch.OPEN
                && StringUtils.isNotBlank(msg)) {
            logger.info("发送消息异常:{}-{}", messageTopic, msg);
            MQExceptionHandler.saveMQException(messageTopic, msg);
        }

        return sendResult;
    }

    /**
     * 普通顺序消息的发送
     *
     * @param messageTopic 消息队列的topic
     * @param messageBody  业务对象消息
     * @param <T>          业务对象泛型 该泛型对象必须实现序列化{@link java.io.Serializable}
     * @return 消息发送的状态{@code MQConstants.MQSendStatus#SEND_SUCCESS} 则成功;
     * {@code MQConstants.MQSendStatus#SEND_FAIL} 则失败;
     */
    public <T> int send(String messageTopic, BaseMQBO<T> messageBody) {
        int sendResult = MQConstants.MQSendStatus.SEND_FAIL;
        String msg = null;
        try {
            if (StringUtils.isBlank(messageTopic) || messageBody == null) {
                throw new IllegalArgumentException("消息Topic不能为空");
            }
            msg = JSONUtils.toJson(messageBody);
            if (StringUtils.isBlank(msg)) {
                throw new IllegalArgumentException("消息内容不能为空");
            }
            logger.info("开始发送消息:{}-{}", messageTopic, msg);
            SendResult sr = messageProducer.send(new Message(messageTopic, msg.getBytes()));
            sendResult = sr.getSendStatus().equals(SendStatus.SEND_OK) ? MQConstants.MQSendStatus.SEND_SUCCESS : MQConstants.MQSendStatus.SEND_FAIL;
            logger.info("开始发送消息完成:{}-{}-{}", messageTopic, msg, sendResult);
        } catch (MQClientException | RemotingException | MQBrokerException | InterruptedException e) {
            logger.error(String.format("发送%s普通顺序消息发送MQ内部异常,投递失败.", messageTopic), e);
            sendResult = MQConstants.MQSendStatus.SEND_FAIL;
        } catch (Exception e) {
            logger.error(String.format("发送%s普通顺序消息发送异常,投递失败.", messageTopic), e);
            sendResult = MQConstants.MQSendStatus.SEND_FAIL;
        }

        //如果消息发送失败并且消息异常开关开启,则做异常消息处理
        if (sendResult == MQConstants.MQSendStatus.SEND_FAIL
                && this.exceptionSwitch == MQConstants.MessageExceptionSwitch.OPEN
                && StringUtils.isNotBlank(msg)) {
            logger.info("发送消息异常:{}-{}", messageTopic, msg);
            MQExceptionHandler.saveMQException(messageTopic, msg);
        }

        return sendResult;
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        if (this.exceptionSwitch == null || this.exceptionSwitch == 0) {
            this.exceptionSwitch = MQConstants.MessageExceptionSwitch.OPEN;
        }
    }
}

2.3.1 业务端调用统一的消息发送接口方法

主要基于公共消息发布接口,完成消息的组装发布。

//注入消息发布接口和消息TOPIC
@Autowired
public YYYYDefaultMQProducer yyyyDefaultMQProducer;

@Value("${ZZZZZ_TOPIC}")
private String paySendMessageTopic;

//发布消息
/**
 * 发送业务消息
 * @param ZZZZZBO
 */
public void sendMsg(ZZZZZBO zzzzzBO){
	BaseMQBO<String> messageBody = new BaseMQBO<>();
	messageBody.setBizMsgObj(zzzzzBO.getId());
	yyyyDefaultMQProducer.send(messageTopic,messageBody );
}

3. RocketMQ的消费端封装

RocketMQ消费端,主要完成 三件事情:

  • 基于不同的业务背景,用统一的消息Handler选择器完成不同消息TOPIC的处理器指派;
  • 基于不同TOPIC的消息处理层接口
  • 具体业务的消息消费接口和处理实现

3.1 统一的消息分发器

在项目启动的时候,利用org.springframework.beans.factory.InitializingBean完成不同topic的消息处理器的项目登记注入。

统一消息处理器的配置

    <bean id="defaultMQPushConsumerMessageSelector"
          class="com.zzzz.xxxx.yyyyy.DefaultMQPushConsumerMessageSelector">
        <property name="namesrvAddr" value="${rocketmq.namesrvAddr}"/>
        <!--消费组名称-->
        <property name="consumerGroup" value="${rocketmq.producerGroup}"/>
        <!-- 消费模式:BROADCASTING、CLUSTERING,一个消费组只能使用一种消费模式 -->
        <!-- 要同时消费广播队列和非广播队列,请配置两个DefaultMQPushConsumerMessageSelector,并使用不同的消费组名称 -->
        <property name="messageModel" value="CLUSTERING"/>
        <!-- 消费顺序:CONCURRENTLY、ORDERLY -->
        <property name="messageListener" value="CONCURRENTLY"/>
        <property name="handlermap">
            <map>
                <!-- PAY -->
                <!-- 付款第一步:付款计划接口表转业务表-->
                <entry key="${rocketmq.topics.BIZ_kkkk_topic}" value-ref="kkkkkAPIHandler"></entry>                
            </map>
        </property>
    </bean>

统一的消息分发处理器试下:

public class DefaultMQPushConsumerMessageSelector implements MQConsumerMessageSelector, InitializingBean {
    private static Logger logger = LoggerFactory.getLogger(DefaultMQPushConsumerMessageSelector.class);
    private String namesrvAddr;
    private String consumerGroup;
    private String messageModel;
    private String messageListener;
    private Map<String, MQMessageHandler> handlermap = new HashMap();
    private DefaultMQPushConsumer consumer;

    public DefaultMQPushConsumerMessageSelector() {
    }

    private void initializingMessageSelector() throws InterruptedException, MQClientException {
        this.consumer = new DefaultMQPushConsumer();
        if (this.consumerGroup != null && this.consumerGroup.trim().length() > 0) {
            this.consumer.setConsumerGroup(this.consumerGroup);
            logger.debug("set consumer group " + this.consumerGroup);
        }

        this.consumer.setNamesrvAddr(this.namesrvAddr);
        this.consumer.setConsumeMessageBatchMaxSize(1);
        logger.debug("set consumer name server address " + this.namesrvAddr);
        logger.debug("set consumer message batch max size 1");
        if ("BROADCASTING".equals(this.messageModel)) {
            this.consumer.setMessageModel(MessageModel.BROADCASTING);
            logger.debug("set consumer message model BROADCASTING");
        } else {
            if (!"CLUSTERING".equals(this.messageModel)) {
                logger.debug("set consumer message model should be BROADCASTING or CLUSTERING");
                throw new RuntimeException("set consumer message model should be BROADCASTING or CLUSTERING");
            }

            this.consumer.setMessageModel(MessageModel.CLUSTERING);
            logger.debug("set consumer message model CLUSTERING");
        }

        if (this.handlermap != null && !this.handlermap.isEmpty()) {
            Iterator var1 = this.handlermap.keySet().iterator();

            while(var1.hasNext()) {
                String topic = (String)var1.next();
                this.consumer.subscribe(topic, "*");
                logger.debug("consumer subscribe topic " + topic + " *");
            }

            this.consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
            if ("CONCURRENTLY".equals(this.messageListener)) {
                this.consumer.registerMessageListener(new MessageListenerConcurrently() {
                    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                        try {
                            MessageExt msg;
                            if (msgs != null && !msgs.isEmpty()) {
                                for(Iterator var3 = msgs.iterator(); var3.hasNext(); DefaultMQPushConsumerMessageSelector.logger.debug(String.format("consume message success! message:id:%s topic:%s tags:%s message:%s", msg.getMsgId(), msg.getTopic(), msg.getTags(), new String(msg.getBody())))) {
                                    msg = (MessageExt)var3.next();
                                    DefaultMQPushConsumerMessageSelector.logger.debug(String.format("start consum message: message:id:%s topic:%s tags:%s message:%s", msg.getMsgId(), msg.getTopic(), msg.getTags(), new String(msg.getBody())));
                                    MQMessageHandler handler = (MQMessageHandler)DefaultMQPushConsumerMessageSelector.this.handlermap.get(msg.getTopic());
                                    if (handler != null) {
                                        handler.handlerMessage(msg);
                                    }
                                }
                            }

                            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                        } catch (Exception var6) {
                            DefaultMQPushConsumerMessageSelector.logger.error("consume message error!", var6);
                            return ConsumeConcurrentlyStatus.RECONSUME_LATER;
                        }
                    }
                });
            } else if ("ORDERLY".equals(this.messageListener)) {
                this.consumer.registerMessageListener(new MessageListenerOrderly() {
                    public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
                        try {
                            MessageExt msg;
                            if (msgs != null && !msgs.isEmpty()) {
                                for(Iterator var3 = msgs.iterator(); var3.hasNext(); DefaultMQPushConsumerMessageSelector.logger.debug(String.format("consume message success! message:id:%s topic:%s tags:%s message:%s", msg.getMsgId(), msg.getTopic(), msg.getTags(), new String(msg.getBody())))) {
                                    msg = (MessageExt)var3.next();
                                    DefaultMQPushConsumerMessageSelector.logger.debug(String.format("start consum message: message:id:%s topic:%s tags:%s message:%s", msg.getMsgId(), msg.getTopic(), msg.getTags(), new String(msg.getBody())));
                                    MQMessageHandler handler = (MQMessageHandler)DefaultMQPushConsumerMessageSelector.this.handlermap.get(msg.getTopic());
                                    if (handler != null) {
                                        handler.handlerMessage(msg);
                                    }
                                }
                            }

                            return ConsumeOrderlyStatus.SUCCESS;
                        } catch (Exception var6) {
                            DefaultMQPushConsumerMessageSelector.logger.error("consume message error!", var6);
                            return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
                        }
                    }
                });
            }

            this.consumer.start();
            logger.debug("consumer start successd!");
        } else {
            logger.debug("you should provide at least one message handler.");
            throw new RuntimeException("you should provide at least one message handler.");
        }
    }

    public void afterPropertiesSet() throws Exception {
        this.initializingMessageSelector();
    }

3.2 基于不同TOPIC的消息处理层接口

为了把消息处理跟业务层解耦,利用SpringUtil.getBean(“bizBeanName”)的方式,完成具体业务消息处理的指派。

3.2.1 统一的消息消费接口

所有的业务逻辑,均基于统一的接口进行消息的消费。

package com.xxx.yyy.zzzz.mq.facade;

/**
 * 系统消息消费处理公共接口
 *
 * <p>
 * 该接口需要在各个模块中做代理实现
 * </p>
 *
 */
public interface AsyncMQHandleService {

    /**
     * 处理业务消息
     *
     * <p>
     * <pre>
     *     如果业务模块的字段由多个字段组成,建议使用{@com.xxx.yyy.zzz.mq.common.constant.MQConstants}.{@code SEPARATOR_MSG}
     *     e.g. msgId : bizNo-version
     *     </pre>
     * </p>
     *
     * @param msgId 业务对象唯一消息ID
     * @return true: 消息处理成功; false: 消息处理失败
     */
    public Boolean handle(String msgId);
}

3.2.2 消息层对业务层的消息指派

如上所述,利用SpringUtil.getBean(“bizBeanName”)的方式,完成具体业务处理层的调用。

@Component("YYYYBizAPIHandler")
public class YYYYBizAPIHandler implements MQMessageHandler {

    private static Logger logger = LoggerFactory.getLogger(YYYYBizAPIHandler.class);

    /**
     * 消息处理逻辑主体
     *
     * <p>
     * 消息内容: biz_no|version
     * </p>
     *
     * @param messageExt 返回的消息内容
     * @throws Exception
     * @author chenyz18
     */
    @Override
    public void handlerMessage(MessageExt messageExt) throws Exception {
        if (messageExt == null) {
            throw new MQException("消息内容为空");
        }
        BaseMQBO<String> messageBody = JSONUtils.toObject(messageExt.getBody(), new TypeReference<BaseMQBO<String>>() {
        });
        String message = messageBody.getBizMsgObj();

        logger.info( "YYYYBizAPIHandler.handlerMessage:{} begin",message );

        AsyncMQHandleService aysncMQHandleService = (AsyncMQHandleService) SpringUtil.getBean( "yyyyyBizService" );
        Boolean result = aysncMQHandleService.handle(message);
        if(!result){
            throw new MQException(String.format("业务接口数据消息:%s处理失败", message));
        }
        logger.info( "YYYYBizAPIHandler.handlerMessage:{} end",message );
    }
}

3.2.3 具体业务的消息消费接口和处理实现

@Service("yyyyyBizService")
public class YYYYYBizServiceImpl implements YYYYBizService,AsyncMQHandleService {
    /**
     * 业务消息处理实现
     * @param msgId 业务对象唯一消息ID
     */
    @Override
    public Boolean handle(String msgId) {
        LOGGER.info("处理业务接口消息ID:{}", msgId);
        String[] messageKey = msgId.split(MQConstants.SEPARATOR_MSG);
        if (messageKey == null || messageKey.length !=2) {
            throw ExceptionFactory.wrapException(GFPServiceException.class, ExcepCode.YYYY.ZZZZ_EXP_CODE, "业务消息无效");
        }
        String bizNo = messageKey[0];
        String version = messageKey[1];
        return this.doYYYYYMsg( bizNo, version);
    }

    /**
     * 具体业务逻辑的校验,和消息消费
     * 
     * @param bizNo 业务编号
     * @param version 业务版本号
     * @return Boolean true:处理成功; false:处理失败
     */
    public Boolean doYYYYYMsg(String bizNo, String version) {
         //完成具体的业务校验和消息消费实现
    }
}

4. RocketMQ消息服务器

RocketMQ官方提供的消息控制台,可以让各个微服务的项目模块,跟踪各自项目的消息发布和消费情况。限于篇幅和能力,主要介绍两个片段。

4.1 消息TOPIC的控制台管理

查看并跟踪总体的项目消息情况

4.1.1 选择待跟踪的消息TOPIC

检索不同TOPIC的消息在这里插入图片描述

4.1.2 消息TOPIC消费情况的总体跟踪

默认,RocketMQ会基于基础的消费者机器集群,分步到四个queue中(可配置);
正常情况下,消息Delay条数应该为0, 同时消息的brokerOffset和consumerOffset应该一致,即消息的发布条数跟消息的消费条数一致。
在这里插入图片描述

4.2 Message控制台管理

4.2.1 基于不同的消息TOPIC和时间段,查找业务消息

在这里插入图片描述

4.2.2 具体消息的消费查看和重发

在这里插入图片描述

5. 总结

在项目完成改造后,其价值主要体现在如下方面:

  • 业务吞吐量提升
    项目从原来的基于调度触发,升级为基于消息驱动的改造,使得业务处理均匀的分步到不同的时间片中,大大的提高了业务的响应速度;
  • 系统高可用实现
    原项目调度服务器没有实现分布式改造调度的情况下,处于单点的状态,且强依赖调度平台,无法实现高可用的项目实际需求;
    平台消息驱动改造后,可以把消息分摊到不同的broker, 并配置实现不同broker的异常消息迁移;
  • 系统兼容性提升
    平台的消息或者业务对象统一序列化后,可以实现不同微服务模块间的消息互通,实现项目的高移植性。

【注意】博文中的代码均为伪代码,只供参考和学习交流之用。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值