MQTT协议

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-integration</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.integration</groupId>
    <artifactId>spring-integration-stream</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.integration</groupId>
    <artifactId>spring-integration-mqtt</artifactId>
</dependency>


(一)MQTT协议配置属性

@Component
@ConfigurationProperties(prefix = "spring.mqtt")
public class MqttProperties {

    private String username = "source";

    private String password = "link";

    private String hostUrl = "tcp://XXXX.XXXX.XXXX.XXXX:端口号";

    private String clientId = "consumerClient";

    private String defaultTopic = "monitor";
    /**监测上报主题*/
    private String upTopic = "monitorUp";
    /**下发的主题*/
    private String downTopic = "displayDown";

(二)MQTT协议的配置类

@Configuration
@IntegrationComponentScan
@Slf4j
public class MqttConfig {

    @Autowired
    private MqttProperties mqttProperties;

    @Bean
    public MqttPahoClientFactory mqttClientFactory() {
        DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory();
        factory.setUserName(mqttProperties.getUsername());
        factory.setPassword(mqttProperties.getPassword());
        factory.setServerURIs(new String[]{mqttProperties.getHostUrl()});
        log.info("mqtt服务器地址:{}", mqttProperties.getHostUrl());
        return factory;
    }

    @Bean
    public MessageProducer inbound() {

        log.info("上报的主题:{}", Arrays.asList(mqttProperties.getUpTopic().split(",")));
        MqttPahoMessageDrivenChannelAdapter adapter = new
                MqttPahoMessageDrivenChannelAdapter(
                        mqttProperties.getClientId()+new Random().nextInt(),
                        mqttClientFactory(),
                        mqttProperties.getUpTopic().split(","));
        adapter.setCompletionTimeout(5000);
        adapter.setConverter(new DefaultPahoMessageConverter());
        adapter.setQos(1);
        adapter.setOutputChannel(mqttInputChannel());
        return adapter;
    }
    /**
     * 发送消息和消费消息Channel可以使用相同MqttPahoClientFactory
     * @return
     */
    @Bean
    @ServiceActivator(inputChannel = "mqttOutboundChannel")
    public MessageHandler outbound() {
        // 在这里进行mqttOutboundChannel的相关设置
        MqttPahoMessageHandler messageHandler =
                new MqttPahoMessageHandler("publishClient", mqttClientFactory());
        //如果设置成true,发送消息时将不会阻塞。
        messageHandler.setAsync(true);
        messageHandler.setDefaultTopic(mqttProperties.getDownTopic());
        log.info("下报的主题:{}", mqttProperties.getDownTopic());
        return messageHandler;
    }

    @Bean
    public MessageChannel mqttInputChannel() {
        return new DirectChannel();
    }

    @Bean
    public MessageChannel mqttOutboundChannel() {
        return new DirectChannel();
    }

(三)用于发送基于MQTT协议的消息服务类

@MessagingGateway(defaultRequestChannel = "mqttOutboundChannel")
public interface MqttSendMessageService {

    /**
     *  用于发送 基于主题的消息
     * @Date 2019/10/14 16:08
     * @param topic 主题
     * @param payload 真实的消息负载
     * @throws
     * @return
     */
    void sendToMqtt(@Header(MqttHeaders.TOPIC) String topic, String payload);

(四)处理上报消息服务类

@Service
@Slf4j
public class MqttAcceptMessageServiceImpl implements MqttAcceptMessageService {

     @Autowired
     private MqttProperties mqttProperties;

     @Autowired
     private MqttDao mqttDao;

