Compare commits

...

2 Commits

Author SHA1 Message Date
1447e26d25 build(vertx-demo): 更新日志库版本 2025-04-28 10:42:26 +08:00
394161415c refactor(vertx-fw):重构异常处理 2025-04-28 09:57:43 +08:00
34 changed files with 837 additions and 417 deletions

View File

@ -8,7 +8,7 @@ plugins {
group = "com.demo"
version = "1.0.0-SNAPSHOT"
val vertxVersion = "4.5.11"
val vertxVersion = "4.5.14"
val junitJupiterVersion = "5.9.1"
application {
@ -67,44 +67,34 @@ dependencies {
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation(platform("io.vertx:vertx-stack-depchain:$vertxVersion"))
implementation(kotlin("stdlib-jdk8"))
// 特定于vertx-demo的依赖保留
implementation("io.vertx:vertx-lang-kotlin:$vertxVersion")
implementation("io.vertx:vertx-lang-kotlin-coroutines:$vertxVersion")
implementation("io.vertx:vertx-core:$vertxVersion")
implementation("io.vertx:vertx-web:$vertxVersion")
implementation("io.vertx:vertx-web-client:$vertxVersion")
implementation("io.vertx:vertx-config:$vertxVersion")
implementation("io.vertx:vertx-config-yaml:$vertxVersion")
implementation("io.vertx:vertx-pg-client:$vertxVersion")
implementation("io.vertx:vertx-mysql-client:$vertxVersion")
implementation("io.vertx:vertx-sql-client-templates:$vertxVersion")
implementation("io.vertx:vertx-auth-jwt:$vertxVersion")
implementation("io.vertx:vertx-redis-client:$vertxVersion")
implementation("dev.langchain4j:langchain4j-open-ai:1.0.0-beta1")
implementation("dev.langchain4j:langchain4j:1.0.0-beta1")
implementation("com.google.inject:guice:5.1.0")
implementation("org.reflections:reflections:0.10.2")
implementation("cn.hutool:hutool-core:5.8.24")
// hutool
implementation("cn.hutool:hutool-json:5.8.24")
implementation("cn.hutool:hutool-crypto: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.github.ben-manes.caffeine:caffeine:3.1.8")
implementation("dev.hsbrysk:caffeine-coroutines:1.0.0")
// log
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("org.slf4j:slf4j-api:2.0.6")
implementation("ch.qos.logback:logback-classic:1.4.14")
implementation("org.codehaus.janino:janino:3.1.8")
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
implementation("org.slf4j:slf4j-api:2.0.17")
implementation("ch.qos.logback:logback-classic:1.5.18")
// db
implementation("org.postgresql:postgresql:42.7.5")
implementation("com.ongres.scram:client:2.1")
// doc
implementation("io.swagger.core.v3:swagger-core:2.2.27")
// implementation("io.swagger.core.v3:swagger-core:2.2.27")
// XML解析库
implementation("javax.xml.bind:jaxb-api:2.3.1")

View File

@ -2,9 +2,9 @@ package app
import app.config.InjectConfig
import app.verticle.MainVerticle
import io.github.oshai.kotlinlogging.KotlinLogging
import io.vertx.core.Vertx
import kotlinx.coroutines.runBlocking
import mu.KotlinLogging
import org.aikrai.vertx.config.Config
object Application {

View File

@ -1,7 +1,7 @@
package app.config
import app.config.auth.JWTAuthProvider
import app.config.db.DbPoolProvider
import app.config.provider.JWTAuthProvider
import app.config.provider.DbPoolProvider
import cn.hutool.core.lang.Snowflake
import cn.hutool.core.util.IdUtil
import com.google.inject.AbstractModule
@ -15,32 +15,40 @@ import io.vertx.sqlclient.SqlClient
import kotlinx.coroutines.CoroutineScope
import org.aikrai.vertx.config.DefaultScope
import org.aikrai.vertx.config.FrameworkConfigModule
import org.aikrai.vertx.http.GlobalErrorHandler
import org.aikrai.vertx.http.RequestLogHandler
/**
* 依赖注入配置
*/
object InjectConfig {
fun configure(vertx: Vertx): Injector {
return Guice.createInjector(InjectorModule(vertx))
}
}
/**
* Guice模块配置
*/
class InjectorModule(
private val vertx: Vertx,
) : AbstractModule() {
override fun configure() {
// 1. 安装框架提供的配置模块
install(FrameworkConfigModule())
// 2. 绑定 Vertx 实例和 CoroutineScope
bind(Vertx::class.java).toInstance(vertx)
bind(CoroutineScope::class.java).toInstance(DefaultScope(vertx))
// 3. 绑定 Snowflake
bind(Snowflake::class.java).toInstance(IdUtil.getSnowflake())
// 4. 绑定数据库连接池 (使用 Provider 来延迟创建)
bind(Pool::class.java).toProvider(DbPoolProvider::class.java).`in`(Singleton::class.java)
bind(SqlClient::class.java).to(Pool::class.java) // 绑定 SqlClient 到 Pool
bind(SqlClient::class.java).to(Pool::class.java)
// 5. 绑定 JWTAuth
bind(JWTAuth::class.java).toProvider(JWTAuthProvider::class.java).`in`(Singleton::class.java)
// 6. 绑定错误处理和日志组件
bind(GlobalErrorHandler::class.java).`in`(Singleton::class.java)
bind(RequestLogHandler::class.java).`in`(Singleton::class.java)
}
}

View File

@ -1,52 +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(code, 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")
}
}
}
//package app.config
//
//import com.fasterxml.jackson.annotation.JsonInclude
//import org.aikrai.vertx.constant.HttpStatus
//
//@JsonInclude(JsonInclude.Include.NON_NULL)
//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(code, message, data)
// }
//
// // 访问受限,授权过期
// fun forbidden(message: String?): RespBean {
// return failure(HttpStatus.FORBIDDEN, message ?: "Restricted access, expired authorizations")
// }
//
// // 未授权
// fun unauthorized(message: String?): RespBean {
// return failure(HttpStatus.UNAUTHORIZED, message ?: "Unauthorized")
// }
// }
//}

View File

@ -1,50 +0,0 @@
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
import kotlinx.coroutines.launch
import org.aikrai.vertx.utlis.Meta
class JwtAuthenticationHandler(
val scope: CoroutineScope,
val tokenService: TokenService,
val context: String,
val snowflake: Snowflake
) : AuthenticationHandler {
override fun handle(event: RoutingContext) {
event.put("requestId", snowflake.nextId())
val path = event.request().path().replace("$context/", "/").replace("//", "/")
if (isPathExcluded(path, anonymous)) {
event.next()
return
}
scope.launch {
try {
val user = tokenService.getLoginUser(event)
tokenService.verifyToken(user)
event.setUser(user)
event.next()
} catch (e: Throwable) {
event.fail(401, Meta.unauthorized(e.message ?: "token"))
}
}
}
var anonymous = mutableListOf(
"/apidoc.json"
)
private fun isPathExcluded(path: String, excludePatterns: List<String>): Boolean {
for (pattern in excludePatterns) {
val regexPattern = pattern
.replace("**", ".+")
.replace("*", "[^/]+")
.replace("?", ".")
val isExclude = path.matches(regexPattern.toRegex())
if (isExclude) return true
}
return false
}
}

View File

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

View File

@ -0,0 +1,68 @@
package app.config.handler
import app.service.auth.TokenService
import com.google.inject.Inject
import io.vertx.ext.web.RoutingContext
import io.vertx.ext.web.handler.AuthenticationHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.aikrai.vertx.config.ServerConfig
import org.aikrai.vertx.constant.HttpStatus
import org.aikrai.vertx.utlis.Meta
import org.slf4j.MDC
/**
* JWT认证处理器
*/
class JwtAuthHandler @Inject constructor(
val scope: CoroutineScope,
val tokenService: TokenService,
val serverConfig: ServerConfig,
) : AuthenticationHandler {
override fun handle(ctx: RoutingContext) {
val path = ctx.request().path().replace("${serverConfig.context}/", "/").replace("//", "/")
if (isPathExcluded(path, anonymous)) {
ctx.next()
}
scope.launch {
try {
val user = tokenService.getLoginUser(ctx)
ctx.setUser(user)
// 将用户ID放入MDC
user.principal().getString("sub")?.let { userId ->
MDC.put("userId", userId)
}
ctx.next()
} catch (e: Throwable) {
MDC.remove("userId")
val metaError = when (e) {
is Meta -> e
else -> Meta.unauthorized(e.message ?: "认证失败")
}
ctx.fail(HttpStatus.UNAUTHORIZED, metaError)
}
}
}
var anonymous = mutableListOf(
"/apidoc.json"
)
/**
* 检查路径是否在排除列表中
*/
private fun isPathExcluded(path: String, excludePatterns: List<String>): Boolean {
for (pattern in excludePatterns) {
val regexPattern = pattern
.replace("**", ".+")
.replace("*", "[^/]+")
.replace("?", ".")
val isExclude = path.matches(regexPattern.toRegex())
if (isExclude) return true
}
return false
}
}

View File

@ -0,0 +1,52 @@
package app.config.handler
import com.google.inject.Singleton
import io.vertx.core.http.HttpHeaders
import io.vertx.ext.web.RoutingContext
import io.github.oshai.kotlinlogging.KotlinLogging
import org.aikrai.vertx.http.RespBean
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.resp.ResponseHandlerInterface
/**
* 响应处理器负责处理API响应
*/
@Singleton
class ResponseHandler : ResponseHandlerInterface {
private val logger = KotlinLogging.logger { }
/**
* 处理成功响应
*/
override suspend fun handle(
ctx: RoutingContext,
responseData: Any?,
customizeResponse: Boolean
) {
// 使用RequestLogHandler设置的请求ID
val requestId = ctx.get<String>("requestId")
val code: Int
val resStr = when (responseData) {
is RespBean<*> -> {
code = responseData.code
responseData.requestId = requestId
JsonUtil.toJsonStr(responseData)
}
// 否则使用RespBean包装
else -> {
val respBean = RespBean.Companion.success(responseData)
respBean.requestId = requestId
code = respBean.code
JsonUtil.toJsonStr(respBean)
}
}
ctx.put("responseData", resStr) // 存储响应内容用于日志
if (customizeResponse) return // 如果需要自定义响应,则不发送标准响应
ctx.response()
.setStatusCode(code)
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
.end(resStr)
}
}

View File

@ -1,4 +1,4 @@
package app.config.db
package app.config.provider
import com.google.inject.Inject
import com.google.inject.Provider

View File

@ -1,4 +1,4 @@
package app.config.auth
package app.config.provider
import com.google.inject.Inject
import com.google.inject.Provider
@ -21,4 +21,4 @@ class JWTAuthProvider @Inject constructor(
)
return JWTAuth.create(vertx, options)
}
}
}

