Merge branch 'refactor/8-mvc-spec-core-jpa-refactoring-the-core-and-jpa-modules-of-spec-in-mvc' into 'main'

refactor(spec): 重构规范模块以提高可维护性和扩展性- 重新组织代码结构,将通用逻辑移至 spec-core 模块

Closes #8

See merge request gewuyou/forgeboot!13
This commit is contained in:
gewuyou 2025-07-25 06:43:25 +00:00
commit 75496b27ec
13 changed files with 357 additions and 184 deletions

View File

@ -1,3 +1,4 @@
dependencies {
api(project(Modules.Webmvc.DTO))
compileOnly(libs.springFramework.data.commons)
}

View File

@ -1,4 +1,4 @@
package com.gewuyou.webmvc.spec.jpa.extension
package com.gewuyou.webmvc.spec.core.extension
import com.gewuyou.forgeboot.webmvc.dto.PageResult
import org.springframework.data.domain.Page

View File

@ -1,4 +1,4 @@
package com.gewuyou.webmvc.spec.jpa.extension
package com.gewuyou.webmvc.spec.core.extension
import com.gewuyou.forgeboot.webmvc.dto.PageResult

View File

@ -0,0 +1,30 @@
package com.gewuyou.webmvc.spec.core.extension
import com.gewuyou.webmvc.spec.core.page.Pageable
import com.gewuyou.webmvc.spec.core.page.QueryComponent
import com.gewuyou.webmvc.spec.core.page.Sortable
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
/**
* QueryComponent 转换为 Spring Data PageRequest 对象
*
*
* @return PageRequest 分页请求对象包含分页和排序信息
*
* 重要逻辑说明
* 1. currentPage 1 开始计数转换为 Spring Data 0 开始的页码
* 2. pageSize 保持不变直接用于分页请求
* 3. 如果对象实现了 Sortable 接口则使用其排序条件否则使用未排序状态
* 4. 如果对象未实现 Pageable 接口则使用默认分页参数第一页每页10条
*/
fun QueryComponent.toPageRequest(): PageRequest {
val pageable = this as? Pageable
val sortable = this as? Sortable
// 默认分页参数
val page = pageable?.let { (it.currentPage - 1).coerceAtLeast(0) } ?: 0
val size = pageable?.pageSize ?: 10
// 排序规则
val sort = sortable?.let { Sort.by(it.toSortOrders()) } ?: Sort.unsorted()
return PageRequest.of(page, size, sort)
}

View File

@ -0,0 +1,12 @@
package com.gewuyou.webmvc.spec.core.extension
import com.gewuyou.webmvc.spec.core.enums.SortDirection
import org.springframework.data.domain.Sort
/**
* 将自定义的排序方向枚举转换为 Spring Data的排序方向
*
* @return 对应的 Spring Data Sort.Direction 枚举值
*/
fun SortDirection.toSpringDirection(): Sort.Direction =
if (this == SortDirection.DESC) Sort.Direction.DESC else Sort.Direction.ASC

View File

@ -0,0 +1,37 @@
package com.gewuyou.webmvc.spec.core.extension
import com.gewuyou.webmvc.spec.core.page.Sortable
import org.springframework.data.domain.Sort
import kotlin.collections.map
/**
* 将当前排序配置转换为可用于 Spring Data Pageable 的排序条件列表
*
* @param defaultSort 当没有其他排序条件时使用的默认排序默认为按 createdAt 降序
* @return 适用于 Pageable 的排序条件列表
*
* 处理逻辑优先级
* 1. 如果存在多个排序条件使用 sortConditions 构建排序列表
* 2. 如果指定了单一排序字段使用 sortBy sortDirection 构建排序条件
* 3. 如果没有指定任何排序条件使用默认排序
*/
fun Sortable.toSortOrders(defaultSort: Sort.Order = Sort.Order.desc("createdAt")): List<Sort.Order> {
return when {
sortConditions.isNotEmpty() -> {
// 使用 map 将每个 SortCondition 转换为 Spring 的 Sort.Order 对象
sortConditions.map {
Sort.Order(
it.direction.toSpringDirection(),
it.property
)
}
}
sortBy.isNotBlank() -> {
// 当存在单一排序字段时,创建对应的排序条件列表
listOf(Sort.Order(sortDirection.toSpringDirection(), sortBy))
}
else -> listOf(defaultSort)
}
}

