From a620fa15b4fc60f4d1f0ee3baa0212d6fbbc5c1b Mon Sep 17 00:00:00 2001 From: TheWhiteDog9487 Date: Thu, 30 Jul 2026 12:04:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=BF=E7=94=A8Kotlin=E9=87=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + AGENTS.md | 3 + build.gradle.kts | 122 +++++++---- compose.yaml | 19 +- gradle/wrapper/gradle-wrapper.jar | Bin 43764 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew | 7 +- gradlew.bat | 32 +-- .../Configuration/GraalVMBuildHints.java | 105 ---------- .../Configuration/OpenAPIConfiguration.java | 36 ---- .../Security/ApiKeyAuthenticationFilter.java | 107 ---------- .../SpringSecurityConfiguration.java | 51 ----- .../WebAPI/Controller/AccessLog.java | 126 ------------ .../Controller/Filter/LogClientInfo.java | 79 ------- .../Filter/SetDefaultContentType.java | 23 --- .../Controller/Filter/SetMDCRequestID.java | 27 --- .../thewhitedog9487/WebAPI/Controller/IP.java | 87 -------- .../WebAPI/Controller/Message.java | 170 --------------- .../RedirectRootToSwaggerWebUI.java | 16 -- .../WebAPI/Controller/ResponseData.java | 35 ---- .../WebAPI/Data/Entity/AccessLog.java | 117 ----------- .../Data/Repository/AccessLogRepository.java | 8 - .../WebAPI/GlobalSharedBean.java | 54 ----- .../Startup/SystemdInstallerManager.java | 178 ---------------- .../WebAPI/WebApiApplication.java | 16 -- .../BackgroundTask/ApiKeyFileManager.kt | 63 ++++++ .../Configuration/OpenApiConfiguration.kt | 31 +++ .../Security/ApiKeyAuthenticationFilter.kt | 53 +++++ .../Security/SpringSecurityConfiguration.kt | 55 +++++ .../Configuration/SpringMvcConfiguration.kt | 11 + .../WebAPI/Controller/AccessLog.kt | 190 +++++++++++++++++ .../WebAPI/Controller/Filter/LogClientInfo.kt | 41 ++++ .../Controller/Filter/SetMdcRequestId.kt | 22 ++ .../thewhitedog9487/WebAPI/Controller/IP.kt | 77 +++++++ .../WebAPI/Controller/Message.kt | 194 ++++++++++++++++++ .../Controller/RedirectRootToSwaggerWebUI.kt | 17 ++ .../WebAPI/Controller/ResponseData.kt | 25 +++ .../WebAPI/Data/Entity/AccessLog.kt | 140 +++++++++++++ .../Data/Repository/AccessLogRepository.kt | 7 + .../Specification/AccessLogSpecification.kt | 14 ++ .../NoSuchDiscordChannelException.kt | 7 + .../WebAPI/Miscellaneous/GlobalSharedBeans.kt | 46 +++++ .../WebAPI/Miscellaneous/Kord.kt | 14 ++ .../Miscellaneous/SpringContextHolder.kt | 12 ++ .../WebAPI/Startup/SystemdInstallerManager.kt | 152 ++++++++++++++ .../WebAPI/WebApiApplication.kt | 17 ++ src/main/resources/application.yml | 5 + .../WebAPI/WebApiApplicationTests.java | 13 -- .../WebAPI/WebApiApplicationTests.kt | 13 ++ 49 files changed, 1328 insertions(+), 1316 deletions(-) create mode 100644 AGENTS.md delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/GraalVMBuildHints.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/OpenAPIConfiguration.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/Security/ApiKeyAuthenticationFilter.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/SpringSecurityConfiguration.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Controller/AccessLog.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Filter/LogClientInfo.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Filter/SetDefaultContentType.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Filter/SetMDCRequestID.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Controller/IP.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Message.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Controller/RedirectRootToSwaggerWebUI.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Controller/ResponseData.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Data/Entity/AccessLog.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Data/Repository/AccessLogRepository.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/GlobalSharedBean.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/Startup/SystemdInstallerManager.java delete mode 100644 src/main/java/xyz/thewhitedog9487/WebAPI/WebApiApplication.java create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/BackgroundTask/ApiKeyFileManager.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/OpenApiConfiguration.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/Security/ApiKeyAuthenticationFilter.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/Security/SpringSecurityConfiguration.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/SpringMvcConfiguration.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/AccessLog.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/Filter/LogClientInfo.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/Filter/SetMdcRequestId.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/IP.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/Message.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/RedirectRootToSwaggerWebUI.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/ResponseData.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Data/Entity/AccessLog.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Data/Repository/AccessLogRepository.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Data/Specification/AccessLogSpecification.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Exception/NoSuchDiscordChannelException.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Miscellaneous/GlobalSharedBeans.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Miscellaneous/Kord.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Miscellaneous/SpringContextHolder.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/Startup/SystemdInstallerManager.kt create mode 100644 src/main/kotlin/xyz/thewhitedog9487/WebAPI/WebApiApplication.kt delete mode 100644 src/test/java/xyz/thewhitedog9487/WebAPI/WebApiApplicationTests.java create mode 100644 src/test/kotlin/xyz/thewhitedog9487/WebAPI/WebApiApplicationTests.kt diff --git a/.gitignore b/.gitignore index d052c49..37c7d80 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ out/ ### VS Code ### .vscode/ +### Kotlin ### +.kotlin + API密钥.txt SQLiteDataBase.db *.http \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..58a4b96 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +- 使用帕斯卡命名风格 +- 括号不允许独占一行 +- 括号和包括括号在内的其他内容之间使用空格隔开 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 4af4ef7..514f9de 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,68 +1,112 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { - java - id("org.springframework.boot") version "4.0.0" - id("io.spring.dependency-management") version "1.1.7" - id("org.hibernate.orm") version "7.1.8.Final" - id("org.graalvm.buildtools.native") version "0.11.3" - id("org.springdoc.openapi-gradle-plugin") version "1.9.0" + kotlin("jvm") version "2.3.21" + kotlin("kapt") version "2.3.21" + kotlin("plugin.spring") version "2.3.21" + kotlin("plugin.jpa") version "2.3.21" + kotlin("plugin.lombok") version "2.3.21" + id("org.springframework.boot") version "4.1.0" + id("io.spring.dependency-management") version "1.1.7" + id("org.hibernate.orm") version "7.4.1.Final" + id("org.graalvm.buildtools.native") version "1.1.1" } group = "xyz.thewhitedog9487" -version = "0.7.5" +version = "0.8.0" java { - toolchain { - languageVersion = JavaLanguageVersion.of(25) - } -} - -configurations { - compileOnly { - extendsFrom(configurations.annotationProcessor.get()) - } + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation("org.springframework.boot:spring-boot-starter-actuator") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-data-redis") - implementation("org.springframework.boot:spring-boot-starter-jdbc") - implementation("org.springframework.boot:spring-boot-starter-security") - implementation("org.springframework.boot:spring-boot-starter-webmvc") - compileOnly("org.projectlombok:lombok") - developmentOnly("org.springframework.boot:spring-boot-devtools") - developmentOnly("org.springframework.boot:spring-boot-docker-compose") - runtimeOnly("org.xerial:sqlite-jdbc") - annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") - annotationProcessor("org.projectlombok:lombok") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") +// implementation("org.springframework.boot:spring-boot-starter-data-redis") +// implementation("org.springframework.boot:spring-boot-starter-jdbc") +// implementation("org.springframework.boot:spring-boot-starter-restclient") + implementation("org.springframework.boot:spring-boot-starter-security") +// implementation("org.springframework.boot:spring-boot-starter-security-oauth2-authorization-server") +// implementation("org.springframework.boot:spring-boot-starter-security-oauth2-client") +// implementation("org.springframework.boot:spring-boot-starter-security-oauth2-resource-server") + implementation("org.springframework.boot:spring-boot-starter-webmvc") +// implementation("org.springframework.boot:spring-boot-starter-websocket") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("tools.jackson.module:jackson-module-kotlin") + compileOnly("org.projectlombok:lombok") + developmentOnly("org.springframework.boot:spring-boot-devtools") +// developmentOnly("org.springframework.boot:spring-boot-docker-compose") +// runtimeOnly("org.mariadb.jdbc:mariadb-java-client") +// runtimeOnly("org.postgresql:postgresql") + runtimeOnly("org.xerial:sqlite-jdbc") + annotationProcessor("org.projectlombok:lombok") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + kapt("org.springframework.boot:spring-boot-configuration-processor") testImplementation("org.springframework.boot:spring-boot-starter-actuator-test") testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test") - testImplementation("org.springframework.boot:spring-boot-starter-data-redis-test") - testImplementation("org.springframework.boot:spring-boot-starter-jdbc-test") +// testImplementation("org.springframework.boot:spring-boot-starter-data-redis-test") +// testImplementation("org.springframework.boot:spring-boot-starter-jdbc-test") +// testImplementation("org.springframework.boot:spring-boot-starter-restclient-test") +// testImplementation("org.springframework.boot:spring-boot-starter-security-oauth2-authorization-server-test") +// testImplementation("org.springframework.boot:spring-boot-starter-security-oauth2-client-test") +// testImplementation("org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test") testImplementation("org.springframework.boot:spring-boot-starter-security-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") +// testImplementation("org.springframework.boot:spring-boot-starter-websocket-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testCompileOnly("org.projectlombok:lombok") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testAnnotationProcessor("org.projectlombok:lombok") + testImplementation(kotlin("test")) - implementation("org.hibernate.orm:hibernate-community-dialects") - implementation("com.discord4j:discord4j-core:3.3.0") - implementation("tools.jackson.core:jackson-core:3.0.3") - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.0") - implementation("org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0") + implementation("io.github.oshai:kotlin-logging-jvm:7.0.3") + implementation("org.hibernate.orm:hibernate-community-dialects") + implementation("dev.kord:kord-core:0.18.1") + implementation("tools.jackson.core:jackson-core") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") + implementation("io.micrometer:context-propagation") + implementation("org.apache.commons:commons-lang3") +} + +hibernate { + enhancement { + } +} + +allOpen { + annotation("jakarta.persistence.Entity") + annotation("jakarta.persistence.MappedSuperclass") + annotation("jakarta.persistence.Embeddable") } tasks.withType { - useJUnitPlatform() + useJUnitPlatform() } + tasks.bootJar { - archiveClassifier.set("boot") + archiveClassifier.set("boot") } //tasks.jar { // enabled = false //} -// https://docs.spring.io/spring-boot/gradle-plugin/packaging.html#packaging-executable.and-plain-archives \ No newline at end of file +// https://docs.spring.io/spring-boot/gradle-plugin/packaging.html#packaging-executable.and-plain-archives + +tasks.withType().configureEach { + compilerOptions { + freeCompilerArgs.add("-Xannotation-default-target=param-property") + } +} +kapt { + keepJavacAnnotationProcessors = true +} \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 1cfb822..c4b0e9a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,5 +1,22 @@ services: +# mariadb: +# image: 'mariadb:latest' +# environment: +# - 'MARIADB_DATABASE=mydatabase' +# - 'MARIADB_PASSWORD=secret' +# - 'MARIADB_ROOT_PASSWORD=verysecret' +# - 'MARIADB_USER=myuser' +# ports: +# - '3306' +# postgres: +# image: 'postgres:latest' +# environment: +# - 'POSTGRES_DB=mydatabase' +# - 'POSTGRES_PASSWORD=secret' +# - 'POSTGRES_USER=myuser' +# ports: +# - '5432' redis: image: 'redis:latest' ports: - - '6379' + - '6379' \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baabb587c669f562ae36f953de2481846..b1b8ef56b44f16b14dc800fa8103a6d89abb526f 100644 GIT binary patch delta 40183 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 delta 35506 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 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 +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,30 +65,18 @@ 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 +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -: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 +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/GraalVMBuildHints.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/GraalVMBuildHints.java deleted file mode 100644 index 58b76fb..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/GraalVMBuildHints.java +++ /dev/null @@ -1,105 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Configuration; - -import org.springframework.aot.hint.MemberCategory; -import org.springframework.aot.hint.RuntimeHints; -import org.springframework.aot.hint.RuntimeHintsRegistrar; -import org.springframework.aot.hint.TypeReference; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.ImportRuntimeHints; -import org.springframework.core.io.Resource; -import org.springframework.core.io.support.PathMatchingResourcePatternResolver; -import org.springframework.core.type.classreading.CachingMetadataReaderFactory; -import org.springframework.core.type.classreading.MetadataReader; -import org.springframework.util.ClassUtils; - -@Configuration(proxyBeanMethods = false) -@ImportRuntimeHints(RegisterGraalVMBuildHints.class) -class GraalVMBuildHints {} - -/** - * GPT写的,测试了一下能用,先作为实验性内容加入 - * @author ChatGPT GPT5 - */ -class RegisterGraalVMBuildHints implements RuntimeHintsRegistrar{ - - @Override - public void registerHints(RuntimeHints hints, ClassLoader classLoader) { - String basePackage = "discord4j"; - String pattern = "classpath*:" + ClassUtils.convertClassNameToResourcePath(basePackage) + "/**/*.class"; - - PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(classLoader); - CachingMetadataReaderFactory mrf = new CachingMetadataReaderFactory(classLoader); - - int ok = 0, skip = 0; - try { - Resource[] resources = resolver.getResources(pattern); - for (Resource resource : resources) { - if (!resource.isReadable()) { - continue; - } - MetadataReader mr = mrf.getMetadataReader(resource); - String className = mr.getClassMetadata().getClassName(); - try { - // 只处理目标包(防御性判断,避免扫到别的) - if (!className.startsWith(basePackage)) { - continue; - } - Class clazz = ClassUtils.forName(className, classLoader); - // 暴力但稳:把构造器/方法/字段都暴露给反射(含内部类、Builder、$Json) - hints.reflection().registerType(clazz,b -> b.withMembers( - MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.INVOKE_DECLARED_METHODS, - MemberCategory.ACCESS_DECLARED_FIELDS)); - ok++; - } catch (Throwable e) { - // 有些类可能因缺失依赖/被 JDK 模块限制而加载失败,跳过即可 - skip++; - } - } - } catch (Exception e) { - throw new RuntimeException("Scan & register discord4j classes failed", e); - } - - hints.reflection().registerType( - TypeReference.of("com.github.benmanes.caffeine.cache.SSLMS"), - builder -> builder.withMembers( - MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.INVOKE_DECLARED_METHODS, - MemberCategory.ACCESS_DECLARED_FIELDS - ) - ); - - hints.reflection().registerType( - TypeReference.of("com.github.benmanes.caffeine.cache.SSMS"), - builder -> builder.withMembers( - MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.INVOKE_DECLARED_METHODS, - MemberCategory.ACCESS_DECLARED_FIELDS - ) - ); - - hints.reflection().registerType( - TypeReference.of("com.github.benmanes.caffeine.cache.PSLMS"), - builder -> builder.withMembers( - MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.INVOKE_DECLARED_METHODS, - MemberCategory.ACCESS_DECLARED_FIELDS - ) - ); - - hints.reflection().registerType( - TypeReference.of("com.github.benmanes.caffeine.cache.PSMS"), - builder -> builder.withMembers( - MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, - MemberCategory.INVOKE_DECLARED_METHODS, - MemberCategory.ACCESS_DECLARED_FIELDS - ) - ); - - - // 用 System.out 打印(AOT 阶段 SLF4J 常常是 NOP) - System.out.println("[Discord4jJsonHints] registered classes: " + ok + ", skipped: " + skip); - - } - -} diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/OpenAPIConfiguration.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/OpenAPIConfiguration.java deleted file mode 100644 index be9563e..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/OpenAPIConfiguration.java +++ /dev/null @@ -1,36 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Configuration; - -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Contact; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.info.License; -import io.swagger.v3.oas.models.servers.Server; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.util.List; - -@Configuration -class OpenAPIConfiguration { - @Bean - OpenAPI CustomOpenAPIConfiguration() { - return new OpenAPI() - .info(new Info() - .title("TheWhiteDog9487的通用API们") - .summary("") - .description("") - .termsOfService("") - .contact(new Contact() - .name("TheWhiteDog9487") - .url("https://www.github.com/TheWhiteDog9487/WebAPI")) - .version("0.7.5") - .license(new License() - .name("WTFPL") - .url("https://spdx.org/licenses/WTFPL")) ) - .servers(List.of( - new Server() - .url("https://api.thewhitedog9487.xyz") - .description("主服务器"), - new Server() - .url("https://dev.thewhitedog9487.xyz") - .description("本地开发服务器(经过Cloudflare)") ) );} } \ No newline at end of file diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/Security/ApiKeyAuthenticationFilter.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/Security/ApiKeyAuthenticationFilter.java deleted file mode 100644 index 3e787c1..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/Security/ApiKeyAuthenticationFilter.java +++ /dev/null @@ -1,107 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Configuration.Security; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.filter.OncePerRequestFilter; -import xyz.thewhitedog9487.WebAPI.Controller.ResponseData; -import xyz.thewhitedog9487.WebAPI.Data.Entity.AccessLog; -import xyz.thewhitedog9487.WebAPI.Data.Repository.AccessLogRepository; - -import java.io.IOException; -import java.time.Instant; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -@Slf4j -@AllArgsConstructor -public class ApiKeyAuthenticationFilter extends OncePerRequestFilter { - - List ApiKeyList; - AccessLogRepository AccessLogRepository; - - @Override - public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String ApiKey = request.getHeader("X-API-Key"); - if (ApiKey == null) { - var ResponseBody = new ResponseData( - HttpStatus.UNAUTHORIZED.value(), - "API密钥验证失败,未传递X-API-Key请求头"); - response.setStatus(HttpStatus.UNAUTHORIZED.value()); - response.setCharacterEncoding("UTF-8"); - response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write(ResponseBody.ToJson()); - var Log = new AccessLog( - null, - request.getRequestId(), - request.getHeader("CF-Connecting-IP".toLowerCase() ), - request.getHeader("X-Forwarded-For".toLowerCase() ), - request.getRemoteAddr(), - request.getHeader("CF-IPCountry".toLowerCase() ), - ( request.getHeader("CF-IPCountry".toLowerCase() ) == null ) ? null : Locale.of(Locale.PRC.getLanguage(), request.getHeader("CF-IPCountry".toLowerCase() ), Locale.SIMPLIFIED_CHINESE.getVariant()).getISO3Country(), - request.getHeader("User-Agent"), - request.getMethod(), - request.getScheme(), - request.getProtocol(), - request.getRequestURL().toString(), - request.getQueryString(), - Collections.list( request.getHeaderNames() ) - .stream() - .map( name -> name + ": " + Collections.list(request.getHeaders(name)) ) - .reduce( ( a, b ) -> a + "\n" + b ) - .orElse(""), - Instant.now(), - HttpStatus.UNAUTHORIZED.value(), - ResponseBody.ToJson()); - AccessLogRepository.save(Log); - return; } - if (ApiKeyList.contains(ApiKey) == true) { - var Auth = new UsernamePasswordAuthenticationToken( - "", - ApiKey, - List.of(new SimpleGrantedAuthority("API"))); - SecurityContextHolder.getContext().setAuthentication(Auth); - filterChain.doFilter(request, response); - } else { - var ResponseBody = new ResponseData( - HttpStatus.UNAUTHORIZED.value(), - "API密钥验证失败", - Map.of("提供的密钥:", ApiKey)); - response.setStatus(HttpStatus.UNAUTHORIZED.value()); - response.setCharacterEncoding("UTF-8"); - response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write(ResponseBody.ToJson()); - var Log = new AccessLog( - null, - request.getRequestId(), - request.getHeader("CF-Connecting-IP".toLowerCase() ), - request.getHeader("X-Forwarded-For".toLowerCase() ), - request.getRemoteAddr(), - request.getHeader("CF-IPCountry".toLowerCase() ), - ( request.getHeader("CF-IPCountry".toLowerCase() ) == null ) ? null : Locale.of(Locale.PRC.getLanguage(), request.getHeader("CF-IPCountry".toLowerCase() ), Locale.SIMPLIFIED_CHINESE.getVariant()).getISO3Country(), - request.getHeader("User-Agent"), - request.getMethod(), - request.getScheme(), - request.getProtocol(), - request.getRequestURL().toString(), - request.getQueryString(), - Collections.list( request.getHeaderNames() ) - .stream() - .map( name -> name + ": " + Collections.list(request.getHeaders(name)) ) - .reduce( ( a, b ) -> a + "\n" + b ) - .orElse(""), - Instant.now(), - HttpStatus.UNAUTHORIZED.value(), - ResponseBody.ToJson() ); - AccessLogRepository.save(Log); - return; } } } - diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/SpringSecurityConfiguration.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/SpringSecurityConfiguration.java deleted file mode 100644 index 220226c..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Configuration/SpringSecurityConfiguration.java +++ /dev/null @@ -1,51 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Configuration; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.annotation.Order; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import xyz.thewhitedog9487.WebAPI.Configuration.Security.ApiKeyAuthenticationFilter; -import xyz.thewhitedog9487.WebAPI.Data.Repository.AccessLogRepository; - -import java.util.List; - -@Configuration -class SpringSecurityConfiguration { - - @Autowired List ApiKeyList; - @Autowired AccessLogRepository AccessLogRepository; - - /** - * @see ApiKeyAuthenticationFilter#doFilterInternal(HttpServletRequest, HttpServletResponse, FilterChain) - */ - @Order(1) - @Bean - SecurityFilterChain RequireAPIKey(HttpSecurity Security) throws Exception { - Security - .securityMatcher("/message/**", "/accesslog/**") - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(AuthorizationManagerRequestMatcherRegistry -> { - AuthorizationManagerRequestMatcherRegistry - .anyRequest() - .authenticated(); }) - .addFilterBefore(new ApiKeyAuthenticationFilter(ApiKeyList, AccessLogRepository), UsernamePasswordAuthenticationFilter.class); - return Security.build(); } - - @Order(2) - @Bean - SecurityFilterChain PermitAll(HttpSecurity Security) throws Exception { - Security - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(AuthorizationManagerRequestMatcherRegistry -> { - AuthorizationManagerRequestMatcherRegistry - .anyRequest() - .permitAll(); }); - return Security.build(); } -} \ No newline at end of file diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/AccessLog.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/AccessLog.java deleted file mode 100644 index 88e77bb..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/AccessLog.java +++ /dev/null @@ -1,126 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.RequestMapping; -import xyz.thewhitedog9487.WebAPI.Data.Repository.AccessLogRepository; - -import java.util.List; - -@Tag(name = "日志相关") -@RestController -@RequestMapping("/accesslog") -class AccessLog { - @Autowired AccessLogRepository AccessLogRepository; - - @Operation(summary = "获取访问日志数据库内所有的记录", - description = "这是一个私有API,需要在请求头中提供X-API-Key以进行身份验证") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", - description = "成功获取到数据", - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - array = @ArraySchema( - minItems = 0, - schema = @Schema( - implementation = xyz.thewhitedog9487.WebAPI.Data.Entity.AccessLog.class ) ), - examples = @ExampleObject(value = """ - [ - { - "ID": 18, - "RequestID": "3", - "CF_Connecting_IP": "223.87.14.146", - "X_Forwarded_For": "223.87.14.146, 141.101.99.156", - "RemoteAddress": "192.168.128.11", - "CF_IPCountry": "CN", - "ISO3166": "CHN", - "UserAgent": "Mozilla/5.0 (Linux; Android 10; SM-A202F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Mobile Safari/537.36", - "HttpMethod": "GET", - "Protocol": "http", - "ProtocolVersion": "HTTP/1.0", - "URL": "http://dev.thewhitedog9487.xyz/", - "QueryString": null, - "Header": "host: [dev.thewhitedog9487.xyz]\\nx-real-ip: [141.101.99.156]\\nx-forwarded-for: [223.87.14.146, 141.101.99.156]\\nx-forwarded-proto: [https]\\nconnection: [close]\\ncf-ray: [986883191fd5ed0b-LHR]\\nuser-agent: [Mozilla/5.0 (Linux; Android 10; SM-A202F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Mobile Safari/537.36]\\naccept-encoding: [gzip, br]\\nreferer: [https://www.baidu.com]\\ncdn-loop: [cloudflare; loops=1]\\ncf-connecting-ip: [223.87.14.146]\\ncf-ipcountry: [CN]\\ncf-visitor: [{\\"scheme\\":\\"https\\"}]", - "Timestamp": "2025-09-29T03:49:42.766Z", - "ResponseStatusCode": 302, - "ResponseBody": "" - }, - { - "ID": 261, - "RequestID": "31", - "CF_Connecting_IP": "2409:8962:77a:49c:a579:f5af:58a3:e2c", - "X_Forwarded_For": "2409:8962:77a:49c:a579:f5af:58a3:e2c, 172.71.150.157", - "RemoteAddress": "192.168.128.11", - "CF_IPCountry": "CN", - "ISO3166": "CHN", - "UserAgent": "IntelliJ HTTP Client/IntelliJ IDEA 2025.2.3", - "HttpMethod": "GET", - "Protocol": "http", - "ProtocolVersion": "HTTP/1.0", - "URL": "http://dev.thewhitedog9487.xyz/accesslog", - "QueryString": null, - "Header": "host: dev.thewhitedog9487.xyz\\nx-real-ip: 172.71.150.157\\nx-forwarded-for: 2409:8962:77a:49c:a579:f5af:58a3:e2c, 172.71.150.157\\nx-forwarded-proto: https\\nconnection: close\\nx-api-key: asdasdaFSEA452453sdcv\\nuser-agent: IntelliJ HTTP Client/IntelliJ IDEA 2025.2.3\\naccept: */*\\ncf-ray: 98cc7d6aaea1c37a-SEA\\naccept-encoding: gzip, br\\ncdn-loop: cloudflare; loops=1\\ncf-connecting-ip: 2409:8962:77a:49c:a579:f5af:58a3:e2c\\ncf-ipcountry: CN\\ncf-visitor: {\\"scheme\\":\\"https\\"}\\ncookie: JSESSIONID=A49527B913170D86A5544A252BD31040", - "Timestamp": "2025-10-11T07:02:05.718Z", - "ResponseStatusCode": 200, - "ResponseBody": null - }, - { - "ID": 263, - "RequestID": "35", - "CF_Connecting_IP": "2409:8962:77a:49c:a579:f5af:58a3:e2c", - "X_Forwarded_For": "2409:8962:77a:49c:a579:f5af:58a3:e2c, 108.162.246.209", - "RemoteAddress": "192.168.128.11", - "CF_IPCountry": "CN", - "ISO3166": "CHN", - "UserAgent": "IntelliJ HTTP Client/IntelliJ IDEA 2025.2.3", - "HttpMethod": "GET", - "Protocol": "http", - "ProtocolVersion": "HTTP/1.0", - "URL": "http://dev.thewhitedog9487.xyz/ip/ip", - "QueryString": null, - "Header": "host: dev.thewhitedog9487.xyz\\nx-real-ip: 108.162.246.209\\nx-forwarded-for: 2409:8962:77a:49c:a579:f5af:58a3:e2c, 108.162.246.209\\nx-forwarded-proto: https\\nconnection: close\\naccept-encoding: gzip, br\\nuser-agent: IntelliJ HTTP Client/IntelliJ IDEA 2025.2.3\\naccept: */*\\ncf-ray: 98cc9ec12c48a1a5-SEA\\ncdn-loop: cloudflare; loops=1\\ncf-connecting-ip: 2409:8962:77a:49c:a579:f5af:58a3:e2c\\ncf-ipcountry: CN\\ncf-visitor: {\\"scheme\\":\\"https\\"}\\ncookie: JSESSIONID=A49527B913170D86A5544A252BD31040", - "Timestamp": "2025-10-11T07:24:51.504Z", - "ResponseStatusCode": 200, - "ResponseBody": "2409:8962:77a:49c:a579:f5af:58a3:e2c" - } - ] - """) ) ), - @ApiResponse(responseCode = "401", - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = ResponseData.class), - examples = { - @ExampleObject(value = "{\n" + - " \"code\": 401,\n" + - " \"message\": \"请求缺少必要的头部信息\",\n" + - " \"data\": {\n" + - " \"缺失的头部\": \"X-API-Key\"\n" + - " }\n" + - "}", name = "缺少头部信息"), - @ExampleObject(value = "{\n" + - " \"code\": 401,\n" + - " \"message\": \"API密钥验证失败\",\n" + - " \"data\": {\n" + - " \"提供的密钥\": \"123456\"\n" + - " }\n" + - "}", name = "密钥不正确") } ), - description = "API密钥验证失败") } ) - @GetMapping - List GetFullLog( - @Parameter(description = "用于身份验证的API密钥", in = ParameterIn.HEADER, required = true, example = "ds1858dscc8745sfwe") - @RequestHeader("X-API-Key") String ApiKey){ - return AccessLogRepository.findAll(); } -} diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Filter/LogClientInfo.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Filter/LogClientInfo.java deleted file mode 100644 index 8611e94..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Filter/LogClientInfo.java +++ /dev/null @@ -1,79 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Controller.Filter; - -import jakarta.servlet.*; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import org.springframework.web.util.ContentCachingResponseWrapper; -import xyz.thewhitedog9487.WebAPI.Data.Entity.AccessLog; -import xyz.thewhitedog9487.WebAPI.Data.Repository.AccessLogRepository; - -import java.io.IOException; -import java.time.Instant; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.concurrent.locks.Lock; - -@Slf4j -@Component -public class LogClientInfo implements Filter { - public static List IgnorePaths = List.of( - "/favicon.ico", - "/swagger-ui", - "/v3/api-docs", - "/accesslog"); - - @Autowired AccessLogRepository AccessLogRepository; - @Autowired Lock SQLiteWriteLock; - - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - HttpServletRequest HttpServletRequest = (HttpServletRequest) request; - HttpServletResponse HttpServletResponse = (HttpServletResponse) response; - var Response = new ContentCachingResponseWrapper(HttpServletResponse); - var Log = new AccessLog( - null, - HttpServletRequest.getRequestId(), - HttpServletRequest.getHeader("CF-Connecting-IP"), - HttpServletRequest.getHeader("X-Forwarded-For"), - HttpServletRequest.getRemoteAddr(), - HttpServletRequest.getHeader("CF-IPCountry"), - ( HttpServletRequest.getHeader("CF-IPCountry") == null ) ? null : Locale.of(Locale.PRC.getLanguage(), HttpServletRequest.getHeader("CF-IPCountry"), Locale.SIMPLIFIED_CHINESE.getVariant()).getISO3Country(), - HttpServletRequest.getHeader("User-Agent" ), - HttpServletRequest.getMethod(), - HttpServletRequest.getScheme(), - HttpServletRequest.getProtocol(), - HttpServletRequest.getRequestURL().toString(), - HttpServletRequest.getQueryString(), - Collections.list( HttpServletRequest.getHeaderNames() ) - .stream() - .map( name -> name + ": " + Collections.list(HttpServletRequest.getHeaders(name)).getFirst() ) - .reduce( ( a, b ) -> a + "\n" + b ) - .orElse(""), - Instant.now(), - null, - null ); - try { - SQLiteWriteLock.lock(); - Log = AccessLogRepository.save(Log); } - catch (Exception e) { - log.error(e.getLocalizedMessage()); } - finally { - SQLiteWriteLock.unlock(); } - chain.doFilter(request, Response); - var ResponseBody = IgnorePaths.stream() - .anyMatch( path -> HttpServletRequest.getRequestURI().startsWith(path) ) - ? null : new String( Response.getContentAsByteArray(), Response.getCharacterEncoding() ); - Log.setResponseStatusCode(HttpServletResponse.getStatus()); - Log.setResponseBody(ResponseBody); - try { - SQLiteWriteLock.lock(); - AccessLogRepository.save(Log); } - catch (Exception e) { - log.error(e.getLocalizedMessage()); } - finally { - SQLiteWriteLock.unlock(); } - Response.copyBodyToResponse(); } } \ No newline at end of file diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Filter/SetDefaultContentType.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Filter/SetDefaultContentType.java deleted file mode 100644 index f35c73e..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Filter/SetDefaultContentType.java +++ /dev/null @@ -1,23 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Controller.Filter; - -import jakarta.servlet.*; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; - -@Component -class SetDefaultContentType extends OncePerRequestFilter { - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (response.getContentType() == null) { - response.setCharacterEncoding(StandardCharsets.UTF_8.name()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); } - filterChain.doFilter(request, response); - } -} diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Filter/SetMDCRequestID.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Filter/SetMDCRequestID.java deleted file mode 100644 index aedd1a8..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Filter/SetMDCRequestID.java +++ /dev/null @@ -1,27 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Controller.Filter; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.slf4j.MDC; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -@Component -@Order(Ordered.HIGHEST_PRECEDENCE) -// ↑ 拉高优先级,确保在日志记录之前设置MDC,不然和没设置就没有区别了 -class SetMDCRequestID extends OncePerRequestFilter { - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - try { - MDC.put("RequestID", request.getRequestId()); - filterChain.doFilter(request, response); - } finally { - // ↓ 防止内存泄漏 - MDC.remove("RequestID"); } } } \ No newline at end of file diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/IP.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/IP.java deleted file mode 100644 index af1cfc6..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/IP.java +++ /dev/null @@ -1,87 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.util.MultiValueMap; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.RequestMapping; - -import java.util.Map; - -@Tag(name="请求客户端IP地址相关") -@Slf4j -@RestController -@RequestMapping("/ip") -class IP { - @Operation(summary = "获取请求客户端的IP地址", description = """ - 优先级: - 1. CF-Connecting-IP - 2. X-Forwarded-For - 3. 直接使用请求的远程地址 - """) - @ApiResponse(responseCode = "200", - content = @Content( - mediaType = MediaType.TEXT_PLAIN_VALUE, - schema = @Schema(implementation = String.class), - examples = @ExampleObject(value = "78.141.226.247")), - description = "成功获取到IP地址,内容为纯文本格式的IP地址") - @GetMapping({"/ip", "", "/"}) - ResponseEntity GetIP(@RequestHeader("CF-COnnecting-IP") String Header_CFConnectingIP, - @RequestHeader("X-Forwarded-For") String Header_XForwardedFor, - HttpServletRequest Request) { - if (Header_CFConnectingIP != null) { - return new ResponseEntity<>(Header_CFConnectingIP, - new HttpHeaders(MultiValueMap.fromSingleValue(Map.of("Content-Type", "text/plain;charset=UTF-8"))), - HttpStatus.OK); } - else if (Header_XForwardedFor != null) { - Header_XForwardedFor = Header_XForwardedFor.split(",")[0].trim(); - return new ResponseEntity<>(Header_XForwardedFor, - new HttpHeaders(MultiValueMap.fromSingleValue(Map.of("Content-Type", "text/plain;charset=UTF-8"))), - HttpStatus.OK); } - else { - return new ResponseEntity<>(Request.getRemoteAddr(), - new HttpHeaders(MultiValueMap.fromSingleValue(Map.of("Content-Type", "text/plain;charset=UTF-8"))), - HttpStatus.OK); } } - - @Operation(summary = "获取请求客户端的ISO 3166-1 alpha-2国家代码", description = """ - 依赖Cloudflare的CF-IPCountry头部 -
- 如果请求没有经过Cloudflare,则会返回"未找到CF-IPCountry头部" - """) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", - content = @Content( - mediaType = MediaType.TEXT_PLAIN_VALUE, - schema = @Schema(implementation = String.class), - examples = @ExampleObject(value = "HK") ), - description = "成功获取到ISO 3166-1 alpha-2国家代码,内容为纯文本格式的国家代码"), - @ApiResponse(responseCode = "404", - content = @Content( - mediaType = MediaType.TEXT_PLAIN_VALUE, - schema = @Schema(implementation = String.class), - examples = { @ExampleObject(value = "未找到CF-IPCountry头部") } ), - description = "未找到CF-IPCountry头部,内容为纯文本格式的错误信息") } ) - @GetMapping("iso3166") - ResponseEntity GetISO3166(@RequestHeader("CF-IPCountry") String Header_CFIPCountry) { - if (Header_CFIPCountry != null) { - return new ResponseEntity<>(Header_CFIPCountry, - new HttpHeaders(MultiValueMap.fromSingleValue(Map.of("Content-Type", "text/plain;charset=UTF-8"))), - HttpStatus.OK); } - else { - return new ResponseEntity<>("未找到CF-IPCountry头部", - new HttpHeaders(MultiValueMap.fromSingleValue(Map.of("Content-Type", "text/plain;charset=UTF-8"))), - HttpStatus.NOT_FOUND); } } -} diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Message.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Message.java deleted file mode 100644 index e74446b..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/Message.java +++ /dev/null @@ -1,170 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Controller; - -import discord4j.common.util.Snowflake; -import discord4j.core.GatewayDiscordClient; -import discord4j.rest.http.client.ClientException; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.web.bind.MissingRequestHeaderException; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.Map; - -@Tag(name="远程消息处理相关") -@Slf4j -@RestController -@RequestMapping("/message") -class Message { - @Autowired GatewayDiscordClient DiscordBotClient; - - @Schema(description = "包含了发送消息的数据包") - record PostMessageData( - @Schema(description = "Discord频道ID", example = "1398192763845214239") Long ChannelID, - @Schema(description = "要发送的消息内容", example = "成功完成备份,最新文件时间为2025 08 21") String Content) {} - - @Operation(summary = "通过Discord Bot向指定频道发送消息", description = """ - 这是一个私有API,需要在请求头中提供X-API-Key以进行身份验证 -
- 需要在请求体中提供频道ID和消息内容 - """) - @ApiResponses(value = { - @ApiResponse(responseCode = "201", - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = ResponseData.class), - examples = @ExampleObject(value = "{\n" + - " \"code\": 201,\n" + - " \"message\": \"消息发送成功\",\n" + - " \"data\": {\n" + - " \"新ID\": \"1407848258813825135\",\n" + - " \"内容\": \"写点什么好呢\",\n" + - " \"频道ID\": \"1398192763845214239\"\n" + - " }\n" + - "}", name = "成功") ), - description = "消息发送成功,响应体中包含新消息的ID、频道ID和内容"), - @ApiResponse(responseCode = "400", - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = ResponseData.class), - examples = @ExampleObject(value = "{\n" + - " \"code\": 400,\n" + - " \"message\": \"请求体无法解析\",\n" + - " \"data\": {\n" + - " \"错误信息\": \"JSON parse error: Unexpected character ('}' (code 125)): was expecting double-quote to start field name\"\n" + - " }\n" + - "}", name = "请求体无法解析") ), - description = "客户端发送的请求存在问题,请检查响应的data字段以获取更多信息"), - @ApiResponse(responseCode = "401", - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = ResponseData.class), - examples = { - @ExampleObject(value = "{\n" + - " \"code\": 401,\n" + - " \"message\": \"请求缺少必要的头部信息\",\n" + - " \"data\": {\n" + - " \"缺失的头部\": \"X-API-Key\"\n" + - " }\n" + - "}", name = "缺少头部信息"), - @ExampleObject(value = "{\n" + - " \"code\": 401,\n" + - " \"message\": \"API密钥验证失败\",\n" + - " \"data\": {\n" + - " \"提供的密钥\": \"123456\"\n" + - " }\n" + - "}", name = "密钥不正确") } ), - description = "API密钥验证失败"), - @ApiResponse(responseCode = "500", - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = ResponseData.class), - examples = { @ExampleObject(value = "{\n" + - " \"code\": 500,\n" + - " \"message\": \"消息发送失败\",\n" + - " \"data\": {\n" + - " \"内容\": \"芝士异常\",\n" + - " \"错误信息\": \"POST /channels/1398192763845214/messages returned 404 Not Found with response {code=10003, message=Unknown Channel}\",\n" + - " \"频道ID\": \"1398192763845214\"\n" + - " }\n" + - "}", name = "消息发送失败"), - @ExampleObject(value = "{\n" + - " \"code\": 500,\n" + - " \"message\": \"处理请求时发生未知错误\",\n" + - " \"data\": {\n" + - " \"错误信息\": \"\"\n" + - " }\n" + - "}", name = "未知错误") } ), - description = """ - 消息发送失败,可能是由于Discord服务器问题或其他内部错误,请查看响应Body以确定原因 -
- 另外,所有未被针对性处理的异常都会触发此响应 - """) } ) - @PostMapping("/discord") - ResponseEntity DiscordPush( - @Parameter(description = "用于身份验证的API密钥", in = ParameterIn.HEADER, required = true, example = "ds1858dscc8745sfwe") - @RequestHeader("X-API-Key") String ApiKey, - @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "包含频道ID和消息内容的JSON对象", - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = PostMessageData.class) ) ) - @RequestBody PostMessageData RequestBody){ - - var ChannelID = Snowflake.of(RequestBody.ChannelID); - try { - var MessageData = DiscordBotClient - .rest() - .getChannelById(ChannelID) - .createMessage(RequestBody.Content) - .block(); - return new ResponseEntity<>(new ResponseData( - HttpStatus.CREATED.value(), - "消息发送成功", - Map.of( - "新ID", MessageData.id().asString(), - "频道ID", ChannelID.asString(), - "内容", RequestBody.Content)), HttpStatus.CREATED); - } catch (ClientException e) { - return new ResponseEntity<>(new ResponseData( - HttpStatus.INTERNAL_SERVER_ERROR.value(), - "消息发送失败", - Map.of( - "错误信息", e.getMessage(), - "频道ID", ChannelID.asString(), - "内容", RequestBody.Content)), HttpStatus.INTERNAL_SERVER_ERROR);}} - - @ExceptionHandler(MissingRequestHeaderException.class) - ResponseEntity HandleMissingHeader(MissingRequestHeaderException Exception, HttpServletRequest Request) { - return new ResponseEntity<>(new ResponseData( - HttpStatus.BAD_REQUEST.value(), - "请求缺少必要的头部信息", - Map.of("缺失的头部", Exception.getHeaderName())), HttpStatus.BAD_REQUEST); } - @ExceptionHandler(HttpMessageNotReadableException.class) - ResponseEntity HandleMessageNotReadable(HttpMessageNotReadableException Exception, HttpServletRequest Request) { - return new ResponseEntity<>(new ResponseData( - HttpStatus.BAD_REQUEST.value(), - "请求体无法解析", - Map.of("错误信息", Exception.getMessage())), HttpStatus.BAD_REQUEST); } - @ExceptionHandler(Exception.class) - ResponseEntity HandleOtherException(Exception Exception, HttpServletRequest Request) { - return new ResponseEntity<>(new ResponseData( - HttpStatus.INTERNAL_SERVER_ERROR.value(), - "处理请求时发生未知错误", - Map.of("错误信息", Exception.getMessage() == null ? "" : Exception.getMessage()) ), HttpStatus.INTERNAL_SERVER_ERROR); } -} diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/RedirectRootToSwaggerWebUI.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/RedirectRootToSwaggerWebUI.java deleted file mode 100644 index a488e25..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/RedirectRootToSwaggerWebUI.java +++ /dev/null @@ -1,16 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Controller; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.servlet.view.RedirectView; - -@Slf4j -@Controller -class RedirectRootToSwaggerWebUI { - static String RedirectTo = "/swagger-ui/index.html"; - - @GetMapping("/") - RedirectView Redirect(){ - log.info("正在将访问 / 的请求重定向到 {}", RedirectTo); - return new RedirectView(RedirectTo); } } \ No newline at end of file diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/ResponseData.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/ResponseData.java deleted file mode 100644 index 22d313b..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Controller/ResponseData.java +++ /dev/null @@ -1,35 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Controller; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.swagger.v3.oas.annotations.media.Schema; - -import java.util.List; - -@Schema(description = "所有API通用的标准响应格式") -public record ResponseData( - @Schema(description = "内部用响应代码" ,example = "201") int code, - @Schema(description = "人类可读说明信息", example = "消息发送成功") Object message, - @Schema(description = "具体的响应数据", example = "{\n" + - " \"新ID\": \"1407848258813825135\",\n" + - " \"内容\": \"写点什么好呢\",\n" + - " \"频道ID\": \"1398192763845214239\"\n" + - " }") Object data) { - static ObjectMapper JsonMapper = new ObjectMapper(); - public ResponseData(int code) { - this(code, null, null); } - public ResponseData(int code, Object message) { - this(code, List.of(message), null); } - - public String ToJson(){ - try { - return JsonMapper.writeValueAsString(this); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); } } - - public static String AnyToJson(Object obj){ - try { - return JsonMapper.writeValueAsString(obj); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); } } -} diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Data/Entity/AccessLog.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Data/Entity/AccessLog.java deleted file mode 100644 index 4924fe1..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Data/Entity/AccessLog.java +++ /dev/null @@ -1,117 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Data.Entity; - -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonProperty; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.time.Instant; - -@JsonAutoDetect( - fieldVisibility = JsonAutoDetect.Visibility.ANY, - getterVisibility = JsonAutoDetect.Visibility.NONE, - isGetterVisibility = JsonAutoDetect.Visibility.NONE, - setterVisibility = JsonAutoDetect.Visibility.NONE, - creatorVisibility = JsonAutoDetect.Visibility.NONE) -@Getter -@Setter -@AllArgsConstructor -@NoArgsConstructor -@Entity -@Table -@Schema(description = "访问日志条目") -public class AccessLog{ - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(nullable = false) - @JsonProperty("ID") - @Schema(description = "主键", example = "1") - Long ID; - - @JsonProperty("RequestID") - @Schema(description = "Servlet请求ID", example = "0") - String RequestID; - - @JsonProperty("CF_Connecting_IP") - @Column(name = "\"CF-Connecting-IP\"") - @Schema(description = "Cloudflare提供的请求IP", example = "2409:9802:74a3:6eff:3cae:5cba:25ac:788a") - String CF_Connecting_IP; - - @JsonProperty("X_Forwarded_For") - @Column(name = "\"X-Forwarded-For\"") - @Schema(description = "X-Forwarded-For请求头提供的请求IP", example = "2409:9802:74a3:6eff:3cae:5cba:25ac:788a, 108.162.245.89") - String X_Forwarded_For; - - @JsonProperty("RemoteAddress") - @Schema(description = "Servlet报告的请求IP", example = "192.168.128.11") - String RemoteAddress; - - @JsonProperty("CF_IPCountry") - @Column(name = "\"CF-IPCountry\"") - @Schema(description = "Cloudflare提供的请求国家代码", example = "CN") - String CF_IPCountry; - - @JsonProperty("ISO3166") - @Schema(description = "Java内部根据CF-IPCountry得到的对应ISO3166代码", example = "CHN") - String ISO3166; - - @JsonProperty("UserAgent") - @Schema(description = "标准HTTP User-Agent请求头", example = "IntelliJ HTTP Client/IntelliJ IDEA 2025.2.2") - String UserAgent; - - @JsonProperty("HttpMethod") - @Schema(description = "HTTP请求方法", example = "GET") - String HttpMethod; - - @JsonProperty("Protocol") - @Schema(description = "请求协议", example = "http") - String Protocol; - - @JsonProperty("ProtocolVersion") - @Schema(description = "协议版本", example = "HTTP/1.1") - String ProtocolVersion; - - @JsonProperty("URL") - @Schema(description = "请求的完整URL", example = "http://dev.thewhitedog9487.xyz/ip/ip") - String URL; - - @JsonProperty("QueryString") - @Schema(description = "查询字符串", example = "") - String QueryString; - - @JsonProperty("Header") - @Column(columnDefinition = "text") - @Schema(description = "所有HTTP请求头的JSON化字符串", example = "host: dev.thewhitedog9487.xyz\n" + - "x-real-ip: 108.42.101.215\n" + - "x-forwarded-for: 2409:3e5a:642:1f64:a9d4:c96c:72d1:88ef, 108.42.101.215\n" + - "x-forwarded-proto: https\n" + - "connection: close\n" + - "x-api-key: 985651Fardsh6452453sdcv\n" + - "user-agent: IntelliJ HTTP Client/IntelliJ IDEA 2025.2.3\n" + - "accept: */*\n" + - "cf-ray: 98b487dc1dead465-SEA\n" + - "accept-encoding: gzip, br\n" + - "cdn-loop: cloudflare; loops=1\n" + - "cf-connecting-ip: 2409:3e5a:642:1f64:a9d4:c96c:72d1:88ef\n" + - "cf-ipcountry: CN\n" + - "cf-visitor: {\"scheme\":\"https\"}\n" + - "cookie: JSESSIONID=59DB3634C2FB313AFFEE54D4F933F101") - String Header; - - @JsonProperty("Timestamp") - @Schema(description = "请求时间的Unix时间戳", example = "1758437539600") - Instant Timestamp; - - @JsonProperty("ResponseStatusCode") - @Schema(description = "响应的HTTP状态码", example = "200") - Integer ResponseStatusCode; - - @JsonProperty("ResponseBody") - @Column(columnDefinition = "text") - @Schema(description = "响应内容", example = "") - String ResponseBody; -} \ No newline at end of file diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Data/Repository/AccessLogRepository.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Data/Repository/AccessLogRepository.java deleted file mode 100644 index 57aa934..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Data/Repository/AccessLogRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Data.Repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import xyz.thewhitedog9487.WebAPI.Data.Entity.AccessLog; - -public interface AccessLogRepository extends JpaRepository { - -} \ No newline at end of file diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/GlobalSharedBean.java b/src/main/java/xyz/thewhitedog9487/WebAPI/GlobalSharedBean.java deleted file mode 100644 index 7122a3b..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/GlobalSharedBean.java +++ /dev/null @@ -1,54 +0,0 @@ -package xyz.thewhitedog9487.WebAPI; - -import discord4j.core.DiscordClientBuilder; -import discord4j.core.GatewayDiscordClient; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.RandomStringUtils; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; - -@Slf4j -@Component -class GlobalSharedBean { - @Value("${Discord_Bot_Token:}") String DiscordBotToken; - - @Bean - GatewayDiscordClient GetDiscordClient(){ - if (DiscordBotToken.isEmpty()) { - log.error("未设置环境变量Discord_Bot_Token,请检查配置。"); - System.exit(-1); } - return DiscordClientBuilder.create(DiscordBotToken) - .build() - .login() - .block();} - - @Bean - List ApiKeyList() { - Path FileName = Path.of("API密钥.txt"); - try { - return Files.readAllLines(FileName, StandardCharsets.UTF_8); - } catch (IOException e) { - try { - Files.createFile(FileName); - var DefaultPassword = RandomStringUtils.secure().nextAlphanumeric(30); - Files.writeString(FileName, DefaultPassword, StandardCharsets.UTF_8); - log.info("已生成API密钥文件,默认密钥为:{}", DefaultPassword); - return List.of(DefaultPassword); - } catch (IOException e1) { - log.error("API密钥文件不存在且无法创建API密钥文件。", e1); - System.exit(-1); } - return null; } } - - @Bean - Lock SQLiteWriteLock(){ - return new ReentrantLock(); } -} diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/Startup/SystemdInstallerManager.java b/src/main/java/xyz/thewhitedog9487/WebAPI/Startup/SystemdInstallerManager.java deleted file mode 100644 index 6769e22..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/Startup/SystemdInstallerManager.java +++ /dev/null @@ -1,178 +0,0 @@ -package xyz.thewhitedog9487.WebAPI.Startup; - -import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.system.ApplicationHome; - -import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.List; -import java.util.NoSuchElementException; - -@Slf4j -public class SystemdInstallerManager{ - static String TemplateContent = """ - [Unit] - Description=WebAPI - After=network.target - - [Service] - User=%s - WorkingDirectory=%s - ExecStart=%s - Restart=always - Type=simple - Environment="%s" - - [Install] - WantedBy=multi-user.target - """; - - static ApplicationHome AppHome = new ApplicationHome( SystemdInstallerManager.class ); - static String CurrentOS = System.getProperty("os.name"); - static String CurrentUser = System.getProperty("user.name"); - static Path CurrentExecutableFilePath = GetExecutblePath(); - static Path WorkingDirectory = AppHome - .getDir() - .toPath(); - static Path ServiceFileDirectory = Path.of("/etc/systemd/system/"); - static Path ServiceFileName = Path.of("WebAPI.service"); - static String SoftLinkFileName = "Current"; - static Path SymbolicLinkPath = WorkingDirectory.resolve(SoftLinkFileName); - static String DiscordBotToken = System.getenv("Discord_Bot_Token"); - /** - * 检测当前运行环境是否为GraalVM生成的Native Image - */ - static boolean IsImageCode(){ - try { - return (boolean) Class.forName("org.graalvm.nativeimage.ImageInfo") - .getMethod("inImageCode") - .invoke(null); - } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | ClassNotFoundException e) { - return false; } } - - /** - * 获取当前可执行文件的路径。 - *
- * 如果是通过GraalVM生成的Native Image,则返回可执行文件的路径 - *
- * 如果是直接用JRE运行的JAR包,则返回JAR文件的路径 - */ - static Path GetExecutblePath(){ - try { - var IsImageCode = IsImageCode(); - log.debug("IsImageCode: {}", IsImageCode); - if( IsImageCode == true ){ - Object ExecutablePath = Class.forName("org.graalvm.nativeimage.ProcessProperties") - .getMethod("getExecutableName") - .invoke(null); - log.debug("ExecutablePath: {}", ExecutablePath); - return Path.of((String) ExecutablePath); } - else{ - // ↓ 没有错误,但是获取Jar包位置的代码在catch里面,扔一个异常出去以进入catch分支 - // ↓ 这个主要是针对使用GraalVM的JVM而不是Native Image的情况 - throw new RuntimeException("Not Image Code"); } - } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | ClassNotFoundException | - RuntimeException e) { - try { - return AppHome - .getSource() - .toPath() - .toRealPath(); - } catch (IOException ex) { - throw new RuntimeException(ex); } } } - - @SneakyThrows - public static void ProcessArguments(String[] CommandLineArguments) { - log.debug("CommandLineArguments: {}", Arrays.toString(CommandLineArguments)); - for (String Argument : CommandLineArguments) { - if ( List.of("install", "--install").contains(Argument) == true ) { - /* - 在Native Image / Jar文件旁边生成指向自身的,名为Current的软连接 - 然后在软连接旁生成systemd服务文件,执行目标指向软连接 - */ - try { - DiscordBotToken = Arrays.stream(CommandLineArguments) - .filter(s -> s.startsWith("--Discord_Bot_Token=") == true) - .toList() - .getFirst() - .substring("--" - .length()); } - catch (NoSuchElementException _){ } - if ( DiscordBotToken == null ) { - log.error("必须通过\"--Discord_Bot_Token=\"参数或Discord_Bot_Token环境变量提供Discord机器人的令牌以使本程序正常工作。"); - throw new IllegalArgumentException("缺少必须的--Discord_Bot_Token参数"); } - log.info("开始安装systemd服务"); - log.debug("SystemProperties.os.name: {}", CurrentOS); - if ( CurrentOS.startsWith("Linux") ) { - log.debug("SystemProperties.user.name: {}", CurrentUser); - if (CurrentUser.equals("root") == false) { - log.error("只有root才有权在系统层级安装systemd服务,但是JVM报告的当前用户是{}", CurrentUser); - throw new IllegalStateException("请使用root用户运行此命令以安装为systemd服务"); } - log.debug("CurrentExecutableFilePath: {}", CurrentExecutableFilePath); - log.debug("WorkingDirectory: {}", WorkingDirectory); - log.debug("SymbolicLinkPath: {}", SymbolicLinkPath); - new ProcessBuilder("systemctl", "stop", ServiceFileName.toString()).start().waitFor(); - log.info("已停止systemd服务: {}", ServiceFileName); - Files.deleteIfExists(SymbolicLinkPath); - Files.createSymbolicLink(SymbolicLinkPath, CurrentExecutableFilePath); - log.info("成功创建指向当前程序的软链接: {} -> {}", SymbolicLinkPath, CurrentExecutableFilePath); - Path CurrentJavaPath = null; - var IsImageCode = IsImageCode(); - if ( IsImageCode == false ) { - CurrentJavaPath = Path.of("/proc/self/exe").toRealPath(); - log.debug("CurrentJavaPath: {}", CurrentJavaPath); } - var ExecCommand = (IsImageCode == true) ? - SymbolicLinkPath.toString() : - CurrentJavaPath + " -jar " + SymbolicLinkPath.toString(); - log.debug("ExecCommand: {}", ExecCommand); - var ServiceFileContent = String.format( - TemplateContent, - CurrentUser, - WorkingDirectory, - ExecCommand, - "Discord_Bot_Token=" + DiscordBotToken); - log.debug("ServiceFileContent: \n{}", ServiceFileContent); - Files.deleteIfExists( WorkingDirectory.resolve(ServiceFileName) ); - Files.writeString(WorkingDirectory.resolve(ServiceFileName), ServiceFileContent); - log.info("成功创建systemd服务文件: {}", WorkingDirectory.resolve(ServiceFileName)); - Files.deleteIfExists( ServiceFileDirectory.resolve(ServiceFileName) ); - Files.createSymbolicLink(ServiceFileDirectory.resolve(ServiceFileName), WorkingDirectory.resolve(ServiceFileName)); - log.info("成功创建指向服务文件的软链接: {} -> {}", ServiceFileDirectory.resolve(ServiceFileName), WorkingDirectory.resolve(ServiceFileName)); - new ProcessBuilder("systemctl", "daemon-reload").start().waitFor(); - log.info("已完成systemd守护进程重载"); - new ProcessBuilder("systemctl", "enable", ServiceFileName.toString()).start().waitFor(); - log.info("已启用systemd服务: {}", ServiceFileName); - new ProcessBuilder("systemctl", "start", ServiceFileName.toString()).start().waitFor(); - log.info("已启动systemd服务: {}", ServiceFileName); } - else if ( CurrentOS.startsWith("Windows") ) { - log.error("只有Linux才能使用systemd,但是JVM报告的当前系统是{}", CurrentOS); - throw new IllegalStateException("Windows系统不支持systemd服务管理,暂不支持在Windows上自动管理服务。"); } - System.exit(0); } - else if ( List.of("uninstall", "--uninstall").contains(Argument) == true ) { - log.info("开始卸载systemd服务"); - log.debug("SystemProperties.os.name: {}", CurrentOS); - if ( CurrentOS.startsWith("Windows") ) { - log.error("只有Linux才能使用systemd,但是JVM报告的当前系统是{}", CurrentOS); - throw new IllegalStateException("Windows系统不支持systemd服务管理,暂不支持在Windows上自动管理服务。"); } - else if ( CurrentOS.startsWith("Linux") ) { - if (CurrentUser.equals("root") == false) { - log.error("只有root才有权在系统层级卸载systemd服务,但是JVM报告的当前用户是{}", CurrentUser); - throw new IllegalStateException("请使用root用户运行此命令以卸载systemd服务"); } - new ProcessBuilder("systemctl", "stop", ServiceFileName.toString()).start().waitFor(); - log.info("已停止systemd服务: {}", ServiceFileName); - new ProcessBuilder("systemctl", "disable", ServiceFileName.toString()).start().waitFor(); - log.info("已禁用systemd服务: {}", ServiceFileName); - Files.deleteIfExists(ServiceFileDirectory.resolve(ServiceFileName)); - log.info("成功删除systemd服务文件: {}", ServiceFileDirectory.resolve(ServiceFileName)); - Files.deleteIfExists(WorkingDirectory.resolve(ServiceFileName)); - log.info("成功删除程序旁的服务文件: {}", WorkingDirectory.resolve(ServiceFileName)); - Files.deleteIfExists(SymbolicLinkPath); - log.info("成功删除指向程序自身的软链接: {}", SymbolicLinkPath); - new ProcessBuilder("systemctl", "daemon-reload").start().waitFor(); - log.info("已完成systemd守护进程重载"); } - System.exit(0); } } } -} diff --git a/src/main/java/xyz/thewhitedog9487/WebAPI/WebApiApplication.java b/src/main/java/xyz/thewhitedog9487/WebAPI/WebApiApplication.java deleted file mode 100644 index 49acc67..0000000 --- a/src/main/java/xyz/thewhitedog9487/WebAPI/WebApiApplication.java +++ /dev/null @@ -1,16 +0,0 @@ -package xyz.thewhitedog9487.WebAPI; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import xyz.thewhitedog9487.WebAPI.Startup.SystemdInstallerManager; - -@SpringBootApplication -public class WebApiApplication { - public static void main(String[] args) { - //TODO: 从配置文件或环境变量读取并设置HTTP端口号 - var SpringApp = new SpringApplication(WebApiApplication.class); - SystemdInstallerManager.ProcessArguments(args); - SpringApp.run(args); - } - -} diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/BackgroundTask/ApiKeyFileManager.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/BackgroundTask/ApiKeyFileManager.kt new file mode 100644 index 0000000..563580d --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/BackgroundTask/ApiKeyFileManager.kt @@ -0,0 +1,63 @@ +package xyz.thewhitedog9487.WebAPI.BackgroundTask + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.apache.commons.lang3.RandomStringUtils +import org.springframework.stereotype.Component +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds +import java.util.concurrent.CopyOnWriteArraySet +import kotlin.io.path.absolute +import kotlin.system.exitProcess + +@Component +class ApiKeyManager { + private val Logger: KLogger = KotlinLogging.logger{} + internal val ApiKeyFile = Path.of("API密钥.txt").absolute() + + internal val ApiKeyListBackendField: CopyOnWriteArraySet = CopyOnWriteArraySet() + val ApiKeyList: Set get() = ApiKeyListBackendField + + init { + try { + if (Files.exists(ApiKeyFile)) { + ApiKeyListBackendField.clear() + ApiKeyListBackendField.addAll(Files.readAllLines(ApiKeyFile, StandardCharsets.UTF_8)) + Logger.info{"API密钥列表已完成加载,当前数量:${ApiKeyListBackendField.size}"} + } else { + try { + Files.createFile(ApiKeyFile) + val DefaultPassword = RandomStringUtils.secure().nextAlphanumeric(32) + Files.writeString(ApiKeyFile, DefaultPassword, StandardCharsets.UTF_8) + Logger.info{"已生成API密钥文件,默认密钥为:$DefaultPassword"} + ApiKeyListBackendField.clear() + ApiKeyListBackendField.add(DefaultPassword) + } catch (Exception: IOException) { + Logger.error{"API密钥文件不存在且无法创建API密钥文件。"} + Logger.error { Exception } + exitProcess(-1) } } + } catch (Exception: IOException) { + Logger.error{"读取API密钥文件失败 $Exception"} } + + Thread.startVirtualThread { + val ParentDirectory = ApiKeyFile.parent + val WatchService = FileSystems.getDefault().newWatchService() + ParentDirectory.register(WatchService, StandardWatchEventKinds.ENTRY_MODIFY) + Logger.info { "开始在目录 $ParentDirectory 监视API密钥文件变动:${ApiKeyFile.fileName}" } + while (true) { + val WatchKey = WatchService.take() + for (Event in WatchKey.pollEvents()) { + val ChangedFile = Event.context() as Path + if (ChangedFile.toString() == ApiKeyFile.fileName.toString()) { + Logger.info { "检测到API密钥文件 ${Event.kind()} 事件,正在重载..." } + try { + ApiKeyListBackendField.clear() + ApiKeyListBackendField.addAll(Files.readAllLines(ApiKeyFile, StandardCharsets.UTF_8)) + Logger.info { "API密钥列表已重新加载,当前数量:${ApiKeyListBackendField.size}" } + } catch (Exception: IOException) { + Logger.error { "读取API密钥文件失败 $Exception" } } } } + WatchKey.reset() } } } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/OpenApiConfiguration.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/OpenApiConfiguration.kt new file mode 100644 index 0000000..bee6957 --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/OpenApiConfiguration.kt @@ -0,0 +1,31 @@ +package xyz.thewhitedog9487.WebAPI.Configuration + +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Contact +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.info.License +import io.swagger.v3.oas.models.servers.Server +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class OpenApiConfiguration { + @Bean + fun CustomOpenAPIConfiguration(): OpenAPI { + return OpenAPI() + .info(Info() + .title("TheWhiteDog9487的通用API们") + .summary("") + .description("") + .termsOfService("") + .contact(Contact() + .name("TheWhiteDog9487") + .url("https://www.github.com/TheWhiteDog9487/WebAPI")) + .version("0.8.0") + .license(License() + .name("WTFPL") + .url("https://spdx.org/licenses/WTFPL")) ) + .servers(listOf( + Server() + .url("https://api.thewhitedog9487.xyz") + .description("主服务器") ) ) } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/Security/ApiKeyAuthenticationFilter.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/Security/ApiKeyAuthenticationFilter.kt new file mode 100644 index 0000000..aa605a3 --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/Security/ApiKeyAuthenticationFilter.kt @@ -0,0 +1,53 @@ +package xyz.thewhitedog9487.WebAPI.Configuration.Security + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.beans.factory.getBean +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.OncePerRequestFilter +import xyz.thewhitedog9487.WebAPI.BackgroundTask.ApiKeyManager +import xyz.thewhitedog9487.WebAPI.Controller.ResponseData +import xyz.thewhitedog9487.WebAPI.Data.Entity.AccessLog +import xyz.thewhitedog9487.WebAPI.Miscellaneous.SpringContextHolder.SpringContext +import java.nio.charset.StandardCharsets + +class ApiKeyAuthenticationFilter(): OncePerRequestFilter() { + val ApiKeyManagerInstance = SpringContext.getBean() + + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { + val ApiKey = request.getHeader("X-API-Key") ?: run { + val Log = AccessLog(request) + Log.ResponseStatusCode = HttpStatus.UNAUTHORIZED.value() + Log.SaveIntoDatabase() + val ResponseBody = ResponseData( + HttpStatus.UNAUTHORIZED.value(), + "API密钥验证失败,未传递X-API-Key请求头") + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = StandardCharsets.UTF_8.name() + response.status = HttpStatus.UNAUTHORIZED.value() + response.writer.write(ResponseBody.json) + return } + if (ApiKey in ApiKeyManagerInstance.ApiKeyList) { + val Auth = UsernamePasswordAuthenticationToken( + "", + ApiKey, + listOf(SimpleGrantedAuthority("API"))) + SecurityContextHolder.getContext().authentication = Auth + filterChain.doFilter(request, response) } + else { + val Log = AccessLog(request) + Log.ResponseStatusCode = HttpStatus.FORBIDDEN.value() + Log.SaveIntoDatabase() + val ResponseBody = ResponseData( + HttpStatus.FORBIDDEN.value(), + "API密钥验证失败,API密钥无效") + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = StandardCharsets.UTF_8.name() + response.status = HttpStatus.FORBIDDEN.value() + response.writer.write(ResponseBody.json) + return } } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/Security/SpringSecurityConfiguration.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/Security/SpringSecurityConfiguration.kt new file mode 100644 index 0000000..d80c019 --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/Security/SpringSecurityConfiguration.kt @@ -0,0 +1,55 @@ +package xyz.thewhitedog9487.WebAPI.Configuration.Security + +import jakarta.servlet.DispatcherType +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.annotation.Order +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +class SpringSecurityConfiguration{ + @Order(1) + @Bean + fun Permit(Security: HttpSecurity): SecurityFilterChain { + return Security + .securityMatcher("/actuator/health") + .csrf( { CsrfConfigurer -> CsrfConfigurer.disable() } ) + .authorizeHttpRequests( { AuthorizationManagerRequestMatcherRegistry -> + AuthorizationManagerRequestMatcherRegistry + .anyRequest() + .permitAll() } ) + .build() } + + /** + * @see ApiKeyAuthenticationFilter#doFilterInternal(HttpServletRequest, HttpServletResponse, FilterChain) + */ + @Order(2) + @Bean + fun RequireApiKey(Security: HttpSecurity): SecurityFilterChain { + return Security + .securityMatcher("/message/**", "/accesslog/**", + "/actuator/**") + .csrf( { CsrfConfigurer -> CsrfConfigurer.disable() } ) + .authorizeHttpRequests( { AuthorizationManagerRequestMatcherRegistry -> + AuthorizationManagerRequestMatcherRegistry + .dispatcherTypeMatchers(DispatcherType.ASYNC) + .permitAll() + .dispatcherTypeMatchers(DispatcherType.ERROR) + .permitAll() + .anyRequest() + .authenticated() } ) + .addFilterBefore( ApiKeyAuthenticationFilter(), UsernamePasswordAuthenticationFilter::class.java) + .build() } + + @Order(3) + @Bean + fun PermitAll(Security: HttpSecurity): SecurityFilterChain { + return Security + .csrf( { CsrfConfigurer -> CsrfConfigurer.disable() } ) + .authorizeHttpRequests( { AuthorizationManagerRequestMatcherRegistry -> + AuthorizationManagerRequestMatcherRegistry + .anyRequest() + .permitAll() } ) + .build() } } diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/SpringMvcConfiguration.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/SpringMvcConfiguration.kt new file mode 100644 index 0000000..be13c85 --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Configuration/SpringMvcConfiguration.kt @@ -0,0 +1,11 @@ +package xyz.thewhitedog9487.WebAPI.Configuration + +import org.springframework.context.annotation.Configuration +import org.springframework.http.MediaType +import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class SpringMvcConfiguration: WebMvcConfigurer{ + override fun configureContentNegotiation(ContentNegotiationConfigurer: ContentNegotiationConfigurer) { + ContentNegotiationConfigurer.defaultContentType(MediaType.APPLICATION_JSON) } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/AccessLog.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/AccessLog.kt new file mode 100644 index 0000000..8b05890 --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/AccessLog.kt @@ -0,0 +1,190 @@ +package xyz.thewhitedog9487.WebAPI.Controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.ExampleObject +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.constraints.Min +import org.springframework.data.domain.Sort +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import xyz.thewhitedog9487.WebAPI.Data.Entity.AccessLog +import xyz.thewhitedog9487.WebAPI.Data.Repository.AccessLogRepository +import xyz.thewhitedog9487.WebAPI.Data.Specification.AccessLogSpecification +import kotlin.reflect.full.declaredMemberProperties + +@Tag(name = "日志相关") +@RestController +@RequestMapping("/accesslog") +class AccessLog(val AccessLogRepositoryInstance: AccessLogRepository) { + + @Operation( + summary = "获取访问日志数据库内所有的记录", + description = "这是一个私有API,需要在请求头中提供X-API-Key以进行身份验证") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "400", + description = "请求参数错误", + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ResponseData::class), + examples = [ + ExampleObject( + name = "Limit小于0", + value = """ + { + "code": 400, + "message": "Limit参数不能小于0", + "data": null + } + """ ), + ExampleObject( + name = "字段不存在", + value = """ + { + "code": 400, + "message": "试图访问一个不存在的数据库字段", + "data": "AccessLog类中不存在名为 NotExistField 的字段" + } + """ ) ] ) ] ), + ApiResponse( + responseCode = "200", + description = "成功获取到数据", + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + array = ArraySchema( + minItems = 0, + schema = Schema(implementation = AccessLog::class) ), + examples = [ + ExampleObject( + value = """ + [ + { + "ID": 18, + "RequestID": "3", + "CF_Connecting_IP": "223.87.14.146", + "X_Forwarded_For": "223.87.14.146, 141.101.99.156", + "RemoteAddress": "192.168.128.11", + "CF_IPCountry": "CN", + "ISO3166": "CHN", + "UserAgent": "Mozilla/5.0 (Linux; Android 10; SM-A202F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Mobile Safari/537.36", + "HttpMethod": "GET", + "Protocol": "http", + "ProtocolVersion": "HTTP/1.0", + "URL": "http://dev.thewhitedog9487.xyz/", + "QueryString": null, + "Header": "host: dev.thewhitedog9487.xyz\nx-real-ip: 141.101.99.156\nx-forwarded-for: 223.87.14.146, 141.101.99.156\nx-forwarded-proto: https\nconnection: close\ncf-ray: 986883191fd5ed0b-LHR\nuser-agent: Mozilla/5.0 (Linux; Android 10; SM-A202F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Mobile Safari/537.36\naccept-encoding: gzip, br\nreferer: https://www.baidu.com\ncdn-loop: cloudflare; loops=1\ncf-connecting-ip: 223.87.14.146\ncf-ipcountry: CN\ncf-visitor: {\"scheme\":\"https\"}", + "Timestamp": "2025-09-29T03:49:42.766Z", + "ResponseStatusCode": 302 + }, + { + "ID": 261, + "RequestID": "31", + "CF_Connecting_IP": "2409:8962:77a:49c:a579:f5af:58a3:e2c", + "X_Forwarded_For": "2409:8962:77a:49c:a579:f5af:58a3:e2c, 172.71.150.157", + "RemoteAddress": "192.168.128.11", + "CF_IPCountry": "CN", + "ISO3166": "CHN", + "UserAgent": "IntelliJ HTTP Client/IntelliJ IDEA 2025.2.3", + "HttpMethod": "GET", + "Protocol": "http", + "ProtocolVersion": "HTTP/1.0", + "URL": "http://dev.thewhitedog9487.xyz/accesslog", + "QueryString": null, + "Header": "host: dev.thewhitedog9487.xyz\nx-real-ip: 172.71.150.157\nx-forwarded-for: 2409:8962:77a:49c:a579:f5af:58a3:e2c, 172.71.150.157\nx-forwarded-proto: https\nconnection: close\nx-api-key: asdasdaFSEA452453sdcv\nuser-agent: IntelliJ HTTP Client/IntelliJ IDEA 2025.2.3\naccept: */*\ncf-ray: 98cc7d6aaea1c37a-SEA\naccept-encoding: gzip, br\ncdn-loop: cloudflare; loops=1\ncf-connecting-ip: 2409:8962:77a:49c:a579:f5af:58a3:e2c\ncf-ipcountry: CN\ncf-visitor: {\"scheme\":\"https\"}\ncookie: JSESSIONID=A49527B913170D86A5544A252BD31040", + "Timestamp": "2025-10-11T07:02:05.718Z", + "ResponseStatusCode": 200 + }, + { + "ID": 263, + "RequestID": "35", + "CF_Connecting_IP": "2409:8962:77a:49c:a579:f5af:58a3:e2c", + "X_Forwarded_For": "2409:8962:77a:49c:a579:f5af:58a3:e2c, 108.162.246.209", + "RemoteAddress": "192.168.128.11", + "CF_IPCountry": "CN", + "ISO3166": "CHN", + "UserAgent": "IntelliJ HTTP Client/IntelliJ IDEA 2025.2.3", + "HttpMethod": "GET", + "Protocol": "http", + "ProtocolVersion": "HTTP/1.0", + "URL": "http://dev.thewhitedog9487.xyz/ip/ip", + "QueryString": null, + "Header": "host: dev.thewhitedog9487.xyz\nx-real-ip: 108.162.246.209\nx-forwarded-for: 2409:8962:77a:49c:a579:f5af:58a3:e2c, 108.162.246.209\nx-forwarded-proto: https\nconnection: close\naccept-encoding: gzip, br\nuser-agent: IntelliJ HTTP Client/IntelliJ IDEA 2025.2.3\naccept: */*\ncf-ray: 98cc9ec12c48a1a5-SEA\ncdn-loop: cloudflare; loops=1\ncf-connecting-ip: 2409:8962:77a:49c:a579:f5af:58a3:e2c\ncf-ipcountry: CN\ncf-visitor: {\"scheme\":\"https\"}\ncookie: JSESSIONID=A49527B913170D86A5544A252BD31040", + "Timestamp": "2025-10-11T07:24:51.504Z", + "ResponseStatusCode": 200 + } + ] + """ ) ] ) ] ), + ApiResponse( + responseCode = "401", + description = "未提供API密钥", + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ResponseData::class), + examples = [ + ExampleObject( + value = """ + { + "code": 401, + "message": "API密钥验证失败,未传递X-API-Key请求头", + "data": null + } + """ ) ] ) ] ), + ApiResponse( + responseCode = "403", + description = "API密钥无效", + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ResponseData::class), + examples = [ + ExampleObject( + value = """ + { + "code": 403, + "message": "API密钥验证失败,API密钥无效", + "data": null + } + """ ) ] ) ] ) ] ) + @GetMapping + fun GetLog(@Parameter(description = "用于身份验证的API密钥", `in` = ParameterIn.HEADER, required = true, example = "ds1858dscc8745sfwe" ) + @RequestHeader("X-API-Key") ApiKey: String, + + @Min(0) + @Parameter(description = "返回记录的数量限制,0表示不限制", example = "10") + @RequestParam(defaultValue = "0") Limit: Int, + + @Parameter(description = "排序方向:ASC 升序 从小到大 / DESC 降序 从大到小", example = "DESC") + @RequestParam(defaultValue = "ASC") Direction: Sort.Direction, + + @Parameter(description = "排序字段名", example = "ID") + @RequestParam(defaultValue = "Timestamp") OrderByFieldName: String): List { + Limit < 0 && throw IllegalArgumentException("Limit参数不能小于0") + AccessLog::class.declaredMemberProperties.all{ + it.name != OrderByFieldName } && throw NoSuchFieldException("${AccessLog::class.simpleName}类中不存在名为 $OrderByFieldName 的字段") + + val Results = AccessLogRepositoryInstance.findAll(AccessLogSpecification.OrderByField(OrderByFieldName, Direction)) + // ↑ ↓ 不能用 AccessLogRepositoryInstance.findAll(Sort.by()) ,字段名处理有问题,传入排序字段Abc会在不知道什么地方变成abc导致查询失败 + return if (Limit > 0) Results.take(Limit) else Results } + + @ExceptionHandler(IllegalArgumentException::class) + fun TriggerWhenLimitLessThanZero(e: IllegalArgumentException): ResponseEntity { + return ResponseEntity.badRequest() + .body(ResponseData(HttpStatus.BAD_REQUEST.value(), message = e.message ) ) } + + @ExceptionHandler(NoSuchFieldException::class) + fun TriggerWhenAccessFieldThatNotExist(e: NoSuchFieldException): ResponseEntity { + return ResponseEntity.badRequest() + .body(ResponseData(HttpStatus.BAD_REQUEST.value(), "试图访问一个不存在的数据库字段", e.message ) ) } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/Filter/LogClientInfo.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/Filter/LogClientInfo.kt new file mode 100644 index 0000000..9196646 --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/Filter/LogClientInfo.kt @@ -0,0 +1,41 @@ +package xyz.thewhitedog9487.WebAPI.Controller.Filter + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.servlet.* +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component +import org.springframework.web.util.ContentCachingResponseWrapper +import xyz.thewhitedog9487.WebAPI.Data.Entity.AccessLog + +@Component +class LogClientInfo(): Filter { + val Logger: KLogger = KotlinLogging.logger{} + + override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { + val HttpServletRequest = request as HttpServletRequest + val HttpServletResponse = response as HttpServletResponse + val CachedResponse = ContentCachingResponseWrapper(HttpServletResponse) + val Log = AccessLog(HttpServletRequest ) + try { + chain.doFilter(request, CachedResponse) + if (request.isAsyncStarted){ + request.asyncContext.addListener(object : AsyncListener { + override fun onComplete(event: AsyncEvent?) { + Log.ResponseStatusCode = CachedResponse.status + Log.SaveIntoDatabase(CachedResponse) } + override fun onTimeout(event: AsyncEvent?) { + Logger.error{ event?.throwable?.localizedMessage } + Log.ResponseStatusCode = CachedResponse.status + Log.SaveIntoDatabase(CachedResponse) } + override fun onError(event: AsyncEvent?) = onTimeout(event) + override fun onStartAsync(event: AsyncEvent?) { } } ) + return } + Log.ResponseStatusCode = CachedResponse.status + } catch (e: Exception) { + Logger.error{ e.localizedMessage } + Log.ResponseStatusCode = CachedResponse.status + Log.SaveIntoDatabase(CachedResponse) + throw e } + Log.SaveIntoDatabase(CachedResponse) } } diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/Filter/SetMdcRequestId.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/Filter/SetMdcRequestId.kt new file mode 100644 index 0000000..7aaab3f --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/Filter/SetMdcRequestId.kt @@ -0,0 +1,22 @@ +package xyz.thewhitedog9487.WebAPI.Controller.Filter + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +// ↑ 拉高优先级,确保在日志记录之前设置MDC,不然和没设置就没有区别了 +class SetMdcRequestId: OncePerRequestFilter() { + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { + MDC.put("RequestID", request.requestId) + try { + filterChain.doFilter(request, response) + } finally { + // 防止内存泄漏 + MDC.remove("RequestID") } } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/IP.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/IP.kt new file mode 100644 index 0000000..85dc470 --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/IP.kt @@ -0,0 +1,77 @@ +package xyz.thewhitedog9487.WebAPI.Controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.ExampleObject +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name="请求客户端IP地址相关") +@RestController +@RequestMapping("/ip") +class IP { + @Operation(summary = "获取请求客户端的IP地址", description = +""" +优先级: +1. CF-Connecting-IP +2. X-Forwarded-For +3. 直接使用请求的远程地址""") + @ApiResponse(responseCode = "200", + content = [Content( + mediaType = MediaType.TEXT_PLAIN_VALUE, + schema = Schema(implementation = String::class), + examples = [ExampleObject(value = "78.141.226.247")])], + description = "成功获取到IP地址,内容为纯文本格式的IP地址") + @GetMapping("") + fun GetIP(@RequestHeader("CF-Connecting-IP", required = false) Header_CFConnectingIP: String?, + @RequestHeader("X-Forwarded-For", required = false) Header_XForwardedFor: String?, + Request: HttpServletRequest): ResponseEntity { + Header_CFConnectingIP?.let { + return ResponseEntity.ok() + .contentType(MediaType.TEXT_PLAIN) + .body(Header_CFConnectingIP) } + Header_XForwardedFor?.let { + return ResponseEntity.ok() + .contentType(MediaType.TEXT_PLAIN) + .body(Header_XForwardedFor) } + return ResponseEntity.ok() + .contentType(MediaType.TEXT_PLAIN) + .body(Request.remoteAddr) } + + @Operation(summary = "获取请求客户端的ISO 3166-1 alpha-2国家代码", description = +""" +依赖Cloudflare的CF-IPCountry头部 +如果请求没有经过Cloudflare,则会返回"未找到CF-IPCountry头部" +""") + @ApiResponses(value = [ + ApiResponse(responseCode = "200", + content = [Content( + mediaType = MediaType.TEXT_PLAIN_VALUE, + schema = Schema(implementation = String::class), + examples = [ExampleObject(value = "HK")])], + description = "成功获取到ISO 3166-1 alpha-2国家代码,内容为纯文本格式的国家代码"), + ApiResponse(responseCode = "404", + content = [Content( + mediaType = MediaType.TEXT_PLAIN_VALUE, + schema = Schema(implementation = String::class), + examples = [ExampleObject(value = "未找到CF-IPCountry头部")] ) ], + description = "未找到CF-IPCountry头部,内容为纯文本格式的错误信息") ] ) + @GetMapping("iso3166") + fun GetIso3166(@RequestHeader("CF-IPCountry", required = false) Header_CFIPCountry: String?): ResponseEntity { + Header_CFIPCountry?.let { + return ResponseEntity.ok() + .contentType(MediaType.TEXT_PLAIN) + .body(Header_CFIPCountry) } + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .contentType(MediaType.TEXT_PLAIN) + .body("未找到CF-IPCountry头部") } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/Message.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/Message.kt new file mode 100644 index 0000000..dd1027e --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/Message.kt @@ -0,0 +1,194 @@ +package xyz.thewhitedog9487.WebAPI.Controller + +import com.fasterxml.jackson.annotation.JsonAutoDetect +import com.fasterxml.jackson.annotation.JsonProperty +import dev.kord.common.entity.Snowflake +import dev.kord.core.Kord +import dev.kord.core.entity.channel.TextChannel +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.ExampleObject +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import kotlinx.coroutines.withContext +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MissingRequestHeaderException +import org.springframework.web.bind.annotation.* +import xyz.thewhitedog9487.WebAPI.Exception.NoSuchDiscordChannelException +import xyz.thewhitedog9487.WebAPI.Miscellaneous.VirtualThreadCoroutineDispatcher +import xyz.thewhitedog9487.WebAPI.Miscellaneous.link +import java.net.URI + +@Tag(name="远程消息处理相关") +@RestController +@RequestMapping("/message") +class Message(val DiscordBotClient: Kord) { + @JsonAutoDetect( + fieldVisibility = JsonAutoDetect.Visibility.ANY, + getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE, + setterVisibility = JsonAutoDetect.Visibility.NONE, + creatorVisibility = JsonAutoDetect.Visibility.NONE) + @Schema(description = "包含了发送消息的数据包") + data class PostMessageData( + @Schema(description = "Discord频道ID", example = "1398192763845214239") + @JsonProperty("ChannelId") + val ChannelId: Long, + @Schema(description = "要发送的消息内容", example = "成功完成备份,最新文件时间为2025 08 21") + @JsonProperty("Content") + val Content: String) + + @Operation(summary = "通过Discord Bot向指定频道发送消息", description = +"""这是一个私有API +需要在请求头中提供X-API-Key以进行身份验证 +需要在请求体中提供频道ID和消息内容""") + @ApiResponses(value = [ + ApiResponse(responseCode = "201", + content = [Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ResponseData::class), + examples = [ExampleObject(value = +""" +{ + "code": 201, + "message": "消息发送成功", + "data": { + "新ID": "1407848258813825135", + "频道ID": "1398192763845214239", + "内容": "写点什么好呢" + } +} +""", name = "成功") ] ) ], + description = "消息发送成功,响应体中包含新消息的ID、频道ID和内容"), + ApiResponse(responseCode = "400", + content = [Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ResponseData::class), + examples = [ExampleObject(value = +""" +{ + "code": 400, + "message": "请求体无法解析", + "data": { + "错误信息": "JSON parse error: Unexpected character ('}' (code 125)): was expecting double-quote to start field name" + } +} +""", name = "请求体无法解析"), + ExampleObject(value = +""" +{ + "code": 400, + "message": "消息发送失败", + "data": { + "错误信息": "ID为1398192763845214239的频道不存在", + "频道ID": "1398192763845214239" + } +} +""", name = "频道不存在")] ) ], + description = "客户端发送的请求存在问题,请检查响应的data字段以获取更多信息"), + ApiResponse(responseCode = "401", + content = [Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ResponseData::class), + examples = [ExampleObject(value = +""" +{ + "code": 401, + "message": "API密钥验证失败,未传递X-API-Key请求头", + "data": null +} +""") ] ) ], + description = "未提供API密钥"), + ApiResponse(responseCode = "403", + content = [Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ResponseData::class), + examples = [ExampleObject(value = +""" +{ + "code": 403, + "message": "API密钥验证失败,API密钥无效", + "data": null +} +""") ] ) ], + description = "API密钥无效"), + ApiResponse(responseCode = "500", + content = [Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ResponseData::class), + examples = [ExampleObject(value = +"""{ + "code": 500, + "message": "处理请求时发生未知错误", + "data": { + "错误信息": "POST /channels/1398192763845214/messages returned 404 Not Found with response {code=10003, message=Unknown Channel}" + } +}""", name = "未知错误") ] ) ], + description = +""" +处理请求时发生未知错误,可能是由于Discord服务器问题或其他内部错误,请查看响应Body以确定原因 +另外,所有未被针对性处理的异常都会触发此响应 +""") ] ) + @PostMapping("/discord") + suspend fun DiscordPush( + @Parameter(description = "用于身份验证的API密钥", `in` = ParameterIn.HEADER, required = true, example = "ds1858dscc8745sfwe") + @RequestHeader("X-API-Key") ApiKey: String, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "包含频道ID和消息内容的JSON对象", + required = true, + content = [Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = PostMessageData::class) ) ] ) + @RequestBody RequestBody: PostMessageData): ResponseEntity { + return withContext(VirtualThreadCoroutineDispatcher) { + val ChannelID = Snowflake(RequestBody.ChannelId) + val MessageData = DiscordBotClient + .getChannelOf(ChannelID) + ?.createMessage(RequestBody.Content) + ?: throw NoSuchDiscordChannelException(ChannelID) + return@withContext ResponseEntity.created(URI.create(MessageData.link)) + .body(ResponseData( + HttpStatus.CREATED.value(), + "消息发送成功", + mapOf( + "新ID" to MessageData.id.toString(), + "频道ID" to ChannelID.value, + "内容" to RequestBody.Content ) ) ) } } + @ExceptionHandler(NoSuchDiscordChannelException::class) + fun TriggerWhenSpecificChannelDoesNotExist(Exception: NoSuchDiscordChannelException, Request: HttpServletRequest): ResponseEntity { + return ResponseEntity.badRequest() + .body(ResponseData( + HttpStatus.BAD_REQUEST.value(), + "消息发送失败", + mapOf( + "错误信息" to Exception.message, + "频道ID" to Exception.TargetChannelId.value ) ) ) } + @ExceptionHandler(MissingRequestHeaderException::class) + fun HandleMissingHeader(Exception: MissingRequestHeaderException, Request: HttpServletRequest): ResponseEntity { + return ResponseEntity.badRequest() + .body(ResponseData( + HttpStatus.BAD_REQUEST.value(), + "请求缺少必要的头部信息", + mapOf("缺失的头部" to Exception.headerName ) ) ) } + @ExceptionHandler(HttpMessageNotReadableException::class) + fun HandleMessageNotReadable(Exception: HttpMessageNotReadableException, Request: HttpServletRequest): ResponseEntity { + return ResponseEntity.badRequest() + .body(ResponseData( + HttpStatus.BAD_REQUEST.value(), + "请求体无法解析", + mapOf("错误信息" to Exception.message ) ) ) } + @ExceptionHandler(Exception::class) + fun HandleOtherException(Exception: Exception, Request: HttpServletRequest): ResponseEntity { + return ResponseEntity.internalServerError() + .body(ResponseData( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "处理请求时发生未知错误", + mapOf("错误信息" to Exception.message ) ) ) } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/RedirectRootToSwaggerWebUI.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/RedirectRootToSwaggerWebUI.kt new file mode 100644 index 0000000..fc476bb --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/RedirectRootToSwaggerWebUI.kt @@ -0,0 +1,17 @@ +package xyz.thewhitedog9487.WebAPI.Controller + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.servlet.view.RedirectView + +@Controller +class RedirectRootToSwaggerWebUI { + val Logger: KLogger = KotlinLogging.logger{} + val RedirectTo = "/swagger-ui/index.html" + + @GetMapping("/") + fun Redirect(): RedirectView { + Logger.info{"正在将访问 / 的请求重定向到 $RedirectTo"} + return RedirectView(RedirectTo) } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/ResponseData.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/ResponseData.kt new file mode 100644 index 0000000..fba434c --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Controller/ResponseData.kt @@ -0,0 +1,25 @@ +package xyz.thewhitedog9487.WebAPI.Controller + +import com.fasterxml.jackson.annotation.JsonIgnore +import io.swagger.v3.oas.annotations.media.Schema +import tools.jackson.databind.ObjectMapper + +@Schema(description = "所有API通用的标准响应格式") +data class ResponseData( + @Schema(description = "内部用响应代码", example = "201") val code: Int, + @Schema(description = "人类可读说明信息", example = "消息发送成功") val message: Any? = null, + @Schema(description = "具体的响应数据", example = """ + { + "新ID": "1407848258813825135", + "内容": "写点什么好呢", + "频道ID": "1398192763845214239" + } + """) val data: Any? = null){ + constructor(code: Int, message: Any): this(code, message, null) + + @JsonIgnore + @Schema(hidden = true) + val json: String = JsonMapper.writeValueAsString(this) + + companion object{ + val JsonMapper = ObjectMapper() } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Data/Entity/AccessLog.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Data/Entity/AccessLog.kt new file mode 100644 index 0000000..b7804b9 --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Data/Entity/AccessLog.kt @@ -0,0 +1,140 @@ +package xyz.thewhitedog9487.WebAPI.Data.Entity + +import com.fasterxml.jackson.annotation.JsonAutoDetect +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.persistence.* +import jakarta.servlet.http.HttpServletRequest +import org.springframework.beans.factory.getBean +import org.springframework.web.util.ContentCachingResponseWrapper +import xyz.thewhitedog9487.WebAPI.Data.Repository.AccessLogRepository +import xyz.thewhitedog9487.WebAPI.Miscellaneous.SpringContextHolder.SpringContext +import java.time.Instant +import java.util.Locale +import java.util.concurrent.locks.ReentrantLock + +@JsonAutoDetect( + fieldVisibility = JsonAutoDetect.Visibility.ANY, + getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE, + setterVisibility = JsonAutoDetect.Visibility.NONE, + creatorVisibility = JsonAutoDetect.Visibility.NONE ) +@Schema(description = "访问日志条目") +@Entity +@Table +class AccessLog( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(nullable = false) + @JsonProperty("ID") + @Schema(description = "主键", example = "1") + var ID: Long? = null, + + @JsonProperty("RequestID") + @Schema(description = "Servlet请求ID", example = "0") + var RequestID: String = "", + + @JsonProperty("CF_Connecting_IP") + @Column(name = "\"CF-Connecting-IP\"") + @Schema(description = "Cloudflare提供的请求IP", example = "2409:9802:74a3:6eff:3cae:5cba:25ac:788a") + var CF_Connecting_IP: String? = null, + + @JsonProperty("X_Forwarded_For") + @Column(name = "\"X-Forwarded-For\"") + @Schema(description = "X-Forwarded-For请求头提供的请求IP", example = "2409:9802:74a3:6eff:3cae:5cba:25ac:788a, 108.162.245.89") + var X_Forwarded_For: String? = null, + + @JsonProperty("RemoteAddress") + @Schema(description = "Servlet报告的请求IP", example = "192.168.128.11") + var RemoteAddress: String = "", + + @JsonProperty("CF_IPCountry") + @Column(name = "\"CF-IPCountry\"") + @Schema(description = "Cloudflare提供的请求国家代码", example = "CN") + var CF_IPCountry: String? = null, + + @JsonProperty("ISO3166") + @Schema(description = "Java内部根据CF-IPCountry得到的对应ISO3166代码", example = "CHN") + var ISO3166: String? = null, + + @JsonProperty("UserAgent") + @Schema(description = "标准HTTP User-Agent请求头", example = "IntelliJ HTTP Client/IntelliJ IDEA 2025.2.2") + var UserAgent: String? = null, + + @JsonProperty("HttpMethod") + @Schema(description = "HTTP请求方法", example = "GET") + var HttpMethod: String = "", + + @JsonProperty("Protocol") + @Schema(description = "请求协议", example = "http") + var Protocol: String = "", + + @JsonProperty("ProtocolVersion") + @Schema(description = "协议版本", example = "HTTP/1.1") + var ProtocolVersion: String = "", + + @JsonProperty("URL") + @Schema(description = "请求的完整URL", example = "https://dev.thewhitedog9487.xyz/ip/ip") + var URL: String = "", + + @JsonProperty("QueryString") + @Schema(description = "查询字符串", example = "") + var QueryString: String? = null, + + @JsonProperty("Header") + @Column(columnDefinition = "text") + @Schema( + description = "所有HTTP请求头的文本字符串", example = ( +""" +host: dev.thewhitedog9487.xyz +x-real-ip: 162.158.42.207 +x-forwarded-for: 2409:8962:f29:7dc:3996:6b11:d996:3cc0, 162.158.42.207 +x-forwarded-proto: https +connection: close +x-api-key: asdasdaFSEA452453sdcv +user-agent: IntelliJ HTTP Client/IntelliJ IDEA 2025.2.2 +accept: */* +cf-ray: 986868be1ca5def5-SEA +accept-encoding: gzip, br +cdn-loop: cloudflare; loops=1 +cf-connecting-ip: 2409:8962:f29:7dc:3996:6b11:d996:3cc0 +cf-ipcountry: CN +cf-visitor: {"scheme":"https"} +cookie: JSESSIONID=A49527B913170D86A5544A252BD31040 +""")) + var Header: String = "", + + @JsonProperty("Timestamp") + @Schema(description = "请求时间(ISO-8601格式)", example = "2025-09-29T03:49:42.766Z") + var Timestamp: Instant = Instant.now(), + + @JsonProperty("ResponseStatusCode") + @Schema(description = "响应的HTTP状态码", example = "200") + var ResponseStatusCode: Int = 0 ){ + constructor(httpRequest: HttpServletRequest): this( + null, + httpRequest.requestId, + httpRequest.getHeader("CF-Connecting-IP"), + httpRequest.getHeader("X-Forwarded-For"), + httpRequest.remoteAddr, + httpRequest.getHeader("CF-IPCountry"), + if (httpRequest.getHeader("CF-IPCountry") == null) null else Locale.of(Locale.PRC.language, httpRequest.getHeader("CF-IPCountry"), Locale.SIMPLIFIED_CHINESE.variant).isO3Country, + httpRequest.getHeader("User-Agent"), + httpRequest.method, + httpRequest.scheme, + httpRequest.protocol, + httpRequest.requestURL.toString(), + httpRequest.queryString, + httpRequest.headerNames.toList().joinToString("\n") { name -> + return@joinToString "$name: ${httpRequest.getHeaders(name).toList().joinToString(", ")}" }, + Instant.now(), + 0 ) + fun SaveIntoDatabase(CachedResponse: ContentCachingResponseWrapper? = null){ + val SQLiteWriteLock = SpringContext.getBean("getSQLiteWriteLock", ReentrantLock::class.java) + val AccessLogRepository = SpringContext.getBean() + try { + SQLiteWriteLock.lock() + AccessLogRepository.save(this) } + finally { + SQLiteWriteLock.unlock() + CachedResponse?.copyBodyToResponse() } } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Data/Repository/AccessLogRepository.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Data/Repository/AccessLogRepository.kt new file mode 100644 index 0000000..9339d85 --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Data/Repository/AccessLogRepository.kt @@ -0,0 +1,7 @@ +package xyz.thewhitedog9487.WebAPI.Data.Repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.JpaSpecificationExecutor +import xyz.thewhitedog9487.WebAPI.Data.Entity.AccessLog + +interface AccessLogRepository: JpaRepository, JpaSpecificationExecutor \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Data/Specification/AccessLogSpecification.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Data/Specification/AccessLogSpecification.kt new file mode 100644 index 0000000..008ae2b --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Data/Specification/AccessLogSpecification.kt @@ -0,0 +1,14 @@ +package xyz.thewhitedog9487.WebAPI.Data.Specification + +import org.springframework.data.domain.Sort +import org.springframework.data.jpa.domain.Specification +import xyz.thewhitedog9487.WebAPI.Data.Entity.AccessLog + +object AccessLogSpecification { + fun OrderByField(TargetFieldName: String, Order: Sort.Direction) = Specification{ + Root, CriteriaQuery, CriteriaBuilder -> + CriteriaQuery.orderBy( + when(Order){ + Sort.Direction.ASC -> CriteriaBuilder.asc(Root.get(TargetFieldName)) + Sort.Direction.DESC -> CriteriaBuilder.desc(Root.get(TargetFieldName)) }) + return@Specification CriteriaBuilder.conjunction() } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Exception/NoSuchDiscordChannelException.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Exception/NoSuchDiscordChannelException.kt new file mode 100644 index 0000000..9f253e2 --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Exception/NoSuchDiscordChannelException.kt @@ -0,0 +1,7 @@ +package xyz.thewhitedog9487.WebAPI.Exception + +import dev.kord.common.entity.Snowflake + +class NoSuchDiscordChannelException(val TargetChannelId: Snowflake, + message: String? = null, + cause: Throwable? = null): Exception(message ?: "ID为${TargetChannelId}的频道不存在", cause) \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Miscellaneous/GlobalSharedBeans.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Miscellaneous/GlobalSharedBeans.kt new file mode 100644 index 0000000..1051db6 --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Miscellaneous/GlobalSharedBeans.kt @@ -0,0 +1,46 @@ +package xyz.thewhitedog9487.WebAPI.Miscellaneous + +import dev.kord.core.Kord +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.* +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.SmartLifecycle +import org.springframework.context.annotation.Bean +import org.springframework.stereotype.Component +import java.util.concurrent.Executors +import java.util.concurrent.locks.Lock +import java.util.concurrent.locks.ReentrantLock + +val VirtualThreadCoroutineDispatcher: ExecutorCoroutineDispatcher = + Executors.newVirtualThreadPerTaskExecutor() + .asCoroutineDispatcher() + +@Component +class GlobalSharedBean { + val Logger = KotlinLogging.logger{} + + @Bean + fun DiscordClient(@Value("\${Discord_Bot_Token:}") DiscordBotToken: String): Kord { + if (DiscordBotToken.isEmpty()) { + Logger.error { "未通过参数--Discord_Bot_Token或环境变量Discord_Bot_Token提供Discord登录令牌,请检查配置。" } + throw IllegalArgumentException("未提供Discord登录令牌") } + return runBlocking(VirtualThreadCoroutineDispatcher) { Kord(DiscordBotToken) } } + + @Bean + fun KordLifecycleManager(KordInstance: Kord) = object: SmartLifecycle { + var Job: Job? = null + override fun start() { + Job = CoroutineScope(VirtualThreadCoroutineDispatcher).launch { + KordInstance.login { } } } + + override fun stop() { + runBlocking(VirtualThreadCoroutineDispatcher) { + try { + KordInstance.logout() + } catch (_: IllegalStateException) { } + Job?.cancelAndJoin() } } + + override fun isRunning() = Job?.isActive ?: false } + + @get:Bean + val SQLiteWriteLock: Lock = ReentrantLock() } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Miscellaneous/Kord.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Miscellaneous/Kord.kt new file mode 100644 index 0000000..1d410ae --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Miscellaneous/Kord.kt @@ -0,0 +1,14 @@ +package xyz.thewhitedog9487.WebAPI.Miscellaneous + +import dev.kord.core.entity.Message +import dev.kord.core.entity.channel.GuildChannel +import dev.kord.core.supplier.getChannelOfOrNull +import kotlinx.coroutines.runBlocking + +val Message.link get(): String { + return runBlocking(VirtualThreadCoroutineDispatcher) { + data.guildId.value?.let { guildId -> + return@runBlocking "https://discord.com/channels/$guildId/$channelId/$id" } + val channel = kord.defaultSupplier.getChannelOfOrNull(channelId) + val guild = channel?.guildId?.toString() ?: "@me" + return@runBlocking "https://discord.com/channels/$guild/$channelId/$id" } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Miscellaneous/SpringContextHolder.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Miscellaneous/SpringContextHolder.kt new file mode 100644 index 0000000..b8f82eb --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Miscellaneous/SpringContextHolder.kt @@ -0,0 +1,12 @@ +package xyz.thewhitedog9487.WebAPI.Miscellaneous + +import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationContextAware +import org.springframework.stereotype.Component + +@Component +object SpringContextHolder: ApplicationContextAware{ + lateinit var SpringContext: ApplicationContext + + override fun setApplicationContext(applicationContext: ApplicationContext) { + SpringContext = applicationContext } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Startup/SystemdInstallerManager.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Startup/SystemdInstallerManager.kt new file mode 100644 index 0000000..fa0b8a3 --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/Startup/SystemdInstallerManager.kt @@ -0,0 +1,152 @@ +package xyz.thewhitedog9487.WebAPI.Startup + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.boot.system.ApplicationHome +import xyz.thewhitedog9487.WebAPI.WebApiApplication +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.system.exitProcess + +val Logger = KotlinLogging.logger{} +val AppHome: ApplicationHome = ApplicationHome(WebApiApplication::class.java) +val CurrentOS: String = System.getProperty("os.name") +val CurrentUser: String = System.getProperty("user.name") +/** + * 当前可执行文件的路径。 + * + * 如果是通过GraalVM生成的Native Image,则是可执行文件的路径 + * + * 如果是直接用JRE运行的JAR包,则是JAR文件的路径 + */ +val CurrentExecutableFilePath: Path get() { + Logger.debug { "IsImageCode: $IsImageCode" } + if (IsImageCode == true) { + val ExecutablePath = Class.forName("org.graalvm.nativeimage.ProcessProperties") + .getMethod("getExecutableName") + .invoke(null) as String + Logger.debug { "${"ExecutablePath: {}"} $ExecutablePath" } + return Path(ExecutablePath) + } else { +// ↓ 使用GraalVM的JVM而不是Native Image + return AppHome + .source!! + .toPath() + .toRealPath() } } +val WorkingDirectory: Path = AppHome + .dir + .toPath() +val ServiceFileDirectory: Path = Path("/etc/systemd/system/") +val ServiceFileName: Path = Path("WebAPI.service") +const val SoftLinkFileName: String = "Current" +val SymbolicLinkPath: Path = WorkingDirectory.resolve(SoftLinkFileName) +var DiscordBotToken: String = System.getenv("Discord_Bot_Token") + +/** + * 检测当前运行环境是否为GraalVM生成的Native Image + */ +val IsImageCode: Boolean get() { + return try { + Class.forName("org.graalvm.nativeimage.ImageInfo") + .getMethod("inImageCode") + .invoke(null) as Boolean + } catch (_: Exception) { + false } } + +fun ProcessArguments(CommandLineArguments: Array) { + Logger.debug { "${"CommandLineArguments: {}"} ${CommandLineArguments.contentToString()}" } + for (Argument in CommandLineArguments) { + if (Argument in listOf("install", "--install")) { + /* + 在Native Image / Jar文件旁边生成指向自身的,名为Current的软连接 + 然后在软连接旁生成systemd服务文件,执行目标指向软连接 + */ + DiscordBotToken = if (CommandLineArguments.contains("--Discord_Bot_Token") && + CommandLineArguments.getOrNull(CommandLineArguments.indexOf("--Discord_Bot_Token") + 1) != null ){ + CommandLineArguments[CommandLineArguments.indexOf("--Discord_Bot_Token") + 1] } + else System.getProperty("Discord_Bot_Token", "") + if (DiscordBotToken.isEmpty()) { + Logger.error { "必须通过--Discord_Bot_Token参数或Discord_Bot_Token环境变量提供Discord机器人的令牌以使本程序正常工作。" } + throw IllegalArgumentException("缺少必须的--Discord_Bot_Token参数") } + Logger.info { "开始安装systemd服务" } + Logger.debug { "SystemProperties.os.name: $CurrentOS" } + if (CurrentOS.startsWith("Linux")) { + Logger.debug { "SystemProperties.user.name: $CurrentUser" } + if (CurrentUser != "root") { + Logger.error { "只有root才有权在系统层级安装systemd服务,但是JVM报告的当前用户是$CurrentUser" } + throw IllegalStateException("请使用root用户运行此命令以安装为systemd服务") } + Logger.debug { "CurrentExecutableFilePath: $CurrentExecutableFilePath" } + Logger.debug { "WorkingDirectory: $WorkingDirectory" } + Logger.debug { "SymbolicLinkPath: $SymbolicLinkPath" } + ProcessBuilder("systemctl", "stop", ServiceFileName.toString()).start().waitFor() + Logger.info { "已停止systemd服务: $ServiceFileName" } + Files.deleteIfExists(SymbolicLinkPath) + Files.createSymbolicLink(SymbolicLinkPath, CurrentExecutableFilePath) + Logger.info { "成功创建指向当前程序的软链接: $SymbolicLinkPath -> $CurrentExecutableFilePath" } + val CurrentJavaPath by lazy { + if (IsImageCode == false) { + Logger.debug { "CurrentJavaPath: ${Path("/proc/self/exe").toRealPath()}" } + Path("/proc/self/exe").toRealPath() } + else throw Exception() } + val ExecCommand = if (IsImageCode) SymbolicLinkPath.toString() else "$CurrentJavaPath -jar $SymbolicLinkPath" + Logger.debug { "ExecCommand: $ExecCommand" } + val ServiceFileContent = """ + [Unit] + Description=WebAPI + After=network.target + + [Service] + User=$CurrentUser + WorkingDirectory=$WorkingDirectory + ExecStart=$ExecCommand + Restart=always + Type=simple + Environment="Discord_Bot_Token=$DiscordBotToken" + + [Install] + WantedBy=multi-user.target + + """.trimIndent() + Logger.debug { "ServiceFileContent: \n$ServiceFileContent" } + Files.deleteIfExists(WorkingDirectory.resolve(ServiceFileName)) + Files.writeString(WorkingDirectory.resolve(ServiceFileName), ServiceFileContent) + Logger.info { "成功创建systemd服务文件: ${WorkingDirectory.resolve(ServiceFileName)}" } + Files.deleteIfExists(ServiceFileDirectory.resolve(ServiceFileName)) + Files.createSymbolicLink( + ServiceFileDirectory.resolve(ServiceFileName), + WorkingDirectory.resolve(ServiceFileName) ) + Logger.info { + "成功创建指向服务文件的软链接: ${ServiceFileDirectory.resolve(ServiceFileName)} -> ${WorkingDirectory.resolve(ServiceFileName)}" } + ProcessBuilder("systemctl", "daemon-reload").start().waitFor() + Logger.info { "已完成systemd守护进程重载" } + ProcessBuilder("systemctl", "enable", ServiceFileName.toString()).start().waitFor() + Logger.info { "已启用systemd服务: $ServiceFileName" } + ProcessBuilder("systemctl", "start", ServiceFileName.toString()).start().waitFor() + Logger.info { "已启动systemd服务: $ServiceFileName" } + } else if (CurrentOS.startsWith("Windows")) { + Logger.error{"只有Linux才能使用systemd,但是JVM报告的当前系统是$CurrentOS"} + throw IllegalStateException("Windows系统不支持systemd服务管理,暂不支持在Windows上自动管理服务。") } + exitProcess(0) + } else if (Argument in listOf("uninstall", "--uninstall")) { + Logger.info { "开始卸载systemd服务" } + Logger.debug { "SystemProperties.os.name: $CurrentOS" } + if (CurrentOS.startsWith("Windows")) { + Logger.error { "只有Linux才能使用systemd,但是JVM报告的当前系统是$CurrentOS" } + throw IllegalStateException("Windows系统不支持systemd服务管理,暂不支持在Windows上自动管理服务。") + } else if (CurrentOS.startsWith("Linux")) { + if (CurrentUser != "root") { + Logger.error { "只有root才有权在系统层级卸载systemd服务,但是JVM报告的当前用户是$CurrentUser" } + throw IllegalStateException("请使用root用户运行此命令以卸载systemd服务") } + ProcessBuilder("systemctl", "stop", ServiceFileName.toString()).start().waitFor() + Logger.info { "已停止systemd服务: $ServiceFileName" } + ProcessBuilder("systemctl", "disable", ServiceFileName.toString()).start().waitFor() + Logger.info { "已禁用systemd服务: $ServiceFileName" } + Files.deleteIfExists(ServiceFileDirectory.resolve(ServiceFileName)) + Logger.info { "成功删除systemd服务文件: ${ServiceFileDirectory.resolve(ServiceFileName)}" } + Files.deleteIfExists(WorkingDirectory.resolve(ServiceFileName)) + Logger.info { "成功删除程序旁的服务文件: ${WorkingDirectory.resolve(ServiceFileName)}" } + Files.deleteIfExists(SymbolicLinkPath) + Logger.info { "成功删除指向程序自身的软链接: $SymbolicLinkPath" } + ProcessBuilder("systemctl", "daemon-reload").start().waitFor() + Logger.info { "已完成systemd守护进程重载" } } + exitProcess(0) } } } \ No newline at end of file diff --git a/src/main/kotlin/xyz/thewhitedog9487/WebAPI/WebApiApplication.kt b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/WebApiApplication.kt new file mode 100644 index 0000000..ba87e48 --- /dev/null +++ b/src/main/kotlin/xyz/thewhitedog9487/WebAPI/WebApiApplication.kt @@ -0,0 +1,17 @@ +package xyz.thewhitedog9487.WebAPI + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.security.core.context.SecurityContextHolder +import reactor.core.publisher.Hooks +import xyz.thewhitedog9487.WebAPI.Startup.ProcessArguments + +@SpringBootApplication +class WebApiApplication + +fun main(args: Array) { + Hooks.enableAutomaticContextPropagation() + SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL) + ProcessArguments(args) + runApplication(*args) +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 27b2be0..1f92e1d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -19,9 +19,14 @@ spring: username: password: url: jdbc:sqlite:SQLiteDataBase.db + servlet: + encoding: + charset: UTF-8 + force: true server: port: 36987 + logging: pattern: level: "%5p %X{RequestID}" diff --git a/src/test/java/xyz/thewhitedog9487/WebAPI/WebApiApplicationTests.java b/src/test/java/xyz/thewhitedog9487/WebAPI/WebApiApplicationTests.java deleted file mode 100644 index b917059..0000000 --- a/src/test/java/xyz/thewhitedog9487/WebAPI/WebApiApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package xyz.thewhitedog9487.WebAPI; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class WebApiApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/kotlin/xyz/thewhitedog9487/WebAPI/WebApiApplicationTests.kt b/src/test/kotlin/xyz/thewhitedog9487/WebAPI/WebApiApplicationTests.kt new file mode 100644 index 0000000..a00ae5d --- /dev/null +++ b/src/test/kotlin/xyz/thewhitedog9487/WebAPI/WebApiApplicationTests.kt @@ -0,0 +1,13 @@ +package xyz.thewhitedog9487.WebAPI + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class WebApiApplicationTests { + + @Test + fun contextLoads() { + } + +}