feat(dto): Added DTO classes related to pagination query and delete requests

- The SortDirection enumeration class has been added to sort directions
- Added the PageQueryReq pagination query request class
- Added the DeleteByIdsReq deletion request class
- Added the DynamicSpecificationBuilder dynamic query building tool class
- Added the PageResult pagination result class
- Added the R Unified Response Encapsulation class
- Added the SortCondition sorting condition class
- Added relevant internationalization resource files
This commit is contained in:
gewuyou 2025-05-28 23:46:48 +08:00
parent b41d9b5c46
commit d31e47d1f8
14 changed files with 605 additions and 12 deletions

3
forgeboot-webmvc/dto/.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary

40
forgeboot-webmvc/dto/.gitignore vendored Normal file
View File

@ -0,0 +1,40 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Kotlin ###
.kotlin

View File

@ -0,0 +1,15 @@
plugins{
alias(libs.plugins.forgeboot.i18n.keygen)
}
dependencies {
api(project(Modules.I18n.API))
api(project(Modules.TRACE.API))
implementation(libs.kotlinReflect)
compileOnly(libs.springBootDependencies.bom)
compileOnly(libs.jackson.annotations)
compileOnly(libs.springBootStarter.jpa)
compileOnly(libs.springBootStarter.validation)
}
i18nKeyGen {
rootPackage.set("com.gewuyou.forgeboot.webmvc.dto.i18n")
}

View File

@ -0,0 +1,112 @@
package com.gewuyou.forgeboot.webmvc.dto
/**
* 分页结果返回对象
*
* 通常用于封装分页查询的返回结果包括总数页码等信息
*
* @author gewuyou
* @since 2024-04-23 下午10:53:04
*/
open class PageResult<T> {
/**
* 当前页的记录列表
*/
var records: MutableList<T>? = mutableListOf()
/**
* 总记录数整个数据集的记录总数
*/
var totalRecords: Long = 0L
/**
* 总页数根据每页大小计算出的总页数
*/
var totalPages: Long = 0L
/**
* 当前页码从1开始计数
*/
var currentPage: Long = 1L
/**
* 每页显示的记录数量
*/
var pageSize: Long = 10L
/**
* 是否存在上一页
*/
var hasPrevious: Boolean = false
/**
* 是否存在下一页
*/
var hasNext: Boolean = false
companion object {
/**
* 创建一个空的分页结果实例
*
* @return 新的PageResult实例
*/
fun <T> of(): PageResult<T> = PageResult()
/**
* 根据指定参数创建分页结果
*
* @param currentPage 当前页码
* @param pageSize 每页大小
* @param totalRecords 总记录数
* @param records 当前页的记录列表
* @return 填充好的PageResult实例
*/
fun <T> of(currentPage: Long, pageSize: Long, totalRecords: Long, records: MutableList<T>?): PageResult<T> {
val result = PageResult<T>()
result.records = records
result.totalRecords = totalRecords
result.pageSize = pageSize
result.currentPage = currentPage
result.totalPages = (totalRecords + pageSize - 1) / pageSize
result.hasPrevious = currentPage > 1
result.hasNext = currentPage < result.totalPages
return result
}
/**
* 根据指定参数创建分页结果支持Int参数
*
* @param currentPage 当前页码
* @param pageSize 每页大小
* @param totalRecords 总记录数
* @param records 当前页的记录列表
* @return 填充好的PageResult实例
*/
fun <T> of(currentPage: Int, pageSize: Int, totalRecords: Long, records: MutableList<T>?): PageResult<T> {
return of(currentPage.toLong(), pageSize.toLong(), totalRecords, records)
}
/**
* 创建一个仅设置页码和页面大小的分页结果
*
* @param currentPage 当前页码
* @param pageSize 每页大小
* @return 部分填充的PageResult实例
*/
fun <T> of(currentPage: Long, pageSize: Long): PageResult<T> {
val result = PageResult<T>()
result.currentPage = currentPage
result.pageSize = pageSize
return result
}
/**
* 创建一个空的分页结果实例
*
* @return 新的PageResult实例
*/
fun <T> empty(): PageResult<T> = PageResult()
}
}

View File

