diff --git a/.gitignore b/.gitignore index bc19509..ad51780 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ bin/ /logs/ /config/ /rcon_playerlist_state.json +/invitation_codes_quarry_state.json.bak +/invitation_codes_quarry_state.json diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml index 29a0deb..96c24d8 100644 --- a/.idea/sqldialects.xml +++ b/.idea/sqldialects.xml @@ -2,6 +2,7 @@ + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 1082a92..38817af 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,6 +39,10 @@ repositories { implementation("com.mysql:mysql-connector-j:8.0.33") // 使用MySQL 8.x驱动 implementation("com.zaxxer:HikariCP:5.0.1") // 连接池 + // 邮箱相关 + implementation("jakarta.mail:jakarta.mail-api:2.0.1") //API + implementation("com.sun.mail:jakarta.mail:2.0.1") // 实现 + // 日志系统 implementation("org.slf4j:slf4j-api:2.0.7") implementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.20.0") @@ -70,7 +74,7 @@ repositories { } tasks { // ShadowJar 配置 - named("shadowJar") { + named("shadowJar") { archiveClassifier.set("") // 去掉 -all 后缀 mergeServiceFiles() manifest { diff --git a/doc/README.MD b/doc/README.MD index 2aa1423..778b38f 100644 --- a/doc/README.MD +++ b/doc/README.MD @@ -1,3 +1,40 @@ # NapCat 将回应抽象为event模型 -将请求抽象为request模型 \ No newline at end of file +将请求抽象为request模型 +## InvitationCodesModule 模块设计时序表 +```mermaid +sequenceDiagram + participant User as 用户 + participant Bot as 机器人 + participant DB as 数据库视图/表 + participant API as Token API + + User->>Bot: 发送消息触发关键词 + Bot->>DB: 根据QQ查询 qualified_user_info 获取id, effective, is_used, token + alt id不存在 + DB-->>Bot: 无记录 + Bot-->>User: 提示无法查询id,请联系管理员 + else id存在 + alt effective=1 && is_used=1 + Bot-->>User: 提示邀请码已使用,勿重复发送 + else effective=1 && is_used=0 + DB-->>Bot: 返回token + Bot->>User: 构造邮件并发送token + Bot-->>User: 邮件已发送 + else effective=0 + Bot->>API: 请求生成新Token + alt API返回 success=false + API-->>Bot: 返回错误信息 + Bot-->>User: 提示API错误消息 + else API返回 success=true + API-->>Bot: 返回新Token + Bot->>User: 构造邮件并发送新Token + Bot-->>User: 邮件已发送 + Bot->>DB: 查询邀请码数据库获取 token_id + DB-->>Bot: 返回 token_id + Bot->>DB: 写入/更新 invitation_code_ascription 映射 + end + end + end + +```` \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 33b6d4d..3d986a5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,4 +3,4 @@ org.gradle.downloadSources=false org.gradle.parallel=true org.gradle.degree_of_parallelism=16 project_group=top.r3944realms.ltdmanager -project_version=1.2-SNAPSHOT +project_version=1.3-SNAPSHOT diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/GlobalManager.kt b/src/main/kotlin/top/r3944realms/ltdmanager/GlobalManager.kt index f5afe5c..0d23b62 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/GlobalManager.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/GlobalManager.kt @@ -1,18 +1,18 @@ package top.r3944realms.ltdmanager -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.* +import top.r3944realms.ltdmanager.blessingskin.BlessingSkinClient import top.r3944realms.ltdmanager.core.mysql.MysqlHikariConnectPool import top.r3944realms.ltdmanager.module.ModuleManager import top.r3944realms.ltdmanager.napcat.NapCatClient import top.r3944realms.ltdmanager.utils.LoggerUtil import java.sql.Connection +import java.util.concurrent.atomic.AtomicBoolean object GlobalManager { // 单例作用域,可在模块中使用协程 val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - + private val isRunning = AtomicBoolean(true) // Hikari 数据源 private val dataSource: MysqlHikariConnectPool by lazy { MysqlHikariConnectPool() @@ -22,6 +22,9 @@ object GlobalManager { val napCatClient: NapCatClient by lazy { NapCatClient.create() } + val blessingSkinClient: BlessingSkinClient by lazy { + BlessingSkinClient.create() + } val moduleManager: ModuleManager by lazy { ModuleManager() } @@ -32,24 +35,58 @@ object GlobalManager { fun getConnection(): Connection { return dataSource.getConnection() } - - /** - * 关闭全局资源 - * 例如在应用退出时调用 - */ - fun shutdown() { - try { - LoggerUtil.logger.info("关闭 NapCatClient") - napCatClient.close() - } catch (e: Exception) { - LoggerUtil.logger.warn("关闭 NapCatClient 失败", e) + fun runBlockingMain(block: suspend () -> Unit) = runBlocking { + // 注册全局关闭钩子 + LoggerUtil.addShutdownHook { + shutdownResources() } + // 启动逻辑交给外部传入 + block() - try { - LoggerUtil.logger.info("关闭 Hikari 数据源") - dataSource.close() - } catch (e: Exception) { - LoggerUtil.logger.warn("关闭 Hikari 数据源失败", e) + // 注册优雅关闭 + Runtime.getRuntime().addShutdownHook(Thread { + shutdownGracefully() + }) + + // 保持运行 + keepRunning() + } + + private fun keepRunning() = runBlocking { + while (isRunning.get()) { + delay(1000L) } } + + private fun shutdownResources() { + val resources = listOf( + "NapCatClient" to { napCatClient.close() }, + "BlessingSkinClient" to { blessingSkinClient.close() }, + "Hikari 数据源" to { dataSource.close() } + ) + + resources.forEach { (name, closer) -> + try { + LoggerUtil.syncInfo("关闭 $name") + closer() + LoggerUtil.syncInfo("$name 关闭完成") + } catch (e: Exception) { + LoggerUtil.syncError("关闭 $name 失败", e) + } + } + } + + fun shutdownGracefully() = runBlocking { + LoggerUtil.syncInfo("\n收到退出信号,正在停止所有模块...") + + moduleManager.stopAllModules() + + LoggerUtil.syncInfo("模块卸载完成,开始关闭资源...") + + // 这会触发 LoggerUtil 中注册的关闭钩子 + LoggerUtil.shutdownGracefully() + + isRunning.set(false) + } + } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/BlessingSkinClient.kt b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/BlessingSkinClient.kt new file mode 100644 index 0000000..65c002c --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/BlessingSkinClient.kt @@ -0,0 +1,212 @@ +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.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.utils.Environment +import top.r3944realms.ltdmanager.utils.LoggerUtil +import java.net.URLEncoder +import java.util.* + +class BlessingSkinClient private constructor() : AutoCloseable { + private val client = HttpClient(CIO) { + expectSuccess = false + + // 安装 HttpTimeout 插件 + install(HttpTimeout) { + // 默认超时配置,会被具体请求的配置覆盖 + requestTimeoutMillis = 30000 + connectTimeoutMillis = 10000 + socketTimeoutMillis = 15000 + } + + } + + private val blessingSkinServerConfig = YamlConfigLoader.loadBlessingSkinServerConfig() + + // 限流控制 + private val semaphore = Semaphore(5) + private val requestMutex = Mutex() + private val requestQueue = PriorityQueue>(compareBy { it.priority }) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + init { + startQueueProcessor() + } + + /** + * 提交请求 + */ + suspend fun submitRequest( + request: BlessingSkinRequest, + priority: Int = 5, + maxRetries: Int = 3 + ): ResponseResult { + val deferred = CompletableDeferred>() + requestMutex.withLock { + requestQueue.add(BlessingSkinQueueItem(request, deferred, priority, maxRetries, true)) + } + return deferred.await() + } + + /** + * 启动队列处理器 + */ + 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) { + semaphore.withPermit { + val (request, deferred, _, maxRetries, expectsResponse) = 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>).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>).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("", ignoreCase = true) || + text.contains("", 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() + } + } + + companion object { + fun create(): BlessingSkinClient = BlessingSkinClient() + } +} diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/BlessingSkinQueueItem.kt b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/BlessingSkinQueueItem.kt new file mode 100644 index 0000000..51ab78d --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/BlessingSkinQueueItem.kt @@ -0,0 +1,16 @@ +package top.r3944realms.ltdmanager.blessingskin + +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 + +data class BlessingSkinQueueItem( + val request: BlessingSkinRequest, + val deferred: CompletableDeferred<*>, + var retries: Int, + val priority: Int, + val expectsResponse: Boolean // true 表示返回 BlessingSkinResponse, false 表示 Unit +) : Comparable> { + override fun compareTo(other: BlessingSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>): Int = priority.compareTo(other.priority) +} diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/request/BlessingSkinRequest.kt b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/request/BlessingSkinRequest.kt new file mode 100644 index 0000000..8ace575 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/request/BlessingSkinRequest.kt @@ -0,0 +1,79 @@ +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 + +@Serializable +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 = emptyMap() + + /** + * 获取请求体参数(用于POST请求的JSON body) + * 例如: mapOf("token" to "abc123", "amount" to 1) + */ + open fun bodyParameters(): Map = 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 + + /** + * 获取预期的成功响应类型名称(用于日志和调试) + */ + abstract fun expectedResponseType(): String + + /** + * 获取预期的失败响应类型名称(用于日志和调试) + */ + abstract fun expectedFailureType(): String + + /** + * 是否需要在失败时重试(默认重试) + */ + open fun shouldRetryOnFailure(): Boolean = true +} diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/request/invitecode/GenerateInvitationCodeRequest.kt b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/request/invitecode/GenerateInvitationCodeRequest.kt new file mode 100644 index 0000000..b5d2f5a --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/request/invitecode/GenerateInvitationCodeRequest.kt @@ -0,0 +1,86 @@ +package top.r3944realms.ltdmanager.blessingskin.request.invitecode + +import io.ktor.http.* +import kotlinx.serialization.Serializable +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.config.YamlConfigLoader +import java.util.* + +@Serializable +class GenerateInvitationCodeRequest( + @Transient + val token: String? = null, + @Transient + val amount: Int? = 1, + @Transient + override val createTime: Long = System.currentTimeMillis() +) : BlessingSkinRequest() { + + override fun toJSON(): String { + // 对于GET请求,参数在URL中,body可以为空 + return "{}" + } + + override fun path(): String { + return YamlConfigLoader.loadBlessingSkinServerConfig().invitationApi?.path ?: "api/invitation-codes/generate" + } + + override fun method(): HttpMethod { + return HttpMethod.Post // 使用POST方法,参数在查询JSON中 + } + + override fun queryParameters(): Map { + val params = mutableMapOf() + + // 添加token参数(如果提供) + token?.let { params["token"] = it } + + // 添加amount参数(如果提供) + amount?.let { params["amount"] = it.toString() } + + return params + } + + override fun headers(): HeadersBuilder.() -> Unit = { + // 调用父类的默认headers + super.headers().invoke(this) + // 可以添加额外的header + append("X-Request-ID", UUID.randomUUID().toString()) + } + + override fun getResponse( + responseJson: String, + httpStatusCode: HttpStatusCode + ): ResponseResult { + return try { + // 使用BlessingSkinResponse的伴生对象方法解析 + val response = BlessingSkinResponse.decode(responseJson) as? InvitationCodeGenerationResponse + ?: throw IllegalArgumentException("响应类型不匹配") + + ResponseResult.Success(response) + } catch (e: Exception) { + ResponseResult.Failure( + FailedBlessingSkinResponse.Default( + failedResult = "解析响应失败: ${e.message}" + ) + ) + } + } + + override fun expectedResponseType(): String { + return "invitation_code_generation" + } + + override fun expectedFailureType(): String { + return "default_failure" + } + + override fun shouldRetryOnFailure(): Boolean { + return false + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/response/BlessingSkinResponse.kt b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/response/BlessingSkinResponse.kt new file mode 100644 index 0000000..1a048c5 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/response/BlessingSkinResponse.kt @@ -0,0 +1,36 @@ +package top.r3944realms.ltdmanager.blessingskin.response + +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.blessingskin.response.invitecode.InvitationCodeGenerationResponse + +@Serializable +abstract class BlessingSkinResponse ( + @Transient + open val httpStatusCode: HttpStatusCode = HttpStatusCode.OK, + @Transient + open val createTime: Long = System.currentTimeMillis() +) { + companion object { + // 通用的反序列化方法 + inline fun 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()) + } + } + } + } + + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/response/FailedBlessingSkinResponse.kt b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/response/FailedBlessingSkinResponse.kt new file mode 100644 index 0000000..2c0a32e --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/response/FailedBlessingSkinResponse.kt @@ -0,0 +1,14 @@ +package top.r3944realms.ltdmanager.blessingskin.response + +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +abstract class FailedBlessingSkinResponse: BlessingSkinResponse() { + abstract fun failedMessage(): String + @Serializable + class Default(@Transient val failedResult: String? = "未知错误") : FailedBlessingSkinResponse() { + override fun failedMessage(): String = failedResult!! + + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/response/ResponseResult.kt b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/response/ResponseResult.kt new file mode 100644 index 0000000..23a037a --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/response/ResponseResult.kt @@ -0,0 +1,38 @@ +package top.r3944realms.ltdmanager.blessingskin.response + +// 响应结果封装 +sealed class ResponseResult { + data class Success(val response: T) : ResponseResult() + data class Failure(val failure: F) : ResponseResult() + + /** + * 检查是否成功 + */ + 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 { + if (this is Success) action(response) + return this + } + + /** + * 失败时执行操作 + */ + inline fun onFailure(action: (F) -> Unit): ResponseResult { + if (this is Failure) action(failure) + return this + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/response/invitecode/InvitationCodeGenerationResponse.kt b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/response/invitecode/InvitationCodeGenerationResponse.kt new file mode 100644 index 0000000..7f6dbc8 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/blessingskin/response/invitecode/InvitationCodeGenerationResponse.kt @@ -0,0 +1,21 @@ +package top.r3944realms.ltdmanager.blessingskin.response.invitecode + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse +@Serializable +data class InvitationCodeGenerationResponse( + val success: Boolean, + val message: String, + val data: List? = null +) : BlessingSkinResponse() { + + @Serializable + data class InvitationCode( + val code: String, + @SerialName("generated_at") + val generatedAt: String, + @SerialName("expires_at") + val expiresAt: String + ) +} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/core/config/BlessingSkinServerConfig.kt b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/BlessingSkinServerConfig.kt new file mode 100644 index 0000000..3fba758 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/BlessingSkinServerConfig.kt @@ -0,0 +1,76 @@ +package top.r3944realms.ltdmanager.core.config + +import top.r3944realms.ltdmanager.utils.CryptoUtil +import top.r3944realms.ltdmanager.utils.YamlUpdater + +data class BlessingSkinServerConfig( + var url: String ?= null, + var invitationApi: BlessingSkinServerConfig.InvitationApiConfig?= null +) { + data class BlessingSkinServerConfig( + var url: String? = null, + var invitationApi: InvitationApiConfig? = null + ) { + data class InvitationApiConfig( + var path: String? = null, + var encryptedToken: String? = null + ) { + /** + * 获取解密后的 token(如果未加密,返回原值) + */ + val decryptedToken: String? + get() { + if (encryptedToken == null) return null + if (!isEncrypted()) return encryptedToken + return try { + val cipherText = encryptedToken!!.substring(4, encryptedToken!!.length - 1) + CryptoUtil.decrypt(cipherText) + } catch (e: Exception) { + throw IllegalStateException("API token 解密失败", e) + } + } + + /** + * 加密 token(如果未加密)并写回 YAML + */ + fun encryptToken() { + if (encryptedToken == null || isEncrypted()) return + try { + encryptedToken = "ENC(${CryptoUtil.encrypt(encryptedToken!!)})" + YamlUpdater.updateYaml( + YamlConfigLoader.configFilePath.toString(), + "blessing-skin-server.invitation-api.encrypted-token", + encryptedToken!! + ) + } catch (e: Exception) { + throw IllegalStateException("API token 加密失败", e) + } + } + + /** + * 检查是否已加密 + */ + private fun isEncrypted(): Boolean { + return encryptedToken != null && + encryptedToken!!.startsWith("ENC(") && + encryptedToken!!.endsWith(")") + } + + /** + * 获取完整 API URL + */ + fun getFullUrl(baseUrl: String?): String? { + if (baseUrl == null || path.isNullOrBlank()) return null + return baseUrl.trimEnd('/') + "/" + path!!.trimStart('/') + } + + override fun toString(): String { + return "InvitationApiConfig(path=$path, token=***)" + } + } + + override fun toString(): String { + return "BlessingSkinServerConfig(url=$url, invitationApi=$invitationApi)" + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/core/config/MailConfig.kt b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/MailConfig.kt new file mode 100644 index 0000000..ac43b10 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/MailConfig.kt @@ -0,0 +1,58 @@ +package top.r3944realms.ltdmanager.core.config + +import top.r3944realms.ltdmanager.utils.CryptoUtil +import top.r3944realms.ltdmanager.utils.YamlUpdater + +data class MailConfig( + var host: String? = null, // SMTP 主机 + var port: Int? = 587, // 端口(25/465/587) + var mailAddress: String? = null, // 邮箱账号 + var encryptedPassword: String? = null, // 加密后的密码或明文 + var auth: Boolean? = true, // 是否需要认证 + var tls: Boolean? = true, // 是否启用 STARTTLS + var protocol: String = "smtp" // 协议,默认 smtp +) { + val decryptedPassword: String? + get() { + if (encryptedPassword == null) return null + if (!isEncrypted()) return encryptedPassword + try { + val cipherText = encryptedPassword!!.substring(4, encryptedPassword!!.length - 1) + return CryptoUtil.decrypt(cipherText) + } catch (e: Exception) { + throw IllegalStateException("邮件密码解密失败", e) + } + } + + /** + * 加密密码(如果未加密),并写回配置文件 + */ + fun encryptPassword() { + if (encryptedPassword == null || isEncrypted()) { + return + } + try { + encryptedPassword = "ENC(${CryptoUtil.encrypt(encryptedPassword!!)})" + YamlUpdater.updateYaml( + YamlConfigLoader.configFilePath.toString(), + "mail.encrypted-password", + this.encryptedPassword!! + ) + } catch (e: Exception) { + throw IllegalStateException("邮件密码加密失败", e) + } + } + + /** + * 检查密码是否已加密 + */ + private fun isEncrypted(): Boolean { + return encryptedPassword != null && + encryptedPassword!!.startsWith("ENC(") && + encryptedPassword!!.endsWith(")") + } + + override fun toString(): String { + return "MailConfig(host=$host, port=$port, emailAddress=$mailAddress, password=***)" + } +} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ToolConfig.kt b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ToolConfig.kt index 2b9c027..2bbb5dc 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ToolConfig.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ToolConfig.kt @@ -6,6 +6,9 @@ import top.r3944realms.ltdmanager.utils.YamlUpdater data class ToolConfig( var rcon: RconConfig = RconConfig() ) { + fun encryptPassword() { + rcon.encryptPassword() + } data class RconConfig( var mcRconToolPath: String? = null, var mcRconToolConfigPath: String? = null, @@ -30,12 +33,12 @@ data class ToolConfig( /** * 加密 rcon 密码(如果未加密) */ - fun encryptPassword(configFilePath: String) { + fun encryptPassword() { if (rconPassword == null || isEncrypted()) return try { rconPassword = "ENC(${CryptoUtil.encrypt(rconPassword!!)})" YamlUpdater.updateYaml( - configFilePath, + YamlConfigLoader.configFilePath.toString(), "tools.rcon.rcon-password", rconPassword!! ) diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/core/config/YamlConfigLoader.kt b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/YamlConfigLoader.kt index 28c06e7..4847206 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/core/config/YamlConfigLoader.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/YamlConfigLoader.kt @@ -21,9 +21,7 @@ object YamlConfigLoader { // 初始化后加密(确保只执行一次) runCatching { - _config.database.encryptPassword() - _config.websocket.encryptToken() - _config.http.encryptToken() + ensureConfigEncrypted(_config) }.onFailure { e -> println("初始化加密失败: ${e.message}") e.printStackTrace() @@ -33,6 +31,9 @@ object YamlConfigLoader { config?.database?.encryptPassword() config?.websocket?.encryptToken() config?.http?.encryptToken() + config?.mail?.encryptPassword() + config?.tools?.rcon?.encryptPassword() + config?.blessingSkinServer?.invitationApi?.encryptToken() } private fun loadConfig(): ConfigWrapper { if (!Files.exists(configFilePath)) { @@ -73,6 +74,8 @@ object YamlConfigLoader { fun loadHttpConfig(): HttpConfig = config.http fun loadModeConfig(): ModeConfig = config.mode fun loadToolConfig(): ToolConfig = config.tools + fun loadMailConfig(): MailConfig = config.mail + fun loadBlessingSkinServerConfig(): BlessingSkinServerConfig = config.blessingSkinServer data class ConfigWrapper( var database: DatabaseConfig = DatabaseConfig(), var crypto: CryptoConfig = CryptoConfig(), @@ -80,6 +83,8 @@ object YamlConfigLoader { var websocket: WebsocketConfig = WebsocketConfig(), var http: HttpConfig = HttpConfig(), var tools: ToolConfig = ToolConfig(), + var mail: MailConfig = MailConfig(), + var blessingSkinServer: BlessingSkinServerConfig = BlessingSkinServerConfig(), ) } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/core/mail/Mail.kt b/src/main/kotlin/top/r3944realms/ltdmanager/core/mail/Mail.kt new file mode 100644 index 0000000..65a0b2c --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/core/mail/Mail.kt @@ -0,0 +1,38 @@ +package top.r3944realms.ltdmanager.core.mail + +data class Mail( + val from: String? = null, // 发件人 + val to: List, // 收件人(至少一个) + val subject: String, // 邮件主题 + val body: String, // 邮件正文 + val isHtml: Boolean = false, // 是否 HTML + val cc: List = emptyList(), // 抄送 + val bcc: List = emptyList() // 密送 +) { + companion object { + fun simple( + to: String, + subject: String, + body: String, + isHtml: Boolean = false + ): Mail = Mail( + to = listOf(to), + subject = subject, + body = body, + isHtml = isHtml + ) + + fun multiple( + to: List, + subject: String, + body: String, + isHtml: Boolean = false + ): Mail = Mail( + to = to, + subject = subject, + body = body, + isHtml = isHtml + ) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/core/mail/MailBuilder.kt b/src/main/kotlin/top/r3944realms/ltdmanager/core/mail/MailBuilder.kt new file mode 100644 index 0000000..d9e422b --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/core/mail/MailBuilder.kt @@ -0,0 +1,20 @@ +package top.r3944realms.ltdmanager.core.mail + +class MailBuilder { + var from: String? = null + val to = mutableListOf() + var subject: String = "" + var body: String = "" + var isHtml: Boolean = false + val cc = mutableListOf() + val bcc = mutableListOf() + + fun build() = Mail(from, to, subject, body, isHtml, cc, bcc) +} + +fun mail(block: MailBuilder.() -> Unit): Mail { + val builder = MailBuilder() + builder.block() + return builder.build() +} + diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/main.kt b/src/main/kotlin/top/r3944realms/ltdmanager/main.kt index 55f4249..7fb0098 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/main.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/main.kt @@ -1,29 +1,31 @@ package top.r3944realms.ltdmanager -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import top.r3944realms.ltdmanager.module.GroupRequestHandlerModule -import top.r3944realms.ltdmanager.module.RconPlayerListModule -import top.r3944realms.ltdmanager.utils.LoggerUtil -import java.util.concurrent.atomic.AtomicBoolean +import top.r3944realms.ltdmanager.core.config.YamlConfigLoader +import top.r3944realms.ltdmanager.module.* -fun main() = runBlocking { - // 标记程序是否运行 - val isRunning = AtomicBoolean(true) - +fun main() = GlobalManager.runBlockingMain { + val groupId:Long = 538751386 + val selfQQId = 3327379836 // 创建模块实例 val groupModule = GroupRequestHandlerModule( client = GlobalManager.napCatClient, - targetGroupId = 538751386 + targetGroupId = groupId ) + val groupMsgPollingModule = GroupMessagePollingModule( + targetGroupId = groupId, + pollIntervalMillis = 5_000L, + msgHistoryCheck = 15 + ) + val toolConfig = YamlConfigLoader.loadToolConfig() val rconModule = RconPlayerListModule( - pollIntervalMillis = 3_000L, - timeout = 2_000L, + groupMessagePollingModule = groupMsgPollingModule, + rconTimeOut = 2_000L, cooldownMillis = 10_000L, - targetGroupId = 538751386, - selfId = 3327379836, + selfId = selfQQId, selfNickName = "闲趣老土豆", + rconPath = toolConfig.rcon.mcRconToolPath.toString(), + rconConfigPath = toolConfig.rcon.mcRconToolConfigPath.toString(), keywords = setOf( //形容 "土豆", "马铃薯", "Potato", "potato", "POTATO", @@ -31,30 +33,39 @@ fun main() = runBlocking { //正经 "列表","服务器状态", "TPS", "tps", "list", "List" ) - ); + ) + val mailConfig = YamlConfigLoader.loadMailConfig() + val mailModule = MailModule( + host = mailConfig.host.toString(), + authToken = mailConfig.decryptedPassword.toString(), + port = mailConfig.port!!, + senderEmailAddress = mailConfig.mailAddress!!, + ) + val blessingSkinConfig = YamlConfigLoader.loadBlessingSkinServerConfig() + val invitationCodesModule = InvitationCodesModule( + groupMessagePollingModule = groupMsgPollingModule, + mailModule = mailModule, + apiToken = blessingSkinConfig.invitationApi?.decryptedToken!!, + selfId = selfQQId, + keywords = setOf( + "申请皮肤站注册邀请码", + "申请土豆服务器注册邀请码", + "申请LTD邀请码", + "Apply for an invitation code" + ) + ) // 注册模块到全局模块管理器 GlobalManager.moduleManager.registerModule(groupModule) + GlobalManager.moduleManager.registerModule(groupMsgPollingModule) GlobalManager.moduleManager.registerModule(rconModule) + GlobalManager.moduleManager.registerModule(mailModule) + GlobalManager.moduleManager.registerModule(invitationCodesModule) // 加载模块 GlobalManager.moduleManager.loadModule(groupModule.name) + GlobalManager.moduleManager.loadModule(groupMsgPollingModule.name) GlobalManager.moduleManager.loadModule(rconModule.name) - - - // 捕获 JVM 关闭信号,优雅退出 - Runtime.getRuntime().addShutdownHook(Thread { - runBlocking { - LoggerUtil.logger.info("\n收到退出信号,正在停止所有模块...") - GlobalManager.moduleManager.stopAllModules() // 批量 stop - LoggerUtil.logger.info("模块卸载完成,程序退出。") - GlobalManager.shutdown() - } - isRunning.set(false) - }) - - // 持续挂起,保持主线程运行 - while (isRunning.get()) { - delay(1000L) - } + GlobalManager.moduleManager.loadModule(mailModule.name) + GlobalManager.moduleManager.loadModule(invitationCodesModule.name) } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/BaseModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/BaseModule.kt index 3fa4c89..b869075 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/BaseModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/BaseModule.kt @@ -1,6 +1,9 @@ package top.r3944realms.ltdmanager.module +import kotlinx.coroutines.CompletableDeferred import top.r3944realms.ltdmanager.GlobalManager +import top.r3944realms.ltdmanager.utils.LoggerUtil +import kotlin.coroutines.cancellation.CancellationException /** * 模块抽象基类 @@ -13,6 +16,10 @@ abstract class BaseModule { */ abstract val name: String + /** + * 停止信号 + */ + private val stopSignal = CompletableDeferred() /** * 模块是否加载 */ @@ -28,6 +35,7 @@ abstract class BaseModule { if (!loaded) { loaded = true onLoad() + LoggerUtil.logger.info("模块加载: $name") } } @@ -35,10 +43,12 @@ abstract class BaseModule { * 模块卸载 * 清理资源,取消协程、关闭监听器等 */ - open fun unload() { + open suspend fun unload() { if (loaded) { loaded = false onUnload() + stopSignal.complete(Unit) + LoggerUtil.logger.info("模块卸载: $name") } } @@ -50,18 +60,27 @@ abstract class BaseModule { /** * 模块卸载时的实际逻辑,由子类实现 */ - protected abstract fun onUnload() + protected abstract suspend fun onUnload() /** * 可选的停止方法,模块内部协程等后台任务在这里被取消 */ open suspend fun stop() { + if (!loaded) return + LoggerUtil.syncInfo("[$name] 收到停止命令") unload() // 默认实现直接卸载 + try { + stopSignal.await() + } catch (_: CancellationException) {} + LoggerUtil.syncInfo("[$name] 模块已安全停止") } /** * 提供访问全局 NapCatClient 的快捷方式 */ protected val napCatClient get() = GlobalManager.napCatClient - + /** + * 提供访问全局 blessingSkinClient 的快捷方式 + */ + protected val blessingSkinClient get() = GlobalManager.blessingSkinClient /** * 获取数据库连接 * 使用 try-with-resources 时会自动关闭 diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupMessagePollingModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupMessagePollingModule.kt new file mode 100644 index 0000000..43eb130 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupMessagePollingModule.kt @@ -0,0 +1,58 @@ +package top.r3944realms.ltdmanager.module + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import top.r3944realms.ltdmanager.napcat.data.ID +import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent +import top.r3944realms.ltdmanager.napcat.event.message.GetGroupMsgHistoryEvent +import top.r3944realms.ltdmanager.napcat.request.message.GetGroupMsgHistoryRequest +import top.r3944realms.ltdmanager.utils.LoggerUtil + +class GroupMessagePollingModule( + val targetGroupId: Long, + private val pollIntervalMillis: Long = 5_000L, + private val msgHistoryCheck: Int = 15 +) : BaseModule() { + + override val name: String = "MessagePollingModule" + private var scope: CoroutineScope? = null + + // 用 Flow 存消息,其他模块可以订阅 + private val _messagesFlow = MutableSharedFlow>( + replay = 1, // 保留最近一份消息 + extraBufferCapacity = 1 + ) + val messagesFlow: SharedFlow> = _messagesFlow.asSharedFlow() + + override fun onLoad() { + LoggerUtil.logger.info("[$name] 启动消息轮询 (群: $targetGroupId)") + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + scope!!.launch { + while (isActive && loaded) { + try { + val event = napCatClient.send( + GetGroupMsgHistoryRequest( + count = msgHistoryCheck, + groupId = ID.long(targetGroupId) + ) + ) as? GetGroupMsgHistoryEvent + + val messages = event?.data?.messages ?: emptyList() + LoggerUtil.logger.debug("[$name] 拉取到 ${messages.size} 条消息") + _messagesFlow.emit(messages) + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 拉取消息失败", e) + } + delay(pollIntervalMillis) + } + } + } + + override suspend fun onUnload() { + LoggerUtil.logger.info("[$name] 模块卸载,停止轮询") + scope?.cancel() // 取消协程 + } + +} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupRequestHandlerModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupRequestHandlerModule.kt index f0b083e..4ae8c4a 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupRequestHandlerModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupRequestHandlerModule.kt @@ -18,66 +18,53 @@ class GroupRequestHandlerModule( override val name: String = "GroupRequestHandlerModule" - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var scope: CoroutineScope? = null - private val stopSignal = CompletableDeferred() override fun onLoad() { LoggerUtil.logger.info("模块[$name]已装载,目标群组: $targetGroupId,轮询间隔: ${pollIntervalMillis}ms") + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // 启动轮询协程 - scope.launch { + scope!!.launch { LoggerUtil.logger.info("[$name] 轮询协程启动") - try { - while (isActive) { - try { - LoggerUtil.logger.debug("[$name] 开始轮询群组请求...") + while (isActive && loaded) { + try { + LoggerUtil.logger.debug("[$name] 开始轮询群组请求...") - // 获取正常请求 - LoggerUtil.logger.debug("[$name] 获取正常群系统消息...") - val systemEvent: GetGroupSystemMsgEvent = - client.send(GetGroupSystemMsgRequest()) - LoggerUtil.logger.debug("[$name] 获取到 ${systemEvent.data.invitedRequest.size} 个邀请请求和 ${systemEvent.data.joinRequests.size} 个加群请求") + // 获取正常请求 + LoggerUtil.logger.debug("[$name] 获取正常群系统消息...") + val systemEvent: GetGroupSystemMsgEvent = + client.send(GetGroupSystemMsgRequest()) + LoggerUtil.logger.debug("[$name] 获取到 ${systemEvent.data.invitedRequest.size} 个邀请请求和 ${systemEvent.data.joinRequests.size} 个加群请求") - handleEvent(systemEvent) + handleEvent(systemEvent) - // 获取被过滤的请求 - LoggerUtil.logger.debug("[$name] 获取被过滤的群系统消息...") - val ignoredEvent: GetGroupIgnoredNotifiesEvent = - client.send(GetGroupIgnoredNotifiesRequest()) - LoggerUtil.logger.debug("[$name] 获取到 ${ignoredEvent.data.invitedRequest.size} 个被过滤的邀请请求和 ${ignoredEvent.data.joinRequests.size} 个被过滤的加群请求") + // 获取被过滤的请求 + LoggerUtil.logger.debug("[$name] 获取被过滤的群系统消息...") + val ignoredEvent: GetGroupIgnoredNotifiesEvent = + client.send(GetGroupIgnoredNotifiesRequest()) + LoggerUtil.logger.debug("[$name] 获取到 ${ignoredEvent.data.invitedRequest.size} 个被过滤的邀请请求和 ${ignoredEvent.data.joinRequests.size} 个被过滤的加群请求") - handleEvent(ignoredEvent) + handleEvent(ignoredEvent) - LoggerUtil.logger.debug("[$name] 本轮轮询完成,等待 ${pollIntervalMillis}ms 后继续") - } catch (e: Exception) { - LoggerUtil.logger.error("[$name] 轮询执行异常", e) - } - delay(pollIntervalMillis) + LoggerUtil.logger.debug("[$name] 本轮轮询完成,等待 ${pollIntervalMillis}ms 后继续") + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 轮询执行异常", e) } - } catch (e: CancellationException) { - LoggerUtil.logger.info("[$name] 轮询协程收到取消信号") - } finally { - LoggerUtil.logger.info("[$name] 轮询协程退出,完成 stopSignal") - stopSignal.complete(Unit) + delay(pollIntervalMillis) } } } - override suspend fun stop() { - LoggerUtil.logger.info("[$name] 收到停止命令,开始关闭协程...") - scope.cancel() - LoggerUtil.logger.info("[$name] 等待协程退出...") - stopSignal.await() - LoggerUtil.logger.info("[$name] 协程已退出,卸载模块资源") - onUnload() - } - public override fun onUnload() { + public override suspend fun onUnload() { LoggerUtil.logger.info("[$name] 已卸载") + scope?.cancel() } private suspend fun handleEvent(event: Any) { + if (!loaded) return LoggerUtil.logger.debug("[$name] 处理群请求事件: ${event.javaClass.simpleName}") val provider: GroupRequestProvider? = when (event) { @@ -115,7 +102,7 @@ class GroupRequestHandlerModule( } 2, 3 -> { - val reason = if (status == 2) "审核未通过" else "待审核" + val reason = if (status == 3) "审核未通过,或请使用填写白名单所用QQ号加群" else "白名单待审核,请通过后再加" LoggerUtil.logger.info("[$name] 拒绝加群: groupId=${request.groupId}, invitorUin=${request.invitorUin}, status=$status, reason=$reason, requestId=${request.requestId}") val request1 = SetGroupAddRequestRequest( false, diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/InvitationCodeException.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/InvitationCodeException.kt new file mode 100644 index 0000000..1f515c3 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/InvitationCodeException.kt @@ -0,0 +1,19 @@ +package top.r3944realms.ltdmanager.module + +/** + * 自定义异常类 + */ +sealed class InvitationCodeException(message: String) : Exception(message) { + + // 使用 public 构造函数 + class QuantityMismatchException( + val expectedCount: Int, + val actualCount: Int + ) : InvitationCodeException("数量不一致: 期望 $expectedCount, 实际 $actualCount") + + // 添加其他类型的异常 + class DatabaseException(message: String) : InvitationCodeException(message) + + class NetworkException(message: String) : InvitationCodeException(message) + class ApiFailureException(message: String) : InvitationCodeException(message) +} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/InvitationCodesModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/InvitationCodesModule.kt new file mode 100644 index 0000000..d9cea82 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/InvitationCodesModule.kt @@ -0,0 +1,648 @@ +package top.r3944realms.ltdmanager.module + +import kotlinx.coroutines.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +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.mail.mail +import top.r3944realms.ltdmanager.napcat.NapCatClient +import top.r3944realms.ltdmanager.napcat.data.ID +import top.r3944realms.ltdmanager.napcat.data.MessageElement +import top.r3944realms.ltdmanager.napcat.data.MessageType +import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent +import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest +import top.r3944realms.ltdmanager.utils.HtmlTemplateUtil +import top.r3944realms.ltdmanager.utils.LoggerUtil +import java.io.File +import java.sql.Timestamp +import java.util.* +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + + +/* + 1. 订阅消息模块 (触发关键词, 注意过滤自己的消息,避免重复触发) [Done] + 2. 根据QQ号去查询机器人数据库中的视图表的id (此操作耗时,应设置针对指定用户的持久化冷却) + 3. id存在 [错误处理: id不存在提醒用户为无法查询到你的id,请联系管理员检查状态] + i. effective 和 is_used 均为 1, + 则回复提醒你已经使用了你的邀请码,切勿重复发送 + + ii. effective 为 1 且 is_used 为 0 + 则查询token_id对应的token记录然后构造发送邮件 + 提醒用户邮件已发送 + + iii. effective 为 0 + 则先通过API创建Token 获取来的响应 [错误处理: 当获取的json消息解析中success为false,则回复用户message中的错误信息] + 用Token去邀请码数据库中查询token_id,将其记录在机器人数据库对应白名单id映射token_id表中 [存在则更新,不存在则插入], + 然后按ii.执行 + + */ +/* +api格式 https://skins.r3944realms.top/api/invitation-codes/generate?token=XXXX&amount=1 +成功消息: +{ + "success": true, + "message": "邀请码生成成功", + "data": [ + { + "code": "XXXXXXX", + "generated_at": "2025-08-29T09:36:36.910623Z", + "expires_at": "2025-09-05T09:36:36.910506Z" + } + ] +} +失败消息: +{ + "success": false, + "message": "无效的 API Token" +} +*/ + +class InvitationCodesModule( + private val groupMessagePollingModule: GroupMessagePollingModule, + private val mailModule: MailModule, + private val apiToken: String, + private val selfId: Long, + private val cooldownMillis: Long = 120_000, + private val keywords: Set = setOf("申请邀请码") +) : BaseModule(), PersistentState { + + override val name: String = "InvitationCodesModule" + private var scope: CoroutineScope? = null + + // 持久化文件(带锁 + 备份) + private val stateFile = File("invitation_codes_quarry_state.json") + private val stateBackupFile = File("invitation_codes_quarry_state.json.bak") + private val fileLock = ReentrantLock() + + private var lastTriggerMapState = loadState() + override fun getStateFile(): File = stateFile + override fun getState(): LastTriggerMapState = lastTriggerMapState + override fun onLoad() { + LoggerUtil.logger.info("[$name] 模块已装载,目标群组: ${groupMessagePollingModule.targetGroupId}") + LoggerUtil.logger.info("[$name] 上次触发状态: lastTriggerMap=${lastTriggerMapState.map}") + LoggerUtil.logger.info("[$name] 关键词列表: $keywords") + + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + scope!!.launch { + LoggerUtil.logger.info("[$name] 轮询协程启动") + groupMessagePollingModule.messagesFlow.collect { messages -> + if (loaded) handleMessages(messages) + } + } + + // 定时落盘(防止异常退出丢状态) + scope!!.launch { + while (isActive) { + delay(60_000) // 每分钟保存一次 + saveState(lastTriggerMapState) + } + } + } + + override suspend fun onUnload() { + LoggerUtil.logger.info("[$name] 模块卸载,保存状态...") + saveState(lastTriggerMapState) + LoggerUtil.logger.info("[$name] 模块卸载,取消协程...") + scope?.cancel() + LoggerUtil.logger.info("[$name] 模块已卸载完成") + } + + // ========================= + // 消息处理主流程 + // ========================= + private suspend fun handleMessages(messages: List) { + val triggerMsgs = filterTriggerMessages(messages) + if (triggerMsgs.isEmpty()) return + + try { + val hadValidCodeButNotUsed = mutableListOf>() + val needNewCode = mutableListOf>() + + getIdAndSelectSituation(triggerMsgs, hadValidCodeButNotUsed, needNewCode) + createAndSearchInvitationCodeIdsThenUpdateDate(needNewCode) + hadVaildCodeButNotUseListHandler(hadValidCodeButNotUsed + needNewCode) + } catch (e: Exception) { + sendFailedMessage(napCatClient, text = "系统错误,请联系管理员: $e") + } finally { + saveState(lastTriggerMapState) + } + } + + /** 过滤出符合条件的触发消息 */ + private fun filterTriggerMessages(messages: List) + : List { + + val filtered = messages.asSequence() + .filter { msg -> + msg.userId != selfId && + (msg.time > lastTriggerMapState.getLastTriggerTime(msg.userId) || + (msg.time == lastTriggerMapState.getLastTriggerTime(msg.userId) + && msg.realId > lastTriggerMapState.getLastTriggerRealId(msg.userId))) && + msg.message.any { seg -> + seg.type == MessageType.Text && + seg.data.text?.let { text -> keywords.any { keyword -> text == keyword } } == true + } + } + .groupBy { it.userId } + .mapNotNull { (_, msgs) -> msgs.maxByOrNull { it.time } } + .filter { runBlocking { filterCoolDownMessage(it) } } + .toList() + + if (filtered.isNotEmpty()) { + LoggerUtil.logger.info("[$name] 待处理消息队列: $filtered") + } + return filtered + } + + private suspend fun getIdAndSelectSituation(msgs: List, + hadVaildCodeButNotUseList : MutableList>, + needNewCodeList: MutableList>) { + if (msgs.isEmpty()) return + + val qqIds = msgs.map { it.userId } + val placeholders = java.lang.String.join(",", Collections.nCopies(qqIds.size, "?")) + // 修正SQL语句的表名引用 + val sql = """ + SELECT q.player_id, q.effective, q.is_used, q.qq + FROM ltd_manager_bot.qualified_user_info q + WHERE q.qq IN ($placeholders) + """.trimIndent() + try { + getConnection().use { conn -> + conn.prepareStatement(sql).use { pstmt -> + // 设置所有参数 + for (i in qqIds.indices) { + pstmt.setLong(i + 1, qqIds[i]) + } + + pstmt.executeQuery().use { rs -> + // 创建结果映射表 + val resultMap = mutableMapOf>() + + while (rs.next()) { + val qq = rs.getLong("qq") + val playerId = rs.getLong("player_id") + // 处理可能的null值 + val playerIdValue = if (rs.wasNull()) null else playerId + val effective = rs.getBoolean("effective") + val isUsed = rs.getBoolean("is_used") + + resultMap[qq] = Triple(playerIdValue, effective, isUsed) + } + + // 分类处理每个消息 + for (msg in msgs) { + val result = resultMap[msg.userId] + + when { + result == null -> { + // 数据库中没有记录, 属于是异常 + LoggerUtil.logger.error("[$name] 无法查询该QQ号为:${msg.userId}的白名单ID,可能该用户非白名单成员") + sendFailedMessage(napCatClient, msg.userId, msg.realId, msg.time, "无法查询到你的白名单应用id,请联系管理员检查状态,对应QQ号:${msg.userId}") + } + result.first != null && result.second == true && result.third == true -> { + // 有player_id且已使用 + LoggerUtil.logger.info("[$name] 该QQ号为:${msg.userId}的白名单ID是${result.first},已使用对应激活码") + sendMessage(napCatClient, msg.userId, msg.realId, msg.time, "你已经使用了你的邀请码,切勿重复发送") + } + result.first != null && result.second == true && result.third == false -> { + // 有player_id、有效且未使用 + LoggerUtil.logger.info("[$name] 该QQ号为:${msg.userId}的白名单ID是${result.first},已有对应激活码但未使用") + hadVaildCodeButNotUseList.add(result.first!! to msg) + } + result.first != null && result.second == false -> { + // 没有player_id但有效,需要新code或处理 + needNewCodeList.add(result.first!! to msg) + } + else -> { + //其它情况,异常,不应该出现 + sendFailedMessage(napCatClient, msg.userId, msg.realId, msg.time, "非法状态,请联系管理员:$result") + } + } + } + } + } + } + } catch (e: Exception) { + // 更好的错误处理 + LoggerUtil.logger.error("[$name] 批量查询用户资格信息失败: ${e.message}", e) + sendFailedMessage(napCatClient, text = "批量查询用户资格信息失败,请联系管理员: ${e.message}") + } + } + private suspend fun hadVaildCodeButNotUseListHandler(list: List>) { + if (list.isEmpty()) return + + val whiteListIds = list.map { it.first } + val placeholders = java.lang.String.join(",", Collections.nCopies(whiteListIds.size, "?")) + + val sql = """ + SELECT q.player_id, q.player_name, q.token, q.expires_at + FROM ltd_manager_bot.qualified_user_info q + WHERE q.player_id IN ($placeholders) + """.trimIndent() + + try { + getConnection().use { conn -> + conn.prepareStatement(sql).use { pstmt -> + for (i in whiteListIds.indices) { + pstmt.setLong(i + 1, whiteListIds[i]) + } + pstmt.executeQuery().use { rs -> + val resultMap = mutableMapOf>() + while (rs.next()) { + val playerId = rs.getLong("player_id") + val playerName = rs.getString("player_name") + val token = rs.getString("token") + val tokenValue = if (rs.wasNull()) null else token + val expiresAt = rs.getTimestamp("expires_at") + val expiresAtValue = if (rs.wasNull()) null else expiresAt + + resultMap[playerId] = Triple(playerName, tokenValue, expiresAtValue) + } + + // 直接遍历原始列表,不需要额外的映射 + for ((playerId, specificMsg) in list) { + val mailRequestArgument = resultMap[playerId] + + if (mailRequestArgument?.second != null && mailRequestArgument.third != null) { + mailModule.enqueue(mail { + to += specificMsg.userId.toString() + "@qq.com" // 直接使用 specificMsg + // 根据需要配置邮件内容 + subject = "LTD邀请码邮件" + isHtml = true + body = HtmlTemplateUtil.tokenMailHtmlTemplate( + mailRequestArgument.first!!, + mailRequestArgument.second!!, + mailRequestArgument.third!!, + 7,2025 + ) + }) + sendMessage(napCatClient, specificMsg.userId, specificMsg.realId, specificMsg.time,"已发送邮件注意,查收QQ邮箱") + } else if (mailRequestArgument?.second != null) { + mailModule.enqueue(mail { + to += specificMsg.userId.toString() + "@qq.com" // 直接使用 specificMsg + // 根据需要配置邮件内容 + subject = "LTD邀请码邮件" + isHtml = true + body = HtmlTemplateUtil.tokenMailHtmlTemplate( + mailRequestArgument.first!!, + mailRequestArgument.second!!, + timeYear = 2025 + ) + }) + sendMessage(napCatClient, specificMsg.userId, specificMsg.realId, specificMsg.time,"已发送邮件注意,查收QQ邮箱") + } else { + LoggerUtil.logger.error("[$name] 异常情况,code为 空值") + sendFailedMessage(napCatClient, specificMsg.userId, specificMsg.realId, specificMsg.time, "系统内部异常,请联系管理员") + } + } + } + } + } + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 查询已获取邀请码但未使用或未过期用户,Code信息失败: ${e.message}", e) + sendFailedMessage(napCatClient, text = "查询已获取邀请码但未使用或未过期用户,Code信息失败: ${e.message}") + } + } + + private suspend fun sendMessage( + client: NapCatClient, + qq: Long, + realId: Long, + time: Long, + text: String = "正常消息" + ) { + LoggerUtil.logger.info("[$name] 发送消息: realId=$realId, text=$text") + + val request = SendGroupMsgRequest( + MessageElement.reply(ID.long(realId), text), + ID.long(groupMessagePollingModule.targetGroupId) + ) + client.sendUnit(request) + LoggerUtil.logger.info("[$name] 已发送 消息") + + // 更新触发的最大 realId + lastTriggerMapState = lastTriggerMapState.updateLastTrigger(qq, realId, time) + } + private suspend fun sendFailedMessage( + client: NapCatClient, + qq: Long? = null, + realId: Long? = null, + time: Long? = null, + text: String = "失败消息" + ) { + LoggerUtil.logger.info("[$name] 发送失败消息: realId=$realId, text=$text") + if (realId != null && qq != null && time != null) { + val request = SendGroupMsgRequest( + MessageElement.reply(ID.long(realId), text), + ID.long(groupMessagePollingModule.targetGroupId) + ) + client.sendUnit(request) + LoggerUtil.logger.info("[$name] 已发送 失败消息") + + // 更新触发的最大 realId + lastTriggerMapState = lastTriggerMapState.updateLastTrigger(qq, realId, time) + } else { + val request = SendGroupMsgRequest( + listOf(MessageElement.text(text)), + ID.long(groupMessagePollingModule.targetGroupId) + ) + client.sendUnit(request) + LoggerUtil.logger.info("[$name] 已发送 失败消息[无指定对象]") + } + } + + // ========================= + // 冷却逻辑 + // ========================= + private suspend fun filterCoolDownMessage(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean { + val triggerDetail = lastTriggerMapState.map[msg.userId] + val lastTriggerTime = triggerDetail?.time ?: -1L + val lastCooldownRealId = triggerDetail?.lastCooldownRealId ?: -1L + val nowSec = System.currentTimeMillis() / 1000 // 转成秒 + + if (lastTriggerTime == -1L || nowSec - lastTriggerTime >= cooldownMillis / 1000) { + // 正常触发 + return true + } + + // 冷却中,如果本消息未发送过冷却提示 + if (msg.realId != lastCooldownRealId) { + val remaining = ((cooldownMillis / 1000) - (nowSec - lastTriggerTime)).coerceAtLeast(1) + val msgText = "⏳ 申请邀请码过于频繁,请稍后再试(剩余 $remaining 秒)" + sendCooldownMessage(napCatClient, msg.userId, msg.realId, msgText) + + // 记录这条消息已发送过冷却提示 + lastTriggerMapState = lastTriggerMapState.updateLastCooldownRealId(msg.userId, msg.realId) + } + + return false + } + + private suspend fun sendCooldownMessage(client: NapCatClient, qq: Long, realId: Long, msg: String) { + val request = SendGroupMsgRequest( + MessageElement.reply(ID.long(realId), msg), + ID.long(groupMessagePollingModule.targetGroupId) + ) + client.sendUnit(request) + lastTriggerMapState = lastTriggerMapState.updateLastTrigger(qq, realId, -1) + } + private suspend fun createAndSearchInvitationCodeIdsThenUpdateDate( + needNewTokenIdAndMsgPairs: List>, + ) { + if (needNewTokenIdAndMsgPairs.isEmpty()) return + + try { + // 1. 创建邀请码 + val invitationCodes = createInvitationCodes(needNewTokenIdAndMsgPairs.size) + + // 2. 验证数量匹配 + validateCodeCountMatch(invitationCodes, needNewTokenIdAndMsgPairs) + + // 3. 获取邀请码ID + val codeToIdMap = getInvitationCodeIds(invitationCodes!!.map { it.code }) + + // 4. 更新或插入关联关系 + updateInvitationCodeAscription(needNewTokenIdAndMsgPairs.map { it.first }, codeToIdMap.values.toList()) + + } catch (e: Exception) { + handleCreationError(e) + } + } + + /** + * 1. 创建邀请码 + */ + private suspend fun createInvitationCodes(amount: Int): List? { + return try { + val response = blessingSkinClient.submitRequest( + GenerateInvitationCodeRequest(amount = amount, token = apiToken) + ) + + when (response) { + is ResponseResult.Success -> { + if (response.response.success) { + response.response.data + } else { + LoggerUtil.logger.warn("[$name] API返回失败: ${response.response.message}") + null + } + } + is ResponseResult.Failure -> { + LoggerUtil.logger.warn("[$name] 创建邀请码失败: ${response.failure.failedResult}") + null + } + } + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 创建邀请码异常", e) + null + } + } + + /** + * 2. 验证数量匹配 + */ + private fun validateCodeCountMatch( + invitationCodes: List?, + needNewTokenIdAndMsgPairs: List> + ) { + if (invitationCodes == null) { + throw InvitationCodeException.ApiFailureException("获取邀请码请求失败") + } + + if (invitationCodes.size != needNewTokenIdAndMsgPairs.size) { + throw InvitationCodeException.QuantityMismatchException( + expectedCount = needNewTokenIdAndMsgPairs.size, + actualCount = invitationCodes.size + ) + } + } + + /** + * 3. 获取邀请码ID + */ + private fun getInvitationCodeIds(invitationCodes: List): Map { + if (invitationCodes.isEmpty()) return emptyMap() + + val placeholders = invitationCodes.joinToString(",") { "?" } + val sql = """ + SELECT i.id, i.code + FROM blessingskin.invitation_codes i + WHERE i.code IN ($placeholders) + """.trimIndent() + + return getConnection().use { conn -> + conn.prepareStatement(sql).use { pstmt -> + // 设置参数 + invitationCodes.forEachIndexed { index, code -> + pstmt.setString(index + 1, code) + } + + val resultMap = mutableMapOf() + pstmt.executeQuery().use { rs -> + while (rs.next()) { + val id = rs.getLong("id") + val code = rs.getString("code") + resultMap[code] = id + } + } + resultMap + } + } + } + + /** + * 4. 更新或插入关联关系 + */ + private fun updateInvitationCodeAscription(playerIds: List, codeIds: List) { + if (playerIds.size != codeIds.size) { + throw IllegalStateException("playerIds和codeIds数量不匹配: ${playerIds.size} vs ${codeIds.size}") + } + + if (playerIds.isEmpty()) return + + val placeholders = playerIds.joinToString(",") { "(?, ?)" } + val sql = """ + INSERT INTO ltd_manager_bot.invitation_code_ascription (id, token_id) + VALUES $placeholders + ON DUPLICATE KEY UPDATE token_id = VALUES(token_id) + """.trimIndent() + + getConnection().use { conn -> + conn.prepareStatement(sql).use { pstmt -> + var paramIndex = 1 + for (i in playerIds.indices) { + pstmt.setLong(paramIndex++, playerIds[i]) + pstmt.setLong(paramIndex++, codeIds[i]) + } + + val affectedRows = pstmt.executeUpdate() + LoggerUtil.logger.debug("[$name] 更新了 $affectedRows 条关联记录") + } + } + } + + /** + * 5. 错误处理 + */ + private suspend fun handleCreationError(e: Exception) { + when (e) { + is InvitationCodeException -> { + LoggerUtil.logger.error("[$name] ${e.message}") + if (e is InvitationCodeException.QuantityMismatchException) { + // 数量不匹配的特殊处理 + handleQuantityMismatch(e.expectedCount, e.actualCount) + } else { + sendFailedMessage(napCatClient, text = "邀请码创建失败,请联系管理员") + } + } + else -> { + LoggerUtil.logger.error("[$name] 捕获异常", e) + sendFailedMessage(napCatClient, text = "系统内部问题,请联系管理员") + } + } + } + + /** + * 数量不匹配的特殊处理 + */ + private suspend fun handleQuantityMismatch(expectedCount: Int, actualCount: Int) { + LoggerUtil.logger.error( + "[$name] 数量不一致BUG,期望: $expectedCount, 实际: $actualCount" + ) + sendFailedMessage(napCatClient, text = "系统内部BUG,请联系管理员") + // TODO: 清理已创建的邀请码 + cleanupCreatedInvitationCodes(actualCount) + } + + /** + * 清理已创建的邀请码(TODO实现) + */ + private fun cleanupCreatedInvitationCodes(createdCount: Int) { + // 实现清理逻辑,删除多余的邀请码 + LoggerUtil.logger.warn("[$name] 需要清理 $createdCount 个邀请码") + } + + + // ========================= + // 状态持久化 + // ========================= + @Serializable + data class LastTriggerMapState( + val map: Map = emptyMap() + ) { + fun getLastTriggerTime(qq: Long): Long = map[qq]?.time ?: -1 + fun getLastTriggerRealId(qq: Long): Long = map[qq]?.realId ?: -1 + + fun updateLastTrigger(qq: Long, realId: Long, time: Long = -1): LastTriggerMapState { + val old = map[qq] + val newTime = if (time != -1L) time else old?.time ?: -1 + val newMap = map.toMutableMap().apply { + put(qq, TriggerDetail(realId, newTime)) + } + return copy(map = newMap) + } + fun updateLastCooldownRealId(qq: Long, realId: Long): LastTriggerMapState { + val old = map[qq] + val newMap = map.toMutableMap().apply { + put(qq, TriggerDetail( + realId = old?.realId ?: -1, + time = old?.time ?: -1, + lastCooldownRealId = realId + )) + } + return copy(map = newMap) + } + } + + @Serializable + data class TriggerDetail(val realId : Long, val time: Long, val lastCooldownRealId: Long = -1L ) + + override fun loadState(): LastTriggerMapState { + return try { + if (!stateFile.exists()) { + LoggerUtil.logger.info("[$name] 状态文件不存在,使用默认值") + return LastTriggerMapState() + } + val content = stateFile.readText() + val state = Json.decodeFromString(content) + LoggerUtil.logger.info("[$name] 成功加载状态: ${state.map}, 文件路径=${stateFile.absolutePath}") + state + } catch (e: Exception) { + LoggerUtil.logger.warn("[$name] 读取状态失败,尝试从备份恢复", e) + try { + if (stateBackupFile.exists()) { + val backup = stateBackupFile.readText() + val state = Json.decodeFromString(backup) + LoggerUtil.logger.info("[$name] 成功从备份恢复状态: ${state.map}") + state + } else { + LastTriggerMapState() + } + } catch (e2: Exception) { + LoggerUtil.logger.error("[$name] 备份也损坏,使用默认值", e2) + LastTriggerMapState() + } + } + } + + override fun saveState(state: LastTriggerMapState) { + fileLock.withLock { + try { + val json = Json.encodeToString(state) + // 先写备份 + if (stateFile.exists()) stateFile.copyTo(stateBackupFile, overwrite = true) + // 写入新文件 + stateFile.writeText(json) + LoggerUtil.logger.info("[$name] 已保存状态: ${state.map}, 文件路径=${stateFile.absolutePath}") + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 保存状态失败", e) + } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/MailModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/MailModule.kt new file mode 100644 index 0000000..d430d20 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/MailModule.kt @@ -0,0 +1,145 @@ +package top.r3944realms.ltdmanager.module + +import jakarta.mail.* +import jakarta.mail.internet.InternetAddress +import jakarta.mail.internet.MimeMessage +import top.r3944realms.ltdmanager.core.mail.Mail +import top.r3944realms.ltdmanager.utils.LoggerUtil +import java.util.* +import java.util.concurrent.LinkedBlockingQueue +import kotlin.concurrent.thread + +class MailModule( + private val protocol: String = "SMTP", + private val host: String, + private val port: Int, + private val senderEmailAddress: String, + private val authToken: String, + private val enableAuth: Boolean = true, + private val enableTLS: Boolean = true, + private val intervalMillis: Long = 2000L // 每封邮件之间的间隔(默认 2s) +) : BaseModule() { + + override val name: String = "MailModule" + + private lateinit var session: Session + private val queue = LinkedBlockingQueue() // 邮件队列 + private var workerThread: Thread? = null + @Volatile private var running = false + + override fun onLoad() { + LoggerUtil.logger.info("[$name] 模块加载中,初始化邮件会话...") + /* + 163 邮箱(以及大多数 SMTP 服务商)区别是: + + 465 👉 “隐式 SSL”,必须启用 mail.smtp.ssl.enable=true。 + + 587 👉 “明文 + STARTTLS”,必须启用 mail.smtp.starttls.enable=true。 + + 而注释中 onLoad() 写死了: + + put("mail.smtp.starttls.enable", enableTLS) + + 所以当用 465 端口时,服务端要求立即握手 SSL,但程序还在用明文 STARTTLS,直接就被 EOF 掉了。 + * */ + // val props = Properties().apply { + // put("mail.transport.protocol", protocol) + // put("mail.smtp.host", host) + // put("mail.smtp.port", port) + // put("mail.smtp.auth", enableAuth) + // put("mail.smtp.starttls.enable", enableTLS) + // } + val props = Properties().apply { + put("mail.transport.protocol", protocol) + put("mail.smtp.host", host) + put("mail.smtp.port", port) + put("mail.smtp.auth", enableAuth) + + when (port) { + 465 -> { + // 隐式 SSL + put("mail.smtp.ssl.enable", "true") + put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory") + put("mail.smtp.socketFactory.port", port.toString()) + } + 587 -> { + // STARTTLS + if (enableTLS) { + put("mail.smtp.starttls.enable", "true") + put("mail.smtp.starttls.required", "true") + } + } + else -> { + // 普通 25 端口或其他情况 + if (enableTLS) { + put("mail.smtp.starttls.enable", "true") + } + } + } + } + session = Session.getInstance(props, object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication { + return PasswordAuthentication(senderEmailAddress, authToken) + } + }) + + running = true + workerThread = thread(start = true, name = "MailSender-Worker") { + LoggerUtil.logger.info("[$name] 邮件发送线程启动") + while (running && loaded) { + try { + val mail = queue.take() // 阻塞等待新任务 + LoggerUtil.logger.info("[$name] 开始发送邮件 -> 收件人: ${mail.to.joinToString(",")}") + sendInternal(mail) + LoggerUtil.logger.info("[$name] 邮件发送完成 -> ${mail.to.joinToString(",")}") + Thread.sleep(intervalMillis) // 限流 + } catch (e: InterruptedException) { + LoggerUtil.logger.info("[$name] 邮件发送线程被中断,准备退出") + break + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 邮件发送出现异常", e) + } + } + } + } + + override suspend fun onUnload() { + LoggerUtil.logger.info("[$name] 模块卸载,停止邮件发送线程") + running = false + workerThread?.interrupt() + workerThread = null + } + + + /** + * 加入发送队列 + */ + fun enqueue(mail: Mail) { + if (!loaded) throw IllegalStateException("MailModule 未加载,不能发送邮件") + LoggerUtil.logger.info("[$name] 邮件加入队列 -> 收件人: ${mail.to.joinToString(",")}, 主题: ${mail.subject}") + queue.put(mail) + } + + /** + * 真正的发送逻辑(内部调用) + */ + private fun sendInternal(mail: Mail) { + val message = MimeMessage(session).apply { + setFrom(InternetAddress(senderEmailAddress,mail.from ?: senderEmailAddress, "UTF-8")) + setRecipients(Message.RecipientType.TO, mail.to.joinToString(",")) + if (mail.cc.isNotEmpty()) { + setRecipients(Message.RecipientType.CC, mail.cc.joinToString(",")) + } + if (mail.bcc.isNotEmpty()) { + setRecipients(Message.RecipientType.BCC, mail.bcc.joinToString(",")) + } + subject = mail.subject + setContent( + mail.body, + if (mail.isHtml) "text/html;charset=UTF-8" else "text/plain;charset=UTF-8" + ) + } + + Transport.send(message) + } +} diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleManager.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleManager.kt index 639bfb7..b668e0c 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleManager.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleManager.kt @@ -33,7 +33,6 @@ class ModuleManager { } try { module.load() - LoggerUtil.logger.info("模块加载: $name") } catch (e: Exception) { LoggerUtil.logger.error("加载模块 $name 失败", e) } @@ -42,7 +41,7 @@ class ModuleManager { /** * 卸载指定模块 */ - fun unloadModule(name: String) { + suspend fun unloadModule(name: String) { val module = modules[name] if (module == null) { LoggerUtil.logger.warn("尝试卸载不存在的模块: $name") @@ -54,7 +53,6 @@ class ModuleManager { } try { module.unload() - LoggerUtil.logger.info("模块卸载: $name") } catch (e: Exception) { LoggerUtil.logger.warn("卸载模块 $name 失败", e) } @@ -63,12 +61,12 @@ class ModuleManager { /** * 卸载所有模块 */ - fun unloadAll() { + suspend fun unloadAll() { modules.values.forEach { module -> try { if (module.loaded) { module.unload() - LoggerUtil.logger.info("模块卸载: ${module.name}") + } } catch (e: Exception) { LoggerUtil.logger.warn("卸载模块 ${module.name} 失败", e) @@ -97,7 +95,7 @@ class ModuleManager { /** * 扩展方法:批量卸载模块 */ - fun ModuleManager.unloadModules(vararg names: String) { + suspend fun ModuleManager.unloadModules(vararg names: String) { names.forEach { unloadModule(it) } } /** diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/PersistentState.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/PersistentState.kt new file mode 100644 index 0000000..6c52fdd --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/PersistentState.kt @@ -0,0 +1,10 @@ +package top.r3944realms.ltdmanager.module + +import java.io.File + +interface PersistentState { + fun getStateFile(): File + fun getState(): T + fun saveState(state: T) + fun loadState(): T +} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/RconPlayerListModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/RconPlayerListModule.kt index 5e08955..8e48a15 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/RconPlayerListModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/RconPlayerListModule.kt @@ -4,13 +4,12 @@ import kotlinx.coroutines.* import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import top.r3944realms.ltdmanager.core.config.YamlConfigLoader +import top.r3944realms.ltdmanager.module.RconPlayerListModule.LastTriggerState import top.r3944realms.ltdmanager.napcat.NapCatClient import top.r3944realms.ltdmanager.napcat.data.ID import top.r3944realms.ltdmanager.napcat.data.MessageElement import top.r3944realms.ltdmanager.napcat.data.MessageType -import top.r3944realms.ltdmanager.napcat.event.message.GetGroupMsgHistoryEvent -import top.r3944realms.ltdmanager.napcat.request.message.GetGroupMsgHistoryRequest +import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent import top.r3944realms.ltdmanager.napcat.request.message.SendForwardMsgRequest import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest import top.r3944realms.ltdmanager.utils.CmdUtil @@ -19,173 +18,161 @@ import java.io.File import java.util.concurrent.TimeoutException class RconPlayerListModule( - private val pollIntervalMillis: Long = 30_000L, - private val timeout: Long = 2_000L, - private val cooldownMillis: Long = 30_000L, // 默认 30 秒 + private val groupMessagePollingModule: GroupMessagePollingModule, + private val rconTimeOut: Long = 2_000L, + private val cooldownMillis: Long = 30_000L, private var lastSuccessTime: Long = 0L, - private var msgHistoryCheck: Int = 5, - private val targetGroupId: Long, private val selfId: Long, private val selfNickName: String, + private val rconPath: String, + private val rconConfigPath: String, private val keywords: Set = setOf("查看玩家列表", "玩家列表", "在线玩家") -) : BaseModule() { +) : BaseModule(), PersistentState { - private val stopSignal = CompletableDeferred() // 用于等待协程退出 override val name: String = "RconPlayerListModule" - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var scope : CoroutineScope? = null // 持久化文件路径 private val stateFile = File("rcon_playerlist_state.json") + override fun getStateFile(): File = stateFile + // 保存最新触发过的消息 realId 和 time - private var moduleState: ModuleState = loadState() + private var lastTriggerState: LastTriggerState = loadState() - private val rconPath: String - get() = YamlConfigLoader.loadToolConfig().rcon.mcRconToolPath - ?: throw IllegalStateException("RCON 工具路径未配置") - - private val rconConfigPath: String - get() = YamlConfigLoader.loadToolConfig().rcon.mcRconToolConfigPath - ?: throw IllegalStateException("Rcon配置路径未配置") + override fun getState(): LastTriggerState = lastTriggerState override fun onLoad() { - LoggerUtil.logger.info("[$name] 模块已装载,目标群组: $targetGroupId,轮询间隔: ${pollIntervalMillis}ms") - LoggerUtil.logger.info("[$name] 上次触发状态: realId=${moduleState.lastTriggeredRealId}, time=${moduleState.lastTriggerTime}") + LoggerUtil.logger.info("[$name] 模块已装载,目标群组: ${groupMessagePollingModule.targetGroupId}") + LoggerUtil.logger.info("[$name] 上次触发状态: realId=${lastTriggerState.lastTriggeredRealId}, time=${lastTriggerState.lastTriggerTime}") LoggerUtil.logger.info("[$name] 关键词列表: $keywords") - - scope.launch { + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + scope!!.launch { LoggerUtil.logger.info("[$name] 轮询协程启动") - try { - while (isActive) { - LoggerUtil.logger.debug("[$name] 开始轮询群消息历史...") - try { - val historyEvent = napCatClient.send( - GetGroupMsgHistoryRequest( - count = msgHistoryCheck, - groupId = ID.long(targetGroupId) - ) - ) as? GetGroupMsgHistoryEvent - - val messages = historyEvent?.data?.messages ?: emptyList() - LoggerUtil.logger.debug("[$name] 获取到 ${messages.size} 条最近消息") - - // 找到比 lastTriggeredRealId 更新的触发消息 - val triggerMessages = messages.filter { msg -> - ((msg.time > moduleState.lastTriggerTime || - (msg.time == moduleState.lastTriggerTime && msg.realId > moduleState.lastTriggeredRealId)) && msg.userId != selfId) && - msg.message.any { seg -> - seg.type == MessageType.Text && - seg.data.text?.let { text -> - keywords.any { keyword -> - text == keyword - } - } == true - } - } - - LoggerUtil.logger.debug("[$name] 找到 ${triggerMessages.size} 条符合条件的触发消息") - - if (triggerMessages.isNotEmpty()) { - val triggerMsg = triggerMessages.maxBy { it.time } - LoggerUtil.logger.info("[$name] 找到触发消息 realId=${triggerMsg.realId}, time=${triggerMsg.time}, userId=${triggerMsg.userId}") - - val now = System.currentTimeMillis() - - // ✅ 首次触发允许直接执行 - val canTrigger = (lastSuccessTime == 0L) || (now - lastSuccessTime >= cooldownMillis) - - if (!canTrigger) { - val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1) - LoggerUtil.logger.info("[$name] 冷却中,拒绝执行,剩余 $remaining 秒") - sendCooldownMessage(napCatClient, triggerMsg.realId, triggerMsg.time) - continue - } - - // 执行 RCON - val commands = listOf("forge tps","list") - LoggerUtil.logger.info("[$name] 执行 RCON 命令: $commands") - - - runCatching { - val tpsOutput = runCatching { - CmdUtil.runExeCommand(rconPath, "-c", rconConfigPath, "-T", (timeout / 1000).toString() + "s", "forge tps") - }.getOrElse { ex -> - LoggerUtil.logger.warn("[$name] 执行 forge tps 失败: ${ex.message}") - throw ex - } - - val listOutput = runCatching { - CmdUtil.runExeCommand(rconPath, "-c", rconConfigPath, "-T", (timeout / 1000).toString() + "s", "list") - }.getOrElse { ex -> - LoggerUtil.logger.warn("[$name] 执行 list 失败: ${ex.message}") - throw ex - } - if (tpsOutput.contains("i/o timeout") || listOutput.contains("i/o timeout")) { - throw TimeoutException() - } - // 合并输出,再解析 - buildString { - appendLine(tpsOutput.trim()) - appendLine("--------") - appendLine(listOutput.trim()) - } - } .onFailure { ex -> - if (ex is TimeoutException) { - lastSuccessTime = now // ✅ 成功后记录冷却开始时间 - LoggerUtil.logger.warn("[$name] RCON 连接超时: ${ex.message}") - sendFailedMessage(napCatClient, triggerMsg.realId, triggerMsg.time) - } else { - lastSuccessTime = now // ✅ 成功后记录冷却开始时间 - LoggerUtil.logger.error("[$name] RCON 命令执行失败", ex) - sendFailedMessage( - napCatClient, - triggerMsg.realId, - triggerMsg.time, - "系统内部错误请联系管理员:${ex.message}" - ) - throw ex - } - } .onSuccess { output -> - lastSuccessTime = now // ✅ 成功后记录冷却开始时间 - LoggerUtil.logger.info("[$name] RCON 命令执行成功,输出长度: ${output.length}") - LoggerUtil.logger.debug("[$name] RCON 输出内容: $output") - val tpsInfo = parseTPS(output) - val playerListInfo = parsePlayerList(output) - LoggerUtil.logger.info("[$name] 解析成功: TPS=${tpsInfo.overall.meanTPS}, 在线 ${playerListInfo.onlineCount} 人") - // 发送转发消息 - sendForwardMessage(napCatClient, tpsInfo, playerListInfo, triggerMsg.realId, triggerMsg.time) - } - } else { - LoggerUtil.logger.debug("[$name] 未找到新的触发消息") - } - } catch (e: Exception) { - LoggerUtil.logger.error("[$name] 轮询玩家列表或发送转发消息失败", e) - } - LoggerUtil.logger.debug("[$name] 本轮轮询完成,等待 ${pollIntervalMillis}ms") - delay(pollIntervalMillis) + scope!!.launch { + LoggerUtil.logger.info("[$name] 开始订阅消息流") + groupMessagePollingModule.messagesFlow.collect { messages -> + if(loaded) handleMessages(messages) } - } catch (e: CancellationException) { - LoggerUtil.logger.info("[$name] 轮询协程收到取消信号") - } finally { - LoggerUtil.logger.info("[$name] 轮询协程退出,完成 stopSignal") - stopSignal.complete(Unit) } } } - public override fun onUnload() { - LoggerUtil.logger.info("[$name] 模块已卸载") - saveState(moduleState.lastTriggeredRealId, moduleState.lastTriggerTime) // 卸载时保存 + override suspend fun onUnload() { + LoggerUtil.logger.info("[$name] 模块卸载,取消协程...") + scope?.cancel() + saveState(lastTriggerState) + LoggerUtil.logger.info("[$name] 模块已卸载完成") } - override suspend fun stop() { - LoggerUtil.logger.info("[$name] 收到停止命令,开始关闭协程...") - scope.cancel() // 取消协程 - LoggerUtil.logger.info("[$name] 等待协程退出...") - stopSignal.await() // 等待协程完成 - LoggerUtil.logger.info("[$name] 协程已退出,卸载模块资源") - onUnload() // 卸载模块资源,保存状态 + private suspend fun handleMessages(messages: List) { + val triggerMessages = messages + .asSequence() // 使用序列提高性能,特别是消息量大时 + .filter { msg -> + ((msg.time > lastTriggerState.lastTriggerTime || + (msg.time == lastTriggerState.lastTriggerTime && msg.realId > lastTriggerState.lastTriggeredRealId)) + && msg.userId != selfId) && + msg.message.any { seg -> + seg.type == MessageType.Text && + seg.data.text?.let { text -> keywords.any { keyword -> text == keyword } } == true + } + }.toList() + + if (triggerMessages.isNotEmpty()) { + val triggerMsg = triggerMessages.maxBy { it.time } + LoggerUtil.logger.info("[$name] 找到触发消息 realId=${triggerMsg.realId}, time=${triggerMsg.time}, userId=${triggerMsg.userId}") + processTrigger(triggerMsg) + } } + private suspend fun processTrigger(msg: GetFriendMsgHistoryEvent.SpecificMsg) { + val now = System.currentTimeMillis() + + // ✅ 冷却检查(首次触发直接允许) + val canTrigger = (lastSuccessTime == 0L) || (now - lastSuccessTime >= cooldownMillis) + if (!canTrigger) { + val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1) + LoggerUtil.logger.info("[$name] 冷却中,拒绝执行,剩余 $remaining 秒") + sendCooldownMessage(napCatClient, msg.realId, msg.time) + return + } + + // ✅ 执行 RCON 命令 + val commands = listOf("forge tps", "list") + LoggerUtil.logger.info("[$name] 执行 RCON 命令: $commands") + + runCatching { + val tpsOutput = runCatching { + CmdUtil.runExeCommand( + rconPath, + "-c", rconConfigPath, + "-T", (rconTimeOut / 1000).toString() + "s", + "forge tps" + ) + }.getOrElse { ex -> + LoggerUtil.logger.warn("[$name] 执行 forge tps 失败: ${ex.message}") + throw ex + } + + val listOutput = runCatching { + CmdUtil.runExeCommand( + rconPath, + "-c", rconConfigPath, + "-T", (rconTimeOut / 1000).toString() + "s", + "list" + ) + }.getOrElse { ex -> + LoggerUtil.logger.warn("[$name] 执行 list 失败: ${ex.message}") + throw ex + } + + if (tpsOutput.contains("i/o timeout") || listOutput.contains("i/o timeout")) { + throw TimeoutException() + } + + // 合并输出,后续一起解析 + buildString { + appendLine(tpsOutput.trim()) + appendLine("--------") + appendLine(listOutput.trim()) + } + }.onFailure { ex -> + lastSuccessTime = now // ✅ 成功/失败都要刷新冷却开始时间 + + if (ex is TimeoutException) { + LoggerUtil.logger.warn("[$name] RCON 连接超时: ${ex.message}") + sendFailedMessage(napCatClient, msg.realId, msg.time) + } else { + LoggerUtil.logger.error("[$name] RCON 命令执行失败", ex) + sendFailedMessage( + napCatClient, + msg.realId, + msg.time, + "系统内部错误请联系管理员:${ex.message}" + ) + throw ex + } + }.onSuccess { output -> + lastSuccessTime = now + LoggerUtil.logger.info("[$name] RCON 命令执行成功,输出长度: ${output.length}") + LoggerUtil.logger.debug("[$name] RCON 输出内容: $output") + + val tpsInfo = parseTPS(output) + val playerListInfo = parsePlayerList(output) + + LoggerUtil.logger.info( + "[$name] 解析成功: TPS=${tpsInfo.overall.meanTPS}, 在线 ${playerListInfo.onlineCount} 人" + ) + + sendForwardMessage(napCatClient, tpsInfo, playerListInfo, msg.realId, msg.time) + } + + // ✅ 更新触发状态 & 持久化 + lastTriggerState.lastTriggeredRealId = msg.realId + lastTriggerState.lastTriggerTime = msg.time + saveState(lastTriggerState) + } + + private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, time: Long) { val now = System.currentTimeMillis() val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1) // 至少显示 1 秒 @@ -195,14 +182,14 @@ class RconPlayerListModule( val request = SendGroupMsgRequest( MessageElement.reply(ID.long(realId), msg), - ID.long(targetGroupId) + ID.long(groupMessagePollingModule.targetGroupId) ) client.sendUnit(request) // 更新触发状态,但不更新 lastSuccessTime(避免延长冷却) - moduleState.lastTriggeredRealId = realId - moduleState.lastTriggerTime = time - saveState(realId, time) + lastTriggerState.lastTriggeredRealId = realId + lastTriggerState.lastTriggerTime = time + saveState(lastTriggerState) } private val failedMessages = listOf( @@ -211,7 +198,21 @@ class RconPlayerListModule( "🐌 RCON 响应太慢,像蜗牛一样", "🛠️ 系统开小差了,请联系管理员", "⚠️ 服务器没理我,可能在打盹", - "🔥 电路冒烟了!查询失败" + "🔥 电路冒烟了!查询失败", + + // 新增的 + "⏳ 等了半天也没回应,土豆睡着了?", + "📡 信号迷路了,RCON 连接失败", + "🌀 数据转圈圈,一直出不来", + "🚧 前方施工中,暂时无法获取数据", + "🤖 RCON 小机器人宕机,请稍后重启", + "🌩️ 网络打雷了,数据全跑丢了", + "🕳️ 请求掉进黑洞了,没有回音", + "🎭 服务器玩消失,不肯理我", + "📉 查询失败,RCON 掉线了", + "🥶 服务器结冰了,冻得说不出话", + "📵 RCON 拒绝通信,像开飞行模式", + "💤 服务器打瞌睡,回应超时" ) private suspend fun sendFailedMessage( client: NapCatClient, @@ -226,15 +227,15 @@ class RconPlayerListModule( val request = SendGroupMsgRequest( MessageElement.reply(ID.long(realId), finalText), - ID.long(targetGroupId) + ID.long(groupMessagePollingModule.targetGroupId) ) client.sendUnit(request) LoggerUtil.logger.info("[$name] 已发送 RCON 失败消息") // 更新触发的最大 realId - moduleState.lastTriggeredRealId = realId - moduleState.lastTriggerTime = time - saveState(realId, time) // 保存到文件 + lastTriggerState.lastTriggeredRealId = realId + lastTriggerState.lastTriggerTime = time + saveState(lastTriggerState) // 保存到文件 } private suspend fun sendForwardMessage(client: NapCatClient, tps: TPSInfo, info: PlayerListInfo, realId: Long, time: Long) { LoggerUtil.logger.info("[$name] 发送转发消息: realId=$realId, TPS=${tps.overall.meanTPS}, 在线玩家数=${info.onlineCount}") @@ -279,7 +280,7 @@ class RconPlayerListModule( } else { messages.add( SendForwardMsgRequest.Message( - data = SendForwardMsgRequest.PurpleData("😴 当前没有玩家在线"), + data = SendForwardMsgRequest.PurpleData("😴 当前没有玩家在线\n"), type = MessageType.Text ) ) @@ -311,7 +312,7 @@ class RconPlayerListModule( ) val request = SendForwardMsgRequest( - groupId = ID.long(targetGroupId), + groupId = ID.long(groupMessagePollingModule.targetGroupId), messages = listOf(topMessage), news = listOf( SendForwardMsgRequest.ForwardModelNews("点击查看服务器状态与玩家列表"), @@ -325,9 +326,9 @@ class RconPlayerListModule( client.sendUnit(request) LoggerUtil.logger.info("[$name] 已发送 TPS+玩家列表 转发消息") - moduleState.lastTriggeredRealId = realId - moduleState.lastTriggerTime = time - saveState(realId, time) + lastTriggerState.lastTriggeredRealId = realId + lastTriggerState.lastTriggerTime = time + saveState(lastTriggerState) } // 添加时间格式化函数 @@ -476,30 +477,29 @@ class RconPlayerListModule( // ---------------- 持久化部分 ---------------- @Serializable - data class ModuleState(var lastTriggeredRealId: Long, var lastTriggerTime: Long) + data class LastTriggerState(var lastTriggeredRealId: Long, var lastTriggerTime: Long) - private fun saveState(realId: Long, time: Long) { + override fun saveState(state: LastTriggerState) { try { - val state = ModuleState(realId, time) stateFile.writeText(Json.encodeToString(state)) - LoggerUtil.logger.info("[$name] 已保存状态: lastTriggeredRealId=$realId, lastTriggerTime=$time") + LoggerUtil.logger.info("[$name] 已保存状态: lastTriggeredRealId=${state.lastTriggeredRealId}, lastTriggerTime=${state.lastTriggerTime}") } catch (e: Exception) { LoggerUtil.logger.error("[$name] 保存状态失败", e) } } - private fun loadState(): ModuleState { + override fun loadState(): LastTriggerState { return try { if (!stateFile.exists()) { LoggerUtil.logger.info("[$name] 状态文件不存在,使用默认值") - return ModuleState(-1L, 0L) + return LastTriggerState(-1L, 0L) } - val state = Json.decodeFromString(stateFile.readText()) + val state = Json.decodeFromString(stateFile.readText()) LoggerUtil.logger.info("[$name] 成功加载状态: lastTriggeredRealId=${state.lastTriggeredRealId}, lastTriggerTime=${state.lastTriggerTime}") state } catch (e: Exception) { LoggerUtil.logger.warn("[$name] 读取状态失败,使用默认值", e) - ModuleState(-1L, 0L) + LastTriggerState(-1L, 0L) } } } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/NapCatClient.kt b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/NapCatClient.kt index 95f2d2c..a955f1b 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/NapCatClient.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/NapCatClient.kt @@ -18,7 +18,6 @@ import top.r3944realms.ltdmanager.utils.Environment import top.r3944realms.ltdmanager.utils.LoggerUtil import java.util.* import kotlin.collections.ArrayDeque -import kotlin.collections.isNotEmpty import kotlin.time.Duration.Companion.seconds class NapCatClient private constructor() : Closeable { @@ -30,11 +29,11 @@ class NapCatClient private constructor() : Closeable { private val semaphore = Semaphore(3) // 普通优先级队列 - private val requestQueue = PriorityQueue(compareBy { it.priority }) + private val requestQueue = PriorityQueue(compareBy { it.priority }) private val queueMutex = Mutex() // 紧急队列 (先进先出,最多 10 个) - private val urgentQueue = ArrayDeque(10) + private val urgentQueue = ArrayDeque(10) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -71,7 +70,7 @@ class NapCatClient private constructor() : Closeable { checkRequest(request) val deferred = CompletableDeferred() queueMutex.withLock { - requestQueue.add(QueueItem(request, deferred, retries, priority, expectsEvent = false)) + requestQueue.add(NapCatQueueItem(request, deferred, retries, priority, expectsEvent = false)) } deferred.await() } @@ -90,7 +89,7 @@ class NapCatClient private constructor() : Closeable { if (urgentQueue.size >= 10) { throw IllegalStateException("紧急任务队列已满 (最多 10 个)") } - urgentQueue.addLast(QueueItem(request, deferred, retries, priority = Int.MIN_VALUE, expectsEvent = false)) + urgentQueue.addLast(NapCatQueueItem(request, deferred, retries, priority = Int.MIN_VALUE, expectsEvent = false)) } deferred.await() } @@ -106,7 +105,7 @@ class NapCatClient private constructor() : Closeable { checkRequest(request) val deferred = CompletableDeferred() queueMutex.withLock { - requestQueue.add(QueueItem(request, deferred, retries, priority, expectsEvent = true)) + requestQueue.add(NapCatQueueItem(request, deferred, retries, priority, expectsEvent = true)) } return deferred.await() } @@ -125,7 +124,7 @@ class NapCatClient private constructor() : Closeable { if (urgentQueue.size >= 10) { throw IllegalStateException("紧急任务队列已满 (最多 10 个)") } - urgentQueue.addLast(QueueItem(request, deferred, retries, priority = Int.MIN_VALUE, expectsEvent = true)) + urgentQueue.addLast(NapCatQueueItem(request, deferred, retries, priority = Int.MIN_VALUE, expectsEvent = true)) } return deferred.await() } @@ -139,7 +138,7 @@ class NapCatClient private constructor() : Closeable { } - private suspend fun processRequest(item: QueueItem) { + private suspend fun processRequest(item: NapCatQueueItem) { semaphore.withPermit { val (request, deferred, retries, _, expectsEvent) = item var attempt = 0 diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/QueueItem.kt b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/NapCatQueueItem.kt similarity index 69% rename from src/main/kotlin/top/r3944realms/ltdmanager/napcat/QueueItem.kt rename to src/main/kotlin/top/r3944realms/ltdmanager/napcat/NapCatQueueItem.kt index b38a4e6..56c9e2b 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/QueueItem.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/NapCatQueueItem.kt @@ -3,12 +3,12 @@ package top.r3944realms.ltdmanager.napcat import kotlinx.coroutines.CompletableDeferred import top.r3944realms.ltdmanager.napcat.request.NapCatRequest -data class QueueItem( +data class NapCatQueueItem( val request: NapCatRequest, val deferred: CompletableDeferred<*>, var retries: Int, val priority: Int, val expectsEvent: Boolean // true 表示返回 NapCatEvent, false 表示 Unit -) : Comparable { - override fun compareTo(other: QueueItem): Int = priority.compareTo(other.priority) +) : Comparable { + override fun compareTo(other: NapCatQueueItem): Int = priority.compareTo(other.priority) } diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/FailedRequestEvent.kt b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/FailedNapCatRequestEvent.kt similarity index 95% rename from src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/FailedRequestEvent.kt rename to src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/FailedNapCatRequestEvent.kt index 250fba4..4d3c7b6 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/FailedRequestEvent.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/FailedNapCatRequestEvent.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @Serializable -data class FailedRequestEvent( +data class FailedNapCatRequestEvent( val status: Status = Status.Failed, val retcode: Int, val data: JsonElement?= null, diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/NapCatEvent.kt b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/NapCatEvent.kt index 96e6ecc..cfc8e70 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/NapCatEvent.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/NapCatEvent.kt @@ -1,7 +1,10 @@ package top.r3944realms.ltdmanager.napcat.event import io.ktor.http.* -import kotlinx.serialization.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import kotlinx.serialization.json.Json import top.r3944realms.ltdmanager.napcat.event.account.AbstractAccountEvent import top.r3944realms.ltdmanager.napcat.event.file.AbstractFileEvent @@ -41,8 +44,8 @@ abstract class NapCatEvent( } } - private fun failedDecode(jsonString: String): FailedRequestEvent { - return FailedRequestEvent.json.decodeFromString(jsonString) + private fun failedDecode(jsonString: String): FailedNapCatRequestEvent { + return FailedNapCatRequestEvent.json.decodeFromString(jsonString) } fun decodeEvent(jsonString: String, type: String): NapCatEvent { return try { diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/group/GetGroupIgnoredNotifiesEvent.kt b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/group/GetGroupIgnoredNotifiesEvent.kt index f3aa6fa..020962e 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/group/GetGroupIgnoredNotifiesEvent.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/group/GetGroupIgnoredNotifiesEvent.kt @@ -3,8 +3,6 @@ package top.r3944realms.ltdmanager.napcat.event.group import kotlinx.serialization.Serializable import kotlinx.serialization.Transient -import top.r3944realms.ltdmanager.napcat.request.group.GetGroupIgnoredNotifiesRequest -import top.r3944realms.ltdmanager.napcat.request.group.GetGroupSystemMsgRequest /** * GetGroupIgnoredNotifies事件 diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/message/group/ForwardGroupSingleMsgEvent.kt b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/message/group/ForwardGroupSingleMsgEvent.kt index e43ee67..8b13d7d 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/message/group/ForwardGroupSingleMsgEvent.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/message/group/ForwardGroupSingleMsgEvent.kt @@ -4,7 +4,6 @@ package top.r3944realms.ltdmanager.napcat.event.message.group import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.json.JsonElement -import top.r3944realms.ltdmanager.napcat.event.NapCatEvent import top.r3944realms.ltdmanager.napcat.event.group.AbstractGroupEvent /** diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/message/group/SendGroupForwardMsgEvent.kt b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/message/group/SendGroupForwardMsgEvent.kt index 09e8414..56034b9 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/message/group/SendGroupForwardMsgEvent.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/napcat/event/message/group/SendGroupForwardMsgEvent.kt @@ -4,7 +4,6 @@ package top.r3944realms.ltdmanager.napcat.event.message.group import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient -import top.r3944realms.ltdmanager.napcat.event.NapCatEvent import top.r3944realms.ltdmanager.napcat.event.group.AbstractGroupEvent /** diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/test.kt b/src/main/kotlin/top/r3944realms/ltdmanager/test.kt deleted file mode 100644 index 59d8dcc..0000000 --- a/src/main/kotlin/top/r3944realms/ltdmanager/test.kt +++ /dev/null @@ -1,42 +0,0 @@ -package top.r3944realms.ltdmanager - -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import top.r3944realms.ltdmanager.module.GroupRequestHandlerModule -import top.r3944realms.ltdmanager.utils.LoggerUtil -import java.util.concurrent.atomic.AtomicBoolean - - -fun main() = runBlocking { - // 标记程序是否运行 - val isRunning = AtomicBoolean(true) - - // 创建模块实例 - val groupModule = GroupRequestHandlerModule( - client = GlobalManager.napCatClient, - targetGroupId = 538751386 - ) - - - // 注册模块到全局模块管理器 - GlobalManager.moduleManager.registerModule(groupModule) - - // 加载模块 - GlobalManager.moduleManager.loadModule(groupModule.name) - - // 捕获 JVM 关闭信号,优雅退出 - Runtime.getRuntime().addShutdownHook(Thread { - runBlocking { - LoggerUtil.logger.info("\n收到退出信号,正在停止所有模块...") - GlobalManager.moduleManager.stopAllModules() // 批量 stop - LoggerUtil.logger.info("模块卸载完成,程序退出。") - GlobalManager.shutdown() - } - isRunning.set(false) - }) - - // 持续挂起,保持主线程运行 - while (isRunning.get()) { - delay(1000L) - } -} \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/utils/ConfigInitializer.kt b/src/main/kotlin/top/r3944realms/ltdmanager/utils/ConfigInitializer.kt index 4350d12..2d4e89d 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/utils/ConfigInitializer.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/utils/ConfigInitializer.kt @@ -28,37 +28,10 @@ object ConfigInitializer { if (resourceStream != null) { Files.copy(resourceStream, filePath, StandardCopyOption.REPLACE_EXISTING) LoggerUtil.logger.info("已生成默认配置文件: $filePath") - } else { - // 资源文件不存在,可写入内置默认 YAML - val defaultYaml = """ - database: - url: "jdbc:mysql://localhost:3306/quizdb?useSSL=false&serverTimezone=UTC" - user: "root" - encrypted-password: "123123aa" - crypto: - secret-key: "ltd25r3944realms" - mode: - bot-api-type: HTTP - environment: DEVELOPMENT - http: - url: "https://127.0.0.1:3001" - encrypted-token: "123123bb" - websocket: - url: "wss://127.0.0.1:3002" - encrypted-token: "123123cc" - tools: - rcon: - mc-rcon-tool-path: "/path/to/rcon" - mc-rcon-tool-config-path: "/path/to/rcon_config" - server-url: "your.minecraft.server" - rcon-password: "123123dd" - """.trimIndent() + LoggerUtil.logger.info("第一次启动,请修改配置后再启动") + exitProcess(-1); + } else throw Error("Jar内部资源文件缺失") - Files.writeString(filePath, defaultYaml) - LoggerUtil.logger.info("已生成默认配置文件(使用内置内容): $filePath") - } - LoggerUtil.logger.info("第一次启动,请修改配置后再启动") - exitProcess(-1); } else { LoggerUtil.logger.info("配置文件已存在: $filePath") } diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/utils/HtmlTemplateUtil.kt b/src/main/kotlin/top/r3944realms/ltdmanager/utils/HtmlTemplateUtil.kt new file mode 100644 index 0000000..00c2f83 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/utils/HtmlTemplateUtil.kt @@ -0,0 +1,57 @@ +package top.r3944realms.ltdmanager.utils + +import java.io.InputStreamReader +import java.nio.charset.Charset +import java.sql.Timestamp +import java.text.SimpleDateFormat + + +object HtmlTemplateUtil { + + /** + * 从指定 HTML 文件读取内容并替换占位符 + * @param resourcePath HTML 文件路径 + * @param variables 占位符变量,如 mapOf("名字" to "小明", "时间" to "2025-08-28") + * @param charset 文件编码,默认 UTF-8 + */ + fun renderTemplateFromClasspath( + resourcePath: String, + variables: Map, + charset: Charset = Charsets.UTF_8 + ): String { + val inputStream = object {}.javaClass.classLoader.getResourceAsStream(resourcePath) + ?: throw IllegalArgumentException("模板文件未找到: $resourcePath") + + val template = InputStreamReader(inputStream, charset).use { it.readText() } + + var result = template + variables.forEach { (key, value) -> + result = result.replace("{$key}", value) + } + + return result + } + + /** + * 生成激活码邮件 HTML + */ + fun tokenMailHtmlTemplate( + playerName: String, + token: String, + expireTime: Timestamp? = null, + validDay: Int? = null, + timeYear: Int + ): String { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") + return renderTemplateFromClasspath( + resourcePath = "mail-body.html", + variables = mapOf( + "player_name" to playerName, + "activation_code" to token, + "expire_time" to (expireTime?.let { sdf.format(it) } ?: "永久有效"), + "valid_days" to (validDay?.toString() ?: "INF"), + "time_year" to timeYear.toString() + ) + ) + } +} diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/utils/LoggerUtil.kt b/src/main/kotlin/top/r3944realms/ltdmanager/utils/LoggerUtil.kt index 756bff5..3c1fde5 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/utils/LoggerUtil.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/utils/LoggerUtil.kt @@ -1,11 +1,258 @@ package top.r3944realms.ltdmanager.utils -import org.slf4j.LoggerFactory +import org.apache.logging.log4j.LogManager +import org.apache.logging.log4j.Logger +import org.apache.logging.log4j.core.LoggerContext +import org.apache.logging.log4j.core.config.Configurator +import java.util.concurrent.atomic.AtomicBoolean -class LoggerUtil { - companion object { - val logger by lazy { - LoggerFactory.getLogger("LTDManagerBot") +object LoggerUtil { + private val isShuttingDown = AtomicBoolean(false) + private val shutdownHooks = mutableListOf<() -> Unit>() + + val logger: Logger by lazy { + LogManager.getLogger("LTDManagerBot") + } + + init { + // 注册关闭钩子 + Runtime.getRuntime().addShutdownHook(Thread { + shutdownGracefully() + }) + } + + /** + * 注册自定义关闭钩子 + */ + fun addShutdownHook(hook: () -> Unit) { + shutdownHooks.add(hook) + } + + /** + * 优雅关闭日志系统 + */ + fun shutdownGracefully() { + if (isShuttingDown.getAndSet(true)) { + return // 避免重复关闭 + } + + try { + // 输出关闭开始信息 + emergencyInfo("🚀 开始优雅关闭日志系统...") + + // 先执行自定义关闭钩子(业务资源关闭) + runCustomShutdownHooks() + + // 刷新所有日志输出(确保日志文件保存) + flushAllLogs() + + // 给日志一些时间写入磁盘 + Thread.sleep(200) + + // 关闭 Log4j2 上下文 + shutdownLog4j2() + + // 最终确认 + println("🎉 日志系统关闭完成") + } catch (e: Exception) { + System.err.println("❌ 关闭过程中发生错误: ${e.message}") + e.printStackTrace() + } + } + + /** + * 执行自定义关闭钩子 + */ + private fun runCustomShutdownHooks() { + if (shutdownHooks.isNotEmpty()) { + emergencyInfo("执行 ${shutdownHooks.size} 个自定义关闭钩子") + shutdownHooks.forEachIndexed { index, hook -> + try { + emergencyInfo("执行关闭钩子 ${index + 1}") + hook() + } catch (e: Exception) { + System.err.println("❌ 关闭钩子 ${index + 1} 执行失败: ${e.message}") + e.printStackTrace() + } + } + } + } + + /** + * 刷新所有日志输出 - 针对 RollingFile Appender 优化 + */ + fun flushAllLogs() { + try { + emergencyInfo("正在刷新日志输出...") + + val context = LogManager.getContext(false) + if (context is LoggerContext) { + // 获取所有 logger 配置 + val loggers = context.configuration.loggers + + loggers.forEach { (loggerName, loggerConfig) -> + loggerConfig.appenders.forEach { (appenderName, appender) -> + try { + // 特别处理 RollingFileAppender + if (appenderName.contains("File", ignoreCase = true)) { + emergencyInfo("刷新文件 Appender: $appenderName") + // 停止并重新启动以确保数据刷新 + appender.stop() + // 短暂延迟确保文件操作完成 + Thread.sleep(50) + appender.start() + } + } catch (e: Exception) { + System.err.println("❌ 刷新 Appender $appenderName 失败: ${e.message}") + } + } + } + } + + // 额外等待确保所有日志写入完成 + Thread.sleep(150) + emergencyInfo("日志刷新完成") + + } catch (e: Exception) { + System.err.println("❌ 刷新日志失败: ${e.message}") + } + } + + /** + * 关闭 Log4j2 上下文 - 安全版本 + */ + private fun shutdownLog4j2() { + try { + emergencyInfo("正在关闭 Log4j2 上下文...") + + val context = LogManager.getContext(false) + if (context is LoggerContext) { + // 先停止所有 appender + context.configuration.loggers.forEach { (_, loggerConfig) -> + loggerConfig.appenders.values.forEach { appender -> + try { + appender.stop() + } catch (e: Exception) { + // 忽略停止错误 + } + } + } + + // 等待一段时间确保文件操作完成 + Thread.sleep(100) + + // 关闭上下文 + context.stop() + + // 使用 Configurator 进行完全关闭 + Configurator.shutdown(context) + } + + emergencyInfo("Log4j2 上下文关闭完成") + + } catch (e: Exception) { + System.err.println("❌ 关闭 Log4j2 上下文失败: ${e.message}") + } + } + + /** + * 同步信息输出(同时输出到控制台和日志) + */ + fun syncInfo(message: String) { + println("[INFO] $message") + if (!isShuttingDown.get()) { + logger.info(message) + } + } + + /** + * 同步调试输出 + */ + fun syncDebug(message: String) { + if (!isShuttingDown.get()) { + logger.debug(message) + } + } + + /** + * 同步错误输出 + */ + fun syncError(message: String, exception: Exception? = null) { + System.err.println("[ERROR] $message") + exception?.let { System.err.println("[ERROR] Exception: ${it.message}") } + + if (!isShuttingDown.get()) { + if (exception != null) { + logger.error(message, exception) + } else { + logger.error(message) + } + } + } + + /** + * 同步警告输出 + */ + fun syncWarn(message: String, exception: Exception? = null) { + println("[WARN] $message") + exception?.let { println("[WARN] Exception: ${it.message}") } + + if (!isShuttingDown.get()) { + if (exception != null) { + logger.warn(message, exception) + } else { + logger.warn(message) + } + } + } + + /** + * 紧急输出(始终输出到控制台,尝试记录日志) + */ + fun emergencyInfo(message: String) { + val formattedMessage = "[EMERGENCY] $message" + println(formattedMessage) + + // 即使正在关闭也尝试记录到日志文件 + if (!isShuttingDown.get()) { + try { + logger.info(formattedMessage) + } catch (e: Exception) { + // 如果日志系统已经关闭,忽略错误 + } + } + } + + /** + * 检查日志系统是否正在关闭 + */ + fun isLoggingShutdown(): Boolean = isShuttingDown.get() + + /** + * 强制刷新当前日志 + */ + fun flushCurrentLogs() { + if (!isShuttingDown.get()) { + try { + logger.info("手动刷新日志...") + // Log4j2 通常会自动刷新,但可以强制调用 + val context = LogManager.getContext(false) + if (context is LoggerContext) { + context.configuration.loggers.forEach { (_, loggerConfig) -> + loggerConfig.appenders.values.forEach { appender -> + try { + appender.stop() + Thread.sleep(10) + appender.start() + } catch (e: Exception) { + // 忽略错误 + } + } + } + } + } catch (e: Exception) { + System.err.println("手动刷新日志失败: ${e.message}") + } } } } \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8edeaac..c23e1b6 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,4 @@ +#修改后再重启注释将会消失 database: # 数据库地址 url: "jdbc:mysql://localhost:3306/quizdb?useSSL=false&serverTimezone=UTC" @@ -25,4 +26,20 @@ tools: mc-rcon-tool-config-path: "/path/to/rcon_config" server-url: "your.minecraft.server" # 格式为 ENC(XXX),若不是则会在加载完成配置后自动加密 - rcon-password: "123123dd" \ No newline at end of file + rcon-password: "123123dd" +mail: + protocol: "SMTP" + host: "smtp.example.com" + port: 587 + mail-address: "your_email@example.com" + # 格式为 ENC(XXX),若不是则会在加载完成配置后自动加密 + encrypted-password: "your_password" + auth: true + tls: true +blessing-skin-server: + url: "https://your.blessing.server" + invitation-api: + path: "/api/invitation-codes/generate" + # 格式为 ENC(XXX),若不是则会在加载完成配置后自动加密 + encrypted-token: "your-secret-token" + diff --git a/src/main/resources/init.sql b/src/main/resources/init.sql new file mode 100644 index 0000000..372e611 --- /dev/null +++ b/src/main/resources/init.sql @@ -0,0 +1,39 @@ +DELIMITER // + +CREATE DATABASE IF NOT EXISTS ltd_manager_bot // + +USE ltd_manager_bot // + +CREATE TABLE IF NOT EXISTS invitation_code_ascription( + id int PRIMARY KEY REFERENCES minecraft_manager_ltd.players(id), + token_id int unsigned NULL REFERENCES blessingskin.invitation_codes(id) ) +//; +-- 也许token_id应该改名为code_id + + +DELIMITER ; + +DELIMITER // +DROP VIEW IF EXISTS qualified_user_info // +CREATE VIEW qualified_user_info AS +SELECT + p.id AS player_id, + p.player_name AS player_name, + p.qq AS qq, + ic.code AS token, + ic.expires_at AS expires_at, + CASE + WHEN ic.is_expired = 0 THEN 1 -- 未过期 → 有效 + WHEN ic.used_by != 0 THEN 1 -- 已使用 → 有效 + ELSE 0 -- 过期且未使用 → 无效 + END AS effective, + IF(ic.used_by != 0, 1, 0) AS is_used -- 是否使用 + +FROM (minecraft_manager_ltd.players p LEFT JOIN ltd_manager_bot.invitation_code_ascription ica ON p.id = ica.id) LEFT JOIN blessingskin.invitation_codes ic ON ica.token_id = ic.id +WHERE p.status = 1; // + +DELIMITER ; +SELECT p1.id, invitation_codes.id +FROM minecraft_manager_ltd.players p1 LEFT JOIN blessingskin.players p2 ON LOWER(p1.player_name) COLLATE utf8mb4_unicode_ci = LOWER(p2.name) COLLATE utf8mb4_unicode_ci JOIN blessingskin.invitation_codes ON p2.uid = invitation_codes.used_by +WHERE used_by != 0; -- +-- uid 拿到token 去查询 ID diff --git a/src/main/resources/mail-body.html b/src/main/resources/mail-body.html new file mode 100644 index 0000000..faf4135 --- /dev/null +++ b/src/main/resources/mail-body.html @@ -0,0 +1,109 @@ + + + + + + + + + + + LTD 专属服务器激活码 + + + + 尊敬的 {player_name},您好: + + 感谢您选择LTD专属服务器,您的激活凭证如下: + + + 激活码 + {activation_code} + + + + ⚠️ 重要提示: + 本激活码与您的账号绑定,请勿泄露给他人。 + 失效日期:{expire_time} + + + + ⏳ 有效期说明: + 本激活码自发放之日起 {valid_days} 天 内有效,过期将自动失效。 + + + 请点击下方按钮前往注册: + 皮肤站注册 + + 如有任何问题,欢迎随时联系我们的 + 技术支持。 + + + + + + + diff --git a/src/test/kotlin/top/r394realms/ltdmanagertest/mail/MailTest.kt b/src/test/kotlin/top/r394realms/ltdmanagertest/mail/MailTest.kt new file mode 100644 index 0000000..58c231b --- /dev/null +++ b/src/test/kotlin/top/r394realms/ltdmanagertest/mail/MailTest.kt @@ -0,0 +1,48 @@ +package top.r394realms.ltdmanagertest.mail + +import top.r3944realms.ltdmanager.GlobalManager +import top.r3944realms.ltdmanager.core.config.YamlConfigLoader +import top.r3944realms.ltdmanager.core.mail.mail +import top.r3944realms.ltdmanager.module.MailModule +import top.r3944realms.ltdmanager.utils.HtmlTemplateUtil +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +fun main() = GlobalManager.runBlockingMain { + val mailConfig = YamlConfigLoader.loadMailConfig() + val mailModule = mailConfig.port?.let { portIt -> + mailConfig.mailAddress?.let { mailAddressIt -> + MailModule( + host = mailConfig.host.toString(), + authToken = mailConfig.decryptedPassword.toString(), + port = portIt, + senderEmailAddress = mailAddressIt, + ) + } + } + if (mailModule == null) throw IllegalStateException("Lost Required Argument") + GlobalManager.moduleManager.registerModule(mailModule) + + GlobalManager.moduleManager.loadModule(mailModule.name) + val template = object {}.javaClass.classLoader + .getResource("mail-body.html")?: throw IllegalArgumentException("模板文件未找到") + val expireHours = 24 // 有效期 24 小时 + val expireTime = LocalDateTime.now().plusHours(expireHours.toLong()) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + val bodyC = HtmlTemplateUtil.renderTemplate(template.file.toString(), mapOf( + "player_name" to "小明", + "activation_code" to "ABC123", + "expire_time" to expireTime, + "valid_days" to "${expireHours/24}", + "time_year" to "2025" + )) + val mail = mail { + from = "闲趣时坞" + to += "f256198830@hotmail.com" + subject = "=-=" + body = bodyC + isHtml = true + cc += "f256198830@outlook.com" + } + mailModule.enqueue(mail) +} \ No newline at end of file diff --git a/src/test/kotlin/top/r394realms/ltdmanagertest/test.kt b/src/test/kotlin/top/r394realms/ltdmanagertest/test.kt new file mode 100644 index 0000000..4e8686b --- /dev/null +++ b/src/test/kotlin/top/r394realms/ltdmanagertest/test.kt @@ -0,0 +1,20 @@ +package top.r394realms.ltdmanagertest + +import top.r3944realms.ltdmanager.GlobalManager +import top.r3944realms.ltdmanager.module.GroupRequestHandlerModule + + +fun main() = GlobalManager.runBlockingMain { + // 创建模块实例 + val groupModule = GroupRequestHandlerModule( + client = GlobalManager.napCatClient, + targetGroupId = 538751386 + ) + + + // 注册模块到全局模块管理器 + GlobalManager.moduleManager.registerModule(groupModule) + + // 加载模块 + GlobalManager.moduleManager.loadModule(groupModule.name) +} \ No newline at end of file
尊敬的 {player_name},您好:
感谢您选择LTD专属服务器,您的激活凭证如下:
请点击下方按钮前往注册:
如有任何问题,欢迎随时联系我们的 + 技术支持。 +