View File

@ -11,7 +11,6 @@ import com.gewuyou.webmvc.spec.core.page.StatusFilterable
*
* @property currentPage 当前页码从1开始计数的分页参数
* @property pageSize 每页记录数量控制分页大小
* @property statusConditions 状态条件映射键值对形式表示的状态过滤条件
*
* @since 2025-07-19 09:15:12
* @author gewuyou

View File

@ -0,0 +1,133 @@
package com.gewuyou.webmvc.spec.core.service
/**
*CRUD服务规范
*
* @since 2025-07-25 11:12:32
* @author gewuyou
*/
interface CrudServiceSpec <Entity: Any, Id: Any> {
/**
* 根据ID获取实体
*
* @param id 实体的唯一标识符
* @return 返回实体如果不存在则返回null
*/
fun findById(id: Id): Entity?
/**
* 获取所有实体列表
*
* @return 返回实体列表
*/
fun list(): List<Entity>
/**
* 保存一个实体
*
* @param entity 要保存的实体
* @return 返回保存后的实体
*/
fun save(entity: Entity): Entity
/**
* 更新一个实体
*
* @param entity 要更新的实体
* @return 返回更新后的实体
*/
fun update(entity: Entity): Entity
/**
* 删除一个实体
*
* @param id 要删除的实体的ID
*/
fun deleteById(id: Id)
/**
* 批量删除实体
*
* @param ids 要删除的实体的ID列表
*/
fun deleteByIds(ids: List<Id>)
/**
* 删除一个实体
*
* @param entity 要删除的实体
*/
fun deleteByOne(entity: Entity)
/**
* 批量删除实体
*
* @param entities 要删除的实体列表
*/
fun deleteByAll(entities: List<Entity>)
/**
* 软删除
*
* 本函数用于标记实体为删除状态而不是真正从数据库中移除
* 这种方法可以保留历史数据同时避免数据泄露
*
* @param id 实体的唯一标识符
*/
fun softDelete(id: Id)
/**
* 批量软删除
*
* @param ids 要软删除的实体ID列表
*/
fun softDeleteByIds(ids: List<Id>)
/**
* 取消软删除恢复已删除实体
*
* @param id 要恢复的实体ID
*/
fun restore(id: Id)
/**
* 批量取消软删除
*
* @param ids 要恢复的实体ID列表
*/
fun restoreByIds(ids: List<Id>)
/**
* 判断实体是否已被软删除
*
* @param id 实体ID
* @return 如果是软删除状态返回 true否则返回 false
*/
fun isSoftDeleted(id: Id): Boolean
/**
* 根据ID检查实体是否存在
*
* @param id 实体的ID
* @return 如果实体存在返回true否则返回false
*/
fun existsById(id: Id): Boolean
/**
* 批量保存实体
*
* @param entities 要保存的实体列表
* @return 返回保存后的实体列表
*/
fun saveAll(entities: List<Entity>): List<Entity>
/**
* 查询记录总数
*
*
* @return 返回记录总数
*/
fun count(): Long
}

View File

