Java单元测试实践-22.Gradle资源文件与配置参数动态替换

Java单元测试实践-00.目录(9万多字文档+700多测试示例)
https://blog.csdn.net/a82514921/article/details/107969340

1. Gradle资源文件与配置参数动态替换

1.1. main模块与test模块资源文件

Gradle任务processResources、processTestResources分别用于复制main模块与test模块的资源文件。

1.1.1. 输入输出文件

在processResources、processTestResources任务中,可以通过getInputs()/getOutputs()方法获取输入输出对应的文件信息。

示例如下:

processResources {
    println "inputs: " + getInputs().getFiles().asList()
    println "outputs: " + getOutputs().getFiles().asList()
}

用于复制main模块资源文件的processResources任务打印结果如下所示,可以看到输入文件为main模块resources目录的全部文件(Gradle脚本中未指定排除特定文件),输出目录为“build\resources\main”。

inputs: [E:\UnitTest\src\main\resources\applicationContext.xml, E:\UnitTest\src\main\resources\base.properties, E:\UnitTest\src\main\resources\com\adrninistrator\dao\sqlmap\TestTable2Mapper.xml, E:\UnitTest\src\main\resources\com\adrninistrator\dao\sqlmap\TestTableMapper.xml, E:\UnitTest\src\main\resources\log4j2.xml]

outputs: [E:\UnitTest\build\resources\main]

用于复制test模块资源文件的processTestResources任务打印结果如下所示,可以看到输入文件为main模块resources目录的全部文件(Gradle脚本中未指定排除特定文件),输出目录为“build\resources\test”。

inputs: [E:\UnitTest\src\test\resources\applicationContext.xml, E:\UnitTest\src\test\resources\applicationContext_replace.xml, E:\UnitTest\src\test\resources\base.properties, E:\UnitTest\src\test\resources\base_replace.properties, E:\UnitTest\src\test\resources\entityGen\jpa_modify_type.properties, E:\UnitTest\src\test\resources\entityGenConfig\entityGenConfig.yml, E:\UnitTest\src\test\resources\log4j2.xml, E:\UnitTest\src\test\resources\springhibernate\springhibernate.xml, E:\UnitTest\src\test\resources\sql\create_table.sql, E:\UnitTest\src\test\resources\sql\set_schema.sql, E:\UnitTest\src\test\resources\unit_test_config.groovy]

outputs: [E:\UnitTest\build\resources\test]

以上代码也可添加到compileJava、compileTestJava任务中,用于查看main模块与test模块编译产生的类文件的输出目录,默认情况下分别为“build\classes\java\main”“build\classes\java\test”;若项目使用了Lombok等插件,main模块与test模块类的输出目录还会分别增加“build\generated\sources\annotationProcessor\java\ma
in”“build\generated\sources\annotationProcessor\java\te
st”。

1.1.2. Gradle test任务执行时使用的资源文件

使用Gradle test任务执行测试类时,会优先使用test模块的资源文件,对于main模块与test模块均存在的同名资源文件,会使用test模块中的资源文件;对于仅在main模块中存在的同名资源文件,会使用main模块中的资源文件。

  • 查看GradleWorkerMain进程的classpath系统属性

使用jinfo命令查看GradleWorkerMain进程的java.class.path系统属性,main模块与test模块的相关目录如下所示。

build\classes\java\test
build\resources\test
build\classes\java\main
build\resources\main

可以看到test模块的类目录build\classes\java\test在main模块的类目录build\classes\java\main之前,test模块的资源文件目录build\resources\test在main模块的资源文件目录build\resources\main之前。

  • 在测试类中获取资源文件路径

applicationContext.xml文件在main模块与test模块的resources目录中均存在,在TestConf类中打印classpath中的applicationContext.xml文件路径,可证明使用了test模块中的资源文件,例如/E:/UnitTest/build/resources/test/applicationContext.xml”。

TestTableMapper.xml文件仅在main模块的resources目录中存在,在TestConf类中打印classpath中的TestTableMapper.xml文件路径,证明使用了main模块中的资源文件,例如“/E:/UnitTest/build/resources/main/com/adrninistrator/dao/sqlmap/TestTableMapper.xml”。

1.1.3. test模块资源文件设置