View File

@ -5,11 +5,12 @@ import app.data.domain.account.AccountRepository
import app.data.emun.Status
import app.service.account.AccountService
import com.google.inject.Inject
import mu.KotlinLogging
import io.github.oshai.kotlinlogging.KotlinLogging
import org.aikrai.vertx.auth.AllowAnonymous
import org.aikrai.vertx.config.Config
import org.aikrai.vertx.context.Controller
import org.aikrai.vertx.context.D
import org.aikrai.vertx.utlis.Meta
/**
* 推荐代码示例
@ -32,6 +33,7 @@ class Demo1Controller @Inject constructor(
@D("account", "账号") account: Account?
) {
logger.info { "你好" }
throw Meta.error("test", "test")
println(age)
println(list)
println("test-$name")

View File

@ -7,7 +7,7 @@ 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 mu.KotlinLogging
import io.github.oshai.kotlinlogging.KotlinLogging
import org.aikrai.vertx.config.Config
class ApifoxClient @Inject constructor(

View File

@ -5,7 +5,7 @@ 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 io.github.oshai.kotlinlogging.KotlinLogging
import org.aikrai.vertx.config.RedisConfig
@Singleton

View File

@ -1,14 +1,14 @@
package app.service.account
import app.config.auth.TokenService
import app.data.domain.account.Account
import app.data.domain.account.AccountRepository
import app.data.domain.account.LoginDTO
import app.service.auth.TokenService
import cn.hutool.core.lang.Snowflake
import cn.hutool.crypto.SecureUtil
import com.google.inject.Inject
import io.vertx.ext.web.RoutingContext
import mu.KotlinLogging
import io.github.oshai.kotlinlogging.KotlinLogging
import org.aikrai.vertx.db.tx.withTransaction
import org.aikrai.vertx.utlis.IpUtil
import org.aikrai.vertx.utlis.Meta

View File

@ -1,8 +1,7 @@
package app.config.auth
package app.service.auth
import app.data.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
@ -14,7 +13,7 @@ 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 io.github.oshai.kotlinlogging.KotlinLogging
import org.aikrai.vertx.auth.AuthUser
import org.aikrai.vertx.constant.CacheConstants
import org.aikrai.vertx.constant.Constants
@ -23,7 +22,6 @@ 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,
@ -54,8 +52,6 @@ class TokenService @Inject constructor(
return genToken(mapOf(Constants.LOGIN_USER_KEY to token))
}
suspend fun verifyToken(loginUser: AuthUser) {
}
private fun genToken(info: Map<String, Any>, expires: Int? = null): String {
val jwtOptions = JWTOptions().setExpiresInSeconds(expires ?: (60 * 60 * 24 * 7))

View File

@ -15,7 +15,7 @@ import io.swagger.v3.oas.models.parameters.RequestBody
import io.swagger.v3.oas.models.responses.ApiResponse
import io.swagger.v3.oas.models.responses.ApiResponses
import io.swagger.v3.oas.models.servers.Server
import mu.KotlinLogging
import io.github.oshai.kotlinlogging.KotlinLogging
import org.aikrai.vertx.context.Controller
import org.aikrai.vertx.context.CustomizeRequest
import org.aikrai.vertx.context.D

View File

@ -2,7 +2,7 @@ package app.verticle
import com.google.inject.Inject
import io.vertx.kotlin.coroutines.CoroutineVerticle
import mu.KotlinLogging
import io.github.oshai.kotlinlogging.KotlinLogging
class MainVerticle @Inject constructor(
private val webVerticle: WebVerticle,

View File

@ -1,57 +1,49 @@
package app.verticle
import app.config.RespBean
import app.config.auth.JwtAuthenticationHandler
import app.config.auth.ResponseHandler
import app.config.auth.TokenService
import app.data.domain.account.Account
import app.config.handler.JwtAuthHandler
import app.config.handler.ResponseHandler
import app.port.aipfox.ApifoxClient
import cn.hutool.core.lang.Snowflake
import com.google.inject.Inject
import com.google.inject.Injector
import io.vertx.core.Handler
import io.vertx.core.http.HttpHeaders
import io.vertx.core.http.HttpMethod
import io.vertx.core.http.HttpServerOptions
import io.vertx.ext.web.Router
import io.vertx.ext.web.RoutingContext
import io.vertx.ext.web.handler.BodyHandler
import io.vertx.ext.web.handler.CorsHandler
import io.vertx.kotlin.coroutines.CoroutineVerticle
import io.vertx.kotlin.coroutines.coAwait
import kotlinx.coroutines.CoroutineScope
import mu.KotlinLogging
import org.aikrai.vertx.auth.AuthUser
import io.github.oshai.kotlinlogging.KotlinLogging
import org.aikrai.vertx.config.ServerConfig
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
import org.aikrai.vertx.http.GlobalErrorHandler
import org.aikrai.vertx.http.RequestLogHandler
class WebVerticle @Inject constructor(
private val getIt: Injector,
private val serverConfig: ServerConfig,
private val coroutineScope: CoroutineScope,
private val tokenService: TokenService,
private val apifoxClient: ApifoxClient,
private val snowflake: Snowflake,
private val jwtAuthHandler: JwtAuthHandler,
private val requestLogHandler: RequestLogHandler,
private val responseHandler: ResponseHandler,
private val serverConfig: ServerConfig
) : CoroutineVerticle() {
private val globalErrorHandler: GlobalErrorHandler,
private val apiFoxClient: ApifoxClient,
) : 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 options = HttpServerOptions()
.setMaxFormAttributeSize(1024 * 1024)
val server = vertx.createHttpServer(options)
.requestHandler(rootRouter)
.listen(serverConfig.port)
.coAwait()
apifoxClient.importOpenapi()
logger.info { "http server start - http://127.0.0.1:${server.actualPort()}${serverConfig.context}" }
// 生成ApiFox接口
apiFoxClient.importOpenapi()
logger.info { "HTTP服务启动 - http://127.0.0.1:${server.actualPort()}${serverConfig.context}" }
}
override suspend fun stop() {
@ -59,19 +51,18 @@ class WebVerticle @Inject constructor(
private fun setupRouter(rootRouter: Router, router: Router) {
rootRouter.route("${serverConfig.context}*").subRouter(router)
router.route()
.handler(corsHandler)
.handler(BodyHandler.create())
.handler(logHandler)
.failureHandler(errorHandler)
.handler(jwtAuthHandler)
.handler(requestLogHandler)
.failureHandler(globalErrorHandler)
val authHandler = JwtAuthenticationHandler(coroutineScope, tokenService, serverConfig.context, snowflake)
router.route("/*").handler(authHandler)
val routerBuilder = RouterBuilder(coroutineScope, router, serverConfig.scanPackage, responseHandler)
.build{ getIt.getInstance(it) }
val routerBuilder = RouterBuilder(coroutineScope, router, serverConfig.scanPackage, responseHandler).build { service ->
getIt.getInstance(service)
}
authHandler.anonymous.addAll(routerBuilder.anonymousPaths)
jwtAuthHandler.anonymous.addAll(routerBuilder.anonymousPaths)
}
private val corsHandler = CorsHandler.create()
@ -81,76 +72,4 @@ class WebVerticle @Inject constructor(
.allowedMethod(HttpMethod.PUT)
.allowedMethod(HttpMethod.DELETE)
.allowedMethod(HttpMethod.OPTIONS)
// 非业务异常处理
private val errorHandler = Handler<RoutingContext> { ctx ->
val failure = ctx.failure()
if (failure != null) {
logger.error { "${ctx.request().uri()}: ${failure.stackTraceToString()}" }
val resObj = when (failure) {
is Meta -> RespBean.failure(ctx.statusCode(), "${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(ctx.statusCode())
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
.end(resStr)
} else {
logger.error("${ctx.request().uri()}: 未知错误")
val resObj = RespBean.failure("未知错误")
val resStr = JsonUtil.toJsonStr(resObj)
ctx.put("responseData", resStr)
ctx.response()
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
.setStatusCode(500)
.end(resStr)
}
}
private val logHandler = Handler<RoutingContext> { ctx ->
val start = System.currentTimeMillis()
ctx.response().endHandler {
val end = System.currentTimeMillis()
val timeCost = "${end - start}ms".let {
when (end - start) {
in 0..500 -> it
in 501..2000 -> "$it⚠️"
else -> "$it"
}
}
val authUser = ctx.user() as? AuthUser
val logContent = if (authUser != null) {
val user = JsonUtil.parseObject(authUser.user, Account::class.java)
"""
|
|>>>>>请求ID:[${ctx.get<String>("requestId")}]
|>>>>>请求URL:[${ctx.request().path()}](${ctx.request().method()})
|>>>>>请求IP:[${ctx.request().remoteAddress().host()}]
|>>>>>用户名:[${user.userName}]
|>>>>>用户ID:[${user.userId}]
|>>>>>角色:[${authUser.roles}]
|>>>>>请求参数:[${JsonUtil.toJsonStr(ctx.request().params().toStringMap())}]
|>>>>>请求体:[${JsonUtil.toJsonStr(ctx.body().asString())}]
|>>>>>响应结果:[${ctx.get<String>("responseData")}]
|>>>>>耗时:[$timeCost]
""".trimMargin()
} else {
"""
|
|>>>>>请求ID:[${ctx.get<String>("requestId")}]
|>>>>>请求URL:["${ctx.request().uri()}"](${ctx.request().method()})
|>>>>>请求IP:[${ctx.request().remoteAddress().host()}]
|>>>>>身份:[未验证]
|>>>>>请求参数:[${JsonUtil.toJsonStr(ctx.request().params().toStringMap())}]
|>>>>>请求体:[${JsonUtil.toJsonStr(ctx.body().asString())}]
|>>>>>响应结果:[${ctx.get<String>("responseData")}]
|>>>>>耗时:[$timeCost]
""".trimMargin()
}
logger.info(logContent)
}
ctx.next()
}
}

View File

@ -6,6 +6,10 @@
<property name="FILESIZE" value="500MB"/>
<property name="MAXHISTORY" value="100"/>
<!-- 定义MDC变量如果不存在则为空字符串 -->
<property name="mdcPattern" value="requestId=%X{requestId:-N/A} userId=%X{userId:-anon} ip=%X{remoteAddr:-} method=%X{method:-} path=%X{path:-} status=%X{statusCode:-} time=%X{duration:-}ms" />
<!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<Target>System.out</Target>
<encoder charset="utf-8">
@ -14,15 +18,14 @@
</encoder>
</appender>
<!-- 警告级别日志 -->
<appender name="WARN" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${ROOT}${APPNAME}-warn.log</file>
<encoder charset="utf-8">
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
</pattern>
<pattern>[%-5level] %d{ISO8601} [%thread] %logger{36} [${mdcPattern}] - %m%n</pattern>
</encoder>
<filter class="ch.qos.logback.core.filter.EvaluatorFilter">
<evaluator>
<expression>return level &gt;= WARN;</expression>
</evaluator>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>WARN</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
@ -36,15 +39,14 @@
</rollingPolicy>
</appender>
<!-- 信息级别日志 -->
<appender name="INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${ROOT}${APPNAME}-info.log</file>
<encoder charset="utf-8">
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
</pattern>
<pattern>[%-5level] %d{ISO8601} [%thread] %logger{36} [${mdcPattern}] - %m%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<evaluator>
<expression>return level &gt;= INFO;</expression>
</evaluator>
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
@ -58,21 +60,21 @@
</rollingPolicy>
</appender>
<!-- 调试级别日志 -->
<appender name="DEBUG" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${ROOT}${APPNAME}-debug.log</file>
<encoder charset="utf-8">
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<evaluator>
<expression>return level &gt;= DEBUG;</expression>
</evaluator>
<level>DEBUG</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy
class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${ROOT}${APPNAME}-%d-info.%i.log</fileNamePattern>
<fileNamePattern>${ROOT}${APPNAME}-%d-debug.%i.log</fileNamePattern>
<maxHistory>${MAXHISTORY}</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
@ -81,21 +83,21 @@
</rollingPolicy>
</appender>
<!-- 跟踪级别日志 -->
<appender name="TRACE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${ROOT}${APPNAME}-trace.log</file>
<encoder charset="utf-8">
<pattern>[%-5level] %d [%thread] %class{36}.%M:%L - %m%n
</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<evaluator>
<expression>return level &gt;= TRACE;</expression>
</evaluator>
<level>TRACE</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy
class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${ROOT}${APPNAME}-%d-info.%i.log</fileNamePattern>
<fileNamePattern>${ROOT}${APPNAME}-%d-trace.%i.log</fileNamePattern>
<maxHistory>${MAXHISTORY}</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
@ -104,11 +106,67 @@
</rollingPolicy>
</appender>
<root level="DEBUG">
<!-- JSON格式日志所有级别 -->
<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${ROOT}${APPNAME}-json.log</file>
<!-- 使用 logstash-logback-encoder需要添加相应依赖 -->
<!-- 如果不想使用 logstash-logback-encoder可以注释掉这个appender或者使用自定义JSON格式 -->
<!--
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdc>true</includeMdc>
<customFields>{"application":"${APPNAME}"}</customFields>
</encoder>
-->
<!-- 简单的JSON格式输出不依赖额外库 -->
<encoder charset="utf-8">
<pattern>{"time":"%d{ISO8601}","level":"%level","thread":"%thread","logger":"%logger","mdc":{%mdc},"message":"%message"}%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${ROOT}${APPNAME}-%d-json.%i.log</fileNamePattern>
<maxHistory>${MAXHISTORY}</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>${FILESIZE}</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
</appender>
<!-- 错误级别日志 -->
<appender name="ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 添加缺少的file属性 -->
<file>${ROOT}${APPNAME}-error.log</file>
<encoder charset="utf-8">
<pattern>[%-5level] %d{ISO8601} [%thread] %logger{36} [${mdcPattern}] - %m%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${ROOT}${APPNAME}-%d-error.%i.log</fileNamePattern>
<maxHistory>${MAXHISTORY}</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>${FILESIZE}</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
</appender>
<!-- 根Logger配置 -->
<root level="INFO">
<appender-ref ref="STDOUT"/>
<appender-ref ref="ERROR"/>
<appender-ref ref="WARN"/>
<appender-ref ref="INFO"/>
<appender-ref ref="DEBUG"/>
<appender-ref ref="TRACE"/>
<appender-ref ref="JSON_FILE"/>
</root>
<!-- 可选:为特定包设置日志级别 -->
<!--
<logger name="app" level="DEBUG"/>
<logger name="org.aikrai.vertx" level="DEBUG"/>
-->
</configuration>

View File

@ -2,12 +2,13 @@ plugins {
kotlin("jvm") version "1.9.20"
id("com.diffplug.spotless") version "6.25.0"
id("com.github.johnrengelman.shadow") version "8.1.1"
`java-library`
}
group = "org.aikrai"
version = "1.0.0-SNAPSHOT"
val vertxVersion = "4.5.11"
val vertxVersion = "4.5.14"
repositories {
mavenLocal()
@ -47,29 +48,28 @@ 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-sql-client-templates:$vertxVersion")
implementation("io.vertx:vertx-auth-jwt:$vertxVersion")
api("org.jetbrains.kotlin:kotlin-reflect:1.9.20")
api("io.vertx:vertx-lang-kotlin-coroutines:$vertxVersion")
api("io.vertx:vertx-core:$vertxVersion")
api("io.vertx:vertx-web:$vertxVersion")
api("io.vertx:vertx-config:$vertxVersion")
api("io.vertx:vertx-config-yaml:$vertxVersion")
api("io.vertx:vertx-sql-client-templates:$vertxVersion")
api("io.vertx:vertx-auth-jwt:$vertxVersion")
implementation("com.google.inject:guice:7.0.0")
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")
api("com.google.inject:guice:7.0.0")
api("org.reflections:reflections:0.10.2")
api("com.fasterxml.jackson.core:jackson-databind:2.15.2")
api("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2")
// hutool
implementation("cn.hutool:hutool-core:5.8.35")
api("cn.hutool:hutool-core:5.8.35")
// log
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("org.slf4j:slf4j-api:2.0.6")
implementation("ch.qos.logback:logback-classic:1.4.14")
implementation("org.codehaus.janino:janino:3.1.8")
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
implementation("org.slf4j:slf4j-api:2.0.17")
implementation("ch.qos.logback:logback-classic:1.5.18")
// doc
implementation("io.swagger.core.v3:swagger-core:2.2.27")
api("io.swagger.core.v3:swagger-core:2.2.27")
}

View File

@ -72,7 +72,7 @@ object Config {
val map = configMapRef.get()
val subMap = map.filterKeys { it.startsWith("$keyPrefix.") }
.mapKeys { it.key.removePrefix("$keyPrefix.") }
return if (subMap.isEmpty()) null else subMap
return subMap.ifEmpty { null }
}
fun getStringList(key: String, defaultValue: List<String> = emptyList()): List<String> {

View File

@ -11,10 +11,6 @@ import com.google.inject.Singleton
*/
class FrameworkConfigModule : AbstractModule() {
override fun configure() {
// 这里不需要bind(Config::class.java)因为Config是object
}
@Provides
@Singleton
fun provideDatabaseConfig(): DatabaseConfig {

View File

@ -1,43 +0,0 @@
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: Throwable) {
logger.error { "${ctx.request().uri()}: ${ctx.failure().stackTraceToString()}" }
val failure = ctx.failure()
if (failure == null) {
ctx.response()
.setStatusCode(ctx.statusCode())
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.end()
} else {
val resStr = JsonUtil.toJsonStr(failure)
ctx.put("responseData", resStr)
ctx.response()
.setStatusCode(ctx.statusCode())
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.end(resStr)
}
}
}