    /**
     * ServiceActivator注解表明当前方法用于处理MQTT消息,
     * inputChannel参数指定了用于接收消息信息的channel
     * 处理所有接收到的MQTT消息
     *
     * */
    @Bean
    @ServiceActivator(inputChannel = "mqttInputChannel")
    public MessageHandler handlerInput() {
        return new MessageHandler() {
            @Override
            public void handleMessage(Message<?> message) throws MessagingException {
                log.info("接收的消息:{}" ,message);
                if (!ObjectUtils.isEmpty(message)) {
                    String topic = message.getHeaders().get("mqtt_topic").toString();
                    final  boolean isMonitorUp = mqttProperties.getUpTopic().equals(topic);
                    if (isMonitorUp) {
                        parseMonitorUpPayLoad(message.getPayload().toString());
                    }
                }

            }
        };
    }
    /**
     *  根据上报负荷信息解析监测对象信息 并且插入数据
     * @Date 2019/10/17 14:43
     * @param payload 真实的上报的负荷数据
     * @throws
     * @return
     */
    private void parseMonitorUpPayLoad(String payload) {
        try {
               MqttFrame frame = FrameUtil.parseFrame(payload.getBytes());
               insertMonitorObjects(frame.getImei(),frame.getMonitorObjects());
        } catch (ParseFrameException e) {
            log.error("解析MQTT协议出错", e);
            throw new BussinessException(BizExceptionEnum.MQTT_PARSE_ERROR);
        } catch (Exception e) {
            log.error("插入上报数值出错", e);
            throw new BussinessException(BizExceptionEnum.MQTT_ACCEPT_INSERT_ERROR);
        }
    }

    /**
     *  插入上报的监测对象的数据
     * @Date 2019/10/21 8:41
     * @param imei 设备唯一标识ID
     * @param objectList 上报的监测对象列表
     * @throws
     * @return
     */
    @Transactional(rollbackFor = BussinessException.class)
    private void insertMonitorObjects(String imei, List<MonitorObject> objectList) {
        if (!CollectionUtils.isEmpty(objectList)) {
            for (MonitorObject monitorObject : objectList) {
                insertMonitor(imei, monitorObject);
            }
        }
    }
    /**
     *  根据设备唯一标识IMEi 插入上报的数据
     * @Date 2019/10/17 16:23
     * @param imei 设备唯一标识
     * @param monitorObject 上报数据对象
     * @throws
     * @return
     */
    private void insertMonitor(String imei, MonitorObject monitorObject) {
        Byte id = monitorObject.getId();
        String value = monitorObject.getValue();
        ObjectType type = ObjectType.valueOf(id);
        if (!ObjectType.isNone(type) && !StringUtils.isEmpty(imei)) {
            String quotaName = type.getName();
            Long quotaId = mqttDao.getQuotaId(quotaName);
            MMonitorPlace place = mqttDao.getMonitorPlace(imei);
            if (quotaId != null && place != null) {
                insertDeviceData(quotaId, place.getId(), place.getAreaId(), value);
            }
            /**判断设备监测点是否是正常*/
            if (place != null && !DeviceStatus.isNormal(place.getStatus())) {
                place.setStatus(DeviceStatus.NORMAL.getCode());
                place.setModifiedTime(new Date());
                place.updateById();
            }
            /**判断发布屏是否正常 监测点的采集设备和发布屏显示设备IMEI是一致的*/
            MScreen screen = mqttDao.getScreen(imei);
            if (screen != null && !DeviceStatus.isNormal(screen.getStatus())) {
                screen.setStatus(DeviceStatus.NORMAL.getCode());
                screen.setModifiedTime(new Date());
                screen.updateById();
            }
        }
    }

    /**
     *  插入上报的数据
     * @Date 2019/10/18 8:35
     * @param quotaId 指标ID
     * @param placeId 监测点ID
     * @param areaId 景区ID
     * @param value 上报数据
     * @throws
     * @return
     */
    private static void insertDeviceData(Long quotaId, Long placeId, Long areaId, String value) {
        MDeviceData data = new MDeviceData();
        data.setQuotaId(quotaId);
        data.setPlaceId(placeId);
        data.setAreaId(areaId);
        data.setData(new BigDecimal(value));
        data.setCreatedTime(new Date());
        data.setModifiedTime(new Date());
        data.setIsDeleted(new Integer(0));
        data.insert();
    }

    /**
     * 检查上报的数据是否满足配置的指标范围之内
     * @Date 2019/11/14 9:01
     * @param quotaId 指标ID
     * @param value 上报的数据
     * @throws
     * @return
     */
    private void checkQuotaValueRange(Long quotaId, String value) {

    }

(五)MQTT协议的帧处理工具类

@Slf4j
public class FrameUtil {

