初始化项目

This commit is contained in:
BBIT-Kai
2026-04-30 10:47:26 +08:00
commit c932419c73
147 changed files with 45298 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
+48
View File
@@ -0,0 +1,48 @@
plugins {
kotlin("jvm") version "2.3.20"
id("io.ktor.plugin") version "3.4.1"
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.20"
}
group = "com.bbit.platform"
version = "0.0.1"
application {
mainClass = "io.ktor.server.netty.EngineMain"
}
kotlin {
jvmToolchain(21)
}
dependencies {
val kotlinVersion = "2.3.20"
implementation("io.ktor:ktor-server-core")
implementation("io.ktor:ktor-serialization-kotlinx-json")
implementation("io.ktor:ktor-server-content-negotiation")
implementation("io.ktor:ktor-server-cors")
implementation("io.ktor:ktor-server-host-common")
implementation("io.ktor:ktor-server-status-pages")
implementation("io.ktor:ktor-server-auth")
implementation("io.ktor:ktor-server-auth-jwt")
implementation("io.ktor:ktor-server-netty")
implementation("io.ktor:ktor-server-call-logging")
implementation("ch.qos.logback:logback-classic:1.5.13")
implementation("io.ktor:ktor-server-config-yaml")
testImplementation("io.ktor:ktor-server-test-host")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion")
// 数据库
val exposedVersion = "1.1.1"
implementation("org.postgresql:postgresql:42.7.10")
implementation("org.jetbrains.exposed:exposed-core:${exposedVersion}")
implementation("org.jetbrains.exposed:exposed-jdbc:${exposedVersion}")
implementation("org.jetbrains.exposed:exposed-java-time:${exposedVersion}")
implementation("org.jetbrains.exposed:exposed-migration-jdbc:$exposedVersion")
implementation("com.zaxxer:HikariCP:7.0.2")
implementation("org.mindrot:jbcrypt:0.4")
// Redis
implementation("org.redisson:redisson:3.38.1")
}
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.3.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+7
View File
@@ -0,0 +1,7 @@
rootProject.name = "platform-a-server"
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
@@ -0,0 +1,63 @@
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.plugins.configureCors
import com.bbit.ticket.plugins.configureDatabase
import com.bbit.ticket.plugins.configureLogging
import com.bbit.ticket.plugins.configureApiAccessLog
import com.bbit.ticket.plugins.configureRedis
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 kotlinx.coroutines.runBlocking
import io.ktor.server.application.Application
import io.ktor.server.netty.EngineMain
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
fun main(args: Array<String>) {
EngineMain.main(args)
}
fun Application.module() {
AppConfig.init(environment)
configureTrace()
configureSerialization()
configureStatusPages()
configureLogging()
configureApiAccessLog()
configureCors()
configureSecurity()
configureDatabase()
configureRedis()
runBlocking {
DatabaseInitializer.initialize()
SeedData.seed()
}
routing {
get("/health") {
call.respond(ok(mapOf("status" to "UP", "service" to AppConfig.app.name)))
}
registerAuthRoutes()
registerUserRoutes()
registerOrgRoutes()
registerRoleRoutes()
registerMenuRoutes()
registerDictRoutes()
registerLogsQueryRoutes()
}
}
@@ -0,0 +1,53 @@
package com.bbit.ticket.bootstrap
import com.bbit.ticket.database.system.SysApiAccessLogTable
import com.bbit.ticket.database.system.SysDictItemTable
import com.bbit.ticket.database.system.SysDictTypeTable
import com.bbit.ticket.database.system.SysMenuTable
import com.bbit.ticket.database.system.SysOperationLogTable
import com.bbit.ticket.database.system.SysOrgTable
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.plugins.dbQuery
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
import org.slf4j.LoggerFactory
object DatabaseInitializer {
private val logger = LoggerFactory.getLogger(DatabaseInitializer::class.java)
suspend fun initialize() {
val tables = arrayOf(
SysOrgTable,
SysUserTable,
SysRoleTable,
SysMenuTable,
SysUserRoleTable,
SysRoleMenuTable,
SysDictTypeTable,
SysDictItemTable,
SysOperationLogTable,
SysApiAccessLogTable,
)
// 先通过 Exposed 生成迁移 SQL,再逐条执行,避免启动时静默跳过缺失表或字段。
dbQuery {
MigrationUtils.statementsRequiredForDatabaseMigration(*tables, withLogs = true)
}
transaction {
val statements = MigrationUtils.statementsRequiredForDatabaseMigration(
*tables,
withLogs = false
)
if (statements.isNotEmpty()) {
logger.info("Migrating database schema, statement count={}", statements.size)
statements.forEach {
logger.debug("Executing migration SQL: {};", it)
exec(it)
}
}
}
logger.info("Database schema initialized")
}
}
@@ -0,0 +1,357 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package com.bbit.ticket.bootstrap
import com.bbit.ticket.database.system.SysDictItemTable
import com.bbit.ticket.database.system.SysDictTypeTable
import com.bbit.ticket.database.system.SysMenuTable
import com.bbit.ticket.database.system.SysOrgTable
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.plugins.dbQuery
import com.bbit.ticket.security.PasswordService
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.statements.UpdateBuilder
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.slf4j.LoggerFactory
import java.time.OffsetDateTime
import kotlin.uuid.Uuid
object SeedData {
private val logger = LoggerFactory.getLogger(SeedData::class.java)
const val ADMIN_USERNAME = "admin"
const val ADMIN_INIT_PASSWORD = "Admin@123456"
private const val DEFAULT_ORG_CODE = "DEFAULT_ORG"
private const val SUPER_ADMIN_ROLE_CODE = "SUPER_ADMIN"
suspend fun seed() {
val now = OffsetDateTime.now()
val orgId = upsertDefaultOrg(now)
val roleId = upsertSuperAdminRole(now)
val adminId = upsertAdminUser(orgId, now)
upsertUserRole(adminId, roleId)
val menuIds = upsertMenus(now)
bindRoleMenus(roleId, menuIds)
seedDicts(now)
logger.info("Seed data initialized, default admin username: {}", ADMIN_USERNAME)
}
private suspend fun upsertDefaultOrg(now: OffsetDateTime): Uuid = dbQuery {
val existing = SysOrgTable.selectAll()
.where { (SysOrgTable.code eq DEFAULT_ORG_CODE) and SysOrgTable.deletedAt.isNull() }
.singleOrNull()
if (existing != null) {
val id = existing[SysOrgTable.id]
SysOrgTable.update({ SysOrgTable.id eq id }) {
it[name] = "默认组织"
it[sort] = 0
it[status] = "ENABLED"
it[updatedAt] = now
}
return@dbQuery id
}
val inserted = SysOrgTable.insert {
it[parentId] = null
it[name] = "默认组织"
it[code] = DEFAULT_ORG_CODE
it[sort] = 0
it[status] = "ENABLED"
it[createdAt] = now
}
inserted[SysOrgTable.id]
}
private suspend fun upsertSuperAdminRole(now: OffsetDateTime): Uuid = dbQuery {
val existing = SysRoleTable.selectAll()
.where { (SysRoleTable.code eq SUPER_ADMIN_ROLE_CODE) and SysRoleTable.deletedAt.isNull() }
.singleOrNull()
if (existing != null) {
val id = existing[SysRoleTable.id]
SysRoleTable.update({ SysRoleTable.id eq id }) {
it[name] = "超级管理员"
it[description] = "系统内置超级管理员角色"
it[status] = "ENABLED"
it[dataScope] = "ALL"
it[updatedAt] = now
}
return@dbQuery id
}
val inserted = SysRoleTable.insert {
it[name] = "超级管理员"
it[code] = SUPER_ADMIN_ROLE_CODE
it[description] = "系统内置超级管理员角色"
it[status] = "ENABLED"
it[dataScope] = "ALL"
it[createdAt] = now
}
inserted[SysRoleTable.id]
}
private suspend fun upsertAdminUser(orgId: Uuid, now: OffsetDateTime): Uuid = dbQuery {
val existing = SysUserTable.selectAll()
.where { (SysUserTable.username eq ADMIN_USERNAME) and SysUserTable.deletedAt.isNull() }
.singleOrNull()
if (existing != null) {
val id = existing[SysUserTable.id]
SysUserTable.update({ SysUserTable.id eq id }) {
it[nickname] = "管理员"
it[realName] = "系统管理员"
it[SysUserTable.orgId] = orgId
it[status] = "ENABLED"
it[updatedAt] = now
}
return@dbQuery id
}
val inserted = SysUserTable.insert {
it[username] = ADMIN_USERNAME
it[passwordHash] = PasswordService.hash(ADMIN_INIT_PASSWORD)
it[nickname] = "管理员"
it[realName] = "系统管理员"
it[SysUserTable.orgId] = orgId
it[status] = "ENABLED"
it[tokenVersion] = 1
it[createdAt] = now
}
inserted[SysUserTable.id]
}
private suspend fun upsertUserRole(userId: Uuid, roleId: Uuid) = dbQuery {
val exists = SysUserRoleTable.selectAll()
.where { (SysUserRoleTable.userId eq userId) and (SysUserRoleTable.roleId eq roleId) }
.any()
if (!exists) {
SysUserRoleTable.insert {
it[SysUserRoleTable.userId] = userId
it[SysUserRoleTable.roleId] = roleId
}
}
}
private suspend fun upsertMenus(now: OffsetDateTime): List<Uuid> {
val seedMenus = listOf(
SeedMenu("dashboard", null, "MENU", "工作台", "Dashboard", "/dashboard", "dashboard/index", "LayoutDashboard", null, 10, true, true),
SeedMenu("system", null, "CATALOG", "系统管理", "SystemRoot", "/system", null, "Settings", null, 20, true, false),
SeedMenu("system_user", "system", "MENU", "用户管理", "SystemUsers", "/system/users", "system/users/index", "Users", "system:user:view", 10, true, true),
SeedMenu("system_user_create", "system_user", "BUTTON", "新增用户", "SystemUserCreate", null, null, null, "system:user:create", 1, true, false),
SeedMenu("system_user_update", "system_user", "BUTTON", "修改用户", "SystemUserUpdate", null, null, null, "system:user:update", 2, true, false),
SeedMenu("system_user_delete", "system_user", "BUTTON", "删除用户", "SystemUserDelete", null, null, null, "system:user:delete", 3, true, false),
SeedMenu("system_org", "system", "MENU", "组织管理", "SystemOrgs", "/system/orgs", "system/orgs/index", "Building2", "system:org:view", 20, true, true),
SeedMenu("system_org_create", "system_org", "BUTTON", "新增组织", "SystemOrgCreate", null, null, null, "system:org:create", 1, true, false),
SeedMenu("system_org_update", "system_org", "BUTTON", "更新组织", "SystemOrgUpdate", null, null, null, "system:org:update", 2, true, false),
SeedMenu("system_org_delete", "system_org", "BUTTON", "删除组织", "SystemOrgDelete", null, null, null, "system:org:delete", 3, true, false),
SeedMenu("system_role", "system", "MENU", "角色管理", "SystemRoles", "/system/roles", "system/roles/index", "Shield", "system:role:view", 30, true, true),
SeedMenu("system_role_create", "system_role", "BUTTON", "新增角色", "SystemRoleCreate", null, null, null, "system:role:create", 1, true, false),
SeedMenu("system_role_update", "system_role", "BUTTON", "更新角色", "SystemRoleUpdate", null, null, null, "system:role:update", 2, true, false),
SeedMenu("system_role_delete", "system_role", "BUTTON", "删除角色", "SystemRoleDelete", null, null, null, "system:role:delete", 3, true, false),
SeedMenu("system_role_assign", "system_role", "BUTTON", "分配角色权限", "SystemRoleAssign", null, null, null, "system:role:assign", 4, true, false),
SeedMenu("system_menu", "system", "MENU", "菜单管理", "SystemMenus", "/system/menus", "system/menus/index", "PanelLeft", "system:menu:view", 40, true, true),
SeedMenu("system_menu_create", "system_menu", "BUTTON", "新增菜单", "SystemMenuCreate", null, null, null, "system:menu:create", 1, true, false),
SeedMenu("system_menu_update", "system_menu", "BUTTON", "更新菜单", "SystemMenuUpdate", null, null, null, "system:menu:update", 2, true, false),
SeedMenu("system_menu_delete", "system_menu", "BUTTON", "删除菜单", "SystemMenuDelete", null, null, null, "system:menu:delete", 3, true, false),
SeedMenu("system_dict", "system", "MENU", "字典管理", "SystemDict", "/system/dicts", "system/dicts/index", "BookType", "system:dict:view", 50, true, true),
SeedMenu("system_dict_create", "system_dict", "BUTTON", "新增字典", "SystemDictCreate", null, null, null, "system:dict:create", 1, true, false),
SeedMenu("system_dict_update", "system_dict", "BUTTON", "更新字典", "SystemDictUpdate", null, null, null, "system:dict:update", 2, true, false),
SeedMenu("system_dict_delete", "system_dict", "BUTTON", "删除字典", "SystemDictDelete", null, null, null, "system:dict:delete", 3, true, false),
SeedMenu("logs", null, "CATALOG", "日志管理", "LogsRoot", "/logs", null, "Logs", null, 30, true, false),
SeedMenu("logs_operation", "logs", "MENU", "操作日志", "LogsOperation", "/logs/operation", "logs/operation/index", "ScrollText", "log:operation:view", 10, true, true),
SeedMenu("logs_api_access", "logs", "MENU", "接口日志", "LogsApiAccess", "/logs/api-access", "logs/api-access/index", "Waypoints", "log:api-access:view", 20, true, true),
)
val idMap = mutableMapOf<String, Uuid>()
for (menu in seedMenus) {
val parentId = menu.parentKey?.let { idMap[it] }
val menuId = upsertMenu(menu, parentId, now)
idMap[menu.key] = menuId
}
return idMap.values.toList()
}
private suspend fun upsertMenu(seedMenu: SeedMenu, parentId: Uuid?, now: OffsetDateTime): Uuid = dbQuery {
val existing = SysMenuTable.selectAll()
.where { (SysMenuTable.name eq seedMenu.name) and SysMenuTable.deletedAt.isNull() }
.singleOrNull()
if (existing != null) {
val id = existing[SysMenuTable.id]
SysMenuTable.update({ SysMenuTable.id eq id }) {
fillMenuColumns(it, seedMenu, parentId, now, isCreate = false)
}
return@dbQuery id
}
val inserted = SysMenuTable.insert {
fillMenuColumns(it, seedMenu, parentId, now, isCreate = true)
}
inserted[SysMenuTable.id]
}
private fun fillMenuColumns(
statement: UpdateBuilder<*>,
seedMenu: SeedMenu,
parentId: Uuid?,
now: OffsetDateTime,
isCreate: Boolean,
) {
statement[SysMenuTable.parentId] = parentId
statement[SysMenuTable.type] = seedMenu.type
statement[SysMenuTable.title] = seedMenu.title
statement[SysMenuTable.name] = seedMenu.name
statement[SysMenuTable.path] = seedMenu.path
statement[SysMenuTable.component] = seedMenu.component
statement[SysMenuTable.icon] = seedMenu.icon
statement[SysMenuTable.permission] = seedMenu.permission
statement[SysMenuTable.sort] = seedMenu.sort
statement[SysMenuTable.visible] = seedMenu.visible
statement[SysMenuTable.keepAlive] = seedMenu.keepAlive
statement[SysMenuTable.builtIn] = seedMenu.builtIn
statement[SysMenuTable.status] = "ENABLED"
if (isCreate) {
statement[SysMenuTable.createdAt] = now
} else {
statement[SysMenuTable.updatedAt] = now
}
}
private suspend fun bindRoleMenus(roleId: Uuid, menuIds: List<Uuid>) = dbQuery {
if (menuIds.isEmpty()) {
return@dbQuery
}
val existing = SysRoleMenuTable.selectAll()
.where { SysRoleMenuTable.roleId eq roleId }
.map { it[SysRoleMenuTable.menuId] }
.toSet()
val toAdd = menuIds.filter { !existing.contains(it) }
toAdd.forEach { menuId ->
SysRoleMenuTable.insert {
it[SysRoleMenuTable.roleId] = roleId
it[SysRoleMenuTable.menuId] = menuId
}
}
val toRemove = existing.filter { !menuIds.contains(it) }
if (toRemove.isNotEmpty()) {
SysRoleMenuTable.deleteWhere { (SysRoleMenuTable.roleId eq roleId) and (SysRoleMenuTable.menuId inList toRemove) }
}
}
private suspend fun seedDicts(now: OffsetDateTime) {
val userStatusTypeId = upsertDictType("user_status", "用户状态", now)
upsertDictItem(userStatusTypeId, "启用", "ENABLED", "green", 1, now)
upsertDictItem(userStatusTypeId, "禁用", "DISABLED", "red", 2, now)
val orgStatusTypeId = upsertDictType("org_status", "组织状态", now)
upsertDictItem(orgStatusTypeId, "启用", "ENABLED", "green", 1, now)
upsertDictItem(orgStatusTypeId, "禁用", "DISABLED", "red", 2, now)
val roleStatusTypeId = upsertDictType("role_status", "角色状态", now)
upsertDictItem(roleStatusTypeId, "启用", "ENABLED", "green", 1, now)
upsertDictItem(roleStatusTypeId, "禁用", "DISABLED", "red", 2, now)
val menuTypeId = upsertDictType("menu_type", "菜单类型", now)
upsertDictItem(menuTypeId, "目录", "CATALOG", "default", 1, now)
upsertDictItem(menuTypeId, "菜单", "MENU", "blue", 2, now)
upsertDictItem(menuTypeId, "按钮", "BUTTON", "orange", 3, now)
val logStatusTypeId = upsertDictType("log_status", "日志状态", now)
upsertDictItem(logStatusTypeId, "成功", "SUCCESS", "green", 1, now)
upsertDictItem(logStatusTypeId, "失败", "FAIL", "red", 2, now)
}
private suspend fun upsertDictType(code: String, name: String, now: OffsetDateTime): Uuid = dbQuery {
val existing = SysDictTypeTable.selectAll()
.where { (SysDictTypeTable.code eq code) and SysDictTypeTable.deletedAt.isNull() }
.singleOrNull()
if (existing != null) {
val id = existing[SysDictTypeTable.id]
SysDictTypeTable.update({ SysDictTypeTable.id eq id }) {
it[SysDictTypeTable.name] = name
it[status] = "ENABLED"
it[updatedAt] = now
}
return@dbQuery id
}
val inserted = SysDictTypeTable.insert {
it[SysDictTypeTable.code] = code
it[SysDictTypeTable.name] = name
it[status] = "ENABLED"
it[createdAt] = now
}
inserted[SysDictTypeTable.id]
}
private suspend fun upsertDictItem(
typeId: Uuid,
label: String,
value: String,
color: String?,
sort: Int,
now: OffsetDateTime,
) = dbQuery {
val existing = SysDictItemTable.selectAll()
.where {
(SysDictItemTable.typeId eq typeId) and
(SysDictItemTable.value eq value) and
SysDictItemTable.deletedAt.isNull()
}
.singleOrNull()
if (existing != null) {
val id = existing[SysDictItemTable.id]
SysDictItemTable.update({ SysDictItemTable.id eq id }) {
it[SysDictItemTable.label] = label
it[SysDictItemTable.color] = color
it[SysDictItemTable.sort] = sort
it[status] = "ENABLED"
it[updatedAt] = now
}
return@dbQuery
}
SysDictItemTable.insert {
it[SysDictItemTable.typeId] = typeId
it[SysDictItemTable.label] = label
it[SysDictItemTable.value] = value
it[SysDictItemTable.color] = color
it[SysDictItemTable.sort] = sort
it[status] = "ENABLED"
it[createdAt] = now
}
}
}
private data class SeedMenu(
val key: String,
val parentKey: String?,
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 = true,
)
@@ -0,0 +1,17 @@
package com.bbit.ticket.common
import kotlinx.serialization.Serializable
@Serializable
data class ApiResult<T>(
val code: String,
val message: String,
val data: T? = null,
val traceId: String? = null,
)
fun <T> ok(data: T? = null, message: String = "成功"): ApiResult<T> =
ApiResult(code = "0", message = message, data = data)
fun fail(code: String, message: String, traceId: String? = null): ApiResult<Nothing> =
ApiResult(code = code, message = message, traceId = traceId)
@@ -0,0 +1,9 @@
package com.bbit.ticket.common
import io.ktor.http.HttpStatusCode
class BizException(
val errorCode: String,
override val message: String,
val status: HttpStatusCode = HttpStatusCode.BadRequest,
) : RuntimeException(message)
@@ -0,0 +1,13 @@
package com.bbit.ticket.common
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val defaultZone: ZoneId = ZoneId.systemDefault()
private val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
fun formatDateTime(value: OffsetDateTime?): String? {
if (value == null) return null
return value.atZoneSameInstant(defaultZone).format(dateTimeFormatter)
}
@@ -0,0 +1,24 @@
package com.bbit.ticket.common
fun statusLabel(status: String): String = when (status) {
"ENABLED" -> "启用"
"DISABLED" -> "禁用"
"SUCCESS" -> "成功"
"FAIL" -> "失败"
else -> status
}
fun menuTypeLabel(type: String): String = when (type) {
"CATALOG" -> "目录"
"MENU" -> "菜单"
"BUTTON" -> "按钮"
else -> type
}
fun dataScopeLabel(scope: String): String = when (scope) {
"ALL" -> "全部数据"
"DEPT" -> "本组织及下级"
"DEPT_ONLY" -> "本组织"
"SELF" -> "仅本人"
else -> scope
}
@@ -0,0 +1,18 @@
package com.bbit.ticket.common
enum class ErrorCode(val code: String, val message: String) {
BAD_REQUEST("COMMON.BAD_REQUEST", "请求参数错误"),
DATA_CONFLICT("COMMON.DATA_CONFLICT", "数据冲突"),
UNAUTHORIZED("AUTH.UNAUTHORIZED", "未登录或登录已失效"),
FORBIDDEN("AUTH.FORBIDDEN", "无权限访问"),
USERNAME_OR_PASSWORD_INVALID("AUTH.USERNAME_OR_PASSWORD_INVALID", "用户名或密码错误"),
USER_DISABLED("AUTH.USER_DISABLED", "用户已禁用"),
USER_NOT_FOUND("SYSTEM.USER_NOT_FOUND", "用户不存在"),
ORG_NOT_FOUND("SYSTEM.ORG_NOT_FOUND", "组织不存在"),
ROLE_NOT_FOUND("SYSTEM.ROLE_NOT_FOUND", "角色不存在"),
MENU_NOT_FOUND("SYSTEM.MENU_NOT_FOUND", "菜单不存在"),
DICT_TYPE_NOT_FOUND("SYSTEM.DICT_TYPE_NOT_FOUND", "字典类型不存在"),
DICT_ITEM_NOT_FOUND("SYSTEM.DICT_ITEM_NOT_FOUND", "字典项不存在"),
TOKEN_VERSION_INVALID("AUTH.TOKEN_VERSION_INVALID", "登录状态已失效,请重新登录"),
INTERNAL_SERVER_ERROR("COMMON.INTERNAL_SERVER_ERROR", "服务器内部错误"),
}
@@ -0,0 +1,18 @@
package com.bbit.ticket.common
import kotlinx.serialization.Serializable
@Serializable
data class PageQuery(
val page: Int = 1,
val pageSize: Int = 20,
) {
init {
require(page >= 1) { "page 必须大于等于 1" }
require(pageSize in 1..200) { "pageSize 必须在 1 到 200 之间" }
}
val offset: Long
get() = ((page - 1) * pageSize).toLong()
}
@@ -0,0 +1,12 @@
package com.bbit.ticket.common
import kotlinx.serialization.Serializable
@Serializable
data class PageResult<T>(
val items: List<T>,
val page: Int,
val pageSize: Int,
val total: Long,
)
@@ -0,0 +1,30 @@
package com.bbit.ticket.common
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
fun ApplicationCall.queryString(name: String): String? = request.queryParameters[name]?.trim()?.takeIf { it.isNotEmpty() }
fun ApplicationCall.queryInt(name: String, default: Int): Int {
val raw = request.queryParameters[name] ?: return default
val value = raw.toIntOrNull() ?: throw BizException(
ErrorCode.BAD_REQUEST.code,
"$name 必须是整数",
HttpStatusCode.BadRequest,
)
if (name == "page" && value < 1) {
throw BizException(ErrorCode.BAD_REQUEST.code, "page 必须大于等于 1", HttpStatusCode.BadRequest)
}
if (name == "pageSize" && value !in 1..200) {
throw BizException(ErrorCode.BAD_REQUEST.code, "pageSize 必须在 1 到 200 之间", HttpStatusCode.BadRequest)
}
return value
}
@OptIn(ExperimentalUuidApi::class)
fun parseUuid(value: String, fieldName: String): Uuid =
runCatching { Uuid.parse(value) }.getOrElse {
throw BizException(ErrorCode.BAD_REQUEST.code, "$fieldName 格式非法", HttpStatusCode.BadRequest)
}
@@ -0,0 +1,10 @@
package com.bbit.ticket.common
import io.ktor.server.application.ApplicationCall
import io.ktor.util.AttributeKey
val TraceIdKey = AttributeKey<String>("traceId")
fun ApplicationCall.traceIdOrNull(): String? =
attributes.getOrNull(TraceIdKey)
@@ -0,0 +1,94 @@
package com.bbit.ticket.config
import io.ktor.server.application.ApplicationEnvironment
object AppConfig {
data class App(
val name: String,
val env: String,
)
data class Database(
val url: String,
val user: String,
val password: String,
val maximumPoolSize: Int,
val minimumIdle: Int,
)
data class Redis(
val url: String,
val password: String?,
)
data class Jwt(
val issuer: String,
val audience: String,
val realm: String,
val secret: String,
val accessTokenTtlMinutes: Long,
)
data class Cors(
val allowedHosts: List<String>,
)
lateinit var app: App
private set
lateinit var database: Database
private set
lateinit var redis: Redis
private set
lateinit var jwt: Jwt
private set
lateinit var cors: Cors
private set
fun init(environment: ApplicationEnvironment) {
app = App(
name = string(environment, "app.name", "Platform A"),
env = string(environment, "app.env", "local"),
)
database = Database(
url = string(environment, "database.url", "jdbc:postgresql://localhost:5432/platform_a"),
user = string(environment, "database.user", "platform_a"),
password = string(environment, "database.password", "platform_a_password"),
maximumPoolSize = int(environment, "database.maximumPoolSize", 16),
minimumIdle = int(environment, "database.minimumIdle", 4),
)
redis = Redis(
url = string(environment, "redis.url", "redis://127.0.0.1:6379"),
password = string(environment, "redis.password", "").ifBlank { null },
)
jwt = Jwt(
issuer = string(environment, "security.jwt.issuer", "platform-a"),
audience = string(environment, "security.jwt.audience", "platform-a-admin"),
realm = string(environment, "security.jwt.realm", "Platform A"),
secret = string(environment, "security.jwt.secret", "change-me-to-a-strong-secret"),
accessTokenTtlMinutes = long(environment, "security.jwt.accessTokenTtlMinutes", 120),
)
cors = Cors(
allowedHosts = string(environment, "cors.allowedHosts", "localhost:5173,127.0.0.1:5173")
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() },
)
}
private fun string(environment: ApplicationEnvironment, path: String, default: String): String =
environment.config.propertyOrNull(path)?.getString() ?: default
private fun int(environment: ApplicationEnvironment, path: String, default: Int): Int =
string(environment, path, default.toString()).toInt()
private fun long(environment: ApplicationEnvironment, path: String, default: Long): Long =
string(environment, path, default.toString()).toLong()
}
@@ -0,0 +1,26 @@
package com.bbit.ticket.database.system
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
object SysApiAccessLogTable : Table("sys_api_access_log") {
val id = uuid("id").clientDefault { Uuid.random() }
val traceId = varchar("trace_id", 64).nullable()
val appKey = varchar("app_key", 100).nullable()
val appName = varchar("app_name", 100).nullable()
val httpMethod = varchar("http_method", 20)
val requestPath = varchar("request_path", 255)
val requestHeaders = text("request_headers").nullable()
val requestBody = text("request_body").nullable()
val responseCode = varchar("response_code", 50).nullable()
val responseBody = text("response_body").nullable()
val ip = varchar("ip", 64).nullable()
val status = varchar("status", 20)
val errorMessage = text("error_message").nullable()
val costMs = long("cost_ms")
val createdAt = timestampWithTimeZone("created_at")
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,28 @@
package com.bbit.ticket.database.system
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
object SysDictItemTable : Table("sys_dict_item") {
val id = uuid("id").clientDefault { Uuid.random() }
val typeId = uuid("type_id").references(SysDictTypeTable.id)
val label = varchar("label", 100)
val value = varchar("value", 100)
val color = varchar("color", 30).nullable()
val sort = integer("sort").default(0)
val status = varchar("status", 20).default("ENABLED")
val remark = varchar("remark", 255).nullable()
val createdAt = timestampWithTimeZone("created_at")
val createdBy = uuid("created_by").nullable()
val updatedAt = timestampWithTimeZone("updated_at").nullable()
val updatedBy = uuid("updated_by").nullable()
val deletedAt = timestampWithTimeZone("deleted_at").nullable()
val deletedBy = uuid("deleted_by").nullable()
val version = integer("version").default(1)
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,24 @@
package com.bbit.ticket.database.system
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
object SysDictTypeTable : Table("sys_dict_type") {
val id = uuid("id").clientDefault { Uuid.random() }
val code = varchar("code", 80).uniqueIndex()
val name = varchar("name", 100)
val status = varchar("status", 20).default("ENABLED")
val remark = varchar("remark", 255).nullable()
val createdAt = timestampWithTimeZone("created_at")
val createdBy = uuid("created_by").nullable()
val updatedAt = timestampWithTimeZone("updated_at").nullable()
val updatedBy = uuid("updated_by").nullable()
val deletedAt = timestampWithTimeZone("deleted_at").nullable()
val deletedBy = uuid("deleted_by").nullable()
val version = integer("version").default(1)
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,34 @@
package com.bbit.ticket.database.system
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
object SysMenuTable : Table("sys_menu") {
val id = uuid("id").clientDefault { Uuid.random() }
val parentId = uuid("parent_id").nullable()
val type = varchar("type", 20)
val title = varchar("title", 100)
val name = varchar("name", 100).nullable()
val path = varchar("path", 255).nullable()
val component = varchar("component", 255).nullable()
val icon = varchar("icon", 100).nullable()
val permission = varchar("permission", 120).nullable()
val sort = integer("sort").default(0)
val visible = bool("visible").default(true)
val keepAlive = bool("keep_alive").default(false)
val builtIn = bool("built_in").default(false)
val status = varchar("status", 20).default("ENABLED")
val createdAt = timestampWithTimeZone("created_at")
val createdBy = uuid("created_by").nullable()
val updatedAt = timestampWithTimeZone("updated_at").nullable()
val updatedBy = uuid("updated_by").nullable()
val deletedAt = timestampWithTimeZone("deleted_at").nullable()
val deletedBy = uuid("deleted_by").nullable()
val version = integer("version").default(1)
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,28 @@
package com.bbit.ticket.database.system
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
object SysOperationLogTable : Table("sys_operation_log") {
val id = uuid("id").clientDefault { Uuid.random() }
val traceId = varchar("trace_id", 64).nullable()
val userId = uuid("user_id").nullable()
val username = varchar("username", 50).nullable()
val orgId = uuid("org_id").nullable()
val operationType = varchar("operation_type", 50)
val operationName = varchar("operation_name", 100)
val httpMethod = varchar("http_method", 20)
val requestPath = varchar("request_path", 255)
val requestParams = text("request_params").nullable()
val ip = varchar("ip", 64).nullable()
val userAgent = varchar("user_agent", 255).nullable()
val status = varchar("status", 20)
val errorMessage = text("error_message").nullable()
val costMs = long("cost_ms")
val createdAt = timestampWithTimeZone("created_at")
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,25 @@
package com.bbit.ticket.database.system
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
object SysOrgTable : Table("sys_org") {
val id = uuid("id").clientDefault { Uuid.random() }
val parentId = uuid("parent_id").nullable()
val name = varchar("name", 100)
val code = varchar("code", 50).uniqueIndex()
val sort = integer("sort").default(0)
val status = varchar("status", 20).default("ENABLED")
val createdAt = timestampWithTimeZone("created_at")
val createdBy = uuid("created_by").nullable()
val updatedAt = timestampWithTimeZone("updated_at").nullable()
val updatedBy = uuid("updated_by").nullable()
val deletedAt = timestampWithTimeZone("deleted_at").nullable()
val deletedBy = uuid("deleted_by").nullable()
val version = integer("version").default(1)
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,12 @@
package com.bbit.ticket.database.system
import org.jetbrains.exposed.v1.core.Table
import kotlin.uuid.ExperimentalUuidApi
@OptIn(ExperimentalUuidApi::class)
object SysRoleMenuTable : Table("sys_role_menu") {
val roleId = uuid("role_id").references(SysRoleTable.id)
val menuId = uuid("menu_id").references(SysMenuTable.id)
override val primaryKey = PrimaryKey(roleId, menuId)
}
@@ -0,0 +1,25 @@
package com.bbit.ticket.database.system
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
object SysRoleTable : Table("sys_role") {
val id = uuid("id").clientDefault { Uuid.random() }
val name = varchar("name", 100)
val code = varchar("code", 50).uniqueIndex()
val description = varchar("description", 255).nullable()
val status = varchar("status", 20).default("ENABLED")
val dataScope = varchar("data_scope", 30).default("SELF")
val createdAt = timestampWithTimeZone("created_at")
val createdBy = uuid("created_by").nullable()
val updatedAt = timestampWithTimeZone("updated_at").nullable()
val updatedBy = uuid("updated_by").nullable()
val deletedAt = timestampWithTimeZone("deleted_at").nullable()
val deletedBy = uuid("deleted_by").nullable()
val version = integer("version").default(1)
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,12 @@
package com.bbit.ticket.database.system
import org.jetbrains.exposed.v1.core.Table
import kotlin.uuid.ExperimentalUuidApi
@OptIn(ExperimentalUuidApi::class)
object SysUserRoleTable : Table("sys_user_role") {
val userId = uuid("user_id").references(SysUserTable.id)
val roleId = uuid("role_id").references(SysRoleTable.id)
override val primaryKey = PrimaryKey(userId, roleId)
}
@@ -0,0 +1,32 @@
package com.bbit.ticket.database.system
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
object SysUserTable : Table("sys_user") {
val id = uuid("id").clientDefault { Uuid.random() }
val username = varchar("username", 50).uniqueIndex()
val passwordHash = varchar("password_hash", 255)
val nickname = varchar("nickname", 50).nullable()
val realName = varchar("real_name", 50).nullable()
val phone = varchar("phone", 32).nullable()
val email = varchar("email", 100).nullable()
val avatar = text("avatar").nullable()
val orgId = uuid("org_id").nullable()
val status = varchar("status", 20).default("ENABLED")
val tokenVersion = integer("token_version").default(1)
val lastLoginAt = timestampWithTimeZone("last_login_at").nullable()
val lastLoginIp = varchar("last_login_ip", 64).nullable()
val createdAt = timestampWithTimeZone("created_at")
val createdBy = uuid("created_by").nullable()
val updatedAt = timestampWithTimeZone("updated_at").nullable()
val updatedBy = uuid("updated_by").nullable()
val deletedAt = timestampWithTimeZone("deleted_at").nullable()
val deletedBy = uuid("deleted_by").nullable()
val version = integer("version").default(1)
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,51 @@
package com.bbit.ticket.modules.auth
import kotlinx.serialization.Serializable
@Serializable
data class LoginRequest(
val username: String,
val password: String,
)
@Serializable
data class LoginResponse(
val accessToken: String,
val tokenType: String = "Bearer",
val expiresIn: Long,
)
@Serializable
data class MeResponse(
val user: CurrentUserProfile,
val menus: List<MenuNode>,
val permissions: Set<String>,
)
@Serializable
data class CurrentUserProfile(
val id: String,
val username: String,
val nickname: String? = null,
val realName: String? = null,
val orgId: String? = null,
val status: String,
)
@Serializable
data class MenuNode(
val id: String,
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,
val visible: Boolean,
val keepAlive: Boolean,
val children: List<MenuNode> = emptyList(),
)
@@ -0,0 +1,44 @@
package com.bbit.ticket.modules.auth
import com.bbit.ticket.common.ok
import com.bbit.ticket.modules.logs.OperationLogService
import com.bbit.ticket.security.requireCurrentUser
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.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlin.time.TimeSource
fun Route.registerAuthRoutes() {
route("/api/auth") {
post("/login") {
val start = TimeSource.Monotonic.markNow()
val request = call.receive<LoginRequest>()
runCatching {
val response = AuthService.login(request, call.request.local.remoteHost)
call.respond(ok(response))
OperationLogService.success(call, null, "LOGIN", "登录成功", start.elapsedNow().inWholeMilliseconds)
}.onFailure {
OperationLogService.fail(call, null, "LOGIN", "登录失败", it.message, start.elapsedNow().inWholeMilliseconds)
throw it
}
}
authenticate("auth-jwt") {
post("/logout") {
val currentUser = call.requireCurrentUser()
call.respond(ok<Unit>(message = "退出成功"))
OperationLogService.success(call, currentUser, "LOGOUT", "退出登录", 0)
}
get("/me") {
val currentUser = call.requireCurrentUser()
val response = AuthService.me(currentUser)
call.respond(ok(response))
}
}
}
}
@@ -0,0 +1,204 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package com.bbit.ticket.modules.auth
import com.bbit.ticket.common.BizException
import com.bbit.ticket.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.plugins.dbQuery
import com.bbit.ticket.security.CurrentUser
import com.bbit.ticket.security.JwtService
import com.bbit.ticket.security.PasswordService
import io.ktor.http.HttpStatusCode
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.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.uuid.Uuid
object AuthService {
suspend fun login(request: LoginRequest, loginIp: String?): LoginResponse {
val username = request.username.trim()
if (username.isBlank() || request.password.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "用户名和密码不能为空", HttpStatusCode.BadRequest)
}
val user = dbQuery {
SysUserTable.selectAll()
.where { (SysUserTable.username eq username) and SysUserTable.deletedAt.isNull() }
.singleOrNull()
} ?: throw BizException(
ErrorCode.USERNAME_OR_PASSWORD_INVALID.code,
ErrorCode.USERNAME_OR_PASSWORD_INVALID.message,
HttpStatusCode.BadRequest,
)
if (!PasswordService.matches(request.password, user[SysUserTable.passwordHash])) {
throw BizException(
ErrorCode.USERNAME_OR_PASSWORD_INVALID.code,
ErrorCode.USERNAME_OR_PASSWORD_INVALID.message,
HttpStatusCode.BadRequest,
)
}
if (user[SysUserTable.status] != "ENABLED") {
throw BizException(ErrorCode.USER_DISABLED.code, ErrorCode.USER_DISABLED.message, HttpStatusCode.BadRequest)
}
val userId = user[SysUserTable.id]
val roleCodes = dbQuery {
(SysUserRoleTable innerJoin SysRoleTable)
.selectAll()
.where {
(SysUserRoleTable.userId eq userId) and
SysRoleTable.deletedAt.isNull() and
(SysRoleTable.status eq "ENABLED")
}
.map { it[SysRoleTable.code] }
}
val (accessToken, expiresIn) = JwtService.issueAccessToken(
userId = userId.toString(),
username = user[SysUserTable.username],
orgId = user[SysUserTable.orgId]?.toString(),
roles = roleCodes,
tokenVersion = user[SysUserTable.tokenVersion],
)
dbQuery {
SysUserTable.update({ SysUserTable.id eq userId }) {
it[lastLoginAt] = OffsetDateTime.now()
it[lastLoginIp] = loginIp
it[updatedAt] = OffsetDateTime.now()
}
}
return LoginResponse(accessToken = accessToken, expiresIn = expiresIn)
}
suspend fun me(currentUser: CurrentUser): MeResponse {
val userRow = dbQuery {
SysUserTable.selectAll()
.where { (SysUserTable.id eq currentUser.id) and SysUserTable.deletedAt.isNull() }
.single()
}
val allMenus = loadMenusForUser(currentUser)
val menuTree = buildMenuTree(allMenus)
val permissions = allMenus.mapNotNull { it.permission }.toSet()
return MeResponse(
user = CurrentUserProfile(
id = currentUser.id.toString(),
username = userRow[SysUserTable.username],
nickname = userRow[SysUserTable.nickname],
realName = userRow[SysUserTable.realName],
orgId = userRow[SysUserTable.orgId]?.toString(),
status = userRow[SysUserTable.status],
),
menus = menuTree,
permissions = permissions,
)
}
private suspend fun loadMenusForUser(currentUser: CurrentUser): List<MenuFlat> {
val rows = if (currentUser.isSuperAdmin) {
dbQuery {
SysMenuTable.selectAll()
.where { SysMenuTable.deletedAt.isNull() and (SysMenuTable.status eq "ENABLED") }
.toList()
}
} else {
val roleIds = dbQuery {
(SysUserRoleTable innerJoin SysRoleTable)
.selectAll()
.where {
(SysUserRoleTable.userId eq currentUser.id) and
SysRoleTable.deletedAt.isNull() and
(SysRoleTable.status eq "ENABLED")
}
.map { it[SysRoleTable.id] }
}
if (roleIds.isEmpty()) {
emptyList()
} else {
dbQuery {
(SysRoleMenuTable innerJoin SysMenuTable)
.selectAll()
.where {
(SysRoleMenuTable.roleId inList roleIds) and
SysMenuTable.deletedAt.isNull() and
(SysMenuTable.status eq "ENABLED")
}
.distinct()
.toList()
}
}
}
return rows.map { row ->
MenuFlat(
id = row[SysMenuTable.id],
parentId = row[SysMenuTable.parentId],
type = row[SysMenuTable.type],
title = row[SysMenuTable.title],
name = row[SysMenuTable.name],
path = row[SysMenuTable.path],
component = row[SysMenuTable.component],
icon = row[SysMenuTable.icon],
permission = row[SysMenuTable.permission],
sort = row[SysMenuTable.sort],
visible = row[SysMenuTable.visible],
keepAlive = row[SysMenuTable.keepAlive],
)
}.sortedWith(compareBy<MenuFlat> { it.sort }.thenBy { it.id.toString() })
}
private fun buildMenuTree(flatMenus: List<MenuFlat>): List<MenuNode> {
val parentMap = flatMenus.groupBy { it.parentId }
fun build(parentId: Uuid?): List<MenuNode> =
(parentMap[parentId] ?: emptyList()).map { menu ->
MenuNode(
id = menu.id.toString(),
parentId = menu.parentId?.toString(),
type = 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,
children = build(menu.id),
)
}
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,
)
@@ -0,0 +1,145 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package com.bbit.ticket.modules.logs
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.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
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,
)
object LogsQueryService {
suspend fun operationLogs(page: Int, pageSize: Int, keyword: String?, status: String?): PageResult<OperationLogItem> = dbQuery {
var where: Op<Boolean> = Op.TRUE
if (!keyword.isNullOrBlank()) {
where = where and ((SysOperationLogTable.username like "%$keyword%") or (SysOperationLogTable.requestPath like "%$keyword%"))
}
if (!status.isNullOrBlank()) where = where and (SysOperationLogTable.status eq status)
val total = SysOperationLogTable.selectAll().where { where }.count()
val rows = SysOperationLogTable.selectAll().where { where }
.orderBy(SysOperationLogTable.createdAt, SortOrder.DESC)
.limit(pageSize)
.offset(((page - 1) * pageSize).toLong())
.toList()
PageResult(
items = rows.map {
OperationLogItem(
id = it[SysOperationLogTable.id].toString(),
traceId = it[SysOperationLogTable.traceId],
username = it[SysOperationLogTable.username],
operationType = it[SysOperationLogTable.operationType],
operationName = it[SysOperationLogTable.operationName],
httpMethod = it[SysOperationLogTable.httpMethod],
requestPath = it[SysOperationLogTable.requestPath],
status = it[SysOperationLogTable.status],
errorMessage = it[SysOperationLogTable.errorMessage],
costMs = it[SysOperationLogTable.costMs],
createdAt = formatDateTime(it[SysOperationLogTable.createdAt]) ?: "",
)
},
page = page,
pageSize = pageSize,
total = total,
)
}
suspend fun apiAccessLogs(page: Int, pageSize: Int, keyword: String?, status: String?): PageResult<ApiAccessLogItem> = dbQuery {
var where: Op<Boolean> = Op.TRUE
if (!keyword.isNullOrBlank()) {
where = where and ((SysApiAccessLogTable.appName like "%$keyword%") or (SysApiAccessLogTable.requestPath like "%$keyword%"))
}
if (!status.isNullOrBlank()) where = where and (SysApiAccessLogTable.status eq status)
val total = SysApiAccessLogTable.selectAll().where { where }.count()
val rows = SysApiAccessLogTable.selectAll().where { where }
.orderBy(SysApiAccessLogTable.createdAt, SortOrder.DESC)
.limit(pageSize)
.offset(((page - 1) * pageSize).toLong())
.toList()
PageResult(
items = rows.map {
ApiAccessLogItem(
id = it[SysApiAccessLogTable.id].toString(),
traceId = it[SysApiAccessLogTable.traceId],
appKey = it[SysApiAccessLogTable.appKey],
appName = it[SysApiAccessLogTable.appName],
httpMethod = it[SysApiAccessLogTable.httpMethod],
requestPath = it[SysApiAccessLogTable.requestPath],
responseCode = it[SysApiAccessLogTable.responseCode],
status = it[SysApiAccessLogTable.status],
errorMessage = it[SysApiAccessLogTable.errorMessage],
costMs = it[SysApiAccessLogTable.costMs],
createdAt = formatDateTime(it[SysApiAccessLogTable.createdAt]) ?: "",
)
},
page = page,
pageSize = pageSize,
total = total,
)
}
}
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,59 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package com.bbit.ticket.modules.logs
import com.bbit.ticket.common.traceIdOrNull
import com.bbit.ticket.database.system.SysOperationLogTable
import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.security.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
object OperationLogService {
suspend fun success(call: ApplicationCall, currentUser: CurrentUser?, operationType: String, operationName: String, costMs: Long) {
save(call, currentUser, operationType, operationName, "SUCCESS", null, costMs)
}
suspend fun fail(
call: ApplicationCall,
currentUser: CurrentUser?,
operationType: String,
operationName: String,
errorMessage: String?,
costMs: Long,
) {
save(call, currentUser, operationType, operationName, "FAIL", errorMessage?.take(500), costMs)
}
private suspend fun save(
call: ApplicationCall,
currentUser: CurrentUser?,
operationType: String,
operationName: String,
status: String,
errorMessage: String?,
costMs: Long,
) = dbQuery {
SysOperationLogTable.insert {
it[traceId] = call.traceIdOrNull()
it[userId] = currentUser?.id
it[username] = currentUser?.username
it[orgId] = currentUser?.orgId
it[SysOperationLogTable.operationType] = operationType
it[SysOperationLogTable.operationName] = operationName
it[httpMethod] = call.request.httpMethod.value
it[requestPath] = call.request.path().take(255)
it[requestParams] = call.request.queryParameters.formUrlEncode().take(1000)
it[ip] = call.request.local.remoteHost.take(64)
it[userAgent] = call.request.headers["User-Agent"]?.take(255)
it[SysOperationLogTable.status] = status
it[SysOperationLogTable.errorMessage] = errorMessage
it[SysOperationLogTable.costMs] = costMs
it[createdAt] = OffsetDateTime.now()
}
}
}
@@ -0,0 +1,338 @@
@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
}
}
}
}
}
@@ -0,0 +1,280 @@
@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
}
}
}
}
}
@@ -0,0 +1,228 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package com.bbit.ticket.modules.system.org
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.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 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()
.where { SysOrgTable.deletedAt.isNull() }
.orderBy(SysOrgTable.sort)
.toList()
val nodes = rows.map(::toNode)
buildTree(nodes)
}
suspend fun create(request: CreateOrgRequest): String = dbQuery {
val code = request.code.trim()
if (code.isBlank() || request.name.trim().isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "组织名称和编码不能为空")
}
val exists = SysOrgTable.selectAll().where {
(SysOrgTable.code eq code) and SysOrgTable.deletedAt.isNull()
}.any()
if (exists) {
throw BizException(ErrorCode.DATA_CONFLICT.code, "组织编码已存在")
}
val parentId = request.parentId?.let { parseUuid(it, "parentId") }
if (parentId != null) requireOrg(parentId)
val inserted = SysOrgTable.insert {
it[SysOrgTable.parentId] = parentId
it[name] = request.name.trim()
it[SysOrgTable.code] = code
it[sort] = request.sort
it[status] = request.status
it[createdAt] = OffsetDateTime.now()
}
inserted[SysOrgTable.id].toString()
}
suspend fun update(id: Uuid, request: UpdateOrgRequest) = dbQuery {
requireOrg(id)
val parentId = request.parentId?.let { parseUuid(it, "parentId") }
if (parentId == id) {
throw BizException(ErrorCode.BAD_REQUEST.code, "上级组织不能选择自身")
}
if (parentId != null) requireOrg(parentId)
SysOrgTable.update({ SysOrgTable.id eq id }) {
it[SysOrgTable.parentId] = parentId
it[name] = request.name.trim()
it[sort] = request.sort
it[status] = request.status
it[updatedAt] = OffsetDateTime.now()
}
}
suspend fun delete(id: Uuid) = dbQuery {
val org = requireOrg(id)
if (org[SysOrgTable.code] == "DEFAULT_ORG") {
throw BizException(ErrorCode.BAD_REQUEST.code, "默认组织不可删除")
}
val hasChildren = SysOrgTable.selectAll()
.where { (SysOrgTable.parentId eq id) and SysOrgTable.deletedAt.isNull() }
.any()
if (hasChildren) {
throw BizException(ErrorCode.BAD_REQUEST.code, "当前组织存在子组织,不能删除")
}
val hasUsers = SysUserTable.selectAll()
.where { (SysUserTable.orgId eq id) and SysUserTable.deletedAt.isNull() }
.any()
if (hasUsers) {
throw BizException(ErrorCode.BAD_REQUEST.code, "当前组织存在用户,不能删除")
}
SysOrgTable.update({ SysOrgTable.id eq id }) {
it[deletedAt] = OffsetDateTime.now()
}
}
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)
private fun toNode(row: ResultRow): OrgNodeFlat = OrgNodeFlat(
id = row[SysOrgTable.id],
parentId = row[SysOrgTable.parentId],
name = row[SysOrgTable.name],
code = row[SysOrgTable.code],
sort = row[SysOrgTable.sort],
status = row[SysOrgTable.status],
)
private fun buildTree(nodes: List<OrgNodeFlat>): List<OrgTreeNode> {
val byParent = nodes.groupBy { it.parentId }
fun children(parentId: Uuid?): List<OrgTreeNode> =
(byParent[parentId] ?: emptyList()).sortedBy { it.sort }.map { item ->
OrgTreeNode(
id = item.id.toString(),
parentId = item.parentId?.toString(),
name = item.name,
code = item.code,
sort = item.sort,
status = item.status,
statusLabel = statusLabel(item.status),
children = children(item.id),
)
}
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
}
}
}
}
}
@@ -0,0 +1,285 @@
@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
}
}
}
}
}
@@ -0,0 +1,421 @@
@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
}
}
}
}
}
@@ -0,0 +1,79 @@
package com.bbit.ticket.plugins
import com.bbit.ticket.common.TraceIdKey
import com.bbit.ticket.database.system.SysApiAccessLogTable
import io.ktor.server.application.Application
import io.ktor.server.application.createApplicationPlugin
import io.ktor.server.application.install
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.principal
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.time.TimeSource
private val accessLogStartMarkKey = io.ktor.util.AttributeKey<TimeSource.Monotonic.ValueTimeMark>("api-access-start")
private val accessLogWrittenKey = io.ktor.util.AttributeKey<Boolean>("api-access-written")
fun Application.configureApiAccessLog() {
install(
createApplicationPlugin("ApiAccessLogPlugin") {
onCall { call ->
if (!call.request.path().startsWith("/api/")) return@onCall
call.attributes.put(accessLogStartMarkKey, TimeSource.Monotonic.markNow())
}
// 统一记录 API 访问日志,业务操作日志由各模块在写操作成功/失败后补充。
onCallRespond { call, _ ->
if (!call.request.path().startsWith("/api/")) return@onCallRespond
if (call.attributes.getOrNull(accessLogWrittenKey) == true) return@onCallRespond
writeAccessLog(call, null)
call.attributes.put(accessLogWrittenKey, true)
}
},
)
}
private suspend fun writeAccessLog(
call: io.ktor.server.application.ApplicationCall,
errorMessage: String?,
) = dbQuery {
val startedAt = call.attributes.getOrNull(accessLogStartMarkKey)
val costMs = startedAt?.elapsedNow()?.inWholeMilliseconds ?: 0L
val traceId = call.attributes.getOrNull(TraceIdKey)
val requestPath = call.request.path().take(255)
val principal = call.principal<JWTPrincipal>()
val appKeyFromHeader = call.request.headers["X-App-Key"]?.take(100)
val appNameFromHeader = call.request.headers["X-App-Name"]?.take(100)
val appNameFromUser = principal?.payload?.getClaim("username")?.asString()?.take(100)
val responseCode = call.response.status()?.value?.toString()
val statusCode = call.response.status()?.value ?: 200
val statusForStore = if (statusCode >= 400) "FAIL" else "SUCCESS"
SysApiAccessLogTable.insert {
it[SysApiAccessLogTable.traceId] = traceId?.take(64)
it[appKey] = appKeyFromHeader
it[appName] = appNameFromHeader ?: appNameFromUser
it[httpMethod] = call.request.httpMethod.value.take(20)
it[SysApiAccessLogTable.requestPath] = requestPath
it[requestHeaders] = maskedHeaders(call.request.headers.entries())
it[requestBody] = null
it[SysApiAccessLogTable.responseCode] = responseCode?.take(50)
it[responseBody] = null
it[ip] = call.request.local.remoteHost.take(64)
it[SysApiAccessLogTable.status] = statusForStore
it[SysApiAccessLogTable.errorMessage] = errorMessage?.take(500)
it[SysApiAccessLogTable.costMs] = costMs
it[createdAt] = OffsetDateTime.now()
}
}
private fun maskedHeaders(entries: Set<Map.Entry<String, List<String>>>): String {
if (entries.isEmpty()) return ""
val content = entries.joinToString("&") { (key, values) ->
val value = if (key.equals("Authorization", ignoreCase = true)) "***" else values.joinToString(",")
"${key.lowercase()}=${value.take(120)}"
}
return content.take(2000)
}
@@ -0,0 +1,29 @@
package com.bbit.ticket.plugins
import com.bbit.ticket.config.AppConfig
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.server.application.Application
import io.ktor.server.application.install
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"))
}
}
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Options)
allowHeader(HttpHeaders.Authorization)
allowHeader(HttpHeaders.ContentType)
}
}
@@ -0,0 +1,49 @@
package com.bbit.ticket.plugins
import com.bbit.ticket.config.AppConfig
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.Application
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction
import org.slf4j.LoggerFactory
private val logger = LoggerFactory.getLogger("DatabasePlugin")
fun Application.configureDatabase() {
connectDatabase()
logger.info("PostgreSQL datasource initialized")
}
lateinit var platformDatabase: Database
private set
fun connectDatabase(): Database {
val hikariConfig = HikariConfig().apply {
jdbcUrl = AppConfig.database.url
username = AppConfig.database.user
password = AppConfig.database.password
driverClassName = "org.postgresql.Driver"
maximumPoolSize = AppConfig.database.maximumPoolSize
minimumIdle = AppConfig.database.minimumIdle
idleTimeout = 60_000
maxLifetime = 600_000
keepaliveTime = 120_000
connectionTimeout = 10_000
validationTimeout = 5_000
transactionIsolation = "TRANSACTION_READ_COMMITTED"
poolName = "platform-a-hikari"
}
platformDatabase = Database.connect(HikariDataSource(hikariConfig))
return platformDatabase
}
suspend fun <T> dbQuery(block: suspend () -> T): T =
withContext(Dispatchers.IO) {
suspendTransaction(platformDatabase) {
block()
}
}
@@ -0,0 +1,22 @@
package com.bbit.ticket.plugins
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.calllogging.CallLogging
import io.ktor.server.request.httpMethod
import io.ktor.server.request.path
import org.slf4j.event.Level
fun Application.configureLogging() {
install(CallLogging) {
level = Level.INFO
mdc("traceId") { call -> call.attributes.getOrNull(com.bbit.ticket.common.TraceIdKey) }
filter { call -> !call.request.path().startsWith("/health") }
format { call ->
val status = call.response.status()
val method = call.request.httpMethod.value
val path = call.request.path()
"HTTP $method $path -> $status"
}
}
}
@@ -0,0 +1,21 @@
package com.bbit.ticket.plugins
import com.bbit.ticket.config.AppConfig
import io.ktor.server.application.Application
import org.redisson.Redisson
import org.redisson.api.RedissonClient
import org.redisson.config.Config
import org.slf4j.LoggerFactory
private val logger = LoggerFactory.getLogger("RedisPlugin")
lateinit var redisClient: RedissonClient
private set
fun Application.configureRedis() {
val config = Config()
val server = config.useSingleServer().setAddress(AppConfig.redis.url)
AppConfig.redis.password?.let { server.password = it }
redisClient = Redisson.create(config)
logger.info("Redis client initialized")
}
@@ -0,0 +1,44 @@
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 io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt
import io.ktor.server.response.respond
fun Application.configureSecurity() {
install(Authentication) {
jwt("auth-jwt") {
realm = AppConfig.jwt.realm
verifier(
JWT.require(Algorithm.HMAC256(AppConfig.jwt.secret))
.withIssuer(AppConfig.jwt.issuer)
.withAudience(AppConfig.jwt.audience)
.build(),
)
validate { credential ->
val userId = credential.payload.subject
val tokenType = credential.payload.getClaim("token_type").asString()
if (userId.isNullOrBlank() || tokenType != "access_token") {
null
} else {
JWTPrincipal(credential.payload)
}
}
challenge { _, _ ->
call.respond(
HttpStatusCode.Unauthorized,
fail(ErrorCode.UNAUTHORIZED.code, ErrorCode.UNAUTHORIZED.message, call.traceIdOrNull()),
)
}
}
}
}
@@ -0,0 +1,20 @@
package com.bbit.ticket.plugins
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import kotlinx.serialization.json.Json
val appJson = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
explicitNulls = false
}
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(appJson)
}
}
@@ -0,0 +1,37 @@
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 io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.response.respond
import org.slf4j.LoggerFactory
private val logger = LoggerFactory.getLogger("StatusPagesPlugin")
fun Application.configureStatusPages() {
install(StatusPages) {
exception<BizException> { call, cause ->
call.respond(cause.status, fail(cause.errorCode, cause.message, call.traceIdOrNull()))
}
exception<IllegalArgumentException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
fail(ErrorCode.BAD_REQUEST.code, cause.message ?: ErrorCode.BAD_REQUEST.message, call.traceIdOrNull()),
)
}
exception<Throwable> { call, cause ->
logger.error("Unhandled server error", cause)
call.respond(
HttpStatusCode.InternalServerError,
fail(ErrorCode.INTERNAL_SERVER_ERROR.code, ErrorCode.INTERNAL_SERVER_ERROR.message, call.traceIdOrNull()),
)
}
}
}
@@ -0,0 +1,24 @@
package com.bbit.ticket.plugins
import com.bbit.ticket.common.TraceIdKey
import io.ktor.server.application.Application
import io.ktor.server.application.createApplicationPlugin
import io.ktor.server.application.install
import java.util.UUID
private const val TRACE_HEADER = "X-Trace-Id"
fun Application.configureTrace() {
install(
createApplicationPlugin("TracePlugin") {
onCall { call ->
val traceId = call.request.headers[TRACE_HEADER] ?: UUID.randomUUID().toString().replace("-", "")
call.attributes.put(TraceIdKey, traceId)
}
onCallRespond { call, _ ->
call.response.headers.append(TRACE_HEADER, call.attributes[TraceIdKey], safeOnly = false)
}
},
)
}
@@ -0,0 +1,36 @@
package com.bbit.ticket.security
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import com.bbit.ticket.config.AppConfig
import java.time.Instant
import java.time.temporal.ChronoUnit
object JwtService {
fun issueAccessToken(
userId: String,
username: String,
orgId: String?,
roles: List<String>,
tokenVersion: Int,
): Pair<String, Long> {
val now = Instant.now()
val expiresAt = now.plus(AppConfig.jwt.accessTokenTtlMinutes, ChronoUnit.MINUTES)
val token = JWT.create()
.withIssuer(AppConfig.jwt.issuer)
.withAudience(AppConfig.jwt.audience)
.withIssuedAt(now)
.withExpiresAt(expiresAt)
.withSubject(userId)
.withClaim("username", username)
.withClaim("orgId", orgId)
.withArrayClaim("roles", roles.toTypedArray())
.withClaim("tokenVersion", tokenVersion)
.withClaim("token_type", "access_token")
.sign(Algorithm.HMAC256(AppConfig.jwt.secret))
return token to AppConfig.jwt.accessTokenTtlMinutes * 60
}
}
@@ -0,0 +1,10 @@
package com.bbit.ticket.security
import org.mindrot.jbcrypt.BCrypt
object PasswordService {
fun hash(rawPassword: String): String = BCrypt.hashpw(rawPassword, BCrypt.gensalt())
fun matches(rawPassword: String, passwordHash: String): Boolean = BCrypt.checkpw(rawPassword, passwordHash)
}
@@ -0,0 +1,16 @@
package com.bbit.ticket.security
import com.bbit.ticket.common.BizException
import com.bbit.ticket.common.ErrorCode
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
suspend fun ApplicationCall.requirePermission(permission: String): CurrentUser {
val currentUser = requireCurrentUser()
if (currentUser.isSuperAdmin || currentUser.permissions.contains(permission)) {
return currentUser
}
throw BizException(ErrorCode.FORBIDDEN.code, ErrorCode.FORBIDDEN.message, HttpStatusCode.Forbidden)
}
@@ -0,0 +1,141 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package com.bbit.ticket.security
import com.bbit.ticket.common.BizException
import com.bbit.ticket.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.plugins.dbQuery
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.principal
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.util.AttributeKey
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.isNotNull
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.jdbc.selectAll
import kotlin.uuid.Uuid
data class CurrentUser(
val id: Uuid,
val username: String,
val orgId: Uuid?,
val tokenVersion: Int,
val roleCodes: Set<String>,
val permissions: Set<String>,
) {
val isSuperAdmin: Boolean
get() = roleCodes.contains("SUPER_ADMIN")
}
private val CurrentUserKey = AttributeKey<CurrentUser>("currentUser")
suspend fun ApplicationCall.requireCurrentUser(): CurrentUser {
attributes.getOrNull(CurrentUserKey)?.let { return it }
val principal = principal<JWTPrincipal>() ?: throw BizException(
ErrorCode.UNAUTHORIZED.code,
ErrorCode.UNAUTHORIZED.message,
HttpStatusCode.Unauthorized,
)
val userId = principal.payload.subject ?: throw BizException(
ErrorCode.UNAUTHORIZED.code,
ErrorCode.UNAUTHORIZED.message,
HttpStatusCode.Unauthorized,
)
val userUuid = runCatching { Uuid.parse(userId) }.getOrElse {
throw BizException(ErrorCode.UNAUTHORIZED.code, ErrorCode.UNAUTHORIZED.message, HttpStatusCode.Unauthorized)
}
val tokenVersion = principal.payload.getClaim("tokenVersion").asInt()
?: throw BizException(ErrorCode.UNAUTHORIZED.code, ErrorCode.UNAUTHORIZED.message, HttpStatusCode.Unauthorized)
val userRow = dbQuery {
SysUserTable.selectAll()
.where { (SysUserTable.id eq userUuid) and SysUserTable.deletedAt.isNull() }
.singleOrNull()
} ?: throw BizException(ErrorCode.USER_NOT_FOUND.code, ErrorCode.USER_NOT_FOUND.message, HttpStatusCode.Unauthorized)
if (userRow[SysUserTable.status] != "ENABLED") {
throw BizException(ErrorCode.USER_DISABLED.code, ErrorCode.USER_DISABLED.message, HttpStatusCode.Unauthorized)
}
if (userRow[SysUserTable.tokenVersion] != tokenVersion) {
throw BizException(
ErrorCode.TOKEN_VERSION_INVALID.code,
ErrorCode.TOKEN_VERSION_INVALID.message,
HttpStatusCode.Unauthorized,
)
}
val roleCodes = dbQuery {
(SysUserRoleTable innerJoin SysRoleTable)
.selectAll()
.where {
(SysUserRoleTable.userId eq userUuid) and
SysRoleTable.deletedAt.isNull() and
(SysRoleTable.status eq "ENABLED")
}
.map { it[SysRoleTable.code] }
.toSet()
}
val permissions = if (roleCodes.contains("SUPER_ADMIN")) {
dbQuery {
SysMenuTable.selectAll()
.where {
SysMenuTable.deletedAt.isNull() and
(SysMenuTable.status eq "ENABLED") and
SysMenuTable.permission.isNotNull()
}
.mapNotNull { it[SysMenuTable.permission] }
.toSet()
}
} else {
val roleIds = dbQuery {
(SysUserRoleTable innerJoin SysRoleTable)
.selectAll()
.where {
(SysUserRoleTable.userId eq userUuid) and
SysRoleTable.deletedAt.isNull() and
(SysRoleTable.status eq "ENABLED")
}
.map { it[SysRoleTable.id] }
}
if (roleIds.isEmpty()) {
emptySet()
} else {
dbQuery {
(SysRoleMenuTable innerJoin SysMenuTable)
.selectAll()
.where {
(SysRoleMenuTable.roleId inList roleIds) and
SysMenuTable.deletedAt.isNull() and
(SysMenuTable.status eq "ENABLED") and
SysMenuTable.permission.isNotNull()
}
.mapNotNull { it[SysMenuTable.permission] }
.toSet()
}
}
}
val currentUser = CurrentUser(
id = userRow[SysUserTable.id],
username = userRow[SysUserTable.username],
orgId = userRow[SysUserTable.orgId],
tokenVersion = userRow[SysUserTable.tokenVersion],
roleCodes = roleCodes,
permissions = permissions,
)
attributes.put(CurrentUserKey, currentUser)
return currentUser
}
@@ -0,0 +1,32 @@
ktor:
application:
modules:
- com.bbit.ticket.ApplicationKt.module
deployment:
port: 8070
app:
name: "Ticket"
env: "local"
database:
url: "jdbc:postgresql://localhost:5432/ticket"
user: "ticket"
password: "ticket_password"
maximumPoolSize: 16
minimumIdle: 4
redis:
url: "redis://127.0.0.1:6379"
password: "ticket_password"
security:
jwt:
issuer: "platform-a"
audience: "platform-a-admin"
realm: "Platform A"
secret: "change-me-to-a-strong-secret"
accessTokenTtlMinutes: 120
cors:
allowedHosts: "localhost:5173,127.0.0.1:5173"
+16
View File
@@ -0,0 +1,16 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{40} - %msg%n</pattern>
</encoder>
</appender>
<logger name="com.zaxxer.hikari" level="WARN"/>
<logger name="org.redisson" level="WARN"/>
<logger name="io.netty" level="WARN"/>
<logger name="Exposed" level="WARN"/>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>