View File

@ -1,8 +0,0 @@
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: Throwable)
}

View File

@ -10,10 +10,10 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.aikrai.vertx.auth.*
import org.aikrai.vertx.auth.AuthUser.Companion.validateAuth
import org.aikrai.vertx.config.resp.DefaultResponseHandler
import org.aikrai.vertx.config.resp.ResponseHandlerInterface
import org.aikrai.vertx.db.annotation.EnumValue
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.resp.DefaultResponseHandler
import org.aikrai.vertx.resp.ResponseHandlerInterface
import org.aikrai.vertx.utlis.ClassUtil
import org.aikrai.vertx.utlis.Meta
import org.reflections.Reflections
@ -214,9 +214,10 @@ class RouterBuilder(
} else {
routeInfo.kFunction.call(instance, *params)
}
responseHandler.normal(ctx, result, routeInfo.customizeResp)
responseHandler.handle(ctx, result, routeInfo.customizeResp)
} catch (e: Throwable) {
responseHandler.exception(ctx, e)
// 异常冒泡到全局错误处理器
ctx.fail(e)
}
}
}

View File

@ -6,7 +6,7 @@ import io.vertx.sqlclient.SqlConnection
import io.vertx.sqlclient.Transaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
import mu.KotlinLogging
import io.github.oshai.kotlinlogging.KotlinLogging
import org.aikrai.vertx.utlis.Meta
import java.util.*
import java.util.concurrent.ConcurrentHashMap