    /**最小解析帧长度*/
    private final static int MIN_PARSE_ARRAY_LENGTH = 24;
    /**上报的监测对象占比字节数*/
    private final static int MONITOR_OBJECT_BYTE_SIZE = 9;
    /**
     * @Description 获取帧的组成的字节数组
     *  帧头  0x7a7a7a7a(4byte)
     * 设备IMEI(15byte)
     * 对象序列号(1byte)
     * 对象值分为2类 数值类型和文本类型 上报只有数值类型,下发才有数值类型和文本类型
     * 数值类型为 8个字节 一个监测对象上报占9个字节
     * 对象序列号结束符(1byte, 0x7e,对象结束)
     * 帧尾  0x61616161 (4byte)
     * @Date 2019/10/16 10:55
     * @param frame 帧对象
     * @throws
     * @return
     */
    public static byte[] getFrameBytes(MqttFrame frame) {
        if (frame == null) {
            return new byte[0];
        }
        /**帧头*/
        byte[] top = getTopBytes(frame.getTop());
        /**IMEI 设备唯一标识*/
        byte[] imei = getImeiBytes(frame.getImei());
        /**监测对象列表 包括 数值类型 和 文本类型*/
        byte[] monitorObjects = getMonitorObjectBytes(frame.getMonitorObjects());
        /**对象结束符 默认是 单字节最大值 =126*/
        byte[] objectEnd = getObjectEndBytes(frame.getObjectEnd());
        /**帧尾*/
        byte[] tail = getTailBytes(frame.getTail());
        List<byte[]> frameByteList = new ArrayList<>();
        frameByteList.add(top);
        frameByteList.add(imei);
        if (!ArrayUtils.isEmpty(monitorObjects)) {
            frameByteList.add(monitorObjects);
        }
        frameByteList.add(objectEnd);
        frameByteList.add(tail);
        return ByteUtil.getBytes(frameByteList);
    }

    /**
     * @Description 获取 监测对象列表 转成 字节数组
     * @Date 2019/10/16 13:40
     * @param objects 监测对象列表
     * @throws
     * @return
     */
    private static byte[] getMonitorObjectBytes(List<MonitorObject> objects) {
        if (CollectionUtils.isEmpty(objects)) {
            return new byte[0];
        }
        List<byte[]> monitorBytes = new ArrayList<>();
        for (MonitorObject object : objects) {
            Byte id = object.getId();
            String value = object.getValue();
            /**判断监测对象类型是文本还是数值类型*/
            final boolean isText = ObjectType.isText(id);
            if (isText) {
                /**文本类型并且需要排除空文本*/
                final boolean isEmptyText = StringUtils.isEmpty(value);
                if (!isEmptyText) {
                    monitorBytes.add(getTextMonitorObjectBytes(ByteUtil.getByteBytes(id), value));
                }
            } else { /**数值类型*/
                monitorBytes.add(getValueMonitorObjectBytes(ByteUtil.getByteBytes(id), value));
            }
        }//end for
        return ByteUtil.getBytes(monitorBytes);
    }
    /**
     * @Description 将数值类型的监测对象 转化为 字节数组
     * 策略 是 监测对象数组 + 数值字节数组(8个字节)=9个字节
     * @Date 2019/10/16 15:21
     * @param idbytes 监测对象ID 字节数组
     * @param value 文本值
     * @throws
     * @return
     */
    private static byte[] getValueMonitorObjectBytes(byte[] idbytes, String value) {
        byte[] valueBytes = ByteUtil.getDoubleStringBytes(new Double(value));
        List<byte[]> byteList = Arrays.asList(new byte[][]{idbytes, valueBytes});
        return ByteUtil.getBytes(byteList);
    }

    /**
     * @Description 将文本类型的监测对象 转化为 字节数组
     * 策略 是 监测对象ID字节数组 + 编码之后的数组长度字节数组(1个字节) + 字符字节数组
     * 字节数组长度 不能超过 64个字节
     * @Date 2019/10/16 15:21
     * @param idbytes 监测对象ID 字节数组
     * @param textValue 文本值
     * @throws
     * @return
     */
    private static byte[] getTextMonitorObjectBytes(byte[] idbytes, String textValue) {
        byte[] textValueBytes = ByteUtil.getStringBytes(textValue);
        /**文本类型长度 用一个字节表示*/
        Integer textLen = textValueBytes.length;
        byte[] textLenBytes = ByteUtil.getByteBytes(textLen.byteValue());
        List<byte[]> byteList = Arrays.asList(new byte[][]{idbytes, textLenBytes, textValueBytes});
        return ByteUtil.getBytes(byteList);
    }