@ -1,64 +1,18 @@
package com.gewuyou.webmvc.spec.jpa.extension
import com.gewuyou.webmvc.spec.core.enums.SortDirection
import com.gewuyou.webmvc.spec.core.page.DateRangeFilterable
import com.gewuyou.webmvc.spec.core.page.KeywordSearchable
import com.gewuyou.webmvc.spec.core.page.Pageable
import com.gewuyou.webmvc.spec.core.page.QueryComponent
import com.gewuyou.webmvc.spec.core.page.Sortable
import com.gewuyou.webmvc.spec.core.extension.toPageRequest
import com.gewuyou.webmvc.spec.core.page.*
import com.gewuyou.webmvc.spec.jpa.page.JpaFilterable
import com.gewuyou.webmvc.spec.jpa.page.JpaStatusFilterable
import jakarta.persistence.criteria.CriteriaBuilder
import jakarta.persistence.criteria.Predicate
import jakarta.persistence.criteria.Root
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.data.jpa.domain.Specification
import java.time.Instant
/**
* 将自定义的排序方向枚举转换为 Spring Data JPA 的排序方向
*
* @return 对应的 Spring Data Sort.Direction 枚举值
*/
fun SortDirection.toSpringDirection(): Sort.Direction =
if (this == SortDirection.DESC) Sort.Direction.DESC else Sort.Direction.ASC
/**
* 将当前排序配置转换为可用于 Spring Data Pageable 的排序条件列表
*
* @param defaultSort 当没有其他排序条件时使用的默认排序默认为按 createdAt 降序
* @return 适用于 Pageable 的排序条件列表
*
* 处理逻辑优先级
* 1. 如果存在多个排序条件使用 sortConditions 构建排序列表
* 2. 如果指定了单一排序字段使用 sortBy sortDirection 构建排序条件
* 3. 如果没有指定任何排序条件使用默认排序
*/
fun Sortable.toSortOrders(defaultSort: Sort.Order = Sort.Order.desc("createdAt")): List<Sort.Order> {
return when {
sortConditions.isNotEmpty() -> {
// 使用 map 将每个 SortCondition 转换为 Spring 的 Sort.Order 对象
sortConditions.map {
Sort.Order(
it.direction.toSpringDirection(),
it.property
)
}
}
sortBy.isNotBlank() -> {
// 当存在单一排序字段时,创建对应的排序条件列表
listOf(Sort.Order(sortDirection.toSpringDirection(), sortBy))
}
else -> listOf(defaultSort)
}
}
/**
* 构建关键字搜索对应的 JPA Predicate 条件列表
*
@ -179,21 +133,6 @@ fun <T> QueryComponent.toSpecification(): Specification<T> {
* @return 返回一个 Pair包含以下两个元素
* - Specification<T>JPA 查询条件规范
* - PageRequest包含分页和排序信息的请求对象
*
* 重要逻辑说明
* 1. currentPage 1 开始计数转换为 Spring Data 0 开始的页码
* 2. pageSize 保持不变直接用于分页请求
* 3. 如果对象实现了 Sortable 接口则使用其排序条件否则使用未排序状态
* 4. 如果对象未实现 Pageable 接口则使用默认分页参数第一页每页10条
*/
fun <T> QueryComponent.toJpaQuery(): Pair<Specification<T>, PageRequest> {
val specification = this.toSpecification<T>()
val pageable = this as? Pageable
val sortable = this as? Sortable
// 默认分页参数
val page = pageable?.let { (it.currentPage - 1).coerceAtLeast(0) } ?: 0
val size = pageable?.pageSize ?: 10
// 排序规则
val sort = sortable?.let { Sort.by(it.toSortOrders()) } ?: Sort.unsorted()
return specification to PageRequest.of(page, size, sort)
}
fun <T> QueryComponent.toJpaQuery(): Pair<Specification<T>, PageRequest> =
this.toSpecification<T>() to this.toPageRequest()

View File

