使用Spring Boot和Kotlin构建web应用程序 --- 基于IntelliJ IDEA

英文原文在这里!内容略有删减或增加。


本教程向您展示如何通过结合Spring Boot和Kotlin的功能来高效地构建示例博客应用程序。

创建新项目

首先,我们需要创建一个Spring Boot应用程序,这可以通过多种方式完成。

  • 使用Initializer网站
  • 使用命令行
  • 使用IntelliJ IDEA(本文使用这种方式)

使用IntelliJ IDEA

Spring Initializer也集成在IntelliJ IDEA Ultimate版本中,允许您创建和导入新项目,而无需离开IDE去使用命令行或web UI。要访问向导,依次点击File | New | Project,然后选择“Spring Initializer”。

按照向导的步骤使用以下参数:

  • Artifact: “blog”
  • Type: “Gradle - Kotlin”
  • Language: Kotlin
  • Name: “Blog”
  • Dependencies: “Spring Web Starter”, “Mustache”, “Spring Data JPA”, “H2 Database” and “Spring Boot DevTools”

如下图:
在这里插入图片描述
点击"Next",选择依赖项:
在这里插入图片描述
点击“Create”,开始创建并初始化项目。

了解Gradle构建

Plugins(插件)

除了明显的Kotlin-Gradle插件外,默认配置还声明了kotlin-spring插件,它会自动打开带有Spring注释的类和方法(与Java不同,Kotlin中的默认限定符是final)或元注释。这对于创建@Configuration或@Transactional bean非常有用,而不必添加CGLIB代理所需的open限定符。

为了能够在JPA中使用Kotlin非空属性,还启用了Kotlin JPA插件。它为任何带有@Entity、@MappedSuperclass或@Embeddable注释的类生成无参数构造函数。

build.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
  id("org.springframework.boot") version "3.2.2"
  id("io.spring.dependency-management") version "1.1.4"
  kotlin("jvm") version "1.9.22"
  kotlin("plugin.spring") version "1.9.22"
  kotlin("plugin.jpa") version "1.9.22"
}

编译器选项

Kotlin的一个关键特性是null安全性,它在编译时干净地处理null值,而不是在运行时遇到著名的NullPointerException。这使得应用程序通过可空性声明和表达“有值或无值”语义变得更安全,而无需支付像Optional这样的包装器的成本。注意,Kotlin允许使用具有可为null值的函数结构;查看这份全面的Kotlin null安全指南

虽然Java不允许在其类型系统中表达null-safety,但Spring Framework通过org.springframework.lang包中声明的工具友好注释提供了整个Spring Framework API的null-ssafety。默认情况下,Kotlin中使用的Java API中的类型被识别为放宽空检查的平台类型。Kotlin对JSR305注释的支持+Spring nullability注释为Kotlin开发人员提供了整个Spring Framework API的null-safety,其优点是在编译时处理与null相关的问题。

可以通过添加带有严格选项的“-Xjsr305”编译器标志来启用此功能。

build.gradle.kts

tasks.withType<KotlinCompile> {
  kotlinOptions {
    freeCompilerArgs += "-Xjsr305=strict"
  }
}

依赖项

对于这样的Spring Boot web应用程序,需要2个Kotlin特定的库(标准库是通过Gradle自动添加的),并在默认情况下进行配置:

  • Kotlin -reflect是Kotlin反射库
  • jackson-module-kotlin增加了对Kotlin类和数据类的序列化/反序列化的支持(可以自动使用单个构造函数类,也支持具有二级构造函数或静态工厂的类)

build.gradle.kts

dependencies {
  implementation("org.springframework.boot:spring-boot-starter-data-jpa")
  implementation("org.springframework.boot:spring-boot-starter-mustache")
  implementation("org.springframework.boot:spring-boot-starter-web")
  implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
  implementation("org.jetbrains.kotlin:kotlin-reflect")
  runtimeOnly("com.h2database:h2")
  runtimeOnly("org.springframework.boot:spring-boot-devtools")
  testImplementation("org.springframework.boot:spring-boot-starter-test")
}

