vertx-pj:0.0.1
This commit is contained in:
commit
f28e1341d0
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
.gradle
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
config/
|
||||
gradle/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
.idea/modules.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/compiler.xml
|
||||
.idea/libraries/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
out/
|
||||
!**/src/main/**/out/
|
||||
!**/src/test/**/out/
|
||||
|
||||
### Kotlin ###
|
||||
.kotlin
|
||||
|
||||
### Eclipse ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
bin/
|
||||
!**/src/main/**/bin/
|
||||
!**/src/test/**/bin/
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
51
README.md
Normal file
51
README.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Vertx-pj 说明
|
||||
|
||||
## 项目简介
|
||||
基于vert.x的web开发框架,提供了一些简单的封装,使得开发更加便捷。<br/>
|
||||
**写的简单,问题不少,仅供参考**。
|
||||
|
||||
|
||||
## 项目结构
|
||||
- **vertx-fw**: 简单封装的web开发框架
|
||||
- **vertx-demo**: 使用vertx-fw开发的demo
|
||||
|
||||
## 注解说明
|
||||
|
||||
### @Controller
|
||||
自定义Controller注解,用于路由注册
|
||||
- 启动时使用反射获取所有标记了@Controller注解的类
|
||||
- 获取类中所有方法,将其统一注册到router中
|
||||
- 可选参数`prefix`:定义请求路径前缀
|
||||
- 不填时默认使用类名(去除"Controller"后首字母小写)作为前缀
|
||||
|
||||
### @D
|
||||
文档生成注解,可用于以下位置:
|
||||
1. 类上:为Controller类添加说明
|
||||
2. 方法上:为方法添加说明
|
||||
3. 方法参数上:格式如 `@D("age", "年龄") age: Int?`
|
||||
- name: 参数名,用于从query或body中自动获取参数
|
||||
- value: 参数说明,用于文档生成
|
||||
|
||||
> 注:参数类型后的`?`表示可为空,不带`?`表示必填。框架会根据此进行参数校验。
|
||||
|
||||
### @AllowAnonymous
|
||||
权限控制注解
|
||||
- 可标记在Controller类或方法上
|
||||
- 表示该接口不需要鉴权即可访问
|
||||
|
||||
### 权限相关注解
|
||||
仿sa-token实现的权限控制:
|
||||
- `@CheckRole()`:角色检查
|
||||
- `@CheckPermission()`:权限检查
|
||||
|
||||
### 请求响应相关注解
|
||||
|
||||
#### @CustomizeRequest
|
||||
请求方式定制
|
||||
- 全局默认使用POST请求
|
||||
- 使用`@CustomizeRequest("get")`可将方法改为GET请求
|
||||
|
||||
#### @CustomizeResponse
|
||||
响应方式定制
|
||||
- 全局默认返回JSON格式
|
||||
- 使用`@CustomizeResponse`可获取response对象自定义返回内容
|
||||
1
gradle.properties
Normal file
1
gradle.properties
Normal file
@ -0,0 +1 @@
|
||||
kotlin.code.style=official
|
||||
6
settings.gradle.kts
Normal file
6
settings.gradle.kts
Normal file
@ -0,0 +1,6 @@
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
|
||||
}
|
||||
rootProject.name = "vertx-pj"
|
||||
include("vertx-fw")
|
||||
include("vertx-demo")
|
||||
115
vertx-demo/build.gradle.kts
Normal file
115
vertx-demo/build.gradle.kts
Normal file
@ -0,0 +1,115 @@
|
||||
plugins {
|
||||
application
|
||||
kotlin("jvm") version "1.9.20"
|
||||
id("com.diffplug.spotless") version "6.25.0"
|
||||
id("com.github.johnrengelman.shadow") version "8.1.1"
|
||||
}
|
||||
|
||||
group = "com.demo"
|
||||
version = "1.0.0-SNAPSHOT"
|
||||
|
||||
val vertxVersion = "4.5.11"
|
||||
|
||||
application {
|
||||
mainClass.set("app.Application")
|
||||
applicationDefaultJvmArgs = listOf(
|
||||
"--add-opens",
|
||||
"java.base/java.lang=ALL-UNNAMED",
|
||||
"--add-opens",
|
||||
"java.base/java.time=ALL-UNNAMED"
|
||||
// "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
|
||||
)
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
tasks.shadowJar {
|
||||
archiveClassifier.set("fat")
|
||||
mergeServiceFiles()
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
testLogging {
|
||||
showStandardStreams = true
|
||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||
// events = setOf(PASSED, SKIPPED, FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.compileKotlin {
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.compileTestKotlin {
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
spotless {
|
||||
kotlin {
|
||||
ktlint()
|
||||
.editorConfigOverride(
|
||||
mapOf(
|
||||
"ktlint_standard_no-wildcard-imports" to "disabled",
|
||||
"ktlint_standard_trailing-comma-on-call-site" to "disabled",
|
||||
"ktlint_standard_trailing-comma-on-declaration-site" to "disabled",
|
||||
"indent_size" to "2"
|
||||
)
|
||||
)
|
||||
target("src/**/*.kt")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":vertx-fw"))
|
||||
// implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.20")
|
||||
// implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.20")
|
||||
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
||||
implementation(platform("io.vertx:vertx-stack-depchain:$vertxVersion"))
|
||||
implementation("io.vertx:vertx-lang-kotlin:$vertxVersion")
|
||||
implementation("io.vertx:vertx-lang-kotlin-coroutines:$vertxVersion")
|
||||
implementation("io.vertx:vertx-core:$vertxVersion")
|
||||
implementation("io.vertx:vertx-web:$vertxVersion")
|
||||
implementation("io.vertx:vertx-web-client:$vertxVersion")
|
||||
implementation("io.vertx:vertx-config:$vertxVersion")
|
||||
implementation("io.vertx:vertx-config-yaml:$vertxVersion")
|
||||
implementation("io.vertx:vertx-pg-client:$vertxVersion")
|
||||
implementation("io.vertx:vertx-mysql-client:$vertxVersion")
|
||||
implementation("io.vertx:vertx-sql-client-templates:$vertxVersion")
|
||||
implementation("io.vertx:vertx-auth-jwt:$vertxVersion")
|
||||
|
||||
implementation("com.google.inject:guice:5.1.0")
|
||||
implementation("org.reflections:reflections:0.9.12")
|
||||
implementation("cn.hutool:hutool-all:5.8.24")
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind:2.15.2")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2")
|
||||
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2")
|
||||
// implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
|
||||
implementation("dev.hsbrysk:caffeine-coroutines:1.0.0")
|
||||
|
||||
// log
|
||||
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
|
||||
implementation("org.slf4j:slf4j-api:2.0.6")
|
||||
implementation("ch.qos.logback:logback-classic:1.4.14")
|
||||
implementation("org.codehaus.janino:janino:3.1.8")
|
||||
|
||||
// jpa
|
||||
implementation("jakarta.persistence:jakarta.persistence-api:3.2.0")
|
||||
implementation("jakarta.validation:jakarta.validation-api:3.1.0")
|
||||
|
||||
// implementation("com.mysql:mysql-connector-j:9.1.0")
|
||||
implementation("mysql:mysql-connector-java:5.1.49")
|
||||
// doc
|
||||
implementation("io.swagger.core.v3:swagger-core:2.2.27")
|
||||
|
||||
testImplementation("io.vertx:vertx-junit5:$vertxVersion")
|
||||
|
||||
implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.jar"))))
|
||||
}
|
||||
27
vertx-demo/src/main/kotlin/app/Application.kt
Normal file
27
vertx-demo/src/main/kotlin/app/Application.kt
Normal file
@ -0,0 +1,27 @@
|
||||
package app
|
||||
|
||||
import app.config.InjectConfig
|
||||
import app.verticle.MainVerticle
|
||||
import io.vertx.core.Vertx
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import mu.KotlinLogging
|
||||
|
||||
object Application {
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
runBlocking {
|
||||
val vertx = Vertx.vertx()
|
||||
val getIt = InjectConfig.configure(vertx)
|
||||
val demoVerticle = getIt.getInstance(MainVerticle::class.java)
|
||||
vertx.deployVerticle(demoVerticle).onComplete {
|
||||
if (it.failed()) {
|
||||
logger.error { "MainVerticle startup failed: ${it.cause()?.stackTraceToString()}" }
|
||||
} else {
|
||||
logger.info { "MainVerticle startup successfully" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
vertx-demo/src/main/kotlin/app/controller/AuthController.kt
Normal file
58
vertx-demo/src/main/kotlin/app/controller/AuthController.kt
Normal file
@ -0,0 +1,58 @@
|
||||
package app.controller
|
||||
|
||||
import app.config.Constant
|
||||
import app.domain.user.LoginDTO
|
||||
import app.domain.user.User
|
||||
import app.domain.user.UserRepository
|
||||
import app.util.CacheUtil
|
||||
import cn.hutool.core.lang.Snowflake
|
||||
import cn.hutool.crypto.SecureUtil
|
||||
import com.google.inject.Inject
|
||||
import io.vertx.ext.auth.jwt.JWTAuth
|
||||
import org.aikrai.vertx.auth.AllowAnonymous
|
||||
import org.aikrai.vertx.auth.TokenUtil
|
||||
import org.aikrai.vertx.context.Controller
|
||||
import org.aikrai.vertx.context.D
|
||||
import org.aikrai.vertx.utlis.Meta
|
||||
|
||||
@AllowAnonymous
|
||||
@D("认证")
|
||||
@Controller("/auth")
|
||||
class AuthController @Inject constructor(
|
||||
private val jwtAuth: JWTAuth,
|
||||
private val snowflake: Snowflake,
|
||||
private val userRepository: UserRepository,
|
||||
private val cacheUtil: CacheUtil
|
||||
) {
|
||||
|
||||
@D("注册")
|
||||
suspend fun doSign(
|
||||
@D("loginInfo", "账号信息") loginInfo: LoginDTO
|
||||
): String {
|
||||
userRepository.getByName(loginInfo.username)?.let {
|
||||
throw Meta.failure("LoginFailed", "用户名已被使用")
|
||||
}
|
||||
val user = User().apply {
|
||||
this.id = snowflake.nextId()
|
||||
this.userName = loginInfo.username
|
||||
this.password = SecureUtil.sha1(loginInfo.password)
|
||||
this.loginName = loginInfo.username
|
||||
}
|
||||
cacheUtil.put(Constant.USER + user.id, user)
|
||||
userRepository.create(user)
|
||||
return TokenUtil.genToken(jwtAuth, mapOf("id" to user.id!!))
|
||||
}
|
||||
|
||||
@D("登录")
|
||||
suspend fun doLogin(
|
||||
@D("loginInfo", "账号信息") loginInfo: LoginDTO
|
||||
): String {
|
||||
val user = userRepository.getByName(loginInfo.username) ?: throw Meta.failure("LoginFailed", "用户名或密码错误")
|
||||
if (user.password == SecureUtil.sha1(loginInfo.password)) {
|
||||
cacheUtil.put(Constant.USER + user.id, user)
|
||||
return TokenUtil.genToken(jwtAuth, mapOf("id" to user.id!!))
|
||||
} else {
|
||||
throw Meta.failure("LoginFailed", "用户名或密码错误")
|
||||
}
|
||||
}
|
||||
}
|
||||
61
vertx-demo/src/main/kotlin/app/controller/Demo1Controller.kt
Normal file
61
vertx-demo/src/main/kotlin/app/controller/Demo1Controller.kt
Normal file
@ -0,0 +1,61 @@
|
||||
package app.controller
|
||||
|
||||
import app.domain.CargoType
|
||||
import app.domain.user.User
|
||||
import app.domain.user.UserRepository
|
||||
import app.service.user.UserService
|
||||
import com.google.inject.Inject
|
||||
import mu.KotlinLogging
|
||||
import org.aikrai.vertx.auth.AllowAnonymous
|
||||
import org.aikrai.vertx.auth.CheckPermission
|
||||
import org.aikrai.vertx.auth.CheckRole
|
||||
import org.aikrai.vertx.config.Config
|
||||
import org.aikrai.vertx.context.Controller
|
||||
import org.aikrai.vertx.context.D
|
||||
|
||||
/**
|
||||
* 推荐代码示例
|
||||
*/
|
||||
@D("测试1:测试")
|
||||
@Controller
|
||||
class Demo1Controller @Inject constructor(
|
||||
private val userService: UserService,
|
||||
private val userRepository: UserRepository
|
||||
) {
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
@D("参数测试", "详细说明......")
|
||||
suspend fun test1(
|
||||
@D("name", "姓名") name: String,
|
||||
@D("age", "年龄") age: Int?,
|
||||
@D("list", "列表") list: List<String>?,
|
||||
@D("cargoType", "货物类型") cargoType: CargoType?
|
||||
) {
|
||||
logger.info { "你好" }
|
||||
println(age)
|
||||
println(list)
|
||||
println("test-$name")
|
||||
println(cargoType)
|
||||
}
|
||||
|
||||
@D("事务测试")
|
||||
suspend fun testT() {
|
||||
userService.testTransaction()
|
||||
}
|
||||
|
||||
@D("查询测试")
|
||||
suspend fun getList(): List<User> {
|
||||
val list = userRepository.getList()
|
||||
println(list)
|
||||
return list
|
||||
}
|
||||
|
||||
@AllowAnonymous
|
||||
@D("配置读取测试")
|
||||
suspend fun testRetriever(
|
||||
@D("key", "key") key: String
|
||||
) {
|
||||
val configMap = Config.getKey(key)
|
||||
println(configMap)
|
||||
}
|
||||
}
|
||||
18
vertx-demo/src/main/kotlin/app/domain/CargoType.kt
Normal file
18
vertx-demo/src/main/kotlin/app/domain/CargoType.kt
Normal file
@ -0,0 +1,18 @@
|
||||
package app.domain
|
||||
|
||||
enum class CargoType(val message: String) {
|
||||
|
||||
RECEIVING("收货"),
|
||||
|
||||
SHIPPING("发货"),
|
||||
|
||||
INTERNAL_TRANSFER("内部调拨")
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun parse(value: String?): CargoType? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
return CargoType.values().find { it.name == value || it.message == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
42
vertx-demo/src/main/kotlin/app/domain/role/Role.kt
Normal file
42
vertx-demo/src/main/kotlin/app/domain/role/Role.kt
Normal file
@ -0,0 +1,42 @@
|
||||
package app.domain.role
|
||||
|
||||
import jakarta.persistence.*
|
||||
import jakarta.validation.constraints.NotNull
|
||||
import jakarta.validation.constraints.Size
|
||||
|
||||
@Entity
|
||||
@Table(name = "`sys_role`", schema = "`vertx-demo`")
|
||||
class Role : org.aikrai.vertx.utlis.Entity() {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "role_id", nullable = false)
|
||||
var id: Long? = null
|
||||
|
||||
@Size(max = 30)
|
||||
@NotNull
|
||||
@Column(name = "role_name", nullable = false, length = 30)
|
||||
var roleName: String? = null
|
||||
|
||||
@Size(max = 100)
|
||||
@NotNull
|
||||
@Column(name = "role_key", nullable = false, length = 100)
|
||||
var roleKey: String? = null
|
||||
|
||||
@NotNull
|
||||
@Column(name = "role_sort", nullable = false)
|
||||
var roleSort: Int? = null
|
||||
|
||||
@Column(name = "data_scope")
|
||||
var dataScope: Char? = null
|
||||
|
||||
@NotNull
|
||||
@Column(name = "status", nullable = false)
|
||||
var status: Char? = null
|
||||
|
||||
@Column(name = "del_flag")
|
||||
var delFlag: Char? = null
|
||||
|
||||
@Size(max = 500)
|
||||
@Column(name = "remark", length = 500)
|
||||
var remark: String? = null
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package app.domain.role
|
||||
|
||||
import com.google.inject.ImplementedBy
|
||||
import org.aikrai.vertx.db.Repository
|
||||
|
||||
@ImplementedBy(RoleRepositoryImpl::class)
|
||||
interface RoleRepository : Repository<Long, Role>
|
||||
@ -0,0 +1,9 @@
|
||||
package app.domain.role
|
||||
|
||||
import com.google.inject.Inject
|
||||
import io.vertx.sqlclient.SqlClient
|
||||
import org.aikrai.vertx.db.RepositoryImpl
|
||||
|
||||
class RoleRepositoryImpl @Inject constructor(
|
||||
sqlClient: SqlClient
|
||||
) : RepositoryImpl<Long, Role>(sqlClient), RoleRepository
|
||||
6
vertx-demo/src/main/kotlin/app/domain/user/LoginDTO.kt
Normal file
6
vertx-demo/src/main/kotlin/app/domain/user/LoginDTO.kt
Normal file
@ -0,0 +1,6 @@
|
||||
package app.domain.user
|
||||
|
||||
data class LoginDTO(
|
||||
var username: String,
|
||||
var password: String
|
||||
)
|
||||
74
vertx-demo/src/main/kotlin/app/domain/user/User.kt
Normal file
74
vertx-demo/src/main/kotlin/app/domain/user/User.kt
Normal file
@ -0,0 +1,74 @@
|
||||
package app.domain.user
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.sql.Timestamp
|
||||
|
||||
@Entity
|
||||
@Table(name = "`sys_user`", schema = "`vertx-demo`")
|
||||
class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "user_id", nullable = false)
|
||||
var id: Long? = 0L
|
||||
|
||||
@Column(name = "dept_id")
|
||||
var deptId: Long? = null
|
||||
|
||||
@Column(name = "login_name", nullable = false, length = 30)
|
||||
var loginName: String? = ""
|
||||
|
||||
@Column(name = "user_name", length = 30)
|
||||
var userName: String? = ""
|
||||
|
||||
@Column(name = "user_type", length = 2)
|
||||
var userType: String? = ""
|
||||
|
||||
@Column(name = "email", length = 50)
|
||||
var email: String? = ""
|
||||
|
||||
@Column(name = "phonenumber", length = 11)
|
||||
var phonenumber: String? = ""
|
||||
|
||||
// @Column(name = "sex")
|
||||
// var sex: Char? = null
|
||||
|
||||
@Column(name = "avatar", length = 100)
|
||||
var avatar: String? = null
|
||||
|
||||
@Column(name = "password", length = 50)
|
||||
var password: String? = null
|
||||
|
||||
@Column(name = "salt", length = 20)
|
||||
var salt: String? = null
|
||||
|
||||
// @Column(name = "status")
|
||||
// var status: Char? = null
|
||||
|
||||
// @Column(name = "del_flag")
|
||||
// var delFlag: Char? = null
|
||||
|
||||
@Column(name = "login_ip", length = 128)
|
||||
var loginIp: String? = null
|
||||
|
||||
@Column(name = "login_date")
|
||||
var loginDate: Timestamp? = null
|
||||
|
||||
@Column(name = "pwd_update_date")
|
||||
var pwdUpdateDate: Timestamp? = null
|
||||
|
||||
@Column(name = "create_by", length = 64)
|
||||
var createBy: String? = null
|
||||
|
||||
@Column(name = "create_time")
|
||||
var createTime: Timestamp? = null
|
||||
|
||||
@Column(name = "update_by", length = 64)
|
||||
var updateBy: String? = null
|
||||
|
||||
@Column(name = "update_time")
|
||||
var updateTime: Timestamp? = null
|
||||
|
||||
@Column(name = "remark", length = 500)
|
||||
var remark: String? = null
|
||||
}
|
||||
13
vertx-demo/src/main/kotlin/app/domain/user/UserRepository.kt
Normal file
13
vertx-demo/src/main/kotlin/app/domain/user/UserRepository.kt
Normal file
@ -0,0 +1,13 @@
|
||||
package app.domain.user
|
||||
|
||||
import com.google.inject.ImplementedBy
|
||||
import org.aikrai.vertx.db.Repository
|
||||
|
||||
@ImplementedBy(UserRepositoryImpl::class)
|
||||
interface UserRepository : Repository<Long, User> {
|
||||
suspend fun getByName(name: String): User?
|
||||
|
||||
suspend fun testTransaction(): User?
|
||||
|
||||
suspend fun getList(): List<User>
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package app.domain.user
|
||||
|
||||
import com.google.inject.Inject
|
||||
import io.vertx.sqlclient.SqlClient
|
||||
import org.aikrai.vertx.db.RepositoryImpl
|
||||
|
||||
class UserRepositoryImpl @Inject constructor(
|
||||
sqlClient: SqlClient
|
||||
) : RepositoryImpl<Long, User>(sqlClient), UserRepository {
|
||||
|
||||
override suspend fun getByName(name: String): User? {
|
||||
val user = queryBuilder()
|
||||
.eq(User::userName, name)
|
||||
.getOne()
|
||||
return user
|
||||
}
|
||||
|
||||
override suspend fun testTransaction(): User? {
|
||||
// throw Meta.failure("test transaction", "test transaction")
|
||||
return queryBuilder().getOne()
|
||||
}
|
||||
|
||||
override suspend fun getList(): List<User> {
|
||||
return queryBuilder().getList()
|
||||
}
|
||||
}
|
||||
12
vertx-demo/src/main/kotlin/app/service/user/UserService.kt
Normal file
12
vertx-demo/src/main/kotlin/app/service/user/UserService.kt
Normal file
@ -0,0 +1,12 @@
|
||||
package app.service.user
|
||||
|
||||
import app.domain.user.User
|
||||
import app.service.user.impl.UserServiceImpl
|
||||
import com.google.inject.ImplementedBy
|
||||
|
||||
@ImplementedBy(UserServiceImpl::class)
|
||||
interface UserService {
|
||||
suspend fun updateUser(user: User)
|
||||
|
||||
suspend fun testTransaction()
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package app.service.user.impl
|
||||
|
||||
import app.domain.role.RoleRepository
|
||||
import app.domain.user.User
|
||||
import app.domain.user.UserRepository
|
||||
import app.service.user.UserService
|
||||
import com.google.inject.Inject
|
||||
import org.aikrai.vertx.db.tx.withTransaction
|
||||
|
||||
class UserServiceImpl @Inject constructor(
|
||||
private val userRepository: UserRepository,
|
||||
private val roleRepository: RoleRepository
|
||||
) : UserService {
|
||||
|
||||
override suspend fun updateUser(user: User) {
|
||||
userRepository.getByName(user.userName!!)?.let {
|
||||
userRepository.update(user)
|
||||
roleRepository.update(user.id!!, mapOf("type" to "normal"))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun testTransaction() {
|
||||
// withTransaction嵌套时, 使用的是同一个事务对象,要成功全部成功,要失败全部失败
|
||||
withTransaction {
|
||||
val execute1 = userRepository.execute<Int>("update sys_user set email = '88888' where user_name = '运若汐'")
|
||||
println("运若汐: $execute1")
|
||||
withTransaction {
|
||||
val execute = userRepository.execute<Int>("update sys_user set email = '88888' where user_name = '郸明'")
|
||||
println("郸明: $execute")
|
||||
// throw Meta.failure("test transaction", "test transaction")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
44
vertx-demo/src/main/kotlin/app/util/CacheUtil.kt
Normal file
44
vertx-demo/src/main/kotlin/app/util/CacheUtil.kt
Normal file
@ -0,0 +1,44 @@
|
||||
package app.util
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
import com.google.inject.Singleton
|
||||
import dev.hsbrysk.caffeine.CoroutineCache
|
||||
import dev.hsbrysk.caffeine.buildCoroutine
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Singleton
|
||||
class CacheUtil {
|
||||
companion object {
|
||||
@Volatile
|
||||
private var cache: CoroutineCache<String, Any>? = null
|
||||
|
||||
fun getCache(): CoroutineCache<String, Any> {
|
||||
return cache ?: synchronized(this) {
|
||||
cache ?: init().also { cache = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun init(): CoroutineCache<String, Any> {
|
||||
return Caffeine.newBuilder()
|
||||
.maximumSize(10_000)
|
||||
.expireAfterAccess(30, TimeUnit.MINUTES)
|
||||
.buildCoroutine()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(str: String): Any? {
|
||||
return getCache().getIfPresent(str)
|
||||
}
|
||||
|
||||
fun put(key: String, value: Any) {
|
||||
getCache().put(key, value)
|
||||
}
|
||||
|
||||
fun invalidate(key: String) {
|
||||
getCache().synchronous().invalidate(key)
|
||||
}
|
||||
|
||||
fun invalidateAll() {
|
||||
getCache().synchronous().invalidateAll()
|
||||
}
|
||||
}
|
||||
54
vertx-demo/src/main/kotlin/app/verticle/ApifoxClient.kt
Normal file
54
vertx-demo/src/main/kotlin/app/verticle/ApifoxClient.kt
Normal file
@ -0,0 +1,54 @@
|
||||
package app.verticle
|
||||
|
||||
import com.google.inject.Inject
|
||||
import com.google.inject.name.Named
|
||||
import io.vertx.core.Vertx
|
||||
import io.vertx.core.http.HttpMethod
|
||||
import io.vertx.core.json.JsonObject
|
||||
import io.vertx.ext.web.client.WebClient
|
||||
import io.vertx.ext.web.client.WebClientOptions
|
||||
import io.vertx.kotlin.coroutines.CoroutineVerticle
|
||||
import mu.KotlinLogging
|
||||
import org.aikrai.vertx.openapi.OpenApiSpecGenerator
|
||||
|
||||
class ApifoxClient @Inject constructor(
|
||||
private val vertx: Vertx,
|
||||
@Named("apifox.token") private val token: String,
|
||||
@Named("apifox.projectId") private val projectId: String,
|
||||
@Named("apifox.folderId") private val folderId: String,
|
||||
@Named("server.name") private val serverName: String,
|
||||
@Named("server.port") private val port: String
|
||||
) : CoroutineVerticle() {
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
override suspend fun start() {
|
||||
importOpenapi()
|
||||
}
|
||||
|
||||
private fun importOpenapi() {
|
||||
val openApiJsonStr = OpenApiSpecGenerator().genOpenApiSpecStr(serverName, "1.0", "http://127.0.0.1:$port/api")
|
||||
val options = WebClientOptions().setDefaultPort(443).setDefaultHost("api.apifox.com").setSsl(true)
|
||||
val client = WebClient.create(vertx, options)
|
||||
// 参数参考 https://apifox-openapi.apifox.cn/api-173409873
|
||||
val reqOptions = JsonObject()
|
||||
.put("targetEndpointFolderId", folderId)
|
||||
.put("endpointOverwriteBehavior", "OVERWRITE_EXISTING")
|
||||
.put("schemaOverwriteBehavior", "OVERWRITE_EXISTING")
|
||||
.put("updateFolderOfChangedEndpoints", false)
|
||||
.put("prependBasePath", false)
|
||||
val requestBody = JsonObject().put("input", openApiJsonStr).put("options", reqOptions)
|
||||
|
||||
client.request(HttpMethod.POST, "/v1/projects/$projectId/import-openapi")
|
||||
.putHeader("X-Apifox-Version", "2024-01-20")
|
||||
.putHeader("Authorization", "Bearer $token")
|
||||
.sendJsonObject(requestBody) { ar ->
|
||||
if (ar.succeeded()) {
|
||||
val response = ar.result()
|
||||
logger.info("Received response with status code: ${response.statusCode()}")
|
||||
logger.info("Response body: ${response.bodyAsString()}")
|
||||
} else {
|
||||
logger.warn("Request failed: ${ar.cause().message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
vertx-demo/src/main/kotlin/app/verticle/MainVerticle.kt
Normal file
31
vertx-demo/src/main/kotlin/app/verticle/MainVerticle.kt
Normal file
@ -0,0 +1,31 @@
|
||||
package app.verticle
|
||||
|
||||
import com.google.inject.Inject
|
||||
import io.vertx.kotlin.coroutines.CoroutineVerticle
|
||||
import mu.KotlinLogging
|
||||
|
||||
class MainVerticle @Inject constructor(
|
||||
private val webVerticle: WebVerticle,
|
||||
private val apifoxClient: ApifoxClient
|
||||
) : CoroutineVerticle() {
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
override suspend fun start() {
|
||||
val verticles = listOf(
|
||||
webVerticle,
|
||||
apifoxClient
|
||||
)
|
||||
|
||||
for (verticle in verticles) {
|
||||
vertx.deployVerticle(verticle).onComplete {
|
||||
val simpleName = verticle.javaClass.simpleName
|
||||
if (it.failed()) {
|
||||
logger.error { "$simpleName startup failed: ${it.cause()?.stackTraceToString()}" }
|
||||
} else {
|
||||
val deploymentId = it.result()
|
||||
logger.info { "$simpleName startup successfully, deploymentId:$deploymentId" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
vertx-demo/src/main/kotlin/app/verticle/WebVerticle.kt
Normal file
105
vertx-demo/src/main/kotlin/app/verticle/WebVerticle.kt
Normal file
@ -0,0 +1,105 @@
|
||||
package app.verticle
|
||||
|
||||
import app.config.auth.AuthHandler
|
||||
import app.config.auth.JwtAuthenticationHandler
|
||||
import com.google.inject.Inject
|
||||
import com.google.inject.Injector
|
||||
import com.google.inject.name.Named
|
||||
import io.vertx.core.Handler
|
||||
import io.vertx.core.http.HttpHeaders
|
||||
import io.vertx.core.http.HttpMethod
|
||||
import io.vertx.core.http.HttpServerOptions
|
||||
import io.vertx.ext.web.Router
|
||||
import io.vertx.ext.web.RoutingContext
|
||||
import io.vertx.ext.web.handler.BodyHandler
|
||||
import io.vertx.ext.web.handler.CorsHandler
|
||||
import io.vertx.kotlin.coroutines.CoroutineVerticle
|
||||
import io.vertx.kotlin.coroutines.coAwait
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import mu.KotlinLogging
|
||||
import org.aikrai.vertx.config.FailureParser
|
||||
import org.aikrai.vertx.context.RouterBuilder
|
||||
import org.aikrai.vertx.jackson.JsonUtil
|
||||
|
||||
class WebVerticle @Inject constructor(
|
||||
private val getIt: Injector,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val authHandler: AuthHandler,
|
||||
@Named("server.name") private val serverName: String,
|
||||
@Named("server.port") private val port: String,
|
||||
@Named("server.context") private val context: String
|
||||
) : CoroutineVerticle() {
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
override suspend fun start() {
|
||||
val rootRouter = Router.router(vertx)
|
||||
val router = Router.router(vertx)
|
||||
setupRouter(rootRouter, router)
|
||||
|
||||
val options = HttpServerOptions().setMaxFormAttributeSize(1024 * 1024)
|
||||
val server = vertx.createHttpServer(options)
|
||||
.requestHandler(rootRouter)
|
||||
.listen(port.toInt())
|
||||
.coAwait()
|
||||
|
||||
logger.info { "http server start - http://127.0.0.1:${server.actualPort()}" }
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
}
|
||||
|
||||
private fun setupRouter(rootRouter: Router, router: Router) {
|
||||
rootRouter.route("/api" + "*").subRouter(router)
|
||||
router.route()
|
||||
.handler(corsHandler)
|
||||
.failureHandler(errorHandler)
|
||||
.handler(BodyHandler.create())
|
||||
|
||||
val authHandler = JwtAuthenticationHandler(coroutineScope, authHandler, context)
|
||||
router.route("/*").handler(authHandler)
|
||||
|
||||
val routerBuilder = RouterBuilder(coroutineScope, router).build { service ->
|
||||
getIt.getInstance(service)
|
||||
}
|
||||
authHandler.exclude.addAll(routerBuilder.anonymousPaths)
|
||||
// 生成 openapi.json
|
||||
/*val openApiJsonStr = OpenApiSpecGenerator().genOpenApiSpecStr(serverName, "1.0", "http://127.0.0.1:$port/api")
|
||||
val resourcesPath = "${System.getProperty("user.dir")}/src/main/resources"
|
||||
val timestamp = System.currentTimeMillis()
|
||||
vertx.fileSystem()
|
||||
.writeFile(
|
||||
"$resourcesPath/openapi/openapi-$timestamp.json",
|
||||
Buffer.buffer(openApiJsonStr)
|
||||
) { writeFileAsyncResult ->
|
||||
if (!writeFileAsyncResult.succeeded()) writeFileAsyncResult.cause().printStackTrace()
|
||||
}*/
|
||||
}
|
||||
|
||||
private val corsHandler = CorsHandler.create()
|
||||
.addOrigin("*")
|
||||
.allowedMethod(HttpMethod.GET)
|
||||
.allowedMethod(HttpMethod.POST)
|
||||
.allowedMethod(HttpMethod.PUT)
|
||||
.allowedMethod(HttpMethod.DELETE)
|
||||
.allowedMethod(HttpMethod.OPTIONS)
|
||||
|
||||
private val errorHandler = Handler<RoutingContext> { ctx ->
|
||||
val failure = ctx.failure()
|
||||
if (failure != null) {
|
||||
logger.error { "${ctx.request().uri()}: ${failure.stackTraceToString()}" }
|
||||
|
||||
val parsedFailure = FailureParser.parse(ctx.statusCode(), failure)
|
||||
val response = ctx.response()
|
||||
|
||||
response.putHeader(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
response.statusCode = if (ctx.statusCode() != 200) ctx.statusCode() else 500
|
||||
response.end(JsonUtil.toJsonStr(parsedFailure.response))
|
||||
} else {
|
||||
logger.error("${ctx.request().uri()}: 未知错误")
|
||||
val response = ctx.response()
|
||||
response.putHeader(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
|
||||
response.statusCode = 500
|
||||
response.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
22
vertx-demo/src/main/resources/bootstrap.yml
Normal file
22
vertx-demo/src/main/resources/bootstrap.yml
Normal file
@ -0,0 +1,22 @@
|
||||
server:
|
||||
name: vtx_demo
|
||||
active: dev
|
||||
port: 8080
|
||||
context: api # 上下文
|
||||
timeout: 120 # eventbus超时时间
|
||||
http:
|
||||
header: # header获取到的变量
|
||||
- x-requested-with
|
||||
- Access-Control-Allow-Origin
|
||||
- origin
|
||||
- Content-Type
|
||||
- accept
|
||||
event-bus:
|
||||
timeout: 10000 # 毫秒
|
||||
jwt:
|
||||
key: 123456sdfjasdfjl # jwt加密key
|
||||
|
||||
apifox:
|
||||
token: APS-xxx
|
||||
projectId: xxx
|
||||
folderId: xxx
|
||||
102
vertx-demo/src/main/resources/logback.xml
Normal file
102
vertx-demo/src/main/resources/logback.xml
Normal file
@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration
|
||||
debug="false" scan="true" scanPeriod="30 second">
|
||||
<property name="ROOT" value="../bucket/log/"/>
|
||||
<property name="APPNAME" value="vertx-fw"/>
|
||||
<property name="FILESIZE" value="500MB"/>
|
||||
<property name="MAXHISTORY" value="100"/>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<Target>System.out</Target>
|
||||
<encoder charset="utf-8">
|
||||
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
|
||||
</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<appender name="WARN" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<encoder charset="utf-8">
|
||||
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
|
||||
</pattern>
|
||||
</encoder>
|
||||
<filter class="ch.qos.logback.core.filter.EvaluatorFilter">
|
||||
<evaluator>
|
||||
<expression>return level >= WARN;</expression>
|
||||
</evaluator>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${ROOT}${APPNAME}-%d-warn.%i.log</fileNamePattern>
|
||||
<maxHistory>${MAXHISTORY}</maxHistory>
|
||||
<timeBasedFileNamingAndTriggeringPolicy
|
||||
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
|
||||
<maxFileSize>${FILESIZE}</maxFileSize>
|
||||
</timeBasedFileNamingAndTriggeringPolicy>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
<appender name="INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<encoder charset="utf-8">
|
||||
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
|
||||
</pattern>
|
||||
</encoder>
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>INFO</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${ROOT}${APPNAME}-%d-info.%i.log</fileNamePattern>
|
||||
<maxHistory>${MAXHISTORY}</maxHistory>
|
||||
<timeBasedFileNamingAndTriggeringPolicy
|
||||
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
|
||||
<maxFileSize>${FILESIZE}</maxFileSize>
|
||||
</timeBasedFileNamingAndTriggeringPolicy>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
<appender name="DEBUG" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<encoder charset="utf-8">
|
||||
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
|
||||
</pattern>
|
||||
</encoder>
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>DEBUG</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<rollingPolicy
|
||||
class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${ROOT}${APPNAME}-%d-debug.%i.log</fileNamePattern>
|
||||
<maxHistory>${MAXHISTORY}</maxHistory>
|
||||
<timeBasedFileNamingAndTriggeringPolicy
|
||||
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
|
||||
<maxFileSize>${FILESIZE}</maxFileSize>
|
||||
</timeBasedFileNamingAndTriggeringPolicy>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
<appender name="TRACE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<encoder charset="utf-8">
|
||||
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
|
||||
</pattern>
|
||||
</encoder>
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>TRACE</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<rollingPolicy
|
||||
class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${ROOT}${APPNAME}-%d-trace.%i.log</fileNamePattern>
|
||||
<maxHistory>${MAXHISTORY}</maxHistory>
|
||||
<timeBasedFileNamingAndTriggeringPolicy
|
||||
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
|
||||
<maxFileSize>${FILESIZE}</maxFileSize>
|
||||
</timeBasedFileNamingAndTriggeringPolicy>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
<appender-ref ref="WARN"/>
|
||||
<appender-ref ref="INFO"/>
|
||||
<appender-ref ref="DEBUG"/>
|
||||
<appender-ref ref="TRACE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
27
vertx-demo/src/main/resources/sql/sys_role.sql
Normal file
27
vertx-demo/src/main/resources/sql/sys_role.sql
Normal file
@ -0,0 +1,27 @@
|
||||
-- ----------------------------
|
||||
-- Table structure for sys_role
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `sys_role`;
|
||||
CREATE TABLE `sys_role` (
|
||||
`role_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
|
||||
`role_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '角色名称',
|
||||
`role_key` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '角色权限字符串',
|
||||
`role_sort` int(4) NOT NULL COMMENT '显示顺序',
|
||||
`data_scope` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '1' COMMENT '数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限)',
|
||||
`status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '角色状态(0正常 1停用)',
|
||||
`del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
|
||||
`create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
|
||||
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注',
|
||||
PRIMARY KEY (`role_id`) USING BTREE
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 100 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '角色信息表' ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of sys_role
|
||||
-- ----------------------------
|
||||
INSERT INTO `sys_role` VALUES (1, '超级管理员', 'admin', 1, '1', '0', '0', 'admin', '2024-12-28 11:30:34', '', NULL, '超级管理员');
|
||||
INSERT INTO `sys_role` VALUES (2, '普通角色', 'common', 2, '2', '0', '0', 'admin', '2024-12-28 11:30:34', '', NULL, '普通角色');
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
39
vertx-demo/src/main/resources/sql/sys_user.sql
Normal file
39
vertx-demo/src/main/resources/sql/sys_user.sql
Normal file
@ -0,0 +1,39 @@
|
||||
-- ----------------------------
|
||||
-- Table structure for sys_user
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `sys_user`;
|
||||
CREATE TABLE `sys_user` (
|
||||
`user_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
|
||||
`dept_id` bigint(20) NULL DEFAULT NULL COMMENT '部门ID',
|
||||
`login_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '登录账号',
|
||||
`user_name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '用户昵称',
|
||||
`user_type` varchar(2) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '00' COMMENT '用户类型(00系统用户 01注册用户)',
|
||||
`email` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '用户邮箱',
|
||||
`phonenumber` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '手机号码',
|
||||
`sex` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '用户性别(0男 1女 2未知)',
|
||||
`avatar` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '头像路径',
|
||||
`password` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '密码',
|
||||
`salt` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '盐加密',
|
||||
`status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '帐号状态(0正常 1停用)',
|
||||
`del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
|
||||
`login_ip` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '最后登录IP',
|
||||
`login_date` datetime NULL DEFAULT NULL COMMENT '最后登录时间',
|
||||
`pwd_update_date` datetime NULL DEFAULT NULL COMMENT '密码最后更新时间',
|
||||
`create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
|
||||
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注',
|
||||
PRIMARY KEY (`user_id`) USING BTREE
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 1875732675218882561 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户信息表' ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of sys_user
|
||||
-- ----------------------------
|
||||
INSERT INTO `sys_user` VALUES (1, 103, 'admin', '若依', '00', 'ry@163.com', '15888888888', '1', '', '29c67a30398638269fe600f73a054934', '111111', '0', '0', '127.0.0.1', NULL, NULL, 'admin', '2024-12-28 11:30:31', '', NULL, '管理员');
|
||||
INSERT INTO `sys_user` VALUES (2, 105, 'ry', '若1', '00', 'ry@qq.com', '15666666666', '1', '', '8e6d98b90472783cc73c17047ddccf36', '222222', '0', '0', '127.0.0.1', NULL, NULL, 'admin', '2024-12-28 11:30:31', '', NULL, '测试员');
|
||||
INSERT INTO `sys_user` VALUES (1875026959495516160, NULL, '运若汐', '运若汐', '', '88888', '', '0', NULL, '7c4a8d09ca3762af61e59520943dc26494f8941b', NULL, '0', '0', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `sys_user` VALUES (1875027180531142656, NULL, '郸明', '郸明', '', '88888', '', '0', NULL, '7c4a8d09ca3762af61e59520943dc26494f8941b', NULL, '0', '0', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
INSERT INTO `sys_user` VALUES (1875732675218882560, NULL, '易静', '易静', '', '88888', '', '0', NULL, '7c4a8d09ca3762af61e59520943dc26494f8941b', NULL, '0', '0', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
89
vertx-fw/build.gradle.kts
Normal file
89
vertx-fw/build.gradle.kts
Normal file
@ -0,0 +1,89 @@
|
||||
plugins {
|
||||
kotlin("jvm") version "1.9.20"
|
||||
id("com.diffplug.spotless") version "6.25.0"
|
||||
id("com.github.johnrengelman.shadow") version "8.1.1"
|
||||
}
|
||||
|
||||
group = "org.aikrai"
|
||||
version = "1.0.0-SNAPSHOT"
|
||||
|
||||
val vertxVersion = "4.5.11"
|
||||
|
||||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
tasks.shadowJar {
|
||||
archiveClassifier.set("fat")
|
||||
mergeServiceFiles()
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
testLogging {
|
||||
showStandardStreams = true
|
||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||
}
|
||||
}
|
||||
|
||||
tasks.compileKotlin {
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.compileTestKotlin {
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
spotless {
|
||||
kotlin {
|
||||
ktlint()
|
||||
.editorConfigOverride(
|
||||
mapOf(
|
||||
"ktlint_standard_no-wildcard-imports" to "disabled",
|
||||
"ktlint_standard_trailing-comma-on-call-site" to "disabled",
|
||||
"ktlint_standard_trailing-comma-on-declaration-site" to "disabled",
|
||||
"indent_size" to "2"
|
||||
)
|
||||
)
|
||||
target("src/**/*.kt")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("io.vertx:vertx-lang-kotlin-coroutines:$vertxVersion")
|
||||
implementation("io.vertx:vertx-core:$vertxVersion")
|
||||
implementation("io.vertx:vertx-web:$vertxVersion")
|
||||
implementation("io.vertx:vertx-config:$vertxVersion")
|
||||
implementation("io.vertx:vertx-config-yaml:$vertxVersion")
|
||||
implementation("io.vertx:vertx-pg-client:$vertxVersion")
|
||||
implementation("io.vertx:vertx-mysql-client:$vertxVersion")
|
||||
implementation("io.vertx:vertx-sql-client-templates:$vertxVersion")
|
||||
implementation("io.vertx:vertx-auth-jwt:$vertxVersion")
|
||||
|
||||
implementation("com.google.inject:guice:7.0.0")
|
||||
implementation("org.reflections:reflections:0.9.12")
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind:2.15.2")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2")
|
||||
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2")
|
||||
|
||||
// hutool
|
||||
implementation("cn.hutool:hutool-core:5.8.35")
|
||||
|
||||
// log
|
||||
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
|
||||
implementation("org.slf4j:slf4j-api:2.0.6")
|
||||
implementation("ch.qos.logback:logback-classic:1.4.14")
|
||||
implementation("org.codehaus.janino:janino:3.1.8")
|
||||
|
||||
// jpa
|
||||
implementation("jakarta.persistence:jakarta.persistence-api:3.2.0")
|
||||
implementation("jakarta.validation:jakarta.validation-api:3.1.0")
|
||||
|
||||
// doc
|
||||
implementation("io.swagger.core.v3:swagger-core:2.2.27")
|
||||
}
|
||||
19
vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/AuthUser.kt
Normal file
19
vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/AuthUser.kt
Normal file
@ -0,0 +1,19 @@
|
||||
package org.aikrai.vertx.auth
|
||||
|
||||
import io.vertx.ext.auth.impl.UserImpl
|
||||
import org.aikrai.vertx.jackson.JsonUtil
|
||||
|
||||
class AuthUser(
|
||||
principal: Principal,
|
||||
attributes: Attributes,
|
||||
) : UserImpl(JsonUtil.toJsonObject(principal), JsonUtil.toJsonObject(attributes))
|
||||
|
||||
class Principal(
|
||||
val id: Long,
|
||||
val info: Any,
|
||||
)
|
||||
|
||||
class Attributes(
|
||||
val role: Set<String>,
|
||||
val permissions: Set<String>,
|
||||
)
|
||||
@ -0,0 +1,27 @@
|
||||
package org.aikrai.vertx.auth
|
||||
|
||||
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class AllowAnonymous
|
||||
|
||||
@Target(AnnotationTarget.TYPE, AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class CheckPermission(
|
||||
val type: String = "",
|
||||
val value: Array<String> = [],
|
||||
val mode: Mode = Mode.AND,
|
||||
val orRole: Array<String> = []
|
||||
)
|
||||
|
||||
@Target(AnnotationTarget.TYPE, AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class CheckRole(
|
||||
val type: String = "",
|
||||
val value: Array<String> = [],
|
||||
val mode: Mode = Mode.AND
|
||||
)
|
||||
|
||||
enum class Mode {
|
||||
AND,
|
||||
OR
|
||||
}
|
||||
22
vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/TokenUtil.kt
Normal file
22
vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/TokenUtil.kt
Normal file
@ -0,0 +1,22 @@
|
||||
package org.aikrai.vertx.auth
|
||||
|
||||
import io.vertx.core.json.JsonObject
|
||||
import io.vertx.ext.auth.JWTOptions
|
||||
import io.vertx.ext.auth.User
|
||||
import io.vertx.ext.auth.authentication.TokenCredentials
|
||||
import io.vertx.ext.auth.jwt.JWTAuth
|
||||
import io.vertx.kotlin.coroutines.coAwait
|
||||
|
||||
class TokenUtil {
|
||||
companion object {
|
||||
fun genToken(jwtAuth: JWTAuth, info: Map<String, Any>): String {
|
||||
val jwtOptions = JWTOptions().setExpiresInSeconds(60 * 60 * 24 * 7)
|
||||
return jwtAuth.generateToken(JsonObject(info), jwtOptions)
|
||||
}
|
||||
|
||||
suspend fun authenticate(jwtAuth: JWTAuth, token: String): User? {
|
||||
val tokenCredentials = TokenCredentials(token)
|
||||
return jwtAuth.authenticate(tokenCredentials).coAwait() ?: return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package org.aikrai.vertx.context
|
||||
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Controller(val prefix: String = "")
|
||||
@ -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
|
||||
11
vertx-fw/src/main/kotlin/org/aikrai/vertx/context/D.kt
Normal file
11
vertx-fw/src/main/kotlin/org/aikrai/vertx/context/D.kt
Normal file
@ -0,0 +1,11 @@
|
||||
package org.aikrai.vertx.context
|
||||
|
||||
import kotlin.annotation.AnnotationTarget
|
||||
import kotlin.annotation.Target
|
||||
|
||||
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class D(
|
||||
val name: String = "",
|
||||
val caption: String = ""
|
||||
)
|
||||
@ -0,0 +1,344 @@
|
||||
package org.aikrai.vertx.context
|
||||
|
||||
import cn.hutool.core.util.StrUtil
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import io.vertx.core.http.HttpMethod
|
||||
import io.vertx.ext.auth.User
|
||||
import io.vertx.ext.web.Router
|
||||
import io.vertx.ext.web.RoutingContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.aikrai.vertx.auth.*
|
||||
import org.aikrai.vertx.utlis.ClassUtil
|
||||
import org.aikrai.vertx.utlis.Meta
|
||||
import org.reflections.Reflections
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.ParameterizedType
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.reflect.KFunction
|
||||
import kotlin.reflect.full.callSuspend
|
||||
import kotlin.reflect.full.declaredFunctions
|
||||
import kotlin.reflect.jvm.javaType
|
||||
|
||||
class RouterBuilder(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val router: Router
|
||||
) {
|
||||
var anonymousPaths = ArrayList<String>()
|
||||
|
||||
fun build(getIt: (clazz: Class<*>) -> Any): RouterBuilder {
|
||||
// 缓存路由信息
|
||||
val routeInfoCache = mutableMapOf<Pair<String, HttpMethod>, RouteInfo>()
|
||||
// 获取所有 Controller 类中的公共方法
|
||||
val packagePath = ClassUtil.getMainClass()?.packageName
|
||||
val controllerClassSet = Reflections(packagePath).getTypesAnnotatedWith(Controller::class.java)
|
||||
val controllerMethods = ClassUtil.getPublicMethods(controllerClassSet)
|
||||
for ((classType, methods) in controllerMethods) {
|
||||
val controllerAnnotation = classType.getDeclaredAnnotationsByType(Controller::class.java).firstOrNull()
|
||||
val prefixPath = controllerAnnotation?.prefix ?: ""
|
||||
val classAllowAnonymous = classType.getAnnotation(AllowAnonymous::class.java) != null
|
||||
if (classAllowAnonymous) {
|
||||
val classPath = getReqPath(prefixPath, classType)
|
||||
anonymousPaths.add("$classPath/**".replace("//", "/"))
|
||||
}
|
||||
for (method in methods) {
|
||||
val reqPath = getReqPath(prefixPath, classType, method)
|
||||
val httpMethod = getHttpMethod(method)
|
||||
val allowAnonymous = method.getAnnotation(AllowAnonymous::class.java) != null
|
||||
if (allowAnonymous) anonymousPaths.add(reqPath)
|
||||
val customizeResp = method.getAnnotation(CustomizeResponse::class.java) != null
|
||||
val role = method.getAnnotation(CheckRole::class.java)
|
||||
val permissions = method.getAnnotation(CheckPermission::class.java)
|
||||
val kFunction = classType.kotlin.declaredFunctions.find { it.name == method.name }
|
||||
if (kFunction != null) {
|
||||
val parameterInfo = kFunction.parameters.mapNotNull { parameter ->
|
||||
val javaType = parameter.type.javaType
|
||||
// 跳过协程的 Continuation 参数
|
||||
if (javaType is Class<*> && Continuation::class.java.isAssignableFrom(javaType)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
parameter.name ?: return@mapNotNull null
|
||||
val annotation = parameter.annotations.find { it is D } as? D
|
||||
val paramName = annotation?.name?.takeIf { it.isNotBlank() } ?: parameter.name ?: ""
|
||||
val typeClass = when (javaType) {
|
||||
is Class<*> -> javaType
|
||||
is ParameterizedType -> javaType.rawType as? Class<*>
|
||||
else -> null
|
||||
}
|
||||
ParameterInfo(
|
||||
name = paramName,
|
||||
type = typeClass ?: parameter.type.javaType as Class<*>,
|
||||
isNullable = parameter.type.isMarkedNullable,
|
||||
isList = parameter.type.classifier == List::class,
|
||||
isComplex = !parameter.type.classifier.toString().startsWith("class kotlin.") &&
|
||||
!parameter.type.javaType.javaClass.isEnum &&
|
||||
parameter.type.javaType is Class<*>
|
||||
)
|
||||
}
|
||||
routeInfoCache[reqPath to httpMethod] =
|
||||
RouteInfo(classType, method, kFunction, parameterInfo, customizeResp, role, permissions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注册路由处理器
|
||||
routeInfoCache.forEach { (path, routeInfo) ->
|
||||
router.route(routeInfo.httpMethod, path.first).handler { ctx ->
|
||||
if (ctx.user() != null) {
|
||||
val user = ctx.user() as AuthUser
|
||||
if (!user.validateAuth(routeInfo)) {
|
||||
ctx.fail(403, Meta.unauthorized("unauthorized"))
|
||||
return@handler
|
||||
}
|
||||
}
|
||||
val instance = getIt(routeInfo.classType)
|
||||
buildLambda(ctx, instance, routeInfo)
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
private fun buildLambda(ctx: RoutingContext, instance: Any, routeInfo: RouteInfo) {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val params = getParamsInstance(ctx, routeInfo.parameterInfo)
|
||||
val result = if (routeInfo.kFunction.isSuspend) {
|
||||
routeInfo.kFunction.callSuspend(instance, *params)
|
||||
} else {
|
||||
routeInfo.kFunction.call(instance, *params)
|
||||
}
|
||||
val json = serializeToJson(result)
|
||||
if (routeInfo.customizeResp) return@launch
|
||||
ctx.response()
|
||||
.putHeader("Content-Type", "application/json")
|
||||
.end(json)
|
||||
} catch (e: Exception) {
|
||||
handleError(ctx, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val objectMapper = jacksonObjectMapper()
|
||||
|
||||
private fun AuthUser.validateAuth(routeInfo: RouteInfo): Boolean {
|
||||
// 如果没有权限要求,直接返回true
|
||||
if (routeInfo.role == null && routeInfo.permissions == null) return true
|
||||
// 验证角色
|
||||
val hasValidRole = routeInfo.role?.let { role ->
|
||||
val roleSet = attributes().getJsonArray("role").toSet() as Set<String>
|
||||
if (roleSet.isEmpty()) {
|
||||
false
|
||||
} else {
|
||||
val reqRoleSet = (role.value + role.type).filter { it.isNotBlank() }.toSet()
|
||||
validateSet(reqRoleSet, roleSet, role.mode)
|
||||
}
|
||||
} ?: true
|
||||
|
||||
// 验证权限
|
||||
val hasValidPermission = routeInfo.permissions?.let { permissions ->
|
||||
val permissionSet = attributes().getJsonArray("permissions").toSet() as Set<String>
|
||||
val roleSet = attributes().getJsonArray("role").toSet() as Set<String>
|
||||
if (permissionSet.isEmpty() && roleSet.isEmpty()) {
|
||||
false
|
||||
} else {
|
||||
if (permissions.orRole.isNotEmpty()) {
|
||||
val roleBoolean = validateSet(permissions.orRole.toSet(), roleSet, Mode.AND)
|
||||
if (roleBoolean) return true
|
||||
}
|
||||
val reqPermissionSet = (permissions.value + permissions.type).filter { it.isNotBlank() }.toSet()
|
||||
validateSet(reqPermissionSet, permissionSet, permissions.mode)
|
||||
}
|
||||
} ?: true
|
||||
return hasValidRole && hasValidPermission
|
||||
}
|
||||
|
||||
private fun validateSet(
|
||||
required: Set<String>,
|
||||
actual: Set<String>,
|
||||
mode: Mode
|
||||
): Boolean {
|
||||
if (required.isEmpty()) return true
|
||||
return when (mode) {
|
||||
Mode.AND -> required == actual
|
||||
Mode.OR -> required.any { it in actual }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getReqPath(prefix: String, clazz: Class<*>): String {
|
||||
val basePath = if (prefix.isNotBlank()) {
|
||||
StrUtil.toCamelCase(StrUtil.toUnderlineCase(prefix))
|
||||
} else {
|
||||
StrUtil.toCamelCase(StrUtil.toUnderlineCase(clazz.simpleName.removeSuffix("Controller")))
|
||||
}
|
||||
return "/$basePath".replace("//", "/")
|
||||
}
|
||||
|
||||
private fun getReqPath(prefix: String, clazz: Class<*>, method: Method): String {
|
||||
val basePath = if (prefix.isNotBlank()) {
|
||||
StrUtil.toCamelCase(StrUtil.toUnderlineCase(prefix))
|
||||
} else {
|
||||
StrUtil.toCamelCase(StrUtil.toUnderlineCase(clazz.simpleName.removeSuffix("Controller")))
|
||||
}
|
||||
val methodName = StrUtil.toCamelCase(StrUtil.toUnderlineCase(method.name))
|
||||
return "/$basePath/$methodName".replace("//", "/")
|
||||
}
|
||||
|
||||
private fun getParamsInstance(ctx: RoutingContext, paramsInfo: List<ParameterInfo>): Array<Any?> {
|
||||
val params = mutableListOf<Any?>()
|
||||
val formAttributes = ctx.request().formAttributes().associate { it.key to it.value }
|
||||
val queryParams = ctx.queryParams().entries().associate { it.key to it.value }
|
||||
val combinedParams = formAttributes + queryParams
|
||||
// 解析Body
|
||||
val bodyStr = if (!ctx.body().isEmpty) ctx.body().asString() else ""
|
||||
val bodyAsMap = if (bodyStr.isNotBlank()) {
|
||||
try {
|
||||
objectMapper.readValue(bodyStr, Map::class.java) as Map<String, Any>
|
||||
} catch (e: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
paramsInfo.forEach { param ->
|
||||
if (param.isList) {
|
||||
val listParamValue = ctx.queryParams().getAll(param.name)
|
||||
if (listParamValue.isEmpty() && !param.isNullable) throw IllegalArgumentException("Missing required parameter: ${param.name}")
|
||||
params.add(listParamValue)
|
||||
return@forEach
|
||||
}
|
||||
if (param.isComplex) {
|
||||
try {
|
||||
val value = objectMapper.readValue(bodyStr, param.type)
|
||||
params.add(value)
|
||||
return@forEach
|
||||
} catch (e: Exception) {
|
||||
if (!param.isNullable) throw IllegalArgumentException("Failed to parse request body for parameter: ${param.name}")
|
||||
}
|
||||
}
|
||||
|
||||
params.add(
|
||||
when (param.type) {
|
||||
RoutingContext::class.java -> ctx
|
||||
User::class.java -> ctx.user()
|
||||
else -> {
|
||||
val bodyValue = bodyAsMap[param.name]
|
||||
val paramValue = bodyValue?.toString() ?: combinedParams[param.name]
|
||||
when {
|
||||
paramValue == null -> {
|
||||
if (!param.isNullable) throw IllegalArgumentException("Missing required parameter: ${param.name}") else null
|
||||
}
|
||||
|
||||
else -> {
|
||||
val value = getParamValue(paramValue.toString(), param.type)
|
||||
if (!param.isNullable && value == null) {
|
||||
throw IllegalArgumentException("Missing required parameter: ${param.name}")
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return params.toTypedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串参数值映射到目标类型。
|
||||
*
|
||||
* @param paramValue 参数的字符串值。
|
||||
* @param type 目标 [Class] 类型。
|
||||
* @return 转换为目标类型的参数值,如果转换失败则返回 `null`。
|
||||
*/
|
||||
private fun getParamValue(paramValue: String, type: Class<*>): Any? {
|
||||
return when {
|
||||
type.isEnum -> {
|
||||
type.enumConstants.firstOrNull { (it as Enum<*>).name.equals(paramValue, ignoreCase = true) }
|
||||
}
|
||||
|
||||
type == String::class.java -> paramValue
|
||||
type == Int::class.java || type == Integer::class.java -> paramValue.toIntOrNull()
|
||||
type == Long::class.java || type == Long::class.java -> paramValue.toLongOrNull()
|
||||
type == Double::class.java || type == Double::class.java -> paramValue.toDoubleOrNull()
|
||||
type == Boolean::class.java || type == Boolean::class.java -> paramValue.toBoolean()
|
||||
else -> paramValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 [CustomizeRequest] 注解确定给定 REST 方法的 HTTP 方法。
|
||||
*
|
||||
* @param method 目标方法。
|
||||
* @return 对应的 [HttpMethod]。
|
||||
*/
|
||||
fun getHttpMethod(method: Method): HttpMethod {
|
||||
val api = method.getAnnotation(CustomizeRequest::class.java)
|
||||
return if (api != null) {
|
||||
when (api.method.uppercase()) {
|
||||
"GET" -> HttpMethod.GET
|
||||
"PUT" -> HttpMethod.PUT
|
||||
"DELETE" -> HttpMethod.DELETE
|
||||
"PATCH" -> HttpMethod.PATCH
|
||||
else -> HttpMethod.POST
|
||||
}
|
||||
} else {
|
||||
HttpMethod.POST
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将对象序列化为 JSON 表示。
|
||||
*
|
||||
* @param obj 要序列化的对象。
|
||||
* @return JSON 字符串。
|
||||
*/
|
||||
private fun serializeToJson(obj: Any?): String {
|
||||
return objectMapper.writeValueAsString(obj)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理错误并通过标准化的错误响应发送。
|
||||
*
|
||||
* @param ctx 发送响应的 [RoutingContext]。
|
||||
* @param e 捕获的异常。
|
||||
*/
|
||||
private fun handleError(ctx: RoutingContext, e: Exception) {
|
||||
ctx.response()
|
||||
.setStatusCode(500)
|
||||
.putHeader("Content-Type", "application/json")
|
||||
.end(
|
||||
objectMapper.writeValueAsString(
|
||||
mapOf(
|
||||
"name" to e::class.simpleName,
|
||||
"message" to (e.message ?: e.cause.toString()),
|
||||
"data" to null
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class RouteInfo(
|
||||
val classType: Class<*>,
|
||||
val method: Method,
|
||||
val kFunction: KFunction<*>,
|
||||
val parameterInfo: List<ParameterInfo>,
|
||||
val customizeResp: Boolean,
|
||||
val role: CheckRole? = null,
|
||||
val permissions: CheckPermission? = null,
|
||||
val httpMethod: HttpMethod = getHttpMethod(method)
|
||||
)
|
||||
|
||||
private data class ParameterInfo(
|
||||
val name: String,
|
||||
val type: Class<*>,
|
||||
val isNullable: Boolean,
|
||||
val isList: Boolean,
|
||||
val isComplex: Boolean
|
||||
)
|
||||
}
|
||||
59
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/DbPool.kt
Normal file
59
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/DbPool.kt
Normal file
@ -0,0 +1,59 @@
|
||||
package org.aikrai.vertx.db
|
||||
|
||||
import com.google.inject.Inject
|
||||
import com.google.inject.name.Named
|
||||
import io.vertx.core.Vertx
|
||||
import io.vertx.mysqlclient.MySQLBuilder
|
||||
import io.vertx.mysqlclient.MySQLConnectOptions
|
||||
import io.vertx.pgclient.PgBuilder
|
||||
import io.vertx.pgclient.PgConnectOptions
|
||||
import io.vertx.sqlclient.Pool
|
||||
import io.vertx.sqlclient.PoolOptions
|
||||
import io.vertx.sqlclient.SqlClient
|
||||
|
||||
class DbPool @Inject constructor(
|
||||
vertx: Vertx,
|
||||
@Named("databases.type") private val type: String,
|
||||
@Named("databases.name") private val name: String,
|
||||
@Named("databases.host") private val host: String,
|
||||
@Named("databases.port") private val port: String,
|
||||
@Named("databases.username") private val user: String,
|
||||
@Named("databases.password") private val password: String
|
||||
) {
|
||||
private var pool: Pool
|
||||
|
||||
init {
|
||||
val poolOptions = PoolOptions().setMaxSize(10)
|
||||
pool = when (type.lowercase()) {
|
||||
"mysql" -> {
|
||||
val clientOptions = MySQLConnectOptions()
|
||||
.setHost(host)
|
||||
.setPort(port.toInt())
|
||||
.setDatabase(name)
|
||||
.setUser(user)
|
||||
.setPassword(password)
|
||||
.setTcpKeepAlive(true)
|
||||
MySQLBuilder.pool().connectingTo(clientOptions).with(poolOptions).using(vertx).build()
|
||||
}
|
||||
"postgre", "postgresql" -> {
|
||||
val clientOptions = PgConnectOptions()
|
||||
.setHost(host)
|
||||
.setPort(port.toInt())
|
||||
.setDatabase(name)
|
||||
.setUser(user)
|
||||
.setPassword(password)
|
||||
.setTcpKeepAlive(true)
|
||||
PgBuilder.pool().connectingTo(clientOptions).with(poolOptions).using(vertx).build()
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unsupported database type: $type")
|
||||
}
|
||||
}
|
||||
|
||||
fun getClient(): SqlClient {
|
||||
return pool
|
||||
}
|
||||
|
||||
fun getPool(): Pool {
|
||||
return pool
|
||||
}
|
||||
}
|
||||
39
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapper.kt
Normal file
39
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapper.kt
Normal file
@ -0,0 +1,39 @@
|
||||
package org.aikrai.vertx.db
|
||||
|
||||
import kotlin.reflect.KProperty1
|
||||
|
||||
interface QueryWrapper<T> {
|
||||
fun select(vararg columns: String): QueryWrapper<T>
|
||||
fun select(vararg columns: KProperty1<T, *>): QueryWrapper<T>
|
||||
|
||||
fun eq(column: String, value: Any): QueryWrapper<T>
|
||||
fun eq(column: KProperty1<T, *>, value: Any): QueryWrapper<T>
|
||||
fun eq(condition: Boolean, column: String, value: Any): QueryWrapper<T>
|
||||
fun eq(condition: Boolean, column: KProperty1<T, *>, value: Any): QueryWrapper<T>
|
||||
|
||||
fun from(table: String): QueryWrapper<T>
|
||||
|
||||
fun like(column: String, value: String): QueryWrapper<T>
|
||||
fun like(column: KProperty1<T, *>, value: String): QueryWrapper<T>
|
||||
// fun like(condition: Boolean, column: String, value: String): QueryWrapper<T>
|
||||
// fun like(condition: Boolean, column: KProperty1<T, *>, value: String): QueryWrapper<T>
|
||||
|
||||
// fun likeLeft(column: String, value: String): QueryWrapper<T>
|
||||
fun likeLeft(column: KProperty1<T, *>, value: String): QueryWrapper<T>
|
||||
|
||||
// fun likeRight(column: String, value: String): QueryWrapper<T>
|
||||
fun likeRight(column: KProperty1<T, *>, value: String): QueryWrapper<T>
|
||||
|
||||
fun `in`(column: KProperty1<T, *>, values: Collection<*>): QueryWrapper<T>
|
||||
fun notIn(column: KProperty1<T, *>, values: Collection<*>): QueryWrapper<T>
|
||||
|
||||
fun groupBy(vararg columns: KProperty1<T, *>): QueryWrapper<T>
|
||||
fun having(condition: String): QueryWrapper<T>
|
||||
|
||||
fun orderByAsc(vararg columns: KProperty1<T, *>): QueryWrapper<T>
|
||||
fun orderByDesc(vararg columns: KProperty1<T, *>): QueryWrapper<T>
|
||||
|
||||
fun genSql(): String
|
||||
suspend fun getList(): List<T>
|
||||
suspend fun getOne(): T?
|
||||
}
|
||||
303
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapperImpl.kt
Normal file
303
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapperImpl.kt
Normal file
@ -0,0 +1,303 @@
|
||||
package org.aikrai.vertx.db
|
||||
|
||||
import cn.hutool.core.util.StrUtil
|
||||
import io.vertx.kotlin.coroutines.coAwait
|
||||
import io.vertx.sqlclient.Row
|
||||
import io.vertx.sqlclient.SqlClient
|
||||
import io.vertx.sqlclient.templates.SqlTemplate
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Table
|
||||
import org.aikrai.vertx.jackson.JsonUtil
|
||||
import kotlin.reflect.KProperty1
|
||||
import kotlin.reflect.jvm.javaField
|
||||
|
||||
class QueryWrapperImpl<T : Any>(
|
||||
private val entityClass: Class<T>,
|
||||
private val sqlClient: SqlClient,
|
||||
) : QueryWrapper<T> {
|
||||
private val conditions = mutableListOf<QueryCondition>()
|
||||
|
||||
override fun select(vararg columns: String): QueryWrapper<T> {
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.SELECT,
|
||||
column = columns.joinToString(",")
|
||||
)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun select(vararg columns: KProperty1<T, *>): QueryWrapper<T> {
|
||||
columns.forEach {
|
||||
val columnName = it.javaField?.getAnnotation(Column::class.java)?.name?.takeIf { it.isNotBlank() }
|
||||
?: StrUtil.toUnderlineCase(it.name)
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.SELECT,
|
||||
column = columnName
|
||||
)
|
||||
)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun eq(column: String, value: Any): QueryWrapper<T> {
|
||||
return eq(true, column, value)
|
||||
}
|
||||
|
||||
override fun eq(column: KProperty1<T, *>, value: Any): QueryWrapper<T> {
|
||||
return eq(true, column, value)
|
||||
}
|
||||
|
||||
override fun eq(condition: Boolean, column: String, value: Any): QueryWrapper<T> {
|
||||
if (condition) {
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.WHERE,
|
||||
column = column,
|
||||
operator = "=",
|
||||
value = value
|
||||
)
|
||||
)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun eq(condition: Boolean, column: KProperty1<T, *>, value: Any): QueryWrapper<T> {
|
||||
if (condition) {
|
||||
val columnName = column.javaField?.getAnnotation(Column::class.java)?.name?.takeIf { it.isNotBlank() }
|
||||
?: StrUtil.toUnderlineCase(column.name)
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.WHERE,
|
||||
column = columnName,
|
||||
operator = "=",
|
||||
value = value
|
||||
)
|
||||
)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun from(table: String): QueryWrapper<T> {
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.FROM,
|
||||
column = table
|
||||
)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun like(column: String, value: String): QueryWrapper<T> {
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.LIKE,
|
||||
column = column,
|
||||
operator = "LIKE",
|
||||
value = "%$value%"
|
||||
)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun like(column: KProperty1<T, *>, value: String): QueryWrapper<T> {
|
||||
conditions.add(QueryCondition(QueryType.LIKE, column.name, "LIKE", "%$value%"))
|
||||
return this
|
||||
}
|
||||
|
||||
override fun likeLeft(column: KProperty1<T, *>, value: String): QueryWrapper<T> {
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.WHERE,
|
||||
column = column.name,
|
||||
operator = "LIKE",
|
||||
value = "%$value"
|
||||
)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun likeRight(column: KProperty1<T, *>, value: String): QueryWrapper<T> {
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.WHERE,
|
||||
column = column.name,
|
||||
operator = "LIKE",
|
||||
value = "$value%"
|
||||
)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun `in`(column: KProperty1<T, *>, values: Collection<*>): QueryWrapper<T> {
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.WHERE,
|
||||
column = column.name,
|
||||
operator = "IN",
|
||||
value = values
|
||||
)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun notIn(column: KProperty1<T, *>, values: Collection<*>): QueryWrapper<T> {
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.WHERE,
|
||||
column = column.name,
|
||||
operator = "NOT IN",
|
||||
value = values
|
||||
)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun groupBy(vararg columns: KProperty1<T, *>): QueryWrapper<T> {
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.GROUP_BY,
|
||||
column = columns.joinToString(",") { it.name }
|
||||
)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun having(condition: String): QueryWrapper<T> {
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.HAVING,
|
||||
column = condition
|
||||
)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun orderByAsc(vararg columns: KProperty1<T, *>): QueryWrapper<T> {
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.ORDER_BY,
|
||||
column = columns.joinToString(",") { it.name },
|
||||
additional = mapOf("direction" to "ASC")
|
||||
)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun orderByDesc(vararg columns: KProperty1<T, *>): QueryWrapper<T> {
|
||||
conditions.add(
|
||||
QueryCondition(
|
||||
type = QueryType.ORDER_BY,
|
||||
column = columns.joinToString(",") { it.name },
|
||||
additional = mapOf("direction" to "DESC")
|
||||
)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
private fun buildSql(): String {
|
||||
val sqlBuilder = StringBuilder()
|
||||
|
||||
// SELECT 子句
|
||||
sqlBuilder.append("SELECT ")
|
||||
val selectCondition = conditions.find { it.type == QueryType.SELECT }
|
||||
if (selectCondition != null) {
|
||||
sqlBuilder.append(selectCondition.column)
|
||||
} else {
|
||||
sqlBuilder.append("*")
|
||||
}
|
||||
|
||||
// FROM 子句
|
||||
val from = conditions.filter { it.type == QueryType.FROM }
|
||||
if (from.isNotEmpty()) {
|
||||
sqlBuilder.append(" FROM ${from.first().column}")
|
||||
} else {
|
||||
entityClass.getAnnotation(Table::class.java)?.name?.let {
|
||||
sqlBuilder.append(" FROM $it")
|
||||
} ?: sqlBuilder.append(" FROM ${StrUtil.toUnderlineCase(entityClass.simpleName)}")
|
||||
}
|
||||
|
||||
// WHERE 子句
|
||||
val whereConditions = conditions.filter { it.type == QueryType.WHERE }
|
||||
if (whereConditions.isNotEmpty()) {
|
||||
sqlBuilder.append(" WHERE ")
|
||||
sqlBuilder.append(
|
||||
whereConditions.joinToString(" AND ") {
|
||||
when (it.operator) {
|
||||
"IN", "NOT IN" -> "${it.column} ${it.operator} (${(it.value as Collection<*>).joinToString(",")})"
|
||||
"LIKE" -> "${it.column} ${it.operator} '${it.value}'"
|
||||
else -> "${it.column} ${it.operator} '${it.value}'"
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// GROUP BY 子句
|
||||
conditions.find { it.type == QueryType.GROUP_BY }?.let {
|
||||
sqlBuilder.append(" GROUP BY ${it.column}")
|
||||
}
|
||||
|
||||
// HAVING 子句
|
||||
conditions.find { it.type == QueryType.HAVING }?.let {
|
||||
sqlBuilder.append(" HAVING ${it.column}")
|
||||
}
|
||||
|
||||
// ORDER BY 子句
|
||||
val orderByConditions = conditions.filter { it.type == QueryType.ORDER_BY }
|
||||
if (orderByConditions.isNotEmpty()) {
|
||||
sqlBuilder.append(" ORDER BY ")
|
||||
sqlBuilder.append(
|
||||
orderByConditions.joinToString(", ") {
|
||||
"${it.column} ${it.additional["direction"]}"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return sqlBuilder.toString()
|
||||
}
|
||||
|
||||
override fun genSql(): String {
|
||||
return buildSql()
|
||||
}
|
||||
|
||||
override suspend fun getList(): List<T> {
|
||||
val sql = buildSql()
|
||||
val objs = SqlTemplate
|
||||
.forQuery(sqlClient, sql)
|
||||
.mapTo(Row::toJson)
|
||||
.execute(emptyMap())
|
||||
.coAwait()
|
||||
.toList()
|
||||
return objs.map { JsonUtil.parseObject(it.encode(), entityClass) }
|
||||
}
|
||||
|
||||
override suspend fun getOne(): T? {
|
||||
val sql = buildSql()
|
||||
val obj = SqlTemplate
|
||||
.forQuery(sqlClient, sql)
|
||||
.mapTo(Row::toJson)
|
||||
.execute(emptyMap())
|
||||
.coAwait()
|
||||
.firstOrNull()
|
||||
return obj?.let { JsonUtil.parseObject(it.encode(), entityClass) }
|
||||
}
|
||||
}
|
||||
|
||||
enum class QueryType {
|
||||
SELECT,
|
||||
WHERE,
|
||||
FROM,
|
||||
GROUP_BY,
|
||||
HAVING,
|
||||
ORDER_BY,
|
||||
LIKE
|
||||
}
|
||||
|
||||
data class QueryCondition(
|
||||
val type: QueryType,
|
||||
val column: String,
|
||||
val operator: String? = null,
|
||||
val value: Any? = null,
|
||||
val additional: Map<String, Any> = emptyMap()
|
||||
)
|
||||
16
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/Repository.kt
Normal file
16
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/Repository.kt
Normal file
@ -0,0 +1,16 @@
|
||||
package org.aikrai.vertx.db
|
||||
|
||||
interface Repository<TId, TEntity> {
|
||||
suspend fun create(t: TEntity): Int
|
||||
suspend fun delete(id: TId): Int
|
||||
suspend fun update(t: TEntity): Int
|
||||
suspend fun update(id: TId, parameters: Map<String, Any?>): Int
|
||||
suspend fun get(id: TId): TEntity?
|
||||
|
||||
suspend fun createBatch(list: List<TEntity>): Int
|
||||
|
||||
suspend fun <R> execute(sql: String): R
|
||||
|
||||
suspend fun queryBuilder(): QueryWrapper<TEntity>
|
||||
suspend fun queryBuilder(clazz: Class<*>): QueryWrapper<*>
|
||||
}
|
||||
272
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/RepositoryImpl.kt
Normal file
272
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/RepositoryImpl.kt
Normal file
@ -0,0 +1,272 @@
|
||||
package org.aikrai.vertx.db
|
||||
|
||||
import cn.hutool.core.util.StrUtil
|
||||
import com.fasterxml.jackson.core.type.TypeReference
|
||||
import io.vertx.kotlin.coroutines.coAwait
|
||||
import io.vertx.sqlclient.*
|
||||
import io.vertx.sqlclient.templates.SqlTemplate
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import mu.KotlinLogging
|
||||
import org.aikrai.vertx.db.tx.TxCtx
|
||||
import org.aikrai.vertx.jackson.JsonUtil
|
||||
import java.lang.reflect.Modifier
|
||||
import java.lang.reflect.ParameterizedType
|
||||
import java.sql.Timestamp
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
open class RepositoryImpl<TId, TEntity : Any>(
|
||||
private val sqlClient: SqlClient
|
||||
) : Repository<TId, TEntity> {
|
||||
private val clazz: Class<TEntity> = (this::class.java.genericSuperclass as ParameterizedType)
|
||||
.actualTypeArguments[1] as Class<TEntity>
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val sqlTemplateMap: Map<Pair<String, String>, String> = mutableMapOf()
|
||||
private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSXXX")
|
||||
|
||||
override suspend fun <R> execute(sql: String): R {
|
||||
return if (sql.trimStart().startsWith("SELECT", true)) {
|
||||
val list = SqlTemplate.forQuery(getConnection(), sql).execute(mapOf())
|
||||
.coAwait().map { it.toJson() }
|
||||
val jsonObject = JsonUtil.toJsonObject(list)
|
||||
val typeReference = object : TypeReference<R>() {}
|
||||
JsonUtil.parseObject(jsonObject, typeReference, true)
|
||||
} else {
|
||||
val rowCount = SqlTemplate.forUpdate(getConnection(), sql).execute(mapOf())
|
||||
.coAwait().rowCount()
|
||||
rowCount as R
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun create(t: TEntity): Int {
|
||||
val tableName = getTableName()
|
||||
val sqlTemplate = sqlTemplateMap[Pair(tableName, "create")] ?: run {
|
||||
val idColumnName = getIdColumnName()
|
||||
val columnsMap = getColumnMappings()
|
||||
// Exclude 'id' field if it's auto-generated
|
||||
val fields = clazz.declaredFields.filter { it.name != idColumnName }
|
||||
val columns = fields.map { columnsMap[it.name] }
|
||||
val parameters = fields.map { it.name }
|
||||
val sql =
|
||||
"INSERT INTO $tableName (${columns.joinToString(", ")}) VALUES (${parameters.joinToString(", ") { "#{$it}" }})"
|
||||
sqlTemplateMap.plus(Pair(tableName, "create")) to sql
|
||||
sql
|
||||
}
|
||||
val params = getNonNullFields(t)
|
||||
logger.info { "SQL: $sqlTemplate, PARAMS: $params" }
|
||||
return SqlTemplate.forUpdate(getConnection(), sqlTemplate)
|
||||
.execute(params)
|
||||
.coAwait()
|
||||
.rowCount()
|
||||
}
|
||||
|
||||
override suspend fun delete(id: TId): Int {
|
||||
val tableName = getTableName()
|
||||
val sqlTemplate = sqlTemplateMap[Pair(tableName, "delete")] ?: run {
|
||||
val idColumnName = getIdColumnName()
|
||||
val sql = "DELETE FROM $tableName WHERE $idColumnName = #{id}"
|
||||
sqlTemplateMap.plus(Pair(tableName, "delete")) to sql
|
||||
sql
|
||||
}
|
||||
val params = mapOf("id" to id)
|
||||
logger.debug { "SQL: $sqlTemplate, PARAMS: $params" }
|
||||
return SqlTemplate.forUpdate(getConnection(), sqlTemplate)
|
||||
.execute(params)
|
||||
.coAwait()
|
||||
.rowCount()
|
||||
}
|
||||
|
||||
override suspend fun update(t: TEntity): Int {
|
||||
val tableName = getTableName()
|
||||
val sqlTemplate = sqlTemplateMap[Pair(tableName, "update")] ?: run {
|
||||
val idColumnName = getIdColumnName()
|
||||
val columnsMap = getColumnMappings()
|
||||
// Exclude 'id' from update fields
|
||||
val fields = clazz.declaredFields.filter { it.name != idColumnName }
|
||||
val setClause = fields.joinToString(", ") { "${columnsMap[it.name]} = #{${it.name}}" }
|
||||
val sql = "UPDATE $tableName SET $setClause WHERE $idColumnName = #{id}"
|
||||
sqlTemplateMap.plus(Pair(tableName, "update")) to sql
|
||||
sql
|
||||
}
|
||||
// Get id value
|
||||
val idColumnName = getIdColumnName()
|
||||
val idField = clazz.declaredFields.find { it.name == idColumnName }
|
||||
?: throw IllegalArgumentException("Class ${clazz.simpleName} must have an 'id' field for update operation.")
|
||||
idField.isAccessible = true
|
||||
val idValue = idField.get(t)
|
||||
// Prepare parameters
|
||||
val params = getNonNullFields(t) + mapOf("id" to idValue)
|
||||
logger.debug { "SQL: $sqlTemplate, PARAMS: $params" }
|
||||
return SqlTemplate.forUpdate(getConnection(), sqlTemplate)
|
||||
.execute(params)
|
||||
.coAwait()
|
||||
.rowCount()
|
||||
}
|
||||
|
||||
override suspend fun update(id: TId, parameters: Map<String, Any?>): Int {
|
||||
val tableName = getTableName()
|
||||
val sqlTemplate = sqlTemplateMap[Pair(tableName, "update")] ?: run {
|
||||
val idColumnName = getIdColumnName()
|
||||
val columnsMap = getColumnMappings()
|
||||
val setClause = parameters.keys.joinToString(", ") { "${columnsMap[it]} = #{$it}" }
|
||||
val sql = "UPDATE $tableName SET $setClause WHERE $idColumnName = #{id}"
|
||||
sqlTemplateMap.plus(Pair(tableName, "update")) to sql
|
||||
sql
|
||||
}
|
||||
val params = parameters + mapOf("id" to id)
|
||||
logger.debug { "SQL: $sqlTemplate, PARAMS: $params" }
|
||||
return SqlTemplate.forUpdate(getConnection(), sqlTemplate)
|
||||
.execute(params)
|
||||
.coAwait()
|
||||
.rowCount()
|
||||
}
|
||||
|
||||
override suspend fun get(id: TId): TEntity? {
|
||||
val tableName = getTableName()
|
||||
val sqlTemplate = sqlTemplateMap[Pair(tableName, "get")] ?: run {
|
||||
val idColumnName = getIdColumnName()
|
||||
val columnsMap = getColumnMappings()
|
||||
val columns = columnsMap.values.joinToString(", ")
|
||||
val sql = "SELECT $columns FROM $tableName WHERE $idColumnName = #{id}"
|
||||
(sqlTemplateMap as MutableMap)[Pair(tableName, "get")] = sql
|
||||
sql
|
||||
}
|
||||
val params = mapOf("id" to id)
|
||||
logger.debug { "SQL: $sqlTemplate, PARAMS: $params" }
|
||||
val rows = SqlTemplate
|
||||
.forQuery(getConnection(), sqlTemplate)
|
||||
.mapTo(Row::toJson)
|
||||
.execute(params)
|
||||
.coAwait()
|
||||
.firstOrNull()
|
||||
return rows?.let { JsonUtil.parseObject(it.toString(), clazz, true) }
|
||||
}
|
||||
|
||||
override suspend fun queryBuilder(): QueryWrapper<TEntity> {
|
||||
return QueryWrapperImpl(clazz, getConnection())
|
||||
}
|
||||
|
||||
override suspend fun queryBuilder(clazz: Class<*>): QueryWrapper<*> {
|
||||
return QueryWrapperImpl(clazz, getConnection())
|
||||
}
|
||||
|
||||
private suspend fun getConnection(): SqlClient {
|
||||
return if (TxCtx.isTransactionActive(coroutineContext)) {
|
||||
TxCtx.currentSqlConnection(coroutineContext) ?: run {
|
||||
logger.error("TransactionContextElement.sqlConnection is null")
|
||||
return sqlClient
|
||||
}
|
||||
} else {
|
||||
sqlClient
|
||||
}
|
||||
}
|
||||
|
||||
// 其他工具方法
|
||||
override suspend fun createBatch(list: List<TEntity>): Int {
|
||||
if (list.isEmpty()) return 0
|
||||
var rowCount = 0
|
||||
list.chunked(1000).forEach {
|
||||
val sql = genBatchInsertSql(it)
|
||||
rowCount += SqlTemplate.forUpdate(sqlClient, sql)
|
||||
.execute(emptyMap())
|
||||
.coAwait()
|
||||
.rowCount()
|
||||
}
|
||||
return rowCount
|
||||
}
|
||||
|
||||
// 工具方法:获取表名
|
||||
private fun getTableName(): String {
|
||||
return clazz.getAnnotation(Table::class.java)?.name?.takeIf { it.isNotBlank() }
|
||||
?: StrUtil.toUnderlineCase(clazz.simpleName)
|
||||
}
|
||||
|
||||
// 添加获取ID字段名称的方法
|
||||
private fun getIdColumnName(): String {
|
||||
val idField = clazz.declaredFields.find { it.isAnnotationPresent(Id::class.java) }
|
||||
?: throw IllegalArgumentException("No @Id field found in ${clazz.simpleName}")
|
||||
return idField.getAnnotation(Column::class.java)?.name?.takeIf { it.isNotBlank() }
|
||||
?: StrUtil.toUnderlineCase(idField.name)
|
||||
}
|
||||
|
||||
private fun getColumnMappings(): Map<String, String> {
|
||||
return clazz.declaredFields.associate { field ->
|
||||
val columnAnnotation = field.getAnnotation(Column::class.java)
|
||||
val columnName = columnAnnotation?.name?.takeIf { it.isNotBlank() }
|
||||
?: StrUtil.toUnderlineCase(field.name)
|
||||
field.name to columnName
|
||||
}
|
||||
}
|
||||
|
||||
// 工具方法:获取非空字段及其值
|
||||
private fun getNonNullFields(t: TEntity): Map<String, Any> {
|
||||
return clazz.declaredFields
|
||||
.filter { field ->
|
||||
field.isAccessible = true
|
||||
// 排除被 @Transient 注解标记的字段
|
||||
!field.isAnnotationPresent(Transient::class.java) &&
|
||||
field.get(t) != null
|
||||
}
|
||||
.associate { field ->
|
||||
field.name to field.get(t)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成批量 INSERT SQL 语句的函数
|
||||
* @param objects 要插入的对象列表
|
||||
* @return 生成的 SQL 语句字符串
|
||||
*/
|
||||
private fun <TEntity> genBatchInsertSql(objects: List<TEntity>): String {
|
||||
// 如果对象列表为空,直接返回空字符串
|
||||
if (objects.isEmpty()) return ""
|
||||
|
||||
// 将类名转换为下划线命名的表名,例如:UserInfo -> user_info
|
||||
val tableName = StrUtil.toUnderlineCase(clazz.simpleName)
|
||||
|
||||
// 获取类的所有字段,包括私有字段
|
||||
val fields = clazz.declaredFields.filter {
|
||||
// 过滤掉静态字段和合成字段
|
||||
!Modifier.isStatic(it.modifiers) && !it.isSynthetic
|
||||
}
|
||||
|
||||
// 确保所有字段可访问
|
||||
fields.forEach { it.isAccessible = true }
|
||||
|
||||
// 将字段名转换为下划线命名的列名,并用逗号隔开
|
||||
val columnNames = fields.joinToString(", ") { StrUtil.toUnderlineCase(it.name) }
|
||||
|
||||
// SQL 转义函数
|
||||
fun escapeSql(value: String): String = value.replace("'", "''")
|
||||
|
||||
// 格式化属性值为 SQL 字符串
|
||||
fun formatValue(value: Any?): String = when (value) {
|
||||
null -> "NULL" // 如果值为 null,返回字符串 "NULL"
|
||||
is String -> "'${escapeSql(value)}'" // 字符串类型,加单引号并进行转义
|
||||
is Enum<*> -> "'${value.name}'" // 枚举类型,使用枚举名,添加单引号
|
||||
is Number, is Boolean -> value.toString() // 数字和布尔类型,直接转换为字符串
|
||||
is Timestamp -> // 时间戳类型,格式化为指定的日期时间字符串
|
||||
"'${formatter.format(value.toInstant().atZone(ZoneId.of("Asia/Shanghai")))}'"
|
||||
|
||||
is Array<*> -> // 数组类型处理
|
||||
if (value.isEmpty()) "'{}'" else "'{${value.joinToString(",") { escapeSql(it?.toString() ?: "NULL") }}}'"
|
||||
|
||||
is Collection<*> -> // 集合类型处理
|
||||
if (value.isEmpty()) "'{}'" else "'{${value.joinToString(",") { escapeSql(it?.toString() ?: "NULL") }}}'"
|
||||
|
||||
else -> "'${escapeSql(value.toString())}'" // 其他类型,调用 toString() 后转义并加单引号
|
||||
}
|
||||
|
||||
// 构建 VALUES 部分,每个对象对应一组值
|
||||
val valuesList = objects.map { instance ->
|
||||
fields.joinToString(", ", "(", ")") { field ->
|
||||
// 获取属性值,并格式化为 SQL 字符串
|
||||
formatValue(field.get(instance))
|
||||
}
|
||||
}
|
||||
return "INSERT INTO $tableName ($columnNames) VALUES ${valuesList.joinToString(", ")};"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package org.aikrai.vertx.db
|
||||
|
||||
object SqlHelper {
|
||||
|
||||
fun retBool(result: Int?): Boolean {
|
||||
return null != result && result >= 1
|
||||
}
|
||||
}
|
||||
41
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxCtxElem.kt
Normal file
41
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxCtxElem.kt
Normal file
@ -0,0 +1,41 @@
|
||||
package org.aikrai.vertx.db.tx
|
||||
|
||||
import io.vertx.sqlclient.SqlConnection
|
||||
import io.vertx.sqlclient.Transaction
|
||||
import java.util.*
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class TxCtxElem(
|
||||
val sqlConnection: SqlConnection,
|
||||
val transaction: Transaction,
|
||||
val isActive: Boolean = true,
|
||||
val isNested: Boolean = false,
|
||||
val transactionStack: Stack<TxCtxElem>,
|
||||
val index: Int = transactionStack.size,
|
||||
val transactionId: String = UUID.randomUUID().toString()
|
||||
) : CoroutineContext.Element {
|
||||
companion object Key : CoroutineContext.Key<TxCtxElem>
|
||||
override val key: CoroutineContext.Key<*> = Key
|
||||
|
||||
override fun toString(): String {
|
||||
return "TransactionContextElement(transactionId=$transactionId, isActive=$isActive, isNested=$isNested)"
|
||||
}
|
||||
}
|
||||
|
||||
object TxCtx {
|
||||
fun getTransactionId(context: CoroutineContext): String? {
|
||||
return context[TxCtxElem.Key]?.transactionId
|
||||
}
|
||||
|
||||
fun currentTransaction(context: CoroutineContext): Transaction? {
|
||||
return context[TxCtxElem.Key]?.transaction
|
||||
}
|
||||
|
||||
fun currentSqlConnection(context: CoroutineContext): SqlConnection? {
|
||||
return context[TxCtxElem.Key]?.sqlConnection
|
||||
}
|
||||
|
||||
fun isTransactionActive(context: CoroutineContext): Boolean {
|
||||
return context[TxCtxElem.Key]?.isActive ?: false
|
||||
}
|
||||
}
|
||||
104
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxMgr.kt
Normal file
104
vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxMgr.kt
Normal file
@ -0,0 +1,104 @@
|
||||
package org.aikrai.vertx.db.tx
|
||||
|
||||
import io.vertx.kotlin.coroutines.coAwait
|
||||
import io.vertx.sqlclient.Pool
|
||||
import io.vertx.sqlclient.SqlConnection
|
||||
import io.vertx.sqlclient.Transaction
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import mu.KotlinLogging
|
||||
import org.aikrai.vertx.utlis.Meta
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
suspend fun <T> withTransaction(block: suspend CoroutineScope.() -> T): Any? {
|
||||
return TxMgrHolder.txMgr.withTransaction(block)
|
||||
}
|
||||
|
||||
object TxMgrHolder {
|
||||
private val _txMgr = AtomicReference<TxMgr?>(null)
|
||||
|
||||
val txMgr: TxMgr
|
||||
get() = _txMgr.get() ?: throw Meta.failure(
|
||||
"TransactionError",
|
||||
"TxMgr(TransactionManager)尚未初始化,请先调用initTxMgr()"
|
||||
)
|
||||
|
||||
/**
|
||||
* 原子地初始化 TxMgr(TransactionManager)。
|
||||
* 如果已经初始化,该方法将直接返回。
|
||||
*
|
||||
* @param pool SQL 客户端连接池。
|
||||
*/
|
||||
fun initTxMgr(pool: Pool) {
|
||||
if (_txMgr.get() != null) return
|
||||
val newManager = TxMgr(pool)
|
||||
_txMgr.compareAndSet(null, newManager)
|
||||
}
|
||||
}
|
||||
|
||||
class TxMgr(
|
||||
private val pool: Pool
|
||||
) {
|
||||
|
||||
private val logger = KotlinLogging.logger { }
|
||||
private val transactionStackMap = ConcurrentHashMap<CoroutineContext, Stack<TxCtxElem>>()
|
||||
|
||||
/**
|
||||
* 在事务上下文中执行一个块。
|
||||
*
|
||||
* @param block 需要在事务中执行的挂起函数。
|
||||
* @return 块的结果。
|
||||
*/
|
||||
suspend fun <T> withTransaction(block: suspend CoroutineScope.() -> T): Any? {
|
||||
val currentContext = coroutineContext
|
||||
val transactionStack = currentContext[TxCtxElem]?.transactionStack ?: Stack<TxCtxElem>()
|
||||
// 外层事务,嵌套事务,都创建新的连接和事务。实现外层事务回滚时所有嵌套事务回滚,嵌套事务回滚不影响外部事务
|
||||
val connection: SqlConnection = pool.connection.coAwait()
|
||||
val transaction: Transaction = connection.begin().coAwait()
|
||||
|
||||
return try {
|
||||
val txCtxElem =
|
||||
TxCtxElem(connection, transaction, true, transactionStack.isNotEmpty(), transactionStack)
|
||||
transactionStack.push(txCtxElem)
|
||||
logger.debug { (if (txCtxElem.isNested) "嵌套" else "") + "事务Id:" + txCtxElem.transactionId + "开始" }
|
||||
|
||||
withContext(currentContext + txCtxElem) {
|
||||
val result = block()
|
||||
if (txCtxElem.index == 0) {
|
||||
while (transactionStack.isNotEmpty()) {
|
||||
val txCtx = transactionStack.pop()
|
||||
txCtx.transaction.commit().coAwait()
|
||||
logger.debug { (if (txCtx.isNested) "嵌套" else "") + "事务Id:" + txCtx.transactionId + "提交" }
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Transaction failed, rollback" }
|
||||
if (transactionStack.isNotEmpty() && !transactionStack.peek().isNested) {
|
||||
// 外层事务失败,回滚所有事务
|
||||
logger.error { "Rolling back all transactions" }
|
||||
while (transactionStack.isNotEmpty()) {
|
||||
val txCtxElem = transactionStack.pop()
|
||||
txCtxElem.transaction.rollback().coAwait()
|
||||
logger.debug { (if (txCtxElem.isNested) "嵌套" else "") + "事务Id:" + txCtxElem.transactionId + "回滚" }
|
||||
}
|
||||
throw e
|
||||
} else {
|
||||
// 嵌套事务失败,只回滚当前事务
|
||||
val txCtxElem = transactionStack.pop()
|
||||
txCtxElem.transaction.rollback().coAwait()
|
||||
logger.debug(e) { (if (txCtxElem.isNested) "嵌套" else "") + "事务Id:" + txCtxElem.transactionId + "回滚" }
|
||||
}
|
||||
} finally {
|
||||
if (transactionStack.isEmpty()) {
|
||||
transactionStackMap.remove(currentContext) // 清理上下文
|
||||
connection.close() // 仅在外层事务时关闭连接
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
145
vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/JsonUtil.kt
Normal file
145
vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/JsonUtil.kt
Normal file
@ -0,0 +1,145 @@
|
||||
package org.aikrai.vertx.jackson
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
import com.fasterxml.jackson.core.type.TypeReference
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.JavaType
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies
|
||||
import com.fasterxml.jackson.databind.introspect.AnnotationIntrospectorPair
|
||||
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import io.vertx.core.json.JsonArray
|
||||
import io.vertx.core.json.JsonObject
|
||||
|
||||
object JsonUtil {
|
||||
|
||||
fun configure(writeClassName: Boolean) {
|
||||
objectMapper = createObjectMapper(writeClassName)
|
||||
objectMapperSnakeCase = createObjectMapperSnakeCase(writeClassName)
|
||||
}
|
||||
|
||||
private var objectMapper = createObjectMapper(false)
|
||||
private var objectMapperSnakeCase = createObjectMapperSnakeCase(false)
|
||||
|
||||
private val objectMapperDeserialization = run {
|
||||
val mapper: ObjectMapper = jacksonObjectMapper()
|
||||
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
mapper.registerModule(JavaTimeModule())
|
||||
mapper.setAnnotationIntrospector(
|
||||
AnnotationIntrospectorPair(ColumnAnnotationIntrospector(), mapper.deserializationConfig.annotationIntrospector)
|
||||
)
|
||||
mapper
|
||||
}
|
||||
|
||||
private val objectMapperSnakeCaseDeserialization = run {
|
||||
val mapper: ObjectMapper = jacksonObjectMapper()
|
||||
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
mapper.propertyNamingStrategy = PropertyNamingStrategies.SNAKE_CASE
|
||||
mapper.registerModule(JavaTimeModule())
|
||||
mapper.setAnnotationIntrospector(
|
||||
AnnotationIntrospectorPair(ColumnAnnotationIntrospector(), mapper.deserializationConfig.annotationIntrospector)
|
||||
)
|
||||
mapper
|
||||
}
|
||||
|
||||
fun <T> toJsonStr(value: T, snakeCase: Boolean = false): String {
|
||||
if (snakeCase) {
|
||||
return objectMapperSnakeCase.writeValueAsString(value)
|
||||
}
|
||||
return objectMapper.writeValueAsString(value)
|
||||
}
|
||||
|
||||
fun <T> toJsonObject(value: T, snakeCase: Boolean = false): JsonObject {
|
||||
return JsonObject(toJsonStr(value, snakeCase))
|
||||
}
|
||||
|
||||
fun <T> toJsonArray(value: T, snakeCase: Boolean = false): JsonArray {
|
||||
return JsonArray(toJsonStr(value, snakeCase))
|
||||
}
|
||||
|
||||
fun <T> parseObject(value: JsonObject, clazz: Class<T>, snakeCase: Boolean = false): T {
|
||||
return parseObject(value.encode(), clazz, snakeCase)
|
||||
}
|
||||
|
||||
fun <T> parseObject(value: JsonObject, typeReference: TypeReference<T>, snakeCase: Boolean = false): T {
|
||||
return parseObject<T>(value.encode(), typeReference, snakeCase)
|
||||
}
|
||||
|
||||
fun <T> parseObject(value: String, clazz: Class<T>, snakeCase: Boolean = false): T {
|
||||
if (snakeCase) {
|
||||
return objectMapperSnakeCaseDeserialization.readValue(value, clazz)
|
||||
}
|
||||
return objectMapperDeserialization.readValue(value, clazz)
|
||||
}
|
||||
|
||||
private fun <T> parseObject(value: String, typeReference: TypeReference<T>, snakeCase: Boolean = false): T {
|
||||
if (snakeCase) {
|
||||
return objectMapperSnakeCaseDeserialization.readValue(value, typeReference)
|
||||
}
|
||||
return objectMapperDeserialization.readValue(value, typeReference)
|
||||
}
|
||||
|
||||
fun <T> parseObject(value: Map<*, *>, clazz: Class<T>, snakeCase: Boolean = false): T {
|
||||
return parseObject(toJsonStr(value), clazz, snakeCase)
|
||||
}
|
||||
|
||||
fun <T> parseObject(value: Map<*, *>, typeReference: TypeReference<T>, snakeCase: Boolean = false): T {
|
||||
return parseObject<T>(
|
||||
toJsonStr(value),
|
||||
typeReference,
|
||||
snakeCase
|
||||
)
|
||||
}
|
||||
|
||||
fun <T> parseArray(value: JsonArray, typeReference: TypeReference<List<T>>, snakeCase: Boolean = false): List<T> {
|
||||
return parseArray(value.encode(), typeReference, snakeCase)
|
||||
}
|
||||
|
||||
fun <T> parseArray(value: String, typeReference: TypeReference<List<T>>, snakeCase: Boolean = false): List<T> {
|
||||
if (snakeCase) {
|
||||
return objectMapperSnakeCaseDeserialization.readValue(value, typeReference)
|
||||
}
|
||||
return objectMapperDeserialization.readValue(value, typeReference)
|
||||
}
|
||||
}
|
||||
|
||||
private class CustomTypeResolverBuilder : ObjectMapper.DefaultTypeResolverBuilder(
|
||||
ObjectMapper.DefaultTyping.NON_FINAL,
|
||||
BasicPolymorphicTypeValidator.builder().build()
|
||||
) {
|
||||
override fun useForType(t: JavaType): Boolean {
|
||||
if (t.rawClass.isEnum) return false
|
||||
return t.rawClass.name.startsWith("app.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createObjectMapper(writeClassName: Boolean): ObjectMapper {
|
||||
val mapper: ObjectMapper = jacksonObjectMapper()
|
||||
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
mapper.registerModule(JavaTimeModule())
|
||||
if (writeClassName) {
|
||||
val typeResolver = CustomTypeResolverBuilder()
|
||||
typeResolver.init(JsonTypeInfo.Id.CLASS, null)
|
||||
typeResolver.inclusion(JsonTypeInfo.As.PROPERTY)
|
||||
typeResolver.typeProperty("@t")
|
||||
mapper.setDefaultTyping(typeResolver)
|
||||
}
|
||||
return mapper
|
||||
}
|
||||
|
||||
private fun createObjectMapperSnakeCase(writeClassName: Boolean): ObjectMapper {
|
||||
val mapper: ObjectMapper = jacksonObjectMapper()
|
||||
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
mapper.propertyNamingStrategy = PropertyNamingStrategies.SNAKE_CASE
|
||||
mapper.registerModule(JavaTimeModule())
|
||||
if (writeClassName) {
|
||||
val typeResolver = CustomTypeResolverBuilder()
|
||||
typeResolver.init(JsonTypeInfo.Id.CLASS, null)
|
||||
typeResolver.inclusion(JsonTypeInfo.As.PROPERTY)
|
||||
typeResolver.typeProperty("@t")
|
||||
mapper.setDefaultTyping(typeResolver)
|
||||
}
|
||||
return mapper
|
||||
}
|
||||
@ -0,0 +1,425 @@
|
||||
package org.aikrai.vertx.openapi
|
||||
|
||||
import cn.hutool.core.util.StrUtil
|
||||
import io.swagger.v3.core.util.Json
|
||||
import io.swagger.v3.oas.models.OpenAPI
|
||||
import io.swagger.v3.oas.models.Operation
|
||||
import io.swagger.v3.oas.models.PathItem
|
||||
import io.swagger.v3.oas.models.Paths
|
||||
import io.swagger.v3.oas.models.info.Info
|
||||
import io.swagger.v3.oas.models.media.Content
|
||||
import io.swagger.v3.oas.models.media.MediaType
|
||||
import io.swagger.v3.oas.models.media.Schema
|
||||
import io.swagger.v3.oas.models.parameters.Parameter
|
||||
import io.swagger.v3.oas.models.parameters.RequestBody
|
||||
import io.swagger.v3.oas.models.responses.ApiResponse
|
||||
import io.swagger.v3.oas.models.responses.ApiResponses
|
||||
import io.swagger.v3.oas.models.servers.Server
|
||||
import mu.KotlinLogging
|
||||
import org.aikrai.vertx.context.Controller
|
||||
import org.aikrai.vertx.context.CustomizeRequest
|
||||
import org.aikrai.vertx.context.D
|
||||
import org.aikrai.vertx.utlis.ClassUtil
|
||||
import org.reflections.Reflections
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.ParameterizedType
|
||||
import java.sql.Date
|
||||
import java.sql.Time
|
||||
import java.sql.Timestamp
|
||||
import java.time.*
|
||||
import kotlin.reflect.KParameter
|
||||
import kotlin.reflect.jvm.javaType
|
||||
import kotlin.reflect.jvm.kotlinFunction
|
||||
|
||||
/**
|
||||
* OpenAPI 规范生成器,用于生成 Vertx 路由的 OpenAPI 文档
|
||||
*/
|
||||
class OpenApiSpecGenerator {
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
// 用于跟踪已处理的类型
|
||||
private val processedTypes = mutableSetOf<Class<*>>()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 基础类型到 OpenAPI 类型的映射关系
|
||||
*/
|
||||
private val PRIMITIVE_TYPE_MAPPING = mapOf(
|
||||
// java.lang classes
|
||||
String::class.java to "string",
|
||||
Int::class.java to "integer",
|
||||
Integer::class.java to "integer",
|
||||
Long::class.java to "number",
|
||||
java.lang.Long::class.java to "number",
|
||||
Double::class.java to "number",
|
||||
java.lang.Double::class.java to "number",
|
||||
Boolean::class.java to "boolean",
|
||||
java.lang.Boolean::class.java to "boolean",
|
||||
List::class.java to "array",
|
||||
Array::class.java to "array",
|
||||
Collection::class.java to "array",
|
||||
// java.time classes
|
||||
Instant::class.java to "number",
|
||||
LocalDate::class.java to "string",
|
||||
LocalTime::class.java to "string",
|
||||
LocalDateTime::class.java to "string",
|
||||
ZonedDateTime::class.java to "number",
|
||||
OffsetDateTime::class.java to "number",
|
||||
Duration::class.java to "number",
|
||||
Period::class.java to "string",
|
||||
// java.sql classes
|
||||
Date::class.java to "string",
|
||||
Time::class.java to "number",
|
||||
Timestamp::class.java to "number",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 OpenAPI 规范的 JSON 字符串
|
||||
*
|
||||
* @param title API 文档标题
|
||||
* @param version API 版本号
|
||||
* @param serverUrl 服务器 URL
|
||||
* @return 格式化后的 OpenAPI 规范 JSON 字符串
|
||||
*/
|
||||
fun genOpenApiSpecStr(title: String, version: String, serverUrl: String): String {
|
||||
return Json.pretty(generateOpenApiSpec(title, version, serverUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 OpenAPI 规范对象
|
||||
*
|
||||
* @param title API 文档标题
|
||||
* @param version API 版本号
|
||||
* @param serverUrl 服务器 URL
|
||||
* @return OpenAPI 规范对象
|
||||
*/
|
||||
private fun generateOpenApiSpec(title: String, version: String, serverUrl: String): OpenAPI {
|
||||
logger.info("Generating OpenAPI Specification for Vertx routes")
|
||||
return OpenAPI().apply {
|
||||
info = Info().apply {
|
||||
this.title = title
|
||||
this.version = version
|
||||
}
|
||||
servers = listOf(Server().apply { url = serverUrl })
|
||||
paths = generatePaths()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 API 路径信息
|
||||
*
|
||||
* @return Paths 对象,包含所有 API 路径的定义
|
||||
*/
|
||||
private fun generatePaths(): Paths {
|
||||
val paths = Paths()
|
||||
// 获取所有带有 @Controller 注解的类
|
||||
val packageName = ClassUtil.getMainClass()?.packageName
|
||||
val controllerClassSet = Reflections(packageName).getTypesAnnotatedWith(Controller::class.java)
|
||||
ClassUtil.getPublicMethods(controllerClassSet).forEach { (controllerClass, methods) ->
|
||||
val controllerInfo = extractControllerInfo(controllerClass)
|
||||
methods.forEach { method ->
|
||||
val pathInfo = generatePathInfo(method, controllerInfo)
|
||||
paths.addPathItem(pathInfo.path, pathInfo.pathItem)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* 从控制器类中提取控制器相关信息
|
||||
*
|
||||
* @param controllerClass 控制器类
|
||||
* @return 包含控制器名称、前缀和标签的 ControllerInfo 对象
|
||||
*/
|
||||
private fun extractControllerInfo(controllerClass: Class<*>): ControllerInfo {
|
||||
val controllerAnnotation = controllerClass.getAnnotation(Controller::class.java)
|
||||
val dAnnotation = controllerClass.getAnnotation(D::class.java)
|
||||
val controllerName = controllerClass.simpleName.removeSuffix("Controller")
|
||||
|
||||
return ControllerInfo(
|
||||
name = controllerName,
|
||||
prefix = controllerAnnotation?.prefix?.takeIf { it.isNotBlank() } ?: controllerName,
|
||||
tag = "${dAnnotation?.name ?: ""} $controllerName"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成路径信息
|
||||
*
|
||||
* @param method 方法对象
|
||||
* @param controllerInfo 控制器信息
|
||||
* @return 包含路径和路径项的 PathInfo 对象
|
||||
*/
|
||||
private fun generatePathInfo(method: Method, controllerInfo: ControllerInfo): PathInfo {
|
||||
val methodDAnnotation = method.getAnnotation(D::class.java)
|
||||
val path = buildPath(controllerInfo.prefix, method.name)
|
||||
|
||||
val operation = Operation().apply {
|
||||
operationId = method.name
|
||||
summary = "${methodDAnnotation?.name ?: ""} $path"
|
||||
description = methodDAnnotation?.caption ?: summary
|
||||
|
||||
// 分离请求体参数和普通参数
|
||||
val allParameters = getParameters(method)
|
||||
val (bodyParams, queryParams) = allParameters.partition { it.`in` == "body" }
|
||||
// 设置查询参数
|
||||
parameters = queryParams
|
||||
// 设置请求体
|
||||
if (bodyParams.isNotEmpty()) {
|
||||
requestBody = RequestBody().apply {
|
||||
content = bodyParams.first().content
|
||||
required = bodyParams.first().required
|
||||
description = bodyParams.first().description
|
||||
}
|
||||
}
|
||||
responses = generateResponsesFromReturnType(method)
|
||||
deprecated = false
|
||||
security = listOf()
|
||||
tags = listOf(controllerInfo.tag)
|
||||
}
|
||||
|
||||
val pathItem = PathItem().apply {
|
||||
operation(getHttpMethod(method), operation)
|
||||
}
|
||||
|
||||
return PathInfo(path, pathItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 API 路径
|
||||
*
|
||||
* @param controllerPrefix 控制器前缀
|
||||
* @param methodName 方法名
|
||||
* @return 格式化后的 API 路径
|
||||
*/
|
||||
private fun buildPath(controllerPrefix: String, methodName: String): String {
|
||||
return (
|
||||
"/${StrUtil.lowerFirst(StrUtil.toCamelCase(controllerPrefix))}/" +
|
||||
StrUtil.lowerFirst(StrUtil.toCamelCase(methodName))
|
||||
).replace("//", "/")
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据方法返回值类型生成响应对象
|
||||
*
|
||||
* @param method 方法对象
|
||||
* @return ApiResponses 对象
|
||||
*/
|
||||
private fun generateResponsesFromReturnType(method: Method): ApiResponses {
|
||||
val returnType = method.kotlinFunction?.returnType?.javaType
|
||||
val schema = when (returnType) {
|
||||
// 处理泛型返回类型
|
||||
is ParameterizedType -> {
|
||||
val rawType = returnType.rawType as Class<*>
|
||||
val typeArguments = returnType.actualTypeArguments
|
||||
when {
|
||||
// 处理集合类型
|
||||
Collection::class.java.isAssignableFrom(rawType) -> {
|
||||
Schema<Any>().apply {
|
||||
type = "array"
|
||||
items = generateSchema(
|
||||
typeArguments[0].let {
|
||||
when (it) {
|
||||
is Class<*> -> it
|
||||
is ParameterizedType -> it.rawType as Class<*>
|
||||
else -> Any::class.java
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
// 可以添加其他泛型类型的处理
|
||||
else -> generateSchema(rawType)
|
||||
}
|
||||
}
|
||||
// 处理普通类型
|
||||
is Class<*> -> generateSchema(returnType)
|
||||
else -> Schema<Any>().type("object")
|
||||
}
|
||||
|
||||
return ApiResponses().addApiResponse(
|
||||
"200",
|
||||
ApiResponse().apply {
|
||||
description = "OK"
|
||||
content = Content().addMediaType(
|
||||
"application/json",
|
||||
MediaType().schema(schema)
|
||||
)
|
||||
headers = mapOf()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取方法的参数列表
|
||||
*
|
||||
* @param method 方法对象
|
||||
* @return OpenAPI 参数列表
|
||||
*/
|
||||
private fun getParameters(method: Method): List<Parameter> {
|
||||
return method.kotlinFunction?.parameters
|
||||
?.mapNotNull { parameter -> generateParameter(parameter) }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 OpenAPI 参数对象
|
||||
*
|
||||
* @param parameter Kotlin 参数对象
|
||||
* @return OpenAPI 参数对象,如果无法生成则返回 null
|
||||
*/
|
||||
private fun generateParameter(parameter: KParameter): Parameter? {
|
||||
if (parameter.kind == KParameter.Kind.INSTANCE) return null
|
||||
val type =
|
||||
(parameter.type.javaType as? Class<*>) ?: (parameter.type.javaType as? ParameterizedType)?.rawType as? Class<*>
|
||||
?: return null
|
||||
|
||||
val paramName = parameter.name ?: return null
|
||||
val annotation = parameter.annotations.filterIsInstance<D>().firstOrNull()
|
||||
|
||||
// 检查是否为自定义数据类
|
||||
if (!parameter.type.classifier.toString().startsWith("class kotlin.") &&
|
||||
!(parameter.type.javaType as Class<*>).isEnum &&
|
||||
parameter.type.javaType is Class<*>
|
||||
) {
|
||||
// 创建请求体参数
|
||||
return Parameter().apply {
|
||||
name = annotation?.name?.takeIf { it.isNotBlank() } ?: paramName
|
||||
required = !parameter.type.isMarkedNullable
|
||||
description = annotation?.caption?.takeIf { it.isNotBlank() } ?: name
|
||||
`in` = "body"
|
||||
schema = generateSchema(type)
|
||||
content = Content().addMediaType(
|
||||
"application/json",
|
||||
MediaType().schema(schema)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理普通参数
|
||||
return Parameter().apply {
|
||||
name = annotation?.name?.takeIf { it.isNotBlank() } ?: paramName
|
||||
required = !parameter.type.isMarkedNullable
|
||||
description = annotation?.caption?.takeIf { it.isNotBlank() } ?: name
|
||||
allowEmptyValue = parameter.type.isMarkedNullable
|
||||
`in` = "query"
|
||||
schema = generateSchema(type)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 OpenAPI Schema 对象
|
||||
*
|
||||
* @param type 参数类型
|
||||
* @return OpenAPI Schema 对象
|
||||
*/
|
||||
private fun generateSchema(type: Class<*>): Schema<Any> {
|
||||
// 如果该类型已经处理过,则返回一个空的 Schema,避免循环引用
|
||||
if (processedTypes.contains(type)) {
|
||||
return Schema<Any>().apply {
|
||||
this.type = "object"
|
||||
description = "Circular reference detected"
|
||||
}
|
||||
}
|
||||
// 将当前类型添加到已处理集合中
|
||||
processedTypes.add(type)
|
||||
return when {
|
||||
// 处理基础类型
|
||||
PRIMITIVE_TYPE_MAPPING.containsKey(type) -> Schema<Any>().apply {
|
||||
this.type = PRIMITIVE_TYPE_MAPPING[type]
|
||||
deprecated = false
|
||||
}
|
||||
// 处理枚举类型
|
||||
type.isEnum -> Schema<Any>().apply {
|
||||
this.type = "string"
|
||||
enum = type.enumConstants?.map { it.toString() }
|
||||
}
|
||||
type.name.startsWith("java.lang") || type.name.startsWith("java.time") || type.name.startsWith("java.sql") -> Schema<Any>().apply {
|
||||
this.type = type.simpleName.lowercase()
|
||||
deprecated = false
|
||||
}
|
||||
type.name.startsWith("java") -> Schema<Any>().apply {
|
||||
this.type = type.simpleName.lowercase()
|
||||
deprecated = false
|
||||
}
|
||||
// 处理自定义对象
|
||||
else -> Schema<Any>().apply {
|
||||
this.type = "object"
|
||||
properties = type.declaredFields
|
||||
.filter { !it.isSynthetic }
|
||||
.associate { field ->
|
||||
field.isAccessible = true
|
||||
field.name to generateSchema(field.type)
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
// 处理完后,从已处理集合中移除当前类型
|
||||
processedTypes.remove(type)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成默认的 API 响应对象
|
||||
*
|
||||
* @return 包含默认响应的 ApiResponses 对象
|
||||
*/
|
||||
private fun generateDefaultResponses(): ApiResponses {
|
||||
return ApiResponses().addApiResponse(
|
||||
"200",
|
||||
ApiResponse().apply {
|
||||
description = "OK"
|
||||
content = Content().addMediaType(
|
||||
"*/*",
|
||||
MediaType().schema(Schema<Any>().type("object"))
|
||||
)
|
||||
headers = mapOf()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取方法对应的 HTTP 方法
|
||||
*
|
||||
* @param method 方法对象
|
||||
* @return PathItem.HttpMethod 枚举值
|
||||
*/
|
||||
private fun getHttpMethod(method: Method): PathItem.HttpMethod {
|
||||
val api = method.getAnnotation(CustomizeRequest::class.java)
|
||||
return if (api != null) {
|
||||
when (api.method.uppercase()) {
|
||||
"GET" -> PathItem.HttpMethod.GET
|
||||
"PUT" -> PathItem.HttpMethod.PUT
|
||||
"DELETE" -> PathItem.HttpMethod.DELETE
|
||||
"PATCH" -> PathItem.HttpMethod.PATCH
|
||||
else -> PathItem.HttpMethod.POST
|
||||
}
|
||||
} else {
|
||||
PathItem.HttpMethod.POST
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 控制器信息数据类
|
||||
*
|
||||
* @property name 控制器名称
|
||||
* @property prefix 控制器路径前缀
|
||||
* @property tag API 文档标签
|
||||
*/
|
||||
private data class ControllerInfo(
|
||||
val name: String,
|
||||
val prefix: String,
|
||||
val tag: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 路径信息数据类
|
||||
*
|
||||
* @property path API 路径
|
||||
* @property pathItem OpenAPI 路径项对象
|
||||
*/
|
||||
private data class PathInfo(
|
||||
val path: String,
|
||||
val pathItem: PathItem
|
||||
)
|
||||
}
|
||||
84
vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/ClassUtil.kt
Normal file
84
vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/ClassUtil.kt
Normal file
@ -0,0 +1,84 @@
|
||||
package org.aikrai.vertx.utlis
|
||||
|
||||
import cn.hutool.core.util.ReflectUtil
|
||||
import java.io.File
|
||||
import java.lang.reflect.Method
|
||||
import java.util.*
|
||||
import java.util.jar.JarFile
|
||||
import kotlin.reflect.KMutableProperty1
|
||||
import kotlin.reflect.full.memberProperties
|
||||
import kotlin.reflect.jvm.javaGetter
|
||||
import kotlin.reflect.jvm.javaSetter
|
||||
|
||||
object ClassUtil {
|
||||
|
||||
/**
|
||||
* 获取应用程序的主类
|
||||
*
|
||||
* @return 主类的 Class 对象,如果未找到则返回 null
|
||||
*/
|
||||
fun getMainClass(): Class<*>? {
|
||||
val classLoader = ServiceLoader.load(ClassLoader::class.java).firstOrNull()
|
||||
?: Thread.currentThread().contextClassLoader
|
||||
val mainCommand = System.getProperty("sun.java.command")
|
||||
if (mainCommand != null) {
|
||||
val mainClassName: String? = if (mainCommand.endsWith(".jar")) {
|
||||
try {
|
||||
val jarFilePath = mainCommand.split(" ").first()
|
||||
val jarFile = File(jarFilePath)
|
||||
if (jarFile.exists()) {
|
||||
JarFile(jarFile).use { jar ->
|
||||
jar.manifest.mainAttributes.getValue("Main-Class")
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
} else {
|
||||
// mainCommand 是类名
|
||||
mainCommand.split(" ").first()
|
||||
}
|
||||
|
||||
if (!mainClassName.isNullOrEmpty()) {
|
||||
return try {
|
||||
classLoader.loadClass(mainClassName)
|
||||
} catch (e: ClassNotFoundException) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取类中的所有公共方法
|
||||
*
|
||||
* @return 包含控制器类和其方法数组的列表
|
||||
*/
|
||||
fun getPublicMethods(classSet: Set<Class<*>>): List<Pair<Class<*>, Array<Method>>> {
|
||||
return classSet.map { clazz ->
|
||||
val kClass = clazz.kotlin
|
||||
// 获取所有属性的 getter 方法
|
||||
val getters: Set<Method> = kClass.memberProperties.mapNotNull { it.javaGetter }.toSet()
|
||||
// 获取所有可变属性的 setter 方法
|
||||
val setters: Set<Method> = kClass.memberProperties
|
||||
// 只处理可变属性
|
||||
.filterIsInstance<KMutableProperty1<*, *>>()
|
||||
.mapNotNull { it.javaSetter }.toSet()
|
||||
// 合并 getter 和 setter 方法
|
||||
val propertyAccessors: Set<Method> = getters + setters
|
||||
// 获取所有公共方法
|
||||
val allPublicMethods = ReflectUtil.getPublicMethods(clazz)
|
||||
// 过滤掉不需要的方法
|
||||
val filteredMethods = allPublicMethods.filter { method ->
|
||||
// 1. 排除合成方法 2. 仅包括在当前类中声明的方法
|
||||
!method.isSynthetic && method.declaringClass == clazz && !propertyAccessors.contains(method)
|
||||
}.toTypedArray()
|
||||
Pair(clazz, filteredMethods)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Entity.kt
Normal file
24
vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Entity.kt
Normal file
@ -0,0 +1,24 @@
|
||||
package org.aikrai.vertx.utlis
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.time.Instant
|
||||
|
||||
@MappedSuperclass
|
||||
open class Entity {
|
||||
|
||||
@Column(name = "create_by", length = 64)
|
||||
var createBy: String? = null
|
||||
|
||||
@Column(name = "create_time")
|
||||
var createTime: Instant? = null
|
||||
|
||||
@Column(name = "update_by", length = 64)
|
||||
var updateBy: String? = null
|
||||
|
||||
@Column(name = "update_time")
|
||||
var updateTime: Instant? = null
|
||||
|
||||
@Version
|
||||
@Column(name = "version")
|
||||
var version: Long = 0
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package org.aikrai.vertx.utlis
|
||||
|
||||
import io.vertx.core.json.JsonObject
|
||||
|
||||
object FlattenUtil {
|
||||
|
||||
fun flattenMap(map: Map<String, Any>, parentKey: String = "", separator: String = "."): Map<String, Any> {
|
||||
val flatMap = mutableMapOf<String, Any>()
|
||||
for ((key, value) in map) {
|
||||
val newKey = if (parentKey.isEmpty()) key else "$parentKey$separator$key"
|
||||
when (value) {
|
||||
is Map<*, *> -> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
flatMap.putAll(flattenMap(value as Map<String, Any>, newKey, separator))
|
||||
}
|
||||
|
||||
else -> flatMap[newKey] = value
|
||||
}
|
||||
}
|
||||
return flatMap
|
||||
}
|
||||
|
||||
fun flattenJsonObject(obj: JsonObject, parentKey: String = "", separator: String = "."): Map<String, Any> {
|
||||
val flatMap = mutableMapOf<String, Any>()
|
||||
for (key in obj.fieldNames()) {
|
||||
val value = obj.getValue(key)
|
||||
val newKey = if (parentKey.isEmpty()) key else "$parentKey$separator$key"
|
||||
when (value) {
|
||||
is JsonObject -> {
|
||||
flatMap.putAll(flattenJsonObject(value, newKey, separator))
|
||||
}
|
||||
|
||||
is Iterable<*> -> {
|
||||
// 如果值是数组,可以根据需要展开或处理
|
||||
flatMap[newKey] = value
|
||||
}
|
||||
|
||||
else -> {
|
||||
flatMap[newKey] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return flatMap
|
||||
}
|
||||
}
|
||||
82
vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/LangUtil.kt
Normal file
82
vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/LangUtil.kt
Normal file
@ -0,0 +1,82 @@
|
||||
package org.aikrai.vertx.utlis
|
||||
|
||||
import io.vertx.core.MultiMap
|
||||
import mu.KotlinLogging
|
||||
import java.sql.Timestamp
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import kotlin.collections.forEach
|
||||
import kotlin.collections.set
|
||||
import kotlin.stackTraceToString
|
||||
import kotlin.text.isNullOrBlank
|
||||
import kotlin.text.trim
|
||||
|
||||
object LangUtil {
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
fun <R> letTry(clause: () -> R): R? {
|
||||
return try {
|
||||
clause()
|
||||
} catch (ex: Throwable) {
|
||||
logger.error { ex.stackTraceToString() }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun <T, R> T?.letIf(clause: (T) -> R): R? {
|
||||
if (this != null) {
|
||||
return clause(this)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun String?.defaultAsNull(): String? {
|
||||
if (this.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
return this.trim()
|
||||
}
|
||||
|
||||
fun Long?.defaultAsNull(): Long? {
|
||||
if (this == 0L) {
|
||||
return null
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun Int?.defaultAsNull(): Int? {
|
||||
if (this == 0) {
|
||||
return null
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun Double?.defaultAsNull(): Double? {
|
||||
if (this == 0.0) {
|
||||
return null
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun MultiMap.toMap(): Map<String, Any> {
|
||||
val map = mutableMapOf<String, Any>()
|
||||
forEach { map[it.key] == it.value }
|
||||
return map
|
||||
}
|
||||
|
||||
fun MultiMap.toStringMap(): Map<String, String> {
|
||||
val map = mutableMapOf<String, String>()
|
||||
forEach { map[it.key] = it.value.toString() }
|
||||
return map
|
||||
}
|
||||
|
||||
fun offsetDateTime(epochMilli: Long?): OffsetDateTime? {
|
||||
if (epochMilli == null || epochMilli == 0L) return null
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(epochMilli), ZoneId.systemDefault())
|
||||
}
|
||||
|
||||
fun timestamp(): Timestamp {
|
||||
return Timestamp.from(Instant.now())
|
||||
}
|
||||
}
|
||||
78
vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Lazyload.kt
Normal file
78
vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Lazyload.kt
Normal file
@ -0,0 +1,78 @@
|
||||
package org.aikrai.vertx.utlis
|
||||
|
||||
import io.vertx.core.Closeable
|
||||
import io.vertx.core.Promise
|
||||
import io.vertx.core.Vertx
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
open class Lazyload<T>(private val block: suspend () -> T) {
|
||||
protected var value: T? = null
|
||||
private val mutex = Mutex()
|
||||
|
||||
fun dirty() {
|
||||
value = null
|
||||
}
|
||||
|
||||
open suspend fun get(): T {
|
||||
if (value == null) {
|
||||
mutex.withLock {
|
||||
if (value == null) {
|
||||
value = block()
|
||||
}
|
||||
}
|
||||
}
|
||||
return value!!
|
||||
}
|
||||
}
|
||||
|
||||
// timeout: milliseconds
|
||||
class Autoload<T>(
|
||||
private val vertx: Vertx,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val timeout: Long,
|
||||
private val block: suspend () -> T
|
||||
) : Lazyload<T>(block), Closeable {
|
||||
|
||||
private var periodicId = vertx.setPeriodic(timeout) {
|
||||
coroutineScope.launch {
|
||||
value = block()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close(completion: Promise<Void>?) {
|
||||
vertx.cancelTimer(periodicId)
|
||||
completion?.complete()
|
||||
}
|
||||
}
|
||||
|
||||
class Aliveload<T>(
|
||||
private val vertx: Vertx,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val timeout: Long,
|
||||
private val block: suspend () -> T
|
||||
) : Lazyload<T>(block), Closeable {
|
||||
|
||||
private var needsUpdate = true
|
||||
|
||||
private var periodicId = vertx.setPeriodic(timeout) {
|
||||
if (needsUpdate) {
|
||||
needsUpdate = false
|
||||
coroutineScope.launch {
|
||||
value = block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun get(): T {
|
||||
needsUpdate = true
|
||||
return super.get()
|
||||
}
|
||||
|
||||
override fun close(completion: Promise<Void>?) {
|
||||
vertx.cancelTimer(periodicId)
|
||||
completion?.complete()
|
||||
}
|
||||
}
|
||||
48
vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Meta.kt
Normal file
48
vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Meta.kt
Normal file
@ -0,0 +1,48 @@
|
||||
package org.aikrai.vertx.utlis
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import org.aikrai.vertx.jackson.JsonUtil
|
||||
|
||||
@JsonIgnoreProperties("localizedMessage", "suppressed", "stackTrace", "cause")
|
||||
class Meta(
|
||||
val name: String,
|
||||
override val message: String = "",
|
||||
val data: Any? = null
|
||||
) : RuntimeException(message, null, false, false) {
|
||||
|
||||
fun stackTraceToString(): String {
|
||||
return JsonUtil.toJsonStr(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun failure(name: String, message: String): Meta =
|
||||
Meta(name, message)
|
||||
|
||||
fun unimplemented(message: String): Meta =
|
||||
Meta("unimplemented", message)
|
||||
|
||||
fun unauthorized(message: String): Meta =
|
||||
Meta("unauthorized", message)
|
||||
|
||||
fun timeout(message: String): Meta =
|
||||
Meta("timeout", message)
|
||||
|
||||
fun requireArgument(argument: String, message: String): Meta =
|
||||
Meta("required_argument:$argument", message)
|
||||
|
||||
fun invalidArgument(argument: String, message: String): Meta =
|
||||
Meta("invalid_argument:$argument", message)
|
||||
|
||||
fun notFound(argument: String, message: String): Meta =
|
||||
Meta("not_found:$argument", message)
|
||||
|
||||
fun badRequest(message: String): Meta =
|
||||
Meta("bad_request", message)
|
||||
|
||||
fun notSupported(message: String): Meta =
|
||||
Meta("not_supported", message)
|
||||
|
||||
fun forbidden(message: String): Meta =
|
||||
Meta("forbidden", message)
|
||||
}
|
||||
}
|
||||
42
vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/TimeUtil.kt
Normal file
42
vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/TimeUtil.kt
Normal file
@ -0,0 +1,42 @@
|
||||
package org.aikrai.vertx.utlis
|
||||
|
||||
import java.sql.Timestamp
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object TimeUtil {
|
||||
private class Time {
|
||||
|
||||
@Volatile
|
||||
var currentTimeMillis: Long
|
||||
|
||||
init {
|
||||
currentTimeMillis = System.currentTimeMillis()
|
||||
scheduleTick()
|
||||
}
|
||||
|
||||
private fun scheduleTick() {
|
||||
ScheduledThreadPoolExecutor(1) { runnable: Runnable? ->
|
||||
val thread = Thread(runnable, "current-time-millis")
|
||||
thread.isDaemon = true
|
||||
thread
|
||||
}.scheduleAtFixedRate(
|
||||
{ currentTimeMillis = System.currentTimeMillis() },
|
||||
1,
|
||||
1,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val instance = Time()
|
||||
|
||||
val now: Long
|
||||
get() {
|
||||
return instance.currentTimeMillis
|
||||
}
|
||||
|
||||
fun now(): Timestamp {
|
||||
return Timestamp(instance.currentTimeMillis)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user