@ -2,106 +2,15 @@ package com.gewuyou.webmvc.spec.jpa.service
import com.gewuyou.forgeboot.webmvc.dto.PageResult
import com.gewuyou.webmvc.spec.core.page.QueryComponent
import com.gewuyou.webmvc.spec.core.service.CrudServiceSpec
/**
* Jpa CRUD 服务规范
*JPA Crud服务规范
*
* 定义了对实体进行基本操作的服务接口包括增删查改和分页查询等功能
*
* @param Entity 实体类型
* @param Id 实体标识符类型
* @since 2025-05-29 20:18:22
* @since 2025-07-25 12:41:39
* @author gewuyou
*/
interface JpaCrudServiceSpec<Entity: Any, Id: Any> {
/**
* 根据ID获取实体
*
* @param id 实体的唯一标识符
* @return 返回实体如果不存在则返回null
*/
fun findById(id: Id): Entity?
/**
* 获取所有实体列表
*
* @return 返回实体列表
*/
fun list(): List<Entity>
/**
* 保存一个实体
*
* @param entity 要保存的实体
* @return 返回保存后的实体
*/
fun save(entity: Entity): Entity
/**
* 更新一个实体
*
* @param entity 要更新的实体
* @return 返回更新后的实体
*/
fun update(entity: Entity): Entity
/**
* 删除一个实体
*
* @param id 要删除的实体的ID
*/
fun deleteById(id: Id)
/**
* 批量删除实体
*
* @param ids 要删除的实体的ID列表
*/
fun deleteByIds(ids: List<Id>)
/**
* 删除一个实体
*
* @param entity 要删除的实体
*/
fun deleteByOne(entity: Entity)
/**
* 批量删除实体
*
* @param entities 要删除的实体列表
*/
fun deleteByAll(entities: List<Entity>)
/**
* 软删除
*
* 本函数用于标记实体为删除状态而不是真正从数据库中移除
* 这种方法可以保留历史数据同时避免数据泄露
*
* @param id 实体的唯一标识符
*/
fun softDelete(id: Id)
/**
* 根据ID检查实体是否存在
*
* @param id 实体的ID
* @return 如果实体存在返回true否则返回false
*/
fun existsById(id: Id): Boolean
/**
* 批量保存实体
*
* @param entities 要保存的实体列表
* @return 返回保存后的实体列表
*/
fun saveAll(entities: List<Entity>): List<Entity>
interface JpaCrudServiceSpec<Entity: Any, Id: Any>: CrudServiceSpec<Entity, Id> {
/**
* 分页查询实体列表
*
@ -136,4 +45,4 @@ interface JpaCrudServiceSpec<Entity: Any, Id: Any> {
* @return 返回符合条件的记录总数
*/
fun count(query: QueryComponent): Long
}
}

View File

@ -1,16 +1,14 @@
package com.gewuyou.webmvc.spec.jpa.service.impl
import com.gewuyou.forgeboot.webmvc.dto.PageResult
import com.gewuyou.webmvc.spec.core.extension.map
import com.gewuyou.webmvc.spec.core.extension.toPageResult
import com.gewuyou.webmvc.spec.core.page.QueryComponent
import com.gewuyou.webmvc.spec.jpa.extension.map
import com.gewuyou.webmvc.spec.jpa.extension.toJpaQuery
import com.gewuyou.webmvc.spec.jpa.extension.toPageResult
import com.gewuyou.webmvc.spec.jpa.extension.toSpecification
import com.gewuyou.webmvc.spec.jpa.repository.JpaCrudRepositorySpec
import com.gewuyou.webmvc.spec.jpa.service.JpaCrudServiceSpec
/**
* Jpa CRUD 服务实现的抽象类提供通用的数据访问操作
*
@ -195,6 +193,24 @@ abstract class JpaCrudServiceImplSpec<Entity : Any, Id : Any>(
*/
protected abstract fun setDeleted(entity: Entity)
/**
* 将实体标记为已恢复状态
*
* 此方法应在子类中实现用于定义如何将实体从软删除状态恢复为正常状态例如清除 deleted 字段或设置为 false
* 该机制允许在不物理删除数据的情况下灵活地控制记录的可见性与状态
*
* @param entity 实体对象表示要恢复的实体
*/
protected abstract fun setRestored(entity: Entity)
/**
* 判断实体是否已被软删除
*
* @param entity 实体对象用于检查其删除状态
* @return 如果实体已被标记为软删除状态返回 true否则返回 false
*/
protected abstract fun isSoftDeletedByEntity(entity: Entity): Boolean
/**
* 执行软删除操作
*
@ -212,4 +228,60 @@ abstract class JpaCrudServiceImplSpec<Entity : Any, Id : Any>(
update(it)
}
}
/**
* 查询记录总数
*
*
* @return 返回记录总数
*/
override fun count(): Long {
return repository.count()
}
/**
* 批量软删除
*
* @param ids 要软删除的实体ID列表
*/
override fun softDeleteByIds(ids: List<Id>) {
ids.forEach {
softDelete( it)
}
}
/**
* 取消软删除恢复已删除实体
*
* @param id 要恢复的实体ID
*/
override fun restore(id: Id) {
val exist: Entity? = findById(id)
exist?.let {
setRestored(exist)
update(it)
}
}
/**
* 批量取消软删除
*
* @param ids 要恢复的实体ID列表
*/
override fun restoreByIds(ids: List<Id>) {
ids.forEach {
restore(it)
}
}
/**
* 判断实体是否已被软删除
*
* @param id 实体ID
* @return 如果是软删除状态返回 true否则返回 false
*/
override fun isSoftDeleted(id: Id): Boolean {
val entity = findById(id)?:return false
return isSoftDeletedByEntity(entity)
}
}

