vertx-pj:0.0.1

This commit is contained in:
AiKrai 2025-01-09 12:01:09 +08:00
commit f28e1341d0
52 changed files with 3400 additions and 0 deletions

48
.gitignore vendored Normal file
View File

@ -0,0 +1,48 @@
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
config/
gradle/
### IntelliJ IDEA ###
.idea
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### Kotlin ###
.kotlin
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store

51
README.md Normal file
View File

@ -0,0 +1,51 @@
# Vertx-pj 说明
## 项目简介
基于vert.x的web开发框架提供了一些简单的封装使得开发更加便捷。<br/>
**写的简单,问题不少,仅供参考**。
## 项目结构
- **vertx-fw**: 简单封装的web开发框架
- **vertx-demo**: 使用vertx-fw开发的demo
## 注解说明
### @Controller
自定义Controller注解用于路由注册
- 启动时使用反射获取所有标记了@Controller注解的类
- 获取类中所有方法将其统一注册到router中
- 可选参数`prefix`:定义请求路径前缀
- 不填时默认使用类名(去除"Controller"后首字母小写)作为前缀
### @D
文档生成注解,可用于以下位置:
1. 类上为Controller类添加说明
2. 方法上:为方法添加说明
3. 方法参数上:格式如 `@D("age", "年龄") age: Int?`
- name: 参数名用于从query或body中自动获取参数
- value: 参数说明,用于文档生成
> 注:参数类型后的`?`表示可为空,不带`?`表示必填。框架会根据此进行参数校验。
### @AllowAnonymous
权限控制注解
- 可标记在Controller类或方法上
- 表示该接口不需要鉴权即可访问
### 权限相关注解
仿sa-token实现的权限控制
- `@CheckRole()`:角色检查
- `@CheckPermission()`:权限检查
### 请求响应相关注解
#### @CustomizeRequest
请求方式定制
- 全局默认使用POST请求
- 使用`@CustomizeRequest("get")`可将方法改为GET请求
#### @CustomizeResponse
响应方式定制
- 全局默认返回JSON格式
- 使用`@CustomizeResponse`可获取response对象自定义返回内容

1
gradle.properties Normal file
View File

@ -0,0 +1 @@
kotlin.code.style=official

6
settings.gradle.kts Normal file
View File

@ -0,0 +1,6 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
rootProject.name = "vertx-pj"
include("vertx-fw")
include("vertx-demo")

115
vertx-demo/build.gradle.kts Normal file
View File

@ -0,0 +1,115 @@
plugins {
application
kotlin("jvm") version "1.9.20"
id("com.diffplug.spotless") version "6.25.0"
id("com.github.johnrengelman.shadow") version "8.1.1"
}
group = "com.demo"
version = "1.0.0-SNAPSHOT"
val vertxVersion = "4.5.11"
application {
mainClass.set("app.Application")
applicationDefaultJvmArgs = listOf(
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"--add-opens",
"java.base/java.time=ALL-UNNAMED"
// "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
)
}
repositories {
mavenLocal()
mavenCentral()
}
tasks.shadowJar {
archiveClassifier.set("fat")
mergeServiceFiles()
}
tasks.test {
useJUnitPlatform()
testLogging {
showStandardStreams = true
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
// events = setOf(PASSED, SKIPPED, FAILED)
}
}
tasks.compileKotlin {
kotlinOptions {
jvmTarget = "17"
}
}
tasks.compileTestKotlin {
kotlinOptions {
jvmTarget = "17"
}
}
spotless {
kotlin {
ktlint()
.editorConfigOverride(
mapOf(
"ktlint_standard_no-wildcard-imports" to "disabled",
"ktlint_standard_trailing-comma-on-call-site" to "disabled",
"ktlint_standard_trailing-comma-on-declaration-site" to "disabled",
"indent_size" to "2"
)
)
target("src/**/*.kt")
}
}
dependencies {
implementation(project(":vertx-fw"))
// implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.20")
// implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.20")
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation(platform("io.vertx:vertx-stack-depchain:$vertxVersion"))
implementation("io.vertx:vertx-lang-kotlin:$vertxVersion")
implementation("io.vertx:vertx-lang-kotlin-coroutines:$vertxVersion")
implementation("io.vertx:vertx-core:$vertxVersion")
implementation("io.vertx:vertx-web:$vertxVersion")
implementation("io.vertx:vertx-web-client:$vertxVersion")
implementation("io.vertx:vertx-config:$vertxVersion")
implementation("io.vertx:vertx-config-yaml:$vertxVersion")
implementation("io.vertx:vertx-pg-client:$vertxVersion")
implementation("io.vertx:vertx-mysql-client:$vertxVersion")
implementation("io.vertx:vertx-sql-client-templates:$vertxVersion")
implementation("io.vertx:vertx-auth-jwt:$vertxVersion")
implementation("com.google.inject:guice:5.1.0")
implementation("org.reflections:reflections:0.9.12")
implementation("cn.hutool:hutool-all:5.8.24")
implementation("com.fasterxml.jackson.core:jackson-databind:2.15.2")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2")
// implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
implementation("dev.hsbrysk:caffeine-coroutines:1.0.0")
// log
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("org.slf4j:slf4j-api:2.0.6")
implementation("ch.qos.logback:logback-classic:1.4.14")
implementation("org.codehaus.janino:janino:3.1.8")
// jpa
implementation("jakarta.persistence:jakarta.persistence-api:3.2.0")
implementation("jakarta.validation:jakarta.validation-api:3.1.0")
// implementation("com.mysql:mysql-connector-j:9.1.0")
implementation("mysql:mysql-connector-java:5.1.49")
// doc
implementation("io.swagger.core.v3:swagger-core:2.2.27")
testImplementation("io.vertx:vertx-junit5:$vertxVersion")
implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.jar"))))
}

View File

@ -0,0 +1,27 @@
package app
import app.config.InjectConfig
import app.verticle.MainVerticle
import io.vertx.core.Vertx
import kotlinx.coroutines.runBlocking
import mu.KotlinLogging
object Application {
private val logger = KotlinLogging.logger { }
@JvmStatic
fun main(args: Array<String>) {
runBlocking {
val vertx = Vertx.vertx()
val getIt = InjectConfig.configure(vertx)
val demoVerticle = getIt.getInstance(MainVerticle::class.java)
vertx.deployVerticle(demoVerticle).onComplete {
if (it.failed()) {
logger.error { "MainVerticle startup failed: ${it.cause()?.stackTraceToString()}" }
} else {
logger.info { "MainVerticle startup successfully" }
}
}
}
}
}

View File

@ -0,0 +1,58 @@
package app.controller
import app.config.Constant
import app.domain.user.LoginDTO
import app.domain.user.User
import app.domain.user.UserRepository
import app.util.CacheUtil
import cn.hutool.core.lang.Snowflake
import cn.hutool.crypto.SecureUtil
import com.google.inject.Inject
import io.vertx.ext.auth.jwt.JWTAuth
import org.aikrai.vertx.auth.AllowAnonymous
import org.aikrai.vertx.auth.TokenUtil
import org.aikrai.vertx.context.Controller
import org.aikrai.vertx.context.D
import org.aikrai.vertx.utlis.Meta
@AllowAnonymous
@D("认证")
@Controller("/auth")
class AuthController @Inject constructor(
private val jwtAuth: JWTAuth,
private val snowflake: Snowflake,
private val userRepository: UserRepository,
private val cacheUtil: CacheUtil
) {
@D("注册")
suspend fun doSign(
@D("loginInfo", "账号信息") loginInfo: LoginDTO
): String {
userRepository.getByName(loginInfo.username)?.let {
throw Meta.failure("LoginFailed", "用户名已被使用")
}
val user = User().apply {
this.id = snowflake.nextId()
this.userName = loginInfo.username
this.password = SecureUtil.sha1(loginInfo.password)
this.loginName = loginInfo.username
}
cacheUtil.put(Constant.USER + user.id, user)
userRepository.create(user)
return TokenUtil.genToken(jwtAuth, mapOf("id" to user.id!!))
}
@D("登录")
suspend fun doLogin(
@D("loginInfo", "账号信息") loginInfo: LoginDTO
): String {
val user = userRepository.getByName(loginInfo.username) ?: throw Meta.failure("LoginFailed", "用户名或密码错误")
if (user.password == SecureUtil.sha1(loginInfo.password)) {
cacheUtil.put(Constant.USER + user.id, user)
return TokenUtil.genToken(jwtAuth, mapOf("id" to user.id!!))
} else {
throw Meta.failure("LoginFailed", "用户名或密码错误")
}
}
}

View File

@ -0,0 +1,61 @@
package app.controller
import app.domain.CargoType
import app.domain.user.User
import app.domain.user.UserRepository
import app.service.user.UserService
import com.google.inject.Inject
import mu.KotlinLogging
import org.aikrai.vertx.auth.AllowAnonymous
import org.aikrai.vertx.auth.CheckPermission
import org.aikrai.vertx.auth.CheckRole
import org.aikrai.vertx.config.Config
import org.aikrai.vertx.context.Controller
import org.aikrai.vertx.context.D
/**
* 推荐代码示例
*/
@D("测试1:测试")
@Controller
class Demo1Controller @Inject constructor(
private val userService: UserService,
private val userRepository: UserRepository
) {
private val logger = KotlinLogging.logger { }
@D("参数测试", "详细说明......")
suspend fun test1(
@D("name", "姓名") name: String,
@D("age", "年龄") age: Int?,
@D("list", "列表") list: List<String>?,
@D("cargoType", "货物类型") cargoType: CargoType?
) {
logger.info { "你好" }
println(age)
println(list)
println("test-$name")
println(cargoType)
}
@D("事务测试")
suspend fun testT() {
userService.testTransaction()
}
@D("查询测试")
suspend fun getList(): List<User> {
val list = userRepository.getList()
println(list)
return list
}
@AllowAnonymous
@D("配置读取测试")
suspend fun testRetriever(
@D("key", "key") key: String
) {
val configMap = Config.getKey(key)
println(configMap)
}
}

View File

@ -0,0 +1,18 @@
package app.domain
enum class CargoType(val message: String) {
RECEIVING("收货"),
SHIPPING("发货"),
INTERNAL_TRANSFER("内部调拨")
;
companion object {
fun parse(value: String?): CargoType? {
if (value.isNullOrBlank()) return null
return CargoType.values().find { it.name == value || it.message == value }
}
}
}

View File

@ -0,0 +1,42 @@
package app.domain.role
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
@Entity
@Table(name = "`sys_role`", schema = "`vertx-demo`")
class Role : org.aikrai.vertx.utlis.Entity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "role_id", nullable = false)
var id: Long? = null
@Size(max = 30)
@NotNull
@Column(name = "role_name", nullable = false, length = 30)
var roleName: String? = null
@Size(max = 100)
@NotNull
@Column(name = "role_key", nullable = false, length = 100)
var roleKey: String? = null
@NotNull
@Column(name = "role_sort", nullable = false)
var roleSort: Int? = null
@Column(name = "data_scope")
var dataScope: Char? = null
@NotNull
@Column(name = "status", nullable = false)
var status: Char? = null
@Column(name = "del_flag")
var delFlag: Char? = null
@Size(max = 500)
@Column(name = "remark", length = 500)
var remark: String? = null
}

View File

@ -0,0 +1,7 @@
package app.domain.role
import com.google.inject.ImplementedBy
import org.aikrai.vertx.db.Repository
@ImplementedBy(RoleRepositoryImpl::class)
interface RoleRepository : Repository<Long, Role>

View File

@ -0,0 +1,9 @@
package app.domain.role
import com.google.inject.Inject
import io.vertx.sqlclient.SqlClient
import org.aikrai.vertx.db.RepositoryImpl
class RoleRepositoryImpl @Inject constructor(
sqlClient: SqlClient
) : RepositoryImpl<Long, Role>(sqlClient), RoleRepository

View File

@ -0,0 +1,6 @@
package app.domain.user
data class LoginDTO(
var username: String,
var password: String
)

View File

@ -0,0 +1,74 @@
package app.domain.user
import jakarta.persistence.*
import java.sql.Timestamp
@Entity
@Table(name = "`sys_user`", schema = "`vertx-demo`")
class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id", nullable = false)
var id: Long? = 0L
@Column(name = "dept_id")
var deptId: Long? = null
@Column(name = "login_name", nullable = false, length = 30)
var loginName: String? = ""
@Column(name = "user_name", length = 30)
var userName: String? = ""
@Column(name = "user_type", length = 2)
var userType: String? = ""
@Column(name = "email", length = 50)
var email: String? = ""
@Column(name = "phonenumber", length = 11)
var phonenumber: String? = ""
// @Column(name = "sex")
// var sex: Char? = null
@Column(name = "avatar", length = 100)
var avatar: String? = null
@Column(name = "password", length = 50)
var password: String? = null
@Column(name = "salt", length = 20)
var salt: String? = null
// @Column(name = "status")
// var status: Char? = null
// @Column(name = "del_flag")
// var delFlag: Char? = null
@Column(name = "login_ip", length = 128)
var loginIp: String? = null
@Column(name = "login_date")
var loginDate: Timestamp? = null
@Column(name = "pwd_update_date")
var pwdUpdateDate: Timestamp? = null
@Column(name = "create_by", length = 64)
var createBy: String? = null
@Column(name = "create_time")
var createTime: Timestamp? = null
@Column(name = "update_by", length = 64)
var updateBy: String? = null
@Column(name = "update_time")
var updateTime: Timestamp? = null
@Column(name = "remark", length = 500)
var remark: String? = null
}