H2的最新版本需要特殊配置才能正确地转义”user“等保留关键字。

src/main/resources/application.properties

spring.jpa.properties.hibernate.globally_quoted_identifiers=true
spring.jpa.properties.hibernate.globally_quoted_identifiers_skip_column_definitions=true

Spring Boot Gradle插件自动使用通过Kotlin Gradle插件声明的Kotlin版本。

现在,您可以更深入地查看生成的应用程序。

了解生成的应用程序

src/main/kotlin/com/example/blog/BlogApplication.kt

//与Java相比,您可以注意到没有分号,空类上没有括号
//(如果需要通过@Bean注释声明Bean,可以添加一些括号),
//以及使用runApplication顶级函数。
package com.example.blog

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class BlogApplication

fun main(args: Array<String>) {
//runApplication<BlogApplication>(*args)是
//SpringApplication.run(BlogApplication::class.java, *args)的Kotlin惯用替代品
  runApplication<BlogApplication>(*args)
}

可以使用以下语法自定义应用程序:

fun main(args: Array<String>) {
  runApplication<BlogApplication>(*args) {
    setBannerMode(Banner.Mode.OFF)
  }
}

编写您的第一个Kotlin控制器

让我们创建一个简单的控制器来显示一个简单网页。
src/main/kotlin/com/example/blog/HtmlController.kt

package com.example.blog

import org.springframework.stereotype.Controller
import org.springframework.ui.Model
//在这里,我们导入org.springframework.ui.set扩展函数,
//以便能够编写model[“title”]=“Blog”,
//而不是model.addAttribute(“title”,“Blog)。
import org.springframework.ui.set
import org.springframework.web.bind.annotation.GetMapping
//请注意,我们在这里使用了一个Kotlin扩展,它允许将Kotlin函数或运算符添加到现有的Spring类型中。
@Controller
class HtmlController {

  @GetMapping("/")
  fun blog(model: Model): String {
    model["title"] = "Blog"
    return "blog"
  }

}

Spring Framework KDoc API列出了为丰富Java API而提供的所有Kotlin扩展。

我们还需要创建相关的Mustache模板。
src/main/resources/templates/header.mustache

<html>
<head>
  <title>{{title}}</title>
</head>
<body>

src/main/resources/templates/footer.mustache

</body>
</html>

src/main/resources/templates/blog.mustache

{{> header}}

<h1>{{title}}</h1>

{{> footer}}

通过运行BlogApplication.kt的主要功能启动web应用程序,然后浏览器打开http://localhost:8080/网址,你应该看到一个简单的网页,标题是“博客”。
在这里插入图片描述
在这里插入图片描述

使用JUnit 5进行测试

JUnit 5现在在Spring Boot中默认使用,它提供了各种与Kotlin非常方便的特性,包括自动装配构造函数/方法参数,允许使用非空的val属性,以及在常规非静态方法上使用@BeforeAll/@AfterAll的可能性。

在Kotlin中编写JUnit 5测试

为了这个例子,让我们创建一个集成测试来演示各种功能:

  • 我们在反引号之间使用真实的句子,而不是驼峰式的大小写,以提供富有表现力的测试函数名
  • JUnit 5允许注入构造函数和方法参数,这非常适合Kotlin只读和非空属性
  • 这段代码利用了getForObject和getForEntity Kotlin扩展(您需要导入它们)

src/test/kotlin/com/example/blog/IntegrationTests.kt

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {

  @Test
  fun `Assert blog page title, content and status code`() {
    val entity = restTemplate.getForEntity<String>("/")
    assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
    assertThat(entity.body).contains("<h1>Blog</h1>")
  }

}

测试实例生命周期

有时,您需要在给定类的所有测试之前或之后执行一个方法。与Junit 4一样,Junit 5默认情况下要求这些方法是静态的(这转换为Kotlin中的伴随对象,这非常冗长且不简单),因为每次测试都会实例化一次测试类。

