合并拉取请求 #4

支持不提供Discord令牌启动服务
This commit is contained in:
2026-07-31 13:39:01 +08:00
committed by GitHub
11 changed files with 151 additions and 156 deletions
+8
View File
@@ -8,6 +8,14 @@
能用,但缺的东西非常多。
就像这个文档一样。
# 命令行参数
| 参数 | 说明 |
|---|------------------------------------------------------------|
| `install` / `--install` | 安装为 systemd 服务 |
| `uninstall` / `--uninstall` | 卸载 systemd 服务 |
| `--Discord_Bot_Token <令牌>` | 设置 Discord 机器人令牌,优先级高于同名环境变量 |
| `--Disable-Discord` | 安装systemd服务时禁用 Discord 相关功能,故不再要求提供令牌 |
# 注意事项
**本项目不保证配置文件和环境变量的向后兼容!!!**
**也就是说,如果你也要用的话,你可能必须在更新之后重新配置环境变量和配置文件**
+1 -1
View File
@@ -13,7 +13,7 @@ plugins {
}
group = "xyz.thewhitedog9487"
version = "0.8.2"
version = "0.8.3"
java {
toolchain {
@@ -21,7 +21,7 @@ class OpenApiConfiguration {
.contact(Contact()
.name("TheWhiteDog9487")
.url("https://www.github.com/TheWhiteDog9487/WebAPI"))
.version("0.8.2")
.version("0.8.3")
.license(License()
.name("WTFPL")
.url("https://spdx.org/licenses/WTFPL")) )
@@ -16,7 +16,7 @@ import xyz.thewhitedog9487.WebAPI.Data.Entity.AccessLog
import xyz.thewhitedog9487.WebAPI.Miscellaneous.SpringContextHolder.SpringContext
import java.nio.charset.StandardCharsets
class ApiKeyAuthenticationFilter(): OncePerRequestFilter() {
class ApiKeyAuthenticationFilter: OncePerRequestFilter() {
val ApiKeyManagerInstance = SpringContext.getBean<ApiKeyManager>()
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
@@ -41,22 +41,18 @@ class AccessLog(val AccessLogRepositoryInstance: AccessLogRepository) {
examples = [
ExampleObject(
name = "Limit小于0",
value = """
{
value = """{
"code": 400,
"message": "Limit参数不能小于0",
"data": null
}
""" ),
}""" ),
ExampleObject(
name = "字段不存在",
value = """
{
value = """{
"code": 400,
"message": "试图访问一个不存在的数据库字段",
"data": "AccessLog类中不存在名为 NotExistField 的字段"
}
""" ) ] ) ] ),
}""" ) ] ) ] ),
ApiResponse(
responseCode = "200",
description = "成功获取到数据",
@@ -68,8 +64,7 @@ class AccessLog(val AccessLogRepositoryInstance: AccessLogRepository) {
schema = Schema(implementation = AccessLog::class) ),
examples = [
ExampleObject(
value = """
[
value = """[
{
"ID": 18,
"RequestID": "3",
@@ -105,27 +100,8 @@ class AccessLog(val AccessLogRepositoryInstance: AccessLogRepository) {
"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密钥",
@@ -135,13 +111,11 @@ class AccessLog(val AccessLogRepositoryInstance: AccessLogRepository) {
schema = Schema(implementation = ResponseData::class),
examples = [
ExampleObject(
value = """
{
value = """{
"code": 401,
"message": "API密钥验证失败,未传递X-API-Key请求头",
"data": null
}
""" ) ] ) ] ),
}""" ) ] ) ] ),
ApiResponse(
responseCode = "403",
description = "API密钥无效",
@@ -151,13 +125,11 @@ class AccessLog(val AccessLogRepositoryInstance: AccessLogRepository) {
schema = Schema(implementation = ResponseData::class),
examples = [
ExampleObject(
value = """
{
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,
@@ -26,12 +26,13 @@ class IP {
1. CF-Connecting-IP
2. X-Forwarded-For
3. 直接使用请求的远程地址""")
@ApiResponse(responseCode = "200",
@ApiResponse(
responseCode = "200",
description = "成功获取到IP地址,内容为纯文本格式的IP地址",
content = [Content(
mediaType = MediaType.TEXT_PLAIN_VALUE,
schema = Schema(implementation = String::class),
examples = [ExampleObject(value = "78.141.226.247")])],
description = "成功获取到IP地址,内容为纯文本格式的IP地址")
examples = [ExampleObject(value = "78.141.226.247") ] ) ] )
@GetMapping("")
fun GetIP(@RequestHeader("CF-Connecting-IP", required = false) Header_CFConnectingIP: String?,
@RequestHeader("X-Forwarded-For", required = false) Header_XForwardedFor: String?,
@@ -54,18 +55,20 @@ class IP {
如果请求没有经过Cloudflare,则会返回"未找到CF-IPCountry头部"
""")
@ApiResponses(value = [
ApiResponse(responseCode = "200",
ApiResponse(
responseCode = "200",
description = "成功获取到ISO 3166-1 alpha-2国家代码,内容为纯文本格式的国家代码",
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",
examples = [ExampleObject(value = "HK") ] ) ] ),
ApiResponse(
responseCode = "404",
description = "未找到CF-IPCountry头部,内容为纯文本格式的错误信息",
content = [Content(
mediaType = MediaType.TEXT_PLAIN_VALUE,
schema = Schema(implementation = String::class),
examples = [ExampleObject(value = "未找到CF-IPCountry头部")] ) ],
description = "未找到CF-IPCountry头部,内容为纯文本格式的错误信息") ] )
examples = [ExampleObject(value = "未找到CF-IPCountry头部") ] ) ] ) ] )
@GetMapping("iso3166")
fun GetIso3166(@RequestHeader("CF-IPCountry", required = false) Header_CFIPCountry: String?): ResponseEntity<String> {
Header_CFIPCountry?.let {
@@ -20,8 +20,8 @@ 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.DiscordBotTokenDoesNotConfiguredException
import xyz.thewhitedog9487.WebAPI.Exception.NoSuchDiscordChannelException
import xyz.thewhitedog9487.WebAPI.Miscellaneous.VirtualThreadCoroutineDispatcher
import xyz.thewhitedog9487.WebAPI.Miscellaneous.link
@@ -30,7 +30,7 @@ import java.net.URI
@Tag(name="远程消息处理相关")
@RestController
@RequestMapping("/message")
class Message(val DiscordBotClient: Kord) {
class Message(val DiscordBotClient: Kord?) {
@JsonAutoDetect(
fieldVisibility = JsonAutoDetect.Visibility.ANY,
getterVisibility = JsonAutoDetect.Visibility.NONE,
@@ -51,92 +51,97 @@ class Message(val DiscordBotClient: Kord) {
需要在请求头中提供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 = "未知错误") ] ) ],
ApiResponse(
responseCode = "201",
description = "消息发送成功,响应体中包含新消息的ID、频道ID和内容",
content = [Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = Schema(implementation = ResponseData::class),
examples = [ExampleObject(value = """{
"code": 201,
"message": "消息发送成功",
"data": {
"新ID": "1407848258813825135",
"频道ID": "1398192763845214239",
"内容": "写点什么好呢"
}
}""") ] ) ] ),
ApiResponse(
responseCode = "400",
description = "客户端发送的请求存在问题,请检查响应的data字段以获取更多信息",
content = [Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = Schema(implementation = ResponseData::class),
examples = [ExampleObject(
name = "请求体无法解析",
value = """{
"code": 400,
"message": "请求体无法解析",
"data": {
"错误信息": "JSON parse error: Unexpected character ('}' (code 125)): was expecting double-quote to start field name"
}
}"""),
ExampleObject(
name = "频道不存在",
value =
"""{
"code": 400,
"message": "消息发送失败",
"data": {
"错误信息": "ID为1398192763845214239的频道不存在",
"频道ID": "1398192763845214239"
}
}""") ] ) ] ),
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
}""") ] ) ] ),
ApiResponse(
responseCode = "503",
description = "服务端未配置Discord令牌,无法使用Discord相关功能",
content = [Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = Schema(implementation = ResponseData::class),
examples = [ExampleObject(value = """{
"code": 503,
"message": "服务端尚未配置Discord令牌,无法使用Discord相关功能!",
"data": null
}""") ] ) ] ),
ApiResponse(
responseCode = "500",
description =
"""
"""
处理请求时发生未知错误,可能是由于Discord服务器问题或其他内部错误,请查看响应Body以确定原因
另外,所有未被针对性处理的异常都会触发此响应
""") ] )
""",
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}"
}
}""") ] ) ] ) ] )
@PostMapping("/discord")
suspend fun DiscordPush(
@Parameter(description = "用于身份验证的API密钥", `in` = ParameterIn.HEADER, required = true, example = "ds1858dscc8745sfwe")
@@ -148,9 +153,10 @@ class Message(val DiscordBotClient: Kord) {
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = Schema(implementation = PostMessageData::class) ) ] )
@RequestBody RequestBody: PostMessageData): ResponseEntity<ResponseData> {
DiscordBotClient ?: throw DiscordBotTokenDoesNotConfiguredException()
return withContext(VirtualThreadCoroutineDispatcher) {
val ChannelID = Snowflake(RequestBody.ChannelId)
val MessageData = DiscordBotClient
val MessageData = DiscordBotClient!!
.getChannelOf<TextChannel>(ChannelID)
?.createMessage(RequestBody.Content)
?: throw NoSuchDiscordChannelException(ChannelID)
@@ -162,6 +168,13 @@ class Message(val DiscordBotClient: Kord) {
"新ID" to MessageData.id.toString(),
"频道ID" to ChannelID.value,
"内容" to RequestBody.Content ) ) ) } }
@ExceptionHandler(DiscordBotTokenDoesNotConfiguredException::class)
fun TriggerWhenDiscordBotTokenDoesNotConfigured(Exception: DiscordBotTokenDoesNotConfiguredException, Request: HttpServletRequest): ResponseEntity<ResponseData> {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(ResponseData(
HttpStatus.SERVICE_UNAVAILABLE.value(),
Exception.message ) ) }
@ExceptionHandler(NoSuchDiscordChannelException::class)
fun TriggerWhenSpecificChannelDoesNotExist(Exception: NoSuchDiscordChannelException, Request: HttpServletRequest): ResponseEntity<ResponseData> {
return ResponseEntity.badRequest()
@@ -171,13 +184,6 @@ class Message(val DiscordBotClient: Kord) {
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()
@@ -105,7 +105,7 @@ cookie: JSESSIONID=A49527B913170D86A5544A252BD31040
var Header: String = "",
@JsonProperty("Timestamp")
@Schema(description = "请求时间ISO-8601格式", example = "2025-09-29T03:49:42.766Z")
@Schema(description = "请求时间 ISO-8601格式", example = "2025-09-29T03:49:42.766Z")
var Timestamp: Instant = Instant.now(),
@JsonProperty("ResponseStatusCode")
@@ -126,11 +126,11 @@ cookie: JSESSIONID=A49527B913170D86A5544A252BD31040
httpRequest.requestURL.toString(),
httpRequest.queryString,
httpRequest.headerNames.toList().joinToString("\n") { name ->
return@joinToString "$name: ${httpRequest.getHeaders(name).toList().joinToString(", ")}" },
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 SQLiteWriteLock = SpringContext.getBean<ReentrantLock>("SQLiteWriteLock")
val AccessLogRepository = SpringContext.getBean<AccessLogRepository>()
try {
SQLiteWriteLock.lock()
@@ -0,0 +1,3 @@
package xyz.thewhitedog9487.WebAPI.Exception
class DiscordBotTokenDoesNotConfiguredException: Exception("服务端尚未配置Discord令牌,无法使用Discord相关功能!")
@@ -20,27 +20,27 @@ class GlobalSharedBean {
val Logger = KotlinLogging.logger{}
@Bean
fun DiscordClient(@Value("\${Discord_Bot_Token:}") DiscordBotToken: String): Kord {
fun DiscordClient(@Value("\${Discord_Bot_Token:}") DiscordBotToken: String): Kord? {
if (DiscordBotToken.isEmpty()) {
Logger.error { "未通过参数--Discord_Bot_Token或环境变量Discord_Bot_Token提供Discord登录令牌,请检查配置" }
throw IllegalArgumentException("未提供Discord登录令牌") }
Logger.warn { "未通过参数--Discord_Bot_Token或环境变量Discord_Bot_Token提供Discord登录令牌,将无法使用Discord相关功能" }
return null }
return runBlocking(VirtualThreadCoroutineDispatcher) { Kord(DiscordBotToken) } }
@Bean
fun KordLifecycleManager(KordInstance: Kord) = object: SmartLifecycle {
fun KordLifecycleManager(KordInstance: Kord?) = object: SmartLifecycle {
var Job: Job? = null
override fun start() {
Job = CoroutineScope(VirtualThreadCoroutineDispatcher).launch {
KordInstance.login { } } }
KordInstance?.login { } } }
override fun stop() {
runBlocking(VirtualThreadCoroutineDispatcher) {
try {
KordInstance.logout()
KordInstance?.logout()
} catch (_: IllegalStateException) { }
Job?.cancelAndJoin() } }
override fun isRunning() = Job?.isActive ?: false }
@get:Bean
val SQLiteWriteLock: Lock = ReentrantLock() }
@get:Bean(name = ["SQLiteWriteLock"])
val SQLiteWriteLock: Lock = ReentrantLock() }
@@ -60,13 +60,16 @@ fun ProcessArguments(CommandLineArguments: Array<String>) {
在Native Image / Jar文件旁边生成指向自身的,名为Current的软连接
然后在软连接旁生成systemd服务文件,执行目标指向软连接
*/
val DisableDiscord = "--Disable-Discord" in CommandLineArguments
val DiscordBotToken = if ("--Discord_Bot_Token" in CommandLineArguments &&
CommandLineArguments.getOrNull(CommandLineArguments.indexOf("--Discord_Bot_Token") + 1) != null ){
CommandLineArguments[CommandLineArguments.indexOf("--Discord_Bot_Token") + 1] }
else System.getenv().getOrDefault("Discord_Bot_Token", "")
if (DiscordBotToken.isEmpty()) {
if (DiscordBotToken.isEmpty() && DisableDiscord == false) {
Logger.error { "必须通过--Discord_Bot_Token参数或Discord_Bot_Token环境变量提供Discord机器人的令牌以使本程序正常工作。" }
throw IllegalArgumentException("缺少必须的--Discord_Bot_Token参数") }
if (DisableDiscord) {
Logger.warn { "已通过--Disable-Discord参数禁用Discord功能,程序无法使用Discord相关功能。" } }
Logger.info { "开始安装systemd服务" }
Logger.debug { "SystemProperties.os.name: $CurrentOS" }
if (CurrentOS.startsWith("Linux")) {
@@ -100,7 +103,7 @@ fun ProcessArguments(CommandLineArguments: Array<String>) {
ExecStart=$ExecCommand
Restart=always
Type=simple
Environment="Discord_Bot_Token=$DiscordBotToken"
${if (DisableDiscord == false) "Environment=\"Discord_Bot_Token=$DiscordBotToken\"" else ""}
[Install]
WantedBy=multi-user.target