由于Gradle test任务执行时,优先使用test模块中的资源文件,可将main模块中的资源文件拷贝至test模块资源目录中并修改,使Gradle在执行测试类时使用test模块中的资源文件。

  • 使用与main模块不同的配置参数

test模块中的资源文件可以使用与main模块不同的配置参数,例如访问不同的依赖环境、使用不同的日志打印参数等。

  • 提高测试效率

test模块中的资源文件可将线程池大小调小,或将与测试无关的定时任务禁用,减少测试时的性能与时间开销。

1.2. Gradle脚本动态替换配置参数

1.2.1. 适用场景

动态替换配置参数的功能,可在以下场景使用(包括但不限于以下场景)。

  • 生成发布包时修改配置参数

生成不同环境使用的发布包时,可将配置参数替换为对应环境的参数。例如区分生产环境与测试环境。

  • 执行测试时修改配置参数

执行测试时,可将配置参数替换为对应环境的参数。例如在本地开发环境执行测试时,访问MySQL数据库;在CI服务器执行测试时,访问本机的H2数据库。

以下方法支持对main模块与test模块的文件内容进行动态替换。

1.2.2. 在filter中使用ReplaceTokens

  • filter()方法

参考Gradle对于替换文件内容的说明,“Filtering file content (token substitution, templating, etc.)”( https://docs.gradle.org/current/userguide/working_with_files.html#sec:filtering_files )。

使用文件内容过滤器,可以在复制文件时替换文件的内容。

filter()方法有两个变种,它们的行为不同:

一个使用FilterReader并与Ant过滤器(例如ReplaceTokens)一起使用;

另一个使用闭包或Transformer定义源文件每一行的转换。

请注意,以上两个变种都假定源文件是基于文本的。将replaceTokens类与filter()一起使用时,结果是模板引擎用定义的值替换@tokenName@形式的标记(Ant样式的标记)。

以上Gradle文档对于提供的示例如下:

filter(ReplaceTokens, tokens: [copyright: '2009', version: '2.3.1'])
  • ReplaceTokens类

参考ReplaceTokens类的API文档( https://ant.apache.org/manual/api/org/apache/tools/ant/filters/ReplaceTokens.html ),可以看到ReplaceTokens类间接继承自FilterReader类,因此可在filter()方法中使用。

查看Ant的org.apache.tools.ant.filters.ReplaceTokens类源码( https://github.com/apache/ant/blob/master/src/main/org/apache/tools/ant/filters/ReplaceTokens.java )。

其中包含setTokens()方法,参数为Hashtable<String, String> hash,该方法用于设置需要替换的标记Map,参数hash的key对应需要替换的标记,value为需要替换的值,参数hash不能为null。

根据以上内容可知,Gradle脚本filter()方法中的tokens参数用于设置ReplaceTokens类的属性,tokens属性支持Hashtable<String, String>类型。在Gradle脚本的filter()方法中使用ReplaceTokens类时,tokens参数也可以使用字符串形式"tokens",更容易理解。

1.2.3. 使用ConfigSlurper读取Groovy配置文件

上述Gradle文档中提供的替换文档的示例中,需要替换的标记及替换后的值,均声明在Gradle脚本中,也支持从配置文件读取。

  • ConfigSlurper类

参考Groovy的ConfigSlurper类( https://docs.groovy-lang.org/latest/html/documentation/#_configslurper )。

ConfigSlurper是一个工具类,可用于读取Groovy脚本形式的配置文件。Groovy配置文件与Java使用的.properties文件类似,ConfigSlurper允许使用点符号和闭包范围的配置值以及任意对象类型。

ConfigSlurper类的parse()方法可用于返回groovy.util.ConfigObject实例。ConfigObject是特殊的java.util.Map实现,它返回配置的值或新的ConfigObject实例,不可能为null。

Groovy配置文件示例如下,属性environments.development.app.port值为8080。

environments {
    development {
        app.port = 8080
    }

    test {
        app.port = 8082
    }

    production {
        app.port = 80
    }
}

在创建ConfigSlurper对象时,指定所需的第二级元素名称,调用parse()方法可获取到第二级元素下的元素。

例如对应上述Groovy配置文件,使用new ConfigSlurper(‘development’).parse()方法,可以获取到environments.development元素的值,即app.port = 8080。

"environments"是内置的,使用registerConditionalBlock()方法可以注册其他的方法名称,该方法的参数为“String blockName, String blockValue”,应分别指定第一级元素名称,与所需的第二级元素名称。示例如下:

def slurper = new ConfigSlurper()
slurper.registerConditionalBlock('environments', 'development')

参考ConfigSlurper类的API文档( http://docs.groovy-lang.org/latest/html/gapi/groovy/util/ConfigSlurper.html ),可以看到存在参数为“URL scriptLocation”的parse()方法,可指定File对象toURI().toURL()返回的URL对象,用于从指定的文件读取配置信息。

  • ConfigObject类

参考 https://docs.groovy-lang.org/latest/html/documentation/#_configslurper 。

在与Java集成时,可以使用toProperties()方法将ConfigObject转换为java.util.Properties对象。请注意,在将配置值添加到新创建的Properties实例时会将其转换为String实例

因此ConfigObject对象的toProperties()方法返回值,可在Gradle脚本的filter()方法作为ReplaceTokens类的tokens参数使用,即可以通过Groovy配置文件指定文件内容替换的相关数据

参考groovy.util.ConfigObject类的API文档( http://docs.groovy-lang.org/latest/html/gapi/groovy/util/ConfigObject.html )。

使用ConfigObject类的toProperties()方法,可以转换为java.util.Properties格式,会预先展平树结构。

假如有以下Groovy配置文件:

a {
    b {
        c1 {
            d1 = 'string'
            d2 = 123
            d3 = 1.23
            d4 = true
        }
        c3 = 1.23
        c4 = true
    }
}

使用以下代码读取上述Groovy配置文件的内容,获取ConfigObject对象后,调用toProperties()方法获取Properties对象。

Properties properties = new ConfigSlurper().parse(file("xxx.groovy").toURI().toURL()).toProperties()
for (Map.Entry entry : properties.entrySet()) {
    println(entry.getKey() + " *" + entry.getValue() + "* " + entry.getValue().getClass().getSimpleName())
}

执行结果如下:

a.b.c3 *[true, 123, string]* String
a.b.c2 *1.23* String
a.b.c1.d4 *true* String
a.b.c1.d3 *1.23* String
a.b.c1.d2 *123* String
a.b.c1.d1 *string* String

可以看到生成的Properties对象的key为Groovy配置文件中的数据的层级关系,每一级元素之间使用“.”分隔,value类型均为String。

对ConfigSlurper对象使用registerConditionalBlock()方法指定需要获取数据的名称,如下所示:

ConfigSlurper configSlurper = new ConfigSlurper()
configSlurper.registerConditionalBlock("a", "b")

执行结果如下:

c1.d4 *true* String
c3 *[true, 123, string]* String
c1.d3 *1.23* String
c2 *1.23* String
c1.d2 *123* String
c1.d1 *string* String

可以看到获取的Properties对象的key为a.b之下层级的元素。

1.2.4. 使用自定义标识符

使用ReplaceTokens类对文件内容进行替换时,默认使用“@”作为替换标记,待替换的原始数据格式为“@tokenName@”。例如待替换内容配置文件中包含“@a.b.c@”,Groovy配置文件中包含参数a.b.c=value,则执行替换操作后,被替换内容的配置文件中,“@tokenName@”会变成“value”。

查看ReplaceTokens类的API文档( https://ant.apache.org/manual/api/org/apache/tools/ant/filters/ReplaceTokens.html ),可以看到包含setBeginToken()、setEndToken()方法,分别用于指定开头及结尾的替换标志。

假如ReplaceTokens类默认的替换标志“@tokenName@”不满足使用要求时,可以通过在Gradle脚本的filter()方法指定ReplaceTokens类的beginToken与endToken属性。如下所示,将替换标志设置为“@@tokenName##”的形式。

filter(org.apache.tools.ant.filters.ReplaceTokens,
        "tokens": configObject.toProperties(),
        "beginToken": "@@",
        "endToken": "##")

1.2.5. 配置文件中文乱码问题

在Gradle脚本的filter()方法使用ReplaceTokens类对文件内容进行替换时,编码为UTF-8的文件中的中文内容在替换后会出现乱码,使Gradle执行时使用UTF-8编码,可解决该问题:

当使用Gradle Wrapper时,在项目中的gradlew.bat/gradlew文件中,在DEFAULT_JVM_OPTS参数增加"-Dfile.encoding=UTF-8";

当不使用Gradle Wrapper时,在Gradle安装目录的bin/gradlew.bat/gradlew文件中,在DEFAULT_JVM_OPTS参数增加"-Dfile.encoding=UTF-8"。

进行以上配置后,在Windows环境执行Gradle命令时,输出的中文会出现乱码(例如编译过程中的提示信息),这是因为Windows批处理窗口的默认编码不是UTF-8。

可在Windows批处理窗口执行“chcp 65001”命令,将编码设置为UTF-8,并将字体修改为True Type字体"Lucida Console",大部分中文不再乱码,但会出现重复。

1.2.6. 示例项目配置文件使用

在示例项目中,根据不同的测试模式(测试验证的内容不相同),需要使用不同的参数配置,在Gradle脚本中根据代表测试模式的参数,对资源文件内容进行替换,如下所示。

  • 根据JVM参数决定使用的参数

在unittest.gradle脚本的ext块中,使用执行Gradle任务时JVM参数中的“testMode”参数,作为当前测试模式,决定当前使用的配置参数。

当未指定该参数时,使用默认测试模式“default_mode”。

在执行Gradle任务时指定“testMode”参数示例如下:

gradlew test -DtestMode=fast
  • Groovy配置文件

在src/test/resources/unit_test_config.groovy配置文件中,配置了不同的测试模式对应的配置参数,第一级元素名称为“test_mode”,第二级元素代表不同的测试模式,如下所示:

test_mode{
    xxx {
        jdbc {
            driver = 数据库驱动名称
            url = 数据库地址
            username = 数据库用户名
            password = 数据库密码
        }
        import_jpa = 引入JPA配置
        test_include = 测试类包含范围(数组)
        test_exclude = 测试类排除范围(数组)
        jacoco_include = 生成代码覆盖率的类包含范围 ( 数组 )
        jacoco_exclude = 生成代码覆盖率的类排除范围 ( 数组 )
    }
}
  • 查看示例支持的testMode参数

unittest.gradle脚本中包含showTestMode任务,用于显示支持的测试模式。

  • 读取Groovy配置文件

在unittest.gradle脚本的ext块中,对Groovy配置文件进行了读取,如下所示:

ext {
    testConfigFilePath = "src/test/resources/unit_test_config.groovy"
    
    configSlurper = new ConfigSlurper()
    configSlurper.registerConditionalBlock("test_mode", testMode)

    configObject = configSlurper.parse(file(testConfigFilePath).toURI().toURL())
}
  • 替换配置文件内容

使用Gradle的test任务执行示例工程单元测试时,需要在使用不同的测试模式时,替换应用程序使用的applicationContext.xml、base.properties配置文件中的参数值。

若以上配置文件中包含替换文件内容时使用的标记,在通过IDE执行单元测试时,替换文件内容的操作不会执行,会导致应用程序无法正确读取配置文件。

因此新增配置文件applicationContext_replace.xml、base_replace.properties,在以上文件中指定替换文件内容时使用的标记,如下所示:

jdbc.driver=@jdbc.driver@
jdbc.url=@jdbc.url@
jdbc.username=@jdbc.username@
jdbc.password=@jdbc.password@

在unittest.gradle脚本的processTestResources任务中,在复制test模块资源文件“applicationContext_replace.xml”与“base_replace.properties”时,在filter()方法中指定通过ReplaceTokens类对文件内容进行替换,使用ext块中获取的configObject变量作为“tokens”参数,如下所示:

processTestResources {
    from(sourceSets.test.resources) {
        include "applicationContext_replace.xml", "base_replace.properties"
        filter(org.apache.tools.ant.filters.ReplaceTokens, "tokens": configObject.toProperties())
    }
}

filter()方法支持在from()方法中使用,或直接在processTestResources等任务中使用。

在from()方法中,也可不使用include指定需要替换内容的文件范围,即对test模块的全部资源文件均尝试进行替换(内容不含标识的文件不会被替换)。

sourceSets.test.resources也可使用sourceSets.test.resources.srcDirs。

  • 覆盖配置文件

在对配置文件applicationContext_replace.xml、base_replace.properties的文件内容进行替换后,需要覆盖应用程序使用的对应配置文件applicationContext.xml、base.properties,在processTestResources任务的doLast Action中,执行替换操作,如下所示:

processTestResources {
    doLast {
        file('build/resources/test/base.properties').renameTo(file('build/resources/test/base.properties.bak'))
        file('build/resources/test/base_replace.properties').renameTo(file('build/resources/test/base.properties'))
        file('build/resources/test/applicationContext.xml').renameTo(file('build/resources/test/applicationContext.xml.bak'))
        file('build/resources/test/applicationContext_replace.xml').renameTo(file('build/resources/test/applicationContext.xml'))
    }
}

1.2.7. 获取Groovy配置文件参数值

使用ConfigSlurper类读取Groovy配置文件中的数据时,获得的ConfigObject对象中包含了原始类型的数据(使用toProperties()方法时参数值类型会变为String)。

假如存在以下Groovy配置文件:

a {
    b {
        c1 {
            d1 = 'string\\1\'2'
            d2 = 123
            d3 = 1.23
            d4 = true
        }
        c2 = 1.23
        c3 = [true, 123, 'string']
    }
}

可通过以下代码进行读取:

ConfigObject configObject = new ConfigSlurper().parse(file("src/test/resources/groovyConfig/test.groovy").toURI().toURL())

ConfigObject configObjectA = configObject.get("a")
println "a: " + configObjectA + " " + configObjectA.getClass().getSimpleName()

ConfigObject configObjectB = configObjectA.get("b")
println "b: " + configObjectB + " " + configObjectB.getClass().getSimpleName()

ConfigObject configObjectC1 = configObjectB.get("c1")
println "c1: " + configObjectC1.getClass().getSimpleName()
for (Map.Entry entry : configObjectC1.entrySet()) {
    println "c1: " + entry.getKey() + " " + entry.getValue() + " " + entry.getValue().getClass().getSimpleName()
}

def c2 = configObjectB.get("c2")
println "c2: " + c2 + " " + c2.getClass().getSimpleName()

List c3 = configObjectB.get("c3")
println "c3: " + c3 + " " + c3.getClass().getSimpleName()
for (Object o : c3) {
    println "c3: " + o + " " + o.getClass().getSimpleName()
}

执行结果如下所示,可以看到从Groovy配置文件读取的数据保持了原有格式,ConfigObject为嵌套格式,参数值支持String、Integer、BigDecimal、Boolean等类型,支持数组格式。

a: [b:[c1:[d1:string\1'2, d2:123, d3:1.23, d4:true], c2:1.23, c3:[true, 123, string]]] ConfigObject
b: [c1:[d1:string\1'2, d2:123, d3:1.23, d4:true], c2:1.23, c3:[true, 123, string]] ConfigObject
c1: ConfigObject
c1: d1 string\1'2 String
c1: d2 123 Integer
c1: d3 1.23 BigDecimal
c1: d4 true Boolean
c2: 1.23 BigDecimal
c3: [true, 123, string] ArrayList
c3: true Boolean
c3: 123 Integer
c3: string String
1.2.7.1. Groovy特殊字符转义

根据以上现象可知,Groovy配置文件中的“\”“’”需要进行转义

参考 https://docs.groovy-lang.org/latest/html/documentation/#_escaping_special_characters 。

可以使用反斜杠字符“\”对单引号进行转义,以避免终止字符串文字:

'an escaped single quote: \' needs a backslash'

也可以用双反斜杠对转义字符进行转义:

'an escaped escape character: \\ needs a double backslash'

以下为使用反斜杠作为转义字符的特殊字符:

字符说明
\t制表符tabulation
\b退格键backspace
\n换行
\r回车
\f换页
\\反斜杠
\’单引号字符串中的单引号(对于三重单引号和双引号字符串是可选的)
\"双引号字符串中的双引号(三重双引号和单引号字符串是可选的)

1.2.8. 测试范围设置

若执行测试时需要根据参数区分测试范围时,可将测试范围保存在Groovy配置文件中,与Gradle脚本分离。

test任务中的filter()方法中的setIncludePatterns()/setExcludePatterns()方法参数不支持数组或集合形式,因此不使用。可使用setIncludes()/setExcludes()方法指定需要包含/排除的测试范围,与test任务中的include或exclude参数使用方法类似。

在示例项目中通过以上方式设置测试范围。

在Groovy配置文件中unit_test_config.groovy,配置了测试范围参数,如下所示:

test_include = ['**']
test_exclude = ['adrninistrator/test/testdatabase/**', 'adrninistrator/test/testmock/mybatis/**', '**/TestSuite**']

在unittest.gradle脚本的test任务的doFirst Action中,对测试范围进行了设置,如下所示:

def testInclude = configObject.get("test_include")
if (testInclude != null) {
    setIncludes(testInclude)
}

def testExclude = configObject.get("test_exclude")
if (testExclude != null) {
    setExcludes(testExclude)
}

1.3. 根据Gradle执行的任务改变操作

在某些情况下,需要根据Gradle执行的任务改变操作。例如当Gradle执行的任务包含a时,执行操作1;任务不包含a时,执行操作2。

通过以下代码可以获得Gradle当前执行的任务,可参考示例项目unittest.gradle文件中的gradleArgsContains()方法:

boolean gradleArgsContains(String... expectedArgs) {
    StartParameter startParameter = gradle.getStartParameter()

    if (startParameter.isDryRun()) {
        return false
    }

    List<TaskExecutionRequest> taskExecutionRequestList = startParameter.getTaskRequests()
    for (TaskExecutionRequest taskExecutionRequest : taskExecutionRequestList) {
        List<String> args = taskExecutionRequest.getArgs()
        for (String arg : args) {
            for (String expectedArg : expectedArgs) {
                if (arg.equals(expectedArg)) {
                    return true
                }
            }
        }
    }

    return false
}

以上使用的方法可参考 https://docs.gradle.org/current/dsl/org.gradle.api.tasks.GradleBuild.html#org.gradle.api.tasks.GradleBuild:startParameter 、 https://docs.gradle.org/current/javadoc/org/gradle/StartParameter.html#getTaskRequests-- 、 https://docs.gradle.org/current/javadoc/org/gradle/TaskExecutionRequest.html#getArgs-- 。

示例项目在以下任务中根据Gradle执行的任务改变操作,避免多余的操作:

  • processTestResources任务

在processTestResources任务中,使用gradleArgsContains()方法判断当执行的Gradle任务包含test任务时,才执行替换配置文件中的测试参数等操作。

  • jacocoTestReport任务

在jacocoTestReport任务中,使用gradleArgsContains()方法判断当执行的Gradle任务包含当前任务时,才执行设置需要生成覆盖率的代码范围操作。

参考 https://docs.gradle.org/current/javadoc/org/gradle/api/Task.html#getName-- ,可以使用getName()方法获取当前任务的名称。

在其他情况下,也可以判断Gradle执行的任务是否包含指定任务,优化Gradle任务的操作。

1.3.1. 解决processResources任务导致找不到资源文件的问题

某些情况下,在生成项目发布使用的jar包或war包时,只需要包含部分资源文件,可能会在processResources任务中通过include指定需要包含的资源文件,如下所示:

processResources {
	include 'com/**'
}

当进行以上配置后,在IDEA中执行单元测试,或正常的Java类时(可能需要清理out目录),以上配置也会生效,导致找不到所需的资源文件。

例如示例项目的main模块包含以下资源文件:

pic

执行MainServer类的main()方法,提示找不到applicationContext.xml文件,查看out/production/resources目录,仅包含以上配置指定的com目录中的资源文件,不包含其他资源文件。

pic

对processResources任务进行修改,判断Gradle执行的任务包含build、jar、war等任务时才执行include操作,如下所示:

processResources {
    if (gradleArgsContains("build", "jar", "war")) {
        include 'com/**'
    }
}

经过以上配置后,可以解决在IDEA执行单元测试,或正常的Java类时,由于processResources任务的配置导致找不到资源文件的问题。同时,生成项目发布使用的jar包或war包时,也能指定需要包含的资源文件。

再次执行示例项目的MainServer类,可以正常执行。执行“gradlew/gradle jar”命令生成jar包,查看其中包含的资源文件只有com目录中的,证明以上配置可以满足不同情况的需求。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值