diff --git a/.gitignore b/.gitignore index e88b371..a451bd6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ build/ !**/src/test/**/build/ /config /gradle +log/ ### IntelliJ IDEA ### .idea diff --git a/vertx-demo/build.gradle.kts b/vertx-demo/build.gradle.kts index 023c07b..ec9f00d 100644 --- a/vertx-demo/build.gradle.kts +++ b/vertx-demo/build.gradle.kts @@ -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") } diff --git a/vertx-demo/src/main/kotlin/app/Application.kt b/vertx-demo/src/main/kotlin/app/Application.kt index 015c634..d879838 100644 --- a/vertx-demo/src/main/kotlin/app/Application.kt +++ b/vertx-demo/src/main/kotlin/app/Application.kt @@ -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) { 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" } + } } - } } } } diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/config/FailureParser.kt b/vertx-demo/src/main/kotlin/app/config/FailureParser.kt similarity index 89% rename from vertx-fw/src/main/kotlin/org/aikrai/vertx/config/FailureParser.kt rename to vertx-demo/src/main/kotlin/app/config/FailureParser.kt index 82faec2..efb451d 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/config/FailureParser.kt +++ b/vertx-demo/src/main/kotlin/app/config/FailureParser.kt @@ -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()) } } diff --git a/vertx-demo/src/main/kotlin/app/config/InjectConfig.kt b/vertx-demo/src/main/kotlin/app/config/InjectConfig.kt index 2f9ac5a..cb26408 100644 --- a/vertx-demo/src/main/kotlin/app/config/InjectConfig.kt +++ b/vertx-demo/src/main/kotlin/app/config/InjectConfig.kt @@ -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 - 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() } } diff --git a/vertx-demo/src/main/kotlin/app/config/RespBean.kt b/vertx-demo/src/main/kotlin/app/config/RespBean.kt new file mode 100644 index 0000000..ffedea7 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/config/RespBean.kt @@ -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") + } + } +} diff --git a/vertx-demo/src/main/kotlin/app/config/auth/AuthHandler.kt b/vertx-demo/src/main/kotlin/app/config/auth/AuthHandler.kt deleted file mode 100644 index e9d9307..0000000 --- a/vertx-demo/src/main/kotlin/app/config/auth/AuthHandler.kt +++ /dev/null @@ -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")), - ) - } -} diff --git a/vertx-demo/src/main/kotlin/app/config/auth/JwtAuthenticationHandler.kt b/vertx-demo/src/main/kotlin/app/config/auth/JwtAuthenticationHandler.kt index c0bbd6c..b87c85e 100644 --- a/vertx-demo/src/main/kotlin/app/config/auth/JwtAuthenticationHandler.kt +++ b/vertx-demo/src/main/kotlin/app/config/auth/JwtAuthenticationHandler.kt @@ -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): Boolean { for (pattern in excludePatterns) { val regexPattern = pattern diff --git a/vertx-demo/src/main/kotlin/app/config/auth/ResponseHandler.kt b/vertx-demo/src/main/kotlin/app/config/auth/ResponseHandler.kt new file mode 100644 index 0000000..2674550 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/config/auth/ResponseHandler.kt @@ -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("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) + } +} \ No newline at end of file diff --git a/vertx-demo/src/main/kotlin/app/config/auth/TokenService.kt b/vertx-demo/src/main/kotlin/app/config/auth/TokenService.kt new file mode 100644 index 0000000..c39bcfd --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/config/auth/TokenService.kt @@ -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, 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 + } +} diff --git a/vertx-demo/src/main/kotlin/app/controller/AuthController.kt b/vertx-demo/src/main/kotlin/app/controller/AuthController.kt index 35aa329..0f251a9 100644 --- a/vertx-demo/src/main/kotlin/app/controller/AuthController.kt +++ b/vertx-demo/src/main/kotlin/app/controller/AuthController.kt @@ -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) } } diff --git a/vertx-demo/src/main/kotlin/app/controller/Demo1Controller.kt b/vertx-demo/src/main/kotlin/app/controller/Demo1Controller.kt index 4dd6454..f98f5d1 100644 --- a/vertx-demo/src/main/kotlin/app/controller/Demo1Controller.kt +++ b/vertx-demo/src/main/kotlin/app/controller/Demo1Controller.kt @@ -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?, @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 { - val list = userRepository.getList() + suspend fun getUserList( + @D("userName", "用户名") userName: String?, + @D("phone", "手机号") phone: String? + ): List { + val list = accountRepository.getUserList(userName, phone) println(list) return list } diff --git a/vertx-demo/src/main/kotlin/app/controller/HelloController.kt b/vertx-demo/src/main/kotlin/app/controller/HelloController.kt new file mode 100644 index 0000000..cc0bcf4 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/controller/HelloController.kt @@ -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" + } +} \ No newline at end of file diff --git a/vertx-demo/src/main/kotlin/app/domain/account/Account.kt b/vertx-demo/src/main/kotlin/app/domain/account/Account.kt new file mode 100644 index 0000000..b5a26e1 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/account/Account.kt @@ -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 +} diff --git a/vertx-demo/src/main/kotlin/app/domain/account/AccountRepository.kt b/vertx-demo/src/main/kotlin/app/domain/account/AccountRepository.kt new file mode 100644 index 0000000..2e2e2d6 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/account/AccountRepository.kt @@ -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 { + suspend fun getByName(name: String): Account? + suspend fun getUserList(userName: String?, phone: String?): List + suspend fun getInfo(id: Long): AccountRoleAccessDTO? + + suspend fun getAccountRole(id: Long): AccountRoleDTO? + suspend fun bindRoles(id: Long, roles: List) + suspend fun removeAllRole(id: Long): Int +} diff --git a/vertx-demo/src/main/kotlin/app/domain/account/AccountRepositoryImpl.kt b/vertx-demo/src/main/kotlin/app/domain/account/AccountRepositoryImpl.kt new file mode 100644 index 0000000..452efb8 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/account/AccountRepositoryImpl.kt @@ -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(sqlClient), AccountRepository { + + override suspend fun getUserList( + userName: String?, + phone: String? + ): List { + 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) { + 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) + } +} diff --git a/vertx-demo/src/main/kotlin/app/domain/user/LoginDTO.kt b/vertx-demo/src/main/kotlin/app/domain/account/LoginDTO.kt similarity index 72% rename from vertx-demo/src/main/kotlin/app/domain/user/LoginDTO.kt rename to vertx-demo/src/main/kotlin/app/domain/account/LoginDTO.kt index 16dba81..3132eee 100644 --- a/vertx-demo/src/main/kotlin/app/domain/user/LoginDTO.kt +++ b/vertx-demo/src/main/kotlin/app/domain/account/LoginDTO.kt @@ -1,4 +1,4 @@ -package app.domain.user +package app.domain.account data class LoginDTO( var username: String, diff --git a/vertx-demo/src/main/kotlin/app/domain/account/modle/AccountRoleAccessDTO.kt b/vertx-demo/src/main/kotlin/app/domain/account/modle/AccountRoleAccessDTO.kt new file mode 100644 index 0000000..b989f54 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/account/modle/AccountRoleAccessDTO.kt @@ -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, + val access: List, +) { + val rolesArr: List + get() { + return roles.mapNotNull { it.roleKey }.filter { it.isNotEmpty() } + } + val accessArr: List + get() { + return if (rolesArr.contains("admin")) { + listOf("*:*:*") + } else { + access.map { it.perms }.filter { it.isNotEmpty() } + } + } +} diff --git a/vertx-demo/src/main/kotlin/app/domain/account/modle/AccountRoleDTO.kt b/vertx-demo/src/main/kotlin/app/domain/account/modle/AccountRoleDTO.kt new file mode 100644 index 0000000..734fc3e --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/account/modle/AccountRoleDTO.kt @@ -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, +) diff --git a/vertx-demo/src/main/kotlin/app/domain/account/modle/LoginUser.kt b/vertx-demo/src/main/kotlin/app/domain/account/modle/LoginUser.kt new file mode 100644 index 0000000..6c8da93 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/account/modle/LoginUser.kt @@ -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 = "" +} diff --git a/vertx-demo/src/main/kotlin/app/domain/menu/Menu.kt b/vertx-demo/src/main/kotlin/app/domain/menu/Menu.kt new file mode 100644 index 0000000..cd33f4b --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/menu/Menu.kt @@ -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() +} diff --git a/vertx-demo/src/main/kotlin/app/domain/menu/MenuManager.kt b/vertx-demo/src/main/kotlin/app/domain/menu/MenuManager.kt new file mode 100644 index 0000000..90e7c99 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/menu/MenuManager.kt @@ -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 { + return getChildPerms(list(user), 0).toMutableList() + } + + suspend fun list(user: User, name: String? = null): List { + 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 { + 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 + */ + fun getChildPerms(list: List, parentId: Long): List { + val returnList = mutableListOf() + 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, 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, t: Menu): List { + return list.filter { it.parentId == t.menuId } + } + + /** + * 判断是否有子节点 + */ + private fun hasChild(list: List, t: Menu): Boolean { + return getChildList(list, t).isNotEmpty() + } + } +} diff --git a/vertx-demo/src/main/kotlin/app/domain/menu/MenuRepository.kt b/vertx-demo/src/main/kotlin/app/domain/menu/MenuRepository.kt new file mode 100644 index 0000000..660e6bf --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/menu/MenuRepository.kt @@ -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 { + suspend fun list(name: String? = null, accountId: Long? = null, roleId: Long? = null): List +} diff --git a/vertx-demo/src/main/kotlin/app/domain/menu/MenuRepositoryImpl.kt b/vertx-demo/src/main/kotlin/app/domain/menu/MenuRepositoryImpl.kt new file mode 100644 index 0000000..308ca64 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/menu/MenuRepositoryImpl.kt @@ -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(sqlClient), MenuRepository { + + override suspend fun list(name: String?, accountId: Long?, roleId: Long?): List { + return emptyList() + } +} diff --git a/vertx-demo/src/main/kotlin/app/domain/menu/MenuType.kt b/vertx-demo/src/main/kotlin/app/domain/menu/MenuType.kt new file mode 100644 index 0000000..b85e0b0 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/menu/MenuType.kt @@ -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 } + } + } +} diff --git a/vertx-demo/src/main/kotlin/app/domain/menu/modle/RouterVo.kt b/vertx-demo/src/main/kotlin/app/domain/menu/modle/RouterVo.kt new file mode 100644 index 0000000..6d94c81 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/menu/modle/RouterVo.kt @@ -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? = null +) diff --git a/vertx-demo/src/main/kotlin/app/domain/menu/modle/TreeSelect.kt b/vertx-demo/src/main/kotlin/app/domain/menu/modle/TreeSelect.kt new file mode 100644 index 0000000..3c35fed --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/domain/menu/modle/TreeSelect.kt @@ -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? = 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) } + } +} diff --git a/vertx-demo/src/main/kotlin/app/domain/role/Role.kt b/vertx-demo/src/main/kotlin/app/domain/role/Role.kt index f560ceb..d8cd6f0 100644 --- a/vertx-demo/src/main/kotlin/app/domain/role/Role.kt +++ b/vertx-demo/src/main/kotlin/app/domain/role/Role.kt @@ -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 } diff --git a/vertx-demo/src/main/kotlin/app/domain/user/User.kt b/vertx-demo/src/main/kotlin/app/domain/user/User.kt deleted file mode 100644 index b5029e9..0000000 --- a/vertx-demo/src/main/kotlin/app/domain/user/User.kt +++ /dev/null @@ -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 -} diff --git a/vertx-demo/src/main/kotlin/app/domain/user/UserRepository.kt b/vertx-demo/src/main/kotlin/app/domain/user/UserRepository.kt deleted file mode 100644 index 1dc62b2..0000000 --- a/vertx-demo/src/main/kotlin/app/domain/user/UserRepository.kt +++ /dev/null @@ -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 { - suspend fun getByName(name: String): User? - - suspend fun testTransaction(): User? - - suspend fun getList(): List -} diff --git a/vertx-demo/src/main/kotlin/app/domain/user/UserRepositoryImpl.kt b/vertx-demo/src/main/kotlin/app/domain/user/UserRepositoryImpl.kt deleted file mode 100644 index ad03df9..0000000 --- a/vertx-demo/src/main/kotlin/app/domain/user/UserRepositoryImpl.kt +++ /dev/null @@ -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(sqlClient), UserRepository { - - override suspend fun getByName(name: String): User? { - val user = queryBuilder() - .eq(User::userName, name) - .getOne() - return user - } - - override suspend fun testTransaction(): User? { -// throw Meta.failure("test transaction", "test transaction") - return queryBuilder().getOne() - } - - override suspend fun getList(): List { - return queryBuilder().getList() - } -} diff --git a/vertx-demo/src/main/kotlin/app/verticle/ApifoxClient.kt b/vertx-demo/src/main/kotlin/app/port/aipfox/ApifoxClient.kt similarity index 91% rename from vertx-demo/src/main/kotlin/app/verticle/ApifoxClient.kt rename to vertx-demo/src/main/kotlin/app/port/aipfox/ApifoxClient.kt index c540e7b..936774c 100644 --- a/vertx-demo/src/main/kotlin/app/verticle/ApifoxClient.kt +++ b/vertx-demo/src/main/kotlin/app/port/aipfox/ApifoxClient.kt @@ -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) diff --git a/vertx-demo/src/main/kotlin/app/port/reids/RedisClient.kt b/vertx-demo/src/main/kotlin/app/port/reids/RedisClient.kt new file mode 100644 index 0000000..3f8d1d9 --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/port/reids/RedisClient.kt @@ -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)) + } +} diff --git a/vertx-demo/src/main/kotlin/app/service/account/AccountService.kt b/vertx-demo/src/main/kotlin/app/service/account/AccountService.kt new file mode 100644 index 0000000..e0ee60f --- /dev/null +++ b/vertx-demo/src/main/kotlin/app/service/account/AccountService.kt @@ -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() + } +} diff --git a/vertx-demo/src/main/kotlin/app/service/user/UserService.kt b/vertx-demo/src/main/kotlin/app/service/user/UserService.kt deleted file mode 100644 index 5ae5ad4..0000000 --- a/vertx-demo/src/main/kotlin/app/service/user/UserService.kt +++ /dev/null @@ -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() -} diff --git a/vertx-demo/src/main/kotlin/app/service/user/impl/UserServiceImpl.kt b/vertx-demo/src/main/kotlin/app/service/user/impl/UserServiceImpl.kt deleted file mode 100644 index eeb394f..0000000 --- a/vertx-demo/src/main/kotlin/app/service/user/impl/UserServiceImpl.kt +++ /dev/null @@ -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("update sys_user set email = '88888' where user_name = '运若汐'") - println("运若汐: $execute1") - withTransaction { - val execute = userRepository.execute("update sys_user set email = '88888' where user_name = '郸明'") - println("郸明: $execute") -// throw Meta.failure("test transaction", "test transaction") - } - } - } -} diff --git a/vertx-demo/src/main/kotlin/app/verticle/MainVerticle.kt b/vertx-demo/src/main/kotlin/app/verticle/MainVerticle.kt index 7898d6a..9de1053 100644 --- a/vertx-demo/src/main/kotlin/app/verticle/MainVerticle.kt +++ b/vertx-demo/src/main/kotlin/app/verticle/MainVerticle.kt @@ -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) { diff --git a/vertx-demo/src/main/kotlin/app/verticle/WebVerticle.kt b/vertx-demo/src/main/kotlin/app/verticle/WebVerticle.kt index 83b3e8c..1fe10d6 100644 --- a/vertx-demo/src/main/kotlin/app/verticle/WebVerticle.kt +++ b/vertx-demo/src/main/kotlin/app/verticle/WebVerticle.kt @@ -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 { 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 { 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("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("responseData")}] + |>>>>>耗时:[$timeCost] + """.trimMargin() + } else { + """ + | + |>>>>>请求ID:[${ctx.get("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("responseData")}] + |>>>>>耗时:[$timeCost] + """.trimMargin() + } + logger.info(logContent) + } + ctx.next() + } } diff --git a/vertx-demo/src/main/resources/bootstrap.yml b/vertx-demo/src/main/resources/bootstrap.yml index 2e0dead..2ef8b9f 100644 --- a/vertx-demo/src/main/resources/bootstrap.yml +++ b/vertx-demo/src/main/resources/bootstrap.yml @@ -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 \ No newline at end of file + key: 123456sdfjasdfjl # jwt加密key \ No newline at end of file diff --git a/vertx-demo/src/main/resources/config/application-apifox.yaml b/vertx-demo/src/main/resources/config/application-apifox.yaml new file mode 100644 index 0000000..157dcf0 --- /dev/null +++ b/vertx-demo/src/main/resources/config/application-apifox.yaml @@ -0,0 +1,4 @@ +apifox: + token: APS-xxxxxxxxxxxxxxx + projectId: xxxxxx + folderId: xxxxxx \ No newline at end of file diff --git a/vertx-demo/src/main/resources/config/application-database.yml b/vertx-demo/src/main/resources/config/application-database.yml new file mode 100644 index 0000000..5f3bd71 --- /dev/null +++ b/vertx-demo/src/main/resources/config/application-database.yml @@ -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 \ No newline at end of file diff --git a/vertx-demo/src/main/resources/config/dev/application-database.yml b/vertx-demo/src/main/resources/config/dev/application-database.yml deleted file mode 100644 index a9dc41d..0000000 --- a/vertx-demo/src/main/resources/config/dev/application-database.yml +++ /dev/null @@ -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 diff --git a/vertx-demo/src/main/resources/config/prod/application-database.yml b/vertx-demo/src/main/resources/config/prod/application-database.yml deleted file mode 100644 index a9dc41d..0000000 --- a/vertx-demo/src/main/resources/config/prod/application-database.yml +++ /dev/null @@ -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 diff --git a/vertx-demo/src/main/resources/logback.xml b/vertx-demo/src/main/resources/logback.xml index 59c6918..2b9418f 100644 --- a/vertx-demo/src/main/resources/logback.xml +++ b/vertx-demo/src/main/resources/logback.xml @@ -1,10 +1,11 @@ - - + + + System.out @@ -12,6 +13,7 @@ + [%-5level] %d [%thread] %class{36}.%M:%L - %m%n @@ -28,18 +30,21 @@ ${ROOT}${APPNAME}-%d-warn.%i.log ${MAXHISTORY} + class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> ${FILESIZE} + [%-5level] %d [%thread] %class{36}.%M:%L - %m%n - INFO + + return level >= INFO; + ACCEPT DENY @@ -47,52 +52,59 @@ ${ROOT}${APPNAME}-%d-info.%i.log ${MAXHISTORY} + class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> ${FILESIZE} + [%-5level] %d [%thread] %class{36}.%M:%L - %m%n - DEBUG + + return level >= DEBUG; + ACCEPT DENY - ${ROOT}${APPNAME}-%d-debug.%i.log + class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> + ${ROOT}${APPNAME}-%d-info.%i.log ${MAXHISTORY} + class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> ${FILESIZE} + [%-5level] %d [%thread] %class{36}.%M:%L - %m%n - TRACE + + return level >= TRACE; + ACCEPT DENY - ${ROOT}${APPNAME}-%d-trace.%i.log + class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> + ${ROOT}${APPNAME}-%d-info.%i.log ${MAXHISTORY} + class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> ${FILESIZE} - + + diff --git a/vertx-demo/src/main/resources/sql/sys_user.sql b/vertx-demo/src/main/resources/sql/sys_user.sql index 0e79db9..a3107a0 100644 --- a/vertx-demo/src/main/resources/sql/sys_user.sql +++ b/vertx-demo/src/main/resources/sql/sys_user.sql @@ -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"); diff --git a/vertx-demo/src/test/kotlin/app/controller/AuthControllerTest.kt b/vertx-demo/src/test/kotlin/app/controller/AuthControllerTest.kt new file mode 100644 index 0000000..a7db055 --- /dev/null +++ b/vertx-demo/src/test/kotlin/app/controller/AuthControllerTest.kt @@ -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()) + } + } +} \ No newline at end of file diff --git a/vertx-demo/src/test/resources/application-apifox.yaml b/vertx-demo/src/test/resources/application-apifox.yaml new file mode 100644 index 0000000..e5235a5 --- /dev/null +++ b/vertx-demo/src/test/resources/application-apifox.yaml @@ -0,0 +1,4 @@ +apifox: + token: APS-xxxxxxxxxxxxxxxxxxxx + projectId: xxxxx + folderId: xxxxx \ No newline at end of file diff --git a/vertx-demo/src/test/resources/application-database.yaml b/vertx-demo/src/test/resources/application-database.yaml new file mode 100644 index 0000000..5f3bd71 --- /dev/null +++ b/vertx-demo/src/test/resources/application-database.yaml @@ -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 \ No newline at end of file diff --git a/vertx-demo/src/test/resources/application.yaml b/vertx-demo/src/test/resources/application.yaml new file mode 100644 index 0000000..ebb6a31 --- /dev/null +++ b/vertx-demo/src/test/resources/application.yaml @@ -0,0 +1,5 @@ +server: + port: 8080 + package: app + + diff --git a/vertx-fw/build.gradle.kts b/vertx-fw/build.gradle.kts index 2b53690..f212056 100644 --- a/vertx-fw/build.gradle.kts +++ b/vertx-fw/build.gradle.kts @@ -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") } diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/AuthUser.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/AuthUser.kt index d48c140..4d399ee 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/AuthUser.kt +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/AuthUser.kt @@ -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, + val accesses: Set, + 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, - val permissions: Set, -) + 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, + actual: Set, + mode: Mode + ): Boolean { + if (required.isEmpty()) return true + return when (mode) { + Mode.AND -> required == actual + Mode.OR -> required.any { it in actual } + } + } + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/TokenUtil.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/TokenUtil.kt deleted file mode 100644 index b1b3b33..0000000 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/auth/TokenUtil.kt +++ /dev/null @@ -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 { - val jwtOptions = JWTOptions().setExpiresInSeconds(60 * 60 * 24 * 7) - return jwtAuth.generateToken(JsonObject(info), jwtOptions) - } - - suspend fun authenticate(jwtAuth: JWTAuth, token: String): User? { - val tokenCredentials = TokenCredentials(token) - return jwtAuth.authenticate(tokenCredentials).coAwait() ?: return null - } - } -} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/config/Config.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/config/Config.kt index 1723e9c..e815242 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/config/Config.kt +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/config/Config.kt @@ -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() } } diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/config/resp/DefaultResponseHandler.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/config/resp/DefaultResponseHandler.kt new file mode 100644 index 0000000..db1e501 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/config/resp/DefaultResponseHandler.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/config/resp/ResponseHandlerInterface.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/config/resp/ResponseHandlerInterface.kt new file mode 100644 index 0000000..a390172 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/config/resp/ResponseHandlerInterface.kt @@ -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) +} \ No newline at end of file diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/constant/CacheConstants.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/constant/CacheConstants.kt new file mode 100644 index 0000000..4284241 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/constant/CacheConstants.kt @@ -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:" +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/constant/Constants.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/constant/Constants.kt new file mode 100644 index 0000000..446a263 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/constant/Constants.kt @@ -0,0 +1,6 @@ +package org.aikrai.vertx.constant + +object Constants { + // 令牌前缀 + val LOGIN_USER_KEY = "login_user_key" +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/constant/HttpStatus.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/constant/HttpStatus.kt new file mode 100644 index 0000000..ad4cc3a --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/constant/HttpStatus.kt @@ -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 +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/RouterBuilder.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/RouterBuilder.kt index 6ded7ce..cf14518 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/RouterBuilder.kt +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/context/RouterBuilder.kt @@ -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() @@ -31,22 +35,18 @@ class RouterBuilder( // 缓存路由信息 val routeInfoCache = mutableMapOf, 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): Array { @@ -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( diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/DbPool.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/DbPool.kt deleted file mode 100644 index 52f489d..0000000 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/DbPool.kt +++ /dev/null @@ -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 - } -} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapper.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapper.kt index 1617761..23ef5b6 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapper.kt +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapper.kt @@ -8,8 +8,8 @@ interface QueryWrapper { fun eq(column: String, value: Any): QueryWrapper fun eq(column: KProperty1, value: Any): QueryWrapper - fun eq(condition: Boolean, column: String, value: Any): QueryWrapper - fun eq(condition: Boolean, column: KProperty1, value: Any): QueryWrapper + fun eq(condition: Boolean = true, column: String, value: Any?): QueryWrapper + fun eq(condition: Boolean = true, column: KProperty1, value: Any?): QueryWrapper fun from(table: String): QueryWrapper @@ -33,7 +33,7 @@ interface QueryWrapper { fun orderByAsc(vararg columns: KProperty1): QueryWrapper fun orderByDesc(vararg columns: KProperty1): QueryWrapper - fun genSql(): String + fun genSql(): Pair> suspend fun getList(): List suspend fun getOne(): T? } diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapperImpl.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapperImpl.kt index e8bca62..d2681fe 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapperImpl.kt +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/QueryWrapperImpl.kt @@ -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( - private val entityClass: Class, - private val sqlClient: SqlClient, + private val clazz: Class ) : QueryWrapper { - private val conditions = mutableListOf() + var sqlClient: SqlClient? = null + private val logger = KotlinLogging.logger { } + private val conditions = CopyOnWriteArrayList() + private val sqlMap = ConcurrentHashMap() + + private val fields: List = clazz.declaredFields.filter { + !it.isAnnotationPresent(Transient::class.java) && + !Modifier.isStatic(it.modifiers) && + !it.isSynthetic + }.onEach { it.isAccessible = true } + + private val fieldMappings: Map = 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 { conditions.add( @@ -29,12 +52,10 @@ class QueryWrapperImpl( override fun select(vararg columns: KProperty1): QueryWrapper { columns.forEach { - val columnName = it.javaField?.getAnnotation(Column::class.java)?.name?.takeIf { it.isNotBlank() } - ?: StrUtil.toUnderlineCase(it.name) conditions.add( QueryCondition( type = QueryType.SELECT, - column = columnName + column = fieldMappings[it.name] ?: it.name ) ) } @@ -49,8 +70,8 @@ class QueryWrapperImpl( return eq(true, column, value) } - override fun eq(condition: Boolean, column: String, value: Any): QueryWrapper { - if (condition) { + override fun eq(condition: Boolean, column: String, value: Any?): QueryWrapper { + if (condition && value != null && value.toString().isNotBlank()) { conditions.add( QueryCondition( type = QueryType.WHERE, @@ -63,14 +84,12 @@ class QueryWrapperImpl( return this } - override fun eq(condition: Boolean, column: KProperty1, value: Any): QueryWrapper { - if (condition) { - val columnName = column.javaField?.getAnnotation(Column::class.java)?.name?.takeIf { it.isNotBlank() } - ?: StrUtil.toUnderlineCase(column.name) + override fun eq(condition: Boolean, column: KProperty1, value: Any?): QueryWrapper { + 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( } 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 { + val params = mutableMapOf() + 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> { + return (buildSql() to buildParams()).also { conditions.clear() } } override suspend fun getList(): List { - val sql = buildSql() - val objs = SqlTemplate - .forQuery(sqlClient, sql) - .mapTo(Row::toJson) - .execute(emptyMap()) - .coAwait() - .toList() - return objs.map { JsonUtil.parseObject(it.encode(), entityClass) } + 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): String { + val keyBuilder = StringBuilder(tableName) + conditions.forEach { condition -> + keyBuilder.append("|${condition.type}|${condition.column}|${condition.operator}") + } + return keyBuilder.toString() } } diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/Repository.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/Repository.kt index eef9795..a6892c1 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/Repository.kt +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/Repository.kt @@ -1,5 +1,7 @@ package org.aikrai.vertx.db +import kotlin.reflect.KProperty1 + interface Repository { suspend fun create(t: TEntity): Int suspend fun delete(id: TId): Int @@ -7,10 +9,8 @@ interface Repository { suspend fun update(id: TId, parameters: Map): Int suspend fun get(id: TId): TEntity? + suspend fun getByField(field: String, value: Any): TEntity? + suspend fun getByField(field: KProperty1, value: Any): TEntity? + suspend fun createBatch(list: List): Int - - suspend fun execute(sql: String): R - - suspend fun queryBuilder(): QueryWrapper - suspend fun queryBuilder(clazz: Class<*>): QueryWrapper<*> } diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/RepositoryImpl.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/RepositoryImpl.kt index 2e88da1..c02e6ec 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/RepositoryImpl.kt +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/RepositoryImpl.kt @@ -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( private val sqlClient: SqlClient ) : Repository { + private val logger = KotlinLogging.logger {} private val clazz: Class = (this::class.java.genericSuperclass as ParameterizedType) .actualTypeArguments[1] as Class - private val logger = KotlinLogging.logger {} - private val sqlTemplateMap: Map, String> = mutableMapOf() - private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSXXX") + private val sqlMap = ConcurrentHashMap>() + private val querySqlMap = ConcurrentHashMap() - override suspend fun execute(sql: String): R { - return if (sql.trimStart().startsWith("SELECT", true)) { - val list = SqlTemplate.forQuery(getConnection(), sql).execute(mapOf()) - .coAwait().map { it.toJson() } - val jsonObject = JsonUtil.toJsonObject(list) - val typeReference = object : TypeReference() {} - JsonUtil.parseObject(jsonObject, typeReference, true) - } else { - val rowCount = SqlTemplate.forUpdate(getConnection(), sql).execute(mapOf()) - .coAwait().rowCount() - rowCount as R - } + // 缓存字段和映射 + private val fields: List = clazz.declaredFields.filter { + !Modifier.isStatic(it.modifiers) && + !it.isSynthetic && + !it.isAnnotationPresent(Transient::class.java) + }.onEach { it.isAccessible = true } + + private val fieldMappings: Map = 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): 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 { - 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, 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): 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 get(sql: String, params: Map, 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 find(sql: String, params: Map, 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>() { + override fun getType() = listType + } + return JsonUtil.parseArray(JsonUtil.toJsonArray(list), listTypeReference, true) as R + } + + suspend fun execute(sql: String, params: Map = 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? = null): QueryWrapper { + val qClass = qClazz ?: clazz + val connection = getConnection() + val queryWrapper = querySqlMap.getOrPut(qClass.simpleName) { + QueryWrapperImpl(qClass) + } as QueryWrapperImpl + 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( } } - // 其他工具方法 - override suspend fun createBatch(list: List): Int { - if (list.isEmpty()) return 0 - var rowCount = 0 - list.chunked(1000).forEach { - val sql = genBatchInsertSql(it) - rowCount += SqlTemplate.forUpdate(sqlClient, sql) - .execute(emptyMap()) - .coAwait() - .rowCount() - } - return rowCount + // 通用获取或创建 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 { - return clazz.declaredFields.associate { field -> - val columnAnnotation = field.getAnnotation(Column::class.java) - val columnName = columnAnnotation?.name?.takeIf { it.isNotBlank() } - ?: StrUtil.toUnderlineCase(field.name) - field.name to columnName - } - } - - // 工具方法:获取非空字段及其值 + // 获取非空字段及其值 private fun getNonNullFields(t: TEntity): Map { - return clazz.declaredFields - .filter { field -> - field.isAccessible = true - // 排除被 @Transient 注解标记的字段 - !field.isAnnotationPresent(Transient::class.java) && - field.get(t) != null - } - .associate { field -> - field.name to field.get(t) - } + 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( private fun genBatchInsertSql(objects: List): String { // 如果对象列表为空,直接返回空字符串 if (objects.isEmpty()) return "" - // 将类名转换为下划线命名的表名,例如:UserInfo -> user_info val tableName = StrUtil.toUnderlineCase(clazz.simpleName) - // 获取类的所有字段,包括私有字段 val fields = clazz.declaredFields.filter { // 过滤掉静态字段和合成字段 !Modifier.isStatic(it.modifiers) && !it.isSynthetic } - // 确保所有字段可访问 fields.forEach { it.isAccessible = true } - // 将字段名转换为下划线命名的列名,并用逗号隔开 val columnNames = fields.joinToString(", ") { StrUtil.toUnderlineCase(it.name) } @@ -248,18 +347,14 @@ open class RepositoryImpl( 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( } 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 + } + } } diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/annotation/Annotation.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/annotation/Annotation.kt new file mode 100644 index 0000000..62e8acd --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/annotation/Annotation.kt @@ -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 = [] +) + +@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 +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxMgr.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxMgr.kt index 35c5610..cdbbe7c 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxMgr.kt +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/db/tx/TxMgr.kt @@ -22,7 +22,7 @@ object TxMgrHolder { private val _txMgr = AtomicReference(null) val txMgr: TxMgr - get() = _txMgr.get() ?: throw Meta.failure( + get() = _txMgr.get() ?: throw Meta.error( "TransactionError", "TxMgr(TransactionManager)尚未初始化,请先调用initTxMgr()" ) diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/ColumnAnnotationIntrospector.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/ColumnAnnotationIntrospector.kt index 9997fad..97c4a45 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/ColumnAnnotationIntrospector.kt +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/ColumnAnnotationIntrospector.kt @@ -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) } } } diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/JsonUtil.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/JsonUtil.kt index bedd855..ad893ed 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/JsonUtil.kt +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/jackson/JsonUtil.kt @@ -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()) diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/openapi/OpenApiSpecGenerator.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/openapi/OpenApiSpecGenerator.kt index b271c48..f56991d 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/openapi/OpenApiSpecGenerator.kt +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/openapi/OpenApiSpecGenerator.kt @@ -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().firstOrNull() diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/BaseEntity.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/BaseEntity.kt new file mode 100644 index 0000000..ae1c6e9 --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/BaseEntity.kt @@ -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 +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/ClassUtil.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/ClassUtil.kt index 1c3b9d1..0f3d837 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/ClassUtil.kt +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/ClassUtil.kt @@ -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", "获取启动类失败") } /** diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Entity.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Entity.kt deleted file mode 100644 index afd6e33..0000000 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Entity.kt +++ /dev/null @@ -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 -} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/IpUtil.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/IpUtil.kt new file mode 100644 index 0000000..56d18cf --- /dev/null +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/IpUtil.kt @@ -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 + } +} diff --git a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Meta.kt b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Meta.kt index 2bef7a3..2aa660c 100644 --- a/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Meta.kt +++ b/vertx-fw/src/main/kotlin/org/aikrai/vertx/utlis/Meta.kt @@ -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 ?: "") } }