优化后端框架

This commit is contained in:
BBIT-Kai
2026-05-07 10:25:02 +08:00
parent c932419c73
commit f7a27d99e1
73 changed files with 1742 additions and 1596 deletions
-1
View File
@@ -1 +0,0 @@
代理商后台入口https://dl.vpiaotong.com/#/login 邀请码:K1N9TP 密码:www.bbitcn.com
+7
View File
@@ -45,4 +45,11 @@ dependencies {
// Redis
implementation("org.redisson:redisson:3.38.1")
// 票通集成
implementation("org.bouncycastle:bcprov-jdk18on:1.84")
implementation("org.bouncycastle:bcpkix-jdk18on:1.84")
implementation("io.ktor:ktor-client-core")
implementation("io.ktor:ktor-client-cio")
implementation("io.ktor:ktor-client-content-negotiation")
}
@@ -2,15 +2,9 @@ package com.bbit.ticket
import com.bbit.ticket.bootstrap.DatabaseInitializer
import com.bbit.ticket.bootstrap.SeedData
import com.bbit.ticket.common.ok
import com.bbit.ticket.config.AppConfig
import com.bbit.ticket.modules.auth.registerAuthRoutes
import com.bbit.ticket.modules.logs.registerLogsQueryRoutes
import com.bbit.ticket.modules.system.dict.registerDictRoutes
import com.bbit.ticket.modules.system.menu.registerMenuRoutes
import com.bbit.ticket.modules.system.org.registerOrgRoutes
import com.bbit.ticket.modules.system.role.registerRoleRoutes
import com.bbit.ticket.modules.system.user.registerUserRoutes
import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.bootstrap.AppConfig
import com.bbit.ticket.route.system.registerAuthRoutes
import com.bbit.ticket.plugins.configureCors
import com.bbit.ticket.plugins.configureDatabase
import com.bbit.ticket.plugins.configureLogging
@@ -20,6 +14,12 @@ import com.bbit.ticket.plugins.configureSecurity
import com.bbit.ticket.plugins.configureSerialization
import com.bbit.ticket.plugins.configureStatusPages
import com.bbit.ticket.plugins.configureTrace
import com.bbit.ticket.route.system.registerDictRoutes
import com.bbit.ticket.route.system.registerLogsQueryRoutes
import com.bbit.ticket.route.system.registerMenuRoutes
import com.bbit.ticket.route.system.registerOrgRoutes
import com.bbit.ticket.route.system.registerRoleRoutes
import com.bbit.ticket.route.system.registerUserRoutes
import kotlinx.coroutines.runBlocking
import io.ktor.server.application.Application
import io.ktor.server.netty.EngineMain
@@ -1,4 +1,4 @@
package com.bbit.ticket.config
package com.bbit.ticket.bootstrap
import io.ktor.server.application.ApplicationEnvironment
@@ -32,9 +32,6 @@ object DatabaseInitializer {
SysApiAccessLogTable,
)
// 先通过 Exposed 生成迁移 SQL,再逐条执行,避免启动时静默跳过缺失表或字段。
dbQuery {
MigrationUtils.statementsRequiredForDatabaseMigration(*tables, withLogs = true)
}
transaction {
val statements = MigrationUtils.statementsRequiredForDatabaseMigration(
*tables,
@@ -11,7 +11,7 @@ import com.bbit.ticket.database.system.SysRoleTable
import com.bbit.ticket.database.system.SysUserRoleTable
import com.bbit.ticket.database.system.SysUserTable
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.security.PasswordService
import com.bbit.ticket.service.system.PasswordService
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
@@ -1,4 +1,4 @@
package com.bbit.ticket.common
package com.bbit.ticket.entity.common
import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package com.bbit.ticket.common
package com.bbit.ticket.entity.common
import io.ktor.http.HttpStatusCode
@@ -1,4 +1,4 @@
package com.bbit.ticket.common
package com.bbit.ticket.entity.common
fun statusLabel(status: String): String = when (status) {
"ENABLED" -> "启用"
@@ -1,4 +1,4 @@
package com.bbit.ticket.common
package com.bbit.ticket.entity.common
enum class ErrorCode(val code: String, val message: String) {
BAD_REQUEST("COMMON.BAD_REQUEST", "请求参数错误"),
@@ -1,4 +1,4 @@
package com.bbit.ticket.common
package com.bbit.ticket.entity.common
import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package com.bbit.ticket.common
package com.bbit.ticket.entity.common
import kotlinx.serialization.Serializable
@@ -1,4 +1,4 @@
package com.bbit.ticket.modules.auth
package com.bbit.ticket.entity.system
import kotlinx.serialization.Serializable
@@ -0,0 +1,54 @@
package com.bbit.ticket.entity.system
import kotlinx.serialization.Serializable
@Serializable
data class DictTypeItem(
val id: String,
val code: String,
val name: String,
val status: String,
val statusLabel: String,
val remark: String? = null,
)
@Serializable
data class DictItem(
val id: String,
val typeId: String,
val label: String,
val value: String,
val color: String? = null,
val sort: Int,
val status: String,
val statusLabel: String,
val remark: String? = null,
)
@Serializable
data class CreateDictTypeRequest(val code: String, val name: String, val status: String = "ENABLED", val remark: String? = null)
@Serializable
data class UpdateDictTypeRequest(val name: String, val status: String = "ENABLED", val remark: String? = null)
@Serializable
data class CreateDictItemRequest(
val typeId: String,
val label: String,
val value: String,
val color: String? = null,
val sort: Int = 0,
val status: String = "ENABLED",
val remark: String? = null,
)
@Serializable
data class UpdateDictItemRequest(
val typeId: String,
val label: String,
val value: String,
val color: String? = null,
val sort: Int = 0,
val status: String = "ENABLED",
val remark: String? = null,
)
@@ -0,0 +1,33 @@
package com.bbit.ticket.entity.system
import kotlinx.serialization.Serializable
@Serializable
data class OperationLogItem(
val id: String,
val traceId: String? = null,
val username: String? = null,
val operationType: String,
val operationName: String,
val httpMethod: String,
val requestPath: String,
val status: String,
val errorMessage: String? = null,
val costMs: Long,
val createdAt: String,
)
@Serializable
data class ApiAccessLogItem(
val id: String,
val traceId: String? = null,
val appKey: String? = null,
val appName: String? = null,
val httpMethod: String,
val requestPath: String,
val responseCode: String? = null,
val status: String,
val errorMessage: String? = null,
val costMs: Long,
val createdAt: String,
)
@@ -0,0 +1,77 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.entity.system
import kotlinx.serialization.Serializable
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Serializable
data class MenuTreeNode(
val id: String,
val parentId: String? = null,
val type: String,
val typeLabel: String,
val title: String,
val name: String? = null,
val path: String? = null,
val component: String? = null,
val icon: String? = null,
val permission: String? = null,
val sort: Int,
val visible: Boolean,
val keepAlive: Boolean,
val builtIn: Boolean = false,
val status: String,
val statusLabel: String,
val children: List<MenuTreeNode> = emptyList(),
)
@Serializable
data class CreateMenuRequest(
val parentId: String? = null,
val type: String,
val title: String,
val name: String? = null,
val path: String? = null,
val component: String? = null,
val icon: String? = null,
val permission: String? = null,
val sort: Int = 0,
val visible: Boolean = true,
val keepAlive: Boolean = false,
val status: String = "ENABLED",
)
@Serializable
data class UpdateMenuRequest(
val parentId: String? = null,
val type: String,
val title: String,
val name: String? = null,
val path: String? = null,
val component: String? = null,
val icon: String? = null,
val permission: String? = null,
val sort: Int = 0,
val visible: Boolean = true,
val keepAlive: Boolean = false,
val status: String = "ENABLED",
)
data class MenuFlat(
val id: Uuid,
val parentId: Uuid?,
val type: String,
val title: String,
val name: String?,
val path: String?,
val component: String?,
val icon: String?,
val permission: String?,
val sort: Int,
val visible: Boolean,
val keepAlive: Boolean,
val builtIn: Boolean,
val status: String,
)
@@ -0,0 +1,32 @@
package com.bbit.ticket.entity.system
import kotlinx.serialization.Serializable
@Serializable
data class OrgTreeNode(
val id: String,
val parentId: String? = null,
val name: String,
val code: String,
val sort: Int,
val status: String,
val statusLabel: String,
val children: List<OrgTreeNode> = emptyList(),
)
@Serializable
data class CreateOrgRequest(
val parentId: String? = null,
val name: String,
val code: String,
val sort: Int = 0,
val status: String = "ENABLED",
)
@Serializable
data class UpdateOrgRequest(
val parentId: String? = null,
val name: String,
val sort: Int = 0,
val status: String = "ENABLED",
)
@@ -0,0 +1,48 @@
package com.bbit.ticket.entity.system
import kotlinx.serialization.Serializable
@Serializable
data class RoleItem(
val id: String,
val name: String,
val code: String,
val description: String? = null,
val status: String,
val statusLabel: String,
val dataScope: String,
val dataScopeLabel: String,
)
@Serializable
data class RoleDetail(
val id: String,
val name: String,
val code: String,
val description: String? = null,
val status: String,
val statusLabel: String,
val dataScope: String,
val dataScopeLabel: String,
val menuIds: List<String>,
)
@Serializable
data class CreateRoleRequest(
val name: String,
val code: String,
val description: String? = null,
val status: String = "ENABLED",
val dataScope: String = "SELF",
)
@Serializable
data class UpdateRoleRequest(
val name: String,
val description: String? = null,
val status: String = "ENABLED",
val dataScope: String = "SELF",
)
@Serializable
data class UpdateRoleMenusRequest(val menuIds: List<String>)
@@ -0,0 +1,62 @@
package com.bbit.ticket.entity.system
import kotlinx.serialization.Serializable
@Serializable
data class UserListItem(
val id: String,
val username: String,
val nickname: String? = null,
val realName: String? = null,
val orgId: String? = null,
val status: String,
val statusLabel: String,
val roleCodes: List<String>,
)
@Serializable
data class UserDetailResponse(
val id: String,
val username: String,
val nickname: String? = null,
val realName: String? = null,
val phone: String? = null,
val email: String? = null,
val avatar: String? = null,
val orgId: String? = null,
val status: String,
val statusLabel: String,
val roleIds: List<String>,
)
@Serializable
data class CreateUserRequest(
val username: String,
val password: String,
val nickname: String? = null,
val realName: String? = null,
val phone: String? = null,
val email: String? = null,
val avatar: String? = null,
val orgId: String? = null,
val status: String = "ENABLED",
)
@Serializable
data class UpdateUserRequest(
val nickname: String? = null,
val realName: String? = null,
val phone: String? = null,
val email: String? = null,
val avatar: String? = null,
val orgId: String? = null,
)
@Serializable
data class UpdateUserStatusRequest(val status: String)
@Serializable
data class UpdateUserPasswordRequest(val password: String)
@Serializable
data class UpdateUserRolesRequest(val roleIds: List<String>)
@@ -1,338 +0,0 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package com.bbit.ticket.modules.system.dict
import com.bbit.ticket.common.BizException
import com.bbit.ticket.common.ErrorCode
import com.bbit.ticket.common.PageResult
import com.bbit.ticket.common.ok
import com.bbit.ticket.common.parseUuid
import com.bbit.ticket.common.queryInt
import com.bbit.ticket.common.queryString
import com.bbit.ticket.common.statusLabel
import com.bbit.ticket.database.system.SysDictItemTable
import com.bbit.ticket.database.system.SysDictTypeTable
import com.bbit.ticket.modules.logs.OperationLogService
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.security.requirePermission
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.time.TimeSource
import kotlin.uuid.Uuid
@Serializable
data class DictTypeItem(
val id: String,
val code: String,
val name: String,
val status: String,
val statusLabel: String,
val remark: String? = null,
)
@Serializable
data class DictItem(
val id: String,
val typeId: String,
val label: String,
val value: String,
val color: String? = null,
val sort: Int,
val status: String,
val statusLabel: String,
val remark: String? = null,
)
@Serializable
data class CreateDictTypeRequest(val code: String, val name: String, val status: String = "ENABLED", val remark: String? = null)
@Serializable
data class UpdateDictTypeRequest(val name: String, val status: String = "ENABLED", val remark: String? = null)
@Serializable
data class CreateDictItemRequest(
val typeId: String,
val label: String,
val value: String,
val color: String? = null,
val sort: Int = 0,
val status: String = "ENABLED",
val remark: String? = null,
)
@Serializable
data class UpdateDictItemRequest(
val typeId: String,
val label: String,
val value: String,
val color: String? = null,
val sort: Int = 0,
val status: String = "ENABLED",
val remark: String? = null,
)
object DictService {
suspend fun listTypes(page: Int, pageSize: Int, keyword: String?): PageResult<DictTypeItem> = dbQuery {
var where = SysDictTypeTable.deletedAt.isNull()
if (!keyword.isNullOrBlank()) {
where = where and ((SysDictTypeTable.code like "%$keyword%") or (SysDictTypeTable.name like "%$keyword%"))
}
val total = SysDictTypeTable.selectAll().where { where }.count()
val rows = SysDictTypeTable.selectAll().where { where }
.orderBy(SysDictTypeTable.createdAt)
.limit(pageSize)
.offset(((page - 1) * pageSize).toLong())
.toList()
PageResult(
items = rows.map {
DictTypeItem(
id = it[SysDictTypeTable.id].toString(),
code = it[SysDictTypeTable.code],
name = it[SysDictTypeTable.name],
status = it[SysDictTypeTable.status],
statusLabel = statusLabel(it[SysDictTypeTable.status]),
remark = it[SysDictTypeTable.remark],
)
},
page = page,
pageSize = pageSize,
total = total,
)
}
suspend fun createType(request: CreateDictTypeRequest): String = dbQuery {
if (request.code.trim().isBlank() || request.name.trim().isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "字典类型编码和名称不能为空")
}
val exists = SysDictTypeTable.selectAll().where {
(SysDictTypeTable.code eq request.code.trim()) and SysDictTypeTable.deletedAt.isNull()
}.any()
if (exists) throw BizException(ErrorCode.DATA_CONFLICT.code, "字典类型编码已存在")
val inserted = SysDictTypeTable.insert {
it[code] = request.code.trim()
it[name] = request.name.trim()
it[status] = request.status
it[remark] = request.remark?.trim()
it[createdAt] = OffsetDateTime.now()
}
inserted[SysDictTypeTable.id].toString()
}
suspend fun updateType(id: Uuid, request: UpdateDictTypeRequest) = dbQuery {
requireType(id)
SysDictTypeTable.update({ SysDictTypeTable.id eq id }) {
it[name] = request.name.trim()
it[status] = request.status
it[remark] = request.remark?.trim()
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun deleteType(id: Uuid) = dbQuery {
requireType(id)
val hasItems = SysDictItemTable.selectAll().where {
(SysDictItemTable.typeId eq id) and SysDictItemTable.deletedAt.isNull()
}.any()
if (hasItems) throw BizException(ErrorCode.BAD_REQUEST.code, "字典类型下存在字典项,不能删除")
SysDictTypeTable.update({ SysDictTypeTable.id eq id }) {
it[deletedAt] = OffsetDateTime.now()
}
}
suspend fun listItems(page: Int, pageSize: Int, typeId: Uuid?): PageResult<DictItem> = dbQuery {
var where = SysDictItemTable.deletedAt.isNull()
if (typeId != null) where = where and (SysDictItemTable.typeId eq typeId)
val total = SysDictItemTable.selectAll().where { where }.count()
val rows = SysDictItemTable.selectAll().where { where }
.orderBy(SysDictItemTable.sort)
.limit(pageSize)
.offset(((page - 1) * pageSize).toLong())
.toList()
PageResult(
items = rows.map {
DictItem(
id = it[SysDictItemTable.id].toString(),
typeId = it[SysDictItemTable.typeId].toString(),
label = it[SysDictItemTable.label],
value = it[SysDictItemTable.value],
color = it[SysDictItemTable.color],
sort = it[SysDictItemTable.sort],
status = it[SysDictItemTable.status],
statusLabel = statusLabel(it[SysDictItemTable.status]),
remark = it[SysDictItemTable.remark],
)
},
page = page,
pageSize = pageSize,
total = total,
)
}
suspend fun createItem(request: CreateDictItemRequest): String = dbQuery {
val typeId = parseUuid(request.typeId, "typeId")
requireType(typeId)
val inserted = SysDictItemTable.insert {
it[SysDictItemTable.typeId] = typeId
it[label] = request.label.trim()
it[value] = request.value.trim()
it[color] = request.color?.trim()
it[sort] = request.sort
it[status] = request.status
it[remark] = request.remark?.trim()
it[createdAt] = OffsetDateTime.now()
}
inserted[SysDictItemTable.id].toString()
}
suspend fun updateItem(id: Uuid, request: UpdateDictItemRequest) = dbQuery {
requireItem(id)
val typeId = parseUuid(request.typeId, "typeId")
requireType(typeId)
SysDictItemTable.update({ SysDictItemTable.id eq id }) {
it[SysDictItemTable.typeId] = typeId
it[label] = request.label.trim()
it[value] = request.value.trim()
it[color] = request.color?.trim()
it[sort] = request.sort
it[status] = request.status
it[remark] = request.remark?.trim()
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun deleteItem(id: Uuid) = dbQuery {
requireItem(id)
SysDictItemTable.update({ SysDictItemTable.id eq id }) {
it[deletedAt] = OffsetDateTime.now()
}
}
private fun requireType(id: Uuid): ResultRow =
SysDictTypeTable.selectAll().where { (SysDictTypeTable.id eq id) and SysDictTypeTable.deletedAt.isNull() }.singleOrNull()
?: throw BizException(ErrorCode.DICT_TYPE_NOT_FOUND.code, ErrorCode.DICT_TYPE_NOT_FOUND.message, HttpStatusCode.NotFound)
private fun requireItem(id: Uuid): ResultRow =
SysDictItemTable.selectAll().where { (SysDictItemTable.id eq id) and SysDictItemTable.deletedAt.isNull() }.singleOrNull()
?: throw BizException(ErrorCode.DICT_ITEM_NOT_FOUND.code, ErrorCode.DICT_ITEM_NOT_FOUND.message, HttpStatusCode.NotFound)
}
fun Route.registerDictRoutes() {
authenticate("auth-jwt") {
route("/api/system/dict-types") {
get {
call.requirePermission("system:dict:view")
val page = call.queryInt("page", 1)
val pageSize = call.queryInt("pageSize", 20)
call.respond(ok(DictService.listTypes(page, pageSize, call.queryString("keyword"))))
}
post {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:dict:create")
val request = call.receive<CreateDictTypeRequest>()
runCatching {
val id = DictService.createType(request)
call.respond(ok(mapOf("id" to id)))
OperationLogService.success(call, currentUser, "CREATE", "新增字典类型", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "CREATE", "新增字典类型", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:dict:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateDictTypeRequest>()
runCatching {
DictService.updateType(id, request)
call.respond(ok<Unit>(message = "更新成功"))
OperationLogService.success(call, currentUser, "UPDATE", "更新字典类型", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE", "更新字典类型", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
delete("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:dict:delete")
val id = parseUuid(call.parameters["id"] ?: "", "id")
runCatching {
DictService.deleteType(id)
call.respond(ok<Unit>(message = "删除成功"))
OperationLogService.success(call, currentUser, "DELETE", "删除字典类型", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "DELETE", "删除字典类型", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
}
route("/api/system/dict-items") {
get {
call.requirePermission("system:dict:view")
val page = call.queryInt("page", 1)
val pageSize = call.queryInt("pageSize", 20)
val typeId = call.queryString("typeId")?.let { parseUuid(it, "typeId") }
call.respond(ok(DictService.listItems(page, pageSize, typeId)))
}
post {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:dict:create")
val request = call.receive<CreateDictItemRequest>()
runCatching {
val id = DictService.createItem(request)
call.respond(ok(mapOf("id" to id)))
OperationLogService.success(call, currentUser, "CREATE", "新增字典项", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "CREATE", "新增字典项", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:dict:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateDictItemRequest>()
runCatching {
DictService.updateItem(id, request)
call.respond(ok<Unit>(message = "更新成功"))
OperationLogService.success(call, currentUser, "UPDATE", "更新字典项", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE", "更新字典项", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
delete("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:dict:delete")
val id = parseUuid(call.parameters["id"] ?: "", "id")
runCatching {
DictService.deleteItem(id)
call.respond(ok<Unit>(message = "删除成功"))
OperationLogService.success(call, currentUser, "DELETE", "删除字典项", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "DELETE", "删除字典项", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
}
}
}
@@ -1,280 +0,0 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package com.bbit.ticket.modules.system.menu
import com.bbit.ticket.common.BizException
import com.bbit.ticket.common.ErrorCode
import com.bbit.ticket.common.ok
import com.bbit.ticket.common.parseUuid
import com.bbit.ticket.common.menuTypeLabel
import com.bbit.ticket.common.statusLabel
import com.bbit.ticket.database.system.SysMenuTable
import com.bbit.ticket.database.system.SysRoleMenuTable
import com.bbit.ticket.modules.logs.OperationLogService
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.security.requirePermission
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.time.TimeSource
import kotlin.uuid.Uuid
@Serializable
data class MenuTreeNode(
val id: String,
val parentId: String? = null,
val type: String,
val typeLabel: String,
val title: String,
val name: String? = null,
val path: String? = null,
val component: String? = null,
val icon: String? = null,
val permission: String? = null,
val sort: Int,
val visible: Boolean,
val keepAlive: Boolean,
val builtIn: Boolean = false,
val status: String,
val statusLabel: String,
val children: List<MenuTreeNode> = emptyList(),
)
@Serializable
data class CreateMenuRequest(
val parentId: String? = null,
val type: String,
val title: String,
val name: String? = null,
val path: String? = null,
val component: String? = null,
val icon: String? = null,
val permission: String? = null,
val sort: Int = 0,
val visible: Boolean = true,
val keepAlive: Boolean = false,
val status: String = "ENABLED",
)
@Serializable
data class UpdateMenuRequest(
val parentId: String? = null,
val type: String,
val title: String,
val name: String? = null,
val path: String? = null,
val component: String? = null,
val icon: String? = null,
val permission: String? = null,
val sort: Int = 0,
val visible: Boolean = true,
val keepAlive: Boolean = false,
val status: String = "ENABLED",
)
object MenuService {
suspend fun tree(): List<MenuTreeNode> = dbQuery {
val rows = SysMenuTable.selectAll().where { SysMenuTable.deletedAt.isNull() }.toList()
val flat = rows.map {
MenuFlat(
id = it[SysMenuTable.id],
parentId = it[SysMenuTable.parentId],
type = it[SysMenuTable.type],
title = it[SysMenuTable.title],
name = it[SysMenuTable.name],
path = it[SysMenuTable.path],
component = it[SysMenuTable.component],
icon = it[SysMenuTable.icon],
permission = it[SysMenuTable.permission],
sort = it[SysMenuTable.sort],
visible = it[SysMenuTable.visible],
keepAlive = it[SysMenuTable.keepAlive],
builtIn = it[SysMenuTable.builtIn],
status = it[SysMenuTable.status],
)
}
buildTree(flat)
}
suspend fun create(request: CreateMenuRequest): String = dbQuery {
validateMenuType(request.type)
val parentId = request.parentId?.let { parseUuid(it, "parentId") }
if (parentId != null) requireMenu(parentId)
val inserted = SysMenuTable.insert {
it[SysMenuTable.parentId] = parentId
it[SysMenuTable.type] = request.type
it[title] = request.title.trim()
it[name] = request.name?.trim()
it[path] = request.path?.trim()
it[component] = request.component?.trim()
it[icon] = request.icon?.trim()
it[permission] = request.permission?.trim()
it[sort] = request.sort
it[visible] = request.visible
it[keepAlive] = request.keepAlive
it[builtIn] = false
it[status] = request.status
it[createdAt] = OffsetDateTime.now()
}
inserted[SysMenuTable.id].toString()
}
suspend fun update(id: Uuid, request: UpdateMenuRequest) = dbQuery {
requireMenu(id)
validateMenuType(request.type)
val parentId = request.parentId?.let { parseUuid(it, "parentId") }
if (parentId == id) throw BizException(ErrorCode.BAD_REQUEST.code, "上级菜单不能选择自身")
if (parentId != null) requireMenu(parentId)
SysMenuTable.update({ SysMenuTable.id eq id }) {
it[SysMenuTable.parentId] = parentId
it[SysMenuTable.type] = request.type
it[title] = request.title.trim()
it[name] = request.name?.trim()
it[path] = request.path?.trim()
it[component] = request.component?.trim()
it[icon] = request.icon?.trim()
it[permission] = request.permission?.trim()
it[sort] = request.sort
it[visible] = request.visible
it[keepAlive] = request.keepAlive
it[status] = request.status
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun delete(id: Uuid) = dbQuery {
requireMenu(id)
val hasChildren = SysMenuTable.selectAll().where {
(SysMenuTable.parentId eq id) and SysMenuTable.deletedAt.isNull()
}.any()
if (hasChildren) throw BizException(ErrorCode.BAD_REQUEST.code, "存在子菜单,不能删除")
val referenced = SysRoleMenuTable.selectAll().where { SysRoleMenuTable.menuId eq id }.any()
if (referenced) throw BizException(ErrorCode.BAD_REQUEST.code, "菜单已被角色引用,不能删除")
val row = requireMenu(id)
if (row[SysMenuTable.builtIn]) throw BizException(ErrorCode.BAD_REQUEST.code, "基础框架内置菜单不可删除")
SysMenuTable.update({ SysMenuTable.id eq id }) {
it[deletedAt] = OffsetDateTime.now()
}
}
private fun requireMenu(id: Uuid): ResultRow =
SysMenuTable.selectAll().where { (SysMenuTable.id eq id) and SysMenuTable.deletedAt.isNull() }.singleOrNull()
?: throw BizException(ErrorCode.MENU_NOT_FOUND.code, ErrorCode.MENU_NOT_FOUND.message, HttpStatusCode.NotFound)
private fun validateMenuType(type: String) {
if (type !in setOf("CATALOG", "MENU", "BUTTON")) {
throw BizException(ErrorCode.BAD_REQUEST.code, "菜单类型必须是目录、菜单或按钮")
}
}
private fun buildTree(items: List<MenuFlat>): List<MenuTreeNode> {
val grouped = items.groupBy { it.parentId }
fun children(parentId: Uuid?): List<MenuTreeNode> =
(grouped[parentId] ?: emptyList()).sortedBy { it.sort }.map { menu ->
MenuTreeNode(
id = menu.id.toString(),
parentId = menu.parentId?.toString(),
type = menu.type,
typeLabel = menuTypeLabel(menu.type),
title = menu.title,
name = menu.name,
path = menu.path,
component = menu.component,
icon = menu.icon,
permission = menu.permission,
sort = menu.sort,
visible = menu.visible,
keepAlive = menu.keepAlive,
builtIn = menu.builtIn,
status = menu.status,
statusLabel = statusLabel(menu.status),
children = children(menu.id),
)
}
return children(null)
}
}
private data class MenuFlat(
val id: Uuid,
val parentId: Uuid?,
val type: String,
val title: String,
val name: String?,
val path: String?,
val component: String?,
val icon: String?,
val permission: String?,
val sort: Int,
val visible: Boolean,
val keepAlive: Boolean,
val builtIn: Boolean,
val status: String,
)
fun Route.registerMenuRoutes() {
authenticate("auth-jwt") {
route("/api/system/menus") {
get {
call.requirePermission("system:menu:view")
call.respond(ok(MenuService.tree()))
}
post {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:menu:create")
val request = call.receive<CreateMenuRequest>()
runCatching {
val id = MenuService.create(request)
call.respond(ok(mapOf("id" to id)))
OperationLogService.success(call, currentUser, "CREATE", "新增菜单", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "CREATE", "新增菜单", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:menu:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateMenuRequest>()
runCatching {
MenuService.update(id, request)
call.respond(ok<Unit>(message = "更新成功"))
OperationLogService.success(call, currentUser, "UPDATE", "更新菜单", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE", "更新菜单", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
delete("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:menu:delete")
val id = parseUuid(call.parameters["id"] ?: "", "id")
runCatching {
MenuService.delete(id)
call.respond(ok<Unit>(message = "删除成功"))
OperationLogService.success(call, currentUser, "DELETE", "删除菜单", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "DELETE", "删除菜单", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
}
}
}
@@ -1,285 +0,0 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package com.bbit.ticket.modules.system.role
import com.bbit.ticket.common.BizException
import com.bbit.ticket.common.ErrorCode
import com.bbit.ticket.common.PageResult
import com.bbit.ticket.common.ok
import com.bbit.ticket.common.parseUuid
import com.bbit.ticket.common.queryInt
import com.bbit.ticket.common.queryString
import com.bbit.ticket.common.dataScopeLabel
import com.bbit.ticket.common.statusLabel
import com.bbit.ticket.database.system.SysMenuTable
import com.bbit.ticket.database.system.SysRoleMenuTable
import com.bbit.ticket.database.system.SysRoleTable
import com.bbit.ticket.database.system.SysUserRoleTable
import com.bbit.ticket.modules.logs.OperationLogService
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.security.requirePermission
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.time.TimeSource
import kotlin.uuid.Uuid
@Serializable
data class RoleItem(
val id: String,
val name: String,
val code: String,
val description: String? = null,
val status: String,
val statusLabel: String,
val dataScope: String,
val dataScopeLabel: String,
)
@Serializable
data class RoleDetail(
val id: String,
val name: String,
val code: String,
val description: String? = null,
val status: String,
val statusLabel: String,
val dataScope: String,
val dataScopeLabel: String,
val menuIds: List<String>,
)
@Serializable
data class CreateRoleRequest(
val name: String,
val code: String,
val description: String? = null,
val status: String = "ENABLED",
val dataScope: String = "SELF",
)
@Serializable
data class UpdateRoleRequest(
val name: String,
val description: String? = null,
val status: String = "ENABLED",
val dataScope: String = "SELF",
)
@Serializable
data class UpdateRoleMenusRequest(val menuIds: List<String>)
object RoleService {
suspend fun list(page: Int, pageSize: Int, keyword: String?, status: String?): PageResult<RoleItem> = dbQuery {
var where = SysRoleTable.deletedAt.isNull()
if (!keyword.isNullOrBlank()) {
where = where and ((SysRoleTable.name like "%$keyword%") or (SysRoleTable.code like "%$keyword%"))
}
if (!status.isNullOrBlank()) {
where = where and (SysRoleTable.status eq status)
}
val total = SysRoleTable.selectAll().where { where }.count()
val rows = SysRoleTable.selectAll().where { where }
.orderBy(SysRoleTable.createdAt)
.limit(pageSize)
.offset(((page - 1) * pageSize).toLong())
.toList()
PageResult(
items = rows.map {
RoleItem(
id = it[SysRoleTable.id].toString(),
name = it[SysRoleTable.name],
code = it[SysRoleTable.code],
description = it[SysRoleTable.description],
status = it[SysRoleTable.status],
statusLabel = statusLabel(it[SysRoleTable.status]),
dataScope = it[SysRoleTable.dataScope],
dataScopeLabel = dataScopeLabel(it[SysRoleTable.dataScope]),
)
},
page = page,
pageSize = pageSize,
total = total,
)
}
suspend fun create(request: CreateRoleRequest): String = dbQuery {
if (request.name.trim().isBlank() || request.code.trim().isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "角色名称和编码不能为空")
}
val exists = SysRoleTable.selectAll().where {
(SysRoleTable.code eq request.code.trim()) and SysRoleTable.deletedAt.isNull()
}.any()
if (exists) throw BizException(ErrorCode.DATA_CONFLICT.code, "角色编码已存在")
val inserted = SysRoleTable.insert {
it[name] = request.name.trim()
it[code] = request.code.trim()
it[description] = request.description?.trim()
it[status] = request.status
it[dataScope] = request.dataScope
it[createdAt] = OffsetDateTime.now()
}
inserted[SysRoleTable.id].toString()
}
suspend fun detail(id: Uuid): RoleDetail = dbQuery {
val role = requireRole(id)
val menuIds = SysRoleMenuTable.selectAll().where { SysRoleMenuTable.roleId eq id }.map { it[SysRoleMenuTable.menuId].toString() }
RoleDetail(
id = role[SysRoleTable.id].toString(),
name = role[SysRoleTable.name],
code = role[SysRoleTable.code],
description = role[SysRoleTable.description],
status = role[SysRoleTable.status],
statusLabel = statusLabel(role[SysRoleTable.status]),
dataScope = role[SysRoleTable.dataScope],
dataScopeLabel = dataScopeLabel(role[SysRoleTable.dataScope]),
menuIds = menuIds,
)
}
suspend fun update(id: Uuid, request: UpdateRoleRequest) = dbQuery {
requireRole(id)
SysRoleTable.update({ SysRoleTable.id eq id }) {
it[name] = request.name.trim()
it[description] = request.description?.trim()
it[status] = request.status
it[dataScope] = request.dataScope
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun delete(id: Uuid) = dbQuery {
val role = requireRole(id)
if (role[SysRoleTable.code] == "SUPER_ADMIN") {
throw BizException(ErrorCode.BAD_REQUEST.code, "超级管理员角色不可删除")
}
val inUse = SysUserRoleTable.selectAll().where { SysUserRoleTable.roleId eq id }.any()
if (inUse) {
throw BizException(ErrorCode.BAD_REQUEST.code, "角色已被用户使用,不能删除")
}
SysRoleTable.update({ SysRoleTable.id eq id }) {
it[deletedAt] = OffsetDateTime.now()
}
SysRoleMenuTable.deleteWhere { SysRoleMenuTable.roleId eq id }
}
suspend fun updateMenus(id: Uuid, request: UpdateRoleMenusRequest) = dbQuery {
requireRole(id)
val menuIds = request.menuIds.distinct().map { parseUuid(it, "menuId") }
if (menuIds.isNotEmpty()) {
val validCount = SysMenuTable.selectAll().where {
(SysMenuTable.id inList menuIds) and
SysMenuTable.deletedAt.isNull() and
(SysMenuTable.status eq "ENABLED")
}.count()
if (validCount != menuIds.size.toLong()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "包含不存在或禁用菜单")
}
}
SysRoleMenuTable.deleteWhere { SysRoleMenuTable.roleId eq id }
menuIds.forEach { menuId ->
SysRoleMenuTable.insertIgnore {
it[roleId] = id
it[SysRoleMenuTable.menuId] = menuId
}
}
}
private fun requireRole(id: Uuid): ResultRow =
SysRoleTable.selectAll().where { (SysRoleTable.id eq id) and SysRoleTable.deletedAt.isNull() }.singleOrNull()
?: throw BizException(ErrorCode.ROLE_NOT_FOUND.code, ErrorCode.ROLE_NOT_FOUND.message, HttpStatusCode.NotFound)
}
fun Route.registerRoleRoutes() {
authenticate("auth-jwt") {
route("/api/system/roles") {
get {
call.requirePermission("system:role:view")
val page = call.queryInt("page", 1)
val pageSize = call.queryInt("pageSize", 20)
call.respond(ok(RoleService.list(page, pageSize, call.queryString("keyword"), call.queryString("status"))))
}
post {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:role:create")
val request = call.receive<CreateRoleRequest>()
runCatching {
val id = RoleService.create(request)
call.respond(ok(mapOf("id" to id)))
OperationLogService.success(call, currentUser, "CREATE", "新增角色", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "CREATE", "新增角色", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
get("/{id}") {
call.requirePermission("system:role:view")
val id = parseUuid(call.parameters["id"] ?: "", "id")
call.respond(ok(RoleService.detail(id)))
}
put("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:role:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateRoleRequest>()
runCatching {
RoleService.update(id, request)
call.respond(ok<Unit>(message = "更新成功"))
OperationLogService.success(call, currentUser, "UPDATE", "更新角色", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE", "更新角色", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
delete("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:role:delete")
val id = parseUuid(call.parameters["id"] ?: "", "id")
runCatching {
RoleService.delete(id)
call.respond(ok<Unit>(message = "删除成功"))
OperationLogService.success(call, currentUser, "DELETE", "删除角色", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "DELETE", "删除角色", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}/menus") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:role:assign")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateRoleMenusRequest>()
runCatching {
RoleService.updateMenus(id, request)
call.respond(ok<Unit>(message = "菜单分配成功"))
OperationLogService.success(call, currentUser, "ASSIGN_MENU", "分配角色菜单", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "ASSIGN_MENU", "分配角色菜单", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
}
}
}
@@ -1,421 +0,0 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package com.bbit.ticket.modules.system.user
import com.bbit.ticket.common.BizException
import com.bbit.ticket.common.ErrorCode
import com.bbit.ticket.common.PageResult
import com.bbit.ticket.common.ok
import com.bbit.ticket.common.parseUuid
import com.bbit.ticket.common.queryInt
import com.bbit.ticket.common.queryString
import com.bbit.ticket.common.statusLabel
import com.bbit.ticket.database.system.SysOrgTable
import com.bbit.ticket.database.system.SysRoleTable
import com.bbit.ticket.database.system.SysUserRoleTable
import com.bbit.ticket.database.system.SysUserTable
import com.bbit.ticket.modules.logs.OperationLogService
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.security.PasswordService
import com.bbit.ticket.security.requirePermission
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.time.TimeSource
import kotlin.uuid.Uuid
@Serializable
data class UserListItem(
val id: String,
val username: String,
val nickname: String? = null,
val realName: String? = null,
val orgId: String? = null,
val status: String,
val statusLabel: String,
val roleCodes: List<String>,
)
@Serializable
data class UserDetailResponse(
val id: String,
val username: String,
val nickname: String? = null,
val realName: String? = null,
val phone: String? = null,
val email: String? = null,
val avatar: String? = null,
val orgId: String? = null,
val status: String,
val statusLabel: String,
val roleIds: List<String>,
)
@Serializable
data class CreateUserRequest(
val username: String,
val password: String,
val nickname: String? = null,
val realName: String? = null,
val phone: String? = null,
val email: String? = null,
val avatar: String? = null,
val orgId: String? = null,
val status: String = "ENABLED",
)
@Serializable
data class UpdateUserRequest(
val nickname: String? = null,
val realName: String? = null,
val phone: String? = null,
val email: String? = null,
val avatar: String? = null,
val orgId: String? = null,
)
@Serializable
data class UpdateUserStatusRequest(val status: String)
@Serializable
data class UpdateUserPasswordRequest(val password: String)
@Serializable
data class UpdateUserRolesRequest(val roleIds: List<String>)
object UserService {
suspend fun list(
page: Int,
pageSize: Int,
username: String?,
nickname: String?,
status: String?,
orgId: Uuid?,
): PageResult<UserListItem> = dbQuery {
val where = buildWhere(username, nickname, status, orgId)
val total = SysUserTable.selectAll().where { where }.count()
val rows = SysUserTable.selectAll()
.where { where }
.orderBy(SysUserTable.createdAt)
.limit(pageSize)
.offset(((page - 1) * pageSize).toLong())
.toList()
val userIds = rows.map { it[SysUserTable.id] }
val roleMap = if (userIds.isEmpty()) {
emptyMap()
} else {
(SysUserRoleTable innerJoin SysRoleTable).selectAll()
.where {
(SysUserRoleTable.userId inList userIds) and
SysRoleTable.deletedAt.isNull()
}
.groupBy { it[SysUserRoleTable.userId] }
.mapValues { entry -> entry.value.map { row -> row[SysRoleTable.code] }.distinct() }
}
PageResult(
items = rows.map { row ->
UserListItem(
id = row[SysUserTable.id].toString(),
username = row[SysUserTable.username],
nickname = row[SysUserTable.nickname],
realName = row[SysUserTable.realName],
orgId = row[SysUserTable.orgId]?.toString(),
status = row[SysUserTable.status],
statusLabel = statusLabel(row[SysUserTable.status]),
roleCodes = roleMap[row[SysUserTable.id]] ?: emptyList(),
)
},
page = page,
pageSize = pageSize,
total = total,
)
}
suspend fun create(request: CreateUserRequest): String = dbQuery {
val username = request.username.trim()
if (username.isBlank() || request.password.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "用户名和密码不能为空")
}
val existed = SysUserTable.selectAll().where {
(SysUserTable.username eq username) and SysUserTable.deletedAt.isNull()
}.any()
if (existed) {
throw BizException(ErrorCode.DATA_CONFLICT.code, "用户名已存在")
}
val orgUuid = request.orgId?.let { parseUuid(it, "orgId") }
if (orgUuid != null) {
ensureOrgExists(orgUuid)
}
val now = OffsetDateTime.now()
val row = SysUserTable.insert {
it[SysUserTable.username] = username
it[passwordHash] = PasswordService.hash(request.password)
it[nickname] = request.nickname?.trim()
it[realName] = request.realName?.trim()
it[phone] = request.phone?.trim()
it[email] = request.email?.trim()
it[avatar] = request.avatar?.trim()
it[orgId] = orgUuid
it[status] = request.status
it[tokenVersion] = 1
it[createdAt] = now
}
row[SysUserTable.id].toString()
}
suspend fun detail(id: Uuid): UserDetailResponse = dbQuery {
val user = requireUser(id)
val roleIds = SysUserRoleTable.selectAll().where { SysUserRoleTable.userId eq id }
.map { it[SysUserRoleTable.roleId].toString() }
UserDetailResponse(
id = user[SysUserTable.id].toString(),
username = user[SysUserTable.username],
nickname = user[SysUserTable.nickname],
realName = user[SysUserTable.realName],
phone = user[SysUserTable.phone],
email = user[SysUserTable.email],
avatar = user[SysUserTable.avatar],
orgId = user[SysUserTable.orgId]?.toString(),
status = user[SysUserTable.status],
statusLabel = statusLabel(user[SysUserTable.status]),
roleIds = roleIds,
)
}
suspend fun update(id: Uuid, request: UpdateUserRequest) = dbQuery {
requireUser(id)
val orgUuid = request.orgId?.let { parseUuid(it, "orgId") }
if (orgUuid != null) {
ensureOrgExists(orgUuid)
}
SysUserTable.update({ SysUserTable.id eq id }) {
it[nickname] = request.nickname?.trim()
it[realName] = request.realName?.trim()
it[phone] = request.phone?.trim()
it[email] = request.email?.trim()
it[avatar] = request.avatar?.trim()
it[orgId] = orgUuid
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun softDelete(id: Uuid) = dbQuery {
if (id.toString() == "00000000-0000-0000-0000-000000000000") {
throw BizException(ErrorCode.BAD_REQUEST.code, "系统保留用户不可删除")
}
requireUser(id)
SysUserTable.update({ SysUserTable.id eq id }) {
it[deletedAt] = OffsetDateTime.now()
}
SysUserRoleTable.deleteWhere { SysUserRoleTable.userId eq id }
}
suspend fun updateStatus(id: Uuid, request: UpdateUserStatusRequest) = dbQuery {
requireUser(id)
SysUserTable.update({ SysUserTable.id eq id }) {
it[status] = request.status
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun updatePassword(id: Uuid, request: UpdateUserPasswordRequest) = dbQuery {
val user = requireUser(id)
if (request.password.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "密码不能为空")
}
val nextTokenVersion = user[SysUserTable.tokenVersion] + 1
SysUserTable.update({ SysUserTable.id eq id }) {
it[passwordHash] = PasswordService.hash(request.password)
it[tokenVersion] = nextTokenVersion
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun updateRoles(id: Uuid, request: UpdateUserRolesRequest) = dbQuery {
requireUser(id)
val roleIds = request.roleIds.distinct().map { parseUuid(it, "roleId") }
if (roleIds.isNotEmpty()) {
val validCount = SysRoleTable.selectAll().where {
(SysRoleTable.id inList roleIds) and
(SysRoleTable.status eq "ENABLED") and
SysRoleTable.deletedAt.isNull()
}.count()
if (validCount != roleIds.size.toLong()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "包含不存在或已禁用角色")
}
}
SysUserRoleTable.deleteWhere { SysUserRoleTable.userId eq id }
roleIds.forEach { roleId ->
SysUserRoleTable.insertIgnore {
it[userId] = id
it[SysUserRoleTable.roleId] = roleId
}
}
}
private fun buildWhere(username: String?, nickname: String?, status: String?, orgId: Uuid?): Op<Boolean> {
var where: Op<Boolean> = SysUserTable.deletedAt.isNull()
if (!username.isNullOrBlank()) {
where = where and (SysUserTable.username like "%$username%")
}
if (!nickname.isNullOrBlank()) {
where = where and (SysUserTable.nickname like "%$nickname%")
}
if (!status.isNullOrBlank()) {
where = where and (SysUserTable.status eq status)
}
if (orgId != null) {
where = where and (SysUserTable.orgId eq orgId)
}
return where
}
private fun requireUser(id: Uuid) =
SysUserTable.selectAll().where { (SysUserTable.id eq id) and SysUserTable.deletedAt.isNull() }.singleOrNull()
?: throw BizException(ErrorCode.USER_NOT_FOUND.code, ErrorCode.USER_NOT_FOUND.message, HttpStatusCode.NotFound)
private fun ensureOrgExists(orgId: Uuid) {
val exists = SysOrgTable.selectAll().where { (SysOrgTable.id eq orgId) and SysOrgTable.deletedAt.isNull() }.any()
if (!exists) {
throw BizException(ErrorCode.ORG_NOT_FOUND.code, ErrorCode.ORG_NOT_FOUND.message, HttpStatusCode.BadRequest)
}
}
}
fun Route.registerUserRoutes() {
authenticate("auth-jwt") {
route("/api/system/users") {
get {
call.requirePermission("system:user:view")
val page = call.queryInt("page", 1)
val pageSize = call.queryInt("pageSize", 20)
val result = UserService.list(
page = page,
pageSize = pageSize,
username = call.queryString("username"),
nickname = call.queryString("nickname"),
status = call.queryString("status"),
orgId = call.queryString("orgId")?.let { parseUuid(it, "orgId") },
)
call.respond(ok(result))
}
post {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:user:create")
val request = call.receive<CreateUserRequest>()
runCatching {
val id = UserService.create(request)
call.respond(ok(mapOf("id" to id)))
OperationLogService.success(call, currentUser, "CREATE", "新增用户", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "CREATE", "新增用户", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
get("/{id}") {
call.requirePermission("system:user:view")
val id = parseUuid(call.parameters["id"] ?: "", "id")
call.respond(ok(UserService.detail(id)))
}
put("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:user:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateUserRequest>()
runCatching {
UserService.update(id, request)
call.respond(ok<Unit>(message = "更新成功"))
OperationLogService.success(call, currentUser, "UPDATE", "更新用户", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE", "更新用户", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
delete("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:user:delete")
val id = parseUuid(call.parameters["id"] ?: "", "id")
runCatching {
UserService.softDelete(id)
call.respond(ok<Unit>(message = "删除成功"))
OperationLogService.success(call, currentUser, "DELETE", "删除用户", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "DELETE", "删除用户", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}/status") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:user:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateUserStatusRequest>()
runCatching {
UserService.updateStatus(id, request)
call.respond(ok<Unit>(message = "状态更新成功"))
OperationLogService.success(call, currentUser, "UPDATE_STATUS", "更新用户状态", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE_STATUS", "更新用户状态", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}/password") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:user:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateUserPasswordRequest>()
runCatching {
UserService.updatePassword(id, request)
call.respond(ok<Unit>(message = "密码更新成功"))
OperationLogService.success(call, currentUser, "RESET_PASSWORD", "重置用户密码", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "RESET_PASSWORD", "重置用户密码", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}/roles") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:role:assign")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateUserRolesRequest>()
runCatching {
UserService.updateRoles(id, request)
call.respond(ok<Unit>(message = "角色分配成功"))
OperationLogService.success(call, currentUser, "ASSIGN_ROLE", "分配用户角色", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "ASSIGN_ROLE", "分配用户角色", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
}
}
}
@@ -1,6 +1,6 @@
package com.bbit.ticket.plugins
import com.bbit.ticket.common.TraceIdKey
import com.bbit.ticket.utils.TraceIdKey
import com.bbit.ticket.database.system.SysApiAccessLogTable
import io.ktor.server.application.Application
import io.ktor.server.application.createApplicationPlugin
@@ -1,6 +1,6 @@
package com.bbit.ticket.plugins
import com.bbit.ticket.config.AppConfig
import com.bbit.ticket.bootstrap.AppConfig
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.server.application.Application
@@ -9,14 +9,7 @@ import io.ktor.server.plugins.cors.routing.CORS
fun Application.configureCors() {
install(CORS) {
if (AppConfig.cors.allowedHosts.contains("*")) {
anyHost()
} else {
AppConfig.cors.allowedHosts.forEach { allowedHost ->
allowHost(allowedHost, schemes = listOf("http", "https"))
}
}
anyHost()
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Put)
@@ -1,6 +1,6 @@
package com.bbit.ticket.plugins
import com.bbit.ticket.config.AppConfig
import com.bbit.ticket.bootstrap.AppConfig
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.Application
@@ -1,5 +1,6 @@
package com.bbit.ticket.plugins
import com.bbit.ticket.utils.TraceIdKey
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.calllogging.CallLogging
@@ -10,7 +11,7 @@ import org.slf4j.event.Level
fun Application.configureLogging() {
install(CallLogging) {
level = Level.INFO
mdc("traceId") { call -> call.attributes.getOrNull(com.bbit.ticket.common.TraceIdKey) }
mdc("traceId") { call -> call.attributes.getOrNull(TraceIdKey) }
filter { call -> !call.request.path().startsWith("/health") }
format { call ->
val status = call.response.status()
@@ -1,6 +1,6 @@
package com.bbit.ticket.plugins
import com.bbit.ticket.config.AppConfig
import com.bbit.ticket.bootstrap.AppConfig
import io.ktor.server.application.Application
import org.redisson.Redisson
import org.redisson.api.RedissonClient
@@ -2,10 +2,10 @@ package com.bbit.ticket.plugins
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import com.bbit.ticket.common.ErrorCode
import com.bbit.ticket.common.fail
import com.bbit.ticket.common.traceIdOrNull
import com.bbit.ticket.config.AppConfig
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.entity.common.fail
import com.bbit.ticket.utils.traceIdOrNull
import com.bbit.ticket.bootstrap.AppConfig
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
@@ -1,9 +1,9 @@
package com.bbit.ticket.plugins
import com.bbit.ticket.common.BizException
import com.bbit.ticket.common.ErrorCode
import com.bbit.ticket.common.fail
import com.bbit.ticket.common.traceIdOrNull
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.entity.common.fail
import com.bbit.ticket.utils.traceIdOrNull
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
@@ -1,6 +1,6 @@
package com.bbit.ticket.plugins
import com.bbit.ticket.common.TraceIdKey
import com.bbit.ticket.utils.TraceIdKey
import io.ktor.server.application.Application
import io.ktor.server.application.createApplicationPlugin
import io.ktor.server.application.install
@@ -1,8 +1,11 @@
package com.bbit.ticket.modules.auth
package com.bbit.ticket.route.system
import com.bbit.ticket.common.ok
import com.bbit.ticket.modules.logs.OperationLogService
import com.bbit.ticket.security.requireCurrentUser
import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.service.system.AuthService
import com.bbit.ticket.entity.system.LoginRequest
import com.bbit.ticket.service.system.OperationLogService
import com.bbit.ticket.utils.requireCurrentUser
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
@@ -12,6 +15,7 @@ import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlin.time.TimeSource
fun Route.registerAuthRoutes() {
route("/api/auth") {
post("/login") {
@@ -0,0 +1,128 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.route.system
import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.utils.parseUuid
import com.bbit.ticket.utils.queryInt
import com.bbit.ticket.utils.queryString
import com.bbit.ticket.entity.system.CreateDictItemRequest
import com.bbit.ticket.entity.system.CreateDictTypeRequest
import com.bbit.ticket.entity.system.UpdateDictItemRequest
import com.bbit.ticket.entity.system.UpdateDictTypeRequest
import com.bbit.ticket.service.system.OperationLogService
import com.bbit.ticket.utils.requirePermission
import com.bbit.ticket.service.system.DictService
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi
fun Route.registerDictRoutes() {
authenticate("auth-jwt") {
route("/api/system/dict-types") {
get {
call.requirePermission("system:dict:view")
val page = call.queryInt("page", 1)
val pageSize = call.queryInt("pageSize", 20)
call.respond(ok(DictService.listTypes(page, pageSize, call.queryString("keyword"))))
}
post {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:dict:create")
val request = call.receive<CreateDictTypeRequest>()
runCatching {
val id = DictService.createType(request)
call.respond(ok(mapOf("id" to id)))
OperationLogService.success(call, currentUser, "CREATE", "新增字典类型", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "CREATE", "新增字典类型", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:dict:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateDictTypeRequest>()
runCatching {
DictService.updateType(id, request)
call.respond(ok<Unit>(message = "更新成功"))
OperationLogService.success(call, currentUser, "UPDATE", "更新字典类型", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE", "更新字典类型", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
delete("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:dict:delete")
val id = parseUuid(call.parameters["id"] ?: "", "id")
runCatching {
DictService.deleteType(id)
call.respond(ok<Unit>(message = "删除成功"))
OperationLogService.success(call, currentUser, "DELETE", "删除字典类型", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "DELETE", "删除字典类型", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
}
route("/api/system/dict-items") {
get {
call.requirePermission("system:dict:view")
val page = call.queryInt("page", 1)
val pageSize = call.queryInt("pageSize", 20)
val typeId = call.queryString("typeId")?.let { parseUuid(it, "typeId") }
call.respond(ok(DictService.listItems(page, pageSize, typeId)))
}
post {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:dict:create")
val request = call.receive<CreateDictItemRequest>()
runCatching {
val id = DictService.createItem(request)
call.respond(ok(mapOf("id" to id)))
OperationLogService.success(call, currentUser, "CREATE", "新增字典项", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "CREATE", "新增字典项", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:dict:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateDictItemRequest>()
runCatching {
DictService.updateItem(id, request)
call.respond(ok<Unit>(message = "更新成功"))
OperationLogService.success(call, currentUser, "UPDATE", "更新字典项", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE", "更新字典项", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
delete("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:dict:delete")
val id = parseUuid(call.parameters["id"] ?: "", "id")
runCatching {
DictService.deleteItem(id)
call.respond(ok<Unit>(message = "删除成功"))
OperationLogService.success(call, currentUser, "DELETE", "删除字典项", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "DELETE", "删除字典项", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
}
}
}
@@ -0,0 +1,31 @@
package com.bbit.ticket.route.system
import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.utils.queryInt
import com.bbit.ticket.utils.queryString
import com.bbit.ticket.utils.requirePermission
import com.bbit.ticket.service.system.LogsQueryService
import io.ktor.server.auth.authenticate
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
fun Route.registerLogsQueryRoutes() {
authenticate("auth-jwt") {
route("/api/logs") {
get("/operation") {
call.requirePermission("log:operation:view")
val page = call.queryInt("page", 1)
val pageSize = call.queryInt("pageSize", 20)
call.respond(ok(LogsQueryService.operationLogs(page, pageSize, call.queryString("keyword"), call.queryString("status"))))
}
get("/api-access") {
call.requirePermission("log:api-access:view")
val page = call.queryInt("page", 1)
val pageSize = call.queryInt("pageSize", 20)
call.respond(ok(LogsQueryService.apiAccessLogs(page, pageSize, call.queryString("keyword"), call.queryString("status"))))
}
}
}
}
@@ -0,0 +1,73 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.route.system
import com.bbit.ticket.entity.system.CreateMenuRequest
import com.bbit.ticket.entity.system.UpdateMenuRequest
import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.utils.parseUuid
import com.bbit.ticket.utils.requirePermission
import com.bbit.ticket.service.system.MenuService
import com.bbit.ticket.service.system.OperationLogService
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi
fun Route.registerMenuRoutes() {
authenticate("auth-jwt") {
route("/api/system/menus") {
get {
call.requirePermission("system:menu:view")
call.respond(ok(MenuService.tree()))
}
post {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:menu:create")
val request = call.receive<CreateMenuRequest>()
runCatching {
val id = MenuService.create(request)
call.respond(ok(mapOf("id" to id)))
OperationLogService.success(call, currentUser, "CREATE", "新增菜单", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "CREATE", "新增菜单", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:menu:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateMenuRequest>()
runCatching {
MenuService.update(id, request)
call.respond(ok<Unit>(message = "更新成功"))
OperationLogService.success(call, currentUser, "UPDATE", "更新菜单", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE", "更新菜单", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
delete("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:menu:delete")
val id = parseUuid(call.parameters["id"] ?: "", "id")
runCatching {
MenuService.delete(id)
call.respond(ok<Unit>(message = "删除成功"))
OperationLogService.success(call, currentUser, "DELETE", "删除菜单", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "DELETE", "删除菜单", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
}
}
}
@@ -0,0 +1,73 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.route.system
import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.entity.system.CreateOrgRequest
import com.bbit.ticket.entity.system.UpdateOrgRequest
import com.bbit.ticket.utils.parseUuid
import com.bbit.ticket.utils.requirePermission
import com.bbit.ticket.service.system.OperationLogService
import com.bbit.ticket.service.system.OrgService
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi
fun Route.registerOrgRoutes() {
authenticate("auth-jwt") {
route("/api/system/orgs") {
get {
call.requirePermission("system:org:view")
call.respond(ok(OrgService.tree()))
}
post {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:org:create")
val request = call.receive<CreateOrgRequest>()
runCatching {
val id = OrgService.create(request)
call.respond(ok(mapOf("id" to id)))
OperationLogService.success(call, currentUser, "CREATE", "新增组织", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "CREATE", "新增组织", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:org:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateOrgRequest>()
runCatching {
OrgService.update(id, request)
call.respond(ok<Unit>(message = "更新成功"))
OperationLogService.success(call, currentUser, "UPDATE", "更新组织", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE", "更新组织", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
delete("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:org:delete")
val id = parseUuid(call.parameters["id"] ?: "", "id")
runCatching {
OrgService.delete(id)
call.respond(ok<Unit>(message = "删除成功"))
OperationLogService.success(call, currentUser, "DELETE", "删除组织", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "DELETE", "删除组织", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
}
}
}
@@ -0,0 +1,97 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.route.system
import com.bbit.ticket.entity.system.CreateRoleRequest
import com.bbit.ticket.entity.system.UpdateRoleMenusRequest
import com.bbit.ticket.entity.system.UpdateRoleRequest
import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.utils.parseUuid
import com.bbit.ticket.utils.queryInt
import com.bbit.ticket.utils.queryString
import com.bbit.ticket.utils.requirePermission
import com.bbit.ticket.service.system.OperationLogService
import com.bbit.ticket.service.system.RoleService
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi
fun Route.registerRoleRoutes() {
authenticate("auth-jwt") {
route("/api/system/roles") {
get {
call.requirePermission("system:role:view")
val page = call.queryInt("page", 1)
val pageSize = call.queryInt("pageSize", 20)
call.respond(ok(RoleService.list(page, pageSize, call.queryString("keyword"), call.queryString("status"))))
}
post {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:role:create")
val request = call.receive<CreateRoleRequest>()
runCatching {
val id = RoleService.create(request)
call.respond(ok(mapOf("id" to id)))
OperationLogService.success(call, currentUser, "CREATE", "新增角色", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "CREATE", "新增角色", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
get("/{id}") {
call.requirePermission("system:role:view")
val id = parseUuid(call.parameters["id"] ?: "", "id")
call.respond(ok(RoleService.detail(id)))
}
put("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:role:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateRoleRequest>()
runCatching {
RoleService.update(id, request)
call.respond(ok<Unit>(message = "更新成功"))
OperationLogService.success(call, currentUser, "UPDATE", "更新角色", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE", "更新角色", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
delete("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:role:delete")
val id = parseUuid(call.parameters["id"] ?: "", "id")
runCatching {
RoleService.delete(id)
call.respond(ok<Unit>(message = "删除成功"))
OperationLogService.success(call, currentUser, "DELETE", "删除角色", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "DELETE", "删除角色", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}/menus") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:role:assign")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateRoleMenusRequest>()
runCatching {
RoleService.updateMenus(id, request)
call.respond(ok<Unit>(message = "菜单分配成功"))
OperationLogService.success(call, currentUser, "ASSIGN_MENU", "分配角色菜单", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "ASSIGN_MENU", "分配角色菜单", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
}
}
}
@@ -0,0 +1,142 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.route.system
import com.bbit.ticket.entity.system.CreateUserRequest
import com.bbit.ticket.entity.system.UpdateUserPasswordRequest
import com.bbit.ticket.entity.system.UpdateUserRequest
import com.bbit.ticket.entity.system.UpdateUserRolesRequest
import com.bbit.ticket.entity.system.UpdateUserStatusRequest
import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.utils.parseUuid
import com.bbit.ticket.utils.queryInt
import com.bbit.ticket.utils.queryString
import com.bbit.ticket.utils.requirePermission
import com.bbit.ticket.service.system.OperationLogService
import com.bbit.ticket.service.system.UserService
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi
fun Route.registerUserRoutes() {
authenticate("auth-jwt") {
route("/api/system/users") {
get {
call.requirePermission("system:user:view")
val page = call.queryInt("page", 1)
val pageSize = call.queryInt("pageSize", 20)
val result = UserService.list(
page = page,
pageSize = pageSize,
username = call.queryString("username"),
nickname = call.queryString("nickname"),
status = call.queryString("status"),
orgId = call.queryString("orgId")?.let { parseUuid(it, "orgId") },
)
call.respond(ok(result))
}
post {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:user:create")
val request = call.receive<CreateUserRequest>()
runCatching {
val id = UserService.create(request)
call.respond(ok(mapOf("id" to id)))
OperationLogService.success(call, currentUser, "CREATE", "新增用户", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "CREATE", "新增用户", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
get("/{id}") {
call.requirePermission("system:user:view")
val id = parseUuid(call.parameters["id"] ?: "", "id")
call.respond(ok(UserService.detail(id)))
}
put("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:user:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateUserRequest>()
runCatching {
UserService.update(id, request)
call.respond(ok<Unit>(message = "更新成功"))
OperationLogService.success(call, currentUser, "UPDATE", "更新用户", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE", "更新用户", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
delete("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:user:delete")
val id = parseUuid(call.parameters["id"] ?: "", "id")
runCatching {
UserService.softDelete(id)
call.respond(ok<Unit>(message = "删除成功"))
OperationLogService.success(call, currentUser, "DELETE", "删除用户", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "DELETE", "删除用户", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}/status") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:user:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateUserStatusRequest>()
runCatching {
UserService.updateStatus(id, request)
call.respond(ok<Unit>(message = "状态更新成功"))
OperationLogService.success(call, currentUser, "UPDATE_STATUS", "更新用户状态", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE_STATUS", "更新用户状态", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}/password") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:user:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateUserPasswordRequest>()
runCatching {
UserService.updatePassword(id, request)
call.respond(ok<Unit>(message = "密码更新成功"))
OperationLogService.success(call, currentUser, "RESET_PASSWORD", "重置用户密码", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "RESET_PASSWORD", "重置用户密码", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}/roles") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:role:assign")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateUserRolesRequest>()
runCatching {
UserService.updateRoles(id, request)
call.respond(ok<Unit>(message = "角色分配成功"))
OperationLogService.success(call, currentUser, "ASSIGN_ROLE", "分配用户角色", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "ASSIGN_ROLE", "分配用户角色", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
}
}
}
@@ -1,18 +1,21 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.modules.auth
package com.bbit.ticket.service.system
import com.bbit.ticket.common.BizException
import com.bbit.ticket.common.ErrorCode
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.database.system.SysMenuTable
import com.bbit.ticket.database.system.SysRoleMenuTable
import com.bbit.ticket.database.system.SysRoleTable
import com.bbit.ticket.database.system.SysUserRoleTable
import com.bbit.ticket.database.system.SysUserTable
import com.bbit.ticket.entity.system.CurrentUserProfile
import com.bbit.ticket.entity.system.LoginRequest
import com.bbit.ticket.entity.system.LoginResponse
import com.bbit.ticket.entity.system.MeResponse
import com.bbit.ticket.entity.system.MenuNode
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.security.CurrentUser
import com.bbit.ticket.security.JwtService
import com.bbit.ticket.security.PasswordService
import com.bbit.ticket.utils.CurrentUser
import io.ktor.http.HttpStatusCode
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
@@ -21,6 +24,7 @@ import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
object AuthService {
@@ -186,19 +190,19 @@ object AuthService {
return build(null)
}
}
private data class MenuFlat(
val id: Uuid,
val parentId: Uuid?,
val type: String,
val title: String,
val name: String?,
val path: String?,
val component: String?,
val icon: String?,
val permission: String?,
val sort: Int,
val visible: Boolean,
val keepAlive: Boolean,
)
private data class MenuFlat(
val id: Uuid,
val parentId: Uuid?,
val type: String,
val title: String,
val name: String?,
val path: String?,
val component: String?,
val icon: String?,
val permission: String?,
val sort: Int,
val visible: Boolean,
val keepAlive: Boolean,
)
}
@@ -0,0 +1,186 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.service.system
import com.bbit.ticket.database.system.SysDictItemTable
import com.bbit.ticket.database.system.SysDictItemTable.color
import com.bbit.ticket.database.system.SysDictItemTable.label
import com.bbit.ticket.database.system.SysDictTypeTable
import com.bbit.ticket.entity.system.CreateDictItemRequest
import com.bbit.ticket.entity.system.CreateDictTypeRequest
import com.bbit.ticket.entity.system.DictItem
import com.bbit.ticket.entity.system.DictTypeItem
import com.bbit.ticket.entity.system.UpdateDictItemRequest
import com.bbit.ticket.entity.system.UpdateDictTypeRequest
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.entity.common.PageResult
import com.bbit.ticket.entity.common.statusLabel
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.utils.parseUuid
import io.ktor.http.HttpStatusCode
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
object DictService {
suspend fun listTypes(page: Int, pageSize: Int, keyword: String?): PageResult<DictTypeItem> = dbQuery {
var where = SysDictTypeTable.deletedAt.isNull()
if (!keyword.isNullOrBlank()) {
where = where and ((SysDictTypeTable.code like "%$keyword%") or (SysDictTypeTable.name like "%$keyword%"))
}
val total = SysDictTypeTable.selectAll().where { where }.count()
val rows = SysDictTypeTable.selectAll().where { where }
.orderBy(SysDictTypeTable.createdAt)
.limit(pageSize)
.offset(((page - 1) * pageSize).toLong())
.toList()
PageResult(
items = rows.map {
DictTypeItem(
id = it[SysDictTypeTable.id].toString(),
code = it[SysDictTypeTable.code],
name = it[SysDictTypeTable.name],
status = it[SysDictTypeTable.status],
statusLabel = statusLabel(it[SysDictTypeTable.status]),
remark = it[SysDictTypeTable.remark],
)
},
page = page,
pageSize = pageSize,
total = total,
)
}
suspend fun createType(request: CreateDictTypeRequest): String = dbQuery {
if (request.code.trim().isBlank() || request.name.trim().isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "字典类型编码和名称不能为空")
}
val exists = SysDictTypeTable.selectAll().where {
(SysDictTypeTable.code eq request.code.trim()) and SysDictTypeTable.deletedAt.isNull()
}.any()
if (exists) throw BizException(ErrorCode.DATA_CONFLICT.code, "字典类型编码已存在")
val inserted = SysDictTypeTable.insert {
it[code] = request.code.trim()
it[name] = request.name.trim()
it[status] = request.status
it[remark] = request.remark?.trim()
it[createdAt] = OffsetDateTime.now()
}
inserted[SysDictTypeTable.id].toString()
}
suspend fun updateType(id: Uuid, request: UpdateDictTypeRequest) = dbQuery {
requireType(id)
SysDictTypeTable.update({ SysDictTypeTable.id eq id }) {
it[name] = request.name.trim()
it[status] = request.status
it[remark] = request.remark?.trim()
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun deleteType(id: Uuid) = dbQuery {
requireType(id)
val hasItems = SysDictItemTable.selectAll().where {
(SysDictItemTable.typeId eq id) and SysDictItemTable.deletedAt.isNull()
}.any()
if (hasItems) throw BizException(ErrorCode.BAD_REQUEST.code, "字典类型下存在字典项,不能删除")
SysDictTypeTable.update({ SysDictTypeTable.id eq id }) {
it[deletedAt] = OffsetDateTime.now()
}
}
suspend fun listItems(page: Int, pageSize: Int, typeId: Uuid?): PageResult<DictItem> = dbQuery {
var where = SysDictItemTable.deletedAt.isNull()
if (typeId != null) where = where and (SysDictItemTable.typeId eq typeId)
val total = SysDictItemTable.selectAll().where { where }.count()
val rows = SysDictItemTable.selectAll().where { where }
.orderBy(SysDictItemTable.sort)
.limit(pageSize)
.offset(((page - 1) * pageSize).toLong())
.toList()
PageResult(
items = rows.map {
DictItem(
id = it[SysDictItemTable.id].toString(),
typeId = it[SysDictItemTable.typeId].toString(),
label = it[label],
value = it[SysDictItemTable.value],
color = it[color],
sort = it[SysDictItemTable.sort],
status = it[SysDictItemTable.status],
statusLabel = statusLabel(it[SysDictItemTable.status]),
remark = it[SysDictItemTable.remark],
)
},
page = page,
pageSize = pageSize,
total = total,
)
}
suspend fun createItem(request: CreateDictItemRequest): String = dbQuery {
val typeId = parseUuid(request.typeId, "typeId")
requireType(typeId)
val inserted = SysDictItemTable.insert {
it[SysDictItemTable.typeId] = typeId
it[label] = request.label.trim()
it[value] = request.value.trim()
it[color] = request.color?.trim()
it[sort] = request.sort
it[status] = request.status
it[remark] = request.remark?.trim()
it[createdAt] = OffsetDateTime.now()
}
inserted[SysDictItemTable.id].toString()
}
suspend fun updateItem(id: Uuid, request: UpdateDictItemRequest) = dbQuery {
requireItem(id)
val typeId = parseUuid(request.typeId, "typeId")
requireType(typeId)
SysDictItemTable.update({ SysDictItemTable.id eq id }) {
it[SysDictItemTable.typeId] = typeId
it[label] = request.label.trim()
it[value] = request.value.trim()
it[color] = request.color?.trim()
it[sort] = request.sort
it[status] = request.status
it[remark] = request.remark?.trim()
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun deleteItem(id: Uuid) = dbQuery {
requireItem(id)
SysDictItemTable.update({ SysDictItemTable.id eq id }) {
it[deletedAt] = OffsetDateTime.now()
}
}
private fun requireType(id: Uuid): ResultRow =
SysDictTypeTable.selectAll().where { (SysDictTypeTable.id eq id) and SysDictTypeTable.deletedAt.isNull() }.singleOrNull()
?: throw BizException(
ErrorCode.DICT_TYPE_NOT_FOUND.code,
ErrorCode.DICT_TYPE_NOT_FOUND.message,
HttpStatusCode.NotFound
)
private fun requireItem(id: Uuid): ResultRow =
SysDictItemTable.selectAll().where { (SysDictItemTable.id eq id) and SysDictItemTable.deletedAt.isNull() }.singleOrNull()
?: throw BizException(
ErrorCode.DICT_ITEM_NOT_FOUND.code,
ErrorCode.DICT_ITEM_NOT_FOUND.message,
HttpStatusCode.NotFound
)
}
@@ -1,8 +1,8 @@
package com.bbit.ticket.security
package com.bbit.ticket.service.system
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import com.bbit.ticket.config.AppConfig
import com.bbit.ticket.bootstrap.AppConfig
import java.time.Instant
import java.time.temporal.ChronoUnit
@@ -33,4 +33,3 @@ object JwtService {
return token to AppConfig.jwt.accessTokenTtlMinutes * 60
}
}
@@ -1,22 +1,14 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.modules.logs
package com.bbit.ticket.service.system
import com.bbit.ticket.common.PageResult
import com.bbit.ticket.common.formatDateTime
import com.bbit.ticket.common.ok
import com.bbit.ticket.common.queryInt
import com.bbit.ticket.common.queryString
import com.bbit.ticket.database.system.SysApiAccessLogTable
import com.bbit.ticket.database.system.SysOperationLogTable
import com.bbit.ticket.entity.system.ApiAccessLogItem
import com.bbit.ticket.entity.system.OperationLogItem
import com.bbit.ticket.entity.common.PageResult
import com.bbit.ticket.utils.formatDateTime
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.security.requirePermission
import io.ktor.server.auth.authenticate
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and
@@ -24,36 +16,7 @@ import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.jdbc.selectAll
@Serializable
data class OperationLogItem(
val id: String,
val traceId: String? = null,
val username: String? = null,
val operationType: String,
val operationName: String,
val httpMethod: String,
val requestPath: String,
val status: String,
val errorMessage: String? = null,
val costMs: Long,
val createdAt: String,
)
@Serializable
data class ApiAccessLogItem(
val id: String,
val traceId: String? = null,
val appKey: String? = null,
val appName: String? = null,
val httpMethod: String,
val requestPath: String,
val responseCode: String? = null,
val status: String,
val errorMessage: String? = null,
val costMs: Long,
val createdAt: String,
)
import kotlin.uuid.ExperimentalUuidApi
object LogsQueryService {
suspend fun operationLogs(page: Int, pageSize: Int, keyword: String?, status: String?): PageResult<OperationLogItem> = dbQuery {
@@ -124,22 +87,3 @@ object LogsQueryService {
)
}
}
fun Route.registerLogsQueryRoutes() {
authenticate("auth-jwt") {
route("/api/logs") {
get("/operation") {
call.requirePermission("log:operation:view")
val page = call.queryInt("page", 1)
val pageSize = call.queryInt("pageSize", 20)
call.respond(ok(LogsQueryService.operationLogs(page, pageSize, call.queryString("keyword"), call.queryString("status"))))
}
get("/api-access") {
call.requirePermission("log:api-access:view")
val page = call.queryInt("page", 1)
val pageSize = call.queryInt("pageSize", 20)
call.respond(ok(LogsQueryService.apiAccessLogs(page, pageSize, call.queryString("keyword"), call.queryString("status"))))
}
}
}
}
@@ -0,0 +1,161 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.service.system
import com.bbit.ticket.database.system.SysMenuTable
import com.bbit.ticket.database.system.SysMenuTable.builtIn
import com.bbit.ticket.database.system.SysMenuTable.component
import com.bbit.ticket.database.system.SysMenuTable.icon
import com.bbit.ticket.database.system.SysMenuTable.keepAlive
import com.bbit.ticket.database.system.SysMenuTable.path
import com.bbit.ticket.database.system.SysMenuTable.permission
import com.bbit.ticket.database.system.SysMenuTable.visible
import com.bbit.ticket.database.system.SysRoleMenuTable
import com.bbit.ticket.entity.system.CreateMenuRequest
import com.bbit.ticket.entity.system.MenuFlat
import com.bbit.ticket.entity.system.MenuTreeNode
import com.bbit.ticket.entity.system.UpdateMenuRequest
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.entity.common.menuTypeLabel
import com.bbit.ticket.entity.common.statusLabel
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.utils.parseUuid
import io.ktor.http.HttpStatusCode
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
object MenuService {
suspend fun tree(): List<MenuTreeNode> = dbQuery {
val rows = SysMenuTable.selectAll().where { SysMenuTable.deletedAt.isNull() }.toList()
val flat = rows.map {
MenuFlat(
id = it[SysMenuTable.id],
parentId = it[SysMenuTable.parentId],
type = it[SysMenuTable.type],
title = it[SysMenuTable.title],
name = it[SysMenuTable.name],
path = it[path],
component = it[component],
icon = it[icon],
permission = it[permission],
sort = it[SysMenuTable.sort],
visible = it[visible],
keepAlive = it[keepAlive],
builtIn = it[builtIn],
status = it[SysMenuTable.status],
)
}
buildTree(flat)
}
suspend fun create(request: CreateMenuRequest): String = dbQuery {
validateMenuType(request.type)
val parentId = request.parentId?.let { parseUuid(it, "parentId") }
if (parentId != null) requireMenu(parentId)
val inserted = SysMenuTable.insert {
it[SysMenuTable.parentId] = parentId
it[SysMenuTable.type] = request.type
it[title] = request.title.trim()
it[name] = request.name?.trim()
it[path] = request.path?.trim()
it[component] = request.component?.trim()
it[icon] = request.icon?.trim()
it[permission] = request.permission?.trim()
it[sort] = request.sort
it[visible] = request.visible
it[keepAlive] = request.keepAlive
it[builtIn] = false
it[status] = request.status
it[createdAt] = OffsetDateTime.now()
}
inserted[SysMenuTable.id].toString()
}
suspend fun update(id: Uuid, request: UpdateMenuRequest) = dbQuery {
requireMenu(id)
validateMenuType(request.type)
val parentId = request.parentId?.let { parseUuid(it, "parentId") }
if (parentId == id) throw BizException(ErrorCode.BAD_REQUEST.code, "上级菜单不能选择自身")
if (parentId != null) requireMenu(parentId)
SysMenuTable.update({ SysMenuTable.id eq id }) {
it[SysMenuTable.parentId] = parentId
it[SysMenuTable.type] = request.type
it[title] = request.title.trim()
it[name] = request.name?.trim()
it[path] = request.path?.trim()
it[component] = request.component?.trim()
it[icon] = request.icon?.trim()
it[permission] = request.permission?.trim()
it[sort] = request.sort
it[visible] = request.visible
it[keepAlive] = request.keepAlive
it[status] = request.status
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun delete(id: Uuid) = dbQuery {
requireMenu(id)
val hasChildren = SysMenuTable.selectAll().where {
(SysMenuTable.parentId eq id) and SysMenuTable.deletedAt.isNull()
}.any()
if (hasChildren) throw BizException(ErrorCode.BAD_REQUEST.code, "存在子菜单,不能删除")
val referenced = SysRoleMenuTable.selectAll().where { SysRoleMenuTable.menuId eq id }.any()
if (referenced) throw BizException(ErrorCode.BAD_REQUEST.code, "菜单已被角色引用,不能删除")
val row = requireMenu(id)
if (row[builtIn]) throw BizException(ErrorCode.BAD_REQUEST.code, "基础框架内置菜单不可删除")
SysMenuTable.update({ SysMenuTable.id eq id }) {
it[deletedAt] = OffsetDateTime.now()
}
}
private fun requireMenu(id: Uuid): ResultRow =
SysMenuTable.selectAll().where { (SysMenuTable.id eq id) and SysMenuTable.deletedAt.isNull() }.singleOrNull()
?: throw BizException(
ErrorCode.MENU_NOT_FOUND.code,
ErrorCode.MENU_NOT_FOUND.message,
HttpStatusCode.NotFound
)
private fun validateMenuType(type: String) {
if (type !in setOf("CATALOG", "MENU", "BUTTON")) {
throw BizException(ErrorCode.BAD_REQUEST.code, "菜单类型必须是目录、菜单或按钮")
}
}
private fun buildTree(items: List<MenuFlat>): List<MenuTreeNode> {
val grouped = items.groupBy { it.parentId }
fun children(parentId: Uuid?): List<MenuTreeNode> =
(grouped[parentId] ?: emptyList()).sortedBy { it.sort }.map { menu ->
MenuTreeNode(
id = menu.id.toString(),
parentId = menu.parentId?.toString(),
type = menu.type,
typeLabel = menuTypeLabel(menu.type),
title = menu.title,
name = menu.name,
path = menu.path,
component = menu.component,
icon = menu.icon,
permission = menu.permission,
sort = menu.sort,
visible = menu.visible,
keepAlive = menu.keepAlive,
builtIn = menu.builtIn,
status = menu.status,
statusLabel = statusLabel(menu.status),
children = children(menu.id),
)
}
return children(null)
}
}
@@ -1,17 +1,18 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.modules.logs
package com.bbit.ticket.service.system
import com.bbit.ticket.common.traceIdOrNull
import com.bbit.ticket.database.system.SysOperationLogTable
import com.bbit.ticket.utils.traceIdOrNull
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.security.CurrentUser
import com.bbit.ticket.utils.CurrentUser
import io.ktor.http.formUrlEncode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.httpMethod
import io.ktor.server.request.path
import org.jetbrains.exposed.v1.jdbc.insert
import java.time.OffsetDateTime
import kotlin.uuid.ExperimentalUuidApi
object OperationLogService {
suspend fun success(call: ApplicationCall, currentUser: CurrentUser?, operationType: String, operationName: String, costMs: Long) {
@@ -1,28 +1,18 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.modules.system.org
package com.bbit.ticket.service.system
import com.bbit.ticket.common.BizException
import com.bbit.ticket.common.ErrorCode
import com.bbit.ticket.common.ok
import com.bbit.ticket.common.parseUuid
import com.bbit.ticket.common.statusLabel
import com.bbit.ticket.database.system.SysOrgTable
import com.bbit.ticket.database.system.SysUserTable
import com.bbit.ticket.modules.logs.OperationLogService
import com.bbit.ticket.entity.system.CreateOrgRequest
import com.bbit.ticket.entity.system.OrgTreeNode
import com.bbit.ticket.entity.system.UpdateOrgRequest
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.entity.common.statusLabel
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.security.requirePermission
import com.bbit.ticket.utils.parseUuid
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
@@ -31,38 +21,9 @@ import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Serializable
data class OrgTreeNode(
val id: String,
val parentId: String? = null,
val name: String,
val code: String,
val sort: Int,
val status: String,
val statusLabel: String,
val children: List<OrgTreeNode> = emptyList(),
)
@Serializable
data class CreateOrgRequest(
val parentId: String? = null,
val name: String,
val code: String,
val sort: Int = 0,
val status: String = "ENABLED",
)
@Serializable
data class UpdateOrgRequest(
val parentId: String? = null,
val name: String,
val sort: Int = 0,
val status: String = "ENABLED",
)
object OrgService {
suspend fun tree(): List<OrgTreeNode> = dbQuery {
val rows = SysOrgTable.selectAll()
@@ -137,7 +98,11 @@ object OrgService {
private fun requireOrg(id: Uuid): ResultRow =
SysOrgTable.selectAll().where { (SysOrgTable.id eq id) and SysOrgTable.deletedAt.isNull() }.singleOrNull()
?: throw BizException(ErrorCode.ORG_NOT_FOUND.code, ErrorCode.ORG_NOT_FOUND.message, HttpStatusCode.NotFound)
?: throw BizException(
ErrorCode.ORG_NOT_FOUND.code,
ErrorCode.ORG_NOT_FOUND.message,
HttpStatusCode.NotFound
)
private fun toNode(row: ResultRow): OrgNodeFlat = OrgNodeFlat(
id = row[SysOrgTable.id],
@@ -165,64 +130,12 @@ object OrgService {
}
return children(null)
}
}
private data class OrgNodeFlat(
val id: Uuid,
val parentId: Uuid?,
val name: String,
val code: String,
val sort: Int,
val status: String,
)
fun Route.registerOrgRoutes() {
authenticate("auth-jwt") {
route("/api/system/orgs") {
get {
call.requirePermission("system:org:view")
call.respond(ok(OrgService.tree()))
}
post {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:org:create")
val request = call.receive<CreateOrgRequest>()
runCatching {
val id = OrgService.create(request)
call.respond(ok(mapOf("id" to id)))
OperationLogService.success(call, currentUser, "CREATE", "新增组织", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "CREATE", "新增组织", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
put("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:org:update")
val id = parseUuid(call.parameters["id"] ?: "", "id")
val request = call.receive<UpdateOrgRequest>()
runCatching {
OrgService.update(id, request)
call.respond(ok<Unit>(message = "更新成功"))
OperationLogService.success(call, currentUser, "UPDATE", "更新组织", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "UPDATE", "更新组织", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
delete("/{id}") {
val start = TimeSource.Monotonic.markNow()
val currentUser = call.requirePermission("system:org:delete")
val id = parseUuid(call.parameters["id"] ?: "", "id")
runCatching {
OrgService.delete(id)
call.respond(ok<Unit>(message = "删除成功"))
OperationLogService.success(call, currentUser, "DELETE", "删除组织", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, currentUser, "DELETE", "删除组织", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
}
}
private data class OrgNodeFlat(
val id: Uuid,
val parentId: Uuid?,
val name: String,
val code: String,
val sort: Int,
val status: String,
)
}
@@ -1,4 +1,4 @@
package com.bbit.ticket.security
package com.bbit.ticket.service.system
import org.mindrot.jbcrypt.BCrypt
@@ -7,4 +7,3 @@ object PasswordService {
fun matches(rawPassword: String, passwordHash: String): Boolean = BCrypt.checkpw(rawPassword, passwordHash)
}
@@ -0,0 +1,165 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.service.system
import com.bbit.ticket.database.system.SysMenuTable
import com.bbit.ticket.database.system.SysRoleMenuTable
import com.bbit.ticket.database.system.SysRoleTable
import com.bbit.ticket.database.system.SysRoleTable.dataScope
import com.bbit.ticket.database.system.SysUserRoleTable
import com.bbit.ticket.entity.system.CreateRoleRequest
import com.bbit.ticket.entity.system.RoleDetail
import com.bbit.ticket.entity.system.RoleItem
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.entity.common.PageResult
import com.bbit.ticket.entity.common.dataScopeLabel
import com.bbit.ticket.entity.common.statusLabel
import com.bbit.ticket.entity.system.UpdateRoleMenusRequest
import com.bbit.ticket.entity.system.UpdateRoleRequest
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.utils.parseUuid
import io.ktor.http.HttpStatusCode
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
object RoleService {
@OptIn(ExperimentalUuidApi::class)
suspend fun list(page: Int, pageSize: Int, keyword: String?, status: String?): PageResult<RoleItem> = dbQuery {
var where = SysRoleTable.deletedAt.isNull()
if (!keyword.isNullOrBlank()) {
where = where and ((SysRoleTable.name like "%$keyword%") or (SysRoleTable.code like "%$keyword%"))
}
if (!status.isNullOrBlank()) {
where = where and (SysRoleTable.status eq status)
}
val total = SysRoleTable.selectAll().where { where }.count()
val rows = SysRoleTable.selectAll().where { where }
.orderBy(SysRoleTable.createdAt)
.limit(pageSize)
.offset(((page - 1) * pageSize).toLong())
.toList()
PageResult(
items = rows.map {
RoleItem(
id = it[SysRoleTable.id].toString(),
name = it[SysRoleTable.name],
code = it[SysRoleTable.code],
description = it[SysRoleTable.description],
status = it[SysRoleTable.status],
statusLabel = statusLabel(it[SysRoleTable.status]),
dataScope = it[dataScope],
dataScopeLabel = dataScopeLabel(it[dataScope]),
)
},
page = page,
pageSize = pageSize,
total = total,
)
}
suspend fun create(request: CreateRoleRequest): String = dbQuery {
if (request.name.trim().isBlank() || request.code.trim().isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "角色名称和编码不能为空")
}
val exists = SysRoleTable.selectAll().where {
(SysRoleTable.code eq request.code.trim()) and SysRoleTable.deletedAt.isNull()
}.any()
if (exists) throw BizException(ErrorCode.DATA_CONFLICT.code, "角色编码已存在")
val inserted = SysRoleTable.insert {
it[name] = request.name.trim()
it[code] = request.code.trim()
it[description] = request.description?.trim()
it[status] = request.status
it[dataScope] = request.dataScope
it[createdAt] = OffsetDateTime.now()
}
inserted[SysRoleTable.id].toString()
}
suspend fun detail(id: Uuid): RoleDetail = dbQuery {
val role = requireRole(id)
val menuIds = SysRoleMenuTable.selectAll().where { SysRoleMenuTable.roleId eq id }
.map { it[SysRoleMenuTable.menuId].toString() }
RoleDetail(
id = role[SysRoleTable.id].toString(),
name = role[SysRoleTable.name],
code = role[SysRoleTable.code],
description = role[SysRoleTable.description],
status = role[SysRoleTable.status],
statusLabel = statusLabel(role[SysRoleTable.status]),
dataScope = role[dataScope],
dataScopeLabel = dataScopeLabel(role[dataScope]),
menuIds = menuIds,
)
}
suspend fun update(id: Uuid, request: UpdateRoleRequest) = dbQuery {
requireRole(id)
SysRoleTable.update({ SysRoleTable.id eq id }) {
it[name] = request.name.trim()
it[description] = request.description?.trim()
it[status] = request.status
it[dataScope] = request.dataScope
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun delete(id: Uuid) = dbQuery {
val role = requireRole(id)
if (role[SysRoleTable.code] == "SUPER_ADMIN") {
throw BizException(ErrorCode.BAD_REQUEST.code, "超级管理员角色不可删除")
}
val inUse = SysUserRoleTable.selectAll().where { SysUserRoleTable.roleId eq id }.any()
if (inUse) {
throw BizException(ErrorCode.BAD_REQUEST.code, "角色已被用户使用,不能删除")
}
SysRoleTable.update({ SysRoleTable.id eq id }) {
it[deletedAt] = OffsetDateTime.now()
}
SysRoleMenuTable.deleteWhere { SysRoleMenuTable.roleId eq id }
}
suspend fun updateMenus(id: Uuid, request: UpdateRoleMenusRequest) = dbQuery {
requireRole(id)
val menuIds = request.menuIds.distinct().map { parseUuid(it, "menuId") }
if (menuIds.isNotEmpty()) {
val validCount = SysMenuTable.selectAll().where {
(SysMenuTable.id inList menuIds) and
SysMenuTable.deletedAt.isNull() and
(SysMenuTable.status eq "ENABLED")
}.count()
if (validCount != menuIds.size.toLong()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "包含不存在或禁用菜单")
}
}
SysRoleMenuTable.deleteWhere { SysRoleMenuTable.roleId eq id }
menuIds.forEach { menuId ->
SysRoleMenuTable.insertIgnore {
it[roleId] = id
it[SysRoleMenuTable.menuId] = menuId
}
}
}
private fun requireRole(id: Uuid): ResultRow =
SysRoleTable.selectAll().where { (SysRoleTable.id eq id) and SysRoleTable.deletedAt.isNull() }.singleOrNull()
?: throw BizException(
ErrorCode.ROLE_NOT_FOUND.code,
ErrorCode.ROLE_NOT_FOUND.message,
HttpStatusCode.NotFound
)
}
@@ -0,0 +1,246 @@
package com.bbit.ticket.service.system
import com.bbit.ticket.database.system.SysOrgTable
import com.bbit.ticket.database.system.SysRoleTable
import com.bbit.ticket.database.system.SysUserRoleTable
import com.bbit.ticket.database.system.SysUserTable
import com.bbit.ticket.database.system.SysUserTable.avatar
import com.bbit.ticket.database.system.SysUserTable.email
import com.bbit.ticket.database.system.SysUserTable.nickname
import com.bbit.ticket.database.system.SysUserTable.phone
import com.bbit.ticket.database.system.SysUserTable.realName
import com.bbit.ticket.entity.system.CreateUserRequest
import com.bbit.ticket.entity.system.UpdateUserPasswordRequest
import com.bbit.ticket.entity.system.UpdateUserRequest
import com.bbit.ticket.entity.system.UpdateUserRolesRequest
import com.bbit.ticket.entity.system.UpdateUserStatusRequest
import com.bbit.ticket.entity.system.UserDetailResponse
import com.bbit.ticket.entity.system.UserListItem
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.entity.common.PageResult
import com.bbit.ticket.entity.common.statusLabel
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.utils.parseUuid
import io.ktor.http.HttpStatusCode
import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
object UserService {
suspend fun list(
page: Int,
pageSize: Int,
username: String?,
nickname: String?,
status: String?,
orgId: Uuid?,
): PageResult<UserListItem> = dbQuery {
val where = buildWhere(username, nickname, status, orgId)
val total = SysUserTable.selectAll().where { where }.count()
val rows = SysUserTable.selectAll()
.where { where }
.orderBy(SysUserTable.createdAt)
.limit(pageSize)
.offset(((page - 1) * pageSize).toLong())
.toList()
val userIds = rows.map { it[SysUserTable.id] }
val roleMap = if (userIds.isEmpty()) {
emptyMap()
} else {
(SysUserRoleTable innerJoin SysRoleTable).selectAll()
.where {
(SysUserRoleTable.userId inList userIds) and
SysRoleTable.deletedAt.isNull()
}
.groupBy { it[SysUserRoleTable.userId] }
.mapValues { entry -> entry.value.map { row -> row[SysRoleTable.code] }.distinct() }
}
PageResult(
items = rows.map { row ->
UserListItem(
id = row[SysUserTable.id].toString(),
username = row[SysUserTable.username],
nickname = row[SysUserTable.nickname],
realName = row[realName],
orgId = row[SysUserTable.orgId]?.toString(),
status = row[SysUserTable.status],
statusLabel = statusLabel(row[SysUserTable.status]),
roleCodes = roleMap[row[SysUserTable.id]] ?: emptyList(),
)
},
page = page,
pageSize = pageSize,
total = total,
)
}
suspend fun create(request: CreateUserRequest): String = dbQuery {
val username = request.username.trim()
if (username.isBlank() || request.password.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "用户名和密码不能为空")
}
val existed = SysUserTable.selectAll().where {
(SysUserTable.username eq username) and SysUserTable.deletedAt.isNull()
}.any()
if (existed) {
throw BizException(ErrorCode.DATA_CONFLICT.code, "用户名已存在")
}
val orgUuid = request.orgId?.let { parseUuid(it, "orgId") }
if (orgUuid != null) {
ensureOrgExists(orgUuid)
}
val now = OffsetDateTime.now()
val row = SysUserTable.insert {
it[SysUserTable.username] = username
it[passwordHash] = PasswordService.hash(request.password)
it[nickname] = request.nickname?.trim()
it[realName] = request.realName?.trim()
it[phone] = request.phone?.trim()
it[email] = request.email?.trim()
it[avatar] = request.avatar?.trim()
it[orgId] = orgUuid
it[status] = request.status
it[tokenVersion] = 1
it[createdAt] = now
}
row[SysUserTable.id].toString()
}
suspend fun detail(id: Uuid): UserDetailResponse = dbQuery {
val user = requireUser(id)
val roleIds = SysUserRoleTable.selectAll().where { SysUserRoleTable.userId eq id }
.map { it[SysUserRoleTable.roleId].toString() }
UserDetailResponse(
id = user[SysUserTable.id].toString(),
username = user[SysUserTable.username],
nickname = user[nickname],
realName = user[realName],
phone = user[phone],
email = user[email],
avatar = user[avatar],
orgId = user[SysUserTable.orgId]?.toString(),
status = user[SysUserTable.status],
statusLabel = statusLabel(user[SysUserTable.status]),
roleIds = roleIds,
)
}
suspend fun update(id: Uuid, request: UpdateUserRequest) = dbQuery {
requireUser(id)
val orgUuid = request.orgId?.let { parseUuid(it, "orgId") }
if (orgUuid != null) {
ensureOrgExists(orgUuid)
}
SysUserTable.update({ SysUserTable.id eq id }) {
it[nickname] = request.nickname?.trim()
it[realName] = request.realName?.trim()
it[phone] = request.phone?.trim()
it[email] = request.email?.trim()
it[avatar] = request.avatar?.trim()
it[orgId] = orgUuid
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun softDelete(id: Uuid) = dbQuery {
if (id.toString() == "00000000-0000-0000-0000-000000000000") {
throw BizException(ErrorCode.BAD_REQUEST.code, "系统保留用户不可删除")
}
requireUser(id)
SysUserTable.update({ SysUserTable.id eq id }) {
it[deletedAt] = OffsetDateTime.now()
}
SysUserRoleTable.deleteWhere { SysUserRoleTable.userId eq id }
}
suspend fun updateStatus(id: Uuid, request: UpdateUserStatusRequest) = dbQuery {
requireUser(id)
SysUserTable.update({ SysUserTable.id eq id }) {
it[status] = request.status
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun updatePassword(id: Uuid, request: UpdateUserPasswordRequest) = dbQuery {
val user = requireUser(id)
if (request.password.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "密码不能为空")
}
val nextTokenVersion = user[SysUserTable.tokenVersion] + 1
SysUserTable.update({ SysUserTable.id eq id }) {
it[passwordHash] = PasswordService.hash(request.password)
it[tokenVersion] = nextTokenVersion
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun updateRoles(id: Uuid, request: UpdateUserRolesRequest) = dbQuery {
requireUser(id)
val roleIds = request.roleIds.distinct().map { parseUuid(it, "roleId") }
if (roleIds.isNotEmpty()) {
val validCount = SysRoleTable.selectAll().where {
(SysRoleTable.id inList roleIds) and
(SysRoleTable.status eq "ENABLED") and
SysRoleTable.deletedAt.isNull()
}.count()
if (validCount != roleIds.size.toLong()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "包含不存在或已禁用角色")
}
}
SysUserRoleTable.deleteWhere { SysUserRoleTable.userId eq id }
roleIds.forEach { roleId ->
SysUserRoleTable.insertIgnore {
it[userId] = id
it[SysUserRoleTable.roleId] = roleId
}
}
}
private fun buildWhere(username: String?, nickname: String?, status: String?, orgId: Uuid?): Op<Boolean> {
var where: Op<Boolean> = SysUserTable.deletedAt.isNull()
if (!username.isNullOrBlank()) {
where = where and (SysUserTable.username like "%$username%")
}
if (!nickname.isNullOrBlank()) {
where = where and (SysUserTable.nickname like "%$nickname%")
}
if (!status.isNullOrBlank()) {
where = where and (SysUserTable.status eq status)
}
if (orgId != null) {
where = where and (SysUserTable.orgId eq orgId)
}
return where
}
private fun requireUser(id: Uuid) =
SysUserTable.selectAll().where { (SysUserTable.id eq id) and SysUserTable.deletedAt.isNull() }.singleOrNull()
?: throw BizException(
ErrorCode.USER_NOT_FOUND.code,
ErrorCode.USER_NOT_FOUND.message,
HttpStatusCode.NotFound
)
private fun ensureOrgExists(orgId: Uuid) {
val exists = SysOrgTable.selectAll().where { (SysOrgTable.id eq orgId) and SysOrgTable.deletedAt.isNull() }.any()
if (!exists) {
throw BizException(ErrorCode.ORG_NOT_FOUND.code, ErrorCode.ORG_NOT_FOUND.message, HttpStatusCode.BadRequest)
}
}
}
@@ -1,4 +1,4 @@
package com.bbit.ticket.common
package com.bbit.ticket.utils
import java.time.OffsetDateTime
import java.time.ZoneId
@@ -1,5 +1,7 @@
package com.bbit.ticket.common
package com.bbit.ticket.utils
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import kotlin.uuid.ExperimentalUuidApi
@@ -1,7 +1,7 @@
package com.bbit.ticket.security
package com.bbit.ticket.utils
import com.bbit.ticket.common.BizException
import com.bbit.ticket.common.ErrorCode
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
@@ -1,9 +1,9 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.security
package com.bbit.ticket.utils
import com.bbit.ticket.common.BizException
import com.bbit.ticket.common.ErrorCode
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.database.system.SysMenuTable
import com.bbit.ticket.database.system.SysRoleMenuTable
import com.bbit.ticket.database.system.SysRoleTable
@@ -21,6 +21,7 @@ import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.isNotNull
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.jdbc.selectAll
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
data class CurrentUser(
@@ -1,4 +1,4 @@
package com.bbit.ticket.common
package com.bbit.ticket.utils
import io.ktor.server.application.ApplicationCall
import io.ktor.util.AttributeKey
+1 -3
View File
@@ -55,9 +55,7 @@ http.interceptors.response.use(
}
if (status === 403) {
if (router.currentRoute.value.path !== '/403') {
await router.replace('/403')
}
console.warn('[HTTP] 收到 403 响应, URL:', error.config?.url, 'Message:', backendMessage)
message.error(backendMessage ?? '无权限访问该资源')
return Promise.reject(new BizError('403', backendMessage ?? '无权限', traceId))
}
+1 -1
View File
@@ -37,7 +37,7 @@ function toOption(menu: MenuNode): MenuOption | null {
.map((item) => toOption(item))
.filter((item): item is MenuOption => Boolean(item))
const key = menu.path || `catalog-${menu.id}`
const key = (menu.path && menu.type !== 'CATALOG') ? menu.path : `catalog-${menu.id}`
return {
key,
label: menu.title,
+1 -1
View File
@@ -15,7 +15,7 @@ export default defineConfig(({ mode }) => {
},
server: {
host: '0.0.0.0',
port: 5173,
port: 5180,
open: false,
proxy: proxyTarget
? {