    /**
     * @Description 解析监测对象 字节数组
     * 上传的都是 数值类型 每一个监测对象 占9个字节
     * 第1个字节对象ID  后面8个对象值
     * @Date 2019/10/17 9:44
     * @param bytes 字节数组
     * @throws
     * @return
     */
    private static List<MonitorObject> parseMonitors(byte[] bytes) {
        if (ArrayUtils.isEmpty(bytes)) {
            return new ArrayList<>();
        }
        /**保证字节数组长度是9的倍数*/
        int total = bytes.length/9;
        List<MonitorObject> monitorObjects = new ArrayList<>(total);
        for (int i = 0; i < total; i++) {
              /**对象ID*/
              byte id = ByteUtil.getByte(new byte[]{bytes[i*9]});
              String value = new String(bytes, 9*i+1,8);
              monitorObjects.add(new MonitorObject(id, value));
        }//end for
        return monitorObjects;
    }

    /**
     * @Description 获取 设备唯一标识IMEI 字节数组
     * 占比 15个字节
     * @Date 2019/10/16 10:31
     * @param imei 设备唯一标识IMEI
     * @throws
     * @return
     */
    private static byte[] getImeiBytes(String imei) {
            return ByteUtil.getStringBytes(imei);
    }

    /**
     * @Description 解析 设备唯一标识IMEI 字节数组
     * 占比 15个字节
     * @Date 2019/10/16 10:31
     * @param bytes imei组成的字节数组
     * @throws
     * @return
     */
    private static String parseImei(byte[] bytes) throws ParseFrameException {
         try {
             return ByteUtil.getString(bytes, "ascii");
         } catch (UnsupportedEncodingException e) {
             log.error("解析设备IMEI出错, 出现不支持编码", e);
             throw new ParseFrameException("解析设备IMEI出错, 出现不支持编码字符");
         }
    }
    /**
     * @Description 解析帧组成的字节数组
     * 帧头  0x7a7a7a7a(4byte)
     * 设备IMEI(15byte)
     * 对象序列号(1byte)
     * 对象值分为2类 数值类型和文本类型 上报只有数值类型,下发才有数值类型和文本类型
     * 数值类型为 8个字节 一个监测对象上报占9个字节
     * 对象序列号结束符(1byte, 0x7e,对象结束)
     * 帧尾  0x61616161 (4byte)
     * @Date 2019/10/16 10:50
     * @param bytes 帧组成的字节数组
     * @throws
     * @return
     */
    public static MqttFrame parseFrame(byte[] bytes) throws ParseFrameException {
        checkParseLength(bytes);
        checkTopAndTail(bytes);
        checkObjectEnd(bytes);
        checkMonitorObject(bytes);
        MqttFrame frame = new MqttFrame();
        byte[] imeiBytes = ArrayUtils.subarray(bytes, 4,19);
        frame.setImei(parseImei(imeiBytes));
        int oLen = bytes.length-MIN_PARSE_ARRAY_LENGTH;
        if (oLen > 0) {
            byte[] monitors = ArrayUtils.subarray(bytes, 19, bytes.length-5);
            frame.setMonitorObjects(parseMonitors(monitors));
        }
        return frame;
    }

    private static void checkParseLength(byte[] bytes) throws ParseFrameException {
        if (ArrayUtils.isEmpty(bytes) || bytes.length < MIN_PARSE_ARRAY_LENGTH) {
            log.error("解析MQTT帧长度:{}", bytes.length);
            throw new ParseFrameException("解析MQTT帧长度不正确");
        }
    }

