From f28e1341d0d01c8c844de085c4eb90ce358f3287 Mon Sep 17 00:00:00 2001 From: AiKrai Date: Thu, 9 Jan 2025 12:01:09 +0800 Subject: [PATCH] vertx-pj:0.0.1 --- .gitignore | 48 ++ README.md | 51 +++ gradle.properties | 1 + settings.gradle.kts | 6 + vertx-demo/build.gradle.kts | 115 +++++ vertx-demo/src/main/kotlin/app/Application.kt | 27 ++ .../kotlin/app/controller/AuthController.kt | 58 +++ .../kotlin/app/controller/Demo1Controller.kt | 61 +++ .../src/main/kotlin/app/domain/CargoType.kt | 18 + .../src/main/kotlin/app/domain/role/Role.kt | 42 ++ .../kotlin/app/domain/role/RoleRepository.kt | 7 + .../app/domain/role/RoleRepositoryImpl.kt | 9 + .../main/kotlin/app/domain/user/LoginDTO.kt | 6 + .../src/main/kotlin/app/domain/user/User.kt | 74 +++ .../kotlin/app/domain/user/UserRepository.kt | 13 + .../app/domain/user/UserRepositoryImpl.kt | 26 ++ .../kotlin/app/service/user/UserService.kt | 12 + .../app/service/user/impl/UserServiceImpl.kt | 34 ++ .../src/main/kotlin/app/util/CacheUtil.kt | 44 ++ .../main/kotlin/app/verticle/ApifoxClient.kt | 54 +++ .../main/kotlin/app/verticle/MainVerticle.kt | 31 ++ .../main/kotlin/app/verticle/WebVerticle.kt | 105 +++++ vertx-demo/src/main/resources/bootstrap.yml | 22 + vertx-demo/src/main/resources/logback.xml | 102 +++++ .../src/main/resources/sql/sys_role.sql | 27 ++ .../src/main/resources/sql/sys_user.sql | 39 ++ vertx-fw/build.gradle.kts | 89 ++++ .../kotlin/org/aikrai/vertx/auth/AuthUser.kt | 19 + .../org/aikrai/vertx/auth/Authentication.kt | 27 ++ .../kotlin/org/aikrai/vertx/auth/TokenUtil.kt | 22 + .../org/aikrai/vertx/context/Controller.kt | 5 + .../org/aikrai/vertx/context/Customize.kt | 14 + .../main/kotlin/org/aikrai/vertx/context/D.kt | 11 + .../org/aikrai/vertx/context/RouterBuilder.kt | 344 ++++++++++++++ .../main/kotlin/org/aikrai/vertx/db/DbPool.kt | 59 +++ .../org/aikrai/vertx/db/QueryWrapper.kt | 39 ++ .../org/aikrai/vertx/db/QueryWrapperImpl.kt | 303 +++++++++++++ .../kotlin/org/aikrai/vertx/db/Repository.kt | 16 + .../org/aikrai/vertx/db/RepositoryImpl.kt | 272 +++++++++++ .../kotlin/org/aikrai/vertx/db/SqlHelper.kt | 8 + .../org/aikrai/vertx/db/tx/TxCtxElem.kt | 41 ++ .../kotlin/org/aikrai/vertx/db/tx/TxMgr.kt | 104 +++++ .../jackson/ColumnAnnotationIntrospector.kt | 22 + .../org/aikrai/vertx/jackson/JsonUtil.kt | 145 ++++++ .../vertx/openapi/OpenApiSpecGenerator.kt | 425 ++++++++++++++++++ .../org/aikrai/vertx/utlis/ClassUtil.kt | 84 ++++ .../kotlin/org/aikrai/vertx/utlis/Entity.kt | 24 + .../org/aikrai/vertx/utlis/FlattenUtil.kt | 45 ++ .../kotlin/org/aikrai/vertx/utlis/LangUtil.kt | 82 ++++ .../kotlin/org/aikrai/vertx/utlis/Lazyload.kt | 78 ++++ .../kotlin/org/aikrai/vertx/utlis/Meta.kt | 48 ++ .../kotlin/org/aikrai/vertx/utlis/TimeUtil.kt | 42 ++ 52 files changed, 3400 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 gradle.properties create mode 100644 settings.gradle.kts create mode 100644 vertx-demo/build.gradle.kts create mode 100644 vertx-demo/src/main/kotlin/app/Application.kt create mode 100644 vertx-demo/src/main/kotlin/app/controller/AuthController.kt create mode 100644 vertx-demo/src/main/kotlin/app/controller/Demo1Controller.kt create mode 100644 vertx-demo/src/main/kotlin/app/domain/CargoType.kt create mode 100644 vertx-demo/src/main/kotlin/app/domain/role/Role.kt create mode 100644 vertx-demo/src/main/kotlin/app/domain/role/RoleRepository.kt create mode 100644 vertx-demo/src/main/kotlin/app/domain/role/RoleRepositoryImpl.kt create mode 100644 vertx-demo/src/main/kotlin/app/domain/user/LoginDTO.kt create mode 100644 vertx-demo/src/main/kotlin/app/domain/user/User.kt create mode 100644 vertx-demo/src/main/kotlin/app/domain/user/UserRepository.kt create mode 100644 vertx-demo/src/main/kotlin/app/domain/user/UserRepositoryImpl.kt create mode 100644 vertx-demo/src/main/kotlin/app/service/user/UserService.kt create mode 100644 vertx-demo/src/main/kotlin/app/service/user/impl/UserServiceImpl.kt create mode 100644 vertx-demo/src/main/kotlin/app/util/CacheUtil.kt create mode 100644 vertx-demo/src/main/kotlin/app/verticle/ApifoxClient.kt create mode 100644 vertx-demo/src/main/kotlin/app/verticle/MainVerticle.kt create mode 100644 vertx-demo/src/main/kotlin/app/verticle/WebVerticle.kt create mode 100644 vertx-demo/src/main/resources/bootstrap.yml create mode 100644 vertx-demo/src/main/resources/logback.xml create mode 100644 vertx-demo/src/main/resources/sql/sys_role.sql create mode 100644 vertx-demo/src/main/resources/sql/sys_user.sql create mode 100644 vertx-fw/build.gradle.kts create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/AuthUser.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/Authentication.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/TokenUtil.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/context/Controller.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/context/Customize.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/context/D.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/context/RouterBuilder.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/db/DbPool.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapper.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapperImpl.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/db/Repository.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/db/RepositoryImpl.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/db/SqlHelper.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxCtxElem.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxMgr.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/ColumnAnnotationIntrospector.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/JsonUtil.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/openapi/OpenApiSpecGenerator.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/ClassUtil.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Entity.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/FlattenUtil.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/LangUtil.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Lazyload.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Meta.kt create mode 100644 vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/TimeUtil.kt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cda3d0a --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2740be --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Vertx-pj 说明 + +## 项目简介 +基于vert.x的web开发框架,提供了一些简单的封装,使得开发更加便捷。
+**写的简单,问题不少,仅供参考**。 + + +## 项目结构 +- **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对象自定义返回内容 \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7fc6f1f --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..e2f660e --- /dev/null +++ b/settings.gradle.kts @@ -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") diff --git a/vertx-demo/build.gradle.kts b/vertx-demo/build.gradle.kts new file mode 100644 index 0000000..023c07b --- /dev/null +++ b/vertx-demo/build.gradle.kts @@ -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")))) +} diff --git a/vertx-demo/src/main/kotlin/app/Application.kt b/vertx-demo/src/main/kotlin/app/Application.kt new file mode 100644 index 0000000..015c634 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/Application.kt @@ -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) { + 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" } + } + } + } + } +} diff --git a/vertx-demo/src/main/kotlin/app/controller/AuthController.kt b/vertx-demo/src/main/kotlin/app/controller/AuthController.kt new file mode 100644 index 0000000..35aa329 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/controller/AuthController.kt @@ -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", "用户名或密码错误") + } + } +} diff --git a/vertx-demo/src/main/kotlin/app/controller/Demo1Controller.kt b/vertx-demo/src/main/kotlin/app/controller/Demo1Controller.kt new file mode 100644 index 0000000..4dd6454 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/controller/Demo1Controller.kt @@ -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?, + @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 { + 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) + } +} diff --git a/vertx-demo/src/main/kotlin/app/domain/CargoType.kt b/vertx-demo/src/main/kotlin/app/domain/CargoType.kt new file mode 100644 index 0000000..1cd3053 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/CargoType.kt @@ -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 } + } + } +} diff --git a/vertx-demo/src/main/kotlin/app/domain/role/Role.kt b/vertx-demo/src/main/kotlin/app/domain/role/Role.kt new file mode 100644 index 0000000..f560ceb --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/role/Role.kt @@ -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 +} diff --git a/vertx-demo/src/main/kotlin/app/domain/role/RoleRepository.kt b/vertx-demo/src/main/kotlin/app/domain/role/RoleRepository.kt new file mode 100644 index 0000000..32df038 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/role/RoleRepository.kt @@ -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 diff --git a/vertx-demo/src/main/kotlin/app/domain/role/RoleRepositoryImpl.kt b/vertx-demo/src/main/kotlin/app/domain/role/RoleRepositoryImpl.kt new file mode 100644 index 0000000..8b30a45 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/role/RoleRepositoryImpl.kt @@ -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(sqlClient), RoleRepository diff --git a/vertx-demo/src/main/kotlin/app/domain/user/LoginDTO.kt b/vertx-demo/src/main/kotlin/app/domain/user/LoginDTO.kt new file mode 100644 index 0000000..16dba81 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/user/LoginDTO.kt @@ -0,0 +1,6 @@ +package app.domain.user + +data class LoginDTO( + var username: String, + var password: String +) diff --git a/vertx-demo/src/main/kotlin/app/domain/user/User.kt b/vertx-demo/src/main/kotlin/app/domain/user/User.kt new file mode 100644 index 0000000..b5029e9 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/user/User.kt @@ -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 +} diff --git a/vertx-demo/src/main/kotlin/app/domain/user/UserRepository.kt b/vertx-demo/src/main/kotlin/app/domain/user/UserRepository.kt new file mode 100644 index 0000000..1dc62b2 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/user/UserRepository.kt @@ -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 { + suspend fun getByName(name: String): User? + + suspend fun testTransaction(): User? + + suspend fun getList(): List +} diff --git a/vertx-demo/src/main/kotlin/app/domain/user/UserRepositoryImpl.kt b/vertx-demo/src/main/kotlin/app/domain/user/UserRepositoryImpl.kt new file mode 100644 index 0000000..ad03df9 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/user/UserRepositoryImpl.kt @@ -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(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 { + return queryBuilder().getList() + } +} diff --git a/vertx-demo/src/main/kotlin/app/service/user/UserService.kt b/vertx-demo/src/main/kotlin/app/service/user/UserService.kt new file mode 100644 index 0000000..5ae5ad4 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/service/user/UserService.kt @@ -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() +} diff --git a/vertx-demo/src/main/kotlin/app/service/user/impl/UserServiceImpl.kt b/vertx-demo/src/main/kotlin/app/service/user/impl/UserServiceImpl.kt new file mode 100644 index 0000000..eeb394f --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/service/user/impl/UserServiceImpl.kt @@ -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("update sys_user set email = '88888' where user_name = '运若汐'") + println("运若汐: $execute1") + withTransaction { + val execute = userRepository.execute("update sys_user set email = '88888' where user_name = '郸明'") + println("郸明: $execute") +// throw Meta.failure("test transaction", "test transaction") + } + } + } +} diff --git a/vertx-demo/src/main/kotlin/app/util/CacheUtil.kt b/vertx-demo/src/main/kotlin/app/util/CacheUtil.kt new file mode 100644 index 0000000..24b8b2e --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/util/CacheUtil.kt @@ -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? = null + + fun getCache(): CoroutineCache { + return cache ?: synchronized(this) { + cache ?: init().also { cache = it } + } + } + + private fun init(): CoroutineCache { + 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() + } +} diff --git a/vertx-demo/src/main/kotlin/app/verticle/ApifoxClient.kt b/vertx-demo/src/main/kotlin/app/verticle/ApifoxClient.kt new file mode 100644 index 0000000..c540e7b --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/verticle/ApifoxClient.kt @@ -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}") + } + } + } +} diff --git a/vertx-demo/src/main/kotlin/app/verticle/MainVerticle.kt b/vertx-demo/src/main/kotlin/app/verticle/MainVerticle.kt new file mode 100644 index 0000000..7898d6a --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/verticle/MainVerticle.kt @@ -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" } + } + } + } + } +} diff --git a/vertx-demo/src/main/kotlin/app/verticle/WebVerticle.kt b/vertx-demo/src/main/kotlin/app/verticle/WebVerticle.kt new file mode 100644 index 0000000..83b3e8c --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/verticle/WebVerticle.kt @@ -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 { 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() + } + } +} diff --git a/vertx-demo/src/main/resources/bootstrap.yml b/vertx-demo/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..2e0dead --- /dev/null +++ b/vertx-demo/src/main/resources/bootstrap.yml @@ -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 \ No newline at end of file diff --git a/vertx-demo/src/main/resources/logback.xml b/vertx-demo/src/main/resources/logback.xml new file mode 100644 index 0000000..59c6918 --- /dev/null +++ b/vertx-demo/src/main/resources/logback.xml @@ -0,0 +1,102 @@ + + + + + + + + System.out + + [%-5level] %d [%thread] %class{36}.%M:%L - %m%n + + + + + + [%-5level] %d [%thread] %class{36}.%M:%L - %m%n + + + + + return level >= WARN; + + ACCEPT + DENY + + + ${ROOT}${APPNAME}-%d-warn.%i.log + ${MAXHISTORY} + + ${FILESIZE} + + + + + + [%-5level] %d [%thread] %class{36}.%M:%L - %m%n + + + + INFO + ACCEPT + DENY + + + ${ROOT}${APPNAME}-%d-info.%i.log + ${MAXHISTORY} + + ${FILESIZE} + + + + + + [%-5level] %d [%thread] %class{36}.%M:%L - %m%n + + + + DEBUG + ACCEPT + DENY + + + ${ROOT}${APPNAME}-%d-debug.%i.log + ${MAXHISTORY} + + ${FILESIZE} + + + + + + [%-5level] %d [%thread] %class{36}.%M:%L - %m%n + + + + TRACE + ACCEPT + DENY + + + ${ROOT}${APPNAME}-%d-trace.%i.log + ${MAXHISTORY} + + ${FILESIZE} + + + + + + + + + + + diff --git a/vertx-demo/src/main/resources/sql/sys_role.sql b/vertx-demo/src/main/resources/sql/sys_role.sql new file mode 100644 index 0000000..5789ef3 --- /dev/null +++ b/vertx-demo/src/main/resources/sql/sys_role.sql @@ -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; diff --git a/vertx-demo/src/main/resources/sql/sys_user.sql b/vertx-demo/src/main/resources/sql/sys_user.sql new file mode 100644 index 0000000..0e79db9 --- /dev/null +++ b/vertx-demo/src/main/resources/sql/sys_user.sql @@ -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; diff --git a/vertx-fw/build.gradle.kts b/vertx-fw/build.gradle.kts new file mode 100644 index 0000000..2b53690 --- /dev/null +++ b/vertx-fw/build.gradle.kts @@ -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") +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/AuthUser.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/AuthUser.kt new file mode 100644 index 0000000..d48c140 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/AuthUser.kt @@ -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, + val permissions: Set, +) diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/Authentication.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/Authentication.kt new file mode 100644 index 0000000..203c879 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/Authentication.kt @@ -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 = [], + val mode: Mode = Mode.AND, + val orRole: Array = [] +) + +@Target(AnnotationTarget.TYPE, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class CheckRole( + val type: String = "", + val value: Array = [], + val mode: Mode = Mode.AND +) + +enum class Mode { + AND, + OR +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/TokenUtil.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/TokenUtil.kt new file mode 100644 index 0000000..b1b3b33 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/TokenUtil.kt @@ -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 { + 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 + } + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/Controller.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/Controller.kt new file mode 100644 index 0000000..b873c46 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/Controller.kt @@ -0,0 +1,5 @@ +package org.aikrai.vertx.context + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class Controller(val prefix: String = "") diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/Customize.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/Customize.kt new file mode 100644 index 0000000..c315102 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/Customize.kt @@ -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 diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/D.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/D.kt new file mode 100644 index 0000000..b974070 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/D.kt @@ -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 = "" +) diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/RouterBuilder.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/RouterBuilder.kt new file mode 100644 index 0000000..6ded7ce --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/RouterBuilder.kt @@ -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() + + fun build(getIt: (clazz: Class<*>) -> Any): RouterBuilder { + // 缓存路由信息 + val routeInfoCache = mutableMapOf, 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 + 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 + val roleSet = attributes().getJsonArray("role").toSet() as Set + 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, + actual: Set, + 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): Array { + val params = mutableListOf() + 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 + } 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, + 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 + ) +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/DbPool.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/DbPool.kt new file mode 100644 index 0000000..52f489d --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/DbPool.kt @@ -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 + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapper.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapper.kt new file mode 100644 index 0000000..1617761 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapper.kt @@ -0,0 +1,39 @@ +package org.aikrai.vertx.db + +import kotlin.reflect.KProperty1 + +interface QueryWrapper { + fun select(vararg columns: String): QueryWrapper + fun select(vararg columns: KProperty1): QueryWrapper + + fun eq(column: String, value: Any): QueryWrapper + fun eq(column: KProperty1, value: Any): QueryWrapper + fun eq(condition: Boolean, column: String, value: Any): QueryWrapper + fun eq(condition: Boolean, column: KProperty1, value: Any): QueryWrapper + + fun from(table: String): QueryWrapper + + fun like(column: String, value: String): QueryWrapper + fun like(column: KProperty1, value: String): QueryWrapper +// fun like(condition: Boolean, column: String, value: String): QueryWrapper +// fun like(condition: Boolean, column: KProperty1, value: String): QueryWrapper + +// fun likeLeft(column: String, value: String): QueryWrapper + fun likeLeft(column: KProperty1, value: String): QueryWrapper + +// fun likeRight(column: String, value: String): QueryWrapper + fun likeRight(column: KProperty1, value: String): QueryWrapper + + fun `in`(column: KProperty1, values: Collection<*>): QueryWrapper + fun notIn(column: KProperty1, values: Collection<*>): QueryWrapper + + fun groupBy(vararg columns: KProperty1): QueryWrapper + fun having(condition: String): QueryWrapper + + fun orderByAsc(vararg columns: KProperty1): QueryWrapper + fun orderByDesc(vararg columns: KProperty1): QueryWrapper + + fun genSql(): String + suspend fun getList(): List + suspend fun getOne(): T? +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapperImpl.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapperImpl.kt new file mode 100644 index 0000000..e8bca62 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapperImpl.kt @@ -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( + private val entityClass: Class, + private val sqlClient: SqlClient, +) : QueryWrapper { + private val conditions = mutableListOf() + + override fun select(vararg columns: String): QueryWrapper { + conditions.add( + QueryCondition( + type = QueryType.SELECT, + column = columns.joinToString(",") + ) + ) + return this + } + + override fun select(vararg columns: KProperty1): QueryWrapper { + 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 { + return eq(true, column, value) + } + + override fun eq(column: KProperty1, value: Any): QueryWrapper { + return eq(true, column, value) + } + + override fun eq(condition: Boolean, column: String, value: Any): QueryWrapper { + if (condition) { + conditions.add( + QueryCondition( + type = QueryType.WHERE, + column = column, + operator = "=", + value = value + ) + ) + } + return this + } + + override fun eq(condition: Boolean, column: KProperty1, value: Any): QueryWrapper { + 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 { + conditions.add( + QueryCondition( + type = QueryType.FROM, + column = table + ) + ) + return this + } + + override fun like(column: String, value: String): QueryWrapper { + conditions.add( + QueryCondition( + type = QueryType.LIKE, + column = column, + operator = "LIKE", + value = "%$value%" + ) + ) + return this + } + + override fun like(column: KProperty1, value: String): QueryWrapper { + conditions.add(QueryCondition(QueryType.LIKE, column.name, "LIKE", "%$value%")) + return this + } + + override fun likeLeft(column: KProperty1, value: String): QueryWrapper { + conditions.add( + QueryCondition( + type = QueryType.WHERE, + column = column.name, + operator = "LIKE", + value = "%$value" + ) + ) + return this + } + + override fun likeRight(column: KProperty1, value: String): QueryWrapper { + conditions.add( + QueryCondition( + type = QueryType.WHERE, + column = column.name, + operator = "LIKE", + value = "$value%" + ) + ) + return this + } + + override fun `in`(column: KProperty1, values: Collection<*>): QueryWrapper { + conditions.add( + QueryCondition( + type = QueryType.WHERE, + column = column.name, + operator = "IN", + value = values + ) + ) + return this + } + + override fun notIn(column: KProperty1, values: Collection<*>): QueryWrapper { + conditions.add( + QueryCondition( + type = QueryType.WHERE, + column = column.name, + operator = "NOT IN", + value = values + ) + ) + return this + } + + override fun groupBy(vararg columns: KProperty1): QueryWrapper { + conditions.add( + QueryCondition( + type = QueryType.GROUP_BY, + column = columns.joinToString(",") { it.name } + ) + ) + return this + } + + override fun having(condition: String): QueryWrapper { + conditions.add( + QueryCondition( + type = QueryType.HAVING, + column = condition + ) + ) + return this + } + + override fun orderByAsc(vararg columns: KProperty1): QueryWrapper { + 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): QueryWrapper { + 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 { + 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 = emptyMap() +) diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/Repository.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/Repository.kt new file mode 100644 index 0000000..eef9795 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/Repository.kt @@ -0,0 +1,16 @@ +package org.aikrai.vertx.db + +interface Repository { + 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): Int + suspend fun get(id: TId): TEntity? + + suspend fun createBatch(list: List): Int + + suspend fun execute(sql: String): R + + suspend fun queryBuilder(): QueryWrapper + suspend fun queryBuilder(clazz: Class<*>): QueryWrapper<*> +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/RepositoryImpl.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/RepositoryImpl.kt new file mode 100644 index 0000000..2e88da1 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/RepositoryImpl.kt @@ -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( + private val sqlClient: SqlClient +) : Repository { + private val clazz: Class = (this::class.java.genericSuperclass as ParameterizedType) + .actualTypeArguments[1] as Class + private val logger = KotlinLogging.logger {} + private val sqlTemplateMap: Map, String> = mutableMapOf() + private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSXXX") + + override suspend fun 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() {} + 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): 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 { + 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): 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 { + 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 { + 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 genBatchInsertSql(objects: List): 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(", ")};" + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/SqlHelper.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/SqlHelper.kt new file mode 100644 index 0000000..3f16260 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/SqlHelper.kt @@ -0,0 +1,8 @@ +package org.aikrai.vertx.db + +object SqlHelper { + + fun retBool(result: Int?): Boolean { + return null != result && result >= 1 + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxCtxElem.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxCtxElem.kt new file mode 100644 index 0000000..2ad34e2 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxCtxElem.kt @@ -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, + val index: Int = transactionStack.size, + val transactionId: String = UUID.randomUUID().toString() +) : CoroutineContext.Element { + companion object Key : CoroutineContext.Key + 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 + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxMgr.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxMgr.kt new file mode 100644 index 0000000..35c5610 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxMgr.kt @@ -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 withTransaction(block: suspend CoroutineScope.() -> T): Any? { + return TxMgrHolder.txMgr.withTransaction(block) +} + +object TxMgrHolder { + private val _txMgr = AtomicReference(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>() + + /** + * 在事务上下文中执行一个块。 + * + * @param block 需要在事务中执行的挂起函数。 + * @return 块的结果。 + */ + suspend fun withTransaction(block: suspend CoroutineScope.() -> T): Any? { + val currentContext = coroutineContext + val transactionStack = currentContext[TxCtxElem]?.transactionStack ?: Stack() + // 外层事务,嵌套事务,都创建新的连接和事务。实现外层事务回滚时所有嵌套事务回滚,嵌套事务回滚不影响外部事务 + 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() // 仅在外层事务时关闭连接 + } + } + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/ColumnAnnotationIntrospector.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/ColumnAnnotationIntrospector.kt new file mode 100644 index 0000000..9997fad --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/ColumnAnnotationIntrospector.kt @@ -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) } + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/JsonUtil.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/JsonUtil.kt new file mode 100644 index 0000000..bedd855 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/JsonUtil.kt @@ -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 toJsonStr(value: T, snakeCase: Boolean = false): String { + if (snakeCase) { + return objectMapperSnakeCase.writeValueAsString(value) + } + return objectMapper.writeValueAsString(value) + } + + fun toJsonObject(value: T, snakeCase: Boolean = false): JsonObject { + return JsonObject(toJsonStr(value, snakeCase)) + } + + fun toJsonArray(value: T, snakeCase: Boolean = false): JsonArray { + return JsonArray(toJsonStr(value, snakeCase)) + } + + fun parseObject(value: JsonObject, clazz: Class, snakeCase: Boolean = false): T { + return parseObject(value.encode(), clazz, snakeCase) + } + + fun parseObject(value: JsonObject, typeReference: TypeReference, snakeCase: Boolean = false): T { + return parseObject(value.encode(), typeReference, snakeCase) + } + + fun parseObject(value: String, clazz: Class, snakeCase: Boolean = false): T { + if (snakeCase) { + return objectMapperSnakeCaseDeserialization.readValue(value, clazz) + } + return objectMapperDeserialization.readValue(value, clazz) + } + + private fun parseObject(value: String, typeReference: TypeReference, snakeCase: Boolean = false): T { + if (snakeCase) { + return objectMapperSnakeCaseDeserialization.readValue(value, typeReference) + } + return objectMapperDeserialization.readValue(value, typeReference) + } + + fun parseObject(value: Map<*, *>, clazz: Class, snakeCase: Boolean = false): T { + return parseObject(toJsonStr(value), clazz, snakeCase) + } + + fun parseObject(value: Map<*, *>, typeReference: TypeReference, snakeCase: Boolean = false): T { + return parseObject( + toJsonStr(value), + typeReference, + snakeCase + ) + } + + fun parseArray(value: JsonArray, typeReference: TypeReference>, snakeCase: Boolean = false): List { + return parseArray(value.encode(), typeReference, snakeCase) + } + + fun parseArray(value: String, typeReference: TypeReference>, snakeCase: Boolean = false): List { + 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 +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/openapi/OpenApiSpecGenerator.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/openapi/OpenApiSpecGenerator.kt new file mode 100644 index 0000000..b271c48 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/openapi/OpenApiSpecGenerator.kt @@ -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>() + + 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().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().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 { + 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().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 { + // 如果该类型已经处理过,则返回一个空的 Schema,避免循环引用 + if (processedTypes.contains(type)) { + return Schema().apply { + this.type = "object" + description = "Circular reference detected" + } + } + // 将当前类型添加到已处理集合中 + processedTypes.add(type) + return when { + // 处理基础类型 + PRIMITIVE_TYPE_MAPPING.containsKey(type) -> Schema().apply { + this.type = PRIMITIVE_TYPE_MAPPING[type] + deprecated = false + } + // 处理枚举类型 + type.isEnum -> Schema().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().apply { + this.type = type.simpleName.lowercase() + deprecated = false + } + type.name.startsWith("java") -> Schema().apply { + this.type = type.simpleName.lowercase() + deprecated = false + } + // 处理自定义对象 + else -> Schema().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().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 + ) +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/ClassUtil.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/ClassUtil.kt new file mode 100644 index 0000000..1c3b9d1 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/ClassUtil.kt @@ -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>): List, Array>> { + return classSet.map { clazz -> + val kClass = clazz.kotlin + // 获取所有属性的 getter 方法 + val getters: Set = kClass.memberProperties.mapNotNull { it.javaGetter }.toSet() + // 获取所有可变属性的 setter 方法 + val setters: Set = kClass.memberProperties + // 只处理可变属性 + .filterIsInstance>() + .mapNotNull { it.javaSetter }.toSet() + // 合并 getter 和 setter 方法 + val propertyAccessors: Set = 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) + } + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Entity.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Entity.kt new file mode 100644 index 0000000..afd6e33 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Entity.kt @@ -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 +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/FlattenUtil.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/FlattenUtil.kt new file mode 100644 index 0000000..1939ddf --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/FlattenUtil.kt @@ -0,0 +1,45 @@ +package org.aikrai.vertx.utlis + +import io.vertx.core.json.JsonObject + +object FlattenUtil { + + fun flattenMap(map: Map, parentKey: String = "", separator: String = "."): Map { + val flatMap = mutableMapOf() + 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, newKey, separator)) + } + + else -> flatMap[newKey] = value + } + } + return flatMap + } + + fun flattenJsonObject(obj: JsonObject, parentKey: String = "", separator: String = "."): Map { + val flatMap = mutableMapOf() + 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 + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/LangUtil.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/LangUtil.kt new file mode 100644 index 0000000..b1cf5f5 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/LangUtil.kt @@ -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 letTry(clause: () -> R): R? { + return try { + clause() + } catch (ex: Throwable) { + logger.error { ex.stackTraceToString() } + null + } + } + + fun 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 { + val map = mutableMapOf() + forEach { map[it.key] == it.value } + return map + } + + fun MultiMap.toStringMap(): Map { + val map = mutableMapOf() + 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()) + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Lazyload.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Lazyload.kt new file mode 100644 index 0000000..b6be54a --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Lazyload.kt @@ -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(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( + private val vertx: Vertx, + private val coroutineScope: CoroutineScope, + private val timeout: Long, + private val block: suspend () -> T +) : Lazyload(block), Closeable { + + private var periodicId = vertx.setPeriodic(timeout) { + coroutineScope.launch { + value = block() + } + } + + override fun close(completion: Promise?) { + vertx.cancelTimer(periodicId) + completion?.complete() + } +} + +class Aliveload( + private val vertx: Vertx, + private val coroutineScope: CoroutineScope, + private val timeout: Long, + private val block: suspend () -> T +) : Lazyload(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?) { + vertx.cancelTimer(periodicId) + completion?.complete() + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Meta.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Meta.kt new file mode 100644 index 0000000..2bef7a3 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Meta.kt @@ -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) + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/TimeUtil.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/TimeUtil.kt new file mode 100644 index 0000000..00086d7 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/TimeUtil.kt @@ -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) + } +}