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 1b33c55..b1b8ef5 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index bad7c24..11cae1e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-all.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 23d15a9..b9bb139 100644 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index db3a6ac..24c62d5 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -23,8 +23,8 @@ @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ 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 :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() { + } + +}