@ -1,11 +1,47 @@
package com.gewuyou.forgeboot.common.result package com.gewuyou.forgeboot.webmvc.dto
import com.gewuyou.forgeboot.common.result.api.MessageResolver
import com.gewuyou.forgeboot.common.result.api.RequestIdProvider import com.gewuyou.forgeboot.i18n.api.MessageResolver
import com.gewuyou.forgeboot.common.result.api.ResponseInformation import com.gewuyou.forgeboot.i18n.api.ResponseInformation
import com.gewuyou.forgeboot.common.result.api.ResultExtender import com.gewuyou.forgeboot.trace.api.RequestIdProvider
import com.gewuyou.forgeboot.common.result.impl.DefaultMessageResolver
import com.gewuyou.forgeboot.common.result.impl.DefaultRequestIdProvider
/**
*默认请求ID提供商
*
* @since 2025-05-03 16:22:18
* @author gewuyou
*/
val DefaultRequestIdProvider : RequestIdProvider = RequestIdProvider{""}
/**
*默认消息解析器
*
* @since 2025-05-03 16:21:43
* @author gewuyou
*/
val DefaultMessageResolver : MessageResolver = MessageResolver { code, _ -> code }
/**
* 结果扩展器
*
* 用于扩展结果映射通过实现此接口可以自定义逻辑以向结果映射中添加修改或删除元素
* 主要用于在某个处理流程结束后对结果数据进行额外的处理或装饰
*
* @since 2025-05-03 16:08:55
* @author gewuyou
*/
fun interface ResultExtender {
/**
* 扩展结果映射
*
* 实现此方法以执行扩展逻辑可以访问并修改传入的结果映射
* 例如可以用于添加额外的信息修改现有值或者根据某些条件删除条目
*
* @param resultMap 一个包含结果数据的可变映射可以在此方法中对其进行修改
*/
fun extend(resultMap: MutableMap<String, Any?>)
}
/** /**
* 统一响应封装类 * 统一响应封装类
@ -18,7 +54,7 @@ data class R<T>(
val message: String, val message: String,
val data: T? = null, val data: T? = null,
val requestId: String? = null, val requestId: String? = null,
val extra: Map<String, Any?> = emptyMap() val extra: Map<String, Any?> = emptyMap(),
) { ) {
/** /**
* 转换为可变 Map包含 extra 中的字段 * 转换为可变 Map包含 extra 中的字段
@ -66,7 +102,7 @@ data class R<T>(
messageResolver: MessageResolver? = null, messageResolver: MessageResolver? = null,
i18bArgs: Array<Any>? = null, i18bArgs: Array<Any>? = null,
requestIdProvider: RequestIdProvider? = null, requestIdProvider: RequestIdProvider? = null,
extenders: List<ResultExtender> = emptyList() extenders: List<ResultExtender> = emptyList(),
): R<T> { ): R<T> {
val msg = (messageResolver ?: DefaultMessageResolver).resolve(info.responseI8nMessageCode, i18bArgs) val msg = (messageResolver ?: DefaultMessageResolver).resolve(info.responseI8nMessageCode, i18bArgs)
val reqId = (requestIdProvider ?: DefaultRequestIdProvider).getRequestId() val reqId = (requestIdProvider ?: DefaultRequestIdProvider).getRequestId()
@ -91,7 +127,7 @@ data class R<T>(
messageResolver: MessageResolver? = null, messageResolver: MessageResolver? = null,
i18bArgs: Array<Any>? = null, i18bArgs: Array<Any>? = null,
requestIdProvider: RequestIdProvider? = null, requestIdProvider: RequestIdProvider? = null,
extenders: List<ResultExtender> = emptyList() extenders: List<ResultExtender> = emptyList(),
): R<T> { ): R<T> {
val msg = (messageResolver ?: DefaultMessageResolver).resolve(info.responseI8nMessageCode, i18bArgs) val msg = (messageResolver ?: DefaultMessageResolver).resolve(info.responseI8nMessageCode, i18bArgs)
val reqId = (requestIdProvider ?: DefaultRequestIdProvider).getRequestId() val reqId = (requestIdProvider ?: DefaultRequestIdProvider).getRequestId()
@ -118,7 +154,7 @@ data class R<T>(
i18nArgs: Array<Any>? = null, i18nArgs: Array<Any>? = null,
messageResolver: MessageResolver? = null, messageResolver: MessageResolver? = null,
requestIdProvider: RequestIdProvider? = null, requestIdProvider: RequestIdProvider? = null,
extenders: List<ResultExtender> = emptyList() extenders: List<ResultExtender> = emptyList(),
): R<T> { ): R<T> {
val msg = (messageResolver ?: DefaultMessageResolver).resolve(messageCode, i18nArgs) val msg = (messageResolver ?: DefaultMessageResolver).resolve(messageCode, i18nArgs)
val reqId = (requestIdProvider ?: DefaultRequestIdProvider).getRequestId() val reqId = (requestIdProvider ?: DefaultRequestIdProvider).getRequestId()
@ -145,7 +181,7 @@ data class R<T>(
i18nArgs: Array<Any>? = null, i18nArgs: Array<Any>? = null,
messageResolver: MessageResolver? = null, messageResolver: MessageResolver? = null,
requestIdProvider: RequestIdProvider? = null, requestIdProvider: RequestIdProvider? = null,
extenders: List<ResultExtender> = emptyList() extenders: List<ResultExtender> = emptyList(),
): R<T> { ): R<T> {
val msg = (messageResolver ?: DefaultMessageResolver).resolve(messageCode, i18nArgs) val msg = (messageResolver ?: DefaultMessageResolver).resolve(messageCode, i18nArgs)
val reqId = (requestIdProvider ?: DefaultRequestIdProvider).getRequestId() val reqId = (requestIdProvider ?: DefaultRequestIdProvider).getRequestId()

View File

@ -0,0 +1,22 @@
package com.gewuyou.forgeboot.webmvc.dto
import com.gewuyou.forgeboot.webmvc.dto.enums.SortDirection
/**
* 排序条件
*
* @author gewuyou
* @since 2025-01-16 16:11:47
*/
class SortCondition {
/**
* 排序字段
*/
var field: String = "createdAt"
/**
* 排序方向
*/
var direction: SortDirection = SortDirection.DESC
}

