refactor: 代码调整

This commit is contained in:
叁玖领域 2026-02-02 12:48:55 +08:00
parent 4da8263b45
commit 37eeaf143c
63 changed files with 1383 additions and 583 deletions

View File

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="@47.116.125.76" uuid="5b1b9d12-d8be-43ba-a647-9d6e467bf201">
<data-source source="LOCAL" name="@110.42.70.155" uuid="5b1b9d12-d8be-43ba-a647-9d6e467bf201">
<driver-ref>mysql.8</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mysql://47.116.125.76:3308</jdbc-url>
<jdbc-url>jdbc:mysql://110.42.70.155:3306</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />

View File

@ -31,6 +31,10 @@ repositories {
maven {
url = uri("https://repo.glaremasters.me/repository/public/")
}
maven {
name = "LTD Maven"
url = uri("https://nexus.bot.leisuretimedock.top/repository/maven-public/")
}
}
//TODO: 0872d1c0-829c-e1d7-6782-89e45c8a6b76
dependencies {
@ -77,7 +81,7 @@ repositories {
//DG_Lab 依赖库导入
implementation("io.netty:netty-all:4.1.109.Final")
implementation("com.google.code.gson:gson:2.10.1")
implementation(files("libs/DgLab-common-${k("dg_lab_version")}.jar"))
implementation("top.r3944realms.dg_lab:Common:${k("dg_lab_version")}")
//生成 二维码
implementation("com.google.zxing:core:[3.5.3,)")

View File

@ -4,4 +4,4 @@ org.gradle.parallel=true
org.gradle.degree_of_parallelism=16
project_group=top.r3944realms.ltdmanager
project_version=1.14-SNAPSHOT
dg_lab_version=4.3.13.18
dg_lab_version=4.4.14.18

View File

@ -4,6 +4,7 @@ import kotlinx.coroutines.*
import top.r3944realms.ltdmanager.blessingskin.BlessingSkinClient
import top.r3944realms.ltdmanager.chevereto.CheveretoClient
import top.r3944realms.ltdmanager.core.mysql.MysqlHikariConnectPool
import top.r3944realms.ltdmanager.mcms.MCSMClient
import top.r3944realms.ltdmanager.mcserver.McSrvStatusClient
import top.r3944realms.ltdmanager.module.ModuleManager
import top.r3944realms.ltdmanager.napcat.NapCatClient
@ -20,6 +21,10 @@ object GlobalManager {
MysqlHikariConnectPool()
}
fun initApplication() {
}
// NapCat 客户端
val napCatClient: NapCatClient by lazy {
NapCatClient.create()
@ -33,6 +38,9 @@ object GlobalManager {
val cheveretoClient: CheveretoClient by lazy {
CheveretoClient.create()
}
val mcsmClient: MCSMClient by lazy {
MCSMClient.create()
}
val moduleManager: ModuleManager by lazy { ModuleManager() }
@ -72,7 +80,8 @@ object GlobalManager {
"McSrvStatusClient" to { mcSrvStatusClient.close() },
"BlessingSkinClient" to { blessingSkinClient.close() },
"Hikari 数据源" to { dataSource.close() },
"CheveretoClient" to { cheveretoClient.close() }
"CheveretoClient" to { cheveretoClient.close() },
"McsmClient" to { mcsmClient.close() },
)
resources.forEach { (name, closer) ->
@ -99,4 +108,5 @@ object GlobalManager {
isRunning.set(false)
}
}

View File

@ -1,27 +1,24 @@
package top.r3944realms.ltdmanager.blessingskin
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
import top.r3944realms.ltdmanager.core.client.IClient
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.utils.Environment
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.net.URLEncoder
import java.util.*
class BlessingSkinClient private constructor() : AutoCloseable {
class BlessingSkinClient private constructor() : IClient<BlessingSkinRequest, BlessingSkinQueueItem, BlessingSkinResponse, FailedBlessingSkinResponse> {
private val client = HttpClient(CIO) {
expectSuccess = false
@ -40,170 +37,40 @@ class BlessingSkinClient private constructor() : AutoCloseable {
// 限流控制
private val semaphore = Semaphore(5)
private val requestMutex = Mutex()
private val requestQueue = PriorityQueue<BlessingSkinQueueItem<BlessingSkinResponse, FailedBlessingSkinResponse>>(compareBy { it.priority })
private val requestQueue = PriorityQueue<BlessingSkinQueueItem>(compareBy { it.priority })
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
startQueueProcessor()
init()
}
/**
* 提交请求
*/
suspend fun <T : BlessingSkinResponse, F : FailedBlessingSkinResponse> submitRequest(
request: BlessingSkinRequest<T, F>,
priority: Int = 5,
maxRetries: Int = 3
): ResponseResult<T, F> {
val deferred = CompletableDeferred<ResponseResult<T, F>>()
requestMutex.withLock {
requestQueue.add(BlessingSkinQueueItem(request, deferred, priority, maxRetries, true))
}
return deferred.await()
override fun getBaseUrl(): String = blessingSkinServerConfig.url!!
override fun getType(): String = "BlessingSkinClient"
override fun getClient(): HttpClient = client
override fun getSemaphore(): Semaphore = semaphore
override fun getRequestMutex(): Mutex = requestMutex
override fun getResponseQueue(): PriorityQueue<BlessingSkinQueueItem> = requestQueue
override fun getScope(): CoroutineScope = scope
override fun createFailureResponse(exception: Exception?): IFailedResponse {
return FailedBlessingSkinResponse.Default(exception?.stackTraceToString()?:"ERROR")
}
/**
* 启动队列处理器
*/
private fun startQueueProcessor() {
scope.launch {
while (isActive) {
val item = requestMutex.withLock {
requestQueue.poll()
}
if (item == null) {
delay(50)
continue
}
processQueueItem(item)
}
}
}
/**
* 处理队列项
*/
private suspend fun processQueueItem(item: BlessingSkinQueueItem<BlessingSkinResponse, FailedBlessingSkinResponse>) {
semaphore.withPermit {
val (request, deferred, _, maxRetries, _) = item
var attempt = 0
var lastError: Exception? = null
while (attempt < maxRetries) {
try {
// 构建完整的URL包括查询参数
val fullUrl = buildFullUrlWithQueryParams(request)
if (!Environment.isProduction()) {
LoggerUtil.logger.debug("发送请求到: $fullUrl")
LoggerUtil.logger.debug("请求方法: {}", request.method())
}
val response = client.request(fullUrl) {
method = request.method()
// 设置请求头
headers {
request.headers().invoke(this)
}
// 对于非GET请求设置请求体
if (request.method() != HttpMethod.Get) {
setBody(request.toJSON())
}
}
val responseText: String = response.body()
if (!Environment.isProduction()) {
LoggerUtil.logger.debug("响应状态: {}", response.status)
LoggerUtil.logger.debug("响应内容: $responseText")
}
// 检查是否是HTML响应重定向
if (isHtmlResponse(responseText)) {
throw IllegalStateException("接收到HTML重定向响应请检查API URL配置")
}
// 解析响应
val result = request.getResponse(responseText, response.status)
@Suppress("UNCHECKED_CAST")
(deferred as CompletableDeferred<ResponseResult<BlessingSkinResponse, FailedBlessingSkinResponse>>).complete(result)
return
} catch (e: Exception) {
lastError = e
attempt++
if (!request.shouldRetryOnFailure() || attempt >= maxRetries) {
break
}
LoggerUtil.logger.warn("BlessingSkin请求失败 (尝试 $attempt/$maxRetries): ${e.message}")
delay((attempt * 1000L)) // 指数退避
}
}
// 所有重试都失败或不应重试
val errorResponse = createFailureResponse(lastError, request)
@Suppress("UNCHECKED_CAST")
(deferred as CompletableDeferred<ResponseResult<BlessingSkinResponse, FailedBlessingSkinResponse>>).complete(
ResponseResult.Failure(errorResponse)
)
}
}
/**
* 构建完整的URL包含查询参数
*/
private fun buildFullUrlWithQueryParams(request: BlessingSkinRequest<*, *>): String {
val baseUrl = blessingSkinServerConfig.url?.removeSuffix("/")
val path = request.path().removePrefix("/")
// 构建基础URL
val urlBuilder = StringBuilder("$baseUrl/$path")
// 添加查询参数
val queryParams = request.queryParameters().entries.joinToString("&") { (key, value) ->
"${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}"
}
if (queryParams.isNotEmpty()) {
urlBuilder.append("?").append(queryParams)
}
return urlBuilder.toString()
}
/**
* 检查是否是HTML响应
*/
private fun isHtmlResponse(text: String): Boolean {
return text.contains("<!DOCTYPE html>", ignoreCase = true) ||
text.contains("<html>", ignoreCase = true) ||
text.contains("Redirecting", ignoreCase = true)
}
/**
* 创建失败响应
*/
private fun createFailureResponse(
exception: Exception?,
request: BlessingSkinRequest<*, *>
): FailedBlessingSkinResponse {
return FailedBlessingSkinResponse.Default(
failedResult = exception?.message ?: "未知错误",
)
}
override fun close() {
scope.cancel()
runBlocking {
client.close()
}
override fun addToQueue(
request: BlessingSkinRequest,
deferredC: CompletableDeferred<ResponseResult<BlessingSkinResponse, FailedBlessingSkinResponse>>,
priority: Int,
maxRetries: Int
): BlessingSkinQueueItem {
val element = BlessingSkinQueueItem(request, deferredC, priority, maxRetries, false)
requestQueue.add(element)
return element
}
companion object {

View File

@ -4,13 +4,14 @@ import kotlinx.coroutines.CompletableDeferred
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
import top.r3944realms.ltdmanager.core.client.QueueItem
data class BlessingSkinQueueItem<out T:BlessingSkinResponse,out F:FailedBlessingSkinResponse>(
val request: BlessingSkinRequest<T,F>,
val deferred: CompletableDeferred<*>,
var retries: Int,
val priority: Int,
val expectsResponse: Boolean // true 表示返回 BlessingSkinResponse, false 表示 Unit
) : Comparable<BlessingSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>> {
override fun compareTo(other: BlessingSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>): Int = priority.compareTo(other.priority)
}
data class BlessingSkinQueueItem (
val request0: BlessingSkinRequest,
val deferred0: CompletableDeferred<*>,
val priority0: Int,
var retries0: Int,
val expectsResponse0: Boolean
) : QueueItem<BlessingSkinRequest, BlessingSkinResponse, FailedBlessingSkinResponse> (
request0, deferred0, retries0, priority0, expectsResponse0
)

View File

@ -1,2 +1,13 @@
package top.r3944realms.ltdmanager.blessingskin.data
package top.r3944realms.ltdmanager.blessingskin.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class InvitationCode(
val code: String,
@SerialName("generated_at")
val generatedAt: String,
@SerialName("expires_at")
val expiresAt: String
)

View File

@ -1,79 +1,13 @@
package top.r3944realms.ltdmanager.blessingskin.request
import io.ktor.http.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
import top.r3944realms.ltdmanager.core.client.request.IRequest
@Serializable
abstract class BlessingSkinRequest<out T : BlessingSkinResponse, out F : FailedBlessingSkinResponse>(
abstract class BlessingSkinRequest(
@Transient
open val createTime: Long = System.currentTimeMillis()
) {
/**
* 转换为JSON字符串
*/
abstract fun toJSON(): String
/**
* 获取API路径不包含基础URL
* 例如: "invitation-codes/generate"
*/
abstract fun path(): String
/**
* 获取HTTP方法默认为GET因为大多数API使用GET+查询参数
*/
open fun method(): HttpMethod = HttpMethod.Get
/**
* 自定义请求头
*/
open fun headers(): HeadersBuilder.() -> Unit = {
// 默认添加Content-Type
append(HttpHeaders.ContentType, ContentType.Application.Json.toString())
// 添加Accept头
append(HttpHeaders.Accept, "application/json")
}
/**
* 获取查询参数用于URL参数
* 例如: mapOf("token" to "abc123", "amount" to "1")
*/
open fun queryParameters(): Map<String, String> = emptyMap()
/**
* 获取请求体参数用于POST请求的JSON body
* 例如: mapOf("token" to "abc123", "amount" to 1)
*/
open fun bodyParameters(): Map<String, Any> = emptyMap()
/**
* 获取请求体内容类型默认为Application.Json
*/
open fun contentType(): ContentType = ContentType.Application.Json
/**
* 解析响应JSON字符串
* @param responseJson 响应JSON字符串
* @param httpStatusCode HTTP状态码
*/
abstract fun getResponse(responseJson: String, httpStatusCode: HttpStatusCode): ResponseResult<T, F>
/**
* 获取预期的成功响应类型名称用于日志和调试
*/
abstract fun expectedResponseType(): String
/**
* 获取预期的失败响应类型名称用于日志和调试
*/
abstract fun expectedFailureType(): String
/**
* 是否需要在失败时重试默认重试
*/
open fun shouldRetryOnFailure(): Boolean = true
}
override val createTime: Long = System.currentTimeMillis()
): IRequest<BlessingSkinResponse, FailedBlessingSkinResponse>

View File

@ -6,8 +6,8 @@ import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import java.util.*
@ -17,9 +17,7 @@ class GenerateInvitationCodeRequest(
val token: String? = null,
@Transient
val amount: Int? = 1,
@Transient
override val createTime: Long = System.currentTimeMillis()
) : BlessingSkinRequest<InvitationCodeGenerationResponse, FailedBlessingSkinResponse.Default>() {
) : BlessingSkinRequest() {
override fun toJSON(): String {
// 对于GET请求参数在URL中body可以为空
@ -66,7 +64,7 @@ class GenerateInvitationCodeRequest(
} catch (e: Exception) {
ResponseResult.Failure(
FailedBlessingSkinResponse.Default(
failedResult = "解析响应失败: ${e.message}"
failedMessage = "解析响应失败: ${e.message}"
)
)
}

View File

@ -7,14 +7,15 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse
import top.r3944realms.ltdmanager.core.client.response.IResponse
@Serializable
abstract class BlessingSkinResponse (
@Transient
open val httpStatusCode: HttpStatusCode = HttpStatusCode.OK,
override val httpStatusCode: HttpStatusCode = HttpStatusCode.OK,
@Transient
open val createTime: Long = System.currentTimeMillis()
) {
override val createTime: Long = System.currentTimeMillis()
) : IResponse {
companion object {
// 通用的反序列化方法
inline fun <reified T : BlessingSkinResponse> decode(jsonString: String): T {

View File

@ -2,13 +2,12 @@ package top.r3944realms.ltdmanager.blessingskin.response
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
@Serializable
abstract class FailedBlessingSkinResponse: BlessingSkinResponse() {
abstract fun failedMessage(): String
abstract class FailedBlessingSkinResponse: BlessingSkinResponse(), IFailedResponse {
@Serializable
class Default(@Transient val failedResult: String? = "未知错误") : FailedBlessingSkinResponse() {
override fun failedMessage(): String = failedResult!!
class Default(@Transient override val failedMessage: String = "未知错误") : FailedBlessingSkinResponse() {
}
}

View File

@ -1,7 +1,7 @@
package top.r3944realms.ltdmanager.blessingskin.response.invitecode
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import top.r3944realms.ltdmanager.blessingskin.data.InvitationCode
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
@Serializable
data class InvitationCodeGenerationResponse(
@ -10,12 +10,4 @@ data class InvitationCodeGenerationResponse(
val data: List<InvitationCode>? = null
) : BlessingSkinResponse() {
@Serializable
data class InvitationCode(
val code: String,
@SerialName("generated_at")
val generatedAt: String,
@SerialName("expires_at")
val expiresAt: String
)
}

View File

@ -3,65 +3,157 @@ package top.r3944realms.ltdmanager.chevereto
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.chevereto.data.CheveretoResponse
import top.r3944realms.ltdmanager.chevereto.data.CheveretoSource
import top.r3944realms.ltdmanager.chevereto.request.CheveretoRequest
import top.r3944realms.ltdmanager.chevereto.request.v1.CheveretoUploadRequest
import top.r3944realms.ltdmanager.chevereto.response.CheveretoResponse
import top.r3944realms.ltdmanager.chevereto.response.FailedCheveretoResponse
import top.r3944realms.ltdmanager.chevereto.response.v1.CheveretoUploadResponse
import top.r3944realms.ltdmanager.core.client.IClient
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
import top.r3944realms.ltdmanager.core.client.response.IResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.utils.Environment
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.io.ByteArrayInputStream
import java.io.Closeable
import java.io.File
import java.util.*
import kotlin.collections.ArrayDeque
class CheveretoClient private constructor() : Closeable {
class CheveretoClient private constructor() :
IClient<CheveretoRequest, CheveretoQueueItem, CheveretoResponse, FailedCheveretoResponse> {
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
expectSuccess = false
// 安装 HttpTimeout 插件
install(HttpTimeout) {
// 默认超时配置,会被具体请求的配置覆盖
requestTimeoutMillis = 30000
connectTimeoutMillis = 10000
socketTimeoutMillis = 15000
}
}
private val imgTuConfig = YamlConfigLoader.loadTuImgConfig()
private val apiUrl = imgTuConfig.url!!
private val baseUrl = imgTuConfig.url!!.removeSuffix("/")
private val apiKey = imgTuConfig.decryptedPassword!!
// 限流,同时最多 3 个上传
private val semaphore = Semaphore(3)
// 普通队列 (按 priority 排序)
private val queue = PriorityQueue<CheveretoQueueItem<CheveretoResponse>>(compareBy { it.priority })
private val queue = PriorityQueue<CheveretoQueueItem>()
private val queueMutex = Mutex()
// 紧急队列 (FIFO最多 10 个)
private val urgentQueue = ArrayDeque<CheveretoQueueItem<CheveretoResponse>>(10)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
scope.launch {
while (isActive) {
val item = queueMutex.withLock {
when {
urgentQueue.isNotEmpty() -> urgentQueue.removeFirst()
queue.isNotEmpty() -> queue.poll()
else -> null
init()
}
override fun getType(): String = "CheveretoClient"
override fun getClient(): HttpClient = client
override fun getSemaphore(): Semaphore = semaphore
override fun getRequestMutex(): Mutex = queueMutex
override fun getResponseQueue(): PriorityQueue<CheveretoQueueItem> = queue
override fun getScope(): CoroutineScope = scope
override fun getBaseUrl(): String = baseUrl
override fun createFailureResponse(exception: Exception?): FailedCheveretoResponse =
FailedCheveretoResponse.Default(
httpStatusCode = HttpStatusCode.InternalServerError,
failedMessage = exception?.message ?: "Unknown error"
)
override fun addToQueue(
request: CheveretoRequest,
deferredC: CompletableDeferred<ResponseResult<CheveretoResponse, FailedCheveretoResponse>>,
priority: Int,
maxRetries: Int
): CheveretoQueueItem {
val item = CheveretoQueueItem(request, deferredC, maxRetries, priority, true)
queue.add(item)
return item
}
override suspend fun processQueueItem(item: CheveretoQueueItem) {
getSemaphore().withPermit {
val request = item.request
val deferred = item.deferred
val maxRetries = item.retries
var attempt = 0
var lastError: Exception?
while (attempt < maxRetries) {
try {
val fullUrl = buildFullUrlWithQueryParams(request)
if (!Environment.isProduction()) {
LoggerUtil.logger.debug("发送请求到: $fullUrl")
LoggerUtil.logger.debug("请求方法: {}", request.method())
}
val response = getClient().request(fullUrl) {
method = request.method()
// 设置请求头
headers {
request.headers().invoke(this)
header("X-API-Key", apiKey)
}
// 对于非GET请求设置请求体
if (request.method() != HttpMethod.Get) {
setBody(request.toJSON())
}
}
val responseText: String = response.body()
if (!Environment.isProduction()) {
LoggerUtil.logger.debug("响应状态: {}", response.status)
LoggerUtil.logger.debug("响应内容: $responseText")
}
// 检查是否是HTML响应重定向
if (isHtmlResponse(responseText)) {
throw IllegalStateException("接收到HTML重定向响应请检查API URL配置")
}
// 解析响应
val result = request.getResponse(responseText, response.status)
@Suppress("UNCHECKED_CAST")
(deferred as CompletableDeferred<ResponseResult<IResponse, IFailedResponse>>).complete(result)
return
} catch (e: Exception) {
lastError = e
attempt++
if (!request.shouldRetryOnFailure() || attempt >= maxRetries) {
break
}
LoggerUtil.logger.warn("${getType()} 请求失败 (尝试 $attempt/$maxRetries): ${e.message}")
delay((attempt * 1000L)) // 指数退避
}
if (item != null) processItem(item)
else delay(20)
// 所有重试都失败或不应重试
val errorResponse = createFailureResponse(lastError)
@Suppress("UNCHECKED_CAST")
(deferred as CompletableDeferred<ResponseResult<IResponse, IFailedResponse>>).complete(
ResponseResult.Failure(errorResponse)
)
}
}
}
/**
* 上传 File
*/
@ -77,35 +169,24 @@ class CheveretoClient private constructor() : Closeable {
nsfw: Int? = null,
format: String = "json",
useFileDate: Int? = null,
priority: Int = 5
priority: Int = 5,
maxRetries: Int = 3
): CheveretoResponse {
val deferred = CompletableDeferred<CheveretoResponse>()
val source = suspend {
safeUpload {
submitFormWithBinaryData(
url = apiUrl,
formData = formData {
append("source", file.readBytes(), Headers.build {
append(HttpHeaders.ContentDisposition, "form-data; name=\"source\"; filename=\"${file.name}\"")
})
append("format", format)
title?.let { append("title", it) }
description?.let { append("description", it) }
tags?.let { append("tags", it) }
albumId?.let { append("album_id", it) }
categoryId?.let { append("category_id", it) }
width?.let { append("width", it.toString()) }
expiration?.let { append("expiration", it) }
nsfw?.let { append("nsfw", it.toString()) }
useFileDate?.let { append("use_file_date", it.toString()) }
}
) {
header("X-API-Key", apiKey)
}
}
}
queueMutex.withLock { queue.add(CheveretoQueueItem(source, deferred, priority)) }
return deferred.await()
upload(CheveretoUploadRequest(
source = CheveretoSource.ByteArraySource(file.readBytes(), file.name),
format = format,
title = title,
description = description,
tags = tags,
albumId = albumId,
categoryId = categoryId,
width = width,
expiration = expiration,
nsfw = nsfw,
useFileDate = useFileDate
), priority, maxRetries).getRetResponse()
throw Exception("Never Reach")
}
@ -125,36 +206,23 @@ class CheveretoClient private constructor() : Closeable {
nsfw: Int? = null,
format: String = "json",
useFileDate: Int? = null,
priority: Int = 5
priority: Int = 5,
maxRetries: Int = 3
): CheveretoResponse {
val deferred = CompletableDeferred<CheveretoResponse>()
val source = suspend {
val bytes = inputStream.readBytes()
safeUpload {
submitFormWithBinaryData(
url = apiUrl,
formData = formData {
append("source", bytes, Headers.build {
append(HttpHeaders.ContentDisposition, "form-data; name=\"source\"; filename=\"$fileName\"")
})
append("format", format)
title?.let { append("title", it) }
description?.let { append("description", it) }
tags?.let { append("tags", it) }
albumId?.let { append("album_id", it) }
categoryId?.let { append("category_id", it) }
width?.let { append("width", it.toString()) }
expiration?.let { append("expiration", it) }
nsfw?.let { append("nsfw", it.toString()) }
useFileDate?.let { append("use_file_date", it.toString()) }
}
) {
header("X-API-Key", apiKey)
}
}
}
queueMutex.withLock { queue.add(CheveretoQueueItem(source, deferred, priority)) }
return deferred.await()
upload(CheveretoUploadRequest(
source = CheveretoSource.ByteArraySource(inputStream.readBytes(), fileName),
format = format,
title = title,
description = description,
tags = tags,
albumId = albumId,
categoryId = categoryId,
width = width,
expiration = expiration,
nsfw = nsfw,
useFileDate = useFileDate
), priority, maxRetries).getRetResponse()
throw Exception("Never Reach")
}
/**
@ -172,64 +240,41 @@ class CheveretoClient private constructor() : Closeable {
nsfw: Int? = null,
format: String = "json",
useFileDate: Int? = null,
priority: Int = 5
priority: Int = 5,
maxRetries: Int = 3
): CheveretoResponse {
val deferred = CompletableDeferred<CheveretoResponse>()
val source = suspend {
safeUpload {
submitForm(
url = apiUrl,
formParameters = Parameters.build {
append("source", url)
append("format", format)
title?.let { append("title", it) }
description?.let { append("description", it) }
tags?.let { append("tags", it) }
albumId?.let { append("album_id", it) }
categoryId?.let { append("category_id", it) }
width?.let { append("width", it.toString()) }
expiration?.let { append("expiration", it) }
nsfw?.let { append("nsfw", it.toString()) }
useFileDate?.let { append("use_file_date", it.toString()) }
}
) {
header("X-API-Key", apiKey)
}
}
}
queueMutex.withLock { queue.add(CheveretoQueueItem(source, deferred, priority)) }
return deferred.await()
upload(CheveretoUploadRequest(
source = CheveretoSource.UrlSource(url),
format = format,
title = title,
description = description,
tags = tags,
albumId = albumId,
categoryId = categoryId,
width = width,
expiration = expiration,
nsfw = nsfw,
useFileDate = useFileDate
), priority, maxRetries).getRetResponse()
throw Exception("Never Reach")
}
private suspend fun processItem(item: CheveretoQueueItem<CheveretoResponse>) {
semaphore.withPermit {
try {
val result = item.source()
item.deferred.complete(result)
} catch (e: Exception) {
item.deferred.completeExceptionally(e)
}
}
}
/**
* 包装上传失败时打印原始响应
*/
private suspend fun safeUpload(block: suspend HttpClient.() -> HttpResponse): CheveretoResponse {
val response = client.block()
suspend fun upload(
request: CheveretoUploadRequest, priority: Int, maxRetries: Int
): ResponseResult<CheveretoUploadResponse, FailedCheveretoResponse> {
return try {
response.body()
@Suppress("UNCHECKED_CAST")
submitRequest(request, priority, maxRetries) as ResponseResult<CheveretoUploadResponse, FailedCheveretoResponse>
} catch (e: Exception) {
val raw = response.bodyAsText()
throw RuntimeException("Upload failed (status=${response.status}): $raw", e)
ResponseResult.Failure(
FailedCheveretoResponse.Default(
httpStatusCode = HttpStatusCode.InternalServerError,
failedMessage = "Byte array upload failed: ${e.message}"
)
)
}
}
override fun close() {
scope.cancel()
runBlocking { client.close() }
}
companion object {
fun create(): CheveretoClient = CheveretoClient()
}

View File

@ -1,9 +1,17 @@
package top.r3944realms.ltdmanager.chevereto
import kotlinx.coroutines.CompletableDeferred
import top.r3944realms.ltdmanager.chevereto.request.CheveretoRequest
import top.r3944realms.ltdmanager.chevereto.response.CheveretoResponse
import top.r3944realms.ltdmanager.chevereto.response.FailedCheveretoResponse
import top.r3944realms.ltdmanager.core.client.QueueItem
data class CheveretoQueueItem<T>(
val source: suspend () -> T,
val deferred: CompletableDeferred<T>,
val priority: Int = 5
data class CheveretoQueueItem(
val request0: CheveretoRequest,
val deferred0: CompletableDeferred<*>,
val priority0: Int,
var retries0: Int,
val expectsResponse0: Boolean
) : QueueItem<CheveretoRequest, CheveretoResponse, FailedCheveretoResponse>(
request0, deferred0, retries0, priority0, expectsResponse0
)

View File

@ -1,4 +1,11 @@
package top.r3944realms.ltdmanager.chevereto.data
class CheveretoSource {
import kotlinx.serialization.Serializable
@Serializable
sealed class CheveretoSource {
@Serializable
data class ByteArraySource(val bytes: ByteArray, val fileName: String) : CheveretoSource()
@Serializable
data class UrlSource(val url: String) : CheveretoSource()
}

View File

@ -3,7 +3,7 @@ package top.r3944realms.ltdmanager.chevereto.data
import kotlinx.serialization.Serializable
@Serializable
data class Success(
data class SuccessInfo(
val message : String? = null,
val code: Int? = 200,
)

View File

@ -1,4 +1,13 @@
package top.r3944realms.ltdmanager.chevereto.request
class CheveretoRequest {
}
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.chevereto.response.CheveretoResponse
import top.r3944realms.ltdmanager.chevereto.response.FailedCheveretoResponse
import top.r3944realms.ltdmanager.core.client.request.IRequest
@Serializable
abstract class CheveretoRequest(
@Transient
override val createTime: Long = System.currentTimeMillis()
) : IRequest<CheveretoResponse, FailedCheveretoResponse>

View File

@ -1,4 +1,89 @@
package top.r3944realms.ltdmanager.chevereto.request.v1
class CheveretoUploadRequest {
}
import io.ktor.http.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.chevereto.data.CheveretoSource
import top.r3944realms.ltdmanager.chevereto.request.CheveretoRequest
import top.r3944realms.ltdmanager.chevereto.response.FailedCheveretoResponse
import top.r3944realms.ltdmanager.chevereto.response.v1.CheveretoUploadResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
@Serializable
data class CheveretoUploadRequest(
private val source: CheveretoSource,
private val title: String? = null,
private val description: String? = null,
private val tags: String? = null,
@SerialName("album_id")
private val albumId: String? = null,
@SerialName("category_id")
private val categoryId: String? = null,
private val width: Int? = null,
private val expiration: String? = null,
private val nsfw: Int? = null,
private val format: String = "json",
@SerialName("use_file_date")
private val useFileDate: Int? = null
) : CheveretoRequest() {
override fun path(): String = "api/1/upload"
override fun method(): HttpMethod = HttpMethod.Post
override fun headers(): HeadersBuilder.() -> Unit = {
append(HttpHeaders.Accept, "application/json")
// 对于文件上传Content-Type 由 Ktor 自动设置
}
override fun bodyParameters(): Map<String, Any> {
val params = mutableMapOf<String, Any>()
title?.let { params["title"] = it }
description?.let { params["description"] = it }
tags?.let { params["tags"] = it }
albumId?.let { params["album_id"] = it }
categoryId?.let { params["category_id"] = it }
width?.let { params["width"] = it }
expiration?.let { params["expiration"] = it }
nsfw?.let { params["nsfw"] = it }
params["format"] = format
useFileDate?.let { params["use_file_date"] = it }
return params
}
override fun toJSON(): String = Json.encodeToString(this)
override fun getResponse(
responseJson: String,
httpStatusCode: HttpStatusCode
): ResponseResult<CheveretoUploadResponse, FailedCheveretoResponse> {
return try {
if (httpStatusCode.isSuccess()) {
val successResponse = Json.decodeFromString<CheveretoUploadResponse>(responseJson)
ResponseResult.Success(successResponse)
} else {
ResponseResult.Failure(
FailedCheveretoResponse.Default(
httpStatusCode = HttpStatusCode.InternalServerError,
failedMessage = responseJson.takeIf { it.isNotBlank() }?:"ERROR"
)
)
}
} catch (e: Exception) {
ResponseResult.Failure(
FailedCheveretoResponse.Default(
httpStatusCode = HttpStatusCode.InternalServerError,
failedMessage = "Failed to parse response: ${e.message}. Raw response: $responseJson"
)
)
}
}
override fun expectedResponseType(): String = "CheveretoUploadResponse"
override fun expectedFailureType(): String = "FailedCheveretoResponse"
override fun shouldRetryOnFailure(): Boolean = true
}

View File

@ -1,4 +1,37 @@
package top.r3944realms.ltdmanager.chevereto.response
class CheveretoSuccessResponse {
import io.ktor.http.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import top.r3944realms.ltdmanager.chevereto.response.v1.CheveretoUploadResponse
import top.r3944realms.ltdmanager.core.client.response.IResponse
@Serializable
abstract class CheveretoResponse (
@Transient
override val httpStatusCode: HttpStatusCode = HttpStatusCode.OK,
@Transient
override val createTime: Long = System.currentTimeMillis()
) : IResponse {
companion object {
// 通用的反序列化方法
inline fun <reified T : CheveretoResponse> decode(jsonString: String): T {
return json.decodeFromString(jsonString)
}
val json: Json by lazy {
Json {
ignoreUnknownKeys = true
serializersModule = SerializersModule {
polymorphic(CheveretoResponse::class) {
subclass(FailedCheveretoResponse.Default::class, FailedCheveretoResponse.Default.serializer())
subclass(CheveretoUploadResponse::class, CheveretoUploadResponse.serializer())
}
}
}
}
}
}

View File

@ -1,4 +1,12 @@
package top.r3944realms.ltdmanager.chevereto.response
class FailedCheveretoResponse {
import io.ktor.http.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
@Serializable
abstract class FailedCheveretoResponse: CheveretoResponse(), IFailedResponse {
@Serializable
class Default(@Transient override val httpStatusCode: HttpStatusCode = HttpStatusCode.OK, @Transient override val failedMessage: String = "未知错误") : FailedCheveretoResponse()
}

View File

@ -1,4 +1,17 @@
package top.r3944realms.ltdmanager.chevereto.response.v1
class CheveretoUploadResponse {
}
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import top.r3944realms.ltdmanager.chevereto.data.CheveretoImage
import top.r3944realms.ltdmanager.chevereto.data.SuccessInfo
import top.r3944realms.ltdmanager.chevereto.response.CheveretoResponse
@Serializable
data class CheveretoUploadResponse(
@SerialName("status_code")
val statusCode: Int,
val success: SuccessInfo,
val image: CheveretoImage,
@SerialName("status_txt")
val statusTxt: String
) : CheveretoResponse()

View File

@ -1,4 +1,170 @@
package top.r3944realms.ltdmanager.basic
package top.r3944realms.ltdmanager.core.client
interface IClient {
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import top.r3944realms.ltdmanager.core.client.request.IRequest
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
import top.r3944realms.ltdmanager.core.client.response.IResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
import top.r3944realms.ltdmanager.utils.Environment
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.net.URLEncoder
import java.util.*
interface IClient<R: IRequest<T, F>, Q: QueueItem<R, T, F>, T: IResponse, F: IFailedResponse> : AutoCloseable {
fun getType(): String
fun getClient(): HttpClient
fun getSemaphore(): Semaphore
fun getRequestMutex(): Mutex
fun getResponseQueue(): PriorityQueue<Q>
fun getScope(): CoroutineScope
fun getBaseUrl(): String = "http://localhost:1234"
fun createFailureResponse(exception: Exception? ): IFailedResponse
fun init() {
startQueueProcessor()
}
fun startQueueProcessor() {
getScope().launch {
while (isActive) {
val item = getRequestMutex().withLock {
getResponseQueue().poll()
}
if (item == null) {
delay(50)
continue
}
processQueueItem(item)
}
}
}
fun addToQueue(request: R,
deferredC: CompletableDeferred<ResponseResult<T, F>>,
priority: Int = 5,
maxRetries: Int = 3): Q
/**
* 提交请求
*/
suspend fun submitRequest(
request: R,
priority: Int = 5,
maxRetries: Int = 3
): ResponseResult<T, F> {
val deferred = CompletableDeferred<ResponseResult<T, F>>()
getRequestMutex().withLock {
addToQueue(request, deferred, priority, maxRetries)
}
return deferred.await()
}
suspend fun processQueueItem(item: Q) {
getSemaphore().withPermit {
val request = item.request
val deferred = item.deferred
val maxRetries = item.retries
var attempt = 0
var lastError: Exception?
while (attempt < maxRetries) {
try {
val fullUrl = buildFullUrlWithQueryParams(request)
if (!Environment.isProduction()) {
LoggerUtil.logger.debug("发送请求到: $fullUrl")
LoggerUtil.logger.debug("请求方法: {}", request.method())
}
val response = getClient().request(fullUrl) {
method = request.method()
// 设置请求头
headers {
request.headers().invoke(this)
}
// 对于非GET请求设置请求体
if (request.method() != HttpMethod.Get) {
setBody(request.toJSON())
}
}
val responseText: String = response.body()
if (!Environment.isProduction()) {
LoggerUtil.logger.debug("响应状态: {}", response.status)
LoggerUtil.logger.debug("响应内容: $responseText")
}
// 检查是否是HTML响应重定向
if (isHtmlResponse(responseText)) {
throw IllegalStateException("接收到HTML重定向响应请检查API URL配置")
}
// 解析响应
val result = request.getResponse(responseText, response.status)
@Suppress("UNCHECKED_CAST")
(deferred as CompletableDeferred<ResponseResult<IResponse, IFailedResponse>>).complete(result)
return
} catch (e: Exception) {
lastError = e
attempt++
if (!request.shouldRetryOnFailure() || attempt >= maxRetries) {
break
}
LoggerUtil.logger.warn("${getType()} 请求失败 (尝试 $attempt/$maxRetries): ${e.message}")
delay((attempt * 1000L)) // 指数退避
}
// 所有重试都失败或不应重试
val errorResponse = createFailureResponse(lastError)
@Suppress("UNCHECKED_CAST")
(deferred as CompletableDeferred<ResponseResult<IResponse, IFailedResponse>>).complete(
ResponseResult.Failure(errorResponse)
)
}
}
}
/**
* 构建完整的URL包含查询参数
*/
fun buildFullUrlWithQueryParams(request: R): String {
val baseUrl = getBaseUrl().removeSuffix("/")
val path = request.path().removePrefix("/")
// 构建基础URL
val urlBuilder = StringBuilder("$baseUrl/$path")
// 添加查询参数
val queryParams = request.queryParameters().entries.joinToString("&") { (key, value) ->
"${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}"
}
if (queryParams.isNotEmpty()) {
urlBuilder.append("?").append(queryParams)
}
return urlBuilder.toString()
}
/**
* 检查是否是HTML响应
*/
fun isHtmlResponse(text: String): Boolean {
return text.contains("<!DOCTYPE html>", ignoreCase = true) ||
text.contains("<html>", ignoreCase = true) ||
text.contains("Redirecting", ignoreCase = true)
}
override fun close() {
getScope().cancel()
runBlocking {
getClient().close()
}
}
}

View File

@ -1,19 +1,26 @@
package top.r3944realms.ltdmanager.core.client
import kotlinx.coroutines.CompletableDeferred
import top.r3944realms.ltdmanager.core.client.request.IRequest
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
import top.r3944realms.ltdmanager.core.client.response.IResponse
import java.util.concurrent.CompletableFuture
interface IQueueItem<out T:IResponse, out F:IFailedResponse> : Comparable<IQueueItem<@UnsafeVariance T, @UnsafeVariance F>> {
fun getRequest(): IRequest<T,F>
fun getDeferred(): CompletableFuture<*>
fun getRetries(): Int
fun getPriority(): Int
open class QueueItem<R: IRequest<T, F>, T:IResponse, F:IFailedResponse>(
val request: R,
val deferred: CompletableDeferred<*>,
val retries: Int,
val priority: Int,
val expectsResponse: Boolean
) : Comparable<QueueItem<R, T, F>> {
// fun getRequest(): R = request
// fun getDeferred(): CompletableDeferred<*> = deferred
// fun getRetries(): Int = retries
// fun getPriority(): Int = priority
/**
* @return true 表示返回 BlessingSkinResponse, false 表示 Unit
*/
fun expectsResponse(): Boolean
override fun compareTo(other: IQueueItem<@UnsafeVariance T, @UnsafeVariance F>): Int = getPriority().compareTo(other.getPriority())
fun expectsResponse(): Boolean = expectsResponse
override fun compareTo(other: QueueItem<R, @UnsafeVariance T, @UnsafeVariance F>): Int = priority.compareTo(other.priority)
}

View File

@ -1,4 +1,76 @@
package top.r3944realms.ltdmanager.core.client.request
interface IRequest {
import io.ktor.http.*
import top.r3944realms.ltdmanager.core.client.response.IFailedResponse
import top.r3944realms.ltdmanager.core.client.response.IResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
interface IRequest<out T: IResponse, out F: IFailedResponse> {
// 只使用属性
val createTime: Long
/**
* 转换为JSON字符串
*/
fun toJSON(): String
/**
* 获取API路径不包含基础URL
* 例如: "invitation-codes/generate"
*/
fun path(): String
/**
* 获取HTTP方法默认为GET因为大多数API使用GET+查询参数
*/
fun method(): HttpMethod = HttpMethod.Get
/**
* 自定义请求头
*/
fun headers(): HeadersBuilder.() -> Unit = {
// 默认添加Content-Type
append(HttpHeaders.ContentType, ContentType.Application.Json.toString())
// 添加Accept头
append(HttpHeaders.Accept, "application/json")
}
/**
* 获取查询参数用于URL参数
* 例如: mapOf("token" to "abc123", "amount" to "1")
*/
fun queryParameters(): Map<String, String> = emptyMap()
/**
* 获取请求体参数用于POST请求的JSON body
* 例如: mapOf("token" to "abc123", "amount" to 1)
*/
fun bodyParameters(): Map<String, Any> = emptyMap()
/**
* 获取请求体内容类型默认为Application.Json
*/
fun contentType(): ContentType = ContentType.Application.Json
/**
* 解析响应JSON字符串
* @param responseJson 响应JSON字符串
* @param httpStatusCode HTTP状态码
*/
fun getResponse(responseJson: String, httpStatusCode: HttpStatusCode): ResponseResult<T, F>
/**
* 获取预期的成功响应类型名称用于日志和调试
*/
fun expectedResponseType(): String
/**
* 获取预期的失败响应类型名称用于日志和调试
*/
fun expectedFailureType(): String
/**
* 是否需要在失败时重试默认重试
*/
fun shouldRetryOnFailure(): Boolean = true
}

View File

@ -1,4 +1,7 @@
package top.r3944realms.ltdmanager.core.client.response
class IFailedResponse {
interface IFailedResponse : IResponse {
val failedMessage: String
val thrownException: Exception
get() = Exception(failedMessage)
}

View File

@ -1,4 +1,8 @@
package top.r3944realms.ltdmanager.core.client.response
import io.ktor.http.*
interface IResponse {
val httpStatusCode: HttpStatusCode
val createTime: Long
}

View File

@ -1,4 +1,47 @@
package top.r3944realms.ltdmanager.core.client.response
class ResponseResult {
sealed class ResponseResult<out T: IResponse, out F: IFailedResponse> {
data class Success<T : IResponse>(val response: T) : ResponseResult<T, Nothing>()
data class Failure<F : IFailedResponse>(val failure: F) : ResponseResult<Nothing, F>()
/**
* 检查是否成功
*/
fun isSuccess(): Boolean = this is Success
/**
* 获取成功响应如果存在
*/
fun getSuccessResponse(): T? = (this as? Success)?.response
/**
* 获取失败响应如果存在
*/
fun getFailureResponse(): F? = (this as? Failure)?.failure
/**
* 成功时执行操作
*/
inline fun onSuccess(action: (T) -> Unit): ResponseResult<T, F> {
if (this is Success) action(response)
return this
}
/**
* 失败时执行操作
*/
inline fun onFailure(action: (F) -> Unit): ResponseResult<T, F> {
if (this is Failure) action(failure)
return this
}
fun getRetResponse(): T {
if (this is Success) {
return response
}
else if (this is Failure) {
@Suppress("UNCHECKED_CAST")
return failure as T
}
throw Exception("Never Reach")
}
}

View File

@ -1,4 +1,54 @@
package top.r3944realms.ltdmanager.core.config
class McsmConfig {
import top.r3944realms.ltdmanager.utils.CryptoUtil
import top.r3944realms.ltdmanager.utils.YamlUpdater
data class McsmConfig(
var url: String ?= null,
var encryptedApiKey: String ?= null,
var instanceID: String ?= null,
) {
val decryptedApiKey: String?
get() {
if (encryptedApiKey == null) return null
if (!isEncrypted()) return encryptedApiKey
try {
val cipherText = encryptedApiKey!!.substring(4, encryptedApiKey!!.length - 1)
return CryptoUtil.decrypt(cipherText)
} catch (e: Exception) {
throw IllegalStateException("API解密失败", e)
}
}
/**
* 加密密码如果未加密并写回配置文件
*/
fun encryptApi() {
if (encryptedApiKey == null || isEncrypted()) {
return
}
try {
encryptedApiKey = "ENC(${CryptoUtil.encrypt(encryptedApiKey!!)})"
YamlUpdater.updateYaml(
YamlConfigLoader.configFilePath.toString(),
"mcsm.encrypted-api-key",
this.encryptedApiKey!!
)
} catch (e: Exception) {
throw IllegalStateException("API加密失败", e)
}
}
/**
* 检查密码是否已加密
*/
private fun isEncrypted(): Boolean {
return encryptedApiKey != null &&
encryptedApiKey!!.startsWith("ENC(") &&
encryptedApiKey!!.endsWith(")")
}
override fun toString(): String {
return "McsmConfig(url=$url, api-key=***)"
}
}

View File

@ -31,6 +31,7 @@ object YamlConfigLoader {
config?.database?.encryptPassword()
config?.websocket?.encryptToken()
config?.http?.encryptToken()
config?.mcsm?.encryptApi()
config?.mail?.encryptPassword()
config?.tools?.rcon?.encryptPassword()
config?.blessingSkinServer?.invitationApi?.encryptToken()
@ -72,6 +73,7 @@ object YamlConfigLoader {
fun loadDatabaseConfig(): DatabaseConfig = config.database
fun loadCryptoConfig(): CryptoConfig = config.crypto
fun loadMcsmConfig(): McsmConfig = config.mcsm
fun loadWebsocketConfig(): WebsocketConfig = config.websocket
fun loadHttpConfig(): HttpConfig = config.http
fun loadModeConfig(): ModeConfig = config.mode
@ -88,6 +90,7 @@ object YamlConfigLoader {
var http: HttpConfig = HttpConfig(),
var tools: ToolConfig = ToolConfig(),
var mail: MailConfig = MailConfig(),
var mcsm: McsmConfig = McsmConfig(),
var blessingSkinServer: BlessingSkinServerConfig = BlessingSkinServerConfig(),
var dgLab: DgLabConfig = DgLabConfig(),
var imgTu: ImgTuConfig = ImgTuConfig(),

View File

@ -1,4 +1,4 @@
package top.r3944realms.ltdmanager.core.init
class DependencyResolver {
class DependencyResolver() {
}

View File

@ -1,26 +1,149 @@
package top.r3944realms.ltdmanager.core.config
package top.r3944realms.ltdmanager.core.init
import top.r3944realms.ltdmanager.module.Modules
import top.r3944realms.ltdmanager.module.exception.ConfigError
data class ModuleConfig(
val name: String,
val type: ModuleType,
val enabled: Boolean,
val
val dependencies: List<Dependency> = emptyList(),
val config: Map<String, Any> = emptyMap()
) {
data class Dependency(
val moduleName: String, // 依赖的模块名称
val type: DependencyType, // 依赖类型
val required: Boolean = true // 是否必需
)
enum class ModuleType {
GROUP_MESSAGE_POLLING_MODULE,
GROUP_REQUEST_HANDLER_MODULE,
MAIL_MODULE,
BAN_MODULE,
DG_LAB_MODULE,
INVITE_MODULE,
MC_SERVER_STATUS_MODULE,
RCON_PLAYER_LIST_MODULE,
STATE_MODULE
private val name: String,
val type: ModuleType,
val required: Boolean = true
) {
private val dependencyName: String = "${type.modName}-$name"
fun getDepName() :String = dependencyName
}
enum class ModuleType(val modName: String) {
GROUP_MESSAGE_POLLING_MODULE(Modules.GROUP_MESSAGE_POLLING),
GROUP_REQUEST_HANDLER_MODULE(Modules.GROUP_REQUEST_HANDLER),
MAIL_MODULE(Modules.MAIL),
BAN_MODULE(Modules.BAN),
DG_LAB_MODULE(Modules.DG_LAB),
INVITE_MODULE(Modules.INVITATION_CODE),
MC_SERVER_STATUS_MODULE(Modules.MC_SERVER_STATUS),
MOD_GROUP_HANDLER_MODULE(Modules.MOD_GROUP_HANDLER),
RCON_PLAYER_LIST_MODULE(Modules.RCON_PLAYER_LIST),
STATE_MODULE(Modules.STATE),
HELP_MODULE(Modules.HELP),;
}
// 基础获取方法
fun value(paramName: String): Any =
config[paramName] ?: throw ConfigError(
ConfigError.Type.MISSING_PARAMETER,
name,
paramName
)
// 泛型获取方法
private inline fun <reified T> get(paramName: String): T {
val value = value(paramName)
return when (T::class) {
Long::class -> convertToLong(value, paramName) as T
Int::class -> convertToInt(value, paramName) as T
String::class -> value.toString() as T
Boolean::class -> convertToBoolean(value, paramName) as T
Double::class -> convertToDouble(value, paramName) as T
Float::class -> convertToFloat(value, paramName) as T
else -> {
if (value is T) value
else throw typeMismatchError<T>(value, paramName)
}
}
}
// 特定类型方法(向后兼容)
fun long(paramName: String): Long = get<Long>(paramName)
fun int(paramName: String): Int = get<Int>(paramName)
fun string(paramName: String): String = get<String>(paramName)
fun boolean(paramName: String): Boolean = get<Boolean>(paramName)
fun double(paramName: String): Double = get<Double>(paramName)
fun float(paramName: String): Float = get<Float>(paramName)
// 可选值方法
inline fun <reified T> getOrNull(paramName: String): T? =
config[paramName] as? T ?: run {
val value = config[paramName]
if (value == null) null
else if (value is T) value
else null
}
inline fun <reified T> getOrDefault(paramName: String, defaultValue: T): T =
getOrNull<T>(paramName) ?: defaultValue
// 类型转换辅助方法
private fun convertToLong(value: Any, paramName: String): Long = when (value) {
is Long -> value
is Number -> value.toLong()
is String -> try {
value.toLong()
} catch (e: NumberFormatException) {
throw typeMismatchError<Long>(value, paramName)
}
else -> throw typeMismatchError<Long>(value, paramName)
}
private fun convertToInt(value: Any, paramName: String): Int = when (value) {
is Int -> value
is Number -> value.toInt()
is String -> try {
value.toInt()
} catch (e: NumberFormatException) {
throw typeMismatchError<Int>(value, paramName)
}
else -> throw typeMismatchError<Int>(value, paramName)
}
private fun convertToBoolean(value: Any, paramName: String): Boolean = when (value) {
is Boolean -> value
is String -> when (value.lowercase()) {
"true", "yes", "1" -> true
"false", "no", "0" -> false
else -> throw typeMismatchError<Boolean>(value, paramName)
}
is Number -> value.toInt() != 0
else -> throw typeMismatchError<Boolean>(value, paramName)
}
private fun convertToDouble(value: Any, paramName: String): Double = when (value) {
is Double -> value
is Number -> value.toDouble()
is String -> try {
value.toDouble()
} catch (e: NumberFormatException) {
throw typeMismatchError<Double>(value, paramName)
}
else -> throw typeMismatchError<Double>(value, paramName)
}
private fun convertToFloat(value: Any, paramName: String): Float = when (value) {
is Float -> value
is Number -> value.toFloat()
is String -> try {
value.toFloat()
} catch (e: NumberFormatException) {
throw typeMismatchError<Float>(value, paramName)
}
else -> throw typeMismatchError<Float>(value, paramName)
}
// 错误处理辅助方法
private inline fun <reified T> typeMismatchError(
actualValue: Any,
paramName: String
): Nothing {
throw ConfigError(
ConfigError.Type.NOT_EXPECTED_VALUE,
name,
T::class.simpleName ?: T::class.java.simpleName,
actualValue::class.simpleName ?: actualValue::class.java.simpleName
)
}
}

View File

@ -1,4 +1,32 @@
package top.r3944realms.ltdmanager.core.init
import top.r3944realms.ltdmanager.GlobalManager
import top.r3944realms.ltdmanager.core.init.ModuleConfig.ModuleType.*
import top.r3944realms.ltdmanager.module.BaseModule
import top.r3944realms.ltdmanager.module.GroupRequestHandlerModule
object ModuleFactory {
fun createModule(config: ModuleConfig): BaseModule {
return when(config.type) {
GROUP_MESSAGE_POLLING_MODULE -> TODO()
GROUP_REQUEST_HANDLER_MODULE -> createGroupRequestHandler(config)
MAIL_MODULE -> TODO()
BAN_MODULE -> TODO()
DG_LAB_MODULE -> TODO()
INVITE_MODULE -> TODO()
MC_SERVER_STATUS_MODULE -> TODO()
RCON_PLAYER_LIST_MODULE -> TODO()
STATE_MODULE -> TODO()
MOD_GROUP_HANDLER_MODULE -> TODO()
HELP_MODULE -> TODO()
}
}
private fun createGroupRequestHandler(config: ModuleConfig): GroupRequestHandlerModule {
val targetGroupId = config.long("targetGroupId")
val pollIntervalMillis = config.getOrDefault("pollIntervalMillis", 30_000L)
return GroupRequestHandlerModule(
config.name, GlobalManager.napCatClient,
targetGroupId, pollIntervalMillis
)
}
}

View File

@ -1,4 +1,10 @@
package top.r3944realms.ltdmanager.core.init
import java.nio.file.Paths
object ModuleLoader {
val configFilePath = Paths.get("config/modules.yaml")
init {
}
}

View File

@ -1,4 +1,15 @@
package top.r3944realms.ltdmanager.core.init
import top.r3944realms.ltdmanager.module.BaseModule
object ModuleRegistry {
private val registry: MutableMap<String, BaseModule> = mutableMapOf()
fun register(baseModule: BaseModule) {
registry.putIfAbsent(baseModule.name, baseModule)
}
fun get(moduleName: String): BaseModule? {
return registry[moduleName]
}
}

View File

@ -8,6 +8,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import top.r3944realms.ltdmanager.GlobalManager
import top.r3944realms.ltdmanager.chevereto.response.FailedCheveretoResponse
import top.r3944realms.ltdmanager.chevereto.response.v1.CheveretoUploadResponse
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.napcat.NapCatClient
import top.r3944realms.ltdmanager.napcat.data.ID
@ -104,7 +106,7 @@ class GameClientOperation(
pattern.replace(originalUrl) { matchResult ->
// 保留原始 URL 中的路径部分(如果有的话)
val path = matchResult.groupValues[1] ?: ""
val path = matchResult.groupValues[1]
"$configUrl$path"
}
} catch (e: Exception) {
@ -130,8 +132,7 @@ class GameClientOperation(
albumId = "BFx",
expiration = "PT5M"
)
if (response.image?.url != null) {
// 发送图床 URL 给玩家
if (response is CheveretoUploadResponse){
napCatClient.sendUnit(
SendPrivateMsgRequest(
listOf(
@ -141,8 +142,15 @@ class GameClientOperation(
ID.long(playerId)
)
)
} else {
LoggerUtil.logger.error("上传二维码返回 JSON 未包含 URL")
} else if (response is FailedCheveretoResponse.Default){
napCatClient.sendUnit(
SendPrivateMsgRequest(
listOf(
MessageElement.text("无法上传图片,请联系管理员:${response.httpStatusCode} , ${response.failedMessage}"),
),
ID.long(playerId)
)
)
}
// 启动 60 秒倒计时任务
bindingTimeoutJob = launch {

View File

@ -3,7 +3,7 @@ package top.r3944realms.ltdmanager
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.module.*
// DSL
fun main() = GlobalManager.runBlockingMain {
val commonGroupId:Long = 538751386
val whitelistGroupId:Long = 920719236
@ -43,7 +43,7 @@ fun main() = GlobalManager.runBlockingMain {
)
val toolConfig = YamlConfigLoader.loadToolConfig()
val corconModule = RconPlayerListModule(
moduleName = "WhiteListGroup",
moduleName = "CommonGroup",
groupMessagePollingModule = commonGroupMsgPollingModule,
rconTimeOut = 2_000L,
cooldownMillis = 10_000L,
@ -98,30 +98,30 @@ fun main() = GlobalManager.runBlockingMain {
// "Apply for an invitation code"
// )
// )
val commonMcServerStatusModule = McServerStatusModule(
moduleName = "CommonGroup",
groupMessagePollingModule = commonGroupMsgPollingModule,
selfId = selfQQId,
cooldownMillis = 20_000L,
selfNickName = selfNickName,
commands = listOf("/m", "/mcs", "seek", "s", "test"),
presetServer = mapOf(
setOf("老土豆", "七周目", "7" ,"ZZ", "zz", "Zz", "seven") to "main.mmccdd.top:11106",
setOf("土豆", "八周目", "8" ,"39", "eight") to "ac.r3944realms.top"
)
)
val whitelistMcServerStatusModule = McServerStatusModule(
moduleName = "WhiteListGroup",
groupMessagePollingModule = whiteListGroupMsgPollingModule,
selfId = selfQQId,
cooldownMillis = 20_000L,
selfNickName = selfNickName,
commands = listOf("/m", "/mcs", "seek", "s", "test"),
presetServer = mapOf(
setOf("老土豆", "七周目", "7" ,"ZZ", "zz", "Zz", "seven") to "main.mmccdd.top:11106",
setOf("土豆", "八周目", "8" ,"39", "eight") to "ac.r3944realms.top"
)
)
// val commonMcServerStatusModule = McServerStatusModule(
// moduleName = "CommonGroup",
// groupMessagePollingModule = commonGroupMsgPollingModule,
// selfId = selfQQId,
// cooldownMillis = 20_000L,
// selfNickName = selfNickName,
// commands = listOf("/m", "/mcs", "seek", "s", "test"),
// presetServer = mapOf(
// setOf("老土豆", "七周目", "7" ,"ZZ", "zz", "Zz", "seven") to "main.mmccdd.top:11106",
// setOf("土豆", "八周目", "8" ,"39", "eight") to "ac.r3944realms.top"
// )
// )
// val whitelistMcServerStatusModule = McServerStatusModule(
// moduleName = "WhiteListGroup",
// groupMessagePollingModule = whiteListGroupMsgPollingModule,
// selfId = selfQQId,
// cooldownMillis = 20_000L,
// selfNickName = selfNickName,
// commands = listOf("/m", "/mcs", "seek", "s", "test"),
// presetServer = mapOf(
// setOf("老土豆", "七周目", "7" ,"ZZ", "zz", "Zz", "seven") to "main.mmccdd.top:11106",
// setOf("土豆", "八周目", "8" ,"39", "eight") to "ac.r3944realms.top"
// )
// )
val dgLabModule = DGLabModule(
moduleName = "DG",
groupMessagePollingModule = commonGroupMsgPollingModule,
@ -134,10 +134,10 @@ fun main() = GlobalManager.runBlockingMain {
GlobalManager.moduleManager.registerModule(groupModule)
GlobalManager.moduleManager.registerModule(commonGroupMsgPollingModule)
GlobalManager.moduleManager.registerModule(whiteListGroupMsgPollingModule)
GlobalManager.moduleManager.registerModule(commonMcServerStatusModule)
// GlobalManager.moduleManager.registerModule(commonMcServerStatusModule)
GlobalManager.moduleManager.registerModule(rconModule)
GlobalManager.moduleManager.registerModule(corconModule)
GlobalManager.moduleManager.registerModule(whitelistMcServerStatusModule)
// GlobalManager.moduleManager.registerModule(whitelistMcServerStatusModule)
// GlobalManager.moduleManager.registerModule(mailModule)
// GlobalManager.moduleManager.registerModule(invitationCodesModule)
GlobalManager.moduleManager.registerModule(whitelistHelpModule)
@ -150,13 +150,13 @@ fun main() = GlobalManager.runBlockingMain {
GlobalManager.moduleManager.loadModule(groupModule.name)
GlobalManager.moduleManager.loadModule(commonGroupMsgPollingModule.name)
GlobalManager.moduleManager.loadModule(whiteListGroupMsgPollingModule.name)
GlobalManager.moduleManager.loadModule(commonMcServerStatusModule.name)
// GlobalManager.moduleManager.loadModule(commonMcServerStatusModule.name)
GlobalManager.moduleManager.loadModule(corconModule.name)
GlobalManager.moduleManager.loadModule(rconModule.name)
// GlobalManager.moduleManager.loadModule(mailModule.name)
// GlobalManager.moduleManager.loadModule(invitationCodesModule.name)
GlobalManager.moduleManager.loadModule(commonHelpModule.name)
GlobalManager.moduleManager.loadModule(whitelistMcServerStatusModule.name)
// GlobalManager.moduleManager.loadModule(whitelistMcServerStatusModule.name)
GlobalManager.moduleManager.loadModule(whitelistHelpModule.name)
GlobalManager.moduleManager.loadModule(dgLabModule.name)
// GlobalManager.moduleManager.loadModule(banModule.name)

View File

@ -1,4 +1,4 @@
package top.r3944realms.ltdmanager.blessingskin
package top.r3944realms.ltdmanager.mcms
import io.ktor.client.*
import io.ktor.client.call.*
@ -11,17 +11,17 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
import top.r3944realms.ltdmanager.mcms.request.MCSMRequest
import top.r3944realms.ltdmanager.mcms.response.FailedMCSMResponse
import top.r3944realms.ltdmanager.mcms.response.MCSMResponse
import top.r3944realms.ltdmanager.mcms.response.ResponseResult
import top.r3944realms.ltdmanager.utils.Environment
import top.r3944realms.ltdmanager.utils.LoggerUtil
import java.net.URLEncoder
import java.util.*
class BlessingSkinClient private constructor() : AutoCloseable {
class MCSMClient private constructor() : AutoCloseable {
private val client = HttpClient(CIO) {
expectSuccess = false
@ -40,7 +40,7 @@ class BlessingSkinClient private constructor() : AutoCloseable {
// 限流控制
private val semaphore = Semaphore(5)
private val requestMutex = Mutex()
private val requestQueue = PriorityQueue<BlessingSkinQueueItem<BlessingSkinResponse, FailedBlessingSkinResponse>>(compareBy { it.priority })
private val requestQueue = PriorityQueue<MCSMSkinQueueItem<MCSMResponse, FailedMCSMResponse>>(compareBy { it.priority })
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
@ -50,14 +50,14 @@ class BlessingSkinClient private constructor() : AutoCloseable {
/**
* 提交请求
*/
suspend fun <T : BlessingSkinResponse, F : FailedBlessingSkinResponse> submitRequest(
request: BlessingSkinRequest<T, F>,
suspend fun <T : MCSMResponse, F : FailedMCSMResponse> submitRequest(
request: MCSMRequest<T, F>,
priority: Int = 5,
maxRetries: Int = 3
): ResponseResult<T, F> {
val deferred = CompletableDeferred<ResponseResult<T, F>>()
requestMutex.withLock {
requestQueue.add(BlessingSkinQueueItem(request, deferred, priority, maxRetries, true))
requestQueue.add(MCSMSkinQueueItem(request, deferred, priority, maxRetries, true))
}
return deferred.await()
}
@ -83,7 +83,7 @@ class BlessingSkinClient private constructor() : AutoCloseable {
/**
* 处理队列项
*/
private suspend fun processQueueItem(item: BlessingSkinQueueItem<BlessingSkinResponse, FailedBlessingSkinResponse>) {
private suspend fun processQueueItem(item: MCSMSkinQueueItem<MCSMResponse, FailedMCSMResponse>) {
semaphore.withPermit {
val (request, deferred, _, maxRetries, _) = item
var attempt = 0
@ -130,7 +130,7 @@ class BlessingSkinClient private constructor() : AutoCloseable {
val result = request.getResponse(responseText, response.status)
@Suppress("UNCHECKED_CAST")
(deferred as CompletableDeferred<ResponseResult<BlessingSkinResponse, FailedBlessingSkinResponse>>).complete(result)
(deferred as CompletableDeferred<ResponseResult<MCSMResponse, FailedMCSMResponse>>).complete(result)
return
@ -142,15 +142,15 @@ class BlessingSkinClient private constructor() : AutoCloseable {
break
}
LoggerUtil.logger.warn("BlessingSkin请求失败 (尝试 $attempt/$maxRetries): ${e.message}")
LoggerUtil.logger.warn("MCSM请求失败 (尝试 $attempt/$maxRetries): ${e.message}")
delay((attempt * 1000L)) // 指数退避
}
}
// 所有重试都失败或不应重试
val errorResponse = createFailureResponse(lastError, request)
val errorResponse = createFailureResponse(lastError)
@Suppress("UNCHECKED_CAST")
(deferred as CompletableDeferred<ResponseResult<BlessingSkinResponse, FailedBlessingSkinResponse>>).complete(
(deferred as CompletableDeferred<ResponseResult<MCSMResponse, FailedMCSMResponse>>).complete(
ResponseResult.Failure(errorResponse)
)
}
@ -159,7 +159,7 @@ class BlessingSkinClient private constructor() : AutoCloseable {
/**
* 构建完整的URL包含查询参数
*/
private fun buildFullUrlWithQueryParams(request: BlessingSkinRequest<*, *>): String {
private fun buildFullUrlWithQueryParams(request: MCSMRequest<*, *>): String {
val baseUrl = blessingSkinServerConfig.url?.removeSuffix("/")
val path = request.path().removePrefix("/")
@ -191,11 +191,10 @@ class BlessingSkinClient private constructor() : AutoCloseable {
* 创建失败响应
*/
private fun createFailureResponse(
exception: Exception?,
request: BlessingSkinRequest<*, *>
): FailedBlessingSkinResponse {
return FailedBlessingSkinResponse.Default(
failedResult = exception?.message ?: "未知错误",
exception: Exception?
): FailedMCSMResponse {
return FailedMCSMResponse.ExceptionFailedMCSMResponse(
result = exception?.message ?: "未知错误",
)
}
@ -207,6 +206,6 @@ class BlessingSkinClient private constructor() : AutoCloseable {
}
companion object {
fun create(): BlessingSkinClient = BlessingSkinClient()
fun create(): MCSMClient = MCSMClient()
}
}

View File

@ -1,16 +1,16 @@
package top.r3944realms.ltdmanager.blessingskin
package top.r3944realms.ltdmanager.mcms
import kotlinx.coroutines.CompletableDeferred
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
import top.r3944realms.ltdmanager.mcms.request.MCSMRequest
import top.r3944realms.ltdmanager.mcms.response.FailedMCSMResponse
import top.r3944realms.ltdmanager.mcms.response.MCSMResponse
data class BlessingSkinQueueItem<out T:BlessingSkinResponse,out F:FailedBlessingSkinResponse>(
val request: BlessingSkinRequest<T,F>,
data class MCSMSkinQueueItem<out T:MCSMResponse,out F:FailedMCSMResponse>(
val request: MCSMRequest<T,F>,
val deferred: CompletableDeferred<*>,
var retries: Int,
val priority: Int,
val expectsResponse: Boolean // true 表示返回 BlessingSkinResponse, false 表示 Unit
) : Comparable<BlessingSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>> {
override fun compareTo(other: BlessingSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>): Int = priority.compareTo(other.priority)
) : Comparable<MCSMSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>> {
override fun compareTo(other: MCSMSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>): Int = priority.compareTo(other.priority)
}

View File

@ -1,14 +1,15 @@
package top.r3944realms.ltdmanager.blessingskin.request
package top.r3944realms.ltdmanager.mcms.request
import io.ktor.http.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
import top.r3944realms.ltdmanager.mcms.response.FailedMCSMResponse
import top.r3944realms.ltdmanager.mcms.response.MCSMResponse
import top.r3944realms.ltdmanager.mcms.response.ResponseResult
@Serializable
abstract class BlessingSkinRequest<out T : BlessingSkinResponse, out F : FailedBlessingSkinResponse>(
abstract class MCSMRequest<out T : MCSMResponse, out F : FailedMCSMResponse>(
@Transient
open val createTime: Long = System.currentTimeMillis()
) {

View File

@ -1,4 +1,50 @@
package top.r3944realms.ltdmanager.mcms.request.instance
class GetInstanceListRequest {
}
import io.ktor.http.*
import kotlinx.serialization.Serializable
import top.r3944realms.ltdmanager.mcms.request.MCSMRequest
import top.r3944realms.ltdmanager.mcms.response.FailedMCSMResponse
import top.r3944realms.ltdmanager.mcms.response.ResponseResult
import top.r3944realms.ltdmanager.mcms.response.instance.GetInstanceListResponse
@Serializable
class GetInstanceListRequest(
private val daemonId: String,
private val page: Int,
private val pageSize: Int,
private val status: String,
private val instanceName: String? = null
) : MCSMRequest<GetInstanceListResponse, FailedMCSMResponse>() {
override fun toJSON(): String = "{}" // GET 无请求体
override fun path(): String = "api/service/remote_service_instances"
override fun queryParameters(): Map<String, String> =
buildMap {
put("daemonId", daemonId)
put("page", page.toString())
put("page_size", pageSize.toString())
put("status", status)
instanceName?.let { put("instance_name", it) }
}
override fun getResponse(
responseJson: String,
httpStatusCode: HttpStatusCode
): ResponseResult<GetInstanceListResponse, FailedMCSMResponse> {
return if (httpStatusCode.value in 200..299) {
ResponseResult.Success(
kotlinx.serialization.json.Json.decodeFromString<GetInstanceListResponse>(responseJson)
)
} else {
ResponseResult.Failure(
kotlinx.serialization.json.Json.decodeFromString<FailedMCSMResponse>(responseJson)
)
}
}
override fun expectedResponseType(): String = GetInstanceListResponse::class.simpleName!!
override fun expectedFailureType(): String = FailedMCSMResponse::class.simpleName!!
}

View File

@ -1,4 +1,53 @@
package top.r3944realms.ltdmanager.mcms.request.instance
class StartInstanceRequest {
}
import io.ktor.http.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.mcms.request.MCSMRequest
import top.r3944realms.ltdmanager.mcms.response.FailedMCSMResponse
import top.r3944realms.ltdmanager.mcms.response.ResponseResult
import top.r3944realms.ltdmanager.mcms.response.instance.StartInstanceResponse
/**
* 启动实例请求
* GET /api/protected_instance/open
*/
@Serializable
data class StartInstanceRequest(
val uuid: String,
val daemonId: String
) : MCSMRequest<StartInstanceResponse, FailedMCSMResponse>() {
override fun toJSON(): String =
Json.encodeToString(this)
override fun path(): String =
"protected_instance/open"
override fun queryParameters(): Map<String, String> =
mapOf(
"uuid" to uuid,
"daemonId" to daemonId
)
override fun method(): HttpMethod = HttpMethod.Get
override fun getResponse(
responseJson: String,
httpStatusCode: HttpStatusCode
): ResponseResult<StartInstanceResponse, FailedMCSMResponse> {
return if (httpStatusCode.value == 200) {
val obj = Json.decodeFromString(StartInstanceResponse.serializer(), responseJson)
ResponseResult.Success(obj)
} else {
val fail = Json.decodeFromString(FailedMCSMResponse.serializer(), responseJson)
ResponseResult.Failure(fail)
}
}
override fun expectedResponseType(): String = "StartInstanceResponse"
override fun expectedFailureType(): String = "FailedMCSMResponse"
}

View File

@ -1,14 +1,29 @@
package top.r3944realms.ltdmanager.blessingskin.response
package top.r3944realms.ltdmanager.mcms.response
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.JsonObject
@Serializable
abstract class FailedBlessingSkinResponse: BlessingSkinResponse() {
abstract fun failedMessage(): String
open class FailedMCSMResponse(
@Transient
val status0: Status = Status.Ok,
val data: JsonObject? = null,
@Transient
val time0: Long = -1,
): MCSMResponse(
status0, time0
) {
@Serializable
class Default(@Transient val failedResult: String? = "未知错误") : FailedBlessingSkinResponse() {
override fun failedMessage(): String = failedResult!!
}
data class ExceptionFailedMCSMResponse(
@Transient
val status1: Status = Status.Ok,
val data0: String? = null,
@Transient
val time1: Long = -1,
@Transient
val result: String? = null,
): FailedMCSMResponse(
status1, null, time1
)
}

View File

@ -1,15 +1,20 @@
package top.r3944realms.ltdmanager.blessingskin.response
package top.r3944realms.ltdmanager.mcms.response
import io.ktor.http.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse
import top.r3944realms.ltdmanager.mcms.response.instance.GetInstanceListResponse
import top.r3944realms.ltdmanager.mcms.response.instance.StartInstanceResponse
@Serializable
abstract class BlessingSkinResponse (
abstract class MCSMResponse (
open val status: Status,
open val time: Long,
@Transient
open val httpStatusCode: HttpStatusCode = HttpStatusCode.OK,
@Transient
@ -17,20 +22,29 @@ abstract class BlessingSkinResponse (
) {
companion object {
// 通用的反序列化方法
inline fun <reified T : BlessingSkinResponse> decode(jsonString: String): T {
inline fun <reified T : MCSMResponse> decode(jsonString: String): T {
return json.decodeFromString(jsonString)
}
val json: Json by lazy {
Json {
ignoreUnknownKeys = true
serializersModule = SerializersModule {
polymorphic(BlessingSkinResponse::class) {
subclass(FailedBlessingSkinResponse.Default::class, FailedBlessingSkinResponse.Default.serializer())
subclass(InvitationCodeGenerationResponse::class, InvitationCodeGenerationResponse.serializer())
polymorphic(MCSMResponse::class) {
subclass(GetInstanceListResponse::class, GetInstanceListResponse.serializer())
subclass(StartInstanceResponse::class, StartInstanceResponse.serializer())
}
}
}
}
}
@Serializable
enum class Status(val value: String) {
@SerialName("200") Ok("200"),
@SerialName("400") ParamsNotRight("400"),
@SerialName("403") PermissionDenied("403"),
@SerialName("500") InternalServerError("500");
companion object {
fun isOk(value: Status): Boolean = value == Ok
}
}
}

View File

@ -1,9 +1,9 @@
package top.r3944realms.ltdmanager.blessingskin.response
package top.r3944realms.ltdmanager.mcms.response
// 响应结果封装
sealed class ResponseResult<out T : BlessingSkinResponse, out F : FailedBlessingSkinResponse> {
data class Success<T : BlessingSkinResponse>(val response: T) : ResponseResult<T, Nothing>()
data class Failure<F : FailedBlessingSkinResponse>(val failure: F) : ResponseResult<Nothing, F>()
sealed class ResponseResult<out T : MCSMResponse, out F : FailedMCSMResponse> {
data class Success<T : MCSMResponse>(val response: T) : ResponseResult<T, Nothing>()
data class Failure<F : FailedMCSMResponse>(val failure: F) : ResponseResult<Nothing, F>()
/**
* 检查是否成功

View File

@ -1,11 +1,60 @@
package top.r3944realms.ltdmanager.mcms.response.instance
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.JsonObject
import top.r3944realms.ltdmanager.mcms.response.MCSMResponse
@Serializable
data class InstanceListResponse(
val status: Int,
val data: InstanceListData?,
val time: Long
) : MCSMResponse
data class GetInstanceListResponse(
@Transient
val status0: Status = Status.Ok,
val data: InstanceListData? = null,
@Transient
val time0: Long = -1,
) : MCSMResponse(status0, time0) {
@Serializable
data class InstanceListData(
val maxPage: Int,
val pageSize: Int,
val data: List<InstanceDetail>
)
@Serializable
data class InstanceDetail(
val config: JsonObject? = null, //TODO: 不清楚是干什么的,需验证
val info: InstanceInfo,
val instanceUuid: String,
val processInfo: ProcessInfo,
val space: Long,
val started: Int,
val status: Int
)
@Serializable
data class InstanceInfo(
val currentPlayers: Int,
val fileLock: Int,
val maxPlayers: Int,
val openFrpStatus: Boolean,
val playersChart: List<PlayerChartItem>,
val version: String
)
@Serializable
data class PlayerChartItem(
val time: Long? = null,
val players: Int? = null
)
@Serializable
data class ProcessInfo(
val cpu: Double,
val memory: Long,
val ppid: Long,
val pid: Long,
val ctime: Long,
val elapsed: Long,
val timestamp: Long
)
}

View File

@ -1,4 +1,21 @@
package top.r3944realms.ltdmanager.mcms.response.instance
class StartInstanceResponse {
}
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import top.r3944realms.ltdmanager.mcms.response.MCSMResponse
@Serializable
data class StartInstanceResponse(
@Transient
val status0: Status = Status.Ok,
val data: StartInstanceData,
@Transient
val time0: Long = -1
) : MCSMResponse(status0, time0){
@Serializable
data class StartInstanceData(
val instanceUuid: String
)
}

View File

@ -1,4 +1,17 @@
package top.r3944realms.ltdmanager.module
class ApplyWhiteListModule {
class ApplyWhitelistModule(
moduleName: String,
private val groupMessagePollingModule: GroupMessagePollingModule,
private val cooldownMillis: Long = 120_000,
private val keywords: Set<String> = setOf("申请白名单")
):
BaseModule(Modules.APPLY_WHITELIST,moduleName) {
override fun onLoad() {
TODO("Not yet implemented")
}
override suspend fun onUnload() {
TODO("Not yet implemented")
}
}

View File

@ -35,7 +35,7 @@ class BanModule(
private val maxBanMinutes: Int = 15,
private val factorX: Int = 2, // 系数 x禁言倍数
) : BaseModule("BanModule", moduleName), PersistentState<BanModule.BanState> {
) : BaseModule(Modules.BAN, moduleName), PersistentState<BanModule.BanState> {
private val banCommandParse = CommandParser(muteCommandPrefixList)
private val pardonCommandParse = CommandParser(unmuteCommandPrefixList)

View File

@ -1,6 +1,7 @@
package top.r3944realms.ltdmanager.module
import kotlinx.coroutines.CompletableDeferred
import org.intellij.lang.annotations.MagicConstant
import top.r3944realms.ltdmanager.GlobalManager
import top.r3944realms.ltdmanager.utils.LoggerUtil
import kotlin.coroutines.cancellation.CancellationException

View File

@ -49,7 +49,7 @@ class DGLabModule(
val adminIds: List<Long> = listOf(),
val maxClientNumber: Int = 10,
val commandHead: List<String> = listOf("dglab"),
) : BaseModule("DGLabModule", moduleName), PersistentState<DGLabModule.DgLabState> {
) : BaseModule(Modules.DG_LAB, moduleName), PersistentState<DGLabModule.DgLabState> {
var dgLabManager: DgLab? = null
private var scope: CoroutineScope? = null

View File

@ -15,7 +15,7 @@ class GroupMessagePollingModule(
val targetGroupId: Long,
private val pollIntervalMillis: Long = 5_000L,
private val msgHistoryCheck: Int = 15,
) : BaseModule("MessagePollingModule", moduleName) {
) : BaseModule(Modules.GROUP_MESSAGE_POLLING, moduleName) {
private var scope: CoroutineScope? = null
// 用 Flow 存消息,其他模块可以订阅

View File

@ -15,7 +15,7 @@ class GroupRequestHandlerModule(
private val client: NapCatClient,
private val targetGroupId: Long,
private val pollIntervalMillis: Long = 30_000L,
) : BaseModule("GroupRequestHandlerModule", moduleName) {
) : BaseModule(Modules.GROUP_REQUEST_HANDLER, moduleName) {
private var scope: CoroutineScope? = null

View File

@ -33,7 +33,7 @@ class HelpModule(
private val selfNickName: String,
private val keywords: List<String> = listOf("help", "帮助"),
private val cooldownMillis: Long = 30_000L
) : BaseModule("HelpModule", moduleName), PersistentState<HelpModule.HelpState> {
) : BaseModule(Modules.HELP, moduleName), PersistentState<HelpModule.HelpState> {
// 命令解析器
private val commandParser = CommandParser(keywords)

View File

@ -4,9 +4,10 @@ import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.r3944realms.ltdmanager.blessingskin.data.InvitationCode
import top.r3944realms.ltdmanager.blessingskin.request.invitecode.GenerateInvitationCodeRequest
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse
import top.r3944realms.ltdmanager.core.client.response.ResponseResult
import top.r3944realms.ltdmanager.core.mail.mail
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownManager
import top.r3944realms.ltdmanager.module.common.cooldown.CooldownScope
@ -77,7 +78,7 @@ class InvitationCodesModule(
selfId: Long,
private val cooldownMillis: Long = 120_000,
private val keywords: Set<String> = setOf("申请邀请码")
) : BaseModule("InvitationCodesModule", moduleName), PersistentState<InvitationCodesModule.LastTriggerMapState> {
) : BaseModule(Modules.INVITATION_CODE, moduleName), PersistentState<InvitationCodesModule.LastTriggerMapState> {
private var scope: CoroutineScope? = null
private val stateFile: File = getStateFileInternal("invitation_codes_quarry_state.json", name)
@ -430,23 +431,33 @@ class InvitationCodesModule(
/**
* 1. 创建邀请码
*/
private suspend fun createInvitationCodes(amount: Int): List<InvitationCodeGenerationResponse.InvitationCode>? {
private suspend fun createInvitationCodes(amount: Int): List<InvitationCode>? {
return try {
val response = blessingSkinClient.submitRequest(
GenerateInvitationCodeRequest(amount = amount, token = apiToken)
)
response
.onFailure {
}
.onSuccess {
}
when (response) {
is ResponseResult.Success -> {
if (response.response.success) {
response.response.data
if (response.response is InvitationCodeGenerationResponse) {
if (response.response.success) {
response.response.data
} else
LoggerUtil.logger.warn("[$name] API返回失败: ${response.response.message}")
null
} else {
LoggerUtil.logger.warn("[$name] API返回失败: ${response.response.message}")
LoggerUtil.logger.warn("[$name] 返回非预期对象类型: ${response.response.javaClass}")
null
}
}
is ResponseResult.Failure -> {
LoggerUtil.logger.warn("[$name] 创建邀请码失败: ${response.failure.failedResult}")
LoggerUtil.logger.warn("[$name] 创建邀请码失败: ${response.failure.failedMessage}")
null
}
}
@ -460,7 +471,7 @@ class InvitationCodesModule(
* 2. 验证数量匹配
*/
private fun validateCodeCountMatch(
invitationCodes: List<InvitationCodeGenerationResponse.InvitationCode>?,
invitationCodes: List<InvitationCode>?,
needNewTokenIdAndMsgPairs: List<Pair<Long, MsgHistorySpecificMsg>>
) {
if (invitationCodes == null) {

View File

@ -19,7 +19,7 @@ class MailModule(
private val enableAuth: Boolean = true,
private val enableTLS: Boolean = true,
private val intervalMillis: Long = 2000L // 每封邮件之间的间隔(默认 2s
) : BaseModule("MailModule", moduleName) {
) : BaseModule(Modules.MAIL, moduleName) {
private lateinit var session: Session
private val queue = LinkedBlockingQueue<Mail>() // 邮件队列

View File

@ -36,7 +36,7 @@ class McServerStatusModule(
setOf("hp", "hypixel") to "mc.hypixel.net",
setOf("pm", "mineplex") to "play.mineplex.com"
)
) : BaseModule("McServerStatusModule", moduleName), PersistentState<McServerStatusModule.CooldownState> {
) : BaseModule(Modules.MC_SERVER_STATUS, moduleName), PersistentState<McServerStatusModule.CooldownState> {
private val stateFile:File = getStateFileInternal("mc_server_status_state.json", name)
private val stateBackupFile:File = getStateFileInternal("mc_server_status_state.json.bak", name)
private val commandParser: CommandParser = CommandParser(commands)

View File

@ -31,7 +31,7 @@ class ModGroupHandlerModule(
private val targetGroupId: Long,
private val answers: List<String> = listOf("正确答案"),
private val pollIntervalMillis: Long = 30_000L
) : BaseModule("ModGroupHandlerModule", moduleName), PersistentState<ModGroupHandlerModule.RejectRecords> {
) : BaseModule(Modules.MOD_GROUP_HANDLER, moduleName), PersistentState<ModGroupHandlerModule.RejectRecords> {
private var scope: CoroutineScope? = null
private val stateFile: File = getStateFileInternal("reject_records.json", name)

View File

@ -1,4 +1,26 @@
package top.r3944realms.ltdmanager.module
import java.util.*
object Modules {
private val MODULES: MutableList<String> = LinkedList();
val BAN: String = register("BanModule")
val APPLY_WHITELIST: String = register("ApplyWhitelistModule")
val DG_LAB: String = register("DGLabModule")
val GROUP_MESSAGE_POLLING: String = register("GroupMessagePollingModule")
val GROUP_REQUEST_HANDLER: String = register("GroupRequestHandlerModule")
val HELP: String = register("HelpModule")
val MAIL: String = register("MailModule")
val MC_SERVER_STATUS: String = register("MCServerStatusModule")
val MOD_GROUP_HANDLER: String = register("ModGroupHandlerModule")
val RCON_PLAYER_LIST: String = register("RconPlayerListModule")
val INVITATION_CODE: String = register("InvitationCodeModule")
val STATE: String = register("StateModule")
fun register(name: String): String {
MODULES.add(name)
return name
}
fun getModules(): Array<String> {
return MODULES.toTypedArray();
}
}

View File

@ -35,7 +35,7 @@ class RconPlayerListModule(
private val rconPath: String,
private val rconConfigPath: String,
private val keywords: Set<String> = setOf("查看玩家列表", "玩家列表", "在线玩家")
) : BaseModule("RconPlayerListModule", moduleName), PersistentState<LastTriggerState> {
) : BaseModule(Modules.RCON_PLAYER_LIST, moduleName), PersistentState<LastTriggerState> {
private val cooldownManager by lazy {
CooldownManager(
cooldownMillis = cooldownMillis,

View File

@ -8,7 +8,7 @@ class StateModule(
moduleName: String,
private val onlineName: String,
private val offlineName: String,
): BaseModule("StateModule", moduleName) {
): BaseModule(Modules.STATE, moduleName) {
private var scope: CoroutineScope? = null
override fun onLoad() {
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

View File

@ -1,7 +1,17 @@
package top.r3944realms.ltdmanager.module.exception
class InvalidConfigException: Exception() {
enum class Type(template: String) {
class ConfigError(type: Type = Type.OTHER, private val pos: String, vararg args: Any) : Exception() {
private val errorType: Type = type
private val arguments = args
override val message: String
get() = String.format(errorType.template, *arguments, pos)
enum class Type(val template: String) {
INVALID_PARAMETER("Invalid Parameter: %s in %s."),
MISSING_PARAMETER("Missing Parameter: %s in %s."),
NOT_EXPECTED_VALUE("Expect for %s but was %s in %s."),
OTHER("%s in %s")
}
}

View File

@ -13,7 +13,7 @@ object ConfigInitializer {
* @param fileName YAML 文件名 application.yml
* @param configDir 配置目录 config
*/
fun initConfig(fileName: String = "application.yml", configDir: String = "config") {
fun initConfig(fileName: String = "application.yml", configDir: String = "config", shouldExit: Boolean = true) {
val dirPath = Paths.get(configDir)
if (!Files.exists(dirPath)) {
Files.createDirectories(dirPath)
@ -28,8 +28,10 @@ object ConfigInitializer {
if (resourceStream != null) {
Files.copy(resourceStream, filePath, StandardCopyOption.REPLACE_EXISTING)
LoggerUtil.logger.info("已生成默认配置文件: $filePath")
LoggerUtil.logger.info("第一次启动,请修改配置后再启动")
exitProcess(-1);
if (shouldExit) {
LoggerUtil.logger.info("第一次启动,请修改配置后再启动")
exitProcess(-1);
}
} else throw Error("Jar内部资源文件缺失")
} else {

View File

@ -20,6 +20,8 @@ websocket:
url: "wss://127.0.0.1:3002"
# 格式为 ENC(XXX),若不是则会在加载完成配置后自动加密
encrypted-token: "123123cc"
mcsm:
tools:
rcon:
mc-rcon-tool-path: "/path/to/rcon"