View File

@ -0,0 +1,47 @@
package com.gewuyou.webmvc.spec.jpa.service.impl
import com.gewuyou.webmvc.spec.jpa.repository.JpaCrudRepositorySpec
/**
*简单的JPA Crud服务实现规范,请注意该抽象实现类不实现软删除
*
* @since 2025-07-25 14:24:19
* @author gewuyou
*/
abstract class SimpleJpaCrudServiceImplSpec<Entity : Any, Id : Any>(
private val repository: JpaCrudRepositorySpec<Entity, Id>,
): JpaCrudServiceImplSpec<Entity,Id>(repository) {
/**
* 标记实体为软删除状态
*
* 此方法应在子类中实现用于定义如何将实体标记为已删除例如设置一个 deleted 字段
* 软删除不会从数据库中物理移除记录而是将其标记为已删除状态以便保留历史数据
*
* @param entity 实体对象表示要标记为删除状态的对象
*/
override fun setDeleted(entity: Entity) {
// 不实现
}
/**
* 将实体标记为已恢复状态
*
* 此方法应在子类中实现用于定义如何将实体从软删除状态恢复为正常状态例如清除 deleted 字段或设置为 false
* 该机制允许在不物理删除数据的情况下灵活地控制记录的可见性与状态
*
* @param entity 实体对象表示要恢复的实体
*/
override fun setRestored(entity: Entity) {
// 不实现
}
/**
* 判断实体是否已被软删除
*
* @param entity 实体对象用于检查其删除状态
* @return 如果实体已被标记为软删除状态返回 true否则返回 false
*/
override fun isSoftDeletedByEntity(entity: Entity): Boolean {
return false
}
}

View File