View File

@ -0,0 +1,13 @@
package app.domain.user
import com.google.inject.ImplementedBy
import org.aikrai.vertx.db.Repository
@ImplementedBy(UserRepositoryImpl::class)
interface UserRepository : Repository<Long, User> {
suspend fun getByName(name: String): User?
suspend fun testTransaction(): User?
suspend fun getList(): List<User>
}

View File

@ -0,0 +1,26 @@
package app.domain.user
import com.google.inject.Inject
import io.vertx.sqlclient.SqlClient
import org.aikrai.vertx.db.RepositoryImpl
class UserRepositoryImpl @Inject constructor(
sqlClient: SqlClient
) : RepositoryImpl<Long, User>(sqlClient), UserRepository {
override suspend fun getByName(name: String): User? {
val user = queryBuilder()
.eq(User::userName, name)
.getOne()
return user
}
override suspend fun testTransaction(): User? {
// throw Meta.failure("test transaction", "test transaction")
return queryBuilder().getOne()
}
override suspend fun getList(): List<User> {
return queryBuilder().getList()
}
}

View File

@ -0,0 +1,12 @@
package app.service.user
import app.domain.user.User
import app.service.user.impl.UserServiceImpl
import com.google.inject.ImplementedBy
@ImplementedBy(UserServiceImpl::class)
interface UserService {
suspend fun updateUser(user: User)
suspend fun testTransaction()
}

View File

@ -0,0 +1,34 @@
package app.service.user.impl
import app.domain.role.RoleRepository
import app.domain.user.User
import app.domain.user.UserRepository
import app.service.user.UserService
import com.google.inject.Inject
import org.aikrai.vertx.db.tx.withTransaction
class UserServiceImpl @Inject constructor(
private val userRepository: UserRepository,
private val roleRepository: RoleRepository
) : UserService {
override suspend fun updateUser(user: User) {
userRepository.getByName(user.userName!!)?.let {
userRepository.update(user)
roleRepository.update(user.id!!, mapOf("type" to "normal"))
}
}
override suspend fun testTransaction() {
// withTransaction嵌套时, 使用的是同一个事务对象,要成功全部成功,要失败全部失败
withTransaction {
val execute1 = userRepository.execute<Int>("update sys_user set email = '88888' where user_name = '运若汐'")
println("运若汐: $execute1")
withTransaction {
val execute = userRepository.execute<Int>("update sys_user set email = '88888' where user_name = '郸明'")
println("郸明: $execute")
// throw Meta.failure("test transaction", "test transaction")
}
}
}
}

View File

@ -0,0 +1,44 @@
package app.util
import com.github.benmanes.caffeine.cache.Caffeine
import com.google.inject.Singleton
import dev.hsbrysk.caffeine.CoroutineCache
import dev.hsbrysk.caffeine.buildCoroutine
import java.util.concurrent.TimeUnit
@Singleton
class CacheUtil {
companion object {
@Volatile
private var cache: CoroutineCache<String, Any>? = null
fun getCache(): CoroutineCache<String, Any> {
return cache ?: synchronized(this) {
cache ?: init().also { cache = it }
}
}
private fun init(): CoroutineCache<String, Any> {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterAccess(30, TimeUnit.MINUTES)
.buildCoroutine()
}
}
suspend fun get(str: String): Any? {
return getCache().getIfPresent(str)
}
fun put(key: String, value: Any) {
getCache().put(key, value)
}
fun invalidate(key: String) {
getCache().synchronous().invalidate(key)
}
fun invalidateAll() {
getCache().synchronous().invalidateAll()
}
}

View File

@ -0,0 +1,54 @@
package app.verticle
import com.google.inject.Inject
import com.google.inject.name.Named
import io.vertx.core.Vertx
import io.vertx.core.http.HttpMethod
import io.vertx.core.json.JsonObject
import io.vertx.ext.web.client.WebClient
import io.vertx.ext.web.client.WebClientOptions
import io.vertx.kotlin.coroutines.CoroutineVerticle
import mu.KotlinLogging
import org.aikrai.vertx.openapi.OpenApiSpecGenerator
class ApifoxClient @Inject constructor(
private val vertx: Vertx,
@Named("apifox.token") private val token: String,
@Named("apifox.projectId") private val projectId: String,
@Named("apifox.folderId") private val folderId: String,
@Named("server.name") private val serverName: String,
@Named("server.port") private val port: String
) : CoroutineVerticle() {
private val logger = KotlinLogging.logger { }
override suspend fun start() {
importOpenapi()
}
private fun importOpenapi() {
val openApiJsonStr = OpenApiSpecGenerator().genOpenApiSpecStr(serverName, "1.0", "http://127.0.0.1:$port/api")
val options = WebClientOptions().setDefaultPort(443).setDefaultHost("api.apifox.com").setSsl(true)
val client = WebClient.create(vertx, options)
// 参数参考 https://apifox-openapi.apifox.cn/api-173409873
val reqOptions = JsonObject()
.put("targetEndpointFolderId", folderId)
.put("endpointOverwriteBehavior", "OVERWRITE_EXISTING")
.put("schemaOverwriteBehavior", "OVERWRITE_EXISTING")
.put("updateFolderOfChangedEndpoints", false)
.put("prependBasePath", false)
val requestBody = JsonObject().put("input", openApiJsonStr).put("options", reqOptions)
client.request(HttpMethod.POST, "/v1/projects/$projectId/import-openapi")
.putHeader("X-Apifox-Version", "2024-01-20")
.putHeader("Authorization", "Bearer $token")
.sendJsonObject(requestBody) { ar ->
if (ar.succeeded()) {
val response = ar.result()
logger.info("Received response with status code: ${response.statusCode()}")
logger.info("Response body: ${response.bodyAsString()}")
} else {
logger.warn("Request failed: ${ar.cause().message}")
}
}
}
}

View File

@ -0,0 +1,31 @@
package app.verticle
import com.google.inject.Inject
import io.vertx.kotlin.coroutines.CoroutineVerticle
import mu.KotlinLogging
class MainVerticle @Inject constructor(
private val webVerticle: WebVerticle,
private val apifoxClient: ApifoxClient
) : CoroutineVerticle() {
private val logger = KotlinLogging.logger { }
override suspend fun start() {
val verticles = listOf(
webVerticle,
apifoxClient
)
for (verticle in verticles) {
vertx.deployVerticle(verticle).onComplete {
val simpleName = verticle.javaClass.simpleName
if (it.failed()) {
logger.error { "$simpleName startup failed: ${it.cause()?.stackTraceToString()}" }
} else {
val deploymentId = it.result()
logger.info { "$simpleName startup successfully, deploymentId:$deploymentId" }
}
}
}
}
}

View File