View File

@ -0,0 +1,11 @@
package com.gewuyou.forgeboot.webmvc.dto.enums
/**
* 排序方向枚举
*
* @author gewuyou
* @since 2025-01-16 17:56:01
*/
enum class SortDirection {
ASC, DESC
}

View File

@ -0,0 +1,123 @@
package com.gewuyou.forgeboot.webmvc.dto.extension
import com.gewuyou.forgeboot.webmvc.dto.enums.SortDirection
import com.gewuyou.forgeboot.webmvc.dto.request.PageQueryReq
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.Pageable
import org.springframework.data.domain.Sort
/**
* 标记属性用于模糊匹配查询条件
*
* 该注解应用于实体类属性表示在构建查询条件时使用LIKE操作符进行模糊匹配
*/
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class Like
/**
* 标记属性用于精确匹配查询条件
*
* 该注解应用于实体类属性表示在构建查询条件时使用EQUAL操作符进行精确匹配
*/
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class Equal
/**
* 标记属性用于IN集合查询条件
*
* 该注解应用于实体类属性表示在构建查询条件时使用IN操作符进行集合匹配
*/
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class In
/**
* 标记属性用于OR逻辑分组
*
* 该注解应用于实体类属性表示该属性的查询条件需要与其他标记相同value值的属性
* 进行OR逻辑组合不同value值的组之间使用AND逻辑连接
*
* @property value 分组标识符相同value的注解属性会被组合为一个OR条件组
*/
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class OrGroup(val value: String)
/**
* 分页查询扩展类
*
* 提供分页和查询条件构建的扩展方法用于将 PageQueryReq 转换为 Spring Data 的分页对象
* 并生成通用的查询条件谓词列表
*
* @since 2025-01-17
* @author gewuyou
*/
fun <T> PageQueryReq<T>.toPageable(defaultSort: Sort.Order = Sort.Order.desc("createdAt")): Pageable {
val orders = when {
// 检查排序条件
sortConditions.isNotEmpty() -> {
sortConditions.map {
if (it.direction == SortDirection.ASC) Sort.Order.asc(it.field) else Sort.Order.desc(it.field)
}
}
// 检查是否有单字段排序
sortBy.isNotBlank() -> {
listOf(
if (sortDirection == SortDirection.ASC) Sort.Order.asc(sortBy) else Sort.Order.desc(sortBy)
)
}
// 如果都没有则默认按创建时间排序
else -> listOf(defaultSort)
}
return PageRequest.of(currentPage - 1, pageSize, Sort.by(orders))
}
/**
* 构建查询条件谓词列表
*
* 根据请求参数生成标准查询条件包括时间范围启用状态和删除状态等常用过滤条件
*
* @param builder CriteriaBuilder 标准查询构造器
* @param root Root<S> 查询的根对象
* @param createAtName 创建时间字段名默认 "createdAt"
* @param enabledName 是否启用字段名默认 "enabled"
* @param deletedName 是否删除字段名默认 "deleted"
* @return MutableList<Predicate> 查询条件谓词列表
*/
fun <T, S> PageQueryReq<T>.getPredicates(
builder: CriteriaBuilder,
root: Root<S>,
createAtName: String = "createdAt",
enabledName: String = "enabled",
deletedName: String = "deleted"
): MutableList<Predicate> {
val predicates = mutableListOf<Predicate>()
// 添加开始日期条件
startDate?.let {
predicates.add(builder.greaterThanOrEqualTo(root.get(createAtName), it))
}
// 添加结束日期条件
endDate?.let {
predicates.add(builder.lessThanOrEqualTo(root.get(createAtName), it))
}
// 添加是否启用条件
enabled?.let {
predicates.add(builder.equal(root.get<Boolean>(enabledName), it))
}
// 添加是否删除条件
deleted.let {
predicates.add(builder.equal(root.get<Boolean>(deletedName), it))
}
return predicates
}