@ -4,7 +4,7 @@
[versions]
jjwt-version = "0.12.6"
gradleMavenPublishPlugin-version="0.32.0"
gradleMavenPublishPlugin-version = "0.32.0"
kotlin-version = "2.0.0"
kotlinxDatetime-version = "0.6.1"
kotlinxSerializationJSON-version = "1.7.3"
@ -12,20 +12,17 @@ kotlinxSerializationJSON-version = "1.7.3"
axion-release-version = "1.18.7"
spring-cloud-version = "2024.0.1"
spring-boot-version = "3.4.4"
spring-framework-version = "6.2.5"
slf4j-version = "2.0.17"
map-struct-version="1.6.3"
caffeine-version="3.2.1"
redisson-version="3.50.0"
caffeine-version = "3.2.1"
redisson-version = "3.50.0"
org-pf4j-version = "3.13.0"
org-pf4j-spring-version = "0.10.0"
org-yaml-snakeyaml-version = "2.4"
org-yaml-snakeyaml-engine-version = "2.9"
[libraries]
kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-version" }
kotlinxDatetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime-version" }
kotlinxSerialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJSON-version" }
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin-version" }
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin-version" }
#kotlinxCoroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines-version" }
kotlinxCoroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" }
# kotlinx
@ -36,18 +33,17 @@ slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j-version" }
springBootDependencies-bom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot-version" }
springBootStarter-aop = { group = "org.springframework.boot", name = "spring-boot-starter-aop" }
springBootStarter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" }
springBootStarter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security" }
springBootStarter-webflux = { group = "org.springframework.boot", name = "spring-boot-starter-webflux" }
springBootStarter-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" }
springBootStarter-validation = { group = "org.springframework.boot", name = "spring-boot-starter-validation" }
springBootStarter-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis" }
redisson-springBootStarter= { group = "org.redisson", name = "redisson-spring-boot-starter", version.ref = "redisson-version" }
redisson-springBootStarter = { group = "org.redisson", name = "redisson-spring-boot-starter", version.ref = "redisson-version" }
springBoot-configuration-processor = { group = "org.springframework.boot", name = "spring-boot-configuration-processor", version.ref = "spring-boot-version" }
springBoot-autoconfigure = { group = "org.springframework.boot", name = "spring-boot-autoconfigure" }
springBoot-starter = { group = "org.springframework.boot", name = "spring-boot-starter" }
springExpression = { group = "org.springframework", name = "spring-expression", version.ref = "spring-framework-version" }
springFramework-data-commons = { group = "org.springframework.data", name = "spring-data-commons" }
springCloudDependencies-bom = { module = "org.springframework.cloud:spring-cloud-dependencies", version.ref = "spring-cloud-version" }
springCloudStarter-openfeign = { group = "org.springframework.cloud", name = "spring-cloud-starter-openfeign" }
@ -60,8 +56,6 @@ jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackso
reactor-core = { group = "io.projectreactor", name = "reactor-core" }
#org
org-mapstruct = { group = "org.mapstruct", name = "mapstruct", version.ref = "map-struct-version" }
org-snakeyaml-snakeyamlEngine = { group = "org.snakeyaml", name = "snakeyaml-engine", version.ref = "org-yaml-snakeyaml-engine-version" }
org-yaml-snakeyaml = { group = "org.yaml", name = "snakeyaml", version.ref = "org-yaml-snakeyaml-version" }
org-pf4j = { group = "org.pf4j", name = "pf4j", version.ref = "org-pf4j-version" }
org-pf4jSpring = { group = "org.pf4j", name = "pf4j-spring", version.ref = "org-pf4j-spring-version" }
@ -71,7 +65,7 @@ jjwt-api = { module = "io.jsonwebtoken:jjwt-api", version.ref = "jjwt-version" }
jjwt-impl = { module = "io.jsonwebtoken:jjwt-impl", version.ref = "jjwt-version" }
jjwt-jackson = { module = "io.jsonwebtoken:jjwt-jackson", version.ref = "jjwt-version" }
# com
com-github-benManes-caffeine = { module = "com.github.ben-manes.caffeine:caffeine",version.ref = "caffeine-version" }
com-github-benManes-caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine-version" }
# io
[bundles]
@ -100,5 +94,5 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-version" }
kotlin-plugin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin-version" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt" }
forgeboot-i18n-keygen = { id = "i18n-key-gen" }
gradleMavenPublishPlugin={id="com.vanniktech.maven.publish", version.ref="gradleMavenPublishPlugin-version"}
gradleMavenPublishPlugin = { id = "com.vanniktech.maven.publish", version.ref = "gradleMavenPublishPlugin-version" }