Mybatis自定义插件的理解与使用情景的举例

mybatis中自定义插件的使用

介绍

基于mybatis框架强大的特性,mybatis允许我们在映射语句的执行过程中对某些方法的调用进行拦截加工,又因为其不是必须的,只根据需要创建,因此称之为自定义插件,实际上就是一个拦截器插件

那么既然是拦截器,一定都需要关注两个点:

  • 拦截的对象是谁,即目标是谁?
  • 拦截后要作何处理?

mybatis的执行流程中,有四个核心对象:

  • ParameterHandler(getParameterObject、setParameters):参数处理器,处理SQL的参数对象
  • ResultSetHandler(handlerResultSets、handleOutputParameters等方法):结果集处理器,处理SQL的返回结果集
  • StatementHandler(prepare、parameterize、batch、update、query等方法):SQL语法构建器,数据库的处理对象,用于执行SQL语句
  • Executor(update、query、commit、rollback等方法):MyBatis的执行器,用于执行增删改查操作

那么mybatis拦截器主要针对的就是上面四个对象。

因为插件的作用对象是mybatis的四大核心对象,因此我们在使用插件的时候,一定非常谨慎,因为你操作的是mybatis最底层的类和方法。另外,在操作完成后,一定要记得执行拦截器的放行操作

原理

mybatis插件所使用的到的核心的设计模式是动态代理模式,利用JDK动态代理机制,为这些接口的实现类创建代理对象,在执行方法时,先去执行代理对象的方法,从而执行自己编写的拦截逻辑,所以真正要用好mybatis插件,主要还是要熟悉这四个接口的方法以及这些方法上的参数的含义。

  • 设计模式:代理模式、责任链模式
  • 软件思想:AOP编程思想,降低模块间的耦合度,使业务模块更加独立

在四大对象创建的时候:

  • 每个创建出来的对象不是直接返回的,而是InterceptorChain.pluginAll(Object)
  • 获取到所有的 Interceptor(拦截器),调用 Interceptor.plugin(target),返回包装后的对象
  • 插件机制,我们可以使用插件为目标对象创建一个代理对象,AOP(面向切面)我们的插件可以为四大对象创建出代理对象,代理对象就可以拦截到四大对象的每一个执行

使用

mybatis的插件要想使用也很简单,只需要实现一个接口Interceptor(org.apache.ibatis.plugin包下的)并重写方法即可。

另外需要用到这两个注解:

  • @Intercepts注解

    标注该类为一个拦截器

  • @Signature注解

    指明改拦截器需要拦截那个借口的哪一个方法

    参数描述
    type四种类型接口作品那个的某一个接口,如Executor.class
    method对应接口中的某一个方法名,比如Executorqueryupdate等方法
    args对应接口中的某一个方法的参数,比如Executorquery方法因为重载原因,有多个,args就是指明参数类型,从而确定是具体哪一个方法。

具体使用,官方给出的例子是:

// ExamplePlugin.java
//这里指明具体想要拦截处理哪些对象的哪些方法,是一个数组
@Intercepts({@Signature(
  type= Executor.class,
  method = "update",
  args = {MappedStatement.class,Object.class})})
public class ExamplePlugin implements Interceptor {
  private Properties properties = new Properties();

  //拦截后进行具体操作的方法,此方法必须重写
  @Override
  public Object intercept(Invocation invocation) throws Throwable {
    // implement pre-processing if needed
    Object returnObject = invocation.proceed();
    // implement post-processing if needed
      
    //操作完后一定要放行!
    return returnObject;
  }

  //可以设置参数
  @Override
  public void setProperties(Properties properties) {
    this.properties = properties;
  }
}

同时也需要我们在配置文件中进行配置,当然,也可以通过全注解开发的方式实现。

<!-- mybatis-config.xml -->
<plugins>
  <plugin interceptor="org.mybatis.example.ExamplePlugin">
      <!--传入配置参数-->
    <property name="someProperty" value="100"/>
  </plugin>
</plugins>

应用场景举例

mybatis中插件的应用还是比较广泛的,下面就几个使用场景简单举例。