    private static void checkTopAndTail(byte[] bytes) throws ParseFrameException {
        byte[] topBytes = ArrayUtils.subarray(bytes, 0,4);
        byte[] tailBytes = ArrayUtils.subarray(bytes, bytes.length-4, bytes.length);
        Integer top = parseTop(topBytes);
        Integer tail = parseTail(tailBytes);
        if (!MqttFrame.DEFAULT_TOP.equals(top) || !MqttFrame.DEFAULT_TAIL.equals(tail)) {
            log.error("解析MQTT帧头和帧尾:{}, {}", Integer.toHexString(top), Integer.toHexString(tail));
            throw new ParseFrameException("解析MQTT帧头帧尾不正确");
        }
    }

    private static void checkMonitorObject(byte[] bytes) throws ParseFrameException {
        int oLen = bytes.length-MIN_PARSE_ARRAY_LENGTH;
        /**对象长度必须9的倍数*/
        if (oLen < 0 || (oLen % MONITOR_OBJECT_BYTE_SIZE) != 0) {
            log.error("解析MQTT的监测对象长度:{}", oLen);
            throw new ParseFrameException("解析MQTT的监测对象长度不正确");
        }
    }

    private static void checkObjectEnd(byte[] bytes) throws ParseFrameException {
        byte[] objectEndBytes = ArrayUtils.subarray(bytes,bytes.length-5, bytes.length-4);
        Byte objectEnd = parseObjectEnd(objectEndBytes);
        if (!MqttFrame.DEFAULT_OBJECT_END.equals(objectEnd)) {
            log.error("解析MQTT的对象终止符:{}", Integer.toHexString(objectEnd));
            throw new ParseFrameException("解析MQTT的对象终止符不正确");
        }
    }
    /**
     * @Description 获取帧头字节数组
     * 枕头占比 4个字节
     * @Date 2019/10/16 10:31
     * @param
     * @throws
     * @return
     */
    private static byte[] getTopBytes(Integer top) {
        return ByteUtil.getIntBytes(top);
    }
    /**
     * @Description 解析枕头字节数组
     *  占比 4个字节
     * @Date 2019/10/16 10:33
     * @param bytes 枕头字节数组
     * @throws
     * @return
     */
    private static Integer parseTop(byte[] bytes) {
        assert (bytes.length == 4);
        return ByteUtil.getInt(bytes);
    }

    /**
     * @Description 解析对象结束符 默认是 0x4e
     * @Date 2019/10/16 10:33
     * @param bytes 对象结束符字节数组 数组长度为1
     * @throws
     * @return
     */
    private static Byte parseObjectEnd(byte[] bytes) {
        assert (bytes.length == 1);
        return ByteUtil.getByte(bytes);
    }
    /**
     * @Description 获取对象结束符数组 默认是
     * 对象结束符 占比 1个字节
     * @Date 2019/10/16 10:31
     * @param
     * @throws
     * @return
     */
    private static byte[] getObjectEndBytes(Byte objectEnd) {
        return ByteUtil.getByteBytes(objectEnd);
    }
    /**
     * @Description 获取帧尾字节数组
     * 帧尾占比 4个字节
     * @Date 2019/10/16 10:31
     * @param
     * @throws
     * @return
     */
    private static byte[] getTailBytes(Integer tail) {
        return ByteUtil.getIntBytes(tail);
    }
    /**
     * @Description 解析帧尾字节数组
     * 帧尾占比 4个字节
     * @Date 2019/10/16 10:33
     * @param bytes 帧尾字节数组
     * @throws
     * @return
     */
    private static Integer parseTail(byte[] bytes) {
        assert (bytes.length == 4);
        return ByteUtil.getInt(bytes);
    }

(六)字节工具类

public class ByteUtil {



    /**
     * @Description 将 int 转化为 4个字节数组
     * 数组中存储的 就是 真实的int值
     * @Date 2019/10/16 9:24
     * @param  data 数据
     * @throws
     * @return
     */
    public static byte[] getIntBytes(int data) {
        return ByteBuffer.allocate(4).putInt(data).array();
    }

    /**
     * @Description 将 4个字节数组 转化为 int
     * 数组中存储的就是真实的int值
     * @Date 2019/10/16 9:24
     * @param  bytes 字节数组
     * @throws
     * @return
     */
    public static int getInt(byte[] bytes) {
        return ByteBuffer.wrap(bytes).getInt();
    }