@ -0,0 +1,105 @@
package app.verticle
import app.config.auth.AuthHandler
import app.config.auth.JwtAuthenticationHandler
import com.google.inject.Inject
import com.google.inject.Injector
import com.google.inject.name.Named
import io.vertx.core.Handler
import io.vertx.core.http.HttpHeaders
import io.vertx.core.http.HttpMethod
import io.vertx.core.http.HttpServerOptions
import io.vertx.ext.web.Router
import io.vertx.ext.web.RoutingContext
import io.vertx.ext.web.handler.BodyHandler
import io.vertx.ext.web.handler.CorsHandler
import io.vertx.kotlin.coroutines.CoroutineVerticle
import io.vertx.kotlin.coroutines.coAwait
import kotlinx.coroutines.CoroutineScope
import mu.KotlinLogging
import org.aikrai.vertx.config.FailureParser
import org.aikrai.vertx.context.RouterBuilder
import org.aikrai.vertx.jackson.JsonUtil
class WebVerticle @Inject constructor(
private val getIt: Injector,
private val coroutineScope: CoroutineScope,
private val authHandler: AuthHandler,
@Named("server.name") private val serverName: String,
@Named("server.port") private val port: String,
@Named("server.context") private val context: String
) : CoroutineVerticle() {
private val logger = KotlinLogging.logger { }
override suspend fun start() {
val rootRouter = Router.router(vertx)
val router = Router.router(vertx)
setupRouter(rootRouter, router)
val options = HttpServerOptions().setMaxFormAttributeSize(1024 * 1024)
val server = vertx.createHttpServer(options)
.requestHandler(rootRouter)
.listen(port.toInt())
.coAwait()
logger.info { "http server start - http://127.0.0.1:${server.actualPort()}" }
}
override suspend fun stop() {
}
private fun setupRouter(rootRouter: Router, router: Router) {
rootRouter.route("/api" + "*").subRouter(router)
router.route()
.handler(corsHandler)
.failureHandler(errorHandler)
.handler(BodyHandler.create())
val authHandler = JwtAuthenticationHandler(coroutineScope, authHandler, context)
router.route("/*").handler(authHandler)
val routerBuilder = RouterBuilder(coroutineScope, router).build { service ->
getIt.getInstance(service)
}
authHandler.exclude.addAll(routerBuilder.anonymousPaths)
// 生成 openapi.json
/*val openApiJsonStr = OpenApiSpecGenerator().genOpenApiSpecStr(serverName, "1.0", "http://127.0.0.1:$port/api")
val resourcesPath = "${System.getProperty("user.dir")}/src/main/resources"
val timestamp = System.currentTimeMillis()
vertx.fileSystem()
.writeFile(
"$resourcesPath/openapi/openapi-$timestamp.json",
Buffer.buffer(openApiJsonStr)
) { writeFileAsyncResult ->
if (!writeFileAsyncResult.succeeded()) writeFileAsyncResult.cause().printStackTrace()
}*/
}
private val corsHandler = CorsHandler.create()
.addOrigin("*")
.allowedMethod(HttpMethod.GET)
.allowedMethod(HttpMethod.POST)
.allowedMethod(HttpMethod.PUT)
.allowedMethod(HttpMethod.DELETE)
.allowedMethod(HttpMethod.OPTIONS)
private val errorHandler = Handler<RoutingContext> { ctx ->
val failure = ctx.failure()
if (failure != null) {
logger.error { "${ctx.request().uri()}: ${failure.stackTraceToString()}" }
val parsedFailure = FailureParser.parse(ctx.statusCode(), failure)
val response = ctx.response()
response.putHeader(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
response.statusCode = if (ctx.statusCode() != 200) ctx.statusCode() else 500
response.end(JsonUtil.toJsonStr(parsedFailure.response))
} else {
logger.error("${ctx.request().uri()}: 未知错误")
val response = ctx.response()
response.putHeader(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
response.statusCode = 500
response.end()
}
}
}

View File

@ -0,0 +1,22 @@
server:
name: vtx_demo
active: dev
port: 8080
context: api # 上下文
timeout: 120 # eventbus超时时间
http:
header: # header获取到的变量
- x-requested-with
- Access-Control-Allow-Origin
- origin
- Content-Type
- accept
event-bus:
timeout: 10000 # 毫秒
jwt:
key: 123456sdfjasdfjl # jwt加密key
apifox:
token: APS-xxx
projectId: xxx
folderId: xxx

View File

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration
debug="false" scan="true" scanPeriod="30 second">
<property name="ROOT" value="../bucket/log/"/>
<property name="APPNAME" value="vertx-fw"/>
<property name="FILESIZE" value="500MB"/>
<property name="MAXHISTORY" value="100"/>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<Target>System.out</Target>
<encoder charset="utf-8">
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
</pattern>
</encoder>
</appender>
<appender name="WARN" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder charset="utf-8">
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
</pattern>
</encoder>
<filter class="ch.qos.logback.core.filter.EvaluatorFilter">
<evaluator>
<expression>return level &gt;= WARN;</expression>
</evaluator>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${ROOT}${APPNAME}-%d-warn.%i.log</fileNamePattern>
<maxHistory>${MAXHISTORY}</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>${FILESIZE}</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
</appender>
<appender name="INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder charset="utf-8">
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${ROOT}${APPNAME}-%d-info.%i.log</fileNamePattern>
<maxHistory>${MAXHISTORY}</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>${FILESIZE}</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
</appender>
<appender name="DEBUG" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder charset="utf-8">
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>DEBUG</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy
class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${ROOT}${APPNAME}-%d-debug.%i.log</fileNamePattern>
<maxHistory>${MAXHISTORY}</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>${FILESIZE}</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
</appender>
<appender name="TRACE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder charset="utf-8">
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>TRACE</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy
class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${ROOT}${APPNAME}-%d-trace.%i.log</fileNamePattern>
<maxHistory>${MAXHISTORY}</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>${FILESIZE}</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
<appender-ref ref="WARN"/>
<appender-ref ref="INFO"/>
<appender-ref ref="DEBUG"/>
<appender-ref ref="TRACE"/>
</root>
</configuration>

View File

@ -0,0 +1,27 @@
-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`role_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`role_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '角色名称',
`role_key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '角色权限字符串',
`role_sort` int(4) NOT NULL COMMENT '显示顺序',
`data_scope` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '1' COMMENT '数据范围1全部数据权限 2自定数据权限 3本部门数据权限 4本部门及以下数据权限',
`status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '角色状态0正常 1停用',
`del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '删除标志0代表存在 2代表删除',
`create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '创建者',
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '更新者',
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`role_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 100 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '角色信息表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES (1, '超级管理员', 'admin', 1, '1', '0', '0', 'admin', '2024-12-28 11:30:34', '', NULL, '超级管理员');
INSERT INTO `sys_role` VALUES (2, '普通角色', 'common', 2, '2', '0', '0', 'admin', '2024-12-28 11:30:34', '', NULL, '普通角色');
SET FOREIGN_KEY_CHECKS = 1;

View File

@ -0,0 +1,39 @@
-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`user_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`dept_id` bigint(20) NULL DEFAULT NULL COMMENT '部门ID',
`login_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '登录账号',
`user_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '用户昵称',
`user_type` varchar(2) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '00' COMMENT '用户类型00系统用户 01注册用户',
`email` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '用户邮箱',
`phonenumber` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '手机号码',
`sex` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '用户性别0男 1女 2未知',
`avatar` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '头像路径',
`password` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '密码',
`salt` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '盐加密',
`status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '帐号状态0正常 1停用',
`del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '删除标志0代表存在 2代表删除',
`login_ip` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '最后登录IP',
`login_date` datetime NULL DEFAULT NULL COMMENT '最后登录时间',
`pwd_update_date` datetime NULL DEFAULT NULL COMMENT '密码最后更新时间',
`create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '创建者',
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '更新者',
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`user_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1875732675218882561 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户信息表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES (1, 103, 'admin', '若依', '00', 'ry@163.com', '15888888888', '1', '', '29c67a30398638269fe600f73a054934', '111111', '0', '0', '127.0.0.1', NULL, NULL, 'admin', '2024-12-28 11:30:31', '', NULL, '管理员');
INSERT INTO `sys_user` VALUES (2, 105, 'ry', '若1', '00', 'ry@qq.com', '15666666666', '1', '', '8e6d98b90472783cc73c17047ddccf36', '222222', '0', '0', '127.0.0.1', NULL, NULL, 'admin', '2024-12-28 11:30:31', '', NULL, '测试员');
INSERT INTO `sys_user` VALUES (1875026959495516160, NULL, '运若汐', '运若汐', '', '88888', '', '0', NULL, '7c4a8d09ca3762af61e59520943dc26494f8941b', NULL, '0', '0', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO `sys_user` VALUES (1875027180531142656, NULL, '郸明', '郸明', '', '88888', '', '0', NULL, '7c4a8d09ca3762af61e59520943dc26494f8941b', NULL, '0', '0', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO `sys_user` VALUES (1875732675218882560, NULL, '易静', '易静', '', '88888', '', '0', NULL, '7c4a8d09ca3762af61e59520943dc26494f8941b', NULL, '0', '0', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
SET FOREIGN_KEY_CHECKS = 1;

89
vertx-fw/build.gradle.kts Normal file
View File

@ -0,0 +1,89 @@
plugins {
kotlin("jvm") version "1.9.20"
id("com.diffplug.spotless") version "6.25.0"
id("com.github.johnrengelman.shadow") version "8.1.1"
}
group = "org.aikrai"
version = "1.0.0-SNAPSHOT"
val vertxVersion = "4.5.11"
repositories {
mavenLocal()
mavenCentral()
}
tasks.shadowJar {
archiveClassifier.set("fat")
mergeServiceFiles()
}
tasks.test {
useJUnitPlatform()
testLogging {
showStandardStreams = true
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
tasks.compileKotlin {
kotlinOptions {
jvmTarget = "17"
}
}
tasks.compileTestKotlin {
kotlinOptions {
jvmTarget = "17"
}
}
spotless {
kotlin {
ktlint()
.editorConfigOverride(
mapOf(
"ktlint_standard_no-wildcard-imports" to "disabled",
"ktlint_standard_trailing-comma-on-call-site" to "disabled",
"ktlint_standard_trailing-comma-on-declaration-site" to "disabled",
"indent_size" to "2"
)
)
target("src/**/*.kt")
}
}
dependencies {
implementation("io.vertx:vertx-lang-kotlin-coroutines:$vertxVersion")
implementation("io.vertx:vertx-core:$vertxVersion")
implementation("io.vertx:vertx-web:$vertxVersion")
implementation("io.vertx:vertx-config:$vertxVersion")
implementation("io.vertx:vertx-config-yaml:$vertxVersion")
implementation("io.vertx:vertx-pg-client:$vertxVersion")
implementation("io.vertx:vertx-mysql-client:$vertxVersion")
implementation("io.vertx:vertx-sql-client-templates:$vertxVersion")
implementation("io.vertx:vertx-auth-jwt:$vertxVersion")
implementation("com.google.inject:guice:7.0.0")
implementation("org.reflections:reflections:0.9.12")
implementation("com.fasterxml.jackson.core:jackson-databind:2.15.2")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2")
// hutool
implementation("cn.hutool:hutool-core:5.8.35")
// log
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("org.slf4j:slf4j-api:2.0.6")
implementation("ch.qos.logback:logback-classic:1.4.14")
implementation("org.codehaus.janino:janino:3.1.8")
// jpa
implementation("jakarta.persistence:jakarta.persistence-api:3.2.0")
implementation("jakarta.validation:jakarta.validation-api:3.1.0")
// doc
implementation("io.swagger.core.v3:swagger-core:2.2.27")
}

View File

@ -0,0 +1,19 @@
package org.aikrai.vertx.auth
import io.vertx.ext.auth.impl.UserImpl
import org.aikrai.vertx.jackson.JsonUtil
class AuthUser(
principal: Principal,
attributes: Attributes,
) : UserImpl(JsonUtil.toJsonObject(principal), JsonUtil.toJsonObject(attributes))
class Principal(
val id: Long,
val info: Any,
)
class Attributes(
val role: Set<String>,
val permissions: Set<String>,
)

View File

@ -0,0 +1,27 @@
package org.aikrai.vertx.auth
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class AllowAnonymous
@Target(AnnotationTarget.TYPE, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class CheckPermission(
val type: String = "",
val value: Array<String> = [],
val mode: Mode = Mode.AND,
val orRole: Array<String> = []
)
@Target(AnnotationTarget.TYPE, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class CheckRole(
val type: String = "",
val value: Array<String> = [],
val mode: Mode = Mode.AND
)
enum class Mode {
AND,
OR
}

View File

@ -0,0 +1,22 @@
package org.aikrai.vertx.auth
import io.vertx.core.json.JsonObject
import io.vertx.ext.auth.JWTOptions
import io.vertx.ext.auth.User
import io.vertx.ext.auth.authentication.TokenCredentials
import io.vertx.ext.auth.jwt.JWTAuth
import io.vertx.kotlin.coroutines.coAwait
class TokenUtil {
companion object {
fun genToken(jwtAuth: JWTAuth, info: Map<String, Any>): String {
val jwtOptions = JWTOptions().setExpiresInSeconds(60 * 60 * 24 * 7)
return jwtAuth.generateToken(JsonObject(info), jwtOptions)
}
suspend fun authenticate(jwtAuth: JWTAuth, token: String): User? {
val tokenCredentials = TokenCredentials(token)
return jwtAuth.authenticate(tokenCredentials).coAwait() ?: return null
}
}
}

View File

@ -0,0 +1,5 @@
package org.aikrai.vertx.context
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class Controller(val prefix: String = "")

View File

@ -0,0 +1,14 @@
package org.aikrai.vertx.context
import kotlin.annotation.AnnotationTarget
import kotlin.annotation.Target
@Target(allowedTargets = [AnnotationTarget.FUNCTION])
@Retention(AnnotationRetention.RUNTIME)
annotation class CustomizeRequest(
val method: String
)
@Target(allowedTargets = [AnnotationTarget.FUNCTION])
@Retention(AnnotationRetention.RUNTIME)
annotation class CustomizeResponse

View File

@ -0,0 +1,11 @@
package org.aikrai.vertx.context
import kotlin.annotation.AnnotationTarget
import kotlin.annotation.Target
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class D(
val name: String = "",
val caption: String = ""
)

View File

@ -0,0 +1,344 @@
package org.aikrai.vertx.context
import cn.hutool.core.util.StrUtil
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import io.vertx.core.http.HttpMethod
import io.vertx.ext.auth.User
import io.vertx.ext.web.Router
import io.vertx.ext.web.RoutingContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.aikrai.vertx.auth.*
import org.aikrai.vertx.utlis.ClassUtil
import org.aikrai.vertx.utlis.Meta
import org.reflections.Reflections
import java.lang.reflect.Method
import java.lang.reflect.ParameterizedType
import kotlin.collections.ArrayList
import kotlin.coroutines.Continuation
import kotlin.reflect.KFunction
import kotlin.reflect.full.callSuspend
import kotlin.reflect.full.declaredFunctions
import kotlin.reflect.jvm.javaType
class RouterBuilder(
private val coroutineScope: CoroutineScope,
private val router: Router
) {
var anonymousPaths = ArrayList<String>()
fun build(getIt: (clazz: Class<*>) -> Any): RouterBuilder {
// 缓存路由信息
val routeInfoCache = mutableMapOf<Pair<String, HttpMethod>, RouteInfo>()
// 获取所有 Controller 类中的公共方法
val packagePath = ClassUtil.getMainClass()?.packageName
val controllerClassSet = Reflections(packagePath).getTypesAnnotatedWith(Controller::class.java)
val controllerMethods = ClassUtil.getPublicMethods(controllerClassSet)
for ((classType, methods) in controllerMethods) {
val controllerAnnotation = classType.getDeclaredAnnotationsByType(Controller::class.java).firstOrNull()
val prefixPath = controllerAnnotation?.prefix ?: ""
val classAllowAnonymous = classType.getAnnotation(AllowAnonymous::class.java) != null
if (classAllowAnonymous) {
val classPath = getReqPath(prefixPath, classType)
anonymousPaths.add("$classPath/**".replace("//", "/"))
}
for (method in methods) {
val reqPath = getReqPath(prefixPath, classType, method)
val httpMethod = getHttpMethod(method)
val allowAnonymous = method.getAnnotation(AllowAnonymous::class.java) != null
if (allowAnonymous) anonymousPaths.add(reqPath)
val customizeResp = method.getAnnotation(CustomizeResponse::class.java) != null
val role = method.getAnnotation(CheckRole::class.java)
val permissions = method.getAnnotation(CheckPermission::class.java)
val kFunction = classType.kotlin.declaredFunctions.find { it.name == method.name }
if (kFunction != null) {
val parameterInfo = kFunction.parameters.mapNotNull { parameter ->
val javaType = parameter.type.javaType
// 跳过协程的 Continuation 参数
if (javaType is Class<*> && Continuation::class.java.isAssignableFrom(javaType)) {
return@mapNotNull null
}
parameter.name ?: return@mapNotNull null
val annotation = parameter.annotations.find { it is D } as? D
val paramName = annotation?.name?.takeIf { it.isNotBlank() } ?: parameter.name ?: ""
val typeClass = when (javaType) {
is Class<*> -> javaType
is ParameterizedType -> javaType.rawType as? Class<*>
else -> null
}
ParameterInfo(
name = paramName,
type = typeClass ?: parameter.type.javaType as Class<*>,
isNullable = parameter.type.isMarkedNullable,
isList = parameter.type.classifier == List::class,
isComplex = !parameter.type.classifier.toString().startsWith("class kotlin.") &&
!parameter.type.javaType.javaClass.isEnum &&
parameter.type.javaType is Class<*>
)
}
routeInfoCache[reqPath to httpMethod] =
RouteInfo(classType, method, kFunction, parameterInfo, customizeResp, role, permissions)
}
}
}
// 注册路由处理器
routeInfoCache.forEach { (path, routeInfo) ->
router.route(routeInfo.httpMethod, path.first).handler { ctx ->
if (ctx.user() != null) {
val user = ctx.user() as AuthUser
if (!user.validateAuth(routeInfo)) {
ctx.fail(403, Meta.unauthorized("unauthorized"))
return@handler
}
}
val instance = getIt(routeInfo.classType)
buildLambda(ctx, instance, routeInfo)
}
}
return this
}
private fun buildLambda(ctx: RoutingContext, instance: Any, routeInfo: RouteInfo) {
coroutineScope.launch {
try {
val params = getParamsInstance(ctx, routeInfo.parameterInfo)
val result = if (routeInfo.kFunction.isSuspend) {
routeInfo.kFunction.callSuspend(instance, *params)
} else {
routeInfo.kFunction.call(instance, *params)
}
val json = serializeToJson(result)
if (routeInfo.customizeResp) return@launch
ctx.response()
.putHeader("Content-Type", "application/json")
.end(json)
} catch (e: Exception) {
handleError(ctx, e)
}
}
}
companion object {
private val objectMapper = jacksonObjectMapper()
private fun AuthUser.validateAuth(routeInfo: RouteInfo): Boolean {
// 如果没有权限要求直接返回true
if (routeInfo.role == null && routeInfo.permissions == null) return true
// 验证角色
val hasValidRole = routeInfo.role?.let { role ->
val roleSet = attributes().getJsonArray("role").toSet() as Set<String>
if (roleSet.isEmpty()) {
false
} else {
val reqRoleSet = (role.value + role.type).filter { it.isNotBlank() }.toSet()
validateSet(reqRoleSet, roleSet, role.mode)
}
} ?: true
// 验证权限
val hasValidPermission = routeInfo.permissions?.let { permissions ->
val permissionSet = attributes().getJsonArray("permissions").toSet() as Set<String>
val roleSet = attributes().getJsonArray("role").toSet() as Set<String>
if (permissionSet.isEmpty() && roleSet.isEmpty()) {
false
} else {
if (permissions.orRole.isNotEmpty()) {
val roleBoolean = validateSet(permissions.orRole.toSet(), roleSet, Mode.AND)
if (roleBoolean) return true
}
val reqPermissionSet = (permissions.value + permissions.type).filter { it.isNotBlank() }.toSet()
validateSet(reqPermissionSet, permissionSet, permissions.mode)
}
} ?: true
return hasValidRole && hasValidPermission
}
private fun validateSet(
required: Set<String>,
actual: Set<String>,
mode: Mode
): Boolean {
if (required.isEmpty()) return true
return when (mode) {
Mode.AND -> required == actual
Mode.OR -> required.any { it in actual }
}
}
private fun getReqPath(prefix: String, clazz: Class<*>): String {
val basePath = if (prefix.isNotBlank()) {
StrUtil.toCamelCase(StrUtil.toUnderlineCase(prefix))
} else {
StrUtil.toCamelCase(StrUtil.toUnderlineCase(clazz.simpleName.removeSuffix("Controller")))
}
return "/$basePath".replace("//", "/")
}
private fun getReqPath(prefix: String, clazz: Class<*>, method: Method): String {
val basePath = if (prefix.isNotBlank()) {
StrUtil.toCamelCase(StrUtil.toUnderlineCase(prefix))
} else {
StrUtil.toCamelCase(StrUtil.toUnderlineCase(clazz.simpleName.removeSuffix("Controller")))
}
val methodName = StrUtil.toCamelCase(StrUtil.toUnderlineCase(method.name))
return "/$basePath/$methodName".replace("//", "/")
}
private fun getParamsInstance(ctx: RoutingContext, paramsInfo: List<ParameterInfo>): Array<Any?> {
val params = mutableListOf<Any?>()
val formAttributes = ctx.request().formAttributes().associate { it.key to it.value }
val queryParams = ctx.queryParams().entries().associate { it.key to it.value }
val combinedParams = formAttributes + queryParams
// 解析Body
val bodyStr = if (!ctx.body().isEmpty) ctx.body().asString() else ""
val bodyAsMap = if (bodyStr.isNotBlank()) {
try {
objectMapper.readValue(bodyStr, Map::class.java) as Map<String, Any>
} catch (e: Exception) {
emptyMap()
}
} else {
emptyMap()
}
paramsInfo.forEach { param ->
if (param.isList) {
val listParamValue = ctx.queryParams().getAll(param.name)
if (listParamValue.isEmpty() && !param.isNullable) throw IllegalArgumentException("Missing required parameter: ${param.name}")
params.add(listParamValue)
return@forEach
}
if (param.isComplex) {
try {
val value = objectMapper.readValue(bodyStr, param.type)
params.add(value)
return@forEach
} catch (e: Exception) {
if (!param.isNullable) throw IllegalArgumentException("Failed to parse request body for parameter: ${param.name}")
}
}
params.add(
when (param.type) {
RoutingContext::class.java -> ctx
User::class.java -> ctx.user()
else -> {
val bodyValue = bodyAsMap[param.name]
val paramValue = bodyValue?.toString() ?: combinedParams[param.name]
when {
paramValue == null -> {
if (!param.isNullable) throw IllegalArgumentException("Missing required parameter: ${param.name}") else null
}
else -> {
val value = getParamValue(paramValue.toString(), param.type)
if (!param.isNullable && value == null) {
throw IllegalArgumentException("Missing required parameter: ${param.name}")
} else {
value
}
}
}
}
}
)
}
return params.toTypedArray()
}
/**
* 将字符串参数值映射到目标类型
*
* @param paramValue 参数的字符串值
* @param type 目标 [Class] 类型
* @return 转换为目标类型的参数值如果转换失败则返回 `null`
*/
private fun getParamValue(paramValue: String, type: Class<*>): Any? {
return when {
type.isEnum -> {
type.enumConstants.firstOrNull { (it as Enum<*>).name.equals(paramValue, ignoreCase = true) }
}
type == String::class.java -> paramValue
type == Int::class.java || type == Integer::class.java -> paramValue.toIntOrNull()
type == Long::class.java || type == Long::class.java -> paramValue.toLongOrNull()
type == Double::class.java || type == Double::class.java -> paramValue.toDoubleOrNull()
type == Boolean::class.java || type == Boolean::class.java -> paramValue.toBoolean()
else -> paramValue
}
}
/**
* 根据 [CustomizeRequest] 注解确定给定 REST 方法的 HTTP 方法
*
* @param method 目标方法
* @return 对应的 [HttpMethod]
*/
fun getHttpMethod(method: Method): HttpMethod {
val api = method.getAnnotation(CustomizeRequest::class.java)
return if (api != null) {
when (api.method.uppercase()) {
"GET" -> HttpMethod.GET
"PUT" -> HttpMethod.PUT
"DELETE" -> HttpMethod.DELETE
"PATCH" -> HttpMethod.PATCH
else -> HttpMethod.POST
}
} else {
HttpMethod.POST
}
}
/**
* 将对象序列化为 JSON 表示
*
* @param obj 要序列化的对象
* @return JSON 字符串
*/
private fun serializeToJson(obj: Any?): String {
return objectMapper.writeValueAsString(obj)
}
/**
* 处理错误并通过标准化的错误响应发送
*
* @param ctx 发送响应的 [RoutingContext]
* @param e 捕获的异常
*/
private fun handleError(ctx: RoutingContext, e: Exception) {
ctx.response()
.setStatusCode(500)
.putHeader("Content-Type", "application/json")
.end(
objectMapper.writeValueAsString(
mapOf(
"name" to e::class.simpleName,
"message" to (e.message ?: e.cause.toString()),
"data" to null
)
)
)
}
}
private data class RouteInfo(
val classType: Class<*>,
val method: Method,
val kFunction: KFunction<*>,
val parameterInfo: List<ParameterInfo>,
val customizeResp: Boolean,
val role: CheckRole? = null,
val permissions: CheckPermission? = null,
val httpMethod: HttpMethod = getHttpMethod(method)
)
private data class ParameterInfo(
val name: String,
val type: Class<*>,
val isNullable: Boolean,
val isList: Boolean,
val isComplex: Boolean
)
}

View File

@ -0,0 +1,59 @@
package org.aikrai.vertx.db
import com.google.inject.Inject
import com.google.inject.name.Named
import io.vertx.core.Vertx
import io.vertx.mysqlclient.MySQLBuilder
import io.vertx.mysqlclient.MySQLConnectOptions
import io.vertx.pgclient.PgBuilder
import io.vertx.pgclient.PgConnectOptions
import io.vertx.sqlclient.Pool
import io.vertx.sqlclient.PoolOptions
import io.vertx.sqlclient.SqlClient
class DbPool @Inject constructor(
vertx: Vertx,
@Named("databases.type") private val type: String,
@Named("databases.name") private val name: String,
@Named("databases.host") private val host: String,
@Named("databases.port") private val port: String,
@Named("databases.username") private val user: String,
@Named("databases.password") private val password: String
) {
private var pool: Pool
init {
val poolOptions = PoolOptions().setMaxSize(10)
pool = when (type.lowercase()) {
"mysql" -> {
val clientOptions = MySQLConnectOptions()
.setHost(host)
.setPort(port.toInt())
.setDatabase(name)
.setUser(user)
.setPassword(password)
.setTcpKeepAlive(true)
MySQLBuilder.pool().connectingTo(clientOptions).with(poolOptions).using(vertx).build()
}
"postgre", "postgresql" -> {
val clientOptions = PgConnectOptions()
.setHost(host)
.setPort(port.toInt())
.setDatabase(name)
.setUser(user)
.setPassword(password)
.setTcpKeepAlive(true)
PgBuilder.pool().connectingTo(clientOptions).with(poolOptions).using(vertx).build()
}
else -> throw IllegalArgumentException("Unsupported database type: $type")
}
}
fun getClient(): SqlClient {
return pool
}
fun getPool(): Pool {
return pool
}
}

View File

@ -0,0 +1,39 @@
package org.aikrai.vertx.db
import kotlin.reflect.KProperty1
interface QueryWrapper<T> {
fun select(vararg columns: String): QueryWrapper<T>
fun select(vararg columns: KProperty1<T, *>): QueryWrapper<T>
fun eq(column: String, value: Any): QueryWrapper<T>
fun eq(column: KProperty1<T, *>, value: Any): QueryWrapper<T>
fun eq(condition: Boolean, column: String, value: Any): QueryWrapper<T>
fun eq(condition: Boolean, column: KProperty1<T, *>, value: Any): QueryWrapper<T>
fun from(table: String): QueryWrapper<T>
fun like(column: String, value: String): QueryWrapper<T>
fun like(column: KProperty1<T, *>, value: String): QueryWrapper<T>
// fun like(condition: Boolean, column: String, value: String): QueryWrapper<T>
// fun like(condition: Boolean, column: KProperty1<T, *>, value: String): QueryWrapper<T>
// fun likeLeft(column: String, value: String): QueryWrapper<T>
fun likeLeft(column: KProperty1<T, *>, value: String): QueryWrapper<T>
// fun likeRight(column: String, value: String): QueryWrapper<T>
fun likeRight(column: KProperty1<T, *>, value: String): QueryWrapper<T>
fun `in`(column: KProperty1<T, *>, values: Collection<*>): QueryWrapper<T>
fun notIn(column: KProperty1<T, *>, values: Collection<*>): QueryWrapper<T>
fun groupBy(vararg columns: KProperty1<T, *>): QueryWrapper<T>
fun having(condition: String): QueryWrapper<T>
fun orderByAsc(vararg columns: KProperty1<T, *>): QueryWrapper<T>
fun orderByDesc(vararg columns: KProperty1<T, *>): QueryWrapper<T>
fun genSql(): String
suspend fun getList(): List<T>
suspend fun getOne(): T?
}

View File

@ -0,0 +1,303 @@
package org.aikrai.vertx.db
import cn.hutool.core.util.StrUtil
import io.vertx.kotlin.coroutines.coAwait
import io.vertx.sqlclient.Row
import io.vertx.sqlclient.SqlClient
import io.vertx.sqlclient.templates.SqlTemplate
import jakarta.persistence.Column
import jakarta.persistence.Table
import org.aikrai.vertx.jackson.JsonUtil
import kotlin.reflect.KProperty1
import kotlin.reflect.jvm.javaField
class QueryWrapperImpl<T : Any>(
private val entityClass: Class<T>,
private val sqlClient: SqlClient,
) : QueryWrapper<T> {
private val conditions = mutableListOf<QueryCondition>()
override fun select(vararg columns: String): QueryWrapper<T> {
conditions.add(
QueryCondition(
type = QueryType.SELECT,
column = columns.joinToString(",")
)
)
return this
}
override fun select(vararg columns: KProperty1<T, *>): QueryWrapper<T> {
columns.forEach {
val columnName = it.javaField?.getAnnotation(Column::class.java)?.name?.takeIf { it.isNotBlank() }
?: StrUtil.toUnderlineCase(it.name)
conditions.add(
QueryCondition(
type = QueryType.SELECT,
column = columnName
)
)
}
return this
}
override fun eq(column: String, value: Any): QueryWrapper<T> {
return eq(true, column, value)
}
override fun eq(column: KProperty1<T, *>, value: Any): QueryWrapper<T> {
return eq(true, column, value)
}
override fun eq(condition: Boolean, column: String, value: Any): QueryWrapper<T> {
if (condition) {
conditions.add(
QueryCondition(
type = QueryType.WHERE,
column = column,
operator = "=",
value = value
)
)
}
return this
}
override fun eq(condition: Boolean, column: KProperty1<T, *>, value: Any): QueryWrapper<T> {
if (condition) {
val columnName = column.javaField?.getAnnotation(Column::class.java)?.name?.takeIf { it.isNotBlank() }
?: StrUtil.toUnderlineCase(column.name)
conditions.add(
QueryCondition(
type = QueryType.WHERE,
column = columnName,
operator = "=",
value = value
)
)
}
return this
}
override fun from(table: String): QueryWrapper<T> {
conditions.add(
QueryCondition(
type = QueryType.FROM,
column = table
)
)
return this
}
override fun like(column: String, value: String): QueryWrapper<T> {
conditions.add(
QueryCondition(
type = QueryType.LIKE,
column = column,
operator = "LIKE",
value = "%$value%"
)
)
return this
}
override fun like(column: KProperty1<T, *>, value: String): QueryWrapper<T> {
conditions.add(QueryCondition(QueryType.LIKE, column.name, "LIKE", "%$value%"))
return this
}
override fun likeLeft(column: KProperty1<T, *>, value: String): QueryWrapper<T> {
conditions.add(
QueryCondition(
type = QueryType.WHERE,
column = column.name,
operator = "LIKE",
value = "%$value"
)
)
return this
}
override fun likeRight(column: KProperty1<T, *>, value: String): QueryWrapper<T> {
conditions.add(
QueryCondition(
type = QueryType.WHERE,
column = column.name,
operator = "LIKE",
value = "$value%"
)
)
return this
}
override fun `in`(column: KProperty1<T, *>, values: Collection<*>): QueryWrapper<T> {
conditions.add(
QueryCondition(
type = QueryType.WHERE,
column = column.name,
operator = "IN",
value = values
)
)
return this
}
override fun notIn(column: KProperty1<T, *>, values: Collection<*>): QueryWrapper<T> {
conditions.add(
QueryCondition(
type = QueryType.WHERE,
column = column.name,
operator = "NOT IN",
value = values
)
)
return this
}
override fun groupBy(vararg columns: KProperty1<T, *>): QueryWrapper<T> {
conditions.add(
QueryCondition(
type = QueryType.GROUP_BY,
column = columns.joinToString(",") { it.name }
)
)
return this
}
override fun having(condition: String): QueryWrapper<T> {
conditions.add(
QueryCondition(
type = QueryType.HAVING,
column = condition
)
)
return this
}
override fun orderByAsc(vararg columns: KProperty1<T, *>): QueryWrapper<T> {
conditions.add(
QueryCondition(
type = QueryType.ORDER_BY,
column = columns.joinToString(",") { it.name },
additional = mapOf("direction" to "ASC")
)
)
return this
}
override fun orderByDesc(vararg columns: KProperty1<T, *>): QueryWrapper<T> {
conditions.add(
QueryCondition(
type = QueryType.ORDER_BY,
column = columns.joinToString(",") { it.name },
additional = mapOf("direction" to "DESC")
)
)
return this
}
private fun buildSql(): String {
val sqlBuilder = StringBuilder()
// SELECT 子句
sqlBuilder.append("SELECT ")
val selectCondition = conditions.find { it.type == QueryType.SELECT }
if (selectCondition != null) {
sqlBuilder.append(selectCondition.column)
} else {
sqlBuilder.append("*")
}
// FROM 子句
val from = conditions.filter { it.type == QueryType.FROM }
if (from.isNotEmpty()) {
sqlBuilder.append(" FROM ${from.first().column}")
} else {
entityClass.getAnnotation(Table::class.java)?.name?.let {
sqlBuilder.append(" FROM $it")
} ?: sqlBuilder.append(" FROM ${StrUtil.toUnderlineCase(entityClass.simpleName)}")
}
// WHERE 子句
val whereConditions = conditions.filter { it.type == QueryType.WHERE }
if (whereConditions.isNotEmpty()) {
sqlBuilder.append(" WHERE ")
sqlBuilder.append(
whereConditions.joinToString(" AND ") {
when (it.operator) {
"IN", "NOT IN" -> "${it.column} ${it.operator} (${(it.value as Collection<*>).joinToString(",")})"
"LIKE" -> "${it.column} ${it.operator} '${it.value}'"
else -> "${it.column} ${it.operator} '${it.value}'"
}
}
)
}
// GROUP BY 子句
conditions.find { it.type == QueryType.GROUP_BY }?.let {
sqlBuilder.append(" GROUP BY ${it.column}")
}
// HAVING 子句
conditions.find { it.type == QueryType.HAVING }?.let {
sqlBuilder.append(" HAVING ${it.column}")
}
// ORDER BY 子句
val orderByConditions = conditions.filter { it.type == QueryType.ORDER_BY }
if (orderByConditions.isNotEmpty()) {
sqlBuilder.append(" ORDER BY ")
sqlBuilder.append(
orderByConditions.joinToString(", ") {
"${it.column} ${it.additional["direction"]}"
}
)
}
return sqlBuilder.toString()
}
override fun genSql(): String {
return buildSql()
}
override suspend fun getList(): List<T> {
val sql = buildSql()
val objs = SqlTemplate
.forQuery(sqlClient, sql)
.mapTo(Row::toJson)
.execute(emptyMap())
.coAwait()
.toList()
return objs.map { JsonUtil.parseObject(it.encode(), entityClass) }
}
override suspend fun getOne(): T? {
val sql = buildSql()
val obj = SqlTemplate
.forQuery(sqlClient, sql)
.mapTo(Row::toJson)
.execute(emptyMap())
.coAwait()
.firstOrNull()
return obj?.let { JsonUtil.parseObject(it.encode(), entityClass) }
}
}
enum class QueryType {
SELECT,
WHERE,
FROM,
GROUP_BY,
HAVING,
ORDER_BY,
LIKE
}
data class QueryCondition(
val type: QueryType,
val column: String,
val operator: String? = null,
val value: Any? = null,
val additional: Map<String, Any> = emptyMap()
)

View File

@ -0,0 +1,16 @@
package org.aikrai.vertx.db
interface Repository<TId, TEntity> {
suspend fun create(t: TEntity): Int
suspend fun delete(id: TId): Int
suspend fun update(t: TEntity): Int
suspend fun update(id: TId, parameters: Map<String, Any?>): Int
suspend fun get(id: TId): TEntity?
suspend fun createBatch(list: List<TEntity>): Int
suspend fun <R> execute(sql: String): R
suspend fun queryBuilder(): QueryWrapper<TEntity>
suspend fun queryBuilder(clazz: Class<*>): QueryWrapper<*>
}

View File

@ -0,0 +1,272 @@
package org.aikrai.vertx.db
import cn.hutool.core.util.StrUtil
import com.fasterxml.jackson.core.type.TypeReference
import io.vertx.kotlin.coroutines.coAwait
import io.vertx.sqlclient.*
import io.vertx.sqlclient.templates.SqlTemplate
import jakarta.persistence.Column
import jakarta.persistence.Id
import jakarta.persistence.Table
import mu.KotlinLogging
import org.aikrai.vertx.db.tx.TxCtx
import org.aikrai.vertx.jackson.JsonUtil
import java.lang.reflect.Modifier
import java.lang.reflect.ParameterizedType
import java.sql.Timestamp
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.coroutines.coroutineContext
open class RepositoryImpl<TId, TEntity : Any>(
private val sqlClient: SqlClient
) : Repository<TId, TEntity> {
private val clazz: Class<TEntity> = (this::class.java.genericSuperclass as ParameterizedType)
.actualTypeArguments[1] as Class<TEntity>
private val logger = KotlinLogging.logger {}
private val sqlTemplateMap: Map<Pair<String, String>, String> = mutableMapOf()
private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSXXX")
override suspend fun <R> execute(sql: String): R {
return if (sql.trimStart().startsWith("SELECT", true)) {
val list = SqlTemplate.forQuery(getConnection(), sql).execute(mapOf())
.coAwait().map { it.toJson() }
val jsonObject = JsonUtil.toJsonObject(list)
val typeReference = object : TypeReference<R>() {}
JsonUtil.parseObject(jsonObject, typeReference, true)
} else {
val rowCount = SqlTemplate.forUpdate(getConnection(), sql).execute(mapOf())
.coAwait().rowCount()
rowCount as R
}
}
override suspend fun create(t: TEntity): Int {
val tableName = getTableName()
val sqlTemplate = sqlTemplateMap[Pair(tableName, "create")] ?: run {
val idColumnName = getIdColumnName()
val columnsMap = getColumnMappings()
// Exclude 'id' field if it's auto-generated
val fields = clazz.declaredFields.filter { it.name != idColumnName }
val columns = fields.map { columnsMap[it.name] }
val parameters = fields.map { it.name }
val sql =
"INSERT INTO $tableName (${columns.joinToString(", ")}) VALUES (${parameters.joinToString(", ") { "#{$it}" }})"
sqlTemplateMap.plus(Pair(tableName, "create")) to sql
sql
}
val params = getNonNullFields(t)
logger.info { "SQL: $sqlTemplate, PARAMS: $params" }
return SqlTemplate.forUpdate(getConnection(), sqlTemplate)
.execute(params)
.coAwait()
.rowCount()
}
override suspend fun delete(id: TId): Int {
val tableName = getTableName()
val sqlTemplate = sqlTemplateMap[Pair(tableName, "delete")] ?: run {
val idColumnName = getIdColumnName()
val sql = "DELETE FROM $tableName WHERE $idColumnName = #{id}"
sqlTemplateMap.plus(Pair(tableName, "delete")) to sql
sql
}
val params = mapOf("id" to id)
logger.debug { "SQL: $sqlTemplate, PARAMS: $params" }
return SqlTemplate.forUpdate(getConnection(), sqlTemplate)
.execute(params)
.coAwait()
.rowCount()
}
override suspend fun update(t: TEntity): Int {
val tableName = getTableName()
val sqlTemplate = sqlTemplateMap[Pair(tableName, "update")] ?: run {
val idColumnName = getIdColumnName()
val columnsMap = getColumnMappings()
// Exclude 'id' from update fields
val fields = clazz.declaredFields.filter { it.name != idColumnName }
val setClause = fields.joinToString(", ") { "${columnsMap[it.name]} = #{${it.name}}" }
val sql = "UPDATE $tableName SET $setClause WHERE $idColumnName = #{id}"
sqlTemplateMap.plus(Pair(tableName, "update")) to sql
sql
}
// Get id value
val idColumnName = getIdColumnName()
val idField = clazz.declaredFields.find { it.name == idColumnName }
?: throw IllegalArgumentException("Class ${clazz.simpleName} must have an 'id' field for update operation.")
idField.isAccessible = true
val idValue = idField.get(t)
// Prepare parameters
val params = getNonNullFields(t) + mapOf("id" to idValue)
logger.debug { "SQL: $sqlTemplate, PARAMS: $params" }
return SqlTemplate.forUpdate(getConnection(), sqlTemplate)
.execute(params)
.coAwait()
.rowCount()
}
override suspend fun update(id: TId, parameters: Map<String, Any?>): Int {
val tableName = getTableName()
val sqlTemplate = sqlTemplateMap[Pair(tableName, "update")] ?: run {
val idColumnName = getIdColumnName()
val columnsMap = getColumnMappings()
val setClause = parameters.keys.joinToString(", ") { "${columnsMap[it]} = #{$it}" }
val sql = "UPDATE $tableName SET $setClause WHERE $idColumnName = #{id}"
sqlTemplateMap.plus(Pair(tableName, "update")) to sql
sql
}
val params = parameters + mapOf("id" to id)
logger.debug { "SQL: $sqlTemplate, PARAMS: $params" }
return SqlTemplate.forUpdate(getConnection(), sqlTemplate)
.execute(params)
.coAwait()
.rowCount()
}
override suspend fun get(id: TId): TEntity? {
val tableName = getTableName()
val sqlTemplate = sqlTemplateMap[Pair(tableName, "get")] ?: run {
val idColumnName = getIdColumnName()
val columnsMap = getColumnMappings()
val columns = columnsMap.values.joinToString(", ")
val sql = "SELECT $columns FROM $tableName WHERE $idColumnName = #{id}"
(sqlTemplateMap as MutableMap)[Pair(tableName, "get")] = sql
sql
}
val params = mapOf("id" to id)
logger.debug { "SQL: $sqlTemplate, PARAMS: $params" }
val rows = SqlTemplate
.forQuery(getConnection(), sqlTemplate)
.mapTo(Row::toJson)
.execute(params)
.coAwait()
.firstOrNull()
return rows?.let { JsonUtil.parseObject(it.toString(), clazz, true) }
}
override suspend fun queryBuilder(): QueryWrapper<TEntity> {
return QueryWrapperImpl(clazz, getConnection())
}
override suspend fun queryBuilder(clazz: Class<*>): QueryWrapper<*> {
return QueryWrapperImpl(clazz, getConnection())
}
private suspend fun getConnection(): SqlClient {
return if (TxCtx.isTransactionActive(coroutineContext)) {
TxCtx.currentSqlConnection(coroutineContext) ?: run {
logger.error("TransactionContextElement.sqlConnection is null")
return sqlClient
}
} else {
sqlClient
}
}
// 其他工具方法
override suspend fun createBatch(list: List<TEntity>): Int {
if (list.isEmpty()) return 0
var rowCount = 0
list.chunked(1000).forEach {
val sql = genBatchInsertSql(it)
rowCount += SqlTemplate.forUpdate(sqlClient, sql)
.execute(emptyMap())
.coAwait()
.rowCount()
}
return rowCount
}
// 工具方法:获取表名
private fun getTableName(): String {
return clazz.getAnnotation(Table::class.java)?.name?.takeIf { it.isNotBlank() }
?: StrUtil.toUnderlineCase(clazz.simpleName)
}
// 添加获取ID字段名称的方法
private fun getIdColumnName(): String {
val idField = clazz.declaredFields.find { it.isAnnotationPresent(Id::class.java) }
?: throw IllegalArgumentException("No @Id field found in ${clazz.simpleName}")
return idField.getAnnotation(Column::class.java)?.name?.takeIf { it.isNotBlank() }
?: StrUtil.toUnderlineCase(idField.name)
}
private fun getColumnMappings(): Map<String, String> {
return clazz.declaredFields.associate { field ->
val columnAnnotation = field.getAnnotation(Column::class.java)
val columnName = columnAnnotation?.name?.takeIf { it.isNotBlank() }
?: StrUtil.toUnderlineCase(field.name)
field.name to columnName
}
}
// 工具方法:获取非空字段及其值
private fun getNonNullFields(t: TEntity): Map<String, Any> {
return clazz.declaredFields
.filter { field ->
field.isAccessible = true
// 排除被 @Transient 注解标记的字段
!field.isAnnotationPresent(Transient::class.java) &&
field.get(t) != null
}
.associate { field ->
field.name to field.get(t)
}
}
/**
* 生成批量 INSERT SQL 语句的函数
* @param objects 要插入的对象列表
* @return 生成的 SQL 语句字符串
*/
private fun <TEntity> genBatchInsertSql(objects: List<TEntity>): String {
// 如果对象列表为空,直接返回空字符串
if (objects.isEmpty()) return ""
// 将类名转换为下划线命名的表名例如UserInfo -> user_info
val tableName = StrUtil.toUnderlineCase(clazz.simpleName)
// 获取类的所有字段,包括私有字段
val fields = clazz.declaredFields.filter {
// 过滤掉静态字段和合成字段
!Modifier.isStatic(it.modifiers) && !it.isSynthetic
}
// 确保所有字段可访问
fields.forEach { it.isAccessible = true }
// 将字段名转换为下划线命名的列名,并用逗号隔开
val columnNames = fields.joinToString(", ") { StrUtil.toUnderlineCase(it.name) }
// SQL 转义函数
fun escapeSql(value: String): String = value.replace("'", "''")
// 格式化属性值为 SQL 字符串
fun formatValue(value: Any?): String = when (value) {
null -> "NULL" // 如果值为 null返回字符串 "NULL"
is String -> "'${escapeSql(value)}'" // 字符串类型,加单引号并进行转义
is Enum<*> -> "'${value.name}'" // 枚举类型,使用枚举名,添加单引号
is Number, is Boolean -> value.toString() // 数字和布尔类型,直接转换为字符串
is Timestamp -> // 时间戳类型,格式化为指定的日期时间字符串
"'${formatter.format(value.toInstant().atZone(ZoneId.of("Asia/Shanghai")))}'"
is Array<*> -> // 数组类型处理
if (value.isEmpty()) "'{}'" else "'{${value.joinToString(",") { escapeSql(it?.toString() ?: "NULL") }}}'"
is Collection<*> -> // 集合类型处理
if (value.isEmpty()) "'{}'" else "'{${value.joinToString(",") { escapeSql(it?.toString() ?: "NULL") }}}'"
else -> "'${escapeSql(value.toString())}'" // 其他类型,调用 toString() 后转义并加单引号
}
// 构建 VALUES 部分,每个对象对应一组值
val valuesList = objects.map { instance ->
fields.joinToString(", ", "(", ")") { field ->
// 获取属性值,并格式化为 SQL 字符串
formatValue(field.get(instance))
}
}
return "INSERT INTO $tableName ($columnNames) VALUES ${valuesList.joinToString(", ")};"
}
}

View File

@ -0,0 +1,8 @@
package org.aikrai.vertx.db
object SqlHelper {
fun retBool(result: Int?): Boolean {
return null != result && result >= 1
}
}

View File

@ -0,0 +1,41 @@
package org.aikrai.vertx.db.tx
import io.vertx.sqlclient.SqlConnection
import io.vertx.sqlclient.Transaction
import java.util.*
import kotlin.coroutines.CoroutineContext
class TxCtxElem(
val sqlConnection: SqlConnection,
val transaction: Transaction,
val isActive: Boolean = true,
val isNested: Boolean = false,
val transactionStack: Stack<TxCtxElem>,
val index: Int = transactionStack.size,
val transactionId: String = UUID.randomUUID().toString()
) : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<TxCtxElem>
override val key: CoroutineContext.Key<*> = Key
override fun toString(): String {
return "TransactionContextElement(transactionId=$transactionId, isActive=$isActive, isNested=$isNested)"
}
}
object TxCtx {
fun getTransactionId(context: CoroutineContext): String? {
return context[TxCtxElem.Key]?.transactionId
}
fun currentTransaction(context: CoroutineContext): Transaction? {
return context[TxCtxElem.Key]?.transaction
}
fun currentSqlConnection(context: CoroutineContext): SqlConnection? {
return context[TxCtxElem.Key]?.sqlConnection
}
fun isTransactionActive(context: CoroutineContext): Boolean {
return context[TxCtxElem.Key]?.isActive ?: false
}
}

View File

@ -0,0 +1,104 @@
package org.aikrai.vertx.db.tx
import io.vertx.kotlin.coroutines.coAwait
import io.vertx.sqlclient.Pool
import io.vertx.sqlclient.SqlConnection
import io.vertx.sqlclient.Transaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
import mu.KotlinLogging
import org.aikrai.vertx.utlis.Meta
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.coroutineContext
suspend fun <T> withTransaction(block: suspend CoroutineScope.() -> T): Any? {
return TxMgrHolder.txMgr.withTransaction(block)
}
object TxMgrHolder {
private val _txMgr = AtomicReference<TxMgr?>(null)
val txMgr: TxMgr
get() = _txMgr.get() ?: throw Meta.failure(
"TransactionError",
"TxMgr(TransactionManager)尚未初始化,请先调用initTxMgr()"
)
/**
* 原子地初始化 TxMgr(TransactionManager)
* 如果已经初始化该方法将直接返回
*
* @param pool SQL 客户端连接池
*/
fun initTxMgr(pool: Pool) {
if (_txMgr.get() != null) return
val newManager = TxMgr(pool)
_txMgr.compareAndSet(null, newManager)
}
}
class TxMgr(
private val pool: Pool
) {
private val logger = KotlinLogging.logger { }
private val transactionStackMap = ConcurrentHashMap<CoroutineContext, Stack<TxCtxElem>>()
/**
* 在事务上下文中执行一个块
*
* @param block 需要在事务中执行的挂起函数
* @return 块的结果
*/
suspend fun <T> withTransaction(block: suspend CoroutineScope.() -> T): Any? {
val currentContext = coroutineContext
val transactionStack = currentContext[TxCtxElem]?.transactionStack ?: Stack<TxCtxElem>()
// 外层事务,嵌套事务,都创建新的连接和事务。实现外层事务回滚时所有嵌套事务回滚,嵌套事务回滚不影响外部事务
val connection: SqlConnection = pool.connection.coAwait()
val transaction: Transaction = connection.begin().coAwait()
return try {
val txCtxElem =
TxCtxElem(connection, transaction, true, transactionStack.isNotEmpty(), transactionStack)
transactionStack.push(txCtxElem)
logger.debug { (if (txCtxElem.isNested) "嵌套" else "") + "事务Id:" + txCtxElem.transactionId + "开始" }
withContext(currentContext + txCtxElem) {
val result = block()
if (txCtxElem.index == 0) {
while (transactionStack.isNotEmpty()) {
val txCtx = transactionStack.pop()
txCtx.transaction.commit().coAwait()
logger.debug { (if (txCtx.isNested) "嵌套" else "") + "事务Id:" + txCtx.transactionId + "提交" }
}
}
result
}
} catch (e: Exception) {
logger.error(e) { "Transaction failed, rollback" }
if (transactionStack.isNotEmpty() && !transactionStack.peek().isNested) {
// 外层事务失败,回滚所有事务
logger.error { "Rolling back all transactions" }
while (transactionStack.isNotEmpty()) {
val txCtxElem = transactionStack.pop()
txCtxElem.transaction.rollback().coAwait()
logger.debug { (if (txCtxElem.isNested) "嵌套" else "") + "事务Id:" + txCtxElem.transactionId + "回滚" }
}
throw e
} else {
// 嵌套事务失败,只回滚当前事务
val txCtxElem = transactionStack.pop()
txCtxElem.transaction.rollback().coAwait()
logger.debug(e) { (if (txCtxElem.isNested) "嵌套" else "") + "事务Id:" + txCtxElem.transactionId + "回滚" }
}
} finally {
if (transactionStack.isEmpty()) {
transactionStackMap.remove(currentContext) // 清理上下文
connection.close() // 仅在外层事务时关闭连接
}
}
}
}

View File

@ -0,0 +1,22 @@
package org.aikrai.vertx.jackson
import com.fasterxml.jackson.databind.PropertyName
import com.fasterxml.jackson.databind.introspect.Annotated
import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector
import jakarta.persistence.Column
class ColumnAnnotationIntrospector : JacksonAnnotationIntrospector() {
override fun findNameForDeserialization(annotated: Annotated?): PropertyName? {
return getColumnName(annotated)
}
override fun findNameForSerialization(annotated: Annotated?): PropertyName? {
return getColumnName(annotated)
}
private fun getColumnName(annotated: Annotated?): PropertyName? {
if (annotated == null) return null
val column = annotated.getAnnotation(Column::class.java)
return column?.let { PropertyName(it.name) }
}
}

View File

@ -0,0 +1,145 @@
package org.aikrai.vertx.jackson
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JavaType
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.databind.introspect.AnnotationIntrospectorPair
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import io.vertx.core.json.JsonArray
import io.vertx.core.json.JsonObject
object JsonUtil {
fun configure(writeClassName: Boolean) {
objectMapper = createObjectMapper(writeClassName)
objectMapperSnakeCase = createObjectMapperSnakeCase(writeClassName)
}
private var objectMapper = createObjectMapper(false)
private var objectMapperSnakeCase = createObjectMapperSnakeCase(false)
private val objectMapperDeserialization = run {
val mapper: ObjectMapper = jacksonObjectMapper()
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
mapper.registerModule(JavaTimeModule())
mapper.setAnnotationIntrospector(
AnnotationIntrospectorPair(ColumnAnnotationIntrospector(), mapper.deserializationConfig.annotationIntrospector)
)
mapper
}
private val objectMapperSnakeCaseDeserialization = run {
val mapper: ObjectMapper = jacksonObjectMapper()
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
mapper.propertyNamingStrategy = PropertyNamingStrategies.SNAKE_CASE
mapper.registerModule(JavaTimeModule())
mapper.setAnnotationIntrospector(
AnnotationIntrospectorPair(ColumnAnnotationIntrospector(), mapper.deserializationConfig.annotationIntrospector)
)
mapper
}
fun <T> toJsonStr(value: T, snakeCase: Boolean = false): String {
if (snakeCase) {
return objectMapperSnakeCase.writeValueAsString(value)
}
return objectMapper.writeValueAsString(value)
}
fun <T> toJsonObject(value: T, snakeCase: Boolean = false): JsonObject {
return JsonObject(toJsonStr(value, snakeCase))
}
fun <T> toJsonArray(value: T, snakeCase: Boolean = false): JsonArray {
return JsonArray(toJsonStr(value, snakeCase))
}
fun <T> parseObject(value: JsonObject, clazz: Class<T>, snakeCase: Boolean = false): T {
return parseObject(value.encode(), clazz, snakeCase)
}
fun <T> parseObject(value: JsonObject, typeReference: TypeReference<T>, snakeCase: Boolean = false): T {
return parseObject<T>(value.encode(), typeReference, snakeCase)
}
fun <T> parseObject(value: String, clazz: Class<T>, snakeCase: Boolean = false): T {
if (snakeCase) {
return objectMapperSnakeCaseDeserialization.readValue(value, clazz)
}
return objectMapperDeserialization.readValue(value, clazz)
}
private fun <T> parseObject(value: String, typeReference: TypeReference<T>, snakeCase: Boolean = false): T {
if (snakeCase) {
return objectMapperSnakeCaseDeserialization.readValue(value, typeReference)
}
return objectMapperDeserialization.readValue(value, typeReference)
}
fun <T> parseObject(value: Map<*, *>, clazz: Class<T>, snakeCase: Boolean = false): T {
return parseObject(toJsonStr(value), clazz, snakeCase)
}
fun <T> parseObject(value: Map<*, *>, typeReference: TypeReference<T>, snakeCase: Boolean = false): T {
return parseObject<T>(
toJsonStr(value),
typeReference,
snakeCase
)
}
fun <T> parseArray(value: JsonArray, typeReference: TypeReference<List<T>>, snakeCase: Boolean = false): List<T> {
return parseArray(value.encode(), typeReference, snakeCase)
}
fun <T> parseArray(value: String, typeReference: TypeReference<List<T>>, snakeCase: Boolean = false): List<T> {
if (snakeCase) {
return objectMapperSnakeCaseDeserialization.readValue(value, typeReference)
}
return objectMapperDeserialization.readValue(value, typeReference)
}
}
private class CustomTypeResolverBuilder : ObjectMapper.DefaultTypeResolverBuilder(
ObjectMapper.DefaultTyping.NON_FINAL,
BasicPolymorphicTypeValidator.builder().build()
) {
override fun useForType(t: JavaType): Boolean {
if (t.rawClass.isEnum) return false
return t.rawClass.name.startsWith("app.")
}
}
private fun createObjectMapper(writeClassName: Boolean): ObjectMapper {
val mapper: ObjectMapper = jacksonObjectMapper()
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
mapper.registerModule(JavaTimeModule())
if (writeClassName) {
val typeResolver = CustomTypeResolverBuilder()
typeResolver.init(JsonTypeInfo.Id.CLASS, null)
typeResolver.inclusion(JsonTypeInfo.As.PROPERTY)
typeResolver.typeProperty("@t")
mapper.setDefaultTyping(typeResolver)
}
return mapper
}
private fun createObjectMapperSnakeCase(writeClassName: Boolean): ObjectMapper {
val mapper: ObjectMapper = jacksonObjectMapper()
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
mapper.propertyNamingStrategy = PropertyNamingStrategies.SNAKE_CASE
mapper.registerModule(JavaTimeModule())
if (writeClassName) {
val typeResolver = CustomTypeResolverBuilder()
typeResolver.init(JsonTypeInfo.Id.CLASS, null)
typeResolver.inclusion(JsonTypeInfo.As.PROPERTY)
typeResolver.typeProperty("@t")
mapper.setDefaultTyping(typeResolver)
}
return mapper
}

View File

@ -0,0 +1,425 @@
package org.aikrai.vertx.openapi
import cn.hutool.core.util.StrUtil
import io.swagger.v3.core.util.Json
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.Operation
import io.swagger.v3.oas.models.PathItem
import io.swagger.v3.oas.models.Paths
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.media.Content
import io.swagger.v3.oas.models.media.MediaType
import io.swagger.v3.oas.models.media.Schema
import io.swagger.v3.oas.models.parameters.Parameter
import io.swagger.v3.oas.models.parameters.RequestBody
import io.swagger.v3.oas.models.responses.ApiResponse
import io.swagger.v3.oas.models.responses.ApiResponses
import io.swagger.v3.oas.models.servers.Server
import mu.KotlinLogging
import org.aikrai.vertx.context.Controller
import org.aikrai.vertx.context.CustomizeRequest
import org.aikrai.vertx.context.D
import org.aikrai.vertx.utlis.ClassUtil
import org.reflections.Reflections
import java.lang.reflect.Method
import java.lang.reflect.ParameterizedType
import java.sql.Date
import java.sql.Time
import java.sql.Timestamp
import java.time.*
import kotlin.reflect.KParameter
import kotlin.reflect.jvm.javaType
import kotlin.reflect.jvm.kotlinFunction
/**
* OpenAPI 规范生成器用于生成 Vertx 路由的 OpenAPI 文档
*/
class OpenApiSpecGenerator {
private val logger = KotlinLogging.logger { }
// 用于跟踪已处理的类型
private val processedTypes = mutableSetOf<Class<*>>()
companion object {
/**
* 基础类型到 OpenAPI 类型的映射关系
*/
private val PRIMITIVE_TYPE_MAPPING = mapOf(
// java.lang classes
String::class.java to "string",
Int::class.java to "integer",
Integer::class.java to "integer",
Long::class.java to "number",
java.lang.Long::class.java to "number",
Double::class.java to "number",
java.lang.Double::class.java to "number",
Boolean::class.java to "boolean",
java.lang.Boolean::class.java to "boolean",
List::class.java to "array",
Array::class.java to "array",
Collection::class.java to "array",
// java.time classes
Instant::class.java to "number",
LocalDate::class.java to "string",
LocalTime::class.java to "string",
LocalDateTime::class.java to "string",
ZonedDateTime::class.java to "number",
OffsetDateTime::class.java to "number",
Duration::class.java to "number",
Period::class.java to "string",
// java.sql classes
Date::class.java to "string",
Time::class.java to "number",
Timestamp::class.java to "number",
)
}
/**
* 生成 OpenAPI 规范的 JSON 字符串
*
* @param title API 文档标题
* @param version API 版本号
* @param serverUrl 服务器 URL
* @return 格式化后的 OpenAPI 规范 JSON 字符串
*/
fun genOpenApiSpecStr(title: String, version: String, serverUrl: String): String {
return Json.pretty(generateOpenApiSpec(title, version, serverUrl))
}
/**
* 生成 OpenAPI 规范对象
*
* @param title API 文档标题
* @param version API 版本号
* @param serverUrl 服务器 URL
* @return OpenAPI 规范对象
*/
private fun generateOpenApiSpec(title: String, version: String, serverUrl: String): OpenAPI {
logger.info("Generating OpenAPI Specification for Vertx routes")
return OpenAPI().apply {
info = Info().apply {
this.title = title
this.version = version
}
servers = listOf(Server().apply { url = serverUrl })
paths = generatePaths()
}
}
/**
* 生成 API 路径信息
*
* @return Paths 对象包含所有 API 路径的定义
*/
private fun generatePaths(): Paths {
val paths = Paths()
// 获取所有带有 @Controller 注解的类
val packageName = ClassUtil.getMainClass()?.packageName
val controllerClassSet = Reflections(packageName).getTypesAnnotatedWith(Controller::class.java)
ClassUtil.getPublicMethods(controllerClassSet).forEach { (controllerClass, methods) ->
val controllerInfo = extractControllerInfo(controllerClass)
methods.forEach { method ->
val pathInfo = generatePathInfo(method, controllerInfo)
paths.addPathItem(pathInfo.path, pathInfo.pathItem)
}
}
return paths
}
/**
* 从控制器类中提取控制器相关信息
*
* @param controllerClass 控制器类
* @return 包含控制器名称前缀和标签的 ControllerInfo 对象
*/
private fun extractControllerInfo(controllerClass: Class<*>): ControllerInfo {
val controllerAnnotation = controllerClass.getAnnotation(Controller::class.java)
val dAnnotation = controllerClass.getAnnotation(D::class.java)
val controllerName = controllerClass.simpleName.removeSuffix("Controller")
return ControllerInfo(
name = controllerName,
prefix = controllerAnnotation?.prefix?.takeIf { it.isNotBlank() } ?: controllerName,
tag = "${dAnnotation?.name ?: ""} $controllerName"
)
}
/**
* 生成路径信息
*
* @param method 方法对象
* @param controllerInfo 控制器信息
* @return 包含路径和路径项的 PathInfo 对象
*/
private fun generatePathInfo(method: Method, controllerInfo: ControllerInfo): PathInfo {
val methodDAnnotation = method.getAnnotation(D::class.java)
val path = buildPath(controllerInfo.prefix, method.name)
val operation = Operation().apply {
operationId = method.name
summary = "${methodDAnnotation?.name ?: ""} $path"
description = methodDAnnotation?.caption ?: summary
// 分离请求体参数和普通参数
val allParameters = getParameters(method)
val (bodyParams, queryParams) = allParameters.partition { it.`in` == "body" }
// 设置查询参数
parameters = queryParams
// 设置请求体
if (bodyParams.isNotEmpty()) {
requestBody = RequestBody().apply {
content = bodyParams.first().content
required = bodyParams.first().required
description = bodyParams.first().description
}
}
responses = generateResponsesFromReturnType(method)
deprecated = false
security = listOf()
tags = listOf(controllerInfo.tag)
}
val pathItem = PathItem().apply {
operation(getHttpMethod(method), operation)
}
return PathInfo(path, pathItem)
}
/**
* 构建 API 路径
*
* @param controllerPrefix 控制器前缀
* @param methodName 方法名
* @return 格式化后的 API 路径
*/
private fun buildPath(controllerPrefix: String, methodName: String): String {
return (
"/${StrUtil.lowerFirst(StrUtil.toCamelCase(controllerPrefix))}/" +
StrUtil.lowerFirst(StrUtil.toCamelCase(methodName))
).replace("//", "/")
}
/**
* 根据方法返回值类型生成响应对象
*
* @param method 方法对象
* @return ApiResponses 对象
*/
private fun generateResponsesFromReturnType(method: Method): ApiResponses {
val returnType = method.kotlinFunction?.returnType?.javaType
val schema = when (returnType) {
// 处理泛型返回类型
is ParameterizedType -> {
val rawType = returnType.rawType as Class<*>
val typeArguments = returnType.actualTypeArguments
when {
// 处理集合类型
Collection::class.java.isAssignableFrom(rawType) -> {
Schema<Any>().apply {
type = "array"
items = generateSchema(
typeArguments[0].let {
when (it) {
is Class<*> -> it
is ParameterizedType -> it.rawType as Class<*>
else -> Any::class.java
}
}
)
}
}
// 可以添加其他泛型类型的处理
else -> generateSchema(rawType)
}
}
// 处理普通类型
is Class<*> -> generateSchema(returnType)
else -> Schema<Any>().type("object")
}
return ApiResponses().addApiResponse(
"200",
ApiResponse().apply {
description = "OK"
content = Content().addMediaType(
"application/json",
MediaType().schema(schema)
)
headers = mapOf()
}
)
}
/**
* 获取方法的参数列表
*
* @param method 方法对象
* @return OpenAPI 参数列表
*/
private fun getParameters(method: Method): List<Parameter> {
return method.kotlinFunction?.parameters
?.mapNotNull { parameter -> generateParameter(parameter) }
?: emptyList()
}
/**
* 生成 OpenAPI 参数对象
*
* @param parameter Kotlin 参数对象
* @return OpenAPI 参数对象如果无法生成则返回 null
*/
private fun generateParameter(parameter: KParameter): Parameter? {
if (parameter.kind == KParameter.Kind.INSTANCE) return null
val type =
(parameter.type.javaType as? Class<*>) ?: (parameter.type.javaType as? ParameterizedType)?.rawType as? Class<*>
?: return null
val paramName = parameter.name ?: return null
val annotation = parameter.annotations.filterIsInstance<D>().firstOrNull()
// 检查是否为自定义数据类
if (!parameter.type.classifier.toString().startsWith("class kotlin.") &&
!(parameter.type.javaType as Class<*>).isEnum &&
parameter.type.javaType is Class<*>
) {
// 创建请求体参数
return Parameter().apply {
name = annotation?.name?.takeIf { it.isNotBlank() } ?: paramName
required = !parameter.type.isMarkedNullable
description = annotation?.caption?.takeIf { it.isNotBlank() } ?: name
`in` = "body"
schema = generateSchema(type)
content = Content().addMediaType(
"application/json",
MediaType().schema(schema)
)
}
}
// 处理普通参数
return Parameter().apply {
name = annotation?.name?.takeIf { it.isNotBlank() } ?: paramName
required = !parameter.type.isMarkedNullable
description = annotation?.caption?.takeIf { it.isNotBlank() } ?: name
allowEmptyValue = parameter.type.isMarkedNullable
`in` = "query"
schema = generateSchema(type)
}
}
/**
* 生成 OpenAPI Schema 对象
*
* @param type 参数类型
* @return OpenAPI Schema 对象
*/
private fun generateSchema(type: Class<*>): Schema<Any> {
// 如果该类型已经处理过,则返回一个空的 Schema避免循环引用
if (processedTypes.contains(type)) {
return Schema<Any>().apply {
this.type = "object"
description = "Circular reference detected"
}
}
// 将当前类型添加到已处理集合中
processedTypes.add(type)
return when {
// 处理基础类型
PRIMITIVE_TYPE_MAPPING.containsKey(type) -> Schema<Any>().apply {
this.type = PRIMITIVE_TYPE_MAPPING[type]
deprecated = false
}
// 处理枚举类型
type.isEnum -> Schema<Any>().apply {
this.type = "string"
enum = type.enumConstants?.map { it.toString() }
}
type.name.startsWith("java.lang") || type.name.startsWith("java.time") || type.name.startsWith("java.sql") -> Schema<Any>().apply {
this.type = type.simpleName.lowercase()
deprecated = false
}
type.name.startsWith("java") -> Schema<Any>().apply {
this.type = type.simpleName.lowercase()
deprecated = false
}
// 处理自定义对象
else -> Schema<Any>().apply {
this.type = "object"
properties = type.declaredFields
.filter { !it.isSynthetic }
.associate { field ->
field.isAccessible = true
field.name to generateSchema(field.type)
}
}
}.also {
// 处理完后,从已处理集合中移除当前类型
processedTypes.remove(type)
}
}
/**
* 生成默认的 API 响应对象
*
* @return 包含默认响应的 ApiResponses 对象
*/
private fun generateDefaultResponses(): ApiResponses {
return ApiResponses().addApiResponse(
"200",
ApiResponse().apply {
description = "OK"
content = Content().addMediaType(
"*/*",
MediaType().schema(Schema<Any>().type("object"))
)
headers = mapOf()
}
)
}
/**
* 获取方法对应的 HTTP 方法
*
* @param method 方法对象
* @return PathItem.HttpMethod 枚举值
*/
private fun getHttpMethod(method: Method): PathItem.HttpMethod {
val api = method.getAnnotation(CustomizeRequest::class.java)
return if (api != null) {
when (api.method.uppercase()) {
"GET" -> PathItem.HttpMethod.GET
"PUT" -> PathItem.HttpMethod.PUT
"DELETE" -> PathItem.HttpMethod.DELETE
"PATCH" -> PathItem.HttpMethod.PATCH
else -> PathItem.HttpMethod.POST
}
} else {
PathItem.HttpMethod.POST
}
}
/**
* 控制器信息数据类
*
* @property name 控制器名称
* @property prefix 控制器路径前缀
* @property tag API 文档标签
*/
private data class ControllerInfo(
val name: String,
val prefix: String,
val tag: String
)
/**
* 路径信息数据类
*
* @property path API 路径
* @property pathItem OpenAPI 路径项对象
*/
private data class PathInfo(
val path: String,
val pathItem: PathItem
)
}

View File

@ -0,0 +1,84 @@
package org.aikrai.vertx.utlis
import cn.hutool.core.util.ReflectUtil
import java.io.File
import java.lang.reflect.Method
import java.util.*
import java.util.jar.JarFile
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaGetter
import kotlin.reflect.jvm.javaSetter
object ClassUtil {
/**
* 获取应用程序的主类
*
* @return 主类的 Class 对象如果未找到则返回 null
*/
fun getMainClass(): Class<*>? {
val classLoader = ServiceLoader.load(ClassLoader::class.java).firstOrNull()
?: Thread.currentThread().contextClassLoader
val mainCommand = System.getProperty("sun.java.command")
if (mainCommand != null) {
val mainClassName: String? = if (mainCommand.endsWith(".jar")) {
try {
val jarFilePath = mainCommand.split(" ").first()
val jarFile = File(jarFilePath)
if (jarFile.exists()) {
JarFile(jarFile).use { jar ->
jar.manifest.mainAttributes.getValue("Main-Class")
}
} else {
null
}
} catch (e: Exception) {
e.printStackTrace()
null
}
} else {
// mainCommand 是类名
mainCommand.split(" ").first()
}
if (!mainClassName.isNullOrEmpty()) {
return try {
classLoader.loadClass(mainClassName)
} catch (e: ClassNotFoundException) {
e.printStackTrace()
null
}
}
}
return null
}
/**
* 获取类中的所有公共方法
*
* @return 包含控制器类和其方法数组的列表
*/
fun getPublicMethods(classSet: Set<Class<*>>): List<Pair<Class<*>, Array<Method>>> {
return classSet.map { clazz ->
val kClass = clazz.kotlin
// 获取所有属性的 getter 方法
val getters: Set<Method> = kClass.memberProperties.mapNotNull { it.javaGetter }.toSet()
// 获取所有可变属性的 setter 方法
val setters: Set<Method> = kClass.memberProperties
// 只处理可变属性
.filterIsInstance<KMutableProperty1<*, *>>()
.mapNotNull { it.javaSetter }.toSet()
// 合并 getter 和 setter 方法
val propertyAccessors: Set<Method> = getters + setters
// 获取所有公共方法
val allPublicMethods = ReflectUtil.getPublicMethods(clazz)
// 过滤掉不需要的方法
val filteredMethods = allPublicMethods.filter { method ->
// 1. 排除合成方法 2. 仅包括在当前类中声明的方法
!method.isSynthetic && method.declaringClass == clazz && !propertyAccessors.contains(method)
}.toTypedArray()
Pair(clazz, filteredMethods)
}
}
}

View File

@ -0,0 +1,24 @@
package org.aikrai.vertx.utlis
import jakarta.persistence.*
import java.time.Instant
@MappedSuperclass
open class Entity {
@Column(name = "create_by", length = 64)
var createBy: String? = null
@Column(name = "create_time")
var createTime: Instant? = null
@Column(name = "update_by", length = 64)
var updateBy: String? = null
@Column(name = "update_time")
var updateTime: Instant? = null
@Version
@Column(name = "version")
var version: Long = 0
}

View File

@ -0,0 +1,45 @@
package org.aikrai.vertx.utlis
import io.vertx.core.json.JsonObject
object FlattenUtil {
fun flattenMap(map: Map<String, Any>, parentKey: String = "", separator: String = "."): Map<String, Any> {
val flatMap = mutableMapOf<String, Any>()
for ((key, value) in map) {
val newKey = if (parentKey.isEmpty()) key else "$parentKey$separator$key"
when (value) {
is Map<*, *> -> {
@Suppress("UNCHECKED_CAST")
flatMap.putAll(flattenMap(value as Map<String, Any>, newKey, separator))
}
else -> flatMap[newKey] = value
}
}
return flatMap
}
fun flattenJsonObject(obj: JsonObject, parentKey: String = "", separator: String = "."): Map<String, Any> {
val flatMap = mutableMapOf<String, Any>()
for (key in obj.fieldNames()) {
val value = obj.getValue(key)
val newKey = if (parentKey.isEmpty()) key else "$parentKey$separator$key"
when (value) {
is JsonObject -> {
flatMap.putAll(flattenJsonObject(value, newKey, separator))
}
is Iterable<*> -> {
// 如果值是数组,可以根据需要展开或处理
flatMap[newKey] = value
}
else -> {
flatMap[newKey] = value
}
}
}
return flatMap
}
}

View File

@ -0,0 +1,82 @@
package org.aikrai.vertx.utlis
import io.vertx.core.MultiMap
import mu.KotlinLogging
import java.sql.Timestamp
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneId
import kotlin.collections.forEach
import kotlin.collections.set
import kotlin.stackTraceToString
import kotlin.text.isNullOrBlank
import kotlin.text.trim
object LangUtil {
private val logger = KotlinLogging.logger { }
fun <R> letTry(clause: () -> R): R? {
return try {
clause()
} catch (ex: Throwable) {
logger.error { ex.stackTraceToString() }
null
}
}
fun <T, R> T?.letIf(clause: (T) -> R): R? {
if (this != null) {
return clause(this)
}
return null
}
fun String?.defaultAsNull(): String? {
if (this.isNullOrBlank()) {
return null
}
return this.trim()
}
fun Long?.defaultAsNull(): Long? {
if (this == 0L) {
return null
}
return this
}
fun Int?.defaultAsNull(): Int? {
if (this == 0) {
return null
}
return this
}
fun Double?.defaultAsNull(): Double? {
if (this == 0.0) {
return null
}
return this
}
fun MultiMap.toMap(): Map<String, Any> {
val map = mutableMapOf<String, Any>()
forEach { map[it.key] == it.value }
return map
}
fun MultiMap.toStringMap(): Map<String, String> {
val map = mutableMapOf<String, String>()
forEach { map[it.key] = it.value.toString() }
return map
}
fun offsetDateTime(epochMilli: Long?): OffsetDateTime? {
if (epochMilli == null || epochMilli == 0L) return null
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(epochMilli), ZoneId.systemDefault())
}
fun timestamp(): Timestamp {
return Timestamp.from(Instant.now())
}
}

View File

@ -0,0 +1,78 @@
package org.aikrai.vertx.utlis
import io.vertx.core.Closeable
import io.vertx.core.Promise
import io.vertx.core.Vertx
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
open class Lazyload<T>(private val block: suspend () -> T) {
protected var value: T? = null
private val mutex = Mutex()
fun dirty() {
value = null
}
open suspend fun get(): T {
if (value == null) {
mutex.withLock {
if (value == null) {
value = block()
}
}
}
return value!!
}
}
// timeout: milliseconds
class Autoload<T>(
private val vertx: Vertx,
private val coroutineScope: CoroutineScope,
private val timeout: Long,
private val block: suspend () -> T
) : Lazyload<T>(block), Closeable {
private var periodicId = vertx.setPeriodic(timeout) {
coroutineScope.launch {
value = block()
}
}
override fun close(completion: Promise<Void>?) {
vertx.cancelTimer(periodicId)
completion?.complete()
}
}
class Aliveload<T>(
private val vertx: Vertx,
private val coroutineScope: CoroutineScope,
private val timeout: Long,
private val block: suspend () -> T
) : Lazyload<T>(block), Closeable {
private var needsUpdate = true
private var periodicId = vertx.setPeriodic(timeout) {
if (needsUpdate) {
needsUpdate = false
coroutineScope.launch {
value = block()
}
}
}
override suspend fun get(): T {
needsUpdate = true
return super.get()
}
override fun close(completion: Promise<Void>?) {
vertx.cancelTimer(periodicId)
completion?.complete()
}
}

View File

@ -0,0 +1,48 @@
package org.aikrai.vertx.utlis
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import org.aikrai.vertx.jackson.JsonUtil
@JsonIgnoreProperties("localizedMessage", "suppressed", "stackTrace", "cause")
class Meta(
val name: String,
override val message: String = "",
val data: Any? = null
) : RuntimeException(message, null, false, false) {
fun stackTraceToString(): String {
return JsonUtil.toJsonStr(this)
}
companion object {
fun failure(name: String, message: String): Meta =
Meta(name, message)
fun unimplemented(message: String): Meta =
Meta("unimplemented", message)
fun unauthorized(message: String): Meta =
Meta("unauthorized", message)
fun timeout(message: String): Meta =
Meta("timeout", message)
fun requireArgument(argument: String, message: String): Meta =
Meta("required_argument:$argument", message)
fun invalidArgument(argument: String, message: String): Meta =
Meta("invalid_argument:$argument", message)
fun notFound(argument: String, message: String): Meta =
Meta("not_found:$argument", message)
fun badRequest(message: String): Meta =
Meta("bad_request", message)
fun notSupported(message: String): Meta =
Meta("not_supported", message)
fun forbidden(message: String): Meta =
Meta("forbidden", message)
}
}

View File

@ -0,0 +1,42 @@
package org.aikrai.vertx.utlis
import java.sql.Timestamp
import java.util.concurrent.ScheduledThreadPoolExecutor
import java.util.concurrent.TimeUnit
object TimeUtil {
private class Time {
@Volatile
var currentTimeMillis: Long
init {
currentTimeMillis = System.currentTimeMillis()
scheduleTick()
}
private fun scheduleTick() {
ScheduledThreadPoolExecutor(1) { runnable: Runnable? ->
val thread = Thread(runnable, "current-time-millis")
thread.isDaemon = true
thread
}.scheduleAtFixedRate(
{ currentTimeMillis = System.currentTimeMillis() },
1,
1,
TimeUnit.MILLISECONDS
)
}
}
private val instance = Time()
val now: Long
get() {
return instance.currentTimeMillis
}
fun now(): Timestamp {
return Timestamp(instance.currentTimeMillis)
}
}