使用Kotlin重写

This commit is contained in:
TheWhiteDog9487
2026-07-30 12:04:43 +08:00
parent 7133d39fb3
commit a620fa15b4
49 changed files with 1328 additions and 1316 deletions
+3
View File
@@ -36,6 +36,9 @@ out/
### VS Code ### ### VS Code ###
.vscode/ .vscode/
### Kotlin ###
.kotlin
API密钥.txt API密钥.txt
SQLiteDataBase.db SQLiteDataBase.db
*.http *.http
+3
View File
@@ -0,0 +1,3 @@
- 使用帕斯卡命名风格
- 括号不允许独占一行
- 括号和包括括号在内的其他内容之间使用空格隔开
+83 -39
View File
@@ -1,68 +1,112 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
java kotlin("jvm") version "2.3.21"
id("org.springframework.boot") version "4.0.0" kotlin("kapt") version "2.3.21"
id("io.spring.dependency-management") version "1.1.7" kotlin("plugin.spring") version "2.3.21"
id("org.hibernate.orm") version "7.1.8.Final" kotlin("plugin.jpa") version "2.3.21"
id("org.graalvm.buildtools.native") version "0.11.3" kotlin("plugin.lombok") version "2.3.21"
id("org.springdoc.openapi-gradle-plugin") version "1.9.0" 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" group = "xyz.thewhitedog9487"
version = "0.7.5" version = "0.8.0"
java { java {
toolchain { toolchain {
languageVersion = JavaLanguageVersion.of(25) languageVersion = JavaLanguageVersion.of(25)
} }
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
} }
repositories { repositories {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator") 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-jpa")
implementation("org.springframework.boot:spring-boot-starter-data-redis") // implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-jdbc") // implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("org.springframework.boot:spring-boot-starter-security") // implementation("org.springframework.boot:spring-boot-starter-restclient")
implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.boot:spring-boot-starter-security")
compileOnly("org.projectlombok:lombok") // implementation("org.springframework.boot:spring-boot-starter-security-oauth2-authorization-server")
developmentOnly("org.springframework.boot:spring-boot-devtools") // implementation("org.springframework.boot:spring-boot-starter-security-oauth2-client")
developmentOnly("org.springframework.boot:spring-boot-docker-compose") // implementation("org.springframework.boot:spring-boot-starter-security-oauth2-resource-server")
runtimeOnly("org.xerial:sqlite-jdbc") implementation("org.springframework.boot:spring-boot-starter-webmvc")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") // implementation("org.springframework.boot:spring-boot-starter-websocket")
annotationProcessor("org.projectlombok:lombok") 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-actuator-test")
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-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-data-redis-test")
testImplementation("org.springframework.boot:spring-boot-starter-jdbc-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-security-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-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") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testAnnotationProcessor("org.projectlombok:lombok")
testImplementation(kotlin("test"))
implementation("org.hibernate.orm:hibernate-community-dialects") implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
implementation("com.discord4j:discord4j-core:3.3.0") implementation("org.hibernate.orm:hibernate-community-dialects")
implementation("tools.jackson.core:jackson-core:3.0.3") implementation("dev.kord:kord-core:0.18.1")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.0") implementation("tools.jackson.core:jackson-core")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0") 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<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
} }
tasks.bootJar { tasks.bootJar {
archiveClassifier.set("boot") archiveClassifier.set("boot")
} }
//tasks.jar { //tasks.jar {
// enabled = false // enabled = false
//} //}
// https://docs.spring.io/spring-boot/gradle-plugin/packaging.html#packaging-executable.and-plain-archives // https://docs.spring.io/spring-boot/gradle-plugin/packaging.html#packaging-executable.and-plain-archives
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
freeCompilerArgs.add("-Xannotation-default-target=param-property")
}
}
kapt {
keepJavacAnnotationProcessors = true
}
+18 -1
View File
@@ -1,5 +1,22 @@
services: 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: redis:
image: 'redis:latest' image: 'redis:latest'
ports: ports:
- '6379' - '6379'
Binary file not shown.
+3 -1
View File
@@ -1,7 +1,9 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
Vendored
+2 -5
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright © 2015 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (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. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM. # 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 # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@" "$@"
Vendored
+10 -22
View File
@@ -23,8 +23,8 @@
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables, and ensure extensions are enabled
if "%OS%"=="Windows_NT" setlocal setlocal EnableExtensions
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=
@rem Execute Gradle @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 :exitWithErrorLevel
@rem End local scope for the variables with windows NT shell @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
if %ERRORLEVEL% equ 0 goto mainEnd "%COMSPEC%" /c exit %ERRORLEVEL%
: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
@@ -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);
}
}
@@ -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)") ) );} }
@@ -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<String> 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; } } }
@@ -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<String> 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(); }
}
@@ -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<xyz.thewhitedog9487.WebAPI.Data.Entity.AccessLog> GetFullLog(
@Parameter(description = "用于身份验证的API密钥", in = ParameterIn.HEADER, required = true, example = "ds1858dscc8745sfwe")
@RequestHeader("X-API-Key") String ApiKey){
return AccessLogRepository.findAll(); }
}
@@ -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<String> 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(); } }
@@ -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);
}
}
@@ -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"); } } }
@@ -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<String> 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头部
<br>
如果请求没有经过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<String> 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); } }
}
@@ -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以进行身份验证
<br>
需要在请求体中提供频道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以确定原因
<br>
另外,所有未被针对性处理的异常都会触发此响应
""") } )
@PostMapping("/discord")
ResponseEntity<ResponseData> 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<ResponseData> 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<ResponseData> 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<ResponseData> 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); }
}
@@ -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); } }
@@ -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); } }
}
@@ -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;
}
@@ -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<AccessLog, Long> {
}
@@ -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<String> 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(); }
}
@@ -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; } }
/**
* 获取当前可执行文件的路径。
* <br>
* 如果是通过GraalVM生成的Native Image,则返回可执行文件的路径
* <br>
* 如果是直接用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); } } }
}
@@ -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);
}
}
@@ -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<String> = CopyOnWriteArraySet()
val ApiKeyList: Set<String> 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() } } } }
@@ -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>(
Server()
.url("https://api.thewhitedog9487.xyz")
.description("主服务器") ) ) } }
@@ -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<ApiKeyManager>()
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 } } }
@@ -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() } }
@@ -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) } }
@@ -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<AccessLog> {
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<ResponseData> {
return ResponseEntity.badRequest()
.body(ResponseData(HttpStatus.BAD_REQUEST.value(), message = e.message ) ) }
@ExceptionHandler(NoSuchFieldException::class)
fun TriggerWhenAccessFieldThatNotExist(e: NoSuchFieldException): ResponseEntity<ResponseData> {
return ResponseEntity.badRequest()
.body(ResponseData(HttpStatus.BAD_REQUEST.value(), "试图访问一个不存在的数据库字段", e.message ) ) } }
@@ -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) } }
@@ -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") } } }
@@ -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<String> {
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<String> {
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头部") } }
@@ -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<ResponseData> {
return withContext(VirtualThreadCoroutineDispatcher) {
val ChannelID = Snowflake(RequestBody.ChannelId)
val MessageData = DiscordBotClient
.getChannelOf<TextChannel>(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<ResponseData> {
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<ResponseData> {
return ResponseEntity.badRequest()
.body(ResponseData(
HttpStatus.BAD_REQUEST.value(),
"请求缺少必要的头部信息",
mapOf("缺失的头部" to Exception.headerName ) ) ) }
@ExceptionHandler(HttpMessageNotReadableException::class)
fun HandleMessageNotReadable(Exception: HttpMessageNotReadableException, Request: HttpServletRequest): ResponseEntity<ResponseData> {
return ResponseEntity.badRequest()
.body(ResponseData(
HttpStatus.BAD_REQUEST.value(),
"请求体无法解析",
mapOf("错误信息" to Exception.message ) ) ) }
@ExceptionHandler(Exception::class)
fun HandleOtherException(Exception: Exception, Request: HttpServletRequest): ResponseEntity<ResponseData> {
return ResponseEntity.internalServerError()
.body(ResponseData(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"处理请求时发生未知错误",
mapOf("错误信息" to Exception.message ) ) ) } }
@@ -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) } }
@@ -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() } }
@@ -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<AccessLogRepository>()
try {
SQLiteWriteLock.lock()
AccessLogRepository.save(this) }
finally {
SQLiteWriteLock.unlock()
CachedResponse?.copyBodyToResponse() } } }
@@ -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<AccessLog, Long>, JpaSpecificationExecutor<AccessLog>
@@ -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<AccessLog>{
Root, CriteriaQuery, CriteriaBuilder ->
CriteriaQuery.orderBy(
when(Order){
Sort.Direction.ASC -> CriteriaBuilder.asc(Root.get<Any>(TargetFieldName))
Sort.Direction.DESC -> CriteriaBuilder.desc(Root.get<Any>(TargetFieldName)) })
return@Specification CriteriaBuilder.conjunction() } }
@@ -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)
@@ -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() }
@@ -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<GuildChannel>(channelId)
val guild = channel?.guildId?.toString() ?: "@me"
return@runBlocking "https://discord.com/channels/$guild/$channelId/$id" } }
@@ -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 } }
@@ -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<String>) {
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) } } }
@@ -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<String>) {
Hooks.enableAutomaticContextPropagation()
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL)
ProcessArguments(args)
runApplication<WebApiApplication>(*args)
}
+5
View File
@@ -19,9 +19,14 @@ spring:
username: username:
password: password:
url: jdbc:sqlite:SQLiteDataBase.db url: jdbc:sqlite:SQLiteDataBase.db
servlet:
encoding:
charset: UTF-8
force: true
server: server:
port: 36987 port: 36987
logging: logging:
pattern: pattern:
level: "%5p %X{RequestID}" level: "%5p %X{RequestID}"
@@ -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() {
}
}
@@ -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() {
}
}