初始化项目
This commit is contained in:
@@ -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/
|
||||
@@ -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")
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
Vendored
+248
@@ -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" "$@"
|
||||
Vendored
+93
@@ -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
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user