但是Junit 5允许您更改这种默认行为,并为每个类实例化一次测试类。这可以通过多种方式实现,在这里我们将使用属性文件来更改整个项目的默认行为:
src/test/resources/junit-platform.properties

junit.jupiter.testinstance.lifecycle.default = per_class

有了这个配置,我们现在可以在常规方法上使用@BeforeAll和@AfterAll注释,就像上面的IntegrationTests的更新版本所示。
src/test/kotlin/com/example/blog/IntegrationTests.kt

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {

  @BeforeAll
  fun setup() {
    println(">> Setup")
  }

  @Test
  fun `Assert blog page title, content and status code`() {
    println(">> Assert blog page title, content and status code")
    val entity = restTemplate.getForEntity<String>("/")
    assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
    assertThat(entity.body).contains("<h1>Blog</h1>")
  }

  @Test
  fun `Assert article page title, content and status code`() {
    println(">> TODO")
  }

  @AfterAll
  fun teardown() {
    println(">> Tear down")
  }

}

创建自己的扩展

Kotlin通常通过Kotlin扩展提供这些功能,而不是像Java中那样将util类与抽象方法一起使用。在这里,我们将向现有的LocalDateTime类型添加一个format()函数,以便生成英文日期格式的文本。
src/main/kotlin/com/example/blog/Extensions.kt

fun LocalDateTime.format(): String = this.format(englishDateFormatter)

private val daysLookup = (1..31).associate { it.toLong() to getOrdinal(it) }

private val englishDateFormatter = DateTimeFormatterBuilder()
    .appendPattern("yyyy-MM-dd")
    .appendLiteral(" ")
    .appendText(ChronoField.DAY_OF_MONTH, daysLookup)
    .appendLiteral(" ")
    .appendPattern("yyyy")
    .toFormatter(Locale.ENGLISH)

private fun getOrdinal(n: Int) = when {
  n in 11..13 -> "${n}th"
  n % 10 == 1 -> "${n}st"
  n % 10 == 2 -> "${n}nd"
  n % 10 == 3 -> "${n}rd"
  else -> "${n}th"
}

fun String.toSlug() = lowercase(Locale.getDefault())
    .replace("\n", " ")
    .replace("[^a-z\\d\\s]".toRegex(), " ")
    .split(" ")
    .joinToString("-")
    .replace("-+".toRegex(), "-")

我们将在下一节中利用这些扩展。

JPA持久化

为了使延迟获取按预期工作,entities 应该是open,如KT-28525中所述。为此,我们将使用Kotlin allopen插件。
build.gradle.kts

plugins {
  ...
  kotlin("plugin.allopen") version "1.9.22"
}

allOpen {
  annotation("jakarta.persistence.Entity")
  annotation("jakarta.persistence.Embeddable")
  annotation("jakarta.persistence.MappedSuperclass")
}

然后,我们通过使用Kotlin主构造函数简明语法来创建我们的模型,该语法允许同时声明属性和构造函数参数。
src/main/kotlin/com/example/blog/Entities.kt

@Entity
class Article(
    var title: String,
    var headline: String,
    var content: String,
    @ManyToOne var author: User,
    var slug: String = title.toSlug(),
    var addedAt: LocalDateTime = LocalDateTime.now(),
    @Id @GeneratedValue var id: Long? = null)

@Entity
class User(
    var login: String,
    var firstname: String,
    var lastname: String,
    var description: String? = null,
    @Id @GeneratedValue var id: Long? = null)

请注意,我们在这里使用String.toSlug()扩展为Article构造函数的slug参数提供默认参数。具有默认值的可选参数在最后一个位置定义,以便在使用位置参数时可以省略它们(Kotlin也支持命名参数)。请注意,在Kotlin中,将简明的类声明分组在同一文件中并不罕见。