View File

@ -4,7 +4,7 @@ import io.vertx.kotlin.coroutines.coAwait
import io.vertx.sqlclient.Row
import io.vertx.sqlclient.SqlClient
import io.vertx.sqlclient.templates.SqlTemplate
import mu.KotlinLogging
import io.github.oshai.kotlinlogging.KotlinLogging
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.utlis.Meta
import java.util.concurrent.ConcurrentHashMap

View File

@ -7,7 +7,7 @@ import com.fasterxml.jackson.databind.type.TypeFactory
import io.vertx.kotlin.coroutines.coAwait
import io.vertx.sqlclient.*
import io.vertx.sqlclient.templates.SqlTemplate
import mu.KotlinLogging
import io.github.oshai.kotlinlogging.KotlinLogging
import org.aikrai.vertx.db.annotation.*
import org.aikrai.vertx.db.tx.TxCtxElem
import org.aikrai.vertx.jackson.JsonUtil

View File

@ -0,0 +1,121 @@
package org.aikrai.vertx.http
import com.google.inject.Singleton
import io.vertx.core.Handler
import io.vertx.core.http.HttpHeaders
import io.vertx.ext.web.RoutingContext
import io.github.oshai.kotlinlogging.KotlinLogging
import org.aikrai.vertx.constant.HttpStatus
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.utlis.Meta
import org.slf4j.MDC
/**
* 全局错误处理器负责捕获并处理所有未捕获的异常
*/
@Singleton
class GlobalErrorHandler : Handler<RoutingContext> {
private val logger = KotlinLogging.logger {}
override fun handle(ctx: RoutingContext) {
val failure = ctx.failure()
val statusCode = determineStatusCode(ctx, failure)
val requestId = ctx.get<String>("requestId") ?: "N/A"
// 记录错误日志
logError(ctx, failure, statusCode, requestId)
// 构建标准错误响应
val apiResponse = buildErrorResponse(failure, statusCode)
apiResponse.requestId = requestId
// 发送响应
if (!ctx.response().ended()) {
val responseJson = try {
JsonUtil.toJsonStr(apiResponse)
} catch (e: Exception) {
logger.error(e) { "序列化错误响应失败 (请求ID: $requestId)" }
// 回退到简单JSON
"""{"code":500,"message":"内部服务器错误 - 无法序列化错误响应","data":null,"requestId":"$requestId","timestamp":${System.currentTimeMillis()}}"""
}
ctx.put("responseData", responseJson) // 存储响应内容用于日志
ctx.response()
.setStatusCode(statusCode)
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
.end(responseJson)
} else {
logger.warn { "请求 ${ctx.request().uri()} 的响应已结束 (请求ID: $requestId)" }
}
}
/**
* 确定HTTP状态码
*/
private fun determineStatusCode(ctx: RoutingContext, failure: Throwable?): Int {
// 优先使用RoutingContext中设置的状态码
if (ctx.statusCode() >= 400) {
return ctx.statusCode()
}
// 根据异常类型确定状态码
return when (failure) {
is Meta -> HttpStatusMapping.getCode(failure.name, HttpStatus.ERROR)
is IllegalArgumentException -> HttpStatus.BAD_REQUEST
// 可添加更多异常类型的映射
else -> HttpStatus.ERROR // 默认为500
}
}
/**
* 构建标准错误响应
*/
private fun buildErrorResponse(failure: Throwable?, statusCode: Int): RespBean<Any?> {
return when (failure) {
null -> RespBean.error(statusCode, "发生未知错误")
is Meta -> RespBean.fromException(failure, statusCode)
else -> RespBean.fromException(failure, statusCode)
}
}
/**
* 记录错误日志
*/
private fun logError(ctx: RoutingContext, failure: Throwable?, statusCode: Int, requestId: String) {
val request = ctx.request()
val uri = request.uri()
val method = request.method().name()
val remoteAddr = request.remoteAddress()?.host()
// 将请求ID放入MDC
MDC.put("requestId", requestId)
try {
val logMessage = buildString {
append("处理请求失败 - ")
append("请求ID: $requestId, ")
append("方法: $method, ")
append("URI: $uri, ")
append("客户端IP: $remoteAddr, ")
append("状态码: $statusCode")
if (failure != null) {
append(", 异常类型: ${failure::class.java.name}")
if (!failure.message.isNullOrBlank()) {
append(", 消息: ${failure.message}")
}
}
}
// 根据状态码选择日志级别
if (statusCode >= 500 && failure != null) {
logger.error(failure) { logMessage } // 记录带堆栈的日志
} else {
logger.warn { logMessage } // 400级别错误只记录警告
}
} finally {
// 清理MDC
MDC.remove("requestId")
}
}
}

View File

@ -0,0 +1,166 @@
package org.aikrai.vertx.http
import cn.hutool.core.lang.Snowflake
import com.google.inject.Inject
import io.vertx.core.Handler
import io.vertx.core.http.HttpHeaders
import io.vertx.core.http.HttpMethod
import io.vertx.core.json.JsonObject
import io.vertx.ext.web.RoutingContext
import io.github.oshai.kotlinlogging.KotlinLogging
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.utlis.IpUtil
import org.slf4j.MDC
import java.time.Instant
/**
* 请求日志处理器负责生成请求ID记录请求和响应的详细信息
*/
class RequestLogHandler @Inject constructor(
private val snowflake: Snowflake
) : Handler<RoutingContext> {
private val logger = KotlinLogging.logger {}
override fun handle(ctx: RoutingContext) {
val startTime = System.currentTimeMillis()
// 生成请求ID
val requestId = snowflake.nextIdStr()
ctx.put("requestId", requestId)
// 将请求ID放入MDC
MDC.put("requestId", requestId)
// 记录基本请求信息
val request = ctx.request()
val method = request.method()
val path = request.path()
val remoteAddr = IpUtil.getIpAddr(request) // 使用工具类获取真实IP
// 将基本信息放入MDC
MDC.put("method", method.name())
MDC.put("path", path)
MDC.put("remoteAddr", remoteAddr)
// 记录开始日志
logger.info { "请求开始 - 方法: $method, 路径: $path, 客户端IP: $remoteAddr, 请求ID: $requestId" }
// 在请求结束时记录详细日志
ctx.response().endHandler {
val endTime = System.currentTimeMillis()
val duration = endTime - startTime
val response = ctx.response()
val statusCode = response.statusCode
try {
// 构建详细日志数据
val logData = JsonObject()
.put("timestamp", Instant.ofEpochMilli(endTime).toString())
.put("requestId", requestId)
.put("method", method.name())
.put("uri", request.uri())
.put("path", path)
.put("statusCode", statusCode)
.put("durationMs", duration)
.put("remoteAddr", remoteAddr)
.put("userAgent", request.getHeader(HttpHeaders.USER_AGENT))
// 尝试获取用户信息
val userId = ctx.user()?.principal()?.getString("sub")
if (userId != null) {
logData.put("userId", userId)
MDC.put("userId", userId)
}
// 视请求方法,可能记录查询参数
if (method == HttpMethod.GET || method == HttpMethod.DELETE) {
val queryParams = request.params().iterator().asSequence()
.map { it.key to it.value }
.toMap()
if (queryParams.isNotEmpty()) {
logData.put("queryParams", JsonObject(queryParams))
}
}
// 根据内容类型,可能记录请求体(小心处理敏感信息)
if (method == HttpMethod.POST || method == HttpMethod.PUT || method == HttpMethod.PATCH) {
val contentType = request.getHeader(HttpHeaders.CONTENT_TYPE)
if (contentType?.contains("application/json") == true) {
val body = ctx.body().asString()
if (!body.isNullOrBlank()) {
try {
val bodyJson = JsonObject(body)
// 处理敏感字段,如密码
val sanitizedBody = sanitizeSensitiveData(bodyJson)
logData.put("requestBody", sanitizedBody)
} catch (e: Exception) {
logData.put("requestBodyRaw", "无法解析为JSON: " + body.take(100) + "...")
}
}
}
}
// 尝试获取和记录响应数据
val responseData = ctx.get<String>("responseData")
if (!responseData.isNullOrBlank()) {
try {
val responseJson = JsonObject(responseData)
logData.put("responseBody", responseJson)
} catch (e: Exception) {
logData.put("responseBodyRaw", responseData.take(100) + "...")
}
}
// 根据状态码选择日志级别
MDC.put("statusCode", statusCode.toString())
MDC.put("duration", duration.toString())
val logMessage = buildString {
append("请求完成 - ")
append("方法: $method, ")
append("路径: $path, ")
append("状态码: $statusCode, ")
append("耗时: ${duration}ms, ")
append("请求ID: $requestId")
}
when {
statusCode >= 500 -> logger.error { logMessage }
statusCode >= 400 -> logger.warn { logMessage }
else -> logger.info { logMessage }
}
// 以JSON格式记录详细信息可根据需要启用
logger.debug { "请求详细信息: ${JsonUtil.toJsonStr(logData)}" }
} finally {
// 清理MDC
MDC.remove("requestId")
MDC.remove("method")
MDC.remove("path")
MDC.remove("remoteAddr")
MDC.remove("userId")
MDC.remove("statusCode")
MDC.remove("duration")
}
}
// 继续下一个处理器
ctx.next()
}
/**
* 处理敏感数据避免在日志中记录敏感信息
*/
private fun sanitizeSensitiveData(json: JsonObject): JsonObject {
val result = json.copy()
val sensitiveFields = listOf("password", "passwordConfirm", "oldPassword", "newPassword", "token", "accessToken", "refreshToken")
for (field in sensitiveFields) {
if (result.containsKey(field)) {
result.put(field, "******")
}
}
return result
}
}

View File

@ -0,0 +1,150 @@
package org.aikrai.vertx.http
import com.fasterxml.jackson.annotation.JsonInclude
import org.aikrai.vertx.constant.HttpStatus
import org.aikrai.vertx.utlis.Meta
/**
* 标准API响应格式用于所有HTTP响应
*
* @param code 状态码
* @param message 消息
* @param data 数据可为null
* @param requestId 请求ID用于跟踪请求
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
data class RespBean<T>(
val code: Int,
val message: String,
val data: T? = null,
var requestId: String? = null,
) {
companion object {
/**
* 创建成功响应
*
* @param data 响应数据
* @param message 成功消息
* @param code 状态码默认为HttpStatus.SUCCESS
* @return ApiResponse实例
*/
fun <T> success(data: T? = null, message: String = "Success", code: Int = HttpStatus.SUCCESS): RespBean<T> {
val finalCode = if (data == null && code == HttpStatus.SUCCESS) HttpStatus.NO_CONTENT else code
return RespBean(
code = finalCode,
message = message,
data = data
)
}
/**
* 创建错误响应
*
* @param code 错误码
* @param message 错误消息
* @param data 错误相关数据可选
* @return ApiResponse实例
*/
fun <T> error(code: Int = HttpStatus.ERROR, message: String, data: T? = null): RespBean<T> {
return RespBean(
code = code,
message = message,
data = data
)
}
/**
* 从异常创建错误响应
*
* @param exception 异常
* @param defaultStatusCode 默认状态码如果无法从异常确定状态码
* @return ApiResponse实例
*/
fun <T> fromException(
exception: Throwable,
defaultStatusCode: Int = HttpStatus.ERROR
): RespBean<T> {
// 确定状态码和消息
var statusCode = defaultStatusCode
val errorName: String
val errorMessage: String
val errorData: Any?
when (exception) {
is Meta -> {
// 根据Meta.name确定状态码
statusCode = when (exception.name) {
"Unauthorized" -> HttpStatus.UNAUTHORIZED
"Forbidden" -> HttpStatus.FORBIDDEN
"NotFound" -> HttpStatus.NOT_FOUND
"RequiredArgument", "InvalidArgument", "BadRequest" -> HttpStatus.BAD_REQUEST
"Timeout" -> HttpStatus.ERROR // 使用ERROR作为超时状态码
"NotSupported" -> HttpStatus.UNSUPPORTED_TYPE
"Unimplemented" -> HttpStatus.NOT_IMPLEMENTED
else -> defaultStatusCode
}
errorName = exception.name
errorMessage = exception.message
errorData = exception.data
}
is IllegalArgumentException -> {
statusCode = HttpStatus.BAD_REQUEST
errorName = "BadRequest"
errorMessage = exception.message ?: "Invalid argument"
errorData = null
}
else -> {
// 通用异常处理
errorName = exception.javaClass.simpleName
errorMessage = exception.message ?: "Internal Server Error"
errorData = null
}
}
// 组合错误名称和消息
val finalMessage = if (errorMessage.contains(errorName, ignoreCase = true)) {
errorMessage
} else {
"$errorName: $errorMessage"
}
@Suppress("UNCHECKED_CAST")
return RespBean(
code = statusCode,
message = finalMessage,
data = errorData as? T
)
}
}
}
/**
* HTTP状态码映射用于将Meta.name映射到HTTP状态码
*/
object HttpStatusMapping {
private val mapping = mapOf(
"Unauthorized" to HttpStatus.UNAUTHORIZED,
"Forbidden" to HttpStatus.FORBIDDEN,
"NotFound" to HttpStatus.NOT_FOUND,
"RequiredArgument" to HttpStatus.BAD_REQUEST,
"InvalidArgument" to HttpStatus.BAD_REQUEST,
"BadRequest" to HttpStatus.BAD_REQUEST,
"Timeout" to HttpStatus.ERROR, // 使用ERROR作为超时状态码
"Repository" to HttpStatus.ERROR,
"Unimplemented" to HttpStatus.NOT_IMPLEMENTED,
"NotSupported" to HttpStatus.UNSUPPORTED_TYPE
)
/**
* 获取与Meta.name对应的HTTP状态码
*
* @param name Meta.name或其前缀
* @param defaultCode 默认状态码
* @return HTTP状态码
*/
fun getCode(name: String, defaultCode: Int = HttpStatus.ERROR): Int {
// 检查是否包含前缀,如"Repository:"
val baseName = name.substringBefore(':')
return mapping[baseName] ?: mapping[name] ?: defaultCode
}
}

View File

@ -0,0 +1,40 @@
package org.aikrai.vertx.resp
import io.vertx.core.http.HttpHeaders
import io.vertx.ext.web.RoutingContext
import org.aikrai.vertx.http.RespBean
import org.aikrai.vertx.jackson.JsonUtil
/**
* 默认响应处理器实现
*/
class DefaultResponseHandler : ResponseHandlerInterface {
/**
* 处理成功响应
*/
override suspend fun handle(
ctx: RoutingContext,
responseData: Any?,
customizeResponse: Boolean
) {
val requestId = ctx.get<String>("requestId")
// 使用RespBean包装响应数据
val respBean = RespBean.success(responseData)
respBean.requestId = requestId
val resStr = JsonUtil.toJsonStr(respBean)
// 存储响应内容用于日志
ctx.put("responseData", resStr)
// 如果需要自定义响应,则不发送标准响应
if (customizeResponse) return
// 发送标准成功响应
ctx.response()
.setStatusCode(respBean.code)
.putHeader(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
.end(resStr)
}
}

View File

@ -0,0 +1,17 @@
package org.aikrai.vertx.resp
import io.vertx.ext.web.RoutingContext
/**
* 响应处理器接口负责处理API响应
*/
interface ResponseHandlerInterface {
/**
* 处理成功响应
*
* @param ctx 路由上下文
* @param responseData 响应数据
* @param customizeResponse 是否自定义响应如果为true则由控制器自行处理响应
*/
suspend fun handle(ctx: RoutingContext, responseData: Any?, customizeResponse: Boolean = false)
}

View File

@ -1,7 +1,7 @@
package org.aikrai.vertx.utlis
import io.vertx.core.MultiMap
import mu.KotlinLogging
import io.github.oshai.kotlinlogging.KotlinLogging
import java.sql.Timestamp
import java.time.Instant
import java.time.OffsetDateTime