View File

@ -0,0 +1,28 @@
package com.gewuyou.forgeboot.webmvc.dto.request
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.gewuyou.forgeboot.webmvc.dto.i18n.I18nKeys
import jakarta.validation.constraints.NotEmpty
/**
* 根据id列表删除请求
*
* 该类用于封装删除操作中需要传入的id列表确保删除操作能够接收一个明确且不为空的id集合
* 它使用了泛型T使得它可以适用于不同类型的id例如LongString等
*
* @author gewuyou
* @since 2025-01-18 17:39:18
*/
@JsonIgnoreProperties(ignoreUnknown = true)
class DeleteByIdsReq<T>(
/**
* 待删除的实体id列表
*
* 这个字段是删除操作的核心参数它不能为空以确保至少有一个id被指定用于删除
* 使用@NotNull注解来确保在序列化和反序列化过程中该字段不能为空
*
* @param ids 实体的唯一标识符列表用于指定哪些实体应当被删除
*/
@field:NotEmpty(message = I18nKeys.Forgeboot.Dto.DELETE_IDS_NOTNOTEMPTY)
var ids: List<T>,
)

View File

@ -0,0 +1,90 @@
package com.gewuyou.forgeboot.webmvc.dto.request
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.gewuyou.forgeboot.webmvc.dto.SortCondition
import com.gewuyou.forgeboot.webmvc.dto.enums.SortDirection
import com.gewuyou.forgeboot.webmvc.dto.i18n.I18nKeys
import jakarta.validation.constraints.Min
import java.time.LocalDateTime
/**
* 分页查询条件请求实体类
*
* 用于通用分页排序关键字搜索日期范围与状态过滤
* 支持自定义泛型过滤器实体
*
* @author gewuyou
* @since 2025-01-16 16:01:12
*/
@JsonIgnoreProperties(ignoreUnknown = true)
open class PageQueryReq<T> {
/**
* 当前页码(默认1)
*/
@field:Min(1, message = I18nKeys.Forgeboot.Dto.PAGEQUERY_CURRENTPAGE_MIN )
var currentPage: Int = 1
/**
* 每页条数(默认10)
*/
@field:Min(1, message = I18nKeys.Forgeboot.Dto.PAGEQUERY_PAGESIZE_MIN)
var pageSize: Int = 10
/**
* 排序字段(单一字段)
*/
var sortBy: String = "createdAt"
/**
* 排序方向(单一字段ASC或DESC)
*/
var sortDirection: SortDirection = SortDirection.DESC
/**
* 排序条件实体类支持多字段排序
*/
var sortConditions: MutableList<SortCondition> = mutableListOf()
/**
* 关键字搜索常用于模糊查询
*/
var keyword: String? = null
/**
* 自定义过滤条件实体类
*/
var filter: T? = null
/**
* 开始日期
*/
var startDate: LocalDateTime? = null
/**
* 结束日期
*/
var endDate: LocalDateTime? = null
/**
* 是否启用
*/
var enabled: Boolean? = null
/**
* 是否删除
*/
var deleted: Boolean = false
/**
* 计算分页偏移量
*/
fun getOffset(): Int = (currentPage - 1) * pageSize
/**
* 校验日期范围是否合法开始时间不能晚于结束时间
*/
fun isDateRangeValid(): Boolean {
return startDate == null || endDate == null || !startDate!!.isAfter(endDate)
}
}

