vertx-pj:0.0.2

This commit is contained in:
AiKrai 2025-01-26 18:03:51 +08:00
parent 9f2105caf8
commit bc505e93f2
74 changed files with 2340 additions and 898 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ build/
!**/src/test/**/build/
/config
/gradle
log/
### IntelliJ IDEA ###
.idea

View File

@ -9,6 +9,7 @@ group = "com.demo"
version = "1.0.0-SNAPSHOT"
val vertxVersion = "4.5.11"
val junitJupiterVersion = "5.9.1"
application {
mainClass.set("app.Application")
@ -68,11 +69,12 @@ spotless {
}
dependencies {
implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.jar"))))
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(kotlin("stdlib-jdk8"))
implementation("io.vertx:vertx-lang-kotlin:$vertxVersion")
implementation("io.vertx:vertx-lang-kotlin-coroutines:$vertxVersion")
implementation("io.vertx:vertx-core:$vertxVersion")
@ -84,13 +86,14 @@ dependencies {
implementation("io.vertx:vertx-mysql-client:$vertxVersion")
implementation("io.vertx:vertx-sql-client-templates:$vertxVersion")
implementation("io.vertx:vertx-auth-jwt:$vertxVersion")
implementation("io.vertx:vertx-redis-client:$vertxVersion")
implementation("com.google.inject:guice:5.1.0")
implementation("org.reflections:reflections:0.9.12")
implementation("org.reflections:reflections:0.10.2")
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")
@ -101,15 +104,18 @@ dependencies {
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("jakarta.persistence:jakarta.persistence-api:3.2.0")
// implementation("jakarta.validation:jakarta.validation-api:3.1.0")
// db
implementation("org.postgresql:postgresql:42.7.5")
implementation("com.ongres.scram:client:2.1")
// 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"))))
testImplementation("io.vertx:vertx-junit5")
testImplementation("org.junit.jupiter:junit-jupiter:$junitJupiterVersion")
testImplementation("org.mockito:mockito-core:5.15.2")
testImplementation("org.mockito:mockito-junit-jupiter:5.15.2")
}

View File

@ -5,6 +5,7 @@ import app.verticle.MainVerticle
import io.vertx.core.Vertx
import kotlinx.coroutines.runBlocking
import mu.KotlinLogging
import org.aikrai.vertx.config.Config
object Application {
private val logger = KotlinLogging.logger { }
@ -13,15 +14,16 @@ object Application {
fun main(args: Array<String>) {
runBlocking {
val vertx = Vertx.vertx()
Config.init(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" }
val mainVerticle = getIt.getInstance(MainVerticle::class.java)
vertx.deployVerticle(mainVerticle).onComplete {
if (it.failed()) {
logger.error { "MainVerticle startup failed: ${it.cause()?.stackTraceToString()}" }
} else {
logger.info { "MainVerticle startup successfully" }
}
}
}
}
}
}

View File

@ -1,4 +1,4 @@
package org.aikrai.vertx.config
package app.config
import io.vertx.mysqlclient.MySQLException
import io.vertx.pgclient.PgException
@ -40,7 +40,7 @@ object FailureParser {
fun parse(statusCode: Int, error: Throwable): Failure {
return when (error) {
is SQLException -> Failure(statusCode, Meta.failure(error.javaClass.name, "执行错误"))
is SQLException -> Failure(statusCode, Meta.error(error.javaClass.name, "执行错误"))
else -> Failure(statusCode, error.toMeta())
}
}

View File

@ -9,8 +9,6 @@ import io.vertx.core.Vertx
import io.vertx.core.http.HttpServer
import io.vertx.core.http.HttpServerOptions
import io.vertx.ext.auth.jwt.JWTAuth
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
@ -22,8 +20,7 @@ import org.aikrai.vertx.config.DefaultScope
import org.aikrai.vertx.db.tx.TxMgrHolder.initTxMgr
object InjectConfig {
suspend fun configure(vertx: Vertx): Injector {
Config.init(vertx)
fun configure(vertx: Vertx): Injector {
return Guice.createInjector(InjectorModule(vertx))
}
}
@ -40,7 +37,6 @@ class InjectorModule(
}
bind(Vertx::class.java).toInstance(vertx)
bind(CoroutineScope::class.java).toInstance(coroutineScope)
bind(HttpServer::class.java).toInstance(vertx.createHttpServer(HttpServerOptions()))
bind(Snowflake::class.java).toInstance(IdUtil.getSnowflake())
bind(JWTAuth::class.java).toProvider(JWTAuthProvider::class.java).`in`(Singleton::class.java)
@ -57,7 +53,6 @@ class InjectorModule(
// val user = configMap["databases.username"].toString()
// val password = configMap["databases.password"].toString()
// val dbMap = Config.getKey("databases") as Map<String, String>
val type = Config.getKey("databases.type").toString()
val name = Config.getKey("databases.name").toString()
val host = Config.getKey("databases.host").toString()
val port = Config.getKey("databases.port").toString()
@ -65,29 +60,13 @@ class InjectorModule(
val password = Config.getKey("databases.password").toString()
val poolOptions = PoolOptions().setMaxSize(10)
val 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")
}
return pool
val clientOptions = PgConnectOptions()
.setHost(host)
.setPort(port.toInt())
.setDatabase(name)
.setUser(user)
.setPassword(password)
.setTcpKeepAlive(true)
return PgBuilder.pool().connectingTo(clientOptions).with(poolOptions).using(vertx).build()
}
}

View File

@ -0,0 +1,54 @@
package app.config
import org.aikrai.vertx.constant.HttpStatus
data class RespBean(
val code: Int,
val message: String,
val data: Any?
) {
var requestId: Long = -1L
companion object {
/**
* 创建一个成功的响应
*
* @param data 响应数据
* @return RespBean 实例
*/
fun success(data: Any? = null): RespBean {
val code = when (data) {
null -> HttpStatus.NO_CONTENT
else -> HttpStatus.SUCCESS
}
return RespBean(code, "Success", data)
}
/**
* 创建一个失败的响应
*
* @param status 状态码
* @param message 错误消息
* @return RespBean 实例
*/
fun failure(message: String, data: Any? = null): RespBean {
return failure(HttpStatus.ERROR, message, data)
}
fun failure(code: Int, message: String, data: Any? = null): RespBean {
return RespBean(HttpStatus.ERROR, message, data)
}
// 访问受限,授权过期
fun forbidden(message: String?): RespBean {
return failure(HttpStatus.FORBIDDEN, message ?: "Restricted access, expired authorizations")
}
// 未授权
fun unauthorized(message: String?): RespBean {
return failure(HttpStatus.UNAUTHORIZED, message ?: "Unauthorized")
}
}
}

View File

@ -1,31 +0,0 @@
package app.config.auth
import app.config.Constant
import app.domain.user.UserRepository
import app.util.CacheUtil
import com.google.inject.Inject
import io.vertx.ext.auth.jwt.JWTAuth
import org.aikrai.vertx.auth.Attributes
import org.aikrai.vertx.auth.AuthUser
import org.aikrai.vertx.auth.Principal
import org.aikrai.vertx.auth.TokenUtil
class AuthHandler @Inject constructor(
private val jwtAuth: JWTAuth,
private val userRepository: UserRepository,
private val cacheUtil: CacheUtil
) {
suspend fun handle(token: String): AuthUser? {
val userInfo = TokenUtil.authenticate(jwtAuth, token) ?: return null
val userId = userInfo.principal().getString("id").toLong()
val user = cacheUtil.get(Constant.USER + userId) ?: userRepository.get(userId)?.let {
cacheUtil.put(Constant.USER + userId, it)
} ?: return null
return AuthUser(
Principal(userId, user),
// get roles and permissions from database
Attributes(setOf("admin"), setOf("user:list")),
)
}
}

View File

@ -1,5 +1,6 @@
package app.config.auth
import cn.hutool.core.lang.Snowflake
import io.vertx.ext.web.RoutingContext
import io.vertx.ext.web.handler.AuthenticationHandler
import kotlinx.coroutines.CoroutineScope
@ -7,41 +8,34 @@ import kotlinx.coroutines.launch
import org.aikrai.vertx.utlis.Meta
class JwtAuthenticationHandler(
private val coroutineScope: CoroutineScope,
private val authHandler: AuthHandler,
private val context: String,
val scope: CoroutineScope,
val tokenService: TokenService,
val context: String,
val snowflake: Snowflake
) : AuthenticationHandler {
var exclude = mutableListOf(
"/auth/**",
)
override fun handle(event: RoutingContext) {
event.put("requestId", snowflake.nextId())
val path = event.request().path().replace("$context/", "/").replace("//", "/")
if (isPathExcluded(path, exclude)) {
if (isPathExcluded(path, anonymous)) {
event.next()
return
}
val authorization = event.request().getHeader("Authorization") ?: null
if (authorization == null || !authorization.startsWith("token ")) {
event.fail(401, Meta.unauthorized("无效Token"))
return
}
val token = authorization.substring(6)
coroutineScope.launch {
val authUser = authHandler.handle(token)
if (authUser != null) {
event.setUser(authUser)
scope.launch {
try {
val user = tokenService.getLoginUser(event)
tokenService.verifyToken(user)
event.setUser(user)
event.next()
} else {
event.fail(401, Meta.unauthorized("token"))
} catch (e: Exception) {
event.fail(401, Meta.unauthorized(e.message ?: "token"))
}
}
}
var anonymous = mutableListOf(
"/apidoc.json"
)
private fun isPathExcluded(path: String, excludePatterns: List<String>): Boolean {
for (pattern in excludePatterns) {
val regexPattern = pattern

View File

@ -0,0 +1,68 @@
package app.config.auth
import app.config.RespBean
import com.google.inject.Singleton
import io.vertx.ext.web.RoutingContext
import mu.KotlinLogging
import org.aikrai.vertx.config.resp.ResponseHandlerInterface
import org.aikrai.vertx.constant.HttpStatus
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.utlis.Meta
@Singleton
class ResponseHandler: ResponseHandlerInterface {
private val logger = KotlinLogging.logger { }
override suspend fun normal(
ctx: RoutingContext,
responseData: Any?,
customizeResponse: Boolean
) {
val requestId = ctx.get<Long>("requestId") ?: -1L
val code: Int
val resStr = when (responseData) {
is Unit -> {
code = HttpStatus.NO_CONTENT
null
}
is RespBean -> {
code = responseData.code
responseData.requestId = requestId
JsonUtil.toJsonStr(responseData)
}
else -> {
val respBean = RespBean.success(responseData).apply {
this.requestId = requestId
}
code = respBean.code
JsonUtil.toJsonStr(respBean)
}
}
ctx.put("responseData", resStr)
if (customizeResponse) return
ctx.response()
.setStatusCode(code)
.putHeader("Content-Type", "application/json")
.end(resStr)
}
// 业务异常处理
override suspend fun exception(ctx: RoutingContext, e: Exception) {
logger.error { "${ctx.request().uri()}: ${e.stackTraceToString()}" }
val resObj = when(e) {
is Meta -> {
RespBean.failure("${e.name}:${e.message}", e.data)
}
else -> {
RespBean.failure("${e.javaClass.simpleName}${if (e.message != null) ":${e.message}" else ""}")
}
}
val resStr = JsonUtil.toJsonStr(resObj)
ctx.put("responseData", resStr)
ctx.response()
.setStatusCode(HttpStatus.ERROR)
.putHeader("Content-Type", "application/json")
.end(resStr)
}
}

View File

@ -0,0 +1,69 @@
package app.config.auth
import app.domain.account.AccountRepository
import app.port.reids.RedisClient
import cn.hutool.core.lang.Snowflake
import cn.hutool.core.util.IdUtil
import com.google.inject.Inject
import com.google.inject.Singleton
import io.vertx.core.cli.UsageMessageFormatter
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.ext.web.RoutingContext
import io.vertx.kotlin.coroutines.coAwait
import mu.KotlinLogging
import org.aikrai.vertx.auth.AuthUser
import org.aikrai.vertx.constant.CacheConstants
import org.aikrai.vertx.constant.Constants
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.utlis.Meta
@Singleton
class TokenService @Inject constructor(
private val snowflake: Snowflake,
private val jwtAuth: JWTAuth,
private val redisClient: RedisClient,
private val accountRepository: AccountRepository,
) {
private val logger = KotlinLogging.logger { }
private val expireSeconds = 60 * 60 * 24 * 7
suspend fun getLoginUser(ctx: RoutingContext): AuthUser {
val request = ctx.request()
val authorization = request.getHeader("Authorization")
if (UsageMessageFormatter.isNullOrEmpty(authorization) || !authorization.startsWith("token ")) {
throw Meta.unauthorized("token")
}
val token = authorization.substring(6)
val user = parseToken(token) ?: throw Meta.unauthorized("token")
val userToken = user.principal().getString(Constants.LOGIN_USER_KEY) ?: throw Meta.unauthorized("token")
val authInfoStr = redisClient.get(CacheConstants.LOGIN_TOKEN_KEY + userToken) ?: throw Meta.unauthorized("token")
return JsonUtil.parseObject(authInfoStr, AuthUser::class.java)
}
suspend fun createToken(userId: Long, ip: String, client: String): String {
val token = IdUtil.randomUUID()
val userInfo = accountRepository.getInfo(userId)
val user = userInfo?.account ?: throw Meta.notFound("AccountNotFound", "账号不存在")
val authInfo = AuthUser(token, JsonUtil.toJsonObject(user), userInfo.rolesArr.toSet(), userInfo.accessArr.toSet(), ip, client)
val authInfoStr = JsonUtil.toJsonStr(authInfo)
redisClient.set(CacheConstants.LOGIN_TOKEN_KEY + token, authInfoStr, expireSeconds)
return genToken(mapOf(Constants.LOGIN_USER_KEY to token))
}
suspend fun verifyToken(loginUser: AuthUser) {
}
private fun genToken(info: Map<String, Any>, expires: Int? = null): String {
val jwtOptions = JWTOptions().setExpiresInSeconds(expires ?: (60 * 60 * 24 * 7))
return jwtAuth.generateToken(JsonObject(info), jwtOptions)
}
private suspend fun parseToken(token: String): User? {
val tokenCredentials = TokenCredentials(token)
return jwtAuth.authenticate(tokenCredentials).coAwait() ?: return null
}
}

View File

@ -1,58 +1,33 @@
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 app.domain.account.LoginDTO
import app.service.account.AccountService
import com.google.inject.Inject
import io.vertx.ext.auth.jwt.JWTAuth
import io.vertx.ext.web.RoutingContext
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
private val accountService: AccountService,
) {
@D("注册")
suspend fun doSign(
context: RoutingContext,
@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!!))
return accountService.sign(context, loginInfo)
}
@D("登录")
suspend fun doLogin(
context: RoutingContext,
@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", "用户名或密码错误")
}
return accountService.login(context, loginInfo)
}
}

View File

@ -1,14 +1,12 @@
package app.controller
import app.domain.CargoType
import app.domain.user.User
import app.domain.user.UserRepository
import app.service.user.UserService
import app.domain.account.Account
import app.domain.account.AccountRepository
import app.service.account.AccountService
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
@ -19,14 +17,15 @@ import org.aikrai.vertx.context.D
@D("测试1:测试")
@Controller
class Demo1Controller @Inject constructor(
private val userService: UserService,
private val userRepository: UserRepository
private val accountService: AccountService,
private val accountRepository: AccountRepository
) {
private val logger = KotlinLogging.logger { }
@AllowAnonymous
@D("参数测试", "详细说明......")
suspend fun test1(
@D("name", "姓名") name: String,
@D("name", "姓名") name: String?,
@D("age", "年龄") age: Int?,
@D("list", "列表") list: List<String>?,
@D("cargoType", "货物类型") cargoType: CargoType?
@ -40,12 +39,15 @@ class Demo1Controller @Inject constructor(
@D("事务测试")
suspend fun testT() {
userService.testTransaction()
accountService.testTransaction()
}
@D("查询测试")
suspend fun getList(): List<User> {
val list = userRepository.getList()
suspend fun getUserList(
@D("userName", "用户名") userName: String?,
@D("phone", "手机号") phone: String?
): List<Account> {
val list = accountRepository.getUserList(userName, phone)
println(list)
return list
}

View File

@ -0,0 +1,14 @@
package app.controller
import org.aikrai.vertx.auth.AllowAnonymous
import org.aikrai.vertx.context.Controller
import org.aikrai.vertx.context.D
@AllowAnonymous
@D("Hello")
@Controller("/")
class HelloController {
suspend fun hello(): String {
return "Hello"
}
}

View File

@ -0,0 +1,34 @@
package app.domain.account
import org.aikrai.vertx.db.annotation.*
import org.aikrai.vertx.utlis.BaseEntity
import java.sql.Timestamp
@TableName("sys_user")
class Account : BaseEntity() {
@TableId(type = IdType.ASSIGN_ID)
var userId: Long = 0L
@TableField("user_name")
var userName: String? = ""
var userType: String? = ""
var email: String? = ""
var phone: String? = ""
var avatar: String? = null
var password: String? = null
var status: Char? = null
var delFlag: Char? = null
var loginIp: String? = null
@TableField(fill = FieldFill.UPDATE)
var loginDate: Timestamp? = null
}

View File

@ -0,0 +1,17 @@
package app.domain.account
import app.base.domain.auth.modle.AccountRoleDTO
import app.domain.account.modle.AccountRoleAccessDTO
import com.google.inject.ImplementedBy
import org.aikrai.vertx.db.Repository
@ImplementedBy(AccountRepositoryImpl::class)
interface AccountRepository : Repository<Long, Account> {
suspend fun getByName(name: String): Account?
suspend fun getUserList(userName: String?, phone: String?): List<Account>
suspend fun getInfo(id: Long): AccountRoleAccessDTO?
suspend fun getAccountRole(id: Long): AccountRoleDTO?
suspend fun bindRoles(id: Long, roles: List<Long>)
suspend fun removeAllRole(id: Long): Int
}

View File

@ -0,0 +1,106 @@
package app.domain.account
import app.base.domain.auth.modle.AccountRoleDTO
import app.domain.account.modle.AccountRoleAccessDTO
import com.google.inject.Inject
import com.google.inject.Singleton
import io.vertx.sqlclient.SqlClient
import org.aikrai.vertx.db.RepositoryImpl
@Singleton
class AccountRepositoryImpl @Inject constructor(
sqlClient: SqlClient
) : RepositoryImpl<Long, Account>(sqlClient), AccountRepository {
override suspend fun getUserList(
userName: String?,
phone: String?
): List<Account> {
return queryBuilder()
.eq(!userName.isNullOrBlank(), Account::userName, userName)
.eq(!phone.isNullOrBlank(), Account::phone, phone)
.getList()
}
override suspend fun getByName(name: String): Account? {
val account = queryBuilder()
.eq(Account::userName, name)
.getOne()
return account
}
override suspend fun getInfo(id: Long): AccountRoleAccessDTO? {
val sql = """
SELECT
JSONB_BUILD_OBJECT(
'user_id', a.user_id, 'user_name', a.user_name, 'phone', a.phone, 'status', a.status, 'avatar', a.avatar,
'password', a.password
) AS account,
COALESCE(
JSONB_AGG(
DISTINCT JSONB_BUILD_OBJECT(
'role_id', r.role_id, 'role_name', r.role_name, 'role_key', r.role_key, 'remark', r.remark
)
) FILTER (WHERE r.role_id IS NOT NULL),
'[]'::jsonb
) AS roles,
COALESCE(
JSONB_AGG(
DISTINCT JSONB_BUILD_OBJECT(
'menu_id', m.menu_id, 'menu_name', m.menu_name, 'parent_id', m.parent_id, 'order_num', m.order_num,
'menu_type', m.menu_type, 'visible', m.visible, 'path', m.path, 'perms', m.perms,
'component', m.component
)
) FILTER (WHERE m.menu_id IS NOT NULL),
'[]'::jsonb
) AS access
FROM
sys_user a
LEFT JOIN sys_user_role ar ON a.user_id = ar.user_id
LEFT JOIN sys_role r ON ar.role_id = r.role_id
LEFT JOIN sys_role_menu rm ON r.role_id = rm.role_id
LEFT JOIN sys_menu m ON rm.menu_id = m.menu_id
where a.user_id = #{id}
GROUP BY a.user_id, a.user_name, a.phone, a.status, a.avatar, a.password;
""".trimIndent()
return get(sql, mapOf("id" to id), AccountRoleAccessDTO::class.java)
}
override suspend fun getAccountRole(id: Long): AccountRoleDTO? {
val sql = """
SELECT a.user_id, a.user_name, a.phone, a.status, a.avatar, a.password,
COALESCE(
JSONB_AGG(
DISTINCT JSONB_BUILD_OBJECT(
'role_id', r.role_id, 'role_name', r.role_name, 'role_key', r.role_key, 'remark', r.remark
)
) FILTER (WHERE r.role_id IS NOT NULL),
'[]'::jsonb
) AS roles
FROM
account a
LEFT JOIN account_role ar ON a.id = ar.account_id
LEFT JOIN role r ON ar.role_id = r.id
WHERE a.id = #{id}
GROUP BY a.user_id, a.user_name, a.phone, a.status, a.avatar, a.password;
""".trimIndent()
return get(sql, mapOf("id" to id), AccountRoleDTO::class.java)
}
override suspend fun bindRoles(id: Long, roles: List<Long>) {
if (roles.isEmpty()) return
val sql = StringBuilder("INSERT INTO account_role (account_id, role_id) VALUES ")
roles.forEachIndexed { index, roleId ->
sql.append("($id, $roleId)")
if (index < roles.size - 1) {
sql.append(", ")
}
}
execute(sql.toString())
}
override suspend fun removeAllRole(id: Long): Int {
val sql = "DELETE FROM account_role WHERE account_id = #{$id}"
return execute(sql)
}
}

View File

@ -1,4 +1,4 @@
package app.domain.user
package app.domain.account
data class LoginDTO(
var username: String,

View File

@ -0,0 +1,24 @@
package app.domain.account.modle
import app.domain.account.Account
import app.domain.menu.Menu
import app.domain.role.Role
data class AccountRoleAccessDTO(
val account: Account,
val roles: List<Role>,
val access: List<Menu>,
) {
val rolesArr: List<String>
get() {
return roles.mapNotNull { it.roleKey }.filter { it.isNotEmpty() }
}
val accessArr: List<String>
get() {
return if (rolesArr.contains("admin")) {
listOf("*:*:*")
} else {
access.map { it.perms }.filter { it.isNotEmpty() }
}
}
}

View File

@ -0,0 +1,15 @@
package app.base.domain.auth.modle
import app.domain.role.Role
data class AccountRoleDTO(
val id: Long,
val name: String,
val phone: String,
// val status: AccountStatus,
val avatar: String,
val openid: String,
val unionid: String,
val sopenid: String,
val roles: List<Role>,
)

View File

@ -0,0 +1,10 @@
package app.base.domain.auth.modle
class LoginUser {
var accountId: Long = 0L
var token: String = ""
var loginTime: Long = 0L
var expireTime: Long = 0L
var ipaddr: String = ""
var client: String = ""
}

View File

@ -0,0 +1,35 @@
package app.domain.menu
import org.aikrai.vertx.db.annotation.IdType
import org.aikrai.vertx.db.annotation.TableId
import org.aikrai.vertx.db.annotation.TableName
import org.aikrai.vertx.utlis.BaseEntity
import kotlin.jvm.Transient
@TableName("sys_menu")
class Menu : BaseEntity() {
@TableId(type = IdType.ASSIGN_ID)
var menuId = 0L
var menuName = ""
var parentId = 0L
var orderNum = 0
var path = ""
var component: String? = ""
var menuType = ""
var visible = "0"
var perms = ""
var parentName = ""
@Transient
var children = mutableListOf<Menu>()
}

View File

@ -0,0 +1,151 @@
package app.domain.menu
import app.base.domain.auth.menu.MenuRepository
import app.domain.account.Account
import com.google.inject.Inject
import com.google.inject.Singleton
import io.vertx.ext.auth.User
import org.aikrai.vertx.auth.AuthUser
import org.aikrai.vertx.auth.AuthUser.Companion.isAdmin
import org.aikrai.vertx.utlis.Meta
@Singleton
class MenuManager @Inject constructor(
private val menuRepository: MenuRepository
) {
suspend fun get(id: Long) = menuRepository.get(id)
suspend fun findAll() = menuRepository.list()
suspend fun getMenuTree(user: User): List<Menu> {
return getChildPerms(list(user), 0).toMutableList()
}
suspend fun list(user: User, name: String? = null): List<Menu> {
val authUser = (user as AuthUser)
return if (authUser.isAdmin()) {
menuRepository.list(name)
} else {
menuRepository.list(name, (authUser.user as Account).userId)
}
}
suspend fun findByRoleId(user: User, roleId: Long): List<Long> {
if ((user as AuthUser).isAdmin()) {
return menuRepository.list().map { it.menuId }
}
val menuList = menuRepository.list(roleId = roleId)
return menuList.map { it.menuId }
}
suspend fun add(
menuName: String,
parentId: Long?,
orderNum: Int?,
path: String,
component: String?,
menuType: String?,
visible: String?,
perms: String
) {
if (menuRepository.list(menuName).isNotEmpty()) {
throw Meta.error("MenuNameConflict", "菜单名称已存在")
}
val menu = Menu().apply {
this.menuName = menuName
this.path = path
this.perms = perms
parentId?.let { this.parentId = it }
orderNum?.let { this.orderNum = it }
component?.let { this.component = it }
menuType?.let { this.menuType = it }
visible?.let { this.visible = it }
}
menuRepository.create(menu)
}
suspend fun edit(
menuId: Long,
menuName: String?,
parentId: Long?,
orderNum: Int?,
path: String?,
component: String?,
menuType: String?,
visible: String?,
perms: String?
) {
val menu = menuRepository.get(menuId) ?: throw Meta.notFound("MenuNotFound", "菜单不存在")
if (menuName != null && menuName != menu.menuName && menuRepository.list(menuName).isNotEmpty()) {
throw Meta.error("MenuNameConflict", "菜单名称已存在")
}
menu.apply {
menuName?.let { this.menuName = it }
path?.let { this.path = it }
perms?.let { this.perms = it }
parentId?.let { this.parentId = it }
orderNum?.let { this.orderNum = it }
component?.let { this.component = it }
menuType?.let { this.menuType = it }
visible?.let { this.visible = it }
}
menuRepository.update(menu)
}
suspend fun remove(menuId: Long) {
menuRepository.delete(menuId)
}
companion object {
/**
* 根据父节点的ID获取所有子节点
*
* @param list 分类表
* @param parentId 传入的父节点ID
* @return List<SysMenu>
*/
fun getChildPerms(list: List<Menu>, parentId: Long): List<Menu> {
val returnList = mutableListOf<Menu>()
for (t in list) {
// 根据传入的某个父节点ID,遍历该父节点的所有子节点
if (t.parentId == parentId) {
recursionFn(list, t)
returnList.add(t)
}
}
return returnList
}
/**
* 递归列表
*
* @param list 分类表
* @param t 子节点
*/
private fun recursionFn(list: List<Menu>, t: Menu) {
// 得到子节点列表
val childList = getChildList(list, t).toMutableList()
t.children = childList
for (tChild in childList) {
if (hasChild(list, tChild)) {
recursionFn(list, tChild)
}
}
}
/**
* 得到子节点列表
*/
private fun getChildList(list: List<Menu>, t: Menu): List<Menu> {
return list.filter { it.parentId == t.menuId }
}
/**
* 判断是否有子节点
*/
private fun hasChild(list: List<Menu>, t: Menu): Boolean {
return getChildList(list, t).isNotEmpty()
}
}
}

View File

@ -0,0 +1,11 @@
package app.base.domain.auth.menu
import app.domain.menu.Menu
import app.domain.menu.MenuRepositoryImpl
import com.google.inject.ImplementedBy
import org.aikrai.vertx.db.Repository
@ImplementedBy(MenuRepositoryImpl::class)
interface MenuRepository : Repository<Long, Menu> {
suspend fun list(name: String? = null, accountId: Long? = null, roleId: Long? = null): List<Menu>
}

View File

@ -0,0 +1,15 @@
package app.domain.menu
import app.base.domain.auth.menu.MenuRepository
import com.google.inject.Inject
import io.vertx.sqlclient.SqlClient
import org.aikrai.vertx.db.RepositoryImpl
class MenuRepositoryImpl @Inject constructor(
sqlClient: SqlClient
) : RepositoryImpl<Long, Menu>(sqlClient), MenuRepository {
override suspend fun list(name: String?, accountId: Long?, roleId: Long?): List<Menu> {
return emptyList()
}
}

View File

@ -0,0 +1,14 @@
package app.base.domain.auth.menu
enum class MenuType(val desc: String) {
M("目录"),
C("菜单"),
F("按钮");
companion object {
fun parse(value: String?): MenuType? {
if (value.isNullOrBlank()) return null
return MenuType.values().find { it.name == value || it.desc == value }
}
}
}

View File

@ -0,0 +1,28 @@
package app.base.domain.auth.menu.modle
data class RouterVo(
/**
* 路由名字
*/
var name: String? = null,
/**
* 路由地址
*/
var path: String? = null,
/**
* 是否隐藏路由当设置 true 的时候该路由不会再侧边栏出现
*/
var hidden: Boolean = false,
/**
* 组件地址
*/
var component: String? = null,
/**
* 当你一个路由下面的 children 声明的路由大于1个时自动会变成嵌套的模式--如组件页面
*/
var alwaysShow: Boolean? = null,
/**
* 子路由
*/
var children: List<RouterVo>? = null
)

View File

@ -0,0 +1,34 @@
package app.base.domain.auth.menu.modle
import app.domain.menu.Menu
class TreeSelect {
/** 节点ID */
var id: Long? = null
/** 节点名称 */
var label: String? = null
/** 节点禁用 */
var disabled: Boolean = false
/** 子节点 */
// @JsonInclude(JsonInclude.Include.NON_EMPTY)
var children: List<TreeSelect>? = null
constructor() // 无参构造函数
// constructor(dept: SysDept) { // 带 SysDept 参数的构造函数
// this.id = dept.deptId
// this.label = dept.deptName
// this.disabled = UserConstants.DEPT_DISABLE == dept.status
// this.children = dept.children.map { TreeSelect(it) }
// }
constructor(menu: Menu) { // 带 SysMenu 参数的构造函数
this.id = menu.menuId
this.label = menu.menuName
this.children = menu.children.map { TreeSelect(it) }
}
}

View File

@ -1,42 +1,22 @@
package app.domain.role
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import org.aikrai.vertx.db.annotation.TableName
import org.aikrai.vertx.utlis.BaseEntity
@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
@TableName("sys_role")
class Role : BaseEntity() {
var roleId: Long = 0L
@Size(max = 30)
@NotNull
@Column(name = "role_name", nullable = false, length = 30)
var roleName: String? = null
@Size(max = 100)
@NotNull
@Column(name = "role_key", nullable = false, length = 100)
var roleKey: String? = null
@NotNull
@Column(name = "role_sort", nullable = false)
var roleSort: Int? = null
@Column(name = "data_scope")
var dataScope: Char? = null
@NotNull
@Column(name = "status", nullable = false)
var status: Char? = null
@Column(name = "del_flag")
var delFlag: Char? = null
@Size(max = 500)
@Column(name = "remark", length = 500)
var remark: String? = null
}

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package app.verticle
package app.port.aipfox
import com.google.inject.Inject
import com.google.inject.name.Named
@ -7,7 +7,6 @@ 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
@ -18,14 +17,10 @@ class ApifoxClient @Inject constructor(
@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() {
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)

View File

@ -0,0 +1,51 @@
package app.port.reids
import com.google.inject.Inject
import com.google.inject.Singleton
import io.vertx.core.Vertx
import io.vertx.kotlin.coroutines.coAwait
import io.vertx.redis.client.*
import mu.KotlinLogging
import org.aikrai.vertx.config.Config
@Singleton
class RedisClient @Inject constructor(
vertx: Vertx
) {
private val logger = KotlinLogging.logger { }
private val host = Config.getKey("redis.host").toString()
private val port = Config.getKey("redis.port").toString()
private val database = Config.getKey("redis.database").toString().toInt()
private val password = Config.getKey("redis.password").toString()
private val maxPoolSize = Config.getKey("redis.maxPoolSize").toString().toInt()
private val maxPoolWaiting = Config.getKey("redis.maxPoolWaiting").toString().toInt()
private var redisClient = Redis.createClient(
vertx,
RedisOptions()
.setType(RedisClientType.STANDALONE)
.addConnectionString("redis://$host:$port/$database")
.setPassword(password)
.setMaxPoolSize(maxPoolSize)
.setMaxPoolWaiting(maxPoolWaiting)
)
// EX秒PX毫秒
suspend fun set(key: String, value: String, expireSeconds: Int) {
redisClient.send(Request.cmd(Command.SET, key, value, "EX", expireSeconds))
}
suspend fun get(key: String): String? {
val res = redisClient.send(Request.cmd(Command.GET, key)).coAwait()
return res?.toString()
}
suspend fun incr(key: String): Int {
val res = redisClient.send(Request.cmd(Command.INCR, key)).coAwait()
return res?.toInteger() ?: 0
}
fun expire(key: String, expire: String) {
redisClient.send(Request.cmd(Command.EXPIRE, key, expire))
}
}

View File

@ -0,0 +1,73 @@
package app.service.account
import app.config.auth.TokenService
import app.domain.account.Account
import app.domain.account.AccountRepository
import app.domain.account.LoginDTO
import cn.hutool.core.lang.Snowflake
import cn.hutool.crypto.SecureUtil
import com.google.inject.Inject
import io.vertx.ext.web.RoutingContext
import org.aikrai.vertx.db.tx.withTransaction
import org.aikrai.vertx.utlis.IpUtil
import org.aikrai.vertx.utlis.Meta
import java.security.SecureRandom
class AccountService @Inject constructor(
private val snowflake: Snowflake,
private val accountRepository: AccountRepository,
private val tokenService: TokenService,
) {
suspend fun testTransaction() {
withTransaction {
accountRepository.update(1L, mapOf("avatar" to "test001"))
// throw Meta.failure("test transaction", "test transaction")
accountRepository.update(1L, mapOf("avatar" to "test002"))
}
}
suspend fun sign(
context: RoutingContext,
loginInfo: LoginDTO
): String {
val ipAddr = IpUtil.getIpAddr(context.request())
accountRepository.getByField(Account::userName, loginInfo.username)?.let {
throw Meta("authentication_failed", "名称已被占用")
}
val account = Account().apply {
// this.userId = snowflake.nextId()
this.userName = loginInfo.username
this.password = SecureUtil.sha1(loginInfo.password)
}
accountRepository.create(account)
return tokenService.createToken(account.userId, ipAddr, "management")
}
suspend fun login(
context: RoutingContext,
loginInfo: LoginDTO
): String {
val ipAddr = IpUtil.getIpAddr(context.request())
val account = accountRepository.getByField(Account::userName, loginInfo.username) ?: throw Meta(
"authentication_failed",
"账号或密码错误"
)
if (!SecureUtil.sha1(loginInfo.password).equals(account.password)) {
throw Meta(
"authentication_failed",
"账号或密码错误"
)
}
return tokenService.createToken(account.userId, ipAddr, "management")
}
private fun genNumericStr(length: Int): String {
val random = SecureRandom()
val numericCode = StringBuilder(length)
for (i in 0 until length) {
val digit = random.nextInt(10)
numericCode.append(digit)
}
return numericCode.toString()
}
}

View File

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

View File

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

View File

@ -6,14 +6,12 @@ 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) {

View File

@ -1,7 +1,12 @@
package app.verticle
import app.config.auth.AuthHandler
import app.config.RespBean
import app.config.auth.JwtAuthenticationHandler
import app.config.auth.ResponseHandler
import app.config.auth.TokenService
import app.domain.account.Account
import app.port.aipfox.ApifoxClient
import cn.hutool.core.lang.Snowflake
import com.google.inject.Inject
import com.google.inject.Injector
import com.google.inject.name.Named
@ -17,32 +22,39 @@ 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.auth.AuthUser
import org.aikrai.vertx.config.Config
import org.aikrai.vertx.context.RouterBuilder
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.utlis.LangUtil.toStringMap
import org.aikrai.vertx.utlis.Meta
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
private val tokenService: TokenService,
private val apifoxClient: ApifoxClient,
private val snowflake: Snowflake,
private val responseHandler: ResponseHandler,
@Named("server.port") private val port: Int,
@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())
.listen(port)
.coAwait()
logger.info { "http server start - http://127.0.0.1:${server.actualPort()}" }
apifoxClient.importOpenapi()
logger.info { "http server start - http://127.0.0.1:${server.actualPort()}/$context" }
}
override suspend fun stop() {
@ -54,25 +66,16 @@ class WebVerticle @Inject constructor(
.handler(corsHandler)
.failureHandler(errorHandler)
.handler(BodyHandler.create())
.handler(logHandler)
val authHandler = JwtAuthenticationHandler(coroutineScope, authHandler, context)
val authHandler = JwtAuthenticationHandler(coroutineScope, tokenService, context, snowflake)
router.route("/*").handler(authHandler)
val routerBuilder = RouterBuilder(coroutineScope, router).build { service ->
val scanPath = Config.getKeyAsString("server.package")
val routerBuilder = RouterBuilder(coroutineScope, router, scanPath, responseHandler).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()
}*/
authHandler.anonymous.addAll(routerBuilder.anonymousPaths)
}
private val corsHandler = CorsHandler.create()
@ -83,23 +86,75 @@ class WebVerticle @Inject constructor(
.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))
val resObj = when (failure) {
is Meta -> RespBean.failure("${failure.name}:${failure.message}", failure.data)
else -> RespBean.failure("${failure.javaClass.simpleName}${if (failure.message != null) ":${failure.message}" else ""}")
}
val resStr = JsonUtil.toJsonStr(resObj)
ctx.put("responseData", resStr)
ctx.response()
.setStatusCode(if (ctx.statusCode() != 200) ctx.statusCode() else 500)
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
.end(resStr)
} 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()
val resObj = RespBean.failure("未知错误")
val resStr = JsonUtil.toJsonStr(resObj)
ctx.put("responseData", resStr)
ctx.response()
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
.setStatusCode(500)
.end(resStr)
}
}
private val logHandler = Handler<RoutingContext> { ctx ->
val start = System.currentTimeMillis()
ctx.response().endHandler {
val end = System.currentTimeMillis()
val timeCost = "${end - start}ms".let {
when (end - start) {
in 0..500 -> it
in 501..2000 -> "$it⚠️"
else -> "$it"
}
}
val authUser = ctx.user() as? AuthUser
val logContent = if (authUser != null) {
val user = JsonUtil.parseObject(authUser.user, Account::class.java)
"""
|
|>>>>>请求ID:[${ctx.get<String>("requestId")}]
|>>>>>请求URL:[${ctx.request().path()}](${ctx.request().method()})
|>>>>>请求IP:[${ctx.request().remoteAddress().host()}]
|>>>>>用户名:[${user.userName}]
|>>>>>用户ID:[${user.userId}]
|>>>>>角色:[${authUser.roles}]
|>>>>>请求参数:[${JsonUtil.toJsonStr(ctx.request().params().toStringMap())}]
|>>>>>请求体:[${JsonUtil.toJsonStr(ctx.body().asString())}]
|>>>>>响应结果:[${ctx.get<String>("responseData")}]
|>>>>>耗时:[$timeCost]
""".trimMargin()
} else {
"""
|
|>>>>>请求ID:[${ctx.get<String>("requestId")}]
|>>>>>请求URL:["${ctx.request().uri()}"](${ctx.request().method()})
|>>>>>请求IP:[${ctx.request().remoteAddress().host()}]
|>>>>>身份:[未验证]
|>>>>>请求参数:[${JsonUtil.toJsonStr(ctx.request().params().toStringMap())}]
|>>>>>请求体:[${JsonUtil.toJsonStr(ctx.body().asString())}]
|>>>>>响应结果:[${ctx.get<String>("responseData")}]
|>>>>>耗时:[$timeCost]
""".trimMargin()
}
logger.info(logContent)
}
ctx.next()
}
}

View File

@ -1,7 +1,5 @@
server:
name: vtx_demo
active: dev
port: 8080
context: api # 上下文
timeout: 120 # eventbus超时时间
http:
@ -14,9 +12,4 @@ server:
event-bus:
timeout: 10000 # 毫秒
jwt:
key: 123456sdfjasdfjl # jwt加密key
apifox:
token: APS-xxx
projectId: xxx
folderId: xxx
key: 123456sdfjasdfjl # jwt加密key

View File

@ -0,0 +1,4 @@
apifox:
token: APS-xxxxxxxxxxxxxxx
projectId: xxxxxx
folderId: xxxxxx

View File

@ -0,0 +1,14 @@
databases:
name: vertx-demo
host: 127.0.0.1
port: 5432
username: root
password: 123456
redis:
host: 127.0.0.1
port: 6379
database: 0
password: xxx
maxPoolSize: 8
maxPoolWaiting: 2000

View File

@ -1,9 +0,0 @@
databases:
has-open: true
type: mysql
driver-class-name: com.mysql.jdbc.Driver
name: vertx-demo
host: 127.0.0.1
port: 3306
username: root
password: 123456

View File

@ -1,9 +0,0 @@
databases:
has-open: true
type: mysql
driver-class-name: com.mysql.jdbc.Driver
name: vertx-demo
host: 127.0.0.1
port: 3306
username: root
password: 123456

View File

@ -1,10 +1,11 @@
<?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="ROOT" value="./log/"/>
<property name="APPNAME" value="vertx-demo"/>
<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">
@ -12,6 +13,7 @@
</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
@ -28,18 +30,21 @@
<fileNamePattern>${ROOT}${APPNAME}-%d-warn.%i.log</fileNamePattern>
<maxHistory>${MAXHISTORY}</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
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>
<evaluator>
<expression>return level &gt;= INFO;</expression>
</evaluator>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
@ -47,52 +52,59 @@
<fileNamePattern>${ROOT}${APPNAME}-%d-info.%i.log</fileNamePattern>
<maxHistory>${MAXHISTORY}</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
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>
<evaluator>
<expression>return level &gt;= DEBUG;</expression>
</evaluator>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy
class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${ROOT}${APPNAME}-%d-debug.%i.log</fileNamePattern>
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">
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>
<evaluator>
<expression>return level &gt;= TRACE;</expression>
</evaluator>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy
class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${ROOT}${APPNAME}-%d-trace.%i.log</fileNamePattern>
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">
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>${FILESIZE}</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
</appender>
<root level="INFO">
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
<appender-ref ref="WARN"/>
<appender-ref ref="INFO"/>

View File

@ -1,39 +1,56 @@
/*
Source Server Type : PostgreSQL
*/
-- ----------------------------
-- 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;
DROP TABLE IF EXISTS "public"."sys_user";
CREATE TABLE "public"."sys_user" (
"user_id" int8 NOT NULL,
"dept_id" int8,
"user_name" varchar(30) COLLATE "pg_catalog"."default",
"nick_name" varchar(30) COLLATE "pg_catalog"."default",
"user_type" varchar(2) COLLATE "pg_catalog"."default",
"email" varchar(50) COLLATE "pg_catalog"."default",
"phone" varchar(11) COLLATE "pg_catalog"."default",
"sex" char(1) COLLATE "pg_catalog"."default",
"avatar" varchar(100) COLLATE "pg_catalog"."default",
"password" varchar(100) COLLATE "pg_catalog"."default",
"status" char(1) COLLATE "pg_catalog"."default",
"del_flag" char(1) COLLATE "pg_catalog"."default",
"login_ip" varchar(128) COLLATE "pg_catalog"."default",
"login_date" timestamp(6),
"create_by" varchar(64) COLLATE "pg_catalog"."default",
"create_time" timestamp(6),
"update_by" varchar(64) COLLATE "pg_catalog"."default",
"update_time" timestamp(6),
"remark" varchar(500) COLLATE "pg_catalog"."default"
)
;
COMMENT ON COLUMN "public"."sys_user"."user_id" IS '用户ID';
COMMENT ON COLUMN "public"."sys_user"."dept_id" IS '部门ID';
COMMENT ON COLUMN "public"."sys_user"."user_name" IS '用户账号';
COMMENT ON COLUMN "public"."sys_user"."nick_name" IS '用户昵称';
COMMENT ON COLUMN "public"."sys_user"."user_type" IS '用户类型00系统用户';
COMMENT ON COLUMN "public"."sys_user"."email" IS '用户邮箱';
COMMENT ON COLUMN "public"."sys_user"."phone" IS '手机号码';
COMMENT ON COLUMN "public"."sys_user"."sex" IS '用户性别0男 1女 2未知';
COMMENT ON COLUMN "public"."sys_user"."avatar" IS '头像地址';
COMMENT ON COLUMN "public"."sys_user"."password" IS '密码';
COMMENT ON COLUMN "public"."sys_user"."status" IS '帐号状态0正常 1停用';
COMMENT ON COLUMN "public"."sys_user"."del_flag" IS '删除标志0代表存在 2代表删除';
COMMENT ON COLUMN "public"."sys_user"."login_ip" IS '最后登录IP';
COMMENT ON COLUMN "public"."sys_user"."login_date" IS '最后登录时间';
COMMENT ON COLUMN "public"."sys_user"."create_by" IS '创建者';
COMMENT ON COLUMN "public"."sys_user"."create_time" IS '创建时间';
COMMENT ON COLUMN "public"."sys_user"."update_by" IS '更新者';
COMMENT ON COLUMN "public"."sys_user"."update_time" IS '更新时间';
COMMENT ON COLUMN "public"."sys_user"."remark" IS '备注';
COMMENT ON TABLE "public"."sys_user" IS '用户信息表';
-- ----------------------------
-- Records of sys_user
-- Primary Key structure for table 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;
ALTER TABLE "public"."sys_user" ADD CONSTRAINT "sys_user_pkey" PRIMARY KEY ("user_id");

View File

@ -0,0 +1,81 @@
package app.controller
import app.config.InjectConfig
import app.domain.account.LoginDTO
import app.verticle.MainVerticle
import io.vertx.core.Vertx
import io.vertx.core.json.JsonObject
import io.vertx.ext.web.client.WebClient
import io.vertx.junit5.VertxExtension
import io.vertx.junit5.VertxTestContext
import kotlinx.coroutines.runBlocking
import org.aikrai.vertx.config.Config
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
/**
* AuthControllerTest
*/
@ExtendWith(VertxExtension::class)
class AuthControllerTest{
private var port = 8080
private var basePath = "/api"
/**
* Test case for doSign
*/
@Test
fun doSign(vertx: Vertx, testContext: VertxTestContext) {
val client = WebClient.create(vertx)
val loginDTO = LoginDTO("运若汐", "123456")
client.post(port, "127.0.0.1", "$basePath/auth/doSign")
.sendJson(loginDTO)
.onSuccess { response ->
val body = JsonObject(response.body())
assertEquals("Success", body.getString("message"))
testContext.completeNow()
}
.onFailure { error ->
testContext.failNow(error)
}
}
/**
* Test case for doLogin
*/
@Test
fun doLogin(vertx: Vertx, testContext: VertxTestContext) {
val client = WebClient.create(vertx)
val loginDTO = LoginDTO("运若汐", "123456")
client.post(port, "127.0.0.1", "$basePath/auth/doLogin")
.sendJson(loginDTO)
.onSuccess { response ->
val body = JsonObject(response.body())
assertEquals("Success", body.getString("message"))
testContext.completeNow()
}
.onFailure { error ->
testContext.failNow(error)
}
}
@BeforeEach
fun startServer(vertx: Vertx, testContext: VertxTestContext) {
runBlocking { Config.init(vertx) }
val getIt = InjectConfig.configure(vertx)
val mainVerticle = getIt.getInstance(MainVerticle::class.java)
vertx.deployVerticle(mainVerticle).onComplete { ar ->
if (ar.succeeded()) {
Config.getKey("server.port")?.let {
port = it.toString().toInt()
}
Config.getKey("server.context")?.let {
basePath = "/$it".replace("//", "/")
}
vertx.setTimer(5000) { testContext.completeNow() }
} else testContext.failNow(ar.cause())
}
}
}

View File

@ -0,0 +1,4 @@
apifox:
token: APS-xxxxxxxxxxxxxxxxxxxx
projectId: xxxxx
folderId: xxxxx

View File

@ -0,0 +1,14 @@
databases:
name: vertx-demo
host: 127.0.0.1
port: 5432
username: root
password: 123456
redis:
host: 127.0.0.1
port: 6379
database: 0
password: xxx
maxPoolSize: 8
maxPoolWaiting: 2000

View File

@ -0,0 +1,5 @@
server:
port: 8080
package: app

View File

@ -55,21 +55,19 @@ spotless {
}
dependencies {
// implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.20")
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("org.reflections:reflections:0.10.2")
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")
@ -80,10 +78,6 @@ dependencies {
implementation("ch.qos.logback:logback-classic:1.4.14")
implementation("org.codehaus.janino:janino:3.1.8")
// jpa
implementation("jakarta.persistence:jakarta.persistence-api:3.2.0")
implementation("jakarta.validation:jakarta.validation-api:3.1.0")
// doc
implementation("io.swagger.core.v3:swagger-core:2.2.27")
}

View File

@ -1,19 +1,75 @@
package org.aikrai.vertx.auth
import io.vertx.core.json.JsonObject
import io.vertx.ext.auth.impl.UserImpl
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.utlis.Meta
class AuthUser(
principal: Principal,
attributes: Attributes,
) : UserImpl(JsonUtil.toJsonObject(principal), JsonUtil.toJsonObject(attributes))
val token: String,
val user: JsonObject,
val roles: Set<String>,
val accesses: Set<String>,
val loginIp: String? = null,
val client: String? = null,
) : UserImpl(JsonObject(), JsonObject()) {
class Principal(
val id: Long,
val info: Any,
)
companion object {
fun AuthUser.isAdmin(): Boolean {
return roles.contains("admin")
}
class Attributes(
val role: Set<String>,
val permissions: Set<String>,
)
fun AuthUser.validateAuth(permission: CheckPermission? = null) {
validateAuth(null, permission)
}
fun AuthUser.validateAuth(role: CheckRole? = null, permission: CheckPermission? = null) {
// 如果没有权限要求,直接返回
if (role == null && permission == null) return
// 验证角色
role?.let { r ->
val roleSet = roles.toSet()
if (roleSet.contains("admin")) return
if (roleSet.isEmpty()) {
throw Meta.forbidden("权限不足")
} else {
val reqRoleSet = (r.value + r.type).filter { it.isNotBlank() }.toSet()
if (!validateSet(reqRoleSet, roleSet, r.mode)) {
throw Meta.forbidden("权限不足")
}
}
}
// 验证权限
permission?.let { p ->
val permissionSet = accesses.toSet()
val roleSet = roles.toSet()
if (roleSet.contains("admin")) return
if (permissionSet.isEmpty() && roleSet.isEmpty()) {
throw Meta.forbidden("权限不足")
} else {
if (p.orRole.isNotEmpty()) {
val roleBoolean = validateSet(p.orRole.toSet(), roleSet, Mode.AND)
if (roleBoolean) return
}
val reqPermissionSet = (p.value + p.type).filter { it.isNotBlank() }.toSet()
if (!validateSet(reqPermissionSet, permissionSet, p.mode)) {
throw Meta.forbidden("权限不足")
}
}
}
}
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 }
}
}
}
}

View File

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

View File

@ -31,7 +31,22 @@ object Config {
configMap[key]
} else {
// 找到所有以 key 开头的条目
configMap.filterKeys { it.startsWith(key) }
val map = configMap.filterKeys { it.startsWith(key) }
// 如果没有找到任何匹配的条目,返回 null
return map.ifEmpty { null }
}
}
fun getKeyAsString(key: String): String? {
if (retriever.get() == null) throw IllegalStateException("Config not initialized")
// 检查 configMap 中是否存在指定的 key
return if (configMap.containsKey(key)) {
configMap[key].toString()
} else {
// 找到所有以 key 开头的条目
val map = configMap.filterKeys { it.startsWith(key) }
// 如果没有找到任何匹配的条目,返回 null
if (map.isEmpty()) return null else map.toString()
}
}

View File

@ -0,0 +1,43 @@
package org.aikrai.vertx.config.resp
import io.vertx.core.http.HttpHeaders
import io.vertx.ext.web.RoutingContext
import mu.KotlinLogging
import org.aikrai.vertx.constant.HttpStatus
import org.aikrai.vertx.jackson.JsonUtil
class DefaultResponseHandler: ResponseHandlerInterface {
private val logger = KotlinLogging.logger { }
override suspend fun normal(
ctx: RoutingContext,
responseData: Any?,
customizeResponse: Boolean
) {
val resStr = JsonUtil.toJsonStr(responseData)
ctx.put("responseData", resStr)
if (customizeResponse) return
ctx.response()
.setStatusCode(HttpStatus.SUCCESS)
.putHeader("Content-Type", "application/json")
.end(resStr)
}
override suspend fun exception(ctx: RoutingContext, e: Exception) {
logger.error { "${ctx.request().uri()}: ${ctx.failure().stackTraceToString()}" }
val failure = ctx.failure()
if (failure == null) {
ctx.response()
.setStatusCode(ctx.statusCode())
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.end()
} else {
val resStr = JsonUtil.toJsonStr(failure)
ctx.put("responseData", resStr)
ctx.response()
.setStatusCode(ctx.statusCode())
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.end(resStr)
}
}
}

View File

@ -0,0 +1,8 @@
package org.aikrai.vertx.config.resp
import io.vertx.ext.web.RoutingContext
interface ResponseHandlerInterface {
suspend fun normal(ctx: RoutingContext, responseData: Any?, customizeResponse: Boolean = false)
suspend fun exception(ctx: RoutingContext, e: Exception)
}

View File

@ -0,0 +1,43 @@
package org.aikrai.vertx.constant
object CacheConstants {
/**
* 搜索历史key
*/
const val SEARCH_CONFIG: String = "search_config:"
/**
* 登录用户 redis key
*/
const val LOGIN_TOKEN_KEY: String = "login_tokens:"
/**
* 验证码 redis key
*/
const val CAPTCHA_CODE_KEY: String = "captcha_codes:"
/**
* 参数管理 cache key
*/
const val SYS_CONFIG_KEY: String = "sys_config:"
/**
* 字典管理 cache key
*/
const val SYS_DICT_KEY: String = "sys_dict:"
/**
* 防重提交 redis key
*/
const val REPEAT_SUBMIT_KEY: String = "repeat_submit:"
/**
* 限流 redis key
*/
const val RATE_LIMIT_KEY: String = "rate_limit:"
/**
* 登录账户密码错误次数 redis key
*/
const val PWD_ERR_CNT_KEY: String = "pwd_err_cnt:"
}

View File

@ -0,0 +1,6 @@
package org.aikrai.vertx.constant
object Constants {
// 令牌前缀
val LOGIN_USER_KEY = "login_user_key"
}

View File

@ -0,0 +1,88 @@
package org.aikrai.vertx.constant
object HttpStatus {
/**
* 操作成功
*/
const val SUCCESS: Int = 200
/**
* 对象创建成功
*/
const val CREATED: Int = 201
/**
* 请求已经被接受
*/
const val ACCEPTED: Int = 202
/**
* 操作已经执行成功但是没有返回数据
*/
const val NO_CONTENT: Int = 204
/**
* 资源已被移除
*/
const val MOVED_PERM: Int = 301
/**
* 重定向
*/
const val SEE_OTHER: Int = 303
/**
* 资源没有被修改
*/
const val NOT_MODIFIED: Int = 304
/**
* 参数列表错误缺少格式不匹配
*/
const val BAD_REQUEST: Int = 400
/**
* 未授权
*/
const val UNAUTHORIZED: Int = 401
/**
* 访问受限授权过期
*/
const val FORBIDDEN: Int = 403
/**
* 资源服务未找到
*/
const val NOT_FOUND: Int = 404
/**
* 不允许的http方法
*/
const val BAD_METHOD: Int = 405
/**
* 资源冲突或者资源被锁
*/
const val CONFLICT: Int = 409
/**
* 不支持的数据媒体类型
*/
const val UNSUPPORTED_TYPE: Int = 415
/**
* 系统内部错误
*/
const val ERROR: Int = 500
/**
* 接口未实现
*/
const val NOT_IMPLEMENTED: Int = 501
/**
* 系统警告消息
*/
const val WARN: Int = 601
}

View File

@ -9,6 +9,8 @@ import io.vertx.ext.web.RoutingContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.aikrai.vertx.auth.*
import org.aikrai.vertx.config.resp.DefaultResponseHandler
import org.aikrai.vertx.config.resp.ResponseHandlerInterface
import org.aikrai.vertx.utlis.ClassUtil
import org.aikrai.vertx.utlis.Meta
import org.reflections.Reflections
@ -23,7 +25,9 @@ import kotlin.reflect.jvm.javaType
class RouterBuilder(
private val coroutineScope: CoroutineScope,
private val router: Router
private val router: Router,
private val scanPath: String? = null,
private val responseHandler: ResponseHandlerInterface = DefaultResponseHandler()
) {
var anonymousPaths = ArrayList<String>()
@ -31,22 +35,18 @@ class RouterBuilder(
// 缓存路由信息
val routeInfoCache = mutableMapOf<Pair<String, HttpMethod>, RouteInfo>()
// 获取所有 Controller 类中的公共方法
val packagePath = ClassUtil.getMainClass()?.packageName
val packagePath = scanPath ?: 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)
if (classAllowAnonymous || 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)
@ -72,6 +72,7 @@ class RouterBuilder(
isNullable = parameter.type.isMarkedNullable,
isList = parameter.type.classifier == List::class,
isComplex = !parameter.type.classifier.toString().startsWith("class kotlin.") &&
!parameter.type.classifier.toString().startsWith("class io.vertx") &&
!parameter.type.javaType.javaClass.isEnum &&
parameter.type.javaType is Class<*>
)
@ -103,18 +104,14 @@ class RouterBuilder(
coroutineScope.launch {
try {
val params = getParamsInstance(ctx, routeInfo.parameterInfo)
val result = if (routeInfo.kFunction.isSuspend) {
val resObj = 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)
responseHandler.normal(ctx, resObj, routeInfo.customizeResp)
} catch (e: Exception) {
handleError(ctx, e)
responseHandler.exception(ctx, e)
}
}
}
@ -176,13 +173,14 @@ class RouterBuilder(
}
private fun getReqPath(prefix: String, clazz: Class<*>, method: Method): String {
val basePath = if (prefix.isNotBlank()) {
var classPath = if (prefix.isNotBlank()) {
StrUtil.toCamelCase(StrUtil.toUnderlineCase(prefix))
} else {
StrUtil.toCamelCase(StrUtil.toUnderlineCase(clazz.simpleName.removeSuffix("Controller")))
}
if (classPath == "/") classPath = ""
val methodName = StrUtil.toCamelCase(StrUtil.toUnderlineCase(method.name))
return "/$basePath/$methodName".replace("//", "/")
return "/$classPath/$methodName".replace("//", "/")
}
private fun getParamsInstance(ctx: RoutingContext, paramsInfo: List<ParameterInfo>): Array<Any?> {
@ -300,27 +298,6 @@ class RouterBuilder(
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(

View File

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

View File

@ -8,8 +8,8 @@ interface 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 eq(condition: Boolean = true, column: String, value: Any?): QueryWrapper<T>
fun eq(condition: Boolean = true, column: KProperty1<T, *>, value: Any?): QueryWrapper<T>
fun from(table: String): QueryWrapper<T>
@ -33,7 +33,7 @@ interface QueryWrapper<T> {
fun orderByAsc(vararg columns: KProperty1<T, *>): QueryWrapper<T>
fun orderByDesc(vararg columns: KProperty1<T, *>): QueryWrapper<T>
fun genSql(): String
fun genSql(): Pair<String, Map<String, String>>
suspend fun getList(): List<T>
suspend fun getOne(): T?
}

View File

@ -5,17 +5,40 @@ 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 mu.KotlinLogging
import org.aikrai.vertx.db.annotation.TableField
import org.aikrai.vertx.db.annotation.TableName
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.utlis.Meta
import java.lang.reflect.Field
import java.lang.reflect.Modifier
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.reflect.KProperty1
import kotlin.reflect.jvm.javaField
class QueryWrapperImpl<T : Any>(
private val entityClass: Class<T>,
private val sqlClient: SqlClient,
private val clazz: Class<T>
) : QueryWrapper<T> {
private val conditions = mutableListOf<QueryCondition>()
var sqlClient: SqlClient? = null
private val logger = KotlinLogging.logger { }
private val conditions = CopyOnWriteArrayList<QueryCondition>()
private val sqlMap = ConcurrentHashMap<String, String>()
private val fields: List<Field> = clazz.declaredFields.filter {
!it.isAnnotationPresent(Transient::class.java) &&
!Modifier.isStatic(it.modifiers) &&
!it.isSynthetic
}.onEach { it.isAccessible = true }
private val fieldMappings: Map<String, String> = fields.associate { field ->
val fieldAnnotation = field.getAnnotation(TableField::class.java)
val fieldName = fieldAnnotation?.value?.takeIf { it.isNotBlank() }
?: StrUtil.toUnderlineCase(field.name)
field.name to fieldName
}
private val tableName: String = clazz.getAnnotation(TableName::class.java)?.value?.takeIf { it.isNotBlank() }
?: StrUtil.toUnderlineCase(clazz.simpleName)
override fun select(vararg columns: String): QueryWrapper<T> {
conditions.add(
@ -29,12 +52,10 @@ class QueryWrapperImpl<T : Any>(
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
column = fieldMappings[it.name] ?: it.name
)
)
}
@ -49,8 +70,8 @@ class QueryWrapperImpl<T : Any>(
return eq(true, column, value)
}
override fun eq(condition: Boolean, column: String, value: Any): QueryWrapper<T> {
if (condition) {
override fun eq(condition: Boolean, column: String, value: Any?): QueryWrapper<T> {
if (condition && value != null && value.toString().isNotBlank()) {
conditions.add(
QueryCondition(
type = QueryType.WHERE,
@ -63,14 +84,12 @@ class QueryWrapperImpl<T : Any>(
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)
override fun eq(condition: Boolean, column: KProperty1<T, *>, value: Any?): QueryWrapper<T> {
if (condition && value != null && value.toString().isNotBlank()) {
conditions.add(
QueryCondition(
type = QueryType.WHERE,
column = columnName,
column = fieldMappings[column.name] ?: column.name,
operator = "=",
value = value
)
@ -197,90 +216,135 @@ class QueryWrapperImpl<T : Any>(
}
private fun buildSql(): String {
val sqlBuilder = StringBuilder()
try {
val sqlBuilder = StringBuilder()
// SELECT 子句
sqlBuilder.append("SELECT ")
val selectCondition = conditions.find { it.type == QueryType.SELECT }
if (selectCondition != null) {
sqlBuilder.append(selectCondition.column)
} else {
fieldMappings.values.joinToString(",").let {
sqlBuilder.append(it)
}
}
// 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 {
sqlBuilder.append(" FROM $tableName")
}
// 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}'"
// WHERE 子句
val whereConditions = conditions.filter { it.type == QueryType.WHERE }
if (whereConditions.isNotEmpty()) {
sqlBuilder.append(" WHERE ")
sqlBuilder.append(
whereConditions.joinToString(" AND ") {
"${it.column} ${it.operator} #{${it.column}}"
}
}
)
}
)
}
// GROUP BY 子句
conditions.find { it.type == QueryType.GROUP_BY }?.let {
sqlBuilder.append(" GROUP BY ${it.column}")
}
// 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}")
}
// 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"]}"
}
)
// 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()
} catch (e: Exception) {
throw Meta.repository(e.javaClass.simpleName, e.message + "SQL 构建失败")
}
return sqlBuilder.toString()
}
override fun genSql(): String {
return buildSql()
private fun buildParams(): Map<String, String> {
val params = mutableMapOf<String, String>()
conditions.filter { it.type == QueryType.WHERE }.forEach {
when (it.operator) {
"IN", "NOT IN" -> {
params[it.column] = "(${(it.value as Collection<*>).joinToString(",")})"
}
else -> {
params[it.column] = it.value.toString()
}
}
}
return params
}
override fun genSql(): Pair<String, Map<String, String>> {
return (buildSql() to buildParams()).also { conditions.clear() }
}
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) }
if (sqlClient == null) {
throw Meta.repository("SqlClientError", "SqlClient 未初始化")
}
try {
val cacheKey = generateCacheKey(tableName, conditions)
val sql = sqlMap.getOrPut(cacheKey) {
buildSql()
}
val params = buildParams()
logger.debug { "SQL: $sql ,PARAMS: $params" }
val objs = SqlTemplate
.forQuery(sqlClient, sql)
.mapTo(Row::toJson)
.execute(params)
.coAwait()
.toList()
return objs.map { JsonUtil.parseObject(it.encode(), clazz) }.also { conditions.clear() }
} catch (e: Exception) {
conditions.clear()
throw Meta.repository(e.javaClass.simpleName, e.message)
}
}
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) }
if (sqlClient == null) {
throw Meta.repository("SqlClientError", "SqlClient 未初始化")
}
try {
val cacheKey = generateCacheKey(tableName, conditions)
val sql = sqlMap.getOrPut(cacheKey) { buildSql() }
val params = buildParams()
logger.debug { "SQL: $sql ,PARAMS: $params" }
val resultSet = SqlTemplate.forQuery(sqlClient, sql).execute(params).coAwait()
val list = resultSet.map { it.toJson() }
return when (list.size) {
0 -> null
1 -> JsonUtil.parseObject(list[0], clazz, true)
else -> throw IllegalStateException("Expected single result but got ${list.size}")
}
} catch (e: Exception) {
conditions.clear()
throw Meta.repository(e.javaClass.simpleName, e.message)
}
}
private fun generateCacheKey(tableName: String, conditions: List<QueryCondition>): String {
val keyBuilder = StringBuilder(tableName)
conditions.forEach { condition ->
keyBuilder.append("|${condition.type}|${condition.column}|${condition.operator}")
}
return keyBuilder.toString()
}
}

View File

@ -1,5 +1,7 @@
package org.aikrai.vertx.db
import kotlin.reflect.KProperty1
interface Repository<TId, TEntity> {
suspend fun create(t: TEntity): Int
suspend fun delete(id: TId): Int
@ -7,10 +9,8 @@ interface Repository<TId, TEntity> {
suspend fun update(id: TId, parameters: Map<String, Any?>): Int
suspend fun get(id: TId): TEntity?
suspend fun getByField(field: String, value: Any): TEntity?
suspend fun getByField(field: KProperty1<TEntity, *>, value: Any): TEntity?
suspend fun createBatch(list: List<TEntity>): Int
suspend fun <R> execute(sql: String): R
suspend fun queryBuilder(): QueryWrapper<TEntity>
suspend fun queryBuilder(clazz: Class<*>): QueryWrapper<*>
}

View File

@ -1,158 +1,300 @@
package org.aikrai.vertx.db
import cn.hutool.core.util.IdUtil
import cn.hutool.core.util.StrUtil
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.type.TypeFactory
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.annotation.*
import org.aikrai.vertx.db.tx.TxCtx
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.utlis.Meta
import java.lang.reflect.Field
import java.lang.reflect.Modifier
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
import java.sql.Timestamp
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.coroutineContext
import kotlin.reflect.KProperty1
open class RepositoryImpl<TId, TEntity : Any>(
private val sqlClient: SqlClient
) : Repository<TId, TEntity> {
private val logger = KotlinLogging.logger {}
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")
private val sqlMap = ConcurrentHashMap<String, ConcurrentHashMap<String, String>>()
private val querySqlMap = ConcurrentHashMap<String, Any>()
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
}
// 缓存字段和映射
private val fields: List<Field> = clazz.declaredFields.filter {
!Modifier.isStatic(it.modifiers) &&
!it.isSynthetic &&
!it.isAnnotationPresent(Transient::class.java)
}.onEach { it.isAccessible = true }
private val fieldMappings: Map<String, String> = fields.associate { field ->
val fieldAnnotation = field.getAnnotation(TableField::class.java)
val fieldName = fieldAnnotation?.value?.takeIf { it.isNotBlank() }
?: StrUtil.toUnderlineCase(field.name)
field.name to fieldName
}
private val idField: Field =
clazz.declaredFields.find { it.isAnnotationPresent(TableId::class.java) }?.also { it.isAccessible = true }
?: throw IllegalArgumentException("No @Id field found in ${clazz.simpleName}")
private val idFieldName: String = idField.getAnnotation(TableField::class.java)?.value?.takeIf { it.isNotBlank() }
?: StrUtil.toUnderlineCase(idField.name)
private val tableName: String = clazz.getAnnotation(TableName::class.java)?.value?.takeIf { it.isNotBlank() }
?: StrUtil.toUnderlineCase(clazz.simpleName)
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
try {
val idAnnotation = idField.getAnnotation(TableId::class.java)
val idValue = idField.get(t)
val excludeId = idAnnotation != null && (idValue == null || idValue == 0L || idValue == -1L) &&
(idAnnotation.type == IdType.AUTO)
val sqlKey = if (excludeId) "createExcludeId" else "createIncludeId"
val sqlTemplate = getOrCreateSql(tableName, sqlKey) {
val columns = if (excludeId) {
fields.filter { it.name != idField.name }.map { fieldMappings[it.name] }
} else {
fields.map { fieldMappings[it.name] }
}.joinToString(", ")
val parameters = if (excludeId) {
fields.filter { it.name != idField.name }.joinToString(", ") { "#{" + it.name + "}" }
} else {
fields.joinToString(", ") { "#{" + it.name + "}" }
}
val returning = if (excludeId) " RETURNING $idFieldName" else ""
"INSERT INTO $tableName ($columns) VALUES ($parameters)$returning"
}
val params = getNonNullFields(t).let {
if (excludeId) it.filterKeys { key -> key != idField.name } else it
}.toMutableMap()
// 填充ID
when (idAnnotation.type) {
IdType.INPUT -> {
if (idValue == 0L || idValue == -1L) throw Meta.repository("CreateError", "must provide ID value")
}
IdType.ASSIGN_ID -> params[idField.name] = IdUtil.getSnowflakeNextId()
IdType.ASSIGN_UUID -> params[idField.name] = IdUtil.simpleUUID()
else -> {}
}
// 处理TableField注解
fields.forEach { field ->
val tableField = field.getAnnotation(TableField::class.java)
if (tableField != null && tableField.fill != FieldFill.DEFAULT) {
// fill属性不为DEFAULT时根据fill属性填充字段
val value = when (tableField.fill) {
FieldFill.INSERT, FieldFill.UPDATE, FieldFill.INSERT_UPDATE -> {
when (field.type) {
LocalDateTime::class.java -> LocalDateTime.now()
Timestamp::class.java -> OffsetDateTime.ofInstant(Instant.now(), ZoneId.systemDefault())
else -> null
}
}
else -> null
}
if (value != null) params[field.name] = value
}
}
return if (excludeId) {
logger.debug { "SQL: $sqlTemplate ,PARAMS: $params" }
// 执行查询以获取生成的ID
val result = SqlTemplate.forQuery(getConnection(), sqlTemplate)
.execute(params)
.coAwait()
val rows = result.toList()
if (rows.isEmpty()) throw IllegalStateException("Insert failed")
// 提取生成的ID并回填
val generatedId = rows.first().getValue(idFieldName)
idField.set(t, generatedId)
1
} else {
execute(sqlTemplate, params)
}
} catch (e: Exception) {
logger.error(e) { "Error creating entity: $t" }
throw Meta.repository(e.javaClass.simpleName, e.message)
}
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
try {
val sqlKey = "delete"
val sqlTemplate = getOrCreateSql(tableName, sqlKey) {
"DELETE FROM $tableName WHERE $idFieldName = #{id}"
}
val params = mapOf("id" to id)
if (logger.isDebugEnabled) {
logger.debug { "SQL: $sqlTemplate, PARAMS: $params" }
}
return execute(sqlTemplate, params)
} catch (e: Exception) {
logger.error(e) { "Error deleting entity with id: $id" }
throw Meta.repository(e.javaClass.simpleName, e.message)
}
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
try {
val sqlKey = "update"
val sqlTemplate = getOrCreateSql(tableName, sqlKey) {
val fields = clazz.declaredFields.filter { it.name != idFieldName }
val setClause = fields.joinToString(", ") { "${fieldMappings[it.name]} = #{${it.name}}" }
"UPDATE $tableName SET $setClause WHERE $idFieldName = #{id}"
}
val params = getNonNullFields(t) + mapOf("id" to idField.get(t))
logger.debug { "SQL: $sqlTemplate, PARAMS: $params" }
return execute(sqlTemplate, params)
} catch (e: Exception) {
logger.error(e) { "Error updating entity: $t" }
throw Meta.repository(e.javaClass.simpleName, e.message)
}
// 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
try {
val sqlKey = "update_$parameters"
val sqlTemplate = getOrCreateSql(tableName, sqlKey) {
val setClause = parameters.keys.joinToString(", ") { "${fieldMappings[it]} = #{$it}" }
"UPDATE $tableName SET $setClause WHERE $idFieldName = #{id}"
}
val params = parameters + mapOf("id" to id)
logger.debug { "SQL: $sqlTemplate, PARAMS: $params" }
return execute(sqlTemplate, params)
} catch (e: Exception) {
logger.error(e) { "Error updating entity with id: $id" }
throw Meta.repository(e.javaClass.simpleName, e.message)
}
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
try {
val sqlKey = "get"
val sqlTemplate = getOrCreateSql(tableName, sqlKey) {
val columns = fieldMappings.values.joinToString(", ")
"SELECT $columns FROM $tableName WHERE $idFieldName = #{id}"
}
return get(sqlTemplate, mapOf("id" to id), clazz)
} catch (e: Exception) {
logger.error(e) { "Error getting entity with id: $id" }
throw Meta.repository(e.javaClass.simpleName, e.message)
}
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 getByField(field: String, value: Any): TEntity? {
try {
val sqlKey = "getByField_$field"
val sqlTemplate = getOrCreateSql(tableName, sqlKey) {
val columns = fieldMappings.values.joinToString(", ")
"SELECT $columns FROM $tableName WHERE $field = #{value}"
}
val params = mapOf("value" to value)
logger.debug { "SQL: $sqlTemplate, PARAMS: $params" }
return get(sqlTemplate, params, clazz)
} catch (e: Exception) {
logger.error(e) { "Error getting entity by field: $field = $value" }
throw Meta.repository(e.javaClass.simpleName, e.message)
}
}
override suspend fun queryBuilder(clazz: Class<*>): QueryWrapper<*> {
return QueryWrapperImpl(clazz, getConnection())
override suspend fun getByField(field: KProperty1<TEntity, *>, value: Any): TEntity? {
try {
val sqlKey = "getByField_${field.name}"
val sql = getOrCreateSql(tableName, sqlKey) {
val columns = fieldMappings.values.joinToString(", ")
"SELECT $columns FROM $tableName WHERE ${fieldMappings[field.name]} = #{value}"
}
val params = mapOf("value" to value)
logger.debug { "SQL: $sql, PARAMS: $params" }
return get(sql, params, clazz)
} catch (e: Exception) {
logger.error(e) { "Error getting entity by field: ${field.name} = $value" }
throw Meta.repository(e.javaClass.simpleName, e.message)
}
}
override suspend fun createBatch(list: List<TEntity>): Int {
try {
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
} catch (e: Exception) {
logger.error(e) { "Error creating batch entities: $list" }
throw Meta.repository(e.javaClass.simpleName, e.message)
}
}
// base方法
suspend fun <R> get(sql: String, params: Map<String, Any?>, clazz: Class<*>): R? {
logger.debug { "SQL: $sql, PARAMS: $params" }
val resultSet = SqlTemplate.forQuery(getConnection(), sql).execute(params).coAwait()
val list = resultSet.map { it.toJson() }
return when (list.size) {
0 -> null
1 -> JsonUtil.parseObject(list[0], clazz, true) as R
else -> throw IllegalStateException("Expected single result but got ${list.size}")
}
}
suspend fun <R> find(sql: String, params: Map<String, Any?>, clazz: Class<*>): R? {
logger.debug { "SQL: $sql, PARAMS: $params" }
val resultSet = SqlTemplate.forQuery(getConnection(), sql).execute(params).coAwait()
val list = resultSet.map { it.toJson() }
val listType = TypeFactory.defaultInstance().constructCollectionType(List::class.java, clazz)
val listTypeReference = object : TypeReference<List<Any>>() {
override fun getType() = listType
}
return JsonUtil.parseArray(JsonUtil.toJsonArray(list), listTypeReference, true) as R
}
suspend fun execute(sql: String, params: Map<String, Any?> = emptyMap()): Int {
logger.debug { "SQL: $sql ,PARAMS: $params" }
return try {
SqlTemplate.forUpdate(getConnection(), sql)
.execute(params)
.coAwait()
.rowCount()
} catch (e: Exception) {
logger.error(e) { "Error executing SQL: $sql, PARAMS: $params" }
throw Meta.repository(e.javaClass.simpleName, e.message)
}
}
suspend fun queryBuilder(qClazz: Class<Any>? = null): QueryWrapper<TEntity> {
val qClass = qClazz ?: clazz
val connection = getConnection()
val queryWrapper = querySqlMap.getOrPut(qClass.simpleName) {
QueryWrapperImpl(qClass)
} as QueryWrapperImpl<TEntity>
queryWrapper.sqlClient = connection
return queryWrapper
}
// 其他工具方法
private suspend fun getConnection(): SqlClient {
return if (TxCtx.isTransactionActive(coroutineContext)) {
TxCtx.currentSqlConnection(coroutineContext) ?: run {
@ -164,55 +306,16 @@ open class RepositoryImpl<TId, TEntity : Any>(
}
}
// 其他工具方法
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
// 通用获取或创建 SQL 模板的方法
private fun getOrCreateSql(tableName: String, key: String, sqlProvider: () -> String): String {
val tableSqlMap = sqlMap.computeIfAbsent(tableName) { ConcurrentHashMap() }
return tableSqlMap.getOrPut(key, sqlProvider)
}
// 工具方法:获取表名
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)
}
return fields.filter { !it.isAnnotationPresent(Transient::class.java) && it.get(t) != null }
.associate { it.name to it.get(t) }
}
/**
@ -223,19 +326,15 @@ open class RepositoryImpl<TId, TEntity : Any>(
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) }
@ -248,18 +347,14 @@ open class RepositoryImpl<TId, TEntity : Any>(
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 Timestamp -> // 时间戳类型
"'${OffsetDateTime.ofInstant(value.toInstant(), ZoneId.systemDefault())}'"
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 ->
@ -269,4 +364,12 @@ open class RepositoryImpl<TId, TEntity : Any>(
}
return "INSERT INTO $tableName ($columnNames) VALUES ${valuesList.joinToString(", ")};"
}
fun isCollectionType(type: Type): Boolean {
return when (type) {
is Class<*> -> type.isArray || Collection::class.java.isAssignableFrom(type)
is ParameterizedType -> Collection::class.java.isAssignableFrom((type.rawType as Class<*>))
else -> false
}
}
}

View File

@ -0,0 +1,58 @@
package org.aikrai.vertx.db.annotation
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.ANNOTATION_CLASS)
annotation class TableName(
val value: String = "",
// val schema: String = "",
// val keepGlobalPrefix: Boolean = false,
// val resultMap: String = "",
// val autoResultMap: Boolean = false,
// val excludeProperty: Array<String> = []
)
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FIELD, AnnotationTarget.ANNOTATION_CLASS)
annotation class TableId(val value: String = "", val type: IdType = IdType.NONE)
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FIELD, AnnotationTarget.ANNOTATION_CLASS)
annotation class TableField(
val value: String = "",
// val exist: Boolean = true,
// val condition: String = "",
// val update: String = "",
val fill: FieldFill = FieldFill.DEFAULT,
// val select: Boolean = true,
// val keepGlobalFormat: Boolean = false,
// val property: String = "",
// val numericScale: String = ""
)
/**
* IdType
* @property key Int
* @constructor
* @property AUTO IdType 数据库ID自增
* @property NONE IdType 无状态
* @property INPUT IdType 手动输入ID
* @property ASSIGN_ID IdType 默认 全局唯一ID (数字类型)
* @property ASSIGN_UUID IdType 全局唯一ID (字符串类型)
*/
enum class IdType(val key: Int) {
AUTO(0),
NONE(1),
INPUT(2),
ASSIGN_ID(3),
ASSIGN_UUID(4)
}
enum class FieldFill {
DEFAULT,
INSERT,
UPDATE,
INSERT_UPDATE
}

View File

@ -22,7 +22,7 @@ object TxMgrHolder {
private val _txMgr = AtomicReference<TxMgr?>(null)
val txMgr: TxMgr
get() = _txMgr.get() ?: throw Meta.failure(
get() = _txMgr.get() ?: throw Meta.error(
"TransactionError",
"TxMgr(TransactionManager)尚未初始化,请先调用initTxMgr()"
)

View File

@ -3,7 +3,7 @@ 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
import org.aikrai.vertx.db.annotation.TableField
class ColumnAnnotationIntrospector : JacksonAnnotationIntrospector() {
override fun findNameForDeserialization(annotated: Annotated?): PropertyName? {
@ -16,7 +16,7 @@ class ColumnAnnotationIntrospector : JacksonAnnotationIntrospector() {
private fun getColumnName(annotated: Annotated?): PropertyName? {
if (annotated == null) return null
val column = annotated.getAnnotation(Column::class.java)
return column?.let { PropertyName(it.name) }
val column = annotated.getAnnotation(TableField::class.java)
return column?.let { PropertyName(it.value) }
}
}

View File

@ -10,6 +10,7 @@ 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 com.fasterxml.jackson.module.kotlin.registerKotlinModule
import io.vertx.core.json.JsonArray
import io.vertx.core.json.JsonObject
@ -24,7 +25,7 @@ object JsonUtil {
private var objectMapperSnakeCase = createObjectMapperSnakeCase(false)
private val objectMapperDeserialization = run {
val mapper: ObjectMapper = jacksonObjectMapper()
val mapper: ObjectMapper = jacksonObjectMapper().registerKotlinModule()
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
mapper.registerModule(JavaTimeModule())
mapper.setAnnotationIntrospector(
@ -34,7 +35,7 @@ object JsonUtil {
}
private val objectMapperSnakeCaseDeserialization = run {
val mapper: ObjectMapper = jacksonObjectMapper()
val mapper: ObjectMapper = jacksonObjectMapper().registerKotlinModule()
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
mapper.propertyNamingStrategy = PropertyNamingStrategies.SNAKE_CASE
mapper.registerModule(JavaTimeModule())
@ -116,7 +117,7 @@ private class CustomTypeResolverBuilder : ObjectMapper.DefaultTypeResolverBuilde
}
private fun createObjectMapper(writeClassName: Boolean): ObjectMapper {
val mapper: ObjectMapper = jacksonObjectMapper()
val mapper: ObjectMapper = jacksonObjectMapper().registerKotlinModule()
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
mapper.registerModule(JavaTimeModule())
if (writeClassName) {
@ -130,7 +131,7 @@ private fun createObjectMapper(writeClassName: Boolean): ObjectMapper {
}
private fun createObjectMapperSnakeCase(writeClassName: Boolean): ObjectMapper {
val mapper: ObjectMapper = jacksonObjectMapper()
val mapper: ObjectMapper = jacksonObjectMapper().registerKotlinModule()
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
mapper.propertyNamingStrategy = PropertyNamingStrategies.SNAKE_CASE
mapper.registerModule(JavaTimeModule())

View File

@ -194,10 +194,11 @@ class OpenApiSpecGenerator {
* @return 格式化后的 API 路径
*/
private fun buildPath(controllerPrefix: String, methodName: String): String {
return (
"/${StrUtil.lowerFirst(StrUtil.toCamelCase(controllerPrefix))}/" +
StrUtil.lowerFirst(StrUtil.toCamelCase(methodName))
).replace("//", "/")
val classPath = if (controllerPrefix != "/") {
StrUtil.toCamelCase(StrUtil.toUnderlineCase(controllerPrefix))
} else ""
val methodPath = StrUtil.toCamelCase(StrUtil.toUnderlineCase(methodName))
return "/$classPath/$methodPath".replace("//", "/")
}
/**
@ -274,7 +275,7 @@ class OpenApiSpecGenerator {
val type =
(parameter.type.javaType as? Class<*>) ?: (parameter.type.javaType as? ParameterizedType)?.rawType as? Class<*>
?: return null
if (type.packageName.startsWith("io.vertx")) return null
val paramName = parameter.name ?: return null
val annotation = parameter.annotations.filterIsInstance<D>().firstOrNull()

View File

@ -0,0 +1,23 @@
package org.aikrai.vertx.utlis
import org.aikrai.vertx.db.annotation.FieldFill
import org.aikrai.vertx.db.annotation.TableField
import org.aikrai.vertx.utlis.TimeUtil.now
import java.sql.Timestamp
open class BaseEntity {
var createBy: String? = null
@TableField(fill = FieldFill.INSERT)
var createTime: Timestamp = now()
var updateBy: String? = null
@TableField(fill = FieldFill.UPDATE)
var updateTime: Timestamp = now()
var remark: String? = null
var version: Long = 0
}

View File

@ -17,7 +17,7 @@ object ClassUtil {
*
* @return 主类的 Class 对象如果未找到则返回 null
*/
fun getMainClass(): Class<*>? {
fun getMainClass(): Class<*> {
val classLoader = ServiceLoader.load(ClassLoader::class.java).firstOrNull()
?: Thread.currentThread().contextClassLoader
val mainCommand = System.getProperty("sun.java.command")
@ -47,11 +47,11 @@ object ClassUtil {
classLoader.loadClass(mainClassName)
} catch (e: ClassNotFoundException) {
e.printStackTrace()
null
throw Meta.error("MainClassNotFound", "获取启动类失败")
}
}
}
return null
throw Meta.error("MainClassNotFound", "获取启动类失败")
}
/**

View File

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

View File

@ -0,0 +1,318 @@
package org.aikrai.vertx.utlis
import cn.hutool.core.util.StrUtil
import io.vertx.core.http.HttpServerRequest
import java.net.InetAddress
import java.net.UnknownHostException
object IpUtil {
private const val REGX_0_255 = "(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]\\d|\\d)"
// 匹配 ip
private const val REGX_IP = "((" + REGX_0_255 + "\\.){3}" + REGX_0_255 + ")"
const val REGX_IP_WILDCARD =
"(((\\*\\.){3}\\*)|(" + REGX_0_255 + "(\\.\\*){3})|(" + REGX_0_255 + "\\." + REGX_0_255 + ")(\\.\\*){2}|((" + REGX_0_255 + "\\.){3}\\*))"
// 匹配网段
const val REGX_IP_SEG = "(" + REGX_IP + "\\-" + REGX_IP + ")"
/**
* 获取客户端IP
*
* @param request 请求对象
* @return IP地址
*/
fun getIpAddr(request: HttpServerRequest?): String {
if (request == null) return "unknown"
var ip: String? = request.getHeader("x-forwarded-for")
if (ip.isNullOrEmpty() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.getHeader("Proxy-Client-IP")
}
if (ip.isNullOrEmpty() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.getHeader("X-Forwarded-For")
}
if (ip.isNullOrEmpty() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.getHeader("WL-Proxy-Client-IP")
}
if (ip.isNullOrEmpty() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.getHeader("X-Real-IP")
}
if (ip.isNullOrEmpty() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.remoteAddress().host()
}
return if (ip == "0:0:0:0:0:0:0:1") {
"127.0.0.1"
} else {
getMultistageReverseProxyIp(ip)
}
}
/**
* 检查是否为内部IP地址
*
* @param ip IP地址
* @return 结果
*/
fun internalIp(ip: String?): Boolean {
val addr = textToNumericFormatV4(ip)
return internalIp(addr) || ip == "127.0.0.1"
}
/**
* 检查是否为内部IP地址
*
* @param addr byte地址
* @return 结果
*/
private fun internalIp(addr: ByteArray?): Boolean {
if (addr == null || addr.size < 2) return true
val b0 = addr[0]
val b1 = addr[1]
// 10.x.x.x/8
val section1: Byte = 0x0A
// 172.16.x.x/12
val section2: Byte = 0xAC.toByte()
val section3: Byte = 0x10
val section4: Byte = 0x1F
// 192.168.x.x/16
val section5: Byte = 0xC0.toByte()
val section6: Byte = 0xA8.toByte()
return when (b0) {
section1 -> true
section2 -> {
if (b1 >= section3 && b1 <= section4) {
true
} else {
false
}
}
section5 -> {
when (b1) {
section6 -> true
else -> false
}
}
else -> false
}
}
/**
* 将IPv4地址转换成字节
*
* @param text IPv4地址
* @return byte 字节
*/
fun textToNumericFormatV4(text: String?): ByteArray? {
if (text.isNullOrEmpty()) {
return null
}
val bytes = ByteArray(4)
val elements = text.split(".", limit = 5).toTypedArray()
return try {
when (elements.size) {
1 -> {
val l = elements[0].toLong()
if (l < 0L || l > 4294967295L) {
return null
}
bytes[0] = ((l shr 24) and 0xFF).toByte()
bytes[1] = ((l shr 16) and 0xFF).toByte()
bytes[2] = ((l shr 8) and 0xFF).toByte()
bytes[3] = (l and 0xFF).toByte()
bytes
}
2 -> {
var l = elements[0].toLong()
if (l < 0L || l > 255L) {
return null
}
bytes[0] = (l and 0xFF).toByte()
l = elements[1].toLong()
if (l < 0L || l > 16777215L) {
return null
}
bytes[1] = ((l shr 16) and 0xFF).toByte()
bytes[2] = ((l shr 8) and 0xFF).toByte()
bytes[3] = (l and 0xFF).toByte()
bytes
}
3 -> {
for (i in 0..1) {
val l = elements[i].toLong()
if (l < 0L || l > 255L) {
return null
}
bytes[i] = (l and 0xFF).toByte()
}
val l = elements[2].toLong()
if (l < 0L || l > 65535L) {
return null
}
bytes[2] = ((l shr 8) and 0xFF).toByte()
bytes[3] = (l and 0xFF).toByte()
bytes
}
4 -> {
for (i in 0..3) {
val l = elements[i].toLong()
if (l < 0L || l > 255L) {
return null
}
bytes[i] = (l and 0xFF).toByte()
}
bytes
}
else -> null
}
} catch (e: NumberFormatException) {
null
}
}
/**
* 获取IP地址
*
* @return 本地IP地址
*/
fun getHostIp(): String {
return try {
InetAddress.getLocalHost().hostAddress
} catch (e: UnknownHostException) {
"127.0.0.1"
}
}
/**
* 获取主机名
*
* @return 本地主机名
*/
fun getHostName(): String {
return try {
InetAddress.getLocalHost().hostName
} catch (e: UnknownHostException) {
"未知"
}
}
/**
* 从多级反向代理中获得第一个非unknown IP地址
*
* @param ip 获得的IP地址
* @return 第一个非unknown IP地址
*/
fun getMultistageReverseProxyIp(ip: String?): String {
var ipAddress = ip
// 多级反向代理检测
if (!ipAddress.isNullOrEmpty() && ipAddress.contains(",")) {
val ips = ipAddress.trim().split(",")
for (subIp in ips) {
if (!isUnknown(subIp)) {
ipAddress = subIp
break
}
}
}
return StrUtil.sub(ipAddress, 0, 255)
}
/**
* 检测给定字符串是否为未知多用于检测HTTP请求相关
*
* @param checkString 被检测的字符串
* @return 是否未知
*/
fun isUnknown(checkString: String?): Boolean {
return checkString.isNullOrBlank() || "unknown".equals(checkString, ignoreCase = true)
}
/**
* 是否为IP
*/
fun isIP(ip: String?): Boolean {
return !ip.isNullOrBlank() && Regex(REGX_IP).matches(ip)
}
/**
* 是否为IP *为间隔的通配符地址
*/
fun isIpWildCard(ip: String?): Boolean {
return !ip.isNullOrBlank() && Regex(REGX_IP_WILDCARD).matches(ip)
}
/**
* 检测参数是否在ip通配符里
*/
fun ipIsInWildCardNoCheck(ipWildCard: String, ip: String): Boolean {
val s1 = ipWildCard.split(".")
val s2 = ip.split(".")
var isMatchedSeg = true
for (i in s1.indices) {
if (s1[i] == "*") {
break
}
if (i >= s2.size || s1[i] != s2[i]) {
isMatchedSeg = false
break
}
}
return isMatchedSeg
}
/**
* 是否为特定格式如:10.10.10.1-10.10.10.99的ip段字符串
*/
fun isIPSegment(ipSeg: String?): Boolean {
return !ipSeg.isNullOrBlank() && Regex(REGX_IP_SEG).matches(ipSeg)
}
/**
* 判断ip是否在指定网段中
*/
fun ipIsInNetNoCheck(iparea: String, ip: String): Boolean {
val idx = iparea.indexOf('-')
if (idx < 0) return false
val sips = iparea.substring(0, idx).split(".")
val sipe = iparea.substring(idx + 1).split(".")
val sipt = ip.split(".")
var ips: Long = 0
var ipe: Long = 0
var ipt: Long = 0
for (i in 0 until 4) {
ips = (ips shl 8) or sips[i].toLong()
ipe = (ipe shl 8) or sipe[i].toLong()
ipt = (ipt shl 8) or sipt[i].toLong()
}
var lower = ips
var upper = ipe
if (lower > upper) {
val t = lower
lower = upper
upper = t
}
return lower <= ipt && ipt <= upper
}
/**
* 校验ip是否符合过滤串规则
*
* @param filter 过滤IP列表,支持后缀'*'通配,支持网段如:`10.10.10.1-10.10.10.99`
* @param ip 校验IP地址
* @return boolean 结果
*/
fun isMatchedIp(filter: String?, ip: String?): Boolean {
if (filter.isNullOrEmpty() || ip.isNullOrEmpty()) {
return false
}
val ips = filter.split(";")
for (iStr in ips) {
when {
isIP(iStr) && iStr == ip -> return true
isIpWildCard(iStr) && ipIsInWildCardNoCheck(iStr, ip) -> return true
isIPSegment(iStr) && ipIsInNetNoCheck(iStr, ip) -> return true
}
}
return false
}
}

View File

@ -6,43 +6,46 @@ import org.aikrai.vertx.jackson.JsonUtil
@JsonIgnoreProperties("localizedMessage", "suppressed", "stackTrace", "cause")
class Meta(
val name: String,
override val message: String = "",
override val message: String = "Internal Server Error",
val data: Any? = null
) : RuntimeException(message, null, false, false) {
) : RuntimeException(message, null, true, false) {
fun stackTraceToString(): String {
return JsonUtil.toJsonStr(this)
}
companion object {
fun failure(name: String, message: String): Meta =
fun error(name: String, message: String): Meta =
Meta(name, message)
fun unimplemented(message: String): Meta =
Meta("unimplemented", message)
Meta("Unimplemented", message)
fun unauthorized(message: String): Meta =
Meta("unauthorized", message)
Meta("Unauthorized", message)
fun timeout(message: String): Meta =
Meta("timeout", message)
Meta("Timeout", message)
fun requireArgument(argument: String, message: String): Meta =
Meta("required_argument:$argument", message)
Meta("RequiredArgument:$argument", message)
fun invalidArgument(argument: String, message: String): Meta =
Meta("invalid_argument:$argument", message)
Meta("InvalidArgument:$argument", message)
fun notFound(argument: String, message: String): Meta =
Meta("not_found:$argument", message)
Meta("NotFound:$argument", message)
fun badRequest(message: String): Meta =
Meta("bad_request", message)
Meta("BadRequest", message)
fun notSupported(message: String): Meta =
Meta("not_supported", message)
Meta("NotSupported", message)
fun forbidden(message: String): Meta =
Meta("forbidden", message)
Meta("Forbidden", message)
fun repository(name: String, message: String?): Meta =
Meta("Repository:$name", message ?: "")
}
}