在这里,我们不使用具有val属性的数据类,因为JPA不是为处理不可变类或数据类自动生成的方法而设计的。如果您正在使用其他Spring Data风格,那么它们中的大多数都是为支持此类构造而设计的,因此当您使用Spring Data MongoDB、Spring Data JDBC等时,应该使用数据类data class User(val login: String, …​)。

虽然Spring Data JPA可以通过Persistable使用自然ID(它可能是User类中的登录属性),但由于KT-6653的原因,它不太适合Kotlin,这就是为什么建议始终使用Kotlin中具有生成ID的实体。

我们还声明我们的Spring Data JPA存储库如下。
src/main/kotlin/com/example/blog/Repositories.kt

interface ArticleRepository : CrudRepository<Article, Long> {
  fun findBySlug(slug: String): Article?
  fun findAllByOrderByAddedAtDesc(): Iterable<Article>
}

interface UserRepository : CrudRepository<User, Long> {
  fun findByLogin(login: String): User?
}

我们编写JPA测试来检查基本用例是否按预期工作。
src/test/kotlin/com/example/blog/RepositoriesTests.kt

@DataJpaTest
class RepositoriesTests @Autowired constructor(
    val entityManager: TestEntityManager,
    val userRepository: UserRepository,
    val articleRepository: ArticleRepository) {

  @Test
  fun `When findByIdOrNull then return Article`() {
    val johnDoe = User("johnDoe", "John", "Doe")
    entityManager.persist(johnDoe)
    val article = Article("Lorem", "Lorem", "dolor sit amet", johnDoe)
    entityManager.persist(article)
    entityManager.flush()
    val found = articleRepository.findByIdOrNull(article.id!!)
    assertThat(found).isEqualTo(article)
  }

  @Test
  fun `When findByLogin then return User`() {
    val johnDoe = User("johnDoe", "John", "Doe")
    entityManager.persist(johnDoe)
    entityManager.flush()
    val user = userRepository.findByLogin(johnDoe.login)
    assertThat(user).isEqualTo(johnDoe)
  }
}

我们在这里使用默认情况下与Spring Data一起提供的CrudRepository.findByIdOrNull Kotlin扩展,它是基于可选的CrudReplository.findById的可为null的变体。

实现博客引擎

我们更新“博客”Mustache模板。
src/main/resources/templates/blog.mustache

{{> header}}

<h1>{{title}}</h1>

