Compare commits

...

7 Commits
sql-gen ... dev

Author SHA1 Message Date
95f76404c2 refactor(vertx-demo):重构项目目录结构 2025-05-12 15:41:14 +08:00
047717ad71 docs(README): 更新项目说明文档 2025-04-29 11:53:12 +08:00
bf3c91f18a refactor(日志): 优化日志记录并移除 MDC 使用
- 移除了 MDC(Mapped Diagnostic Context)的使用,简化了日志记录逻辑- 优化了请求日志和错误日志的记录格式,增加了用户 ID 等信息
- 调整了日志输出级别和格式,提高了日志的可读性和实用性
- 移除了未使用的 RespBean 类,简化了项目结构
2025-04-29 09:58:57 +08:00
1447e26d25 build(vertx-demo): 更新日志库版本 2025-04-28 10:42:26 +08:00
394161415c refactor(vertx-fw):重构异常处理 2025-04-28 09:57:43 +08:00
e7016373c2 feat(config): 重构配置管理 2025-04-27 15:50:13 +08:00
e97f3f5519 feat(db): 添加数据库迁移工具和相关功能 2025-03-21 15:55:37 +08:00
81 changed files with 5025 additions and 963 deletions

3
.gitignore vendored
View File

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

393
README.md
View File

@ -1,213 +1,236 @@
# Vertx-pj 说明
闲暇时编写,问题较多,仅供参考。
基于vert.x的web开发框架提供了一些简单的封装使得开发更加便捷。<br/>
**写的简单,问题不少,仅供参考**。
- **vertx-fw**: 简单封装的web开发框架
- **vertx-demo**: 使用vertx-fw开发的demo
# Vert.x 快速开发模板
### 项目介绍
这是一个基于 Vert.x 库的后端快速开发模板,采用 Kotlin 语言和协程实现高性能异步编程。本项目提供了完整的开发框架,能够帮助开发者快速搭建响应式、模块化、高性能的后端服务。
#### 技术栈
- **框架**: Vert.x 4.x (响应式异步框架)
- **语言**: Kotlin + 协程
- **数据库**: PostgreSQL + Vert.x PgClient
- **DI**: Google Guice
- **认证**: JWT + 角色权限体系
- **工具库**: Hutool(工具包)、mu(日志)、jackson(序列化)
- **文档**: OpenAPI 3.0 + Apifox集成
## 技术栈
#### 设计
```
├── Application (入口)
├── Verticles
│ ├── MainVerticle (主部署器)
│ └── WebVerticle (WEB服务)
├── Config
│ ├── JWT认证
│ └── 依赖注入配置
├── Controller (接口层)
├── Service (业务层)
├── Repository (数据层)
└── Domain (领域模型)
```
- **核心框架**: Vert.x 4.5.x (响应式、事件驱动框架)
- **编程语言**: Kotlin 1.9.x (协程支持)
- **依赖注入**: Google Guice 7.0.0
- **数据库**: PostgreSQL, (不支持MySQL)
- **缓存**: Redis
- **认证**: JWT
- **构建工具**: Gradle with Kotlin DSL
- **代码规范**: Spotless, ktlint
#### 核心特性
1. **全异步架构**基于Vert.x事件循环和Kotlin协程
2. **自动路由注册**通过注解自动映射REST接口
3. **声明式事务**通过withTransaction函数管理事务
4. **RBAC权限控制**:支持角色+权限双重验证
5. **智能参数解析**:自动处理路径/查询/表单/JSON参数
6. **全链路日志**包含请求ID、用户上下文、耗时预警
7. **代码生成**Repository自动生成基础SQL
## 项目结构
---
项目采用模块化设计,主要分为两个模块:
### 使用说明
### vertx-fw (框架核心)
提供基础架构和通用功能:
- 依赖注入配置
- 路由管理
- 认证与授权
- 数据库操作封装
- 事务管理
- 全局异常处理
- 通用工具类
### vertx-demo (应用示例)
包含示例应用代码:
- 控制器实现示例
- 服务层实现
- 数据访问层
- 实体类定义
- 配置文件
## 主要特性
### 1. 注解驱动的路由
通过简单的注解即可定义 API 路由和参数绑定:
#### 1. 快速启动
```kotlin
// 配置application.yaml
server:
name: vtx_demo
port: 8080
context: api
package: app
@D("测试1:测试")
@Controller
class Demo1Controller @Inject constructor(
private val accountService: AccountService,
private val accountRepository: AccountRepository
) {
jwt:
key: 123456sdfjasdfjl
@AllowAnonymous
@D("参数测试", "详细说明......")
suspend fun test1(
@D("name", "姓名") name: String?,
@D("age", "年龄") age: Int?,
@D("list", "列表") list: List<String>?,
@D("status", "状态-0正常,1禁用,2删除") status: Status?,
@D("account", "账号") account: Account?
) {
// 业务逻辑
}
}
```
### 2. 据库支持
#### Repository 模式
```kotlin
interface AccountRepository : Repository<Long, Account> {
suspend fun getByName(name: String): Account?
suspend fun getUserList(userName: String?, phone: String?): List<Account>
}
```
#### 事务管理
```kotlin
suspend fun testTransaction() {
withTransaction {
accountRepository.update(1L, mapOf("avatar" to "test0001"))
// 嵌套事务
try {
withTransaction {
accountRepository.update(1L, mapOf("avatar" to "test002"))
throw Meta.error("test transaction", "test transaction")
}
} catch (e: Exception) {
logger.info { "内层事务失败已处理: ${e.message}" }
}
}
}
```
#### 数据库迁移
支持通过实体类注解自动生成数据库变更脚本,遵循版本化管理。
### 3. 安全与认证
- JWT 认证
- 基于角色的权限控制
- 自定义安全注解: `@AllowAnonymous`, `@CheckRole`, `@CheckPermission`
### 4. API 文档生成
自动生成 OpenAPI 规范文档,支持与 ApiFox 等 API 工具集成。
### 5. 统一响应处理
标准化的 JSON 响应格式:
```json
{
"code": 200,
"message": "Success",
"data": { ... },
"requestId": "1899712678486753280"
}
```
### 6. 全局异常处理
统一捕获和处理异常,转换为友好的 API 响应。
### 7. 配置管理
支持 YAML 配置,环境变量和系统属性覆盖,多环境配置。
## 快速开始
### 环境要求
- JDK 17+
- PostgreSQL 数据库
- Redis (可选)
### 构建与运行
1. **克隆项目**
```bash
git clone https://github.com/yourusername/vertx-pj.git
cd vertx-pj
```
2. **配置数据库**
编辑 `vertx-demo/src/main/resources/config/application-database.yml`
```yaml
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
apifox:
token: APS-xxxxxxxxxxxxxxx
projectId: xxxxxx
folderId: xxxxxx
username: your_username
password: your_password
```
#### 2. 创建Controller
3. **构建项目**
```bash
./gradlew clean build
```
4. **运行项目**
```bash
./gradlew :vertx-demo:run
```
## 创建新API
1. 创建实体类
```kotlin
@Controller("/user")
@TableName("users")
class User : BaseEntity() {
@TableId(type = IdType.ASSIGN_ID)
var userId: Long = 0L
@TableField("user_name")
var userName: String? = ""
var status: Status? = Status.ACTIVE
}
```
2. 创建Repository
```kotlin
interface UserRepository : Repository<Long, User> {
suspend fun findByName(name: String): User?
}
```
3. 创建Service
```kotlin
class UserService @Inject constructor(
private val userRepository: UserRepository
) {
suspend fun getUserByName(name: String): User? {
return userRepository.findByName(name)
}
}
```
4. 创建Controller
```kotlin
@D("用户管理")
@Controller("/users")
class UserController @Inject constructor(
private val userService: UserService
) {
@D("获取用户详情")
@CheckRole("admin", mode = Mode.OR) // 权限控制
suspend fun getDetail(
ctx: RoutingContext,
@D("userId") userId: Long
): User {
return userService.getDetail(userId)
@D("通过用户名获取用户")
suspend fun getByName(
@D("name", "用户名") name: String
): User? {
return userService.getUserByName(name)
}
}
```
#### 3. 定义Service
```kotlin
class UserService @Inject constructor(
private val snowflake: Snowflake,
private val userRepository: UserRepository
) {
suspend fun createUser(dto: UserDTO): Long {
return userRepository.create(dto.toEntity(snowflake))
}
suspend fun batchUpdate(ids: List<Long>) {
withTransaction() {
ids.forEach { id ->
userRepository.update(id, mapOf("status" to 1))
}
}
}
}
```
#### 4. 实现Repository
```kotlin
@ImplementedBy(UserRepositoryImpl::class)
interface UserRepository : Repository<Long, User> {
suspend fun findByEmail(email: String): User?
}
@Singleton
class UserRepositoryImpl @Inject constructor(sqlClient: SqlClient)
: RepositoryImpl<Long, User>(sqlClient), UserRepository {
override suspend fun findByEmail(email: String): User? {
return queryBuilder()
.eq(User::email, email)
.getOne()
}
}
```
#### 5. 数据库实体
```kotlin
@TableName("sys_user")
data class User(
@TableId(type = IdType.ASSIGN_ID)
@TableField("user_id")
val userId: Long = 0,
@TableField("user_name")
val userName: String,
@TableField(fill = FieldFill.INSERT)
val createTime: LocalDateTime = LocalDateTime.now()
)
```
#### 6. 运行与测试
```bash
# 启动应用
gradle run
# 生成的API文档
http://localhost:8080/api/docs
# 示例请求
POST /api/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "123456"
}
```
---
### 高级功能
#### 事务管理
```kotlin
suspend fun transfer(from: Long, to: Long, amount: Double) {
withTransaction {
accountRepository.deductBalance(tx, from, amount)
accountRepository.addBalance(tx, to, amount)
}
}
```
#### 复杂查询
```kotlin
val users = queryBuilder()
.like(User::name, "%张%")
.between("create_time", start, end)
.orderByDesc("user_id")
.getList()
```
#### 自定义响应
```kotlin
@CustomizeResponse
suspend fun customResponse(): RespBean {
return RespBean.success("自定义格式")
}
```
#### 权限配置
```kotlin
@CheckRole(value = ["admin", "supervisor"], mode = Mode.OR)
@CheckPermission(value = ["user:write", "user:update"])
suspend fun sensitiveOperation() {
// 需要admin或supervisor角色
// 且拥有user:write或user:update权限
}
```
---
## 快速开发注解说明

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 {
@ -41,16 +41,8 @@ tasks.test {
}
}
tasks.compileKotlin {
kotlinOptions {
jvmTarget = "17"
}
}
tasks.compileTestKotlin {
kotlinOptions {
jvmTarget = "17"
}
kotlin {
jvmToolchain(17)
}
spotless {
@ -75,44 +67,37 @@ 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")
// hutool
implementation("cn.hutool:hutool-json:5.8.24")
implementation("cn.hutool:hutool-crypto:5.8.24")
implementation("com.google.inject:guice:5.1.0")
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.github.ben-manes.caffeine:caffeine:3.1.8")
implementation("dev.hsbrysk:caffeine-coroutines:1.0.0")
// log
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("org.slf4j:slf4j-api:2.0.6")
implementation("ch.qos.logback:logback-classic:1.4.14")
implementation("org.codehaus.janino:janino:3.1.8")
// jpa
// implementation("jakarta.persistence:jakarta.persistence-api:3.2.0")
// implementation("jakarta.validation:jakarta.validation-api:3.1.0")
implementation("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")
testImplementation("io.vertx:vertx-junit5")
testImplementation("org.junit.jupiter:junit-jupiter:$junitJupiterVersion")

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,47 +0,0 @@
package app.config
import io.vertx.mysqlclient.MySQLException
import io.vertx.pgclient.PgException
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.utlis.Meta
import java.sql.SQLException
object FailureParser {
private fun Throwable.toMeta(): Meta {
val error = this as? Meta
if (error != null) {
return error
}
val name = javaClass.simpleName
val pgException = this as? PgException
if (pgException != null) {
return Meta(name, pgException.errorMessage ?: "", null)
}
val mysqlException = this as? MySQLException
if (mysqlException != null) {
return Meta(name, mysqlException.message ?: "", null)
}
val message = if (message != null) message.orEmpty() else toString()
return Meta(name, message, null)
}
private fun Throwable.info(): String {
if (this is Meta) {
return JsonUtil.toJsonStr(this)
}
return stackTraceToString()
}
data class Failure(val statusCode: Int, val response: Meta)
fun parse(statusCode: Int, error: Throwable): Failure {
return when (error) {
is SQLException -> Failure(statusCode, Meta.error(error.javaClass.name, "执行错误"))
else -> Failure(statusCode, error.toMeta())
}
}
}

View File

@ -1,70 +1,55 @@
package app.config
import app.config.auth.JWTAuthProvider
import app.config.provider.JWTAuthProvider
import app.config.provider.DbPoolProvider
import app.config.provider.RedisProvider
import cn.hutool.core.lang.Snowflake
import cn.hutool.core.util.IdUtil
import com.google.inject.*
import com.google.inject.name.Names
import com.google.inject.AbstractModule
import com.google.inject.Guice
import com.google.inject.Injector
import com.google.inject.Singleton
import io.vertx.core.Vertx
import io.vertx.ext.auth.jwt.JWTAuth
import io.vertx.pgclient.PgBuilder
import io.vertx.pgclient.PgConnectOptions
import io.vertx.redis.client.Redis
import io.vertx.sqlclient.Pool
import io.vertx.sqlclient.PoolOptions
import io.vertx.sqlclient.SqlClient
import kotlinx.coroutines.CoroutineScope
import org.aikrai.vertx.config.Config
import org.aikrai.vertx.config.DefaultScope
import org.aikrai.vertx.db.tx.TxMgrHolder.initTxMgr
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() {
val pool = getDbPool().also { initTxMgr(it) }
val coroutineScope = DefaultScope(vertx)
install(FrameworkConfigModule())
for ((key, value) in Config.getConfigMap()) {
bind(String::class.java).annotatedWith(Names.named(key)).toInstance(value.toString())
}
bind(Vertx::class.java).toInstance(vertx)
bind(CoroutineScope::class.java).toInstance(coroutineScope)
bind(CoroutineScope::class.java).toInstance(DefaultScope(vertx))
bind(Snowflake::class.java).toInstance(IdUtil.getSnowflake())
bind(Redis::class.java).toProvider(RedisProvider::class.java).`in`(Singleton::class.java)
bind(Pool::class.java).toProvider(DbPoolProvider::class.java).`in`(Singleton::class.java)
bind(SqlClient::class.java).to(Pool::class.java)
bind(JWTAuth::class.java).toProvider(JWTAuthProvider::class.java).`in`(Singleton::class.java)
// 绑定 DbPool 为单例
bind(Pool::class.java).toInstance(pool)
bind(SqlClient::class.java).toInstance(pool)
}
private fun getDbPool(): Pool {
// val type = configMap["databases.type"].toString()
// val name = configMap["databases.name"].toString()
// val host = configMap["databases.host"].toString()
// val port = configMap["databases.port"].toString()
// val user = configMap["databases.username"].toString()
// val password = configMap["databases.password"].toString()
// val dbMap = Config.getKey("databases") as Map<String, String>
val name = Config.getKey("databases.name").toString()
val host = Config.getKey("databases.host").toString()
val port = Config.getKey("databases.port").toString()
val user = Config.getKey("databases.username").toString()
val password = Config.getKey("databases.password").toString()
val poolOptions = PoolOptions().setMaxSize(10)
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()
bind(RequestLogHandler::class.java).`in`(Singleton::class.java)
bind(GlobalErrorHandler::class.java).`in`(Singleton::class.java)
}
}

View File

@ -1,52 +0,0 @@
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")
}
}
}

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

@ -1,33 +1,41 @@
package app.config.auth
package app.config.handler
import cn.hutool.core.lang.Snowflake
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
class JwtAuthenticationHandler(
/**
* JWT认证处理器
*/
class JwtAuthHandler @Inject constructor(
val scope: CoroutineScope,
val tokenService: TokenService,
val context: String,
val snowflake: Snowflake
val serverConfig: ServerConfig,
) : AuthenticationHandler {
override fun handle(event: RoutingContext) {
event.put("requestId", snowflake.nextId())
val path = event.request().path().replace("$context/", "/").replace("//", "/")
override fun handle(ctx: RoutingContext) {
val path = ctx.request().path().replace("${serverConfig.context}/", "/").replace("//", "/")
if (isPathExcluded(path, anonymous)) {
event.next()
ctx.next()
return
}
scope.launch {
try {
val user = tokenService.getLoginUser(event)
tokenService.verifyToken(user)
event.setUser(user)
event.next()
val user = tokenService.getLoginUser(ctx)
ctx.setUser(user)
ctx.next()
} catch (e: Throwable) {
event.fail(401, Meta.unauthorized(e.message ?: "token"))
val metaError = when (e) {
is Meta -> e
else -> Meta.unauthorized(e.message ?: "认证失败")
}
ctx.fail(HttpStatus.UNAUTHORIZED, metaError)
}
}
}
@ -36,6 +44,9 @@ class JwtAuthenticationHandler(
"/apidoc.json"
)
/**
* 检查路径是否在排除列表中
*/
private fun isPathExcluded(path: String, excludePatterns: List<String>): Boolean {
for (pattern in excludePatterns) {
val regexPattern = pattern

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.ResponseHandler
/**
* 响应处理器负责处理API响应
*/
@Singleton
class ResponseHandler : ResponseHandler {
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)
}
else -> {
val respBean = RespBean.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

@ -0,0 +1,43 @@
package app.config.provider
import com.google.inject.Inject
import com.google.inject.Provider
import com.google.inject.Singleton
import io.vertx.core.Vertx
import io.vertx.pgclient.PgBuilder
import io.vertx.pgclient.PgConnectOptions
import io.vertx.sqlclient.Pool
import io.vertx.sqlclient.PoolOptions
import org.aikrai.vertx.config.DatabaseConfig
import org.aikrai.vertx.db.tx.TxMgrHolder
/**
* 数据库连接池提供者
*/
@Singleton
class DbPoolProvider @Inject constructor(
private val vertx: Vertx,
private val dbConfig: DatabaseConfig
) : Provider<Pool> {
override fun get(): Pool {
val poolOptions = PoolOptions().setMaxSize(dbConfig.maxPoolSize)
val clientOptions = PgConnectOptions()
.setHost(dbConfig.host)
.setPort(dbConfig.port)
.setDatabase(dbConfig.name)
.setUser(dbConfig.username)
.setPassword(dbConfig.password)
.setTcpKeepAlive(true)
val pool = PgBuilder.pool()
.connectingTo(clientOptions)
.with(poolOptions)
.using(vertx)
.build()
// 初始化事务管理器
TxMgrHolder.initTxMgr(pool)
return pool
}
}

View File

@ -1,23 +1,23 @@
package app.config.auth
package app.config.provider
import com.google.inject.Inject
import com.google.inject.Provider
import com.google.inject.name.Named
import io.vertx.core.Vertx
import io.vertx.ext.auth.PubSecKeyOptions
import io.vertx.ext.auth.jwt.JWTAuth
import io.vertx.ext.auth.jwt.JWTAuthOptions
import org.aikrai.vertx.config.JwtConfig
class JWTAuthProvider @Inject constructor(
private val vertx: Vertx,
@Named("jwt.key") private val key: String
private val jwtConfig: JwtConfig
) : Provider<JWTAuth> {
override fun get(): JWTAuth {
val options = JWTAuthOptions()
.addPubSecKey(
PubSecKeyOptions()
.setAlgorithm("HS256")
.setBuffer(key)
.setAlgorithm(jwtConfig.algorithm)
.setBuffer(jwtConfig.key)
)
return JWTAuth.create(vertx, options)
}

View File

@ -0,0 +1,24 @@
package app.config.provider
import com.google.inject.Inject
import com.google.inject.Provider
import io.vertx.core.Vertx
import io.vertx.redis.client.Redis
import io.vertx.redis.client.RedisClientType
import io.vertx.redis.client.RedisOptions
import org.aikrai.vertx.config.RedisConfig
class RedisProvider @Inject constructor(
private val vertx: Vertx,
private val redisConfig: RedisConfig
) : Provider<Redis> {
override fun get(): Redis {
val options = RedisOptions()
.setType(RedisClientType.STANDALONE)
.addConnectionString("redis://${redisConfig.host}:${redisConfig.port}/${redisConfig.db}")
.setMaxPoolSize(redisConfig.poolSize)
.setMaxPoolWaiting(redisConfig.maxPoolWaiting)
redisConfig.password?.let { options.setPassword(it) }
return Redis.createClient(vertx, options)
}
}

View File

@ -1,6 +1,6 @@
package app.controller
import app.data.domain.account.LoginDTO
import app.data.dto.account.LoginDTO
import app.service.account.AccountService
import com.google.inject.Inject
import io.vertx.ext.web.RoutingContext

View File

@ -1,15 +1,16 @@
package app.controller
import app.data.domain.account.Account
import app.data.domain.account.AccountRepository
import app.repository.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,8 @@ class Demo1Controller @Inject constructor(
@D("account", "账号") account: Account?
) {
logger.info { "你好" }
// throw Meta.error("TestError", "测试错误")
// throw IllegalArgumentException("测试错误")
println(age)
println(list)
println("test-$name")
@ -59,7 +62,7 @@ class Demo1Controller @Inject constructor(
suspend fun testRetriever(
@D("key", "key") key: String
) {
val configMap = Config.getKey(key)
val configMap = Config.getStringOrNull(key)
println(configMap)
}
}

View File

@ -1,5 +1,6 @@
package app.data.domain.account
import app.data.emun.SexType
import app.data.emun.Status
import org.aikrai.vertx.db.annotation.*
import org.aikrai.vertx.jackson.JsonUtil
@ -10,23 +11,50 @@ import java.sql.Timestamp
class Account : BaseEntity() {
@TableId(type = IdType.ASSIGN_ID)
@TableFieldComment("用户ID")
var userId: Long = 0L
@TableField("user_name")
var userName: String? = ""
@TableFieldComment("部门ID")
var deptId: Long? = 0L
@TableField(length = 30)
@TableFieldComment("用户账号")
var userName: String = ""
@TableField(length = 30)
@TableFieldComment("用户昵称")
var nickName: String = ""
@TableField(length = 2)
@TableFieldComment("用户类型")
var userType: String? = ""
@TableField(length = 50)
@TableFieldComment("用户邮箱")
var email: String? = ""
var phone: String? = ""
@TableField(length = 11)
@TableFieldComment("手机号码")
var phonenumber: String? = ""
@TableField(length = 1)
@TableFieldComment("用户性别0男 1女 2未知")
var sex: SexType? = SexType.UNKNOWN
@TableField(length = 100)
@TableFieldComment("头像地址")
var avatar: String? = null
@TableField(length = 100)
@TableFieldComment("密码")
var password: String? = null
@TableField(length = 1)
@TableFieldComment("帐号状态0正常 1停用")
var status: Status? = Status.ACTIVE
@TableField(length = 1)
@TableFieldComment("删除标志0代表存在 2代表删除")
var delFlag: Char? = null
var loginIp: String? = null

View File

@ -1,7 +0,0 @@
package app.data.domain.role
import com.google.inject.ImplementedBy
import org.aikrai.vertx.db.Repository
@ImplementedBy(RoleRepositoryImpl::class)
interface RoleRepository : Repository<Long, Role>

View File

@ -1,9 +0,0 @@
package app.data.domain.role
import com.google.inject.Inject
import io.vertx.sqlclient.SqlClient
import org.aikrai.vertx.db.RepositoryImpl
class RoleRepositoryImpl @Inject constructor(
sqlClient: SqlClient
) : RepositoryImpl<Long, Role>(sqlClient), RoleRepository

View File

@ -1,4 +1,4 @@
package app.data.domain.account.modle
package app.data.dto.account
import app.data.domain.account.Account
import app.data.domain.menu.Menu

View File

@ -1,4 +1,4 @@
package app.data.domain.account.modle
package app.data.dto.account
import app.data.domain.role.Role

View File

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

View File

@ -1,4 +1,4 @@
package app.base.domain.auth.modle
package app.data.dto.account
class LoginUser {
var accountId: Long = 0L

View File

@ -1,4 +1,4 @@
package app.data.domain.menu.modle
package app.data.dto.menu
import app.data.domain.menu.Menu

View File

@ -1,4 +1,4 @@
package app.base.domain.auth.menu
package app.data.emun
enum class MenuType(val desc: String) {
M("目录"),
@ -8,7 +8,7 @@ enum class MenuType(val desc: String) {
companion object {
fun parse(value: String?): MenuType? {
if (value.isNullOrBlank()) return null
return MenuType.values().find { it.name == value || it.desc == value }
return MenuType.entries.find { it.name == value || it.desc == value }
}
}
}

View File

@ -0,0 +1,28 @@
package app.data.emun
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonValue
import org.aikrai.vertx.db.annotation.EnumValue
enum class SexType(private val code: Int, private val description: String) {
MALE(0, ""),
FEMALE(1, ""),
UNKNOWN(2, "未知");
@JsonValue
@EnumValue
fun getCode(): Int {
return code
}
override fun toString(): String {
return description
}
companion object {
@JsonCreator
fun parse(code: Int): SexType? {
return SexType.entries.find { it.code == code }
}
}
}

View File

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

View File

@ -1,9 +1,11 @@
package app.data.domain.account
package app.repository
import app.data.domain.account.modle.AccountRoleAccessDTO
import app.data.domain.account.modle.AccountRoleDTO
import app.data.domain.account.Account
import app.repository.impl.AccountRepositoryImpl
import app.data.dto.account.AccountRoleAccessDTO
import app.data.dto.account.AccountRoleDTO
import com.google.inject.ImplementedBy
import org.aikrai.vertx.db.Repository
import org.aikrai.vertx.db.wrapper.Repository
@ImplementedBy(AccountRepositoryImpl::class)
interface AccountRepository : Repository<Long, Account> {

View File

@ -1,7 +1,9 @@
package app.data.domain.menu
package app.repository
import app.data.domain.menu.Menu
import app.repository.impl.MenuRepositoryImpl
import com.google.inject.ImplementedBy
import org.aikrai.vertx.db.Repository
import org.aikrai.vertx.db.wrapper.Repository
@ImplementedBy(MenuRepositoryImpl::class)
interface MenuRepository : Repository<Long, Menu> {

View File

@ -0,0 +1,9 @@
package app.repository
import app.data.domain.role.Role
import app.repository.impl.RoleRepositoryImpl
import com.google.inject.ImplementedBy
import org.aikrai.vertx.db.wrapper.Repository
@ImplementedBy(RoleRepositoryImpl::class)
interface RoleRepository : Repository<Long, Role>

View File

@ -1,10 +1,12 @@
package app.data.domain.account
package app.repository.impl
import app.data.domain.account.modle.AccountRoleAccessDTO
import app.data.domain.account.modle.AccountRoleDTO
import app.data.domain.account.Account
import app.data.dto.account.AccountRoleAccessDTO
import app.data.dto.account.AccountRoleDTO
import app.repository.AccountRepository
import com.google.inject.Inject
import io.vertx.sqlclient.SqlClient
import org.aikrai.vertx.db.RepositoryImpl
import org.aikrai.vertx.db.wrapper.RepositoryImpl
class AccountRepositoryImpl @Inject constructor(
sqlClient: SqlClient
@ -16,7 +18,7 @@ class AccountRepositoryImpl @Inject constructor(
): List<Account> {
return queryBuilder()
.eq(!userName.isNullOrBlank(), Account::userName, userName)
.eq(!phone.isNullOrBlank(), Account::phone, phone)
.eq(!phone.isNullOrBlank(), Account::phonenumber, phone)
.getList()
}

View File

@ -1,8 +1,10 @@
package app.data.domain.menu
package app.repository.impl
import app.data.domain.menu.Menu
import app.repository.MenuRepository
import com.google.inject.Inject
import io.vertx.sqlclient.SqlClient
import org.aikrai.vertx.db.RepositoryImpl
import org.aikrai.vertx.db.wrapper.RepositoryImpl
class MenuRepositoryImpl @Inject constructor(
sqlClient: SqlClient

View File

@ -0,0 +1,11 @@
package app.repository.impl
import app.data.domain.role.Role
import app.repository.RoleRepository
import com.google.inject.Inject
import io.vertx.sqlclient.SqlClient
import org.aikrai.vertx.db.wrapper.RepositoryImpl
class RoleRepositoryImpl @Inject constructor(
sqlClient: SqlClient
) : RepositoryImpl<Long, Role>(sqlClient), RoleRepository

View File

@ -1,6 +1,8 @@
package app.data.domain.menu
package app.service
import app.data.domain.account.Account
import app.data.domain.menu.Menu
import app.repository.MenuRepository
import com.google.inject.Inject
import com.google.inject.Singleton
import io.vertx.ext.auth.User
@ -47,7 +49,7 @@ class MenuManager @Inject constructor(
perms: String
) {
if (menuRepository.list(menuName).isNotEmpty()) {
throw Meta.error("MenuNameConflict", "菜单名称已存在")
throw Meta.Companion.error("MenuNameConflict", "菜单名称已存在")
}
val menu = Menu().apply {
this.menuName = menuName
@ -73,10 +75,10 @@ class MenuManager @Inject constructor(
visible: String?,
perms: String?
) {
val menu = menuRepository.get(menuId) ?: throw Meta.notFound("MenuNotFound", "菜单不存在")
val menu = menuRepository.get(menuId) ?: throw Meta.Companion.notFound("MenuNotFound", "菜单不存在")
if (menuName != null && menuName != menu.menuName && menuRepository.list(menuName).isNotEmpty()) {
throw Meta.error("MenuNameConflict", "菜单名称已存在")
throw Meta.Companion.error("MenuNameConflict", "菜单名称已存在")
}
menu.apply {

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.repository.AccountRepository
import app.data.dto.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 app.repository.AccountRepository
import app.utils.RedisUtil
import cn.hutool.core.util.IdUtil
import com.google.inject.Inject
import com.google.inject.Singleton
@ -14,22 +13,25 @@ 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 io.vertx.redis.client.Redis
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
import java.util.concurrent.TimeUnit
@Singleton
class TokenService @Inject constructor(
private val snowflake: Snowflake,
redis: Redis,
private val jwtAuth: JWTAuth,
private val redisClient: RedisClient,
private val accountRepository: AccountRepository,
) {
private val logger = KotlinLogging.logger { }
private val expireSeconds = 60 * 60 * 24 * 7
private val expireSeconds = 60L * 60 * 24 * 7
private val redisUtil = RedisUtil(redis)
suspend fun getLoginUser(ctx: RoutingContext): AuthUser {
val request = ctx.request()
@ -40,7 +42,7 @@ class TokenService @Inject constructor(
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")
val authInfoStr = redisUtil.getObject<String>(CacheConstants.LOGIN_TOKEN_KEY + userToken) ?: throw Meta.unauthorized("token")
return JsonUtil.parseObject(authInfoStr, AuthUser::class.java)
}
@ -48,14 +50,12 @@ class TokenService @Inject constructor(
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 authInfo = AuthUser(userInfo.account.userId, 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)
redisUtil.setObject(CacheConstants.LOGIN_TOKEN_KEY + token, authInfoStr, expireSeconds, TimeUnit.SECONDS)
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

@ -1,4 +1,4 @@
package app.util
package app.utils
import com.github.benmanes.caffeine.cache.Caffeine
import com.google.inject.Singleton

View File

@ -0,0 +1,835 @@
package app.utils
import io.vertx.kotlin.coroutines.coAwait
import io.vertx.redis.client.Command
import io.vertx.redis.client.Redis
import io.vertx.redis.client.Request
import java.util.concurrent.TimeUnit
class RedisUtil constructor(private val redis: Redis) {
/**
* 缓存基本的对象IntegerString实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
*/
suspend fun <T> setObject(key: String, value: T): Boolean {
val request = Request.cmd(Command.SET)
.arg(key)
.arg(value.toString())
.arg("KEEPTTL")
val response = redis.send(request).coAwait()
return response?.toString() == "OK"
}
/**
* 缓存基本的对象IntegerString实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
* @param timeout 时间
* @param timeUnit 时间颗粒度
*/
suspend fun <T> setObject(key: String, value: T, timeout: Long, timeUnit: TimeUnit): Boolean {
val expireSeconds = timeUnit.toSeconds(timeout)
val request = Request.cmd(Command.SET, key, value.toString(), "EX", expireSeconds.toString())
val response = redis.send(request).coAwait()
return response?.toString() == "OK"
}
/**
* 设置有效时间
*
* @param key Redis键
* @param timeout 超时时间
* @return true=设置成功false=设置失败
*/
suspend fun expire(key: String, timeout: Long): Boolean {
return expire(key, timeout, TimeUnit.SECONDS)
}
/**
* 设置有效时间
*
* @param key Redis键
* @param timeout 超时时间
* @param unit 时间单位
* @return true=设置成功false=设置失败
*/
suspend fun expire(key: String, timeout: Long, unit: TimeUnit): Boolean {
val expireSeconds = unit.toSeconds(timeout)
val response = redis.send(Request.cmd(Command.EXPIRE, key, expireSeconds.toString())).coAwait()
return response?.toLong() == 1L
}
/**
* 获取有效时间
*
* @param key Redis键
* @return 有效时间
*/
suspend fun getExpire(key: String): Long? {
val response = redis.send(Request.cmd(Command.TTL, key)).coAwait()
val ttl = response?.toLong()
return if (ttl == -1L || ttl == -2L) null else ttl
}
/**
* 判断 key是否存在
*
* @param key
* @return true 存在 false不存在
*/
suspend fun hasKey(key: String): Boolean {
val response = redis.send(Request.cmd(Command.EXISTS, key)).coAwait()
return (response?.toLong() ?: 0) > 0
}
/**
* 获得缓存的基本对象
*
* @param key 缓存键值
* @return 缓存键值对应的数据
*/
suspend fun <T> getObject(key: String): T? {
val response = redis.send(Request.cmd(Command.GET, key)).coAwait()
return response?.toString() as? T
}
/**
* 删除单个对象
*
* @param key
*/
suspend fun deleteObject(key: String): Boolean {
val response = redis.send(Request.cmd(Command.DEL, key)).coAwait()
return response?.toLong() == 1L
}
/**
* 删除集合对象
*
* @param collection 多个对象
* @return
*/
suspend fun deleteObject(collection: Collection<String>): Boolean {
if (collection.isEmpty()) return false
val response = redis.send(Request.cmd(Command.DEL, *collection.toTypedArray())).coAwait()
return (response?.toLong() ?: 0) > 0
}
/**
* 缓存List数据
*
* @param key 缓存的键值
* @param dataList 待缓存的List数据
* @return 缓存的对象
*/
suspend fun <T> setList(key: String, dataList: List<T>): Long {
val args = mutableListOf<String>().apply {
add(key)
dataList.forEach { add(it.toString()) }
}
val response = redis.send(Request.cmd(Command.RPUSH, *args.toTypedArray())).coAwait()
return response?.toLong() ?: 0
}
/**
* 获得缓存的list对象
*
* @param key 缓存的键值
* @return 缓存键值对应的数据
*/
suspend fun <T> getList(key: String): List<T>? {
val response = redis.send(Request.cmd(Command.LRANGE, key, "0", "-1")).coAwait()
return response?.map { it.toString() as T }?.toList()
}
/**
* 缓存Set
*
* @param key 缓存键值
* @param dataSet 缓存的数据
* @return 缓存数据的对象
*/
suspend fun <T> setSet(key: String, dataSet: Set<T>): Long {
val args = mutableListOf<String>().apply {
add(key)
dataSet.forEach { add(it.toString()) }
}
val response = redis.send(Request.cmd(Command.SADD, *args.toTypedArray())).coAwait()
return response?.toLong() ?: 0
}
/**
* 获得缓存的set
*
* @param key
* @return
*/
suspend fun <T> getSet(key: String): Set<T>? {
val response = redis.send(Request.cmd(Command.SMEMBERS, key)).coAwait()
return response?.map { it.toString() as T }?.toSet()
}
/**
* 向Set中添加一个或多个元素
*
* @param key 缓存键值
* @param values 要添加的元素
* @return 成功添加的元素数量
*/
suspend fun <T> sAdd(key: String, vararg values: T): Long {
val request = Request.cmd(Command.SADD)
.arg(key)
// 逐个添加参数
values.forEach {
request.arg(it.toString())
}
val response = redis.send(request).coAwait()
return response?.toLong() ?: 0
}
/**
* 向Set中添加一个或多个元素并设置过期时间
*
* @param key 缓存键值
* @param value 要添加的元素
* @param timeout 过期时间
* @param timeUnit 时间单位
* @return 是否成功添加元素
*/
suspend fun <T> sAdd(key: String, value: T, timeout: Long, timeUnit: TimeUnit): Boolean {
val added = sAdd(key, value) > 0
if (added) {
expire(key, timeout, timeUnit)
}
return added
}
/**
* 从Set中移除一个或多个元素
*
* @param key 缓存键值
* @param values 要移除的元素
* @return 成功移除的元素数量
*/
suspend fun <T> sRemove(key: String, vararg values: T): Long {
val args = mutableListOf<String>().apply {
add(key)
values.forEach { add(it.toString()) }
}
val response = redis.send(Request.cmd(Command.SREM, *args.toTypedArray())).coAwait()
return response?.toLong() ?: 0
}
/**
* 获取Set中的所有成员
*
* @param key 缓存键值
* @return Set中的所有成员
*/
suspend fun sMembers(key: String): List<String> {
val response = redis.send(Request.cmd(Command.SMEMBERS, key)).coAwait()
return response?.map { it.toString() } ?: emptyList()
}
/**
* 缓存Map
*
* @param key
* @param dataMap
*/
suspend fun <T> setMap(key: String, dataMap: Map<String, T>): Boolean {
if (dataMap.isEmpty()) return false
val args = mutableListOf<String>().apply {
add(key)
dataMap.forEach { (k, v) ->
add(k)
add(v.toString())
}
}
val response = redis.send(Request.cmd(Command.HMSET, *args.toTypedArray())).coAwait()
return response?.toString() == "OK"
}
/**
* 获得缓存的Map
*
* @param key
* @return
*/
suspend fun <T> getMap(key: String): Map<String, T>? {
val response = redis.send(Request.cmd(Command.HGETALL, key)).coAwait()
if (response == null || response.size() == 0) return null
val map = mutableMapOf<String, T>()
// for (i in 0 until response.size() step 2) {
// val k = response.get(i).toString()
// val v = response.get(i + 1).toString() as T
// map[k] = v
// }
for (item in response) {
val list = item.toMutableList()
val k = list[0].toString()
val v = list[1].toString() as T
map[k] = v
}
return map
}
/**
* 往Hash中存入数据
*
* @param key Redis键
* @param hKey Hash键
* @param value
*/
suspend fun <T> setMapValue(key: String, hKey: String, value: T): Boolean {
val response = redis.send(Request.cmd(Command.HSET, key, hKey, value.toString())).coAwait()
return response?.toLong() == 1L
}
/**
* 获取Hash中的数据
*
* @param key Redis键
* @param hKey Hash键
* @return Hash中的对象
*/
suspend fun <T> getMapValue(key: String, hKey: String): T? {
val response = redis.send(Request.cmd(Command.HGET, key, hKey)).coAwait()
return response?.toString() as? T
}
/**
* 获取多个Hash中的数据
*
* @param key Redis键
* @param hKeys Hash键集合
* @return Hash对象集合
*/
suspend fun <T> getMultiMapValue(key: String, hKeys: Collection<String>): List<T?>? {
val args = mutableListOf<String>().apply {
add(key)
addAll(hKeys)
}
val response = redis.send(Request.cmd(Command.HMGET, *args.toTypedArray())).coAwait()
return response?.map { if (it == null) null else it.toString() as T }
}
/**
* 删除Hash中的某条数据
*
* @param key Redis键
* @param hKey Hash键
* @return 是否成功
*/
suspend fun deleteMapValue(key: String, hKey: String): Boolean {
val response = redis.send(Request.cmd(Command.HDEL, key, hKey)).coAwait()
return response?.toLong() == 1L
}
/**
* 获得缓存的基本对象列表
*
* @param pattern 字符串前缀
* @return 对象列表
*/
suspend fun keys(pattern: String): List<String>? {
val response = redis.send(Request.cmd(Command.KEYS, pattern)).coAwait()
return response?.map { it.toString() }
}
/**
* 自增操作
*
* @param key Redis键
* @return 自增后的值
*/
suspend fun incr(key: String): Long {
val response = redis.send(Request.cmd(Command.INCR, key)).coAwait()
return response?.toLong() ?: 0L
}
/**
* 自增指定步长
*
* @param key Redis键
* @param increment 步长
* @return 自增后的值
*/
suspend fun incrBy(key: String, increment: Long): Long {
val response = redis.send(Request.cmd(Command.INCRBY, key, increment.toString())).coAwait()
return response?.toLong() ?: 0L
}
/**
* 自减操作
*
* @param key Redis键
* @return 自减后的值
*/
suspend fun decr(key: String): Long {
val response = redis.send(Request.cmd(Command.DECR, key)).coAwait()
return response?.toLong() ?: 0L
}
/**
* 自减指定步长
*
* @param key Redis键
* @param decrement 步长
* @return 自减后的值
*/
suspend fun decrBy(key: String, decrement: Long): Long {
val response = redis.send(Request.cmd(Command.DECRBY, key, decrement.toString())).coAwait()
return response?.toLong() ?: 0L
}
/**
* 浮点数自增
*
* @param key Redis键
* @param increment 增量
* @return 自增后的值
*/
suspend fun incrByFloat(key: String, increment: Double): Double {
val response = redis.send(Request.cmd(Command.INCRBYFLOAT, key, increment.toString())).coAwait()
return response?.toDouble() ?: 0.0
}
/**
* 设置键值并返回旧值
*
* @param key Redis键
* @param value 新值
* @return 旧值
*/
suspend fun <T> getSet(key: String, value: T): T? {
val response = redis.send(Request.cmd(Command.GETSET, key, value.toString())).coAwait()
return response?.toString() as? T
}
/**
* 设置键值仅当键不存在时
*
* @param key Redis键
* @param value
* @return 是否设置成功
*/
suspend fun <T> setIfAbsent(key: String, value: T): Boolean {
val response = redis.send(Request.cmd(Command.SETNX, key, value.toString())).coAwait()
return response?.toLong() == 1L
}
/**
* 获取字符串长度
*
* @param key Redis键
* @return 字符串长度
*/
suspend fun strlen(key: String): Long {
val response = redis.send(Request.cmd(Command.STRLEN, key)).coAwait()
return response?.toLong() ?: 0L
}
/**
* 追加字符串
*
* @param key Redis键
* @param value 追加的值
* @return 追加后的字符串长度
*/
suspend fun append(key: String, value: String): Long {
val response = redis.send(Request.cmd(Command.APPEND, key, value)).coAwait()
return response?.toLong() ?: 0L
}
/**
* 获取子字符串
*
* @param key Redis键
* @param start 开始位置
* @param end 结束位置
* @return 子字符串
*/
suspend fun getRange(key: String, start: Long, end: Long): String? {
val response = redis.send(Request.cmd(Command.GETRANGE, key, start.toString(), end.toString())).coAwait()
return response?.toString()
}
/**
* 设置子字符串
*
* @param key Redis键
* @param offset 偏移量
* @param value
* @return 修改后的字符串长度
*/
suspend fun setRange(key: String, offset: Long, value: String): Long {
val response = redis.send(Request.cmd(Command.SETRANGE, key, offset.toString(), value)).coAwait()
return response?.toLong() ?: 0L
}
/**
* 设置多个键值对
*
* @param keyValues 键值对key1, value1, key2, value2...
* @return 是否成功
*/
suspend fun mset(vararg keyValues: String): Boolean {
if (keyValues.size % 2 != 0) throw IllegalArgumentException("参数数量必须为偶数")
val response = redis.send(Request.cmd(Command.MSET, *keyValues)).coAwait()
return response?.toString() == "OK"
}
/**
* 获取多个键的值
*
* @param keys 键集合
* @return 值列表
*/
suspend fun mget(vararg keys: String): List<String?> {
val response = redis.send(Request.cmd(Command.MGET, *keys)).coAwait()
return response?.map { it?.toString() } ?: emptyList()
}
/**
* 向有序集合添加一个或多个成员或者更新已存在成员的分数
*
* @param key Redis键
* @param score 分数
* @param member 成员
* @return 成功添加的新成员的数量不包括那些被更新的已经存在的成员
*/
suspend fun zadd(key: String, score: Double, member: String): Long {
val request = Request.cmd(Command.ZADD)
.arg(key)
.arg(score.toString())
.arg(member)
val response = redis.send(request).coAwait()
return response?.toLong() ?: 0L
}
/**
* 为有序集合的成员增加分数
*
* @param key Redis键
* @param increment 增量分数
* @param member 成员
* @return 增加后的分数
*/
suspend fun zincrby(key: String, increment: Double, member: String): Double {
val request = Request.cmd(Command.ZINCRBY)
.arg(key)
.arg(increment.toString())
.arg(member)
val response = redis.send(request).coAwait()
return response?.toDouble() ?: 0.0
}
/**
* 获取有序集合中指定成员的分数
*
* @param key Redis键
* @param member 成员
* @return 分数如果成员不存在或键不存在则返回null
*/
suspend fun zscore(key: String, member: String): Double? {
val request = Request.cmd(Command.ZSCORE)
.arg(key)
.arg(member)
val response = redis.send(request).coAwait()
return response?.toDouble()
}
/**
* 获取有序集合中指定区间的成员按分数从高到低排序
*
* @param key Redis键
* @param start 开始位置
* @param stop 结束位置
* @param withScores 是否返回分数
* @return 指定区间的成员列表如果withScores为true则返回成员和分数的交替列表
*/
suspend fun zrevrange(key: String, start: Long, stop: Long, withScores: Boolean = false): List<String> {
// 使用可变参数列表而不是数组,避免类型转换问题
val request = if (withScores) {
Request.cmd(Command.ZREVRANGE)
.arg(key)
.arg(start.toString())
.arg(stop.toString())
.arg("WITHSCORES")
} else {
Request.cmd(Command.ZREVRANGE)
.arg(key)
.arg(start.toString())
.arg(stop.toString())
}
val response = redis.send(request).coAwait()
return response?.map { it.toString() } ?: emptyList()
}
/**
* 获取有序集合中所有成员的分数总和
*
* @param key Redis键
* @return 所有成员的分数总和
*/
suspend fun zsumScores(key: String): Double {
// 首先检查键是否存在
if (!hasKey(key)) return 0.0
val request = Request.cmd(Command.ZRANGE)
.arg(key)
.arg("0")
.arg("-1")
.arg("WITHSCORES")
val response = redis.send(request).coAwait()
if (response == null || response.size() == 0) return 0.0
var sum = 0.0
for (item in response) {
sum += item.toMutableList()[1]?.toString()?.toDoubleOrNull() ?: 0.0
}
return sum
}
/**
* 使用 SCAN 命令获取匹配指定模式的所有键
*
* @param pattern 匹配模式例如content:clicks:*
* @return 匹配模式的键列表
*/
suspend fun scan(pattern: String): List<String> {
val keys = mutableListOf<String>()
var cursor = "0"
do {
val request = Request.cmd(Command.SCAN)
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg("100") // 每次迭代返回的键数量
val response = redis.send(request).coAwait()
if (response != null && response.size() >= 2) {
cursor = response.get(0).toString()
// 从索引1处获取键列表
val scanKeys = response.get(1)
for (i in 0 until scanKeys.size()) {
keys.add(scanKeys.get(i).toString())
}
} else {
break
}
} while (cursor != "0")
return keys
}
/**
* 计算多个有序集合的并集并将结果存储在新的键中
*
* @param destKey 目标键存储计算结果
* @param keys 要计算并集的有序集合键列表
* @param weights 各有序集的权重可选
* @param aggregate 结果集的聚合方式可选默认为 SUM
* @return 目标键中的元素数量
*/
suspend fun zunionstore(
destKey: String,
keys: Array<String>,
weights: DoubleArray? = null,
aggregate: String = "SUM"
): Long {
if (keys.isEmpty()) return 0
val request = Request.cmd(Command.ZUNIONSTORE)
.arg(destKey)
.arg(keys.size.toString())
// 添加源集合键
keys.forEach { request.arg(it) }
// 添加权重(如果指定)
if (weights != null && weights.size == keys.size) {
request.arg("WEIGHTS")
weights.forEach { request.arg(it.toString()) }
}
// 添加聚合方式
if (aggregate in listOf("SUM", "MIN", "MAX")) {
request.arg("AGGREGATE")
request.arg(aggregate)
}
val response = redis.send(request).coAwait()
return response?.toLong() ?: 0L
}
/**
* 计算多个有序集合的并集使用相同的权重
*
* @param destKey 目标键存储计算结果
* @param keys 要计算并集的有序集合键列表
* @return 目标键中的元素数量
*/
suspend fun zunionstoreWithEqualWeights(destKey: String, keys: Array<String>): Long {
val weights = DoubleArray(keys.size) { 1.0 }
return zunionstore(destKey, keys, weights)
}
/**
* 计算多个有序集合的并集对结果取最大值
*
* @param destKey 目标键存储计算结果
* @param keys 要计算并集的有序集合键列表
* @return 目标键中的元素数量
*/
suspend fun zunionstoreMax(destKey: String, keys: Array<String>): Long {
return zunionstore(destKey, keys, null, "MAX")
}
/**
* 计算两个或多个有序集合的差集并将结果存储在新的键中
* 此方法仅适用于Redis 6.2+版本
*
* @param destKey 目标键存储计算结果
* @param keys 要计算差集的有序集合键数组第一个集合是基准
* @return 目标键中的元素数量
*/
suspend fun zdiffstore(destKey: String, keys: Array<String>): Long {
if (keys.isEmpty() || keys.size < 2) return 0L
val request = Request.cmd(Command.ZDIFFSTORE)
.arg(destKey)
.arg(keys.size.toString())
// 添加源集合键
keys.forEach { request.arg(it) }
val response = redis.send(request).coAwait()
return response?.toLong() ?: 0L
}
/**
* 获取有序集合的大小成员数量
*
* @param key Redis键
* @return 有序集合的大小
*/
suspend fun zcard(key: String): Long {
val request = Request.cmd(Command.ZCARD)
.arg(key)
val response = redis.send(request).coAwait()
return response?.toLong() ?: 0L
}
/**
* 获取有序集合中所有成员
*
* @param key Redis键
* @return 有序集合的所有成员
*/
suspend fun zall(key: String): List<String> {
return zrevrange(key, 0, -1)
}
/**
* 计算两个有序集合的差集并返回结果
* 此方法仅适用于Redis 6.2+版本
*
* @param keys 要计算差集的有序集合键数组第一个集合是基准
* @return 差集结果
*/
suspend fun zdiff(vararg keys: String): List<String> {
if (keys.isEmpty() || keys.size < 2) return emptyList()
val request = Request.cmd(Command.ZDIFF)
// 添加集合数量
request.arg(keys.size.toString())
// 添加源集合键
keys.forEach { request.arg(it) }
val response = redis.send(request).coAwait()
return response?.map { it.toString() } ?: emptyList()
}
/**
* 执行 Lua 脚本
*
* @param script Lua 脚本
* @param keys 脚本中使用的 KEYS 参数
* @param args 脚本中使用的 ARGV 参数
* @return 脚本执行结果
*/
suspend fun eval(script: String, keys: List<String> = emptyList(), vararg args: String): List<String>? {
val request = Request.cmd(Command.EVAL)
.arg(script)
.arg(keys.size.toString())
// 添加 KEYS 参数
keys.forEach { request.arg(it) }
// 添加 ARGV 参数
args.forEach { request.arg(it) }
val response = redis.send(request).coAwait()
return response?.map { it.toString() }
}
/**
* 执行 Lua 脚本并返回整数结果
*
* @param script Lua 脚本
* @param keys 脚本中使用的 KEYS 参数
* @param args 脚本中使用的 ARGV 参数
* @return 脚本执行结果整数
*/
suspend fun evalToInt(script: String, keys: List<String> = emptyList(), vararg args: String): Int? {
val result = eval(script, keys, *args)
return result?.firstOrNull()?.toIntOrNull()
}
/**
* 执行 Lua 脚本并返回长整数结果
*
* @param script Lua 脚本
* @param keys 脚本中使用的 KEYS 参数
* @param args 脚本中使用的 ARGV 参数
* @return 脚本执行结果长整数
*/
suspend fun evalToLong(script: String, keys: List<String> = emptyList(), vararg args: String): Long? {
val result = eval(script, keys, *args)
return result?.firstOrNull()?.toLongOrNull()
}
/**
* 执行 Lua 脚本并返回布尔结果
*
* @param script Lua 脚本
* @param keys 脚本中使用的 KEYS 参数
* @param args 脚本中使用的 ARGV 参数
* @return 脚本执行结果布尔值
*/
suspend fun evalToBoolean(script: String, keys: List<String> = emptyList(), vararg args: String): Boolean {
val result = eval(script, keys, *args)
return result?.firstOrNull()?.toIntOrNull() == 1
}
}

View File

@ -1,25 +1,25 @@
package app.port.aipfox
package app.utils.openapi
import app.util.openapi.OpenApiSpecGenerator
import com.google.inject.Inject
import com.google.inject.name.Named
import io.github.oshai.kotlinlogging.KotlinLogging
import io.vertx.core.Vertx
import io.vertx.core.http.HttpMethod
import io.vertx.core.json.JsonObject
import io.vertx.ext.web.client.WebClient
import io.vertx.ext.web.client.WebClientOptions
import mu.KotlinLogging
import org.aikrai.vertx.config.Config
class ApifoxClient @Inject constructor(
class ApifoxUtil @Inject constructor(
private val vertx: Vertx,
@Named("apifox.token") private val token: String,
@Named("apifox.projectId") private val projectId: String,
@Named("apifox.folderId") private val folderId: String,
@Named("server.name") private val serverName: String,
@Named("server.port") private val port: String
) {
private val logger = KotlinLogging.logger { }
private val token = Config.getString("apifox.token", "")
private val projectId = Config.getString("apifox.projectId", "")
private val folderId = Config.getString("apifox.folderId", "")
private val serverName = Config.getString("server.name", "")
private val port = Config.getString("server.port", "")
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)
@ -39,10 +39,10 @@ class ApifoxClient @Inject constructor(
.sendJsonObject(requestBody) { ar ->
if (ar.succeeded()) {
val response = ar.result()
logger.info("Received response with status code: ${response.statusCode()}")
logger.info("Response body: ${response.bodyAsString()}")
logger.info { "Received response with status code: ${response.statusCode()}" }
logger.info { "Response body: ${response.bodyAsString()}" }
} else {
logger.warn("Request failed: ${ar.cause().message}")
logger.warn { "Request failed: ${ar.cause().message}" }
}
}
}

View File

@ -1,4 +1,4 @@
package app.util.openapi
package app.utils.openapi
import cn.hutool.core.util.StrUtil
import io.swagger.v3.core.util.Json
@ -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,43 +1,33 @@
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.port.aipfox.ApifoxClient
import cn.hutool.core.lang.Snowflake
import app.config.handler.JwtAuthHandler
import app.config.handler.ResponseHandler
import app.utils.openapi.ApifoxUtil
import com.google.inject.Inject
import com.google.inject.Injector
import com.google.inject.name.Named
import io.vertx.core.Handler
import io.vertx.core.http.HttpHeaders
import io.vertx.core.http.HttpMethod
import io.vertx.core.http.HttpServerOptions
import io.vertx.ext.web.Router
import io.vertx.ext.web.RoutingContext
import io.vertx.ext.web.handler.BodyHandler
import io.vertx.ext.web.handler.CorsHandler
import io.vertx.kotlin.coroutines.CoroutineVerticle
import io.vertx.kotlin.coroutines.coAwait
import kotlinx.coroutines.CoroutineScope
import mu.KotlinLogging
import org.aikrai.vertx.auth.AuthUser
import org.aikrai.vertx.config.Config
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,
@Named("server.port") private val port: Int,
@Named("server.context") private val context: String,
private val globalErrorHandler: GlobalErrorHandler,
private val apiFoxUtil: ApifoxUtil,
) : CoroutineVerticle() {
private val logger = KotlinLogging.logger { }
@ -45,36 +35,34 @@ class WebVerticle @Inject constructor(
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(port)
.listen(serverConfig.port)
.coAwait()
apifoxClient.importOpenapi()
logger.info { "http server start - http://127.0.0.1:${server.actualPort()}/$context" }
// 生成ApiFox接口
apiFoxUtil.importOpenapi()
logger.info { "HTTP服务启动 - http://127.0.0.1:${server.actualPort()}${serverConfig.context}" }
}
override suspend fun stop() {
}
private fun setupRouter(rootRouter: Router, router: Router) {
rootRouter.route("/api" + "*").subRouter(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, context, snowflake)
router.route("/*").handler(authHandler)
val routerBuilder = RouterBuilder(coroutineScope, router, serverConfig.scanPackage, responseHandler)
.build{ getIt.getInstance(it) }
val scanPath = Config.getKeyAsString("server.package")
val routerBuilder = RouterBuilder(coroutineScope, router, scanPath, responseHandler).build { service ->
getIt.getInstance(service)
}
authHandler.anonymous.addAll(routerBuilder.anonymousPaths)
jwtAuthHandler.anonymous.addAll(routerBuilder.anonymousPaths)
}
private val corsHandler = CorsHandler.create()
@ -84,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

@ -1,15 +1,5 @@
server:
name: vtx_demo
context: api # 上下文
timeout: 120 # eventbus超时时间
http:
header: # header获取到的变量
- x-requested-with
- Access-Control-Allow-Origin
- origin
- Content-Type
- accept
event-bus:
timeout: 10000 # 毫秒
jwt:
key: 123456sdfjasdfjl # jwt加密key
context: /api
# active: dev
package: app

View File

@ -0,0 +1,18 @@
application:
version: 1.0.0
server:
http:
header: # header获取到的变量
- x-requested-with
- Access-Control-Allow-Origin
- origin
- Content-Type
- accept
event-bus:
timeout: 10000 # 毫秒
jwt:
key: 123456sdfjasdfjl
algorithm: HS256
expiresInSeconds: 604800 # 7天

View File

@ -0,0 +1,59 @@
-- apply changes
CREATE TABLE sys_menu (
menu_id BIGINT DEFAULT 0 NOT NULL,
menu_name VARCHAR(255) DEFAULT '',
parent_id BIGINT DEFAULT 0,
order_num INTEGER DEFAULT 0,
path VARCHAR(255) DEFAULT '',
component VARCHAR(255) DEFAULT '',
menu_type VARCHAR(255) DEFAULT '',
visible VARCHAR(255) DEFAULT '',
perms VARCHAR(255) DEFAULT '',
parent_name VARCHAR(255) DEFAULT '',
children JSONB DEFAULT '{}',
CONSTRAINT pk_sys_menu PRIMARY KEY (menu_id)
);
CREATE TABLE sys_user (
user_id BIGINT DEFAULT 0 NOT NULL,
dept_id BIGINT DEFAULT 0,
user_name VARCHAR(30) DEFAULT '',
nick_name VARCHAR(30) DEFAULT '',
user_type VARCHAR(2) DEFAULT '',
email VARCHAR(50) DEFAULT '',
phonenumber VARCHAR(11) DEFAULT '',
sex INTEGER DEFAULT 2,
avatar VARCHAR(100) DEFAULT '',
password VARCHAR(100) DEFAULT '',
status INTEGER DEFAULT 0,
del_flag CHAR(1) DEFAULT 0,
login_ip VARCHAR(255) DEFAULT '',
login_date TIMESTAMPTZ,
CONSTRAINT pk_sys_user PRIMARY KEY (user_id)
);
-- 添加字段注释
COMMENT ON COLUMN sys_user.user_id IS '用户ID';
COMMENT ON COLUMN sys_user.dept_id IS '部门ID';
COMMENT ON COLUMN sys_user.user_name IS '用户账号';
COMMENT ON COLUMN sys_user.nick_name IS '用户昵称';
COMMENT ON COLUMN sys_user.user_type IS '用户类型';
COMMENT ON COLUMN sys_user.email IS '用户邮箱';
COMMENT ON COLUMN sys_user.phonenumber IS '手机号码';
COMMENT ON COLUMN sys_user.sex IS '用户性别0男 1女 2未知';
COMMENT ON COLUMN sys_user.avatar IS '头像地址';
COMMENT ON COLUMN sys_user.password IS '密码';
COMMENT ON COLUMN sys_user.status IS '帐号状态0正常 1停用';
COMMENT ON COLUMN sys_user.del_flag IS '删除标志0代表存在 2代表删除';
CREATE TABLE sys_role (
role_id BIGINT DEFAULT 0,
role_name VARCHAR(255) DEFAULT '',
role_key VARCHAR(255) DEFAULT '',
role_sort INTEGER DEFAULT 0,
data_scope CHAR(1),
status CHAR(1) DEFAULT 0,
del_flag CHAR(1) DEFAULT 0
);

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<migration generated="2025-05-12T11:18:05.3077594">
<changeSet type="apply">
<createTable name="sys_menu" pkName="pk_sys_menu">
<column name="menu_id" notnull="true" primaryKey="true" type="BIGINT"/>
<column name="menu_name" type="VARCHAR(255)"/>
<column name="parent_id" type="BIGINT"/>
<column name="order_num" type="INTEGER"/>
<column name="path" type="VARCHAR(255)"/>
<column name="component" type="VARCHAR(255)"/>
<column name="menu_type" type="VARCHAR(255)"/>
<column name="visible" type="VARCHAR(255)"/>
<column name="perms" type="VARCHAR(255)"/>
<column name="parent_name" type="VARCHAR(255)"/>
<column name="children" type="JSONB"/>
</createTable>
<createTable name="sys_user" pkName="pk_sys_user">
<column name="user_id" notnull="true" primaryKey="true" type="BIGINT"/>
<column name="dept_id" type="BIGINT"/>
<column name="user_name" type="VARCHAR(30)"/>
<column name="nick_name" type="VARCHAR(30)"/>
<column name="user_type" type="VARCHAR(2)"/>
<column name="email" type="VARCHAR(50)"/>
<column name="phonenumber" type="VARCHAR(11)"/>
<column defaultValue="2" name="sex" type="INTEGER"/>
<column name="avatar" type="VARCHAR(100)"/>
<column name="password" type="VARCHAR(100)"/>
<column defaultValue="0" name="status" type="INTEGER"/>
<column name="del_flag" type="CHAR(1)"/>
<column name="login_ip" type="VARCHAR(255)"/>
<column name="login_date" type="TIMESTAMPTZ"/>
</createTable>
<createTable name="sys_role">
<column name="role_id" type="BIGINT"/>
<column name="role_name" type="VARCHAR(255)"/>
<column name="role_key" type="VARCHAR(255)"/>
<column name="role_sort" type="INTEGER"/>
<column name="data_scope" type="CHAR(1)"/>
<column name="status" type="CHAR(1)"/>
<column name="del_flag" type="CHAR(1)"/>
</createTable>
</changeSet>
</migration>

View File

@ -1,11 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration
debug="false" scan="true" scanPeriod="30 second">
<property name="ROOT" value="./log/"/>
<property name="ROOT" value="./logs/"/>
<property name="APPNAME" value="vertx-demo"/>
<property name="FILESIZE" value="500MB"/>
<property name="MAXHISTORY" value="100"/>
<!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<Target>System.out</Target>
<encoder charset="utf-8">
@ -14,15 +15,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} - %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 +36,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} - %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 +57,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 +80,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 +103,59 @@
</rollingPolicy>
</appender>
<root level="DEBUG">
<!-- JSON格式日志所有级别 -->
<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${ROOT}${APPNAME}-json.log</file>
<!-- 简单的JSON格式输出不依赖额外库 -->
<encoder charset="utf-8">
<pattern>{"time":"%d{ISO8601}","level":"%level","thread":"%thread","logger":"%logger","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} - %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

@ -0,0 +1,113 @@
package app
import org.aikrai.vertx.db.annotation.TableField
import org.aikrai.vertx.db.annotation.TableId
import org.aikrai.vertx.db.annotation.TableName
import org.aikrai.vertx.db.annotation.EnumValue
import org.aikrai.vertx.db.annotation.TableFieldComment
import org.aikrai.vertx.db.migration.AnnotationMapping
import org.aikrai.vertx.db.migration.ColumnMapping
import org.aikrai.vertx.db.migration.DbMigration
import org.aikrai.vertx.db.migration.SqlAnnotationMapper
/**
* PostgreSQL数据库迁移生成工具
*/
object GenerateMigration {
/**
* 生成数据库迁移脚本
*/
@JvmStatic
fun main(args: Array<String>) {
try {
// 创建SQL注解映射器
val mapper = createSqlAnnotationMapper()
// 设置迁移生成器
val dbMigration = DbMigration.create()
dbMigration.setPathToResources("vertx-demo/src/main/resources")
dbMigration.setEntityPackage("app.data.domain") // 指定实体类包路径
dbMigration.setSqlAnnotationMapper(mapper)
dbMigration.setGenerateDropStatements(false) // 不生成删除语句
// 生成迁移
val migrationVersion = dbMigration.generateMigration()
println("生成的迁移版本: $migrationVersion")
} catch (e: Exception) {
println("生成迁移失败: ${e.message}")
e.printStackTrace()
}
}
/**
* 创建SQL注解映射器
* 根据项目中使用的注解配置映射关系
*/
private fun createSqlAnnotationMapper(): SqlAnnotationMapper {
val mapper = SqlAnnotationMapper()
// 设置实体类注解映射
mapper.entityMapping = AnnotationMapping(
annotationClass = TableName::class,
)
// 设置表名映射
mapper.tableName = AnnotationMapping(
annotationClass = TableName::class,
propertyName = "value"
)
// 设置列映射
mapper.addColumnMapping(
ColumnMapping(
nameMapping = AnnotationMapping(
annotationClass = TableField::class,
propertyName = "value"
),
typeMapping = AnnotationMapping(
annotationClass = TableField::class,
propertyName = "type"
),
nullableMapping = AnnotationMapping(
annotationClass = TableField::class,
propertyName = "nullable"
),
defaultValueMapping = AnnotationMapping(
annotationClass = TableField::class,
propertyName = "default"
),
lengthMapping = AnnotationMapping(
annotationClass = TableField::class,
propertyName = "length"
),
uniqueMapping = AnnotationMapping(
annotationClass = TableField::class,
propertyName = "unique"
),
commentMapping = AnnotationMapping(
annotationClass = TableFieldComment::class,
propertyName = "value"
)
)
)
// 设置主键映射
mapper.primaryKeyMapping = AnnotationMapping(
annotationClass = TableId::class,
)
// 设置索引映射
// mapper.indexMapping = AnnotationMapping(
// annotationClass = TableIndex::class,
// propertyName = "name"
// )
// 设置枚举值映射
mapper.enumValueMapping = AnnotationMapping(
annotationClass = EnumValue::class,
propertyName = "value"
)
return mapper
}
}

View File

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

View File

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

View File

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

View File

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

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()
@ -27,16 +28,8 @@ tasks.test {
}
}
tasks.compileKotlin {
kotlinOptions {
jvmTarget = "17"
}
}
tasks.compileTestKotlin {
kotlinOptions {
jvmTarget = "17"
}
kotlin {
jvmToolchain(17)
}
spotless {
@ -55,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

@ -5,6 +5,7 @@ import io.vertx.ext.auth.impl.UserImpl
import org.aikrai.vertx.utlis.Meta
class AuthUser(
val id: Long,
val token: String,
val user: JsonObject,
val roles: Set<String>,

View File

@ -0,0 +1,53 @@
package org.aikrai.vertx.config
/**
* 数据库配置
*/
data class DatabaseConfig(
val name: String,
val host: String,
val port: Int,
val username: String,
val password: String,
val maxPoolSize: Int = 10
)
/**
* Redis配置
*/
data class RedisConfig(
val host: String,
val port: Int,
val db: Int,
val password: String?,
val poolSize: Int = 8,
val maxPoolWaiting: Int = 32
)
/**
* JWT配置
*/
data class JwtConfig(
val key: String,
val algorithm: String = "HS256",
val expiresInSeconds: Int = 60 * 60 * 24 * 7 // 7天
)
/**
* 服务器配置
*/
data class ServerConfig(
val port: Int,
val context: String,
val scanPackage: String
)
/**
* 框架配置
*/
data class FrameworkConfiguration(
val server: ServerConfig,
val database: DatabaseConfig,
val redis: RedisConfig,
val jwt: JwtConfig
)

View File

@ -12,7 +12,7 @@ import java.util.concurrent.atomic.AtomicReference
object Config {
private val retriever = AtomicReference<ConfigRetriever?>(null)
private var configMap = emptyMap<String, Any>()
private val configMapRef = AtomicReference<Map<String, Any>>(emptyMap())
suspend fun init(vertx: Vertx) {
if (retriever.get() != null) return
@ -20,38 +20,71 @@ object Config {
val cas = retriever.compareAndSet(null, configRetriever)
if (cas) {
val configObj = configRetriever.config.coAwait()
configMap = FlattenUtil.flattenJsonObject(configObj)
// 存储扁平化的 Map
configMapRef.set(FlattenUtil.flattenJsonObject(configObj))
}
}
fun getKey(key: String): Any? {
if (retriever.get() == null) throw IllegalStateException("Config not initialized")
// 检查 configMap 中是否存在指定的 key
return if (configMap.containsKey(key)) {
configMap[key]
} else {
// 找到所有以 key 开头的条目
val map = configMap.filterKeys { it.startsWith(key) }
// 如果没有找到任何匹配的条目,返回 null
return map.ifEmpty { null }
fun getString(key: String, defaultValue: String): String {
return configMapRef.get()[key]?.toString() ?: defaultValue
}
fun getStringOrNull(key: String): String? {
return configMapRef.get()[key]?.toString()
}
fun getInt(key: String, defaultValue: Int): Int {
return configMapRef.get()[key]?.toString()?.toIntOrNull() ?: defaultValue
}
fun getIntOrNull(key: String): Int? {
return configMapRef.get()[key]?.toString()?.toIntOrNull()
}
fun getLong(key: String, defaultValue: Long): Long {
return configMapRef.get()[key]?.toString()?.toLongOrNull() ?: defaultValue
}
fun getLongOrNull(key: String): Long? {
return configMapRef.get()[key]?.toString()?.toLongOrNull()
}
fun getBoolean(key: String, defaultValue: Boolean): Boolean {
val value = configMapRef.get()[key]
return when (value) {
is Boolean -> value
is String -> value.toBooleanStrictOrNull() ?: defaultValue // toBooleanStrictOrNull 更安全
else -> defaultValue
}
}
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()
fun getBooleanOrNull(key: String): Boolean? {
val value = configMapRef.get()[key]
return when (value) {
is Boolean -> value
is String -> value.toBooleanStrictOrNull()
else -> null
}
}
// 获取嵌套对象或列表
fun getObject(keyPrefix: String): Map<String, Any>? {
val map = configMapRef.get()
val subMap = map.filterKeys { it.startsWith("$keyPrefix.") }
.mapKeys { it.key.removePrefix("$keyPrefix.") }
return subMap.ifEmpty { null }
}
fun getStringList(key: String, defaultValue: List<String> = emptyList()): List<String> {
return (configMapRef.get()[key] as? JsonArray)?.mapNotNull { it?.toString() } ?: defaultValue
}
fun getStringListOrNull(key: String): List<String>? {
return (configMapRef.get()[key] as? JsonArray)?.mapNotNull { it?.toString() }
}
fun getConfigMap(): Map<String, Any> {
return configMap
return configMapRef.get()
}
private suspend fun load(vertx: Vertx): ConfigRetriever {

View File

@ -0,0 +1,74 @@
package org.aikrai.vertx.config
import com.google.inject.AbstractModule
import com.google.inject.Provides
import com.google.inject.Singleton
/**
* 框架配置模块
*
* 负责使用增强后的Config对象读取配置并将其实例化为数据类进行绑定
*/
class FrameworkConfigModule : AbstractModule() {
@Provides
@Singleton
fun provideDatabaseConfig(): DatabaseConfig {
return DatabaseConfig(
name = Config.getString("databases.name", "default_db"),
host = Config.getString("databases.host", "localhost"),
port = Config.getInt("databases.port", 5432),
username = Config.getString("databases.username", "user"),
password = Config.getString("databases.password", "password"),
maxPoolSize = Config.getInt("databases.maxPoolSize", 10)
)
}
@Provides
@Singleton
fun provideRedisConfig(): RedisConfig {
return RedisConfig(
host = Config.getString("redis.host", "localhost"),
port = Config.getInt("redis.port", 6379),
db = Config.getInt("redis.database", 0),
password = Config.getStringOrNull("redis.password"),
poolSize = Config.getInt("redis.maxPoolSize", 8),
maxPoolWaiting = Config.getInt("redis.maxPoolWaiting", 32)
)
}
@Provides
@Singleton
fun provideJwtConfig(): JwtConfig {
val key = Config.getStringOrNull("jwt.key")
?: throw IllegalStateException("缺少必要配置: jwt.key")
return JwtConfig(
key = key,
algorithm = Config.getString("jwt.algorithm", "HS256"),
expiresInSeconds = Config.getInt("jwt.expiresInSeconds", 60 * 60 * 24 * 7)
)
}
@Provides
@Singleton
fun provideServerConfig(): ServerConfig {
val scanPackage = Config.getStringOrNull("server.package")
?: throw IllegalStateException("缺少必要配置: server.package")
return ServerConfig(
port = Config.getInt("server.port", 8080),
context = Config.getString("server.context", "/api"),
scanPackage = scanPackage
)
}
@Provides
@Singleton
fun provideFrameworkConfiguration(
server: ServerConfig,
database: DatabaseConfig,
redis: RedisConfig,
jwt: JwtConfig
): FrameworkConfiguration {
return FrameworkConfiguration(server, database, redis, jwt)
}
}

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

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

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.ResponseHandler
import org.aikrai.vertx.utlis.ClassUtil
import org.aikrai.vertx.utlis.Meta
import org.reflections.Reflections
@ -34,7 +34,7 @@ class RouterBuilder(
private val coroutineScope: CoroutineScope,
private val router: Router,
private val scanPath: String? = null,
private val responseHandler: ResponseHandlerInterface = DefaultResponseHandler()
private val responseHandler: ResponseHandler = DefaultResponseHandler()
) {
// 不需要认证的路径集合
val anonymousPaths = mutableListOf<String>()
@ -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

@ -1,8 +0,0 @@
package org.aikrai.vertx.db
object SqlHelper {
fun retBool(result: Int?): Boolean {
return null != result && result >= 1
}
}

View File

@ -34,6 +34,18 @@ annotation class TableField(
// val keepGlobalFormat: Boolean = false,
// val property: String = "",
// val numericScale: String = ""
val type: String = "",
val length: Int = 255,
val nullable: Boolean = true,
val unique: Boolean = false,
val default: String = ""
)
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FIELD, AnnotationTarget.ANNOTATION_CLASS)
annotation class TableFieldComment(
val value: String = "",
)
@MustBeDocumented

View File

@ -0,0 +1,123 @@
package org.aikrai.vertx.db.migration
import java.io.File
/**
* 生成PostgreSQL DDL迁移脚本基于实体类及其注解的变更
*
* <p>
* 通常在开发人员对模型进行了一组更改后作为测试阶段的主要方法运行
* </p>
*
* <h3>示例: 运行生成PostgreSQL迁移脚本</h3>
*
* <pre>{@code
*
* val migration = DbMigration.create()
*
* // 可选:指定版本和名称
* migration.setName("添加用户表索引")
*
* // 设置实体包路径
* migration.setEntityPackage("org.aikrai.vertx.entity")
*
* // 设置SQL注解映射器
* migration.setSqlAnnotationMapper(createMapper())
*
* // 生成迁移
* migration.generateMigration()
*
* }</pre>
*/
interface DbMigration {
companion object {
/**
* 创建DbMigration实现实例
*/
fun create(): DbMigration {
return DefaultDbMigration()
}
}
/**
* 设置实体类所在的包路径
*/
fun setEntityPackage(packagePath: String)
/**
* 设置SQL注解映射器
*/
fun setSqlAnnotationMapper(mapper: SqlAnnotationMapper)
/**
* 设置资源文件路径
* <p>
* 默认为Maven风格的'src/main/resources'
*/
fun setPathToResources(pathToResources: String)
/**
* 设置迁移文件生成的路径默认为"dbmigration"
*/
fun setMigrationPath(migrationPath: String)
/**
* 设置模型文件生成的路径默认为"model"
*/
fun setModelPath(modelPath: String)
/**
* 设置模型文件后缀默认为".model.xml"
*/
fun setModelSuffix(modelSuffix: String)
/**
* 设置迁移的版本号
*/
fun setVersion(version: String)
/**
* 设置迁移的名称
*/
fun setName(name: String)
/**
* 设置是否输出日志到控制台默认为true
*/
fun setLogToSystemOut(logToSystemOut: Boolean)
/**
* 设置是否生成删除语句默认为false
* <p>
* 如果设置为false生成的SQL中将不包含任何DROP语句
* .model.xml文件中仍会记录删除表和字段的信息
* </p>
*/
fun setGenerateDropStatements(generateDropStatements: Boolean)
/**
* 生成下一次迁移SQL脚本和相关模型XML
* <p>
* 不会实际运行迁移或DDL脚本只是生成它们
* </p>
*
* @return 生成的迁移版本或null如果没有变更
*/
fun generateMigration(): String?
/**
* 生成包含所有变更的"初始"迁移
* <p>
* "初始"迁移只能在尚未对其运行任何先前迁移的数据库上执行和使用
* </p>
*
* @return 生成的迁移版本
*/
fun generateInitMigration(): String?
/**
* 返回迁移主目录
*/
fun migrationDirectory(): File
}

View File

@ -0,0 +1,181 @@
package org.aikrai.vertx.db.migration
import java.io.File
/**
* PostgreSQL数据库迁移工具的默认实现
*/
class DefaultDbMigration : DbMigration {
private var entityPackage: String = ""
private var sqlAnnotationMapper: SqlAnnotationMapper? = null
private var pathToResources: String = "src/main/resources"
private var migrationPath: String = "dbmigration"
private var modelPath: String = "model"
private var modelSuffix: String = ".model.xml"
private var version: String? = null
private var name: String? = null
private var logToSystemOut: Boolean = true
private var generateDropStatements: Boolean = false
override fun setEntityPackage(packagePath: String) {
this.entityPackage = packagePath
}
override fun setSqlAnnotationMapper(mapper: SqlAnnotationMapper) {
this.sqlAnnotationMapper = mapper
}
override fun setPathToResources(pathToResources: String) {
this.pathToResources = pathToResources
}
override fun setMigrationPath(migrationPath: String) {
this.migrationPath = migrationPath
}
override fun setModelPath(modelPath: String) {
this.modelPath = modelPath
}
override fun setModelSuffix(modelSuffix: String) {
this.modelSuffix = modelSuffix
}
override fun setVersion(version: String) {
this.version = version
}
override fun setName(name: String) {
this.name = name
}
override fun setLogToSystemOut(logToSystemOut: Boolean) {
this.logToSystemOut = logToSystemOut
}
override fun setGenerateDropStatements(generateDropStatements: Boolean) {
this.generateDropStatements = generateDropStatements
}
override fun generateMigration(): String? {
validateConfiguration()
configureMigrationGenerator()
// 将版本和名称设置到系统属性中以便SqlMigrationGenerator能够读取
if (version != null) {
System.setProperty("ddl.migration.version", version!!)
}
if (name != null) {
System.setProperty("ddl.migration.name", name!!)
}
// 设置是否生成删除语句
System.setProperty("ddl.migration.generateDropStatements", generateDropStatements.toString())
try {
SqlMigrationGenerator.generateMigrations(entityPackage, sqlAnnotationMapper!!)
return version
} catch (e: Exception) {
if (logToSystemOut) {
println("生成迁移失败: ${e.message}")
e.printStackTrace()
}
throw e
} finally {
// 清理系统属性
if (version != null) {
System.clearProperty("ddl.migration.version")
}
if (name != null) {
System.clearProperty("ddl.migration.name")
}
System.clearProperty("ddl.migration.generateDropStatements")
}
}
override fun generateInitMigration(): String? {
validateConfiguration()
configureMigrationGenerator()
// 将版本和名称设置到系统属性中以便SqlMigrationGenerator能够读取
if (version != null) {
System.setProperty("ddl.migration.version", version!!)
}
if (name != null) {
System.setProperty("ddl.migration.name", name!!)
}
// 设置是否生成删除语句
System.setProperty("ddl.migration.generateDropStatements", generateDropStatements.toString())
try {
// 修改目录结构,强制生成初始迁移
val modelDir = File("${pathToResources}/${migrationPath}/${modelPath}")
if (modelDir.exists()) {
// 备份原有文件
val backupDir = File("${pathToResources}/${migrationPath}/${modelPath}_backup_${System.currentTimeMillis()}")
modelDir.renameTo(backupDir)
if (logToSystemOut) {
println("已将现有模型文件备份到: ${backupDir.absolutePath}")
}
}
// 生成初始迁移
SqlMigrationGenerator.generateMigrations(entityPackage, sqlAnnotationMapper!!)
return version
} catch (e: Exception) {
if (logToSystemOut) {
println("生成初始迁移失败: ${e.message}")
e.printStackTrace()
}
throw e
} finally {
// 清理系统属性
if (version != null) {
System.clearProperty("ddl.migration.version")
}
if (name != null) {
System.clearProperty("ddl.migration.name")
}
System.clearProperty("ddl.migration.generateDropStatements")
}
}
override fun migrationDirectory(): File {
return File("${pathToResources}/${migrationPath}")
}
/**
* 验证配置确保必要的配置项已经设置
*/
private fun validateConfiguration() {
if (entityPackage.isEmpty()) {
throw IllegalStateException("实体包路径未设置请调用setEntityPackage()")
}
if (sqlAnnotationMapper == null) {
throw IllegalStateException("SQL注解映射器未设置请调用setSqlAnnotationMapper()")
}
if (sqlAnnotationMapper?.entityMapping == null) {
throw IllegalStateException("实体注解映射未设置请配置entityMapping")
}
}
/**
* 配置迁移生成器
*/
private fun configureMigrationGenerator() {
// 设置静态字段以便SqlMigrationGenerator能够使用配置的路径
SqlMigrationGenerator.setResourcePath(pathToResources)
SqlMigrationGenerator.setMigrationPath("${pathToResources}/${migrationPath}")
SqlMigrationGenerator.setModelPath("${pathToResources}/${migrationPath}/${modelPath}")
SqlMigrationGenerator.setModelSuffix(modelSuffix)
SqlMigrationGenerator.setLogToSystemOut(logToSystemOut)
// 同时设置SqlAnnotationMapperGenerator的日志配置
SqlAnnotationMapperGenerator.setLogToSystemOut(logToSystemOut)
}
}

View File

@ -0,0 +1,90 @@
package org.aikrai.vertx.db.migration
import kotlin.reflect.KClass
/**
* SQL注解映射中间类
* 用于记录从哪些注解获取SQL生成所需的信息
*/
class SqlAnnotationMapper {
/**
* 实体类注解映射
* 用于标识哪个类是实体类
*/
var entityMapping: AnnotationMapping? = null
/**
* 表名映射信息
*/
var tableName: AnnotationMapping? = null
/**
* 列名映射信息列表
*/
var columnMappings: MutableList<ColumnMapping> = mutableListOf()
/**
* 主键映射信息
*/
var primaryKeyMapping: AnnotationMapping? = null
/**
* 索引映射信息
*/
var indexMapping: AnnotationMapping? = null
/**
* 枚举值映射信息
*/
var enumValueMapping: AnnotationMapping? = null
/**
* 其他自定义映射
*/
var customMappings: MutableMap<String, AnnotationMapping> = mutableMapOf()
/**
* 添加一个列映射
*/
fun addColumnMapping(columnMapping: ColumnMapping) {
columnMappings.add(columnMapping)
}
/**
* 添加一个自定义映射
*/
fun addCustomMapping(key: String, mapping: AnnotationMapping) {
customMappings[key] = mapping
}
}
/**
* 注解映射类
* 记录从哪个注解的哪个属性获取信息
*/
data class AnnotationMapping(
/** 注解类 */
val annotationClass: KClass<out Annotation>,
/** 注解属性名 */
val propertyName: String = ""
)
/**
* 列映射信息
*/
data class ColumnMapping(
/** 字段名称映射 */
val nameMapping: AnnotationMapping,
/** 字段类型映射,可选 */
val typeMapping: AnnotationMapping? = null,
/** 是否可为空映射,可选 */
val nullableMapping: AnnotationMapping? = null,
/** 默认值映射,可选 */
val defaultValueMapping: AnnotationMapping? = null,
/** 字段长度映射,可选 */
val lengthMapping: AnnotationMapping? = null,
/** 是否唯一映射,可选 */
val uniqueMapping: AnnotationMapping? = null,
/** 字段注释映射,可选 */
val commentMapping: AnnotationMapping? = null
)

View File

@ -0,0 +1,617 @@
package org.aikrai.vertx.db.migration
import cn.hutool.core.util.StrUtil
import java.time.LocalDateTime
import kotlin.reflect.KClass
/**
* SQL注解映射生成器
* 用于生成和使用SQL注解映射中间类
*/
class SqlAnnotationMapperGenerator {
companion object {
// 是否输出日志到控制台
private var LOG_TO_SYSTEM_OUT = true
/**
* 设置是否输出日志到控制台
*/
fun setLogToSystemOut(log: Boolean) {
LOG_TO_SYSTEM_OUT = log
}
/**
* 记录日志信息
*/
private fun log(message: String) {
if (LOG_TO_SYSTEM_OUT) {
println("DbMigration> $message")
}
}
/**
* 从实体类获取SQL信息
* @param entityClass 实体类
* @param mapper 注解映射中间类
* @return SQL信息
*/
fun extractSqlInfo(entityClass: KClass<*>, mapper: SqlAnnotationMapper): SqlInfo {
val sqlInfo = SqlInfo()
// 获取表名 - 优先从表名注解中获取,如果没有则使用类名转换
try {
if (mapper.tableName != null) {
val tableNameMapping = mapper.tableName!!
val annotation = entityClass.annotations.find {
it.annotationClass.qualifiedName == tableNameMapping.annotationClass.qualifiedName
}
if (annotation != null) {
try {
val method = annotation.javaClass.getMethod(tableNameMapping.propertyName)
val tableName = method.invoke(annotation) as String
if (tableName.isNotBlank()) {
sqlInfo.tableName = tableName
} else {
// 使用类名转蛇形命名作为表名
sqlInfo.tableName = StrUtil.toUnderlineCase(entityClass.simpleName ?: "")
}
} catch (e: Exception) {
// 使用类名转蛇形命名作为表名
sqlInfo.tableName = StrUtil.toUnderlineCase(entityClass.simpleName ?: "")
}
} else {
// 使用类名转蛇形命名作为表名
sqlInfo.tableName = StrUtil.toUnderlineCase(entityClass.simpleName ?: "")
}
} else {
// 使用类名转蛇形命名作为表名
sqlInfo.tableName = StrUtil.toUnderlineCase(entityClass.simpleName ?: "")
}
} catch (e: Exception) {
throw IllegalArgumentException("处理实体类 ${entityClass.simpleName} 的表名时出错: ${e.message}", e)
}
// 获取实体类的所有字段
val fields = entityClass.java.declaredFields
if (fields.isEmpty()) {
throw IllegalArgumentException("实体类 ${entityClass.simpleName} 没有声明任何字段")
}
// 创建已处理字段名集合,用于记录已经处理过的字段
val processedFields = mutableSetOf<String>()
// 查找实体类中的@Transient注解类
val transientAnnotationClasses = listOf(
"kotlin.jvm.Transient",
"javax.persistence.Transient",
"jakarta.persistence.Transient",
"java.beans.Transient",
"org.aikrai.vertx.db.annotation.Transient"
)
fields.forEach { field ->
try {
// 检查字段是否有@Transient注解如果有则跳过
val isTransient = field.annotations.any { annotation ->
transientAnnotationClasses.any { className ->
annotation.annotationClass.qualifiedName == className
}
}
if (isTransient) {
return@forEach
}
// 获取列信息
val columnInfo = ColumnInfo()
var foundColumnMapping = false
// 处理每个列映射 - 这部分处理带有特定注解的字段
mapper.columnMappings.forEach { columnMapping ->
val nameAnnotation = field.annotations.find {
it.annotationClass.qualifiedName == columnMapping.nameMapping.annotationClass.qualifiedName
}
foundColumnMapping = true
try {
val nameMethod = nameAnnotation?.javaClass?.getMethod(columnMapping.nameMapping.propertyName)
val columnName = nameMethod?.invoke(nameAnnotation) as? String
columnInfo.name = if (columnName.isNullOrEmpty()) StrUtil.toUnderlineCase(field.name) else columnName
// 处理类型映射
columnMapping.typeMapping?.let { typeMapping ->
val typeAnnotation = field.annotations.find {
it.annotationClass.qualifiedName == typeMapping.annotationClass.qualifiedName
}
if (typeAnnotation != null) {
try {
val typeMethod = typeAnnotation.javaClass.getMethod(typeMapping.propertyName)
val typeName = typeMethod.invoke(typeAnnotation) as String
columnInfo.type = if (typeName.isEmpty()) {
inferSqlType(field.type, columnInfo, mapper)
} else {
typeName
}
} catch (e: Exception) {
throw IllegalArgumentException("处理字段 ${field.name} 的类型映射时出错: ${e.message}", e)
}
} else {
columnInfo.type = inferSqlType(field.type, columnInfo, mapper)
}
} ?: {
columnInfo.type = inferSqlType(field.type, columnInfo, mapper)
}
// 检查字段是否为枚举类型,并且有默认初始值
if (field.type.isEnum) {
try {
// 使字段可访问
field.isAccessible = true
// 获取声明类的实例(暂时创建一个实例)
val declaringClass = field.declaringClass
val instance = try {
declaringClass.getDeclaredConstructor().newInstance()
} catch (e: Exception) {
null
}
if (instance != null) {
// 获取默认枚举值
val defaultEnumValue = field.get(instance)
if (defaultEnumValue != null) {
// 如果有EnumValue注解的方法使用它获取枚举值
if (mapper.enumValueMapping != null) {
val enumValueMethod = field.type.methods.find { method ->
method.annotations.any {
it.annotationClass.qualifiedName == mapper.enumValueMapping!!.annotationClass.qualifiedName
}
}
if (enumValueMethod != null) {
// 获取枚举值并设置为默认值
val enumValue = enumValueMethod.invoke(defaultEnumValue)
if (enumValue != null) {
columnInfo.defaultValue = enumValue.toString()
}
}
}
}
}
} catch (e: Exception) {
// 忽略获取默认值失败的情况
log("获取枚举默认值失败: ${e.message}")
}
}
// 处理可空映射
columnMapping.nullableMapping?.let { nullableMapping ->
val nullableAnnotation = field.annotations.find {
it.annotationClass.qualifiedName == nullableMapping.annotationClass.qualifiedName
}
if (nullableAnnotation != null) {
try {
val nullableMethod = nullableAnnotation.javaClass.getMethod(nullableMapping.propertyName)
columnInfo.nullable = nullableMethod.invoke(nullableAnnotation) as Boolean
} catch (e: Exception) {
throw IllegalArgumentException("处理字段 ${field.name} 的可空性映射时出错: ${e.message}", e)
}
}
}
// 处理长度映射
columnMapping.lengthMapping?.let { lengthMapping ->
val lengthAnnotation = field.annotations.find {
it.annotationClass.qualifiedName == lengthMapping.annotationClass.qualifiedName
}
if (lengthAnnotation != null) {
try {
val lengthMethod = lengthAnnotation.javaClass.getMethod(lengthMapping.propertyName)
val lengthValue = lengthMethod.invoke(lengthAnnotation)
if (lengthValue is Int) {
columnInfo.length = lengthValue
// 如果是VARCHAR类型更新类型定义中的长度
if (columnInfo.type.startsWith("VARCHAR")) {
columnInfo.type = "VARCHAR(${columnInfo.length})"
}
}
} catch (e: Exception) {
throw IllegalArgumentException("处理字段 ${field.name} 的长度映射时出错: ${e.message}", e)
}
}
}
// 处理唯一性映射
columnMapping.uniqueMapping?.let { uniqueMapping ->
val uniqueAnnotation = field.annotations.find {
it.annotationClass.qualifiedName == uniqueMapping.annotationClass.qualifiedName
}
if (uniqueAnnotation != null) {
try {
val uniqueMethod = uniqueAnnotation.javaClass.getMethod(uniqueMapping.propertyName)
val uniqueValue = uniqueMethod.invoke(uniqueAnnotation)
if (uniqueValue is Boolean) {
columnInfo.unique = uniqueValue
}
} catch (e: Exception) {
throw IllegalArgumentException("处理字段 ${field.name} 的唯一性映射时出错: ${e.message}", e)
}
}
}
// 处理注释映射
columnMapping.commentMapping?.let { commentMapping ->
val commentAnnotation = field.annotations.find {
it.annotationClass.qualifiedName == commentMapping.annotationClass.qualifiedName
}
if (commentAnnotation != null) {
try {
val commentMethod = commentAnnotation.javaClass.getMethod(commentMapping.propertyName)
val commentValue = commentMethod.invoke(commentAnnotation)
if (commentValue is String) {
columnInfo.comment = commentValue
}
} catch (e: Exception) {
throw IllegalArgumentException("处理字段 ${field.name} 的注释映射时出错: ${e.message}", e)
}
}
}
// 处理默认值映射 - 只有在字段不是枚举或枚举但没有初始值时才处理注解中的默认值
if (columnInfo.defaultValue.isEmpty()) {
columnMapping.defaultValueMapping?.let { defaultValueMapping ->
val defaultValueAnnotation = field.annotations.find {
it.annotationClass.qualifiedName == defaultValueMapping.annotationClass.qualifiedName
}
if (defaultValueAnnotation != null) {
try {
val defaultValueMethod =
defaultValueAnnotation.javaClass.getMethod(defaultValueMapping.propertyName)
columnInfo.defaultValue = defaultValueMethod.invoke(defaultValueAnnotation) as String
} catch (e: Exception) {
throw IllegalArgumentException("处理字段 ${field.name} 的默认值映射时出错: ${e.message}", e)
}
}
}
}
sqlInfo.columns.add(columnInfo)
processedFields.add(field.name) // 记录已处理的字段
} catch (e: Exception) {
throw IllegalArgumentException("处理字段 ${field.name} 时出错: ${e.message}", e)
}
}
// 处理主键
if (mapper.primaryKeyMapping != null) {
val pkMapping = mapper.primaryKeyMapping!!
val pkAnnotation = field.annotations.find {
it.annotationClass.qualifiedName == pkMapping.annotationClass.qualifiedName
}
// 只要字段有@TableId注解不管属性值如何都视为主键
if (pkAnnotation != null) {
// 如果字段还未处理,创建默认列信息
if (!processedFields.contains(field.name)) {
columnInfo.name = StrUtil.toUnderlineCase(field.name)
columnInfo.type = inferSqlType(field.type, columnInfo, mapper)
columnInfo.isPrimaryKey = true
// 主键不可为空
columnInfo.nullable = false
sqlInfo.columns.add(columnInfo)
sqlInfo.primaryKeys.add(columnInfo.name)
processedFields.add(field.name)
} else {
// 如果已处理,找到对应列并标记为主键
val column =
sqlInfo.columns.find { it.name == StrUtil.toUnderlineCase(field.name) || it.name == field.name }
if (column != null) {
column.isPrimaryKey = true
// 主键不可为空
column.nullable = false
if (!sqlInfo.primaryKeys.contains(column.name)) {
sqlInfo.primaryKeys.add(column.name)
}
}
}
}
}
// 如果字段未被处理并且不是static或transient添加默认处理
if (!processedFields.contains(field.name) &&
!java.lang.reflect.Modifier.isStatic(field.modifiers) &&
!java.lang.reflect.Modifier.isTransient(field.modifiers)
) {
// 检查字段类型是否可空
val isNullable = isNullableType(field)
// 创建默认列信息
val defaultColumnInfo = ColumnInfo(
name = StrUtil.toUnderlineCase(field.name),
type = "", // 先不设置类型
nullable = isNullable,
defaultValue = "",
isPrimaryKey = false
)
// 设置类型并处理枚举值
defaultColumnInfo.type = inferSqlType(field.type, defaultColumnInfo, mapper)
sqlInfo.columns.add(defaultColumnInfo)
processedFields.add(field.name)
}
} catch (e: Exception) {
throw IllegalArgumentException(
"处理实体类 ${entityClass.simpleName} 的字段 ${field.name} 时出错: ${e.message}",
e
)
}
}
// 验证结果
if (sqlInfo.tableName.isEmpty()) {
throw IllegalArgumentException("实体类 ${entityClass.simpleName} 的表名为空,请检查表名注解")
}
if (sqlInfo.columns.isEmpty()) {
throw IllegalArgumentException("实体类 ${entityClass.simpleName} 没有可用的列信息,请检查列注解")
}
// 处理表级别的索引注解
processTableIndexes(entityClass, sqlInfo, mapper)
return sqlInfo
}
/**
* 处理表级别的索引注解
*/
private fun processTableIndexes(entityClass: KClass<*>, sqlInfo: SqlInfo, mapper: SqlAnnotationMapper) {
// 只有当有indexMapping配置时才处理
if (mapper.indexMapping == null) {
return
}
try {
val indexMapping = mapper.indexMapping!!
// 查找类上的所有TableIndex注解
val tableIndexAnnotations = entityClass.annotations.filter {
it.annotationClass.qualifiedName == indexMapping.annotationClass.qualifiedName
}
// 处理每个TableIndex注解
tableIndexAnnotations.forEach { annotation ->
try {
// 提取索引信息
val indexInfo = IndexInfo()
// 获取索引名称
val nameMethod = annotation.javaClass.getMethod("name")
val indexName = nameMethod.invoke(annotation) as String
indexInfo.name =
if (indexName.isNotEmpty()) indexName else "idx_${sqlInfo.tableName}_${System.currentTimeMillis()}"
// 获取唯一性
val uniqueMethod = annotation.javaClass.getMethod("unique")
indexInfo.unique = uniqueMethod.invoke(annotation) as Boolean
// 获取并发创建选项
val concurrentMethod = annotation.javaClass.getMethod("concurrent")
indexInfo.concurrent = concurrentMethod.invoke(annotation) as Boolean
// 获取列名列表
val columnNamesMethod = annotation.javaClass.getMethod("columnNames")
val columnNames = columnNamesMethod.invoke(annotation) as Array<*>
indexInfo.columnNames = columnNames.map { it.toString() }
// 获取自定义定义
val definitionMethod = annotation.javaClass.getMethod("definition")
val definition = definitionMethod.invoke(annotation) as String
indexInfo.definition = definition
// 只有当至少有一个列名或自定义定义时才添加索引
if (indexInfo.columnNames.isNotEmpty() || indexInfo.definition.isNotEmpty()) {
sqlInfo.indexes.add(indexInfo)
}
} catch (e: Exception) {
// 处理单个索引注解失败时记录错误但继续处理其他索引
log("处理索引注解时出错: ${e.message}")
}
}
} catch (e: Exception) {
// 处理索引注解整体失败时记录错误
log("处理表 ${sqlInfo.tableName} 的索引注解时出错: ${e.message}")
}
}
/**
* 根据Java类型推断SQL类型
*/
private fun inferSqlType(
javaType: Class<*>,
columnInfo: ColumnInfo? = null,
mapper: SqlAnnotationMapper? = null
): String {
val sqlType = when {
javaType == String::class.java -> {
if (columnInfo != null) {
"VARCHAR(${columnInfo.length})"
} else {
"VARCHAR(255)"
}
}
javaType == Int::class.java || javaType == Integer::class.java -> "INTEGER"
javaType == Long::class.java || javaType == java.lang.Long::class.java -> "BIGINT"
javaType == Double::class.java || javaType == java.lang.Double::class.java -> "DOUBLE PRECISION"
javaType == Float::class.java || javaType == java.lang.Float::class.java -> "REAL"
javaType == Boolean::class.java || javaType == java.lang.Boolean::class.java -> "BOOLEAN"
javaType == Char::class.java || javaType == Character::class.java -> "CHAR(1)"
javaType == java.util.Date::class.java || javaType == java.sql.Date::class.java -> "DATE"
javaType == java.sql.Timestamp::class.java -> "TIMESTAMPTZ"
javaType == LocalDateTime::class.java -> "TIMESTAMPTZ"
javaType == ByteArray::class.java -> "BYTEA"
javaType.name.contains("Map") || javaType.name.contains("HashMap") -> "JSONB"
javaType.name.contains("List") || javaType.name.contains("ArrayList") -> "JSONB"
javaType.name.contains("Set") || javaType.name.contains("HashSet") -> "JSONB"
javaType.name.endsWith("DTO") || javaType.name.endsWith("Dto") -> "JSONB"
javaType.name.contains("Json") || javaType.name.contains("JSON") -> "JSONB"
javaType.isEnum -> {
// 处理枚举类型提取枚举值并保存到columnInfo中
if (columnInfo != null) {
try {
// 获取枚举类中的所有枚举常量
val enumConstants = javaType.enumConstants
if (enumConstants != null && enumConstants.isNotEmpty()) {
// 查找带有EnumValue注解的方法
val enumValues = if (mapper?.enumValueMapping != null) {
val enumValueMethod = javaType.methods.find { method ->
method.annotations.any {
it.annotationClass.qualifiedName == mapper.enumValueMapping!!.annotationClass.qualifiedName
}
}
if (enumValueMethod != null) {
// 使用EnumValue标注的方法获取枚举值
enumConstants.map { enumValueMethod.invoke(it).toString() }
} else {
// 如果没有找到EnumValue注解的方法使用枚举名称
enumConstants.map { (it as Enum<*>).name }
}
} else {
// 默认使用枚举的toString()
enumConstants.map { it.toString() }
}
columnInfo.enumValues = enumValues
// 如果字段有默认值确保默认值是通过带EnumValue的方法获取的
val defaultEnumValue = javaType.enumConstants.find { (it as Enum<*>).name == columnInfo.defaultValue }
if (defaultEnumValue != null && mapper?.enumValueMapping != null) {
val enumValueMethod = javaType.methods.find { method ->
method.annotations.any {
it.annotationClass.qualifiedName == mapper.enumValueMapping!!.annotationClass.qualifiedName
}
}
if (enumValueMethod != null) {
// 更新默认值为EnumValue方法的返回值
columnInfo.defaultValue = enumValueMethod.invoke(defaultEnumValue).toString()
}
}
}
} catch (e: Exception) {
// 忽略枚举值提取失败的情况
log("提取枚举值失败: ${e.message}")
}
}
// 根据枚举值的类型决定SQL类型
if (columnInfo != null && columnInfo.enumValues?.isNotEmpty() == true) {
val firstValue = columnInfo.enumValues!!.first()
val enumType = when {
// 尝试将值转换为数字,判断是否是数值型枚举
firstValue.toIntOrNull() != null -> "INTEGER"
firstValue.toLongOrNull() != null -> "BIGINT"
firstValue.toDoubleOrNull() != null -> "DOUBLE PRECISION"
// 如果值很短使用CHAR
firstValue.length <= 1 -> "CHAR(1)"
// 找出最长的枚举值并基于此设置VARCHAR长度
else -> {
val maxLength = columnInfo.enumValues!!.maxBy { it.length }.length
val safeLength = maxLength + 10 // 增加一些余量
"VARCHAR($safeLength)"
}
}
enumType
} else {
"VARCHAR(50)" // 默认回退
}
}
else -> "VARCHAR(255)"
}
return sqlType
}
/**
* 判断字段类型是否可空
*/
private fun isNullableType(field: java.lang.reflect.Field): Boolean {
// 检查字段类型名称中是否包含Nullable标记
val typeName = field.genericType.typeName
// 1. 先检查字段类型名是否包含"?"这是Kotlin可空类型的标志
if (typeName.contains("?")) {
return true
}
// 2. 检查字段是否为Java原始类型这些类型不可为空
if (field.type.isPrimitive) {
return false
}
// 3. 通过Java反射获取字段的声明可空性
try {
// 检查是否有@Nullable相关注解
val hasNullableAnnotation = field.annotations.any {
val name = it.annotationClass.qualifiedName ?: ""
name.contains("Nullable") || name.contains("nullable")
}
if (hasNullableAnnotation) {
return true
}
} catch (e: Exception) {
// 忽略注解检查错误
}
// 4. 检查字段的类型并判断其可空性
// Kotlin的String类型不可为空而Java的String类型可为空
if (field.type == String::class.java) {
// 尝试通过字段的初始值判断
try {
field.isAccessible = true
// 如果是非静态字段且初始值为null则可能为可空类型
if (!java.lang.reflect.Modifier.isStatic(field.modifiers)) {
// 对于具有初始值的非静态字段如果初始值为非null字符串则认为是非可空类型
// 如果字段名以OrNull或Optional结尾认为是可空类型
if (field.name.endsWith("OrNull") || field.name.endsWith("Optional")) {
return true
}
// 对于Kotlin中的非空String类型如果有初始值"",则不可为空
// 检查是否为Kotlin类型
val isKotlinType = typeName.startsWith("kotlin.")
if (isKotlinType) {
return false // Kotlin中的String类型不可为空
}
}
} catch (e: Exception) {
// 忽略访问字段值的错误
}
}
// 5. 检查字段是否为其他Kotlin基本类型且非可空
if (field.type == Int::class.java ||
field.type == Long::class.java ||
field.type == Boolean::class.java ||
field.type == Float::class.java ||
field.type == Double::class.java ||
field.type == Char::class.java ||
field.type == Byte::class.java ||
field.type == Short::class.java
) {
// Kotlin基本类型不带?就不可为空
return false
}
// 6. 默认情况: 引用类型默认认为是可空的
return true
}
}
}

View File

@ -0,0 +1,333 @@
package org.aikrai.vertx.db.migration
import kotlin.reflect.KClass
/**
* PostgreSQL SQL生成工具类
*/
class SqlGenerator {
companion object {
/**
* 生成创建表SQL
* @param entityClass 实体类
* @param mapper 注解映射中间类
* @return 创建表SQL语句
*/
fun generateCreateTableSql(entityClass: KClass<*>, mapper: SqlAnnotationMapper): String {
val sqlInfo = SqlAnnotationMapperGenerator.extractSqlInfo(entityClass, mapper)
val tableName = sqlInfo.tableName
val columns = sqlInfo.columns
if (tableName.isEmpty() || columns.isEmpty()) {
throw IllegalArgumentException("无法生成SQL表名或列信息为空")
}
val sb = StringBuilder()
sb.append("CREATE TABLE $tableName (\n")
// 添加列定义 - 改为ANSI标准格式
val columnDefinitions = columns.map { column ->
// 特殊处理一些常见约定字段
val specialFieldDefaults = getSpecialFieldDefaults(column.name, column.type)
var defaultValue = ""
// 处理默认值
if (column.defaultValue.isNotEmpty()) {
// 对于时间戳类型字段特殊处理NOW()函数作为默认值
if (column.type.contains("TIMESTAMP") && column.defaultValue.equals("now()", ignoreCase = true)) {
defaultValue = " DEFAULT 'now()'"
} else if (column.enumValues != null && column.enumValues!!.isNotEmpty()) {
// 对于枚举类型字段,直接使用默认值
// 如果是数字,就不带引号,否则加上引号
val isNumeric = column.defaultValue.matches(Regex("^[0-9]+$"))
if (isNumeric) {
defaultValue = " DEFAULT ${column.defaultValue}"
} else {
defaultValue = " DEFAULT '${column.defaultValue}'"
}
} else {
defaultValue = " DEFAULT ${column.defaultValue}"
}
} else if (specialFieldDefaults.isNotEmpty()) {
defaultValue = specialFieldDefaults
} else {
// 为一些常见类型提供合理的默认值
when {
column.type.contains("VARCHAR") -> defaultValue = " DEFAULT ''"
column.type == "INTEGER" || column.type == "BIGINT" -> defaultValue = " DEFAULT 0"
column.type == "BOOLEAN" -> defaultValue = " DEFAULT false"
column.type.contains("JSON") -> defaultValue = " DEFAULT '{}'"
}
}
val nullable = if (column.nullable) "" else " NOT NULL"
// 移除内联注释改用COMMENT ON语句
" ${column.name} ${column.type}$defaultValue$nullable"
}
sb.append(columnDefinitions.joinToString(",\n"))
// 添加唯一约束
val uniqueColumns = columns.filter { it.unique && !it.isPrimaryKey }
for (column in uniqueColumns) {
sb.append(",\n CONSTRAINT uk_${tableName}_${column.name} UNIQUE (${column.name})")
}
// 添加枚举约束 - 识别枚举类型的列并添加CHECK约束
val enumColumns = columns.filter { it.type.contains("VARCHAR") && it.enumValues != null && it.enumValues!!.isNotEmpty() }
for (column in enumColumns) {
if (column.enumValues != null && column.enumValues!!.isNotEmpty()) {
// 检查枚举值是否都是数字
val allNumeric = column.enumValues!!.all { it.matches(Regex("^[0-9]+$")) }
sb.append(",\n CONSTRAINT ck_${tableName}_${column.name} CHECK ( ${column.name} in (")
if (allNumeric) {
// 如果全是数字,不需要加引号
sb.append(column.enumValues!!.joinToString(","))
} else {
// 否则加上引号
sb.append(column.enumValues!!.joinToString(",") { "'$it'" })
}
sb.append("))")
}
}
// 添加主键约束
if (sqlInfo.primaryKeys.isNotEmpty()) {
sb.append(",\n CONSTRAINT pk_$tableName PRIMARY KEY (${sqlInfo.primaryKeys.joinToString(", ")})")
}
sb.append("\n);")
// 添加字段注释 - 使用PostgreSQL的COMMENT ON语句
val columnsWithComment = columns.filter { it.comment.isNotEmpty() }
if (columnsWithComment.isNotEmpty()) {
sb.append("\n\n-- 添加字段注释\n")
for (column in columnsWithComment) {
sb.append("COMMENT ON COLUMN ${tableName}.${column.name} IS '${column.comment.replace("'", "''")}';")
sb.append("\n")
}
}
// 添加索引创建语句
val indexSql = generateCreateIndexSql(sqlInfo)
if (indexSql.isNotEmpty()) {
sb.append("\n\n")
sb.append(indexSql)
}
return sb.toString()
}
/**
* 生成创建索引的SQL语句
* @param sqlInfo SQL信息
* @return 创建索引SQL语句
*/
private fun generateCreateIndexSql(sqlInfo: SqlInfo): String {
val sb = StringBuilder()
sqlInfo.indexes.forEach { index ->
if (index.columnNames.isNotEmpty() || index.definition.isNotEmpty()) {
sb.append("CREATE ")
if (index.unique) {
sb.append("UNIQUE ")
}
sb.append("INDEX ")
if (index.concurrent) {
sb.append("CONCURRENTLY ")
}
sb.append("${index.name} ON ${sqlInfo.tableName}")
if (index.definition.isNotEmpty()) {
// 使用自定义索引定义
sb.append(" ${index.definition}")
} else {
// 使用列名列表
sb.append(" (${index.columnNames.joinToString(", ")})")
}
sb.append(";\n")
}
}
return sb.toString()
}
/**
* 获取特殊字段的默认值定义
*/
private fun getSpecialFieldDefaults(fieldName: String, fieldType: String): String {
return when {
// 版本字段
fieldName == "version" && (fieldType == "INTEGER" || fieldType == "BIGINT") ->
" DEFAULT 0"
// 创建时间字段
fieldName == "created" || fieldName == "create_time" || fieldName == "creation_time" || fieldName == "created_at" -> {
if (fieldType.contains("TIMESTAMP")) " DEFAULT 'now()'" else ""
}
// 更新时间字段
fieldName == "updated" || fieldName == "update_time" || fieldName == "last_update" || fieldName == "updated_at" -> {
if (fieldType.contains("TIMESTAMP")) " DEFAULT 'now()'" else ""
}
// 是否删除标记
fieldName == "deleted" || fieldName == "is_deleted" || fieldName == "del_flag" -> {
if (fieldType == "BOOLEAN") " DEFAULT false"
else if (fieldType.contains("INTEGER") || fieldType.contains("CHAR")) " DEFAULT 0"
else ""
}
// 状态字段
fieldName == "status" || fieldName == "state" -> {
if (fieldType.contains("VARCHAR")) {
// 默认为空值,实际值将通过字段的默认值处理
""
} else {
// 对于数字类型状态默认为0
" DEFAULT 0"
}
}
else -> ""
}
}
/**
* 生成插入SQL
* @param entityClass 实体类
* @param mapper 注解映射中间类
* @return 插入SQL语句模板
*/
fun generateInsertSql(entityClass: KClass<*>, mapper: SqlAnnotationMapper): String {
val sqlInfo = SqlAnnotationMapperGenerator.extractSqlInfo(entityClass, mapper)
val tableName = sqlInfo.tableName
val columns = sqlInfo.columns
if (tableName.isEmpty() || columns.isEmpty()) {
throw IllegalArgumentException("无法生成SQL表名或列信息为空")
}
val columnNames = columns.map { it.name }
val placeholders = columns.mapIndexed { index, _ -> "$$${index + 1}" }
return "INSERT INTO $tableName (${columnNames.joinToString(", ")}) VALUES (${placeholders.joinToString(", ")});"
}
/**
* 生成更新SQL
* @param entityClass 实体类
* @param mapper 注解映射中间类
* @return 更新SQL语句模板
*/
fun generateUpdateSql(entityClass: KClass<*>, mapper: SqlAnnotationMapper): String {
val sqlInfo = SqlAnnotationMapperGenerator.extractSqlInfo(entityClass, mapper)
val tableName = sqlInfo.tableName
val columns = sqlInfo.columns.filter { !it.isPrimaryKey }
val primaryKeys = sqlInfo.columns.filter { it.isPrimaryKey }
if (tableName.isEmpty() || columns.isEmpty() || primaryKeys.isEmpty()) {
throw IllegalArgumentException("无法生成SQL表名、列信息或主键为空")
}
val sb = StringBuilder()
sb.append("UPDATE $tableName SET ")
// 设置列
val setStatements = columns.mapIndexed { index, column ->
"${column.name} = $$${index + 1}"
}
sb.append(setStatements.joinToString(", "))
// 添加条件
sb.append(" WHERE ")
val whereStatements = primaryKeys.mapIndexed { index, pk ->
"${pk.name} = $$${columns.size + index + 1}"
}
sb.append(whereStatements.joinToString(" AND "))
sb.append(";")
return sb.toString()
}
/**
* 生成删除SQL
* @param entityClass 实体类
* @param mapper 注解映射中间类
* @return 删除SQL语句模板
*/
fun generateDeleteSql(entityClass: KClass<*>, mapper: SqlAnnotationMapper): String {
val sqlInfo = SqlAnnotationMapperGenerator.extractSqlInfo(entityClass, mapper)
val tableName = sqlInfo.tableName
val primaryKeys = sqlInfo.columns.filter { it.isPrimaryKey }
if (tableName.isEmpty() || primaryKeys.isEmpty()) {
throw IllegalArgumentException("无法生成SQL表名或主键为空")
}
val sb = StringBuilder()
sb.append("DELETE FROM $tableName WHERE ")
// 添加条件
val whereStatements = primaryKeys.mapIndexed { index, pk ->
"${pk.name} = $$${index + 1}"
}
sb.append(whereStatements.joinToString(" AND "))
sb.append(";")
return sb.toString()
}
/**
* 生成查询SQL
* @param entityClass 实体类
* @param mapper 注解映射中间类
* @return 查询SQL语句
*/
fun generateSelectSql(entityClass: KClass<*>, mapper: SqlAnnotationMapper): String {
val sqlInfo = SqlAnnotationMapperGenerator.extractSqlInfo(entityClass, mapper)
val tableName = sqlInfo.tableName
val columns = sqlInfo.columns
if (tableName.isEmpty() || columns.isEmpty()) {
throw IllegalArgumentException("无法生成SQL表名或列信息为空")
}
val columnNames = columns.map { it.name }
return "SELECT ${columnNames.joinToString(", ")} FROM $tableName;"
}
/**
* 生成根据主键查询SQL
* @param entityClass 实体类
* @param mapper 注解映射中间类
* @return 根据主键查询SQL语句
*/
fun generateSelectByPrimaryKeySql(entityClass: KClass<*>, mapper: SqlAnnotationMapper): String {
val sqlInfo = SqlAnnotationMapperGenerator.extractSqlInfo(entityClass, mapper)
val tableName = sqlInfo.tableName
val columns = sqlInfo.columns
val primaryKeys = sqlInfo.columns.filter { it.isPrimaryKey }
if (tableName.isEmpty() || columns.isEmpty() || primaryKeys.isEmpty()) {
throw IllegalArgumentException("无法生成SQL表名、列信息或主键为空")
}
val columnNames = columns.map { it.name }
val sb = StringBuilder()
sb.append("SELECT ${columnNames.joinToString(", ")} FROM $tableName WHERE ")
// 添加条件
val whereStatements = primaryKeys.mapIndexed { index, pk ->
"${pk.name} = $$${index + 1}"
}
sb.append(whereStatements.joinToString(" AND "))
sb.append(";")
return sb.toString()
}
}
}

View File

@ -0,0 +1,38 @@
package org.aikrai.vertx.db.migration
/**
* SQL信息类
* 存储从实体类中提取的SQL相关信息
*/
data class SqlInfo(
var tableName: String = "",
val columns: MutableList<ColumnInfo> = mutableListOf(),
val primaryKeys: MutableList<String> = mutableListOf(),
val indexes: MutableList<IndexInfo> = mutableListOf()
)
/**
* 列信息类
*/
data class ColumnInfo(
var name: String = "",
var type: String = "",
var nullable: Boolean = true,
var defaultValue: String = "",
var isPrimaryKey: Boolean = false,
var enumValues: List<String>? = null,
var unique: Boolean = false,
var length: Int = 255,
var comment: String = ""
)
/**
* 索引信息类
*/
data class IndexInfo(
var name: String = "",
var columnNames: List<String> = listOf(),
var unique: Boolean = false,
var concurrent: Boolean = false,
var definition: String = ""
)

File diff suppressed because it is too large Load Diff

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

@ -1,4 +1,4 @@
package org.aikrai.vertx.db
package org.aikrai.vertx.db.wrapper
import kotlin.reflect.KProperty1

View File

@ -1,10 +1,10 @@
package org.aikrai.vertx.db
package org.aikrai.vertx.db.wrapper
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

@ -1,4 +1,4 @@
package org.aikrai.vertx.db
package org.aikrai.vertx.db.wrapper
import kotlin.reflect.KProperty1

View File

@ -1,4 +1,4 @@
package org.aikrai.vertx.db
package org.aikrai.vertx.db.wrapper
import cn.hutool.core.util.IdUtil
import cn.hutool.core.util.StrUtil
@ -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,168 @@
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.auth.AuthUser
import org.aikrai.vertx.constant.HttpStatus
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.utlis.Meta
import java.lang.reflect.InvocationTargetException
/**
* 全局错误处理器负责捕获并处理所有未捕获的异常
*/
@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)" }
}
}
/**
* 提取底层实际异常
*/
private fun extractActualException(throwable: Throwable?): Throwable? {
if (throwable == null) return null
return when {
// 处理InvocationTargetException和反射异常它们通常包装了实际异常
throwable is InvocationTargetException -> extractActualException(throwable.targetException)
// 处理包含cause属性的异常
throwable.cause != null && throwable::class.java.name == "java.lang.reflect.InvocationTargetException" -> extractActualException(throwable.cause)
// 对于其他包含targetException属性的异常尝试提取
hasTargetException(throwable) -> getTargetException(throwable)
else -> throwable
}
}
/**
* 检查异常是否有targetException属性
*/
private fun hasTargetException(throwable: Throwable): Boolean {
return try {
val field = throwable::class.java.getDeclaredField("targetException")
field.isAccessible = true
true
} catch (_: Exception) {
false
}
}
/**
* 获取异常的targetException属性值
*/
private fun getTargetException(throwable: Throwable): Throwable {
return try {
val field = throwable::class.java.getDeclaredField("targetException")
field.isAccessible = true
field.get(throwable) as Throwable
} catch (_: Exception) {
throwable
}
}
/**
* 确定HTTP状态码
*/
private fun determineStatusCode(ctx: RoutingContext, failure: Throwable?): Int {
// 优先使用RoutingContext中设置的状态码
if (ctx.statusCode() >= 400) {
return ctx.statusCode()
}
// 提取实际异常
val actualException = extractActualException(failure)
// 根据异常类型确定状态码
return when (actualException) {
is Meta -> HttpStatusMapping.getCode(actualException.name, HttpStatus.ERROR)
is IllegalArgumentException -> HttpStatus.BAD_REQUEST
// 可添加更多异常类型的映射
else -> HttpStatus.ERROR // 默认为500
}
}
/**
* 构建标准错误响应
*/
private fun buildErrorResponse(failure: Throwable?, statusCode: Int): RespBean<Any?> {
// 提取实际异常
val actualException = extractActualException(failure)
return when (actualException) {
null -> RespBean.error(statusCode, "发生未知错误")
is Meta -> RespBean.fromException(actualException, statusCode)
else -> RespBean.fromException(actualException, 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()
val user = (ctx.user() as? AuthUser)
// 提取实际异常
val actualException = extractActualException(failure)
val logMessage = buildString {
append("处理请求失败 - ")
append("请求ID: $requestId, ")
append("用户ID: ${user?.id}, ")
append("方法: $method, ")
append("URI: $uri, ")
append("客户端IP: $remoteAddr, ")
append("状态码: $statusCode")
if (actualException != null) {
append(", 异常类型: ${actualException::class.java.name}")
if (!actualException.message.isNullOrBlank()) {
append(", 消息: ${actualException.message}")
}
}
}
// 根据状态码选择日志级别
if (statusCode >= 500 && actualException != null) {
logger.error(actualException) { logMessage } // 记录带堆栈的日志
} else {
logger.warn { logMessage } // 400级别错误只记录警告
}
}
}

View File

@ -0,0 +1,144 @@
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.auth.AuthUser
import org.aikrai.vertx.jackson.JsonUtil
import org.aikrai.vertx.utlis.IpUtil
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)
// 记录基本请求信息
val request = ctx.request()
val method = request.method()
val path = request.path()
val remoteAddr = IpUtil.getIpAddr(request)
// 记录开始日志
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
// 构建详细日志数据
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() as? AuthUser)?.id
if (userId != null) {
logData.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) + "...")
}
}
val logMessage = buildString {
append("请求完成 - ")
append("方法: $method, ")
append("路径: $path, ")
append("状态码: $statusCode, ")
append("耗时: ${duration}ms, ")
append("请求ID: $requestId")
if (userId != null) {
append(", 用户ID: $userId")
}
}
when {
statusCode >= 500 -> logger.error { logMessage }
statusCode >= 400 -> logger.warn { logMessage }
else -> logger.info { logMessage }
}
// 以JSON格式记录详细信息
logger.debug { "请求详细信息: ${JsonUtil.toJsonStr(logData)}" }
}
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 : ResponseHandler {
/**
* 处理成功响应
*/
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 ResponseHandler {
/**
* 处理成功响应
*
* @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