View File

@ -0,0 +1,104 @@
package com.gewuyou.forgeboot.webmvc.dto.util
import com.gewuyou.forgeboot.webmvc.dto.extension.Equal
import com.gewuyou.forgeboot.webmvc.dto.extension.In
import com.gewuyou.forgeboot.webmvc.dto.extension.Like
import com.gewuyou.forgeboot.webmvc.dto.extension.OrGroup
import com.gewuyou.forgeboot.webmvc.dto.extension.getPredicates
import com.gewuyou.forgeboot.webmvc.dto.request.PageQueryReq
import jakarta.persistence.criteria.Path
import jakarta.persistence.criteria.Predicate
import jakarta.persistence.criteria.Root
import org.springframework.data.jpa.domain.Specification
import kotlin.collections.isNotEmpty
import kotlin.collections.toTypedArray
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.isAccessible
/**
* 动态构建 JPA Specification 的工具类
*
* 支持字段注解控制查询方式@Like, @Equal, @In
* 支持嵌套字段路径 "user.name"
* 支持 OR 条件组@OrGroup("groupName")
* @since 2025-05-28 23:37:17
* @author gewuyou
*/
object DynamicSpecificationBuilder {
/**
* 构建动态查询 Specification
*
* @param query PageQuery<T> 查询参数对象
* @param createAtName 时间字段名
* @param enabledName 是否启用字段名
* @param deletedName 是否删除字段名
*/
inline fun <reified T : Any, reified E> build(
query: PageQueryReq<T>,
createAtName: String = "createdAt",
enabledName: String = "enabled",
deletedName: String = "deleted",
): Specification<E> {
return Specification { root, _, builder ->
val predicates = mutableListOf<Predicate>()
// 1. 加入基本条件(时间、启用、删除)
predicates += query.getPredicates(builder, root, createAtName, enabledName, deletedName)
// 2. 处理 filter 字段
val filterObj = query.filter ?: return@Specification builder.and(*predicates.toTypedArray())
// 3. OR 分组
val orGroups = mutableMapOf<String, MutableList<Predicate>>()
T::class.memberProperties.forEach { prop ->
prop.isAccessible = true
val value = prop.get(filterObj) ?: return@forEach
val fieldPath = prop.name
val path = getPath(root, fieldPath)
val predicate = when {
prop.findAnnotation<Like>() != null && value is String ->
builder.like(path as Path<String>, "%$value%")
prop.findAnnotation<Equal>() != null ->
builder.equal(path, value)
prop.findAnnotation<In>() != null && value is Collection<*> ->
path.`in`(value)
// 默认策略:非空值执行 equal
else -> builder.equal(path, value)
}
val orGroup = prop.findAnnotation<OrGroup>()?.value
if (orGroup != null) {
orGroups.getOrPut(orGroup) { mutableListOf() }.add(predicate)
} else {
predicates.add(predicate)
}
}
// 4. 添加 OR 条件组
orGroups.values.forEach { group ->
if (group.isNotEmpty()) {
predicates.add(builder.or(*group.toTypedArray()))
}
}
builder.and(*predicates.toTypedArray())
}
}
/**
* 支持嵌套字段路径解析例如 "user.name"
*/
fun <E> getPath(root: Root<E>, pathStr: String): Path<*> {
return pathStr.split(".").fold(root as Path<*>) { path, part ->
path.get<Any>(part)
}
}
}

View File

@ -0,0 +1,3 @@
forgeboot.dto.delete.ids.notNotEmpty=请求删除的id列表不能为空\!
forgeboot.dto.pageQuery.currentPage.min=当前页码不得小于1页\!
forgeboot.dto.pageQuery.pageSize.min=每页条数不得小于1页\!

View File

@ -0,0 +1,3 @@
forgeboot.dto.delete.ids.notNotEmpty=The list of IDs requested to be removed cannot be empty\!
forgeboot.dto.pageQuery.currentPage.min=The current page number must not be less than 1 page\!
forgeboot.dto.pageQuery.pageSize.min=The number of entries per page shall not be less than 1 page\!

View File

@ -0,0 +1,3 @@
forgeboot.dto.delete.ids.notNotEmpty=请求删除的id列表不能为空\!
forgeboot.dto.pageQuery.currentPage.min=当前页码不得小于1页\!
forgeboot.dto.pageQuery.pageSize.min=每页条数不得小于1页\!