<div class="articles">

  {{#articles}}
    <section>
      <header class="article-header">
        <h2 class="article-title"><a href="/article/{{slug}}">{{title}}</a></h2>
        <div class="article-meta">By  <strong>{{author.firstname}}</strong>, on <strong>{{addedAt}}</strong></div>
      </header>
      <div class="article-description">
        {{headline}}
      </div>
    </section>
  {{/articles}}
</div>

{{> footer}}

我们创建了一个新的“article”。
src/main/resources/templates/article.mustache

{{> header}}

<section class="article">
  <header class="article-header">
    <h1 class="article-title">{{article.title}}</h1>
    <p class="article-meta">By  <strong>{{article.author.firstname}}</strong>, on <strong>{{article.addedAt}}</strong></p>
  </header>

  <div class="article-description">
    {{article.headline}}

    {{article.content}}
  </div>
</section>

{{> footer}}

我们更新了HtmlController,以便用格式化的日期呈现博客和文章页面。由于HtmlController只有一个构造函数(隐式的@Autowired), ArticleRepository和MarkdownConverter构造函数参数将自动装配。

src/main/kotlin/com/example/blog/HtmlController.kt

@Controller
class HtmlController(private val repository: ArticleRepository) {

  @GetMapping("/")
  fun blog(model: Model): String {
    model["title"] = "Blog"
    model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.render() }
    return "blog"
  }

  @GetMapping("/article/{slug}")
  fun article(@PathVariable slug: String, model: Model): String {
    val article = repository
        .findBySlug(slug)
        ?.render()
        ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")
    model["title"] = article.title
    model["article"] = article
    return "article"
  }

  fun Article.render() = RenderedArticle(
      slug,
      title,
      headline,
      content,
      author,
      addedAt.format()
  )

  data class RenderedArticle(
      val slug: String,
      val title: String,
      val headline: String,
      val content: String,
      val author: User,
      val addedAt: String)

}

然后,我们将数据初始化添加到一个新的BlogConfiguration类中。
src/main/kotlin/com/example/blog/BlogConfiguration.kt

@Configuration
class BlogConfiguration {

  @Bean
  fun databaseInitializer(userRepository: UserRepository,
              articleRepository: ArticleRepository) = ApplicationRunner {

    val johnDoe = userRepository.save(User("johnDoe", "John", "Doe"))
    articleRepository.save(Article(
        title = "Lorem",
        headline = "Lorem",
        content = "dolor sit amet",
        author = johnDoe
    ))
    articleRepository.save(Article(
        title = "Ipsum",
        headline = "Ipsum",
        content = "dolor sit amet",
        author = johnDoe
    ))
  }
}

请注意命名参数的使用,以提高代码的可读性。

我们还相应地更新了集成测试。
src/test/kotlin/com/example/blog/IntegrationTests.kt

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {

  @BeforeAll
  fun setup() {
    println(">> Setup")
  }

  @Test
  fun `Assert blog page title, content and status code`() {
    println(">> Assert blog page title, content and status code")
    val entity = restTemplate.getForEntity<String>("/")
    assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
    assertThat(entity.body).contains("<h1>Blog</h1>", "Lorem")
  }

  @Test
  fun `Assert article page title, content and status code`() {
    println(">> Assert article page title, content and status code")
    val title = "Lorem"
    val entity = restTemplate.getForEntity<String>("/article/${title.toSlug()}")
    assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
    assertThat(entity.body).contains(title, "Lorem", "dolor sit amet")
  }

  @AfterAll
  fun teardown() {
    println(">> Tear down")
  }

}

公开HTTP API

我们现在将通过@RestController注释控制器实现HTTP API。
src/main/kotlin/com/example/blog/HttpControllers.kt

@RestController
@RequestMapping("/api/article")
class ArticleController(private val repository: ArticleRepository) {

  @GetMapping("/")
  fun findAll() = repository.findAllByOrderByAddedAtDesc()

  @GetMapping("/{slug}")
  fun findOne(@PathVariable slug: String) =
      repository.findBySlug(slug) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")

}

@RestController
@RequestMapping("/api/user")
class UserController(private val repository: UserRepository) {

  @GetMapping("/")
  fun findAll() = repository.findAll()

  @GetMapping("/{login}")
  fun findOne(@PathVariable login: String) =
      repository.findByLogin(login) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This user does not exist")
}

对于测试,我们将利用@WebMvcTest和Mockk,而不是集成测试,这与Mockito相似,但更适合Kotlin。

由于@MockBean和@SpyBean注释是Mockito特有的,我们将利用SpringMockK,它为Mockk提供类似的@MockkBean和@SpykBean注释。

build.gradle.kts

testImplementation("org.springframework.boot:spring-boot-starter-test") {
  exclude(module = "mockito-core")
}
testImplementation("org.junit.jupiter:junit-jupiter-api")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testImplementation("com.ninja-squad:springmockk:4.0.2")

src/test/kotlin/com/example/blog/HttpControllersTests.kt

@WebMvcTest
class HttpControllersTests(@Autowired val mockMvc: MockMvc) {

  @MockkBean
  lateinit var userRepository: UserRepository

  @MockkBean
  lateinit var articleRepository: ArticleRepository

  @Test
  fun `List articles`() {
    val johnDoe = User("johnDoe", "John", "Doe")
    val lorem5Article = Article("Lorem", "Lorem", "dolor sit amet", johnDoe)
    val ipsumArticle = Article("Ipsum", "Ipsum", "dolor sit amet", johnDoe)
    every { articleRepository.findAllByOrderByAddedAtDesc() } returns listOf(lorem5Article, ipsumArticle)
    mockMvc.perform(get("/api/article/").accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk)
        .andExpect(content().contentType(MediaType.APPLICATION_JSON))
        .andExpect(jsonPath("\$.[0].author.login").value(johnDoe.login))
        .andExpect(jsonPath("\$.[0].slug").value(lorem5Article.slug))
        .andExpect(jsonPath("\$.[1].author.login").value(johnDoe.login))
        .andExpect(jsonPath("\$.[1].slug").value(ipsumArticle.slug))
  }

  @Test
  fun `List users`() {
    val johnDoe = User("johnDoe", "John", "Doe")
    val janeDoe = User("janeDoe", "Jane", "Doe")
    every { userRepository.findAll() } returns listOf(johnDoe, janeDoe)
    mockMvc.perform(get("/api/user/").accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk)
        .andExpect(content().contentType(MediaType.APPLICATION_JSON))
        .andExpect(jsonPath("\$.[0].login").value(johnDoe.login))
        .andExpect(jsonPath("\$.[1].login").value(janeDoe.login))
  }
}

$需要在字符串中转义,因为它用于字符串插值。

配置属性

在Kotlin中,建议使用只读属性来管理应用程序属性。
src/main/kotlin/com/example/blog/BlogProperties.kt

@ConfigurationProperties("blog")
data class BlogProperties(var title: String, val banner: Banner) {
  data class Banner(val title: String? = null, val content: String)
}

然后我们在BlogApplication级别启用它。
src/main/kotlin/com/example/blog/BlogApplication.kt

@SpringBootApplication
@EnableConfigurationProperties(BlogProperties::class)
class BlogApplication {
  // ...
}

为了生成您自己的元数据以便让IDE识别这些自定义属性,kapt应该使用以下spring-boot配置处理器依赖项进行配置。
build.gradle.kts

plugins {
  ...
  kotlin("kapt") version "1.9.22"
}

dependencies {
  ...
  kapt("org.springframework.boot:spring-boot-configuration-processor")
}

请注意,由于kapt提供的模型中的限制,一些功能(如检测默认值或不推荐使用的项)无法工作。此外,由于KT-18022,Maven还不支持注释处理,请参阅initializr#438了解更多详细信息。

在IntelliJ IDEA中:

  • 确保在菜单“File | Settings | Plugins | Spring Boot”中启用了Spring Boot插件
  • 通过菜单“File | Settings | Build, Execution, Deployment | Compiler | Annotation Processors | Enable annotation processing”启用注释处理
  • 由于Kapt尚未集成在IDEA中,因此需要手动运行该命令./gradlew-kaptKotlin生成元数据

现在,在编辑application.properties(自动完成、验证等)时,应该可以识别您的自定义属性。
src/main/resources/application.properties

blog.title=Blog
blog.banner.title=Warning
blog.banner.content=The blog will be down tomorrow.

相应地编辑模板和控制器。
src/main/resources/templates/blog.mustache

{{> header}}

<div class="articles">

  {{#banner.title}}
  <section>
    <header class="banner">
      <h2 class="banner-title">{{banner.title}}</h2>
    </header>
    <div class="banner-content">
      {{banner.content}}
    </div>
  </section>
  {{/banner.title}}

  ...

</div>

{{> footer}}

src/main/kotlin/com/example/blog/HtmlController.kt

@Controller
class HtmlController(private val repository: ArticleRepository,
           private val properties: BlogProperties) {

  @GetMapping("/")
  fun blog(model: Model): String {
    model["title"] = properties.title
    model["banner"] = properties.banner
    model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.render() }
    return "blog"
  }

  // ...

重新启动web应用程序,刷新http://localhost:8080/,你应该在博客主页上看到横幅。
在这里插入图片描述

小结

我们现在已经完成了这个示例Kotlin博客应用程序的构建。源代码可在Github上获得。如果您需要有关特定功能的更多详细信息,还可以查看Spring Framework Kotlin支持文档Spring Boot参考文档

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值