这里我是在Spring中进行模拟的,如果是在Springboot中,则配置起来会更简单。

准备工作

数据库搭建

创建一个数据库,这里我命名为mybatis_example,并创建一个表book

表结构如下:

CREATE TABLE `book` (
  `id` bigint unsigned NOT NULL,
  `name` varchar(255) DEFAULT NULL,
  `author` varchar(50) DEFAULT NULL,
  `price` decimal(6,1) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
测试环境搭建
  • 创建一个maven工程,导入相关的jar包依赖:
<dependencies>
    <!-- logback-classic -->
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>1.2.11</version>
        <scope>test</scope>
    </dependency>
    <!--mybatis-spring -->
    <dependency>
        <groupId>org.mybatis</groupId>
        <artifactId>mybatis-spring</artifactId>
        <version>2.0.7</version>
    </dependency>
    <!-- mybatis -->
    <dependency>
        <groupId>org.mybatis</groupId>
        <artifactId>mybatis</artifactId>
        <version>3.5.9</version>
    </dependency>
    <!-- druid 数据库连接池 -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <version>1.2.9</version>
    </dependency>
    <!-- spring-context -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-context</artifactId>
        <version>5.3.18</version>
    </dependency>
    <!-- /spring-jdbc -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-jdbc</artifactId>
        <version>5.3.18</version>
    </dependency>
    <!-- spring-test -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-test</artifactId>
        <version>5.3.18</version>
    </dependency>
    <!-- mysql-connector-java -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.21</version>
    </dependency>
    <!-- lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.22</version>
        <scope>provided</scope>
    </dependency>
    <!-- junit-jupiter-api -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.8.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>
  • 配置spring以及mybatis,这里我使用的是全注解的方式,因此需要创建配置类:

    SpringConfig

    package com.soberw.config;
    
    import org.springframework.context.annotation.ComponentScan;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.context.annotation.Import;
    
    /**
     * @author soberw
     */
    @Configuration @Import(MybatisConfig.class) @ComponentScan("com.soberw")
    public class SpringConfig {
    }
    

    MybatisConfig

    package com.soberw.config;
    
    import com.alibaba.druid.pool.DruidDataSource;
    import org.mybatis.spring.SqlSessionFactoryBean;
    import org.mybatis.spring.annotation.MapperScan;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.context.annotation.*;
    import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
    
    import java.io.IOException;
    
    /**
     * @author soberw
     */
    @Configuration
    
    //加载resources/db.properties 配置文件
    @PropertySource("classpath:db.properties")
    
    //扫描有Mapper注解的接口 @Mapper 主要是解决,单元测试时报红
    @ComponentScan("com.soberw.mapper")
    
    //也是扫描包,在接口上可以不加@Mapper
    @MapperScan("com.soberw.mapper")
    public class MybatisConfig {
        @Value("${db.driver:com.mysql.cj.jdbc.Driver}")
        private String driver;
        @Value("${db.url:jdbc:mysql:/mybatis_example}")
        private String url;
        @Value("${db.username:root}")
        private String username;
        @Value("${db.password:123456}")
        private String password;
    
    
        @Autowired
        private SnowFlakeInterceptor sfi;
    
        @Bean(name = "ds", initMethod = "init", destroyMethod = "close")
        @Primary
        public DruidDataSource ds() {
            DruidDataSource ds = new DruidDataSource();
            ds.setDriverClassName(driver);
            ds.setUsername(username);
            ds.setPassword(password);
            ds.setUrl(url);
            return ds;
        }
    
        @Bean(name = "sf")
        @Primary
        public SqlSessionFactoryBean sf(DruidDataSource ds) throws IOException {
            SqlSessionFactoryBean sf = new SqlSessionFactoryBean();
            sf.setDataSource(ds);
    
            // resources/mapper/XxxMapper.xml
            sf.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath*:mapper/*Mapper*.xml"));
    
            //扫描定义别名 com.soberw.entity.Book 别名为 book
            sf.setTypeAliasesPackage("com.soberw.entity");
            return sf;
        }
    }
    
  • 创建实体类Book

    package com.soberw.entity;
    
    import lombok.Data;
    
    /**
     * @author soberw
     * @Classname Book
     * @Description
     * @Date 2022-04-12 20:51
     */
    @Data
    public class Book {
        /**
         * id
         */
        private Long id;
        /**
         * 书名
         */
        private String name;
        /**
         * 作者
         */
        private String author;
        /**
         * 价格
         */
        private Double price;
    }
    
  • 创建mapper映射类以及映射文件,添加插入和查询方法:

    package com.soberw.mapper;
    
    import com.soberw.entity.Book;
    import org.apache.ibatis.annotations.*;
    
    import java.util.List;
    import java.util.Map;
    
    /**
     * @author soberw
     */
    @Mapper
    public interface BookMapper {
        /**
         * 插入一条记录
         * @param book
         * @return
         */
        int insert(Book book);
    
        /**
         * 查询所有
         * @return
         */
        List<Book> selectAll();
    }
    
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper
            PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
            "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.soberw.mapper.BookMapper">
        <insert id="insert" parameterType="book">
            insert into book (id, name, author, price)
            values (#{id}, #{name}, #{author}, #{price})
        </insert>
        <select id="selectAll" resultType="book">
            select id,name,author,price from book
        </select>
    </mapper>
    
  • 日志配置以及数据源配置

    <?xml version="1.0"?>
    <configuration>
        <!-- ch.qos.logback.core.ConsoleAppender 控制台输出 -->
        <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>[%-5level] %d{HH:mm:ss} [%thread] %logger{36} - %msg%n</pattern>
            </encoder>
        </appender>
        <!-- 日志级别 -->
        <root>
            <level value="off" />
            <appender-ref ref="STDOUT" />
        </root>
        <logger name="cn" level="DEBUG"/>
    </configuration>
    
    db.driver=com.mysql.cj.jdbc.Driver
    db.url=jdbc:mysql://localhost:3306/mybatis_example?useUnicode=true&characterEncodeing=UTF-8&useSSL=false&serverTimezone=GMT
    db.username=root
    db.password=123456
    

设置主键插件

主键插件就是在添加表数据的时候,通过拦截器的形式自动为新数据添加主键,当前比较流行的主键设置是UUID或者雪花算法,这里以雪花算法为例,创建一个主键插件,在执行插入操作的时候自动为记录添加并设置主键。

因此需要一个用于生成雪花算法的类SnowflakeIdWorker

package com.soberw.entity;

/**
 * Twitter_Snowflake

 * SnowFlake的结构如下(每部分用-分开):

 * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000

 * 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0

 * 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)
 * 得到的值),这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69

 * 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId

 * 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号

 * 加起来刚好64位,为一个Long型。

 * SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。
 * @author soberw
 */
public class SnowflakeIdWorker {

    // ==============================Fields===========================================
    /** 开始时间截 (2019-09-27) */
    private final long twepoch = 1569513600000L;

    /** 机器id所占的位数 */
    private final long workerIdBits = 5L;

    /** 数据标识id所占的位数 */
    private final long datacenterIdBits = 5L;

    /** 支持的最大机器id,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数) */
    private final long maxWorkerId = -1L ^ (-1L << workerIdBits);

    /** 支持的最大数据标识id,结果是31 */
    private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

    /** 序列在id中占的位数 */
    private final long sequenceBits = 12L;

    /** 机器ID向左移12位 */
    private final long workerIdShift = sequenceBits;

    /** 数据标识id向左移17位(12+5) */
    private final long datacenterIdShift = sequenceBits + workerIdBits;

    /** 时间截向左移22位(5+5+12) */
    private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

    /** 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095) */
    private final long sequenceMask = -1L ^ (-1L << sequenceBits);

    /** 工作机器ID(0~31) */
    private long workerId;

    /** 数据中心ID(0~31) */
    private long datacenterId;

    /** 毫秒内序列(0~4095) */
    private long sequence = 0L;

    /** 上次生成ID的时间截 */
    private long lastTimestamp = -1L;

    //==============================Constructors=====================================
    /**
     * 构造函数
     * @param workerId 工作ID (0~31)
     * @param datacenterId 数据中心ID (0~31)
     */
    public SnowflakeIdWorker(long workerId, long datacenterId) {
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
        }
        if (datacenterId > maxDatacenterId || datacenterId < 0) {
            throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
        }
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }

    // ==============================Methods==========================================
    /**
     * 获得下一个ID (该方法是线程安全的)
     * @return SnowflakeId
     */
    public synchronized long nextId() {
        long timestamp = timeGen();

        //如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过这个时候应当抛出异常
        if (timestamp < lastTimestamp) {
            throw new RuntimeException(
                    String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
        }

        //如果是同一时间生成的,则进行毫秒内序列
        if (lastTimestamp == timestamp) {
            sequence = (sequence + 1) & sequenceMask;
            //毫秒内序列溢出
            if (sequence == 0) {
                //阻塞到下一个毫秒,获得新的时间戳
                timestamp = tilNextMillis(lastTimestamp);
            }
        }
        //时间戳改变,毫秒内序列重置
        else {
            sequence = 0L;
        }

        //上次生成ID的时间截
        lastTimestamp = timestamp;

        //移位并通过或运算拼到一起组成64位的ID
        return ((timestamp - twepoch) << timestampLeftShift) //
                | (datacenterId << datacenterIdShift) //
                | (workerId << workerIdShift) //
                | sequence;
    }

    /**
     * 阻塞到下一个毫秒,直到获得新的时间戳
     * @param lastTimestamp 上次生成ID的时间截
     * @return 当前时间戳
     */
    private long tilNextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }

    /**
     * 返回以毫秒为单位的当前时间
     * @return 当前时间(毫秒)
     */
    private long timeGen() {
        return System.currentTimeMillis();
    }

    //==============================Test=============================================
    /** 测试 */
    public static void main(String[] args) {
        SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
        for (int i = 0; i < 1000; i++) {
            long id = idWorker.nextId();
            System.out.println(id);
        }
    }
}

image-20220418191435354

生成的数不会重复且具有规律。

  • 下面创建一个拦截器类SnowFlakeInterceptor,用于主键的设置:

    package com.soberw.interceptor;
    
    import com.soberw.entity.IdWorker;
    import org.apache.ibatis.executor.Executor;
    import org.apache.ibatis.mapping.MappedStatement;
    import org.apache.ibatis.plugin.*;
    import org.springframework.beans.factory.annotation.Autowired;
    
    import java.lang.reflect.Method;
    import java.util.HashMap;
    import java.util.Map;
    import java.util.Properties;
    
    /**
     * @author soberw
     */
    
    @Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})})
    public class SnowFlakeInterceptor implements Interceptor {
        @Autowired
        private IdWorker idw;
    
        @Override
        public Object intercept(Invocation io) throws Throwable {
            MappedStatement ms = (MappedStatement) io.getArgs()[0];
            if ("INSERT".equals(ms.getSqlCommandType().toString())) {
                Object obj = io.getArgs()[1];
                if (obj instanceof HashMap) {
                    Map<String, Object> map = (Map<String, Object>) obj;
                    map.put("id", idw.nextId());
                } else {
                    Method m = obj.getClass().getMethod("setId", Long.class);
                    m.invoke(obj, idw.nextId());
                }
                return io.proceed();
            }else{
                return io.proceed();
            }
    
        }
    
        /**
         * Plugin.wrap生成拦截代理对象
         */
        @Override
        public Object plugin(Object o) {
            if (o instanceof Executor) {
                return Plugin.wrap(o, this);
            } else {
                return o;
            }
        }
    
        @Override
        public void setProperties(Properties properties) {
    
        }
    }
    
  • 接下来需要将插件注册到mybatis中去,因此需要在配置类中设置,如果你使用的是配置文件的方式,则在配置文件中配置即可:

    首先使用@Bean将插件注入到spring容器中,然后设置到mybatis中:

    image-20220418202228404

    image-20220418192134842

  • 接下来进行测试,创建一个test测试类:

    package test;
    
    import com.soberw.config.SpringConfig;
    import com.soberw.entity.Book;
    import com.soberw.mapper.BookMapper;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
    
    import java.util.List;
    
    /**
     * @author soberw
     * @Classname MyTest
     * @Description
     * @Date 2022-04-18 19:23
     */
    @SpringJUnitConfig(classes = SpringConfig.class)
    public class MyTest {
        @Autowired
        BookMapper bookMapper;
    
        @Test
        public void insert() {
            bookMapper.insert(new Book(null,"《javase》","张三",36.9));
            bookMapper.insert(new Book(null,"《javaee》","李四",38.9));
            bookMapper.insert(new Book(null,"《mysql》","王五",20.9));
            bookMapper.insert(new Book(null,"《html》","马六",63.9));
            bookMapper.insert(new Book(null,"《css》","赵七",96.3));
            bookMapper.insert(new Book(null,"《JavaScript》","孙八",36.7));
            List<Book> books = bookMapper.selectAll();
            System.out.println("books = " + books);
        }
    }
    

    image-20220418193626035

    可以看到,插入成功,查看数据:

    image-20220418193705861

分页插件

mybatis插件的另一个比较常用的使用场景就是分页处理了,分页操作是必不可少的一步操作,尤其是当数据量大的时候,而实际中实现分页的方式有很多种,可以直接通过编写SQL语句进行分页处理,也可以使用一些优秀的分页组件,当然,你完全也可以使用mybatis的插件技术进行分页处理。

实际上,已经存在一款分页插件了PageHelper,我们只需要导入maven依赖,并通过简单的配置即可,官方网址点此

这里我们自己编写一个分页插件:

创建一个拦截器类MyPager

package com.soberw.interceptor;

import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;

import java.sql.Connection;
import java.util.Properties;

/**
 * @author soberw
 * @Classname MyPager
 * @Description
 * @Date 2022-04-18 19:46
 */

args: 你需要mybatis传入什么参数给你 type :你需要拦截的对象 method=要拦截的方法
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare",
        args = {Connection.class, Integer.class})})
public class MyPager implements Interceptor {
    public static void startPage(int c, int p) {
        currpage = c;
        pagesize = p;

    }

    public static void startPage(int c) {
        currpage = c;
    }

    public static int currpage = 1;
    public static int pagesize = 10;
    public static int recordcount = 0;
    public static int pagecount = 1;

    /**
     * 插件的核心处理方法
     *
     * @param invocation
     * @return
     * @throws Throwable
     */
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object sh = invocation.getTarget();
        MetaObject mh = SystemMetaObject.forObject(sh);
        MappedStatement ms = (MappedStatement) mh.getValue("delegate.mappedStatement");
        String msid = ms.getId();
        //判断方法是否以ByPage为后缀
        if (msid.endsWith("ByPage")) {
            mh.getValue("delegate.parameterHandler");
            String sql =
                    //在原来的SQL语句上拼接上分页条件语句
                    mh.getValue("delegate.boundSql.sql").toString().trim() + String.format(" limit % d, % d ", currpage * pagesize - pagesize, pagesize);
            mh.setValue("delegate.boundSql.sql", sql);
        }
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
//固定的写法,第二个参数表示要代理的对象 this
        return Plugin.wrap(target, this);
    }

    /**
     * 要传给这个插件的配置信息
     *
     * @param p
     */
    @Override
    public void setProperties(Properties p) {
        pagesize = Integer.parseInt(p.getOrDefault("pagesize", 15).toString());
        currpage = Integer.parseInt(p.getOrDefault("currpage", 1).toString());
    }
}

注入容器并设置到插件中:

image-20220418202937810

测试:

image-20220418203203665

若想查看第二页,则修改“currpage”即可。

image-20220418203346370

当然 ,mybatis插件的使用场景远不止与此,比如:

  • 很多场景中都会给表添加添加时间、更新时间字段,我们就可以通过插件自动设置字段值到数据库记录
  • 对于一些用户比较敏感的信息,比如手机号、身份证号、密码等信息,可以通过插件在插入的时候加密处理,当需要查看时,再进行解密操作
  • 还可以通过插件完成逻辑删除的操作

当然,mybatis插件的使用情景还有很多很多。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值