    /**
     * @Description 将 byte 转化为 1个字节数组
     * 数组中存储的是字符 因此加上字符‘0'
     * @Date 2019/10/16 9:24
     * @param  data 数据
     * @throws
     * @return
     */
    public static byte[] getByteBytes(byte data) {
        byte[] bytes = new byte[1];
        bytes[0] = (byte)(data + '0');
        return bytes;
    }
    /**
     * @Description 将 1个字节数组 转化为 byte
     * 数组中存储的是 字符 因此将原来传递的字符减去字符 '0' 等于真实的数值
     * @Date 2019/10/16 9:24
     * @param  bytes 字节数组
     * @throws
     * @return
     */
    public static byte getByte(byte[] bytes) {
        return (byte)(bytes[0] -'0');
    }

    /**
     * @Description 将double类型的数值当成字符串传递
     * 字符串长度固定为 8个字节并且包括小数点
     * @Date 2019/10/17 9:05
     * @param data 数值
     * @throws
     * @return
     */
    public static byte[] getDoubleStringBytes(Double data) {
        byte[] src = getStringBytes(data.toString());
        byte[] des = new byte[8];
        Arrays.fill(des, (byte)'0');
        for (int i = 0; i < src.length && i < des.length; i++) {
            des[i] = src[i];
        }//end for
        return des;
    }

    /**
     * @Description 将double类型的数值组成的字符串 转变为double
     * 字符串长度固定为 8个字节并且包括小数点
     * @Date 2019/10/17 9:05
     * @param bytes
     * @throws
     * @return
     */
    public static Double parseDoubleString(byte[] bytes) {
        String data = getString(bytes);
        return new Double(data);
    }
    /**
     * @Description 将字符串 转化为 字节数组
     * @Date 2019/10/16 10:27
     * @param data 字符串数据
     * @throws 
     * @return 
     */
    public static byte[] getStringBytes(String data) {
        return data.getBytes();
    }

    /**
     * @Description 将字符串 转化为 特定的编码 字节数组
     * @Date 2019/10/16 10:27
     * @param data 字符串数据
     * @param charsetName 编码名称
     * @throws
     * @return
     */
    public static byte[] getStringBytes(String data, String charsetName) throws UnsupportedEncodingException {
        return data.getBytes(charsetName);
    }
    /**
     * @Description 字节数组 转化为 字符串 默认编码是 ASCII编码
     * @Date 2019/10/16 10:28
     * @param bytes 字节数组
     * @throws 
     * @return 
     */
    public static String getString(byte[] bytes) {
        return new String(bytes);
    }
    /**
     * @Description 字节数组 转化为 特定的编码 字符串
     * @Date 2019/10/16 10:28
     * @param bytes 字节数组
     * @param charsetName 编码名称
     * @throws
     * @return
     */
    public static String getString(byte[] bytes, String charsetName) throws UnsupportedEncodingException {
        return new String(bytes, charsetName);
    }

    /**
     * @Description 将字节数组 合并为一个字节数组
     * @Date 2019/10/16 15:12
     * @param
     * @throws
     * @return
     */
    public static byte[] getBytes(List<byte[]> byteList) {
        if (CollectionUtils.isEmpty(byteList)) {
            return new byte[0];
        }
        Integer len = 0;
        for (byte[] bytes : byteList) {
            len += bytes.length;
        }
        ByteBuffer byteBuffer = ByteBuffer.allocate(len);
        for (byte[] bytes : byteList) {
            byteBuffer.put(bytes);
        }
        return byteBuffer.array();
    }

    /**
     * @Description 将字节数组转化为 十六进制字符串
     * @Date 2019/10/18 9:32
     * @param bytes 字节数组
     * @throws
     * @return
     */
    public static String bytesToHex(byte[] bytes) {
        StringBuffer sb = new StringBuffer();
        for(int i = 0; i < bytes.length; i++) {
            String hex = Integer.toHexString(bytes[i] & 0xFF);
            if(hex.length() < 2){
                sb.append(0);
            }
            sb.append(hex);
        }
        return sb.toString();
    }
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值