Bean Validation 数据校验实战

Bean Validation 数据校验实战

一.Bean Validation 数据校验实战

1.1 为什么会出现数据校验Bean Validation

在日常的开发工作中会出现大量的、重复的Check、Assert这种代码。影响代码的美观,也增加了维护成本。
类似下面这种
public String query(QueryParam param) {
checkNotNull(param.getName(), “Name must be not null”);
checkNotNull(param.getNo(), “No must be not null”);
AssertUtils.isFalse(param.isRegistered);

}
这时,JavaEE推出了一套标准,根据注解来标识变量、方法、类来做通用校验,便于开发者写一些重复枯燥的代码。

百度百科:
Bean Validation是Java EE 6数据验证新框架,Validation API并不依赖特定的应用层或是编程模型,这样同一套验证可由应用的所有层共享。它还提供了通过扩展Validation API来增加客户化验证约束的机制以及查询约束元数据仓库的手段。
后来,也是到现在的Hibernate Validator也是对Bean Validation的参考实现,提供了 JSR 303 规范中所有内置 constraint 的实现,除此之外还有一些附加的 constraint。

1.2 Bean Validation的发展历史

1.2.1 JSR 303(2009) Bean Validation 1.0

第一版的考虑场景并不会很多,主要是基于JavaBean的属性进行验证,还未涉及到方法和返回值。更多的应该是树立了几个目标:

  • 减少校验代码冗余
  • 减少各层之间代码管理成本
  • 保证错误提示语义一致性
Bean Validation实现版本
Hibernate Validator4.3.1.Final
Apache BVal0.5

参考链接:
http://jcp.org/en/jsr/detail?id=303

1.2.2 JSR 349(2013) Bean Validation 1.1

在有了第一版的基础,1.1算是一次1.0的加强版,支持了各种方法校验、验证组件的引入等等。
主要更新如下:

  • 增加了方法级别的验证(参数或返回值的验证)
  • Bean验证组件的依赖注入
  • 用EL表达式的错误消息的补充
    。。。
Bean Validation实现版本
Hibernate Validator5.1.1.Final
Apache BVal1.1.2

参考链接:
http://jcp.org/en/jsr/detail?id=349
https://beanvalidation.org/1.1/

1.2.3 JSR 380(2019) Bean Validation 2.0

这次算是极大的变化,支持Java8,最高支持到Java9,与Hibernate Validator进行了吸取和融合,目前的唯一实现,只有Hibernate Validator。对应的Maven版本hibernate-validator
6.0.17.Final。同时,一些在2.0以前的hibernate-validator的注解也成为了「正式员工」加入了Bean Validation的标准中。

题外话,在2018年,JavaEE已经改名为Jakarta,虽然包路径还是原理JavaEE风格以 javax.xxx开头,后续会修改。

Bean Validation实现版本
Hibernate Validator6.0.17.Final

PS:据说是因为Hibernate的员工跳槽到Eclipse基金会,所以后面基本上只会有Hibernate Validator这一个实现了。

注:JSR是什么?
答:JSR是JavaSpecification Requests的缩写,意思是“Java 规范提案”。是指向JCP(JavaCommunity Process)提出新增一个标准化技术规范的正式请求。任何人都可以提交JSR,以向Java平台增添新的API和服务。JSR已成为Java界的一个重要标准。简单的就是jsr是java开发者以及授权者指定的标准,而java开发者以及授权者形成一个jcp国际组织。职能是指定java标准。

1.3 Bean Validation的未来

后续Hibernate Validator要推出的7.0版,也会支持后续推出的Jakarta Validation 3.0。不过新特性据说没有什么更新= =。

当然,你如果有兴趣的话:
Hibernate validator 7.0 的文档也可以看看:
https://docs.jboss.org/hibernate/validator/7.0/reference/en-US/html_single/

二.Bean Validation的综合使用 – Hibernate Validator 6.0.17.Final

2.1 常见的22个注解

注意:这里只是根据发布版本分类,在目前的Jakarta Bean Validation 2.0中均可使用。

JSR-303

注解详细信息
@Min(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Max(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@DecimalMax(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@Size(max, min)被注释的元素的大小必须在指定的范围内
@Digits (integer, fraction)被注释的元素必须是一个数字,其值必须在可接受的范围内
@Past被注释的元素必须是一个过去的日期
@Future被注释的元素必须是一个将来的日期
@Pattern(value)被注释的元素必须符合指定的正则表达式
@AssertTrue只能为true
@AssertFalse只能是false

JSR-349

相比1.0没有新增。

JSR-380

注解详细信息
@Email元素必须为电子邮箱地址
@NotEmpty集合的Size必须大于0
@NotBlank字符串必须包含至少一个非空白的字符
@Positive元素必须为正数(不包括0)
@PositiveOrZero同上(包括0)
@Negative元素必须为负数(不包括0)
@NegativeOrZero同上(包括0)
@PastOrPresent在@Past基础上包括相等
@FutureOrPresent在@Futrue基础上包括相等

2.2 声明验证类别

2.2.1 Field-level constraints (字段级别的约束 - 非常常用)

验证引擎会直接访问到该对象的值,并对值进行校验,这种方式不会调用getter方法。

public class Car {
    @NotNull
    private String manufacturer;
    @AssertTrue
    private boolean isRegistered;
    public Car(String manufacturer, boolean isRegistered) {
        this.manufacturer = manufacturer;
        this.isRegistered = isRegistered;
    }
    //getters and setters...
}

2.2.2 Property-level constraints (属性级别的约束)

当你的模型遵循JavaBean的规范的时候,可以把校验注解放到getter方法上进行校验。

public class Car {
    private String manufacturer;
    private boolean isRegistered;
    public Car(String manufacturer, boolean isRegistered) {
        this.manufacturer = manufacturer;
        this.isRegistered = isRegistered;
    }
    @NotNull
    public String getManufacturer() {
        return manufacturer;
    }
    public void setManufacturer(String manufacturer) {
        this.manufacturer = manufacturer;
    }
    @AssertTrue
    public boolean isRegistered() {
        return isRegistered;
    }
    public void setRegistered(boolean isRegistered) {
        this.isRegistered = isRegistered;
    }
}

2.2.3 Container element constraints (容器元素约束 - 2.0新增)

这里的注解@Target需要增加ElementType.TYPE_USE,支持的容器如下。

  • 实现java.util.Iterable(例如List,Set)
  • 实现java.util.Map,并支持键和值.
  • java.util.Optional,java.util.OptionalInt,java.util.OptionalDouble,java.util.OptionalLong
  • JavaFX的各种实现javafx.beans.observable.ObservableValue.
public class Car {
    private Set<@NotNull String> parts = new HashSet<>();
    public void addPart(String part) {
        parts.add( part );
    }
    //...
}

测试类:

public class ValidTest {
    private static Validator validator;
    //用于验证参数和返回值的validator
    private static ExecutableValidator executableValidator;
    //使用init方法先进行初始化
    @BeforeClass
    public static void init(){
        //创建校验工厂
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        //获取校验实例对象
        validator = validatorFactory.getValidator();
        executableValidator = validator.forExecutables();
    }
    
    @Test
    public void test(){
        Car car = new Car();
        car.addPart( "Wheel" );
        car.addPart( null );
        Set<ConstraintViolation<Car6>> constraintViolations = validator.validate( car );
        assertEquals( 1, constraintViolations.size() );
        ConstraintViolation<Car6> constraintViolation =
                constraintViolations.iterator().next();
        assertEquals(
                "'null' is not a valid car part.",
                constraintViolation.getMessage()
        );
        assertEquals( "parts[].<iterable element>",
                constraintViolation.getPropertyPath().toString() );
    }
    
}

2.2.4 Class-level constraints(类级别的约束)

即不是对某个属性起作用,而是针对的整个对象。配合自定义Validatator来使用(后面会讲到)。

2.2.5 Constraint inheritance(约束可继承)

说白了,就是子类在在做校验时,父类中的Validation的注解仍然有效。

//父类
public class Car3 {
    @NotNull
    private String manufacturer;//制造商
    public Car3(@NotNull String manufacturer) {
        this.manufacturer = manufacturer;
    }
}
//子类 出租车
public class RentalCar extends Car3 {
    public RentalCar(@NotNull String manufacturer, @DecimalMin(value = "10") Double price) {
        super(manufacturer);
        this.price = price;
    }
    @DecimalMin(value = "10")
    private Double price;//起价费
}

测试类:

public class ValidTest {
    private static Validator validator;
    //用于验证参数和返回值的validator
    private static ExecutableValidator executableValidator;
    //使用init方法先进行初始化
    @BeforeClass
    public static void init(){
        //创建校验工厂
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        //获取校验实例对象
        validator = validatorFactory.getValidator();
        executableValidator = validator.forExecutables();
    }
    
    @Test
    public void test(){
        RentalCar rc = new RentalCar(null,19.0);
        Set<ConstraintViolation<RentalCar>> validate = validator.validate(rc);
        for (ConstraintViolation<RentalCar> carConstraintViolation : validate) {
            System.out.println("共有"+validate.size()+"条错误信息");
            System.out.println("错误字段:"+carConstraintViolation.getPropertyPath());
            System.out.println("错误信息:"+carConstraintViolation.getMessage());
            
        }
    }
}

2.2.6 Object graphs (级联验证,即@Valid)

当在使用的类中还含有其他需要校验的JavaBean,即可对这个字段或者属性加上@Valid,进行级联校验。

//Car4
public class Car4 {
    @NotNull
    @Valid //校验Car4的同时也会校验 Person
    private Person person;
    public Car4(@NotNull Person person) {
        this.person = person;
    }
}
// Person
public class Person {
    @NotNull
    private String name;
    public Person(@NotNull String name) {
        this.name = name;
    }
}

测试类

public class ValidTest {
    private static Validator validator;
    //用于验证参数和返回值的validator
    private static ExecutableValidator executableValidator;
    //使用init方法先进行初始化
    @BeforeClass
    public static void init(){
        //创建校验工厂
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        //获取校验实例对象
        validator = validatorFactory.getValidator();
        executableValidator = validator.forExecutables();
    }
    
    @Test
    public void test(){
        Person p = new Person(null);
        Car4 car4 = new Car4(p);
        Set<ConstraintViolation<Car4>> validate = validator.validate(car4);
        for (ConstraintViolation<Car4> carConstraintViolation : validate) {
            System.out.println("共有"+validate.size()+"条错误信息");
            System.out.println("错误字段:"+carConstraintViolation.getPropertyPath());
            System.out.println("错误信息:"+carConstraintViolation.getMessage());
            
        }
    }
}

2.3 验证约束

其实在前面的案例代码中已经出现了,就是Validator对象用于对constraint(约束)的校验。
以下会以代码片段的形式展示。

2.3.1 获取验证器实例

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
2.3.2 验证器方法 - validate()
Car car = new Car( null, true );
//使用validate校验
Set<ConstraintViolation> constraintViolations = validator.validate( car );
assertEquals( 1, constraintViolations.size() );
assertEquals( “must not be null”, constraintViolations.iterator().next().getMessage() );

2.3.3 验证器方法 - validateProperty()

Car car = new Car( null, true );
Set<ConstraintViolation> constraintViolations = validator.validateProperty(
car,
“manufacturer”
);
assertEquals( 1, constraintViolations.size() );
assertEquals( “must not be null”, constraintViolations.iterator().next().getMessage() );

2.3.4 验证器方法 - validateValue()

Set<ConstraintViolation> constraintViolations = validator.validateValue(
Car.class,
“manufacturer”,
null
);
assertEquals( 1, constraintViolations.size() );
assertEquals( “must not be null”, constraintViolations.iterator().next().getMessage() );

2.4 约束违反 - ConstraintViolation

即验证后如果出现不满足条件的结果集。
常用方法

方法含义
String getMessage();错误信息
Path getPropertyPath();返回字段节点返回字段节点路径,即字段名

2.5 自定义容器验证

2.5.1 @ScriptAssert(仅适合简单场景)

满足 JSR 223定义的Java支持的脚本规范,只要在classpath下配置了兼容引擎,几乎支持任何脚本语言

//这里以javascript举例
@ScriptAssert(lang = "javascript",alias = "this",script = "this.x > 2 && this.list.length > this.x")
/**
 * 在Spring环境下,支持Spring的表达式
 * https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#expressions
  */
//@ScriptAssert(lang = "spring",script = "x > 2")
public class Car12 {
    @Positive
    private int x;
    public Car12(@Positive int x) {
        this.x = x;
    }
    private List<String> list = new ArrayList<>();
    public void addList(String str){
        list.add(str);
    }
}

测试类:

 @Test
    public void testBeanScript(){
        Car12 car12 = new Car12(1);
        Set<ConstraintViolation<Car12>> validate = validator.validate(car12);
        for (ConstraintViolation<Car12> carConstraintViolation : validate) {
            System.out.println("共有"+validate.size()+"条错误信息");
            System.out.println("错误字段:"+carConstraintViolation.getPropertyPath());
            System.out.println("错误信息:"+carConstraintViolation.getMessage());
        }
    }

执行结果:
共有1条错误信息
错误字段:
错误信息:执行脚本表达式"this.x > 2 && this.list.length > this.x"没有返回期望结果

2.5.2 继承ConstraintValidator

步骤:

  1. 自定义注解,定义@Target范围,定义必须要有的四个方法

    必须在自定义注解类定义的方法含义
    String message()错误消息
    value();自定义的value值
    lass<? extends Payload>[] payload()bean Validator API的使用者可以通过约束条件指定严重级别
    lass<?>[] groups()分组的类
  2. 自定义类并继承ConstraintValidator,并且关联前面自定义的注解.话不多说,直接上代码


//自定义注解
@Target({ElementType.FIELD,ElementType.METHOD,ElementType.PACKAGE,ElementType.CONSTRUCTOR,ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
//关联下面的校验器类
@Constraint(validatedBy = CheckCaseValidator.class)
@Documented
public @interface CheckCase {
    String message() default "车牌号必须大写字母";
    Class<?>[] groups() default {};
    //bean Validator API的使用者可以通过贵约束条件指定严重级别
    Class<? extends Payload>[] payload() default {};
    CaseMode value();
}
**
 * @program
 * @description:
 * @author:
 * @create: 2020/11/23 12:33 AM
 *
 * 这里需要提供两个泛型
 *  * 第一个是校验器所服务的注解类型
 *  * 第二个是这个校验器所支持对的被校验元素的类型,@CheckCase,防止到String类型的字段上,那么第二个参数就是String
 **/
public class CheckCaseValidator implements ConstraintValidator<CheckCase,String> {
    private CaseMode caseMode;
    /**
     * 初始化需要完成的方法
     * 可以用来获取@CheckCase 中的value的属性值
     * @param constraintAnnotation
     */
    @Override
    public void initialize(CheckCase constraintAnnotation) {
        caseMode = constraintAnnotation.value();
    }
    /**
     * 进行校验的方法
     * 传递的两个参数
     * String value 传递的值
     * @param value
     * @param context
     * @return true 通过 false 未通过
     */
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
//        System.out.println("传递的值: "+value);
//        System.out.println("使用的CaseMode: "+caseMode);
        if (value == null){
            return true;
        }
        boolean isValid;
        if (CaseMode.UPPER.equals(caseMode)){
            //如果都是大写,那转换大写后的结果应该与原字符串一致
            isValid = value.equals(value.toUpperCase());
        }else {
            //如果都是小写,那转换小写后的结果应该与原字符串一致
            isValid = value.equals(value.toLowerCase());
        }
        if (!isValid){
            //自定义错误信息
            //1.关闭默认的校验信息,即CheckCase注解中的message信息
            context.disableDefaultConstraintViolation();
            //2.自定义错误信息
            context.buildConstraintViolationWithTemplate("自定义错误:车牌号必须都是大写")
                    .addPropertyNode("error").addConstraintViolation();
        }
        return isValid;
    }
}

测试类:

    @Test
    public void testMyValid(){
        Car5 car5 = new Car5(null,null,1);
        Set<ConstraintViolation<Car5>> validate = validator.validate(car5);
         for (ConstraintViolation<RentalCar> carConstraintViolation : validate) {
            System.out.println("共有"+validate.size()+"条错误信息");
            System.out.println("错误字段:"+carConstraintViolation.getPropertyPath());
            System.out.println("错误信息:"+carConstraintViolation.getMessage());
        }
    }

2.6 ExecutableValidator - 用于验证参数和返回值的validator

2.6.1 获取一个ExecutableValidator实例

在这里插入图片描述

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
ExecutableValidator executableValidator = factory.getValidator().forExecutables();

2.6.2 ExecutableValidator方法

用于方法验证:

  • validateParameters() 入参验证
  • validateReturnValue() 返回值验证
    用于构造函数验证:
  • validateConstructorParameters() 构造函数入参验证
  • validateConstructorReturnValue() 返回值验证
2.6.2.1 ExecutableValidator#validateParameters()

分别有三个关键参数

object被校验类Object对象
method被校验方法Method对象
parameterValues参数数组Object[]

示例代码:

Car object = new Car( "Morris" );
Method method = Car.class.getMethod( "drive", int.class );
Object[] parameterValues = { 80 };
Set<ConstraintViolation<Car>> violations = executableValidator.validateParameters(
        object,
        method,
        parameterValues
);
assertEquals( 1, violations.size() );
Class<? extends Annotation> constraintType = violations.iterator()
        .next()
        .getConstraintDescriptor()
        .getAnnotation()
        .annotationType();
assertEquals( Max.class, constraintType );
2.6.2.2 ExecutableValidator#validateReturnValue()

分别有三个关键参数

object被校验类Object对象
method被校验方法Method对象
returnValue返回值Objec对象

示例代码:

Car object = new Car( "Morris" );
Method method = Car.class.getMethod( "getPassengers" );
Object returnValue = Collections.<Passenger>emptyList();
Set<ConstraintViolation<Car>> violations = executableValidator.validateReturnValue(
        object,
        method,
        returnValue
);
assertEquals( 1, violations.size() );
Class<? extends Annotation> constraintType = violations.iterator()
        .next()
        .getConstraintDescriptor()
        .getAnnotation()
        .annotationType();
assertEquals( Size.class, constraintType );
2.6.2.3 ExecutableValidator#validateConstructorParameters()
Constructor<Car> constructor = Car.class.getConstructor( String.class );
Object[] parameterValues = { null };
Set<ConstraintViolation<Car>> violations = executableValidator.validateConstructorParameters(
        constructor,
        parameterValues
);
assertEquals( 1, violations.size() );
Class<? extends Annotation> constraintType = violations.iterator()
        .next()
        .getConstraintDescriptor()
        .getAnnotation()
        .annotationType();
assertEquals( NotNull.class, constraintType );

2.6.2.4 ExecutableValidator#validateConstructorReturnValue()
//constructor for creating racing cars
Constructor constructor = Car.class.getConstructor( String.class, String.class );
Car createdObject = new Car( “Morris”, null );
Set<ConstraintViolation> violations = executableValidator.validateConstructorReturnValue(
constructor,
createdObject
);
assertEquals( 1, violations.size() );
Class<? extends Annotation> constraintType = violations.iterator()
.next()
.getConstraintDescriptor()
.getAnnotation()
.annotationType();
assertEquals( ValidRacingCar.class, constraintType );

2.7 message自定义

2.7.1 在注解的参数列表里面都会有message()字样,定义message

public class Car1 {
    @NotNull(message = "制造商名称不能为空")
    private String manufacturer;//制造商
    @AssertTrue(message = "出厂时,必须注册,设置为true")
    private boolean isRegistered;//是否进行了注册
    public Car1(@NotNull String manufacturer, @AssertTrue boolean isRegistered) {
        this.manufacturer = manufacturer;
        this.isRegistered = isRegistered;
    }
}

2.7.2 自定义ValidationMessages.properties(可以支持动态的错误提示)

在resources下新创建一个properties,命名为ValidationMessages.properties
在这里插入图片描述

# 先用工具转换成unicode 然后把其中的动态参数使用{}包起来,即可动态展示了
com.hv.domain.Car.licensePlate = \u8f66\u724c\u53f7\u5fc5\u987b\u5728{min}\u5230{max}\u4f4d\u4e4b\u95f4
public class Car5 {
    /**
     * 这里的CarChecks.class表示一个组,校验的时候可以针对标注组的标签进行判断
     */
    @NotNull(message = "制造商不能为空",groups = CarChecks.class)
    private String manufacturer;//制造商
    @NotNull
    //注意这一行 min、max都是属于@Size注解的动态参数
    //message的参数用{}中的key就是properties里面配置的自定义message
    @Size(min = 2, max = 14,message = "{com.hv.domain.Car.licensePlate}")
    private String licensePlate;//车牌号
    @Min(2)
    private int seatCount;//座位
    public Car5(String manufacturer, String licensePlate, int seatCount) {
        this.manufacturer = manufacturer;
        this.licensePlate = licensePlate;
        this.seatCount = seatCount;
    }
 //...
}

测试类:

@Test
    public void testMyValid(){
        Car5 car5 = new Car5(null,"1",1);
        Set<ConstraintViolation<Car5>> validate = validator.validate(car5);
        for (ConstraintViolation<Car5> carConstraintViolation : validate) {
            System.out.println("共有"+validate.size()+"条错误信息");
            System.out.println("校验字段出现错误:字段:"+carConstraintViolation.getPropertyPath()+carConstraintViolation.getMessage());
        }
    }

输出结果:
共有2条错误信息
校验字段出现错误:字段:licensePlate车牌号必须在2到14位之间 (这里就是我们在配置文件中动态错误提示)
校验字段出现错误:字段:seatCount最小不能小于2

三.与Spring AOP整合,在Service层支持Bean Validation(用于网关层)

目前在Spring中暂时支持到Spring-mvc,在Service层还没有得到支持,同时,结合公司的框架,也是属于Service层,刚好可以利用ExecutableValidator校验器和Spring AOP来帮助我们解决这个问题。
具体的配置如下了。

@Configuration
@Aspect
@Slf4j
public class AdviceConfiguration{
    //定义Service使用validate
    private static Validator validator;
    static {
        validator = Validation.byDefaultProvider()
                .configure()
                .buildValidatorFactory()
                .getValidator();
    }
    @Pointcut(value = "execution(public * com.xzk.blog.service..*(..))")
    private void validateMethod(){
    }
    public  void beforeValidCheck2(JoinPoint joinPoint){
        System.out.println(joinPoint);
        Object[] args = joinPoint.getArgs();
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        // 执行方法参数的校验
        ExecutableValidator executableValidator = validator.forExecutables();
        Set<ConstraintViolation<Object>> constraintViolations = executableValidator.validateParameters(joinPoint.getThis(), signature.getMethod(), args);
        List<String> messages = Lists.newArrayList();
        for (ConstraintViolation<Object> error : constraintViolations) {
            messages.add(error.getMessage());
        }
        if(!messages.isEmpty()){
            throw new RuntimeException(JSONObject.toJSONString(messages));
        }
}

使用方法,直接在接口参数列表添加Validation注解。
如果是接口的形式
在这里插入图片描述

没有接口的形式:
在这里插入图片描述

⚠️注意事项:使用注解时,一定要放在接口层,实现类可加可不加,不能只在实现类加,接口层不加!

四.补充

本次的内容不算很多,还有好多特性,像group、ValueExtractor等概念和一些验证器的加载(SPI)等内容还未提到, 更多的内容,我已经放到了附录文档,便于各位学习,文档永远是学习框架最快的。

Hibernate Validator 6.0英文原版文档链接
https://docs.jboss.org/hibernate/validator/6.0/reference/en-US/html_single/
打不开,PDF下载链接
📎hibernate_validator_reference.pdf.Bean Validation 数据校验实战

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值