Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95f76404c2 | |||
| 047717ad71 | |||
| bf3c91f18a | |||
| 1447e26d25 | |||
| 394161415c | |||
| e7016373c2 | |||
| e97f3f5519 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -5,7 +5,8 @@ build/
|
||||
!**/src/test/**/build/
|
||||
/config
|
||||
/gradle
|
||||
log/
|
||||
/logs
|
||||
/.cursor
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
|
||||
393
README.md
393
README.md
@ -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权限
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速开发注解说明
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
@ -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
|
||||
@ -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
|
||||
@ -1,4 +1,4 @@
|
||||
package app.data.domain.account.modle
|
||||
package app.data.dto.account
|
||||
|
||||
import app.data.domain.role.Role
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package app.data.domain.account
|
||||
package app.data.dto.account
|
||||
|
||||
data class LoginDTO(
|
||||
var username: String,
|
||||
@ -1,4 +1,4 @@
|
||||
package app.base.domain.auth.modle
|
||||
package app.data.dto.account
|
||||
|
||||
class LoginUser {
|
||||
var accountId: Long = 0L
|
||||
@ -1,4 +1,4 @@
|
||||
package app.data.domain.menu.modle
|
||||
package app.data.dto.menu
|
||||
|
||||
import app.data.domain.menu.Menu
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
28
vertx-demo/src/main/kotlin/app/data/emun/SexType.kt
Normal file
28
vertx-demo/src/main/kotlin/app/data/emun/SexType.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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> {
|
||||
@ -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> {
|
||||
@ -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>
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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 {
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
@ -1,4 +1,4 @@
|
||||
package app.util
|
||||
package app.utils
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
import com.google.inject.Singleton
|
||||
835
vertx-demo/src/main/kotlin/app/utils/RedisUtil.kt
Normal file
835
vertx-demo/src/main/kotlin/app/utils/RedisUtil.kt
Normal 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) {
|
||||
|
||||
/**
|
||||
* 缓存基本的对象,Integer、String、实体类等
|
||||
*
|
||||
* @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"
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存基本的对象,Integer、String、实体类等
|
||||
*
|
||||
* @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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
18
vertx-demo/src/main/resources/config/application.yaml
Normal file
18
vertx-demo/src/main/resources/config/application.yaml
Normal 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天
|
||||
59
vertx-demo/src/main/resources/dbmigration/1.0__initial.sql
Normal file
59
vertx-demo/src/main/resources/dbmigration/1.0__initial.sql
Normal 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
|
||||
);
|
||||
|
||||
@ -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>
|
||||
@ -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 >= 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 >= 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 >= 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 >= 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>
|
||||
|
||||
113
vertx-demo/src/test/kotlin/app/GenerateMigration.kt
Normal file
113
vertx-demo/src/test/kotlin/app/GenerateMigration.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
apifox:
|
||||
token: APS-xxxxxxxxxxxxxxxxxxxx
|
||||
projectId: xxxxx
|
||||
folderId: xxxxx
|
||||
@ -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
|
||||
@ -1,5 +0,0 @@
|
||||
server:
|
||||
port: 8080
|
||||
package: app
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
package org.aikrai.vertx.db
|
||||
|
||||
object SqlHelper {
|
||||
|
||||
fun retBool(result: Int?): Boolean {
|
||||
return null != result && result >= 1
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
@ -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
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package org.aikrai.vertx.db
|
||||
package org.aikrai.vertx.db.wrapper
|
||||
|
||||
import kotlin.reflect.KProperty1
|
||||
|
||||
@ -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
|
||||
@ -1,4 +1,4 @@
|
||||
package org.aikrai.vertx.db
|
||||
package org.aikrai.vertx.db.wrapper
|
||||
|
||||
import kotlin.reflect.KProperty1
|
||||
|
||||
@ -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
|
||||
@ -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级别错误只记录警告
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
150
vertx-fw/src/main/kotlin/org/aikrai/vertx/http/RespBean.kt
Normal file
150
vertx-fw/src/main/kotlin/org/aikrai/vertx/http/RespBean.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user