Spring Data JPA 介绍和使用

本文参考了Spring Data JPA官方文档,引用了部分文档的代码。

Spring Data JPA是Spring基于Hibernate开发的一个JPA框架。如果用过Hibernate或者MyBatis的话,就会知道对象关系映射(ORM)框架有多么方便。但是Spring Data JPA框架功能更进一步,为我们做了 一个数据持久层框架几乎能做的任何事情。下面来逐步介绍它的强大功能。

添加依赖

我们可以简单的声明Spring Data JPA的单独依赖项。以Gradle为例,依赖项如下,Spring Data JPA会自动添加它的Spring依赖项。当前版本需要Spring框架版本为4.3.7.RELEASE或更新,使用旧版本的Spring框架可能会出现bug。由于Spring Data JPA基于Hibernate,所以别忘了添加Hibernate的依赖项。

compile group: 'org.springframework.data', name: 'spring-data-jpa', version: '1.11.1.RELEASE'
compile group: 'org.hibernate', name: 'hibernate-core', version: '5.2.8.Final'


基本使用

创建环境

Spring Data JPA也是一个JPA框架,因此我们需要数据源、JPA Bean、数据库驱动、事务管理器等等。下面以XML配置为例,我们来配置一下所需的Bean。重点在于<jpa:repositories base-package="yitian.study.dao"/>一句,它告诉Spring去哪里寻找并创建这些接口类。

<!--启用注解配置和包扫描-->
<context:annotation-config/>
<context:component-scan base-package="yitian.study"/>
<!--创建Spring Data JPA实例对象-->
<jpa:repositories base-package="yitian.study.dao"/>
<!--数据源-->
<bean id="dataSource"
      class="com.mysql.jdbc.jdbc2.optional.MysqlConnectionPoolDataSource">
    <property name="useSSL" value="false"/>
    <property name="url" value="jdbc:mysql://localhost:3306/test"/>
    <property name="user" value="root"/>
    <property name="password" value="12345678"/>
</bean>
<!--JPA工厂对象-->
<bean id="entityManagerFactory"
      class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
    <property name="dataSource" ref="dataSource"/>
    <property name="packagesToScan" value="yitian.study.entity"/>
    <property name="jpaVendorAdapter">
        <bean class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter">
            <property name="generateDdl" value="true"/>
            <property name="showSql" value="true"/>
        </bean>
    </property>
</bean>
<!--事务管理器-->
<bean id="transactionManager"
      class="org.springframework.orm.jpa.JpaTransactionManager">
    <property name="entityManagerFactory" ref="entityManagerFactory"/>
</bean>
<!--事务管理-->
<tx:advice id="transactionAdvice"
           transaction-manager="transactionManager">
    <tx:attributes>
        <tx:method name="get*" read-only="true"/>
        <tx:method name="*"/>
    </tx:attributes>
</tx:advice>
<aop:config>
    <aop:pointcut id="daoPointCut" expression="execution(* yitian.study.dao.*.*(..))"/>
    <aop:advisor advice-ref="transactionAdvice" pointcut-ref="daoPointCut"/>
</aop:config>

创建DAO对象

前几天学了一点Groovy,再回头看看Java,实在是麻烦。所以这里我用Groovy写的实体类,不过语法和Java很相似。大家能看懂意思即可。不过确实Groovy能比Java少些很多代码,对开发挺有帮助的。有兴趣的同学可以看看我的Groovy学习笔记

Groovy类的字段默认是私有的,方法默认是公有的,分号可以省略,对于默认字段Groovy编译器还会自动生成Getter和Setter,可以减少不少代码量。只不过equals等方法不能自动生成,多少有点遗憾。这里使用了JPA注解,建立了一个实体类和数据表的映射。

@Entity
class User {
    @Id
    @GeneratedValue
    int id
    @Column(unique = true, nullable = false)
    String username
    @Column(nullable = false)
    String nickname
    @Column
    String email
    @Column
    LocalDate birthday
    @Column(nullable = false)
    LocalDateTime registerTime

    String toString() {
        "User(id:$id,username:$username,nickname:$nickname,email:$email,birthday:$birthday,registerTime:$registerTime)"
    }
}

然后就是Spring Data JPA的魔法部分了!我们继承Spring提供的一个接口,放到前面jpa:repositories指定的包下。

interface CommonUserRepository extends CrudRepository<User, Integer> {
}

然后测试一下,会发生什么事情呢?查看一下数据库就会发现数据已经成功插入了。好吧,好像没什么有魔力的事情。

@RunWith(SpringRunner)
@ContextConfiguration("file:src/main/webapp/WEB-INF/applicationContext.xml")
class DaoTest {
    @Autowired
    CommonUserRepository commonUserRepository

    @Test
    void testCrudRepository() {
        User user = new User(username: 'yitian', nickname: '易天', registerTime: LocalDateTime.now())
        commonUserRepository.save(user)

    }
}

这次我们在接口中再定义一个方法。

interface CommonUserRepository extends CrudRepository<User, Integer> {
    List<User> getByUsernameLike(String username)
}

我们再测试一下。这里也是用的Groovy代码,意思应该很容易懂,就是循环20次,然后插入20个用户,用户的名字和邮箱都是由循环变量生成的。然后调用我们刚刚的方法。这次真的按照我们的要求查询出了用户名以2结尾的所有用户!

    @Test
    void testCrudRepository() {
        (1..20).each {
            User user = new User(username: "user$it", nickname: "用户$it", email: "user$it@yitian.com", registerTime: LocalDateTime.now())
            commonUserRepository.save(user)
        }
        List<User> users = commonUserRepository.getByUsernameLike('%2')
        println(users)
    }
//结果如下
//[User(id:3,username:user2,nickname:用户2,email:user2@yitian.com,birthday:null,registerTime:2017-03-08T20:25:58), User(id:13,username:user12,nickname:用户12,email:user12@yitian.com,birthday:null,registerTime:2017-03-08T20:25:59)]

Spring Data 接口

从上面的例子中我们可以看到Spring Data JPA的真正功能了。我们只要继承它提供的接口,然后按照命名规则定义相应的查询方法。Spring就会自动创建实现了该接口和查询方法的对象,我们直接使用就可以了。也就是说,Spring Data JPA连查询方法都可以帮我们完成,我们几乎什么也不用干了。

下面来介绍一下Spring的这些接口。上面的例子中,我们继承了CrudRepository接口。CrudRepository接口的定义如下。如果我们需要增删查改功能。只需要继承该接口就可以立即获得该接口的所有功能。CrudRepository接口有两个泛型参数,第一个参数是实际储存的类型,第二个参数是主键。

public interface CrudRepository<T, ID extends Serializable>
    extends Repository<T, ID> {

    <S extends T> S save(S entity); 

    T findOne(ID primaryKey);       

    Iterable<T> findAll();          

    Long count();                   

    void delete(T entity);          

    boolean exists(ID primaryKey);  

    // … more functionality omitted.
}

CrudRepository接口虽然方便,但是暴露了增删查改的所有方法,如果你的DAO层不需要某些方法,就不要继承该接口。Spring提供了其他几个接口,org.springframework.data.repository.Repository接口没有任何方法。

如果对数据访问需要详细控制,就可以使用该接口。PagingAndSortingRepository接口则提供了分页和排序功能。PagingAndSortingRepository接口的方法接受额外的Pagable和Sort对象,用来指定获取结果的页数和排序方式。返回类型则是Page<T>类型,我们可以调用它的方法获取总页数和可迭代的数据集合。下面是一个Groovy写的例子。注意Pageable是一个接口,如果我们需要创建Pageable对象,使用PageRequest类并指定获取的页数和每页的数据量。页是从0开始计数的。

    @Test
    void testPagingRepository() {
        int countPerPage = 5
        long totalCount = pageableUserRepository.count()
        int totalPage = totalCount % 5 == 0L ? totalCount / 5 : totalCount / 5 + 1
        (0..totalPage - 1).each {
            Page<User> users = pageableUserRepository.findAll(new PageRequest(it, countPerPage))
            println "第${it}页数据,共${users.totalPages}页"
            users.each {
                println it
            }
        }

    }

查询方法

查询方法可以由我们声明的命名查询生成,也可以像前面那样由方法名解析。下面是官方文档的例子。方法名称规则如下。如果需要详细说明的话可以查看官方文档Appendix C: Repository query keywords一节。

  • 方法名以find…By, read…By, query…By, count…Byget…By做开头。在By之前可以添加Distinct表示查找不重复数据。By之后是真正的查询条件。
  • 可以查询某个属性,也可以使用条件进行比较复杂的查询,例如Between, LessThan, GreaterThan, LikeAnd,Or等。
  • 字符串属性后面可以跟IgnoreCase表示不区分大小写,也可以后跟AllIgnoreCase表示所有属性都不区分大小写。
  • 可以使用OrderBy对结果进行升序或降序排序。
  • 可以查询属性的属性,直接将几个属性连着写即可,如果可能出现歧义属性,可以使用下划线分隔多个属性。
public interface PersonRepository extends Repository<User, Long> {

  List<Person> findByEmailAddressAndLastname(EmailAddress emailAddress, String lastname);

  // 唯一查询
  List<Person> findDistinctPeopleByLastnameOrFirstname(String lastname, String firstname);
  List<Person> findPeopleDistinctByLastnameOrFirstname(String lastname, String firstname);

  // 对某一属性不区分大小写
  List<Person> findByLastnameIgnoreCase(String lastname);
  // 所有属性不区分大小写
  List<Person> findByLastnameAndFirstnameAllIgnoreCase(String lastname, String firstname);

  // 启用静态排序
  List<Person> findByLastnameOrderByFirstnameAsc(String lastname);
  List<Person> findByLastnameOrderByFirstnameDesc(String lastname);
}
  //查询Person.Address.ZipCode
  List<Person> findByAddressZipCode(ZipCode zipCode);
  //避免歧义可以这样
  List<Person> findByAddress_ZipCode(ZipCode zipCode);

如果需要限制查询结果也很简单。

User findFirstByOrderByLastnameAsc();

User findTopByOrderByAgeDesc();

Page<User> queryFirst10ByLastname(String lastname, Pageable pageable);

Slice<User> findTop3ByLastname(String lastname, Pageable pageable);

List<User> findFirst10ByLastname(String lastname, Sort sort);

List<User> findTop10ByLastname(String lastname, Pageable pageable);

如果查询很费时间,也可以方便的使用异步查询。只要添加@Async注解,然后将返回类型设定为异步的即可。

@Async
Future<User> findByFirstname(String firstname);               

@Async
CompletableFuture<User> findOneByFirstname(String firstname); 

@Async
ListenableFuture<User> findOneByLastname(String lastname); 

Spring Data扩展功能

Querydsl扩展

Querydsl扩展能让我们以流式方式代码编写查询方法。该扩展需要一个接口QueryDslPredicateExecutor,它定义了很多查询方法。

public interface QueryDslPredicateExecutor<T> {

    T findOne(Predicate predicate);             

    Iterable<T> findAll(Predicate predicate);   

    long count(Predicate predicate);            

    boolean exists(Predicate predicate);        

    // … more functionality omitted.
}

只要我们的接口继承了该接口,就可以使用该接口提供的各种方法了。

interface UserRepository extends CrudRepository<User, Long>, QueryDslPredicateExecutor<User> {

}

查询方法可以这样简单的编写。

Predicate predicate = user.firstname.equalsIgnoreCase("dave")
    .and(user.lastname.startsWithIgnoreCase("mathews"));

userRepository.findAll(predicate);

Spring Web Mvc集成

这个功能需要我们引入Spring Web Mvc的相应依赖包。然后在程序中启用Spring Data支持。使用Java配置的话,在配置类上添加@EnableSpringDataWebSupport注解。

@Configuration
@EnableWebMvc
@EnableSpringDataWebSupport
class WebConfiguration { }

使用XML配置的话,添加下面的Bean声明。

<bean class="org.springframework.data.web.config.SpringDataWebConfiguration" />
<!-- 如果使用Spring HATEOAS 的话用下面这个替换上面这个 -->
<bean class="org.springframework.data.web.config.HateoasAwareSpringDataWebConfiguration" />

不管使用哪种方式,都会向Spring额外注册几个组件,支持Spring Data的额外功能。首先会注册一个DomainClassConverter,它可以自动将查询参数或者路径参数转换为领域模型对象。下面的例子中,Spring Data会自动用主键查询对应的用户,然后我们直接就可以从处理方法参数中获得用户实例。注意,Spring Data需要调用findOne方法查询对象,现版本下我们必须继承CrudRepository,才能实现该功能。

@Controller
@RequestMapping("/users")
public class UserController {

  @RequestMapping("/{id}")
  public String showUserForm(@PathVariable("id") User user, Model model) {

    model.addAttribute("user", user);
    return "userForm";
  }
}

另外Spring会注册HandlerMethodArgumentResolverPageableHandlerMethodArgumentResolverSortHandlerMethodArgumentResolver等几个实例。它们支持从请求参数中读取分页和排序信息。

@Controller
@RequestMapping("/users")
public class UserController {

  @Autowired UserRepository repository;

  @RequestMapping
  public String showUsers(Model model, Pageable pageable) {

    model.addAttribute("users", repository.findAll(pageable));
    return "users";
  }
}

对于上面的例子,如果在请求参数中包含sort、page、size等几个参数,它们就会被映射为Spring Data的Pageable和Sort对象。请求参数的详细信息如下。

  • page 想要获取的页数,默认是0,以零开始计数的。
  • size 每页的数据大小,默认是20.
  • 数据的排序规则,默认是升序,也可以对多个属性执行排序,这时候需要多个sort参数,例如?sort=firstname&sort=lastname,asc

如果需要多个分页对象,我们可以用@Qualifier注解,然后请求对象就可以写成foo_pagebar_page这样的了。

public String showUsers(Model model,
      @Qualifier("foo") Pageable first,
      @Qualifier("bar") Pageable second) { … }

如果需要自定义这些行为,可以让配置类继承SpringDataWebConfiguration基类,然后重写pageableResolver()sortResolver()方法。这样就不需要使用@EnableXXX注解了。

最后一个功能就是Querydsl 了。如果相关Jar包在类路径上,@EnableSpringDataWebSupport注解同样会启用该功能。比方说,在前面的例子中,如果在用户用户参数上添加下面的查询参数。

?firstname=Dave&lastname=Matthews

那么就会被QuerydslPredicateArgumentResolver解析为下面的查询语句。

QUser.user.firstname.eq("Dave").and(QUser.user.lastname.eq("Matthews"))

还可以将QuerydslPredicate注解到对应类型的方法参数上,Spring会自动实例化相应的参数。为了Spring能够准确找到应该查找什么领域对象,我们最好指定root属性。

@Controller
class UserController {

  @Autowired UserRepository repository;

  @RequestMapping(value = "/", method = RequestMethod.GET)
  String index(Model model, @QuerydslPredicate(root = User.class) Predicate predicate,    
          Pageable pageable, @RequestParam MultiValueMap<String, String> parameters) {

    model.addAttribute("users", repository.findAll(predicate, pageable));

    return "index";
  }
}

官方文档的其他内容

JPA命名查询

如果查询方法不能完全满足需要,我们可以使用自定义查询来满足需求。使用XML配置的话,在类路径下添加META/orm.xml文件,类似下面这样。我们用named-query就定义命名查询了。

<?xml version="1.0" ?>
<entity-mappings
        xmlns="http://java.sun.com/xml/ns/persistence/orm"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://java.sun.com/xml/ns/persistence/orm
        http://java.sun.com/xml/ns/persistence/orm_2_0.xsd"
        version="2.0">
    <named-query name="User.findByNickname">
        <query>select u from User u where u.nickname=?1</query>
    </named-query>
</entity-mappings>

还可以使用注解,在对应实体类上注解命名查询。

@Entity
@NamedQuery(name = "User.findByNickname",
  query = "select u from User u where u.nickname=?1")
public class User {

}

之后,在接口中声明对应名称的查询方法。这样我们就可以使用JPQL语法自定义查询方法了。

List<User> findByNickname(String nickname)

使用Query注解

在上面的方法中,查询方法和JPQL是对应的,但是却不在同一个地方定义。如果查询方法很多的话,查找和修改就很麻烦。这时候可以改用@Query注解。下面的例子直接在方法上定义了JPQL语句,如果需要引用orm.xml文件中的查询语句,使用注解的name属性,如果没有指定,会使用领域模型名.方法名作为命名查询语句的名称。

public interface UserRepository extends JpaRepository<User, Long> {

  @Query("select u from User u where u.emailAddress = ?1")
  User findByEmailAddress(String emailAddress);
}

细心的同学会发现,该注解还有一个nativeQuery属性,用作直接执行SQL使用。如果我们将该属性指定为true,查询语句也要相应的修改为SQL语句。

Modifying注解

@Modifying注解用来指定某个查询是一个更新操作,这样可以让Spring执行相应的优化。

@Modifying
@Query("update User u set u.firstname = ?1 where u.lastname = ?2")
int setFixedFirstnameFor(String firstname, String lastname);

投影

有时候数据库和实体类之间并不存在一一对应的关系,或者根据某些情况需要隐藏数据库中的某些字段。这可以通过投影实现。来看看Spring的例子。

假设有下面的实体类和仓库。我们在获取人的时候会顺带获取它的地址。

@Entity
public class Person {

  @Id @GeneratedValue
  private Long id;
  private String firstName, lastName;

  @OneToOne
  private Address address;
  …
}

@Entity
public class Address {

  @Id @GeneratedValue
  private Long id;
  private String street, state, country;

  …
}

interface PersonRepository extends CrudRepository<Person, Long> {

  Person findPersonByFirstName(String firstName);
}

如果不希望同时获取地址的话,可以定义一个新接口,其中定义一些Getter方法,暴露你需要的属性。然后仓库方法也做相应修改。

interface NoAddresses {  

  String getFirstName(); 

  String getLastName();  
}

interface PersonRepository extends CrudRepository<Person, Long> {

  NoAddresses findByFirstName(String firstName);
}

利用@Value注解和SpEl,我们可以灵活的组织属性。例如下面,定义一个接口,重命名了lastname属性。关于Spring表达式,可以看看我的文章Spring EL 简介

interface RenamedProperty {    

  String getFirstName();       

  @Value("#{target.lastName}")
  String getName();            
}

或者组合多个属性也可以,下面的例子将姓和名组合成全名。Spring El的使用很灵活,合理使用可以达到事半功倍的效果。

interface FullNameAndCountry {

  @Value("#{target.firstName} #{target.lastName}")
  String getFullName();

  @Value("#{target.address.country}")
  String getCountry();
}

规范

这里说的规范指的是JPA 2 引入的新的编程方式实现查询的规范。其他框架比如Hibernate也废弃了自己的Criteria查询方法,改为使用JPA规范的Criteria。这种方式的好处就是完全是编程式的,不需要额外的功能,使用IDE的代码提示功能即可。但是我个人不太喜欢,一来没怎么详细了解,二来感觉不如JPQL这样的查询简单粗暴。

废话不多说,直接看官方的例子吧。首先仓库接口需要继承JpaSpecificationExecutor接口。

public interface CustomerRepository extends CrudRepository<Customer, Long>, JpaSpecificationExecutor {
 …
}

这样仓库接口就继承了一组以Specification接口作参数的查询方法,类似下面这样。

List<T> findAll(Specification<T> spec);

而Specification又是这么个东西。所以我们要使用JPA规范的查询方法,就需要实现toPredicate方法。

public interface Specification<T> {
  Predicate toPredicate(Root<T> root, CriteriaQuery<?> query,
            CriteriaBuilder builder);
}

官方文档有这么个例子,这个类中包含了多个静态方法,每个方法都返回一个实现了的Specification对象。

public class CustomerSpecs {

  public static Specification<Customer> isLongTermCustomer() {
    return new Specification<Customer>() {
      public Predicate toPredicate(Root<Customer> root, CriteriaQuery<?> query,
            CriteriaBuilder builder) {

         LocalDate date = new LocalDate().minusYears(2);
         return builder.lessThan(root.get(_Customer.createdAt), date);
      }
    };
  }
  //其他方法

}

之后我们将Specification对象传递给仓库中定义的方法即可。

List<Customer> customers = customerRepository.findAll(isLongTermCustomer());

多个规范组合起来的查询也可以。

MonetaryAmount amount = new MonetaryAmount(200.0, Currencies.DOLLAR);
List<Customer> customers = customerRepository.findAll(
  where(isLongTermCustomer()).or(hasSalesOfMoreThan(amount)));

Example查询

前段时间在研究Spring的时候,发现Spring对Hibernate有一个封装类HibernateTemplate,它将Hibernate的Session封装起来,由Spring的事务管理器管理,我们只需要调用HibernateTemplate的方法即可。在HibernateTemplate中有一组Example方法我没搞明白啥意思,后来才发现这是Spring提供的一组简便查询方式。不过这种查询方式的介绍居然在Spring Data这个框架中。

这种方式的优点就是比较简单,如果使用上面的JPA规范,还需要再学习很多知识。使用Example查询的话要学习的东西就少很多了。我们只要使用已有的实体对象,创建一个例子,然后在例子上设置各种约束(即查询条件),然后将例子扔给查询方法即可。这种方式也有缺点,就是不能实现所有的查询功能,我们只能进行前后缀匹配等的字符串查询和其他类型属性的精确查询。

首先,仓库接口需要继承QueryByExampleExecutor接口,这样会引入一组以Example作参数的方法。然后创建一个ExampleMatcher对象,最后再用Example的of方法构造相应的Example对象并传递给相关查询方法。我们看看Spring的例子。

ExampleMatcher用于创建一个查询对象,下面的代码就创建了一个查询对象。withIgnorePaths方法用来排除某个属性的查询。withIncludeNullValues方法让空值也参与查询,如果我们设置了对象的姓,而名为空值,那么实际查询条件也是这样的。

Person person = new Person();                          
person.setFirstname("Dave");                           

ExampleMatcher matcher = ExampleMatcher.matching()     
  .withIgnorePaths("lastname")                         
  .withIncludeNullValues()                             
  .withStringMatcherEnding();                          

Example<Person> example = Example.of(person, matcher);

withStringMatcher方法用于指定字符串查询。例如下面的例子就是查询所有昵称以2结尾的用户。虽然用的Groovy代码但是大家应该很容易看懂吧。

    @Test
    void testExamples() {
        User user = new User(nickname: '2')

        ExampleMatcher matcher = ExampleMatcher.matching()
                .withStringMatcher(ExampleMatcher.StringMatcher.ENDING)
                .withIgnorePaths('id')
        Example<User> example = Example.of(user, matcher)
        Iterable<User> users = exampleRepository.findAll(example)
        users.each {
            println it
        }
    }

如果用Java 8的话还可以使用lambda表达式写出漂亮的matcher语句。

ExampleMatcher matcher = ExampleMatcher.matching()
  .withMatcher("firstname", match -> match.endsWith())
  .withMatcher("firstname", match -> match.startsWith());
}

基本的审计

文章写得非常长了,所以这里最后就在写一个小特性吧,那就是审计功能。这里说的是很基本的审计功能,也就是追踪谁创建和修改相关实体类。相关的注解有4个:@CreatedBy, @LastModifiedBy,@CreatedDate@LastModifiedDate,分别代表创建和修改实体类的对象和时间。

这几个时间注解支持JodaTime、java.util.Date、Calender、Java 8 的新API以及long基本类型。在我们的程序中这几个注解可以帮我们省不少事情,比如说,一个博客系统中的文章,就可以使用这些注解轻松实现新建和修改文章的时间记录。

class Customer {

  @CreatedBy
  private User user;

  @CreatedDate
  private DateTime createdDate;

  // … further properties omitted
}

当然不是直接用了这两个注解就行了。我们还需要启用审计功能。审计功能需要spring-aspects.jar这个包,因此首先需要引入Spring Aspects。在Gradle项目中是这样的。

compile group: 'org.springframework', name: 'spring-aspects', version: '4.3.7.RELEASE'

如果使用Java配置的话,在配置类上使用@EnableJpaAuditing注解。

@Configuration
@EnableJpaAuditing
class Config {

如果使用XML配置的话,添加下面的一行。

<jpa:auditing/>

最后在实体类上添加@EntityListeners(AuditingEntityListener)注解。这样,以后当我们创建和修改实体类时,不需要管@LastModifiedDate@CreatedDate这种字段,Spring会帮我们完成一切。

@Entity
@EntityListeners(AuditingEntityListener)
class Article {
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值