From e8fbd30c5e805cb16d735c361625492c6df1c6a6 Mon Sep 17 00:00:00 2001 From: 3944Realms Date: Tue, 9 Jun 2026 13:09:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E5=AE=A1=E8=AE=A1=E7=B3=BB=E7=BB=9F&=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../r3944realms/ltdmanager/GlobalManager.kt | 5 + .../ltdmanager/core/config/ModuleConfig.kt | 1 + .../ltdmanager/core/config/ToolConfig.kt | 1 + .../core/config/WhitelistSystemConfig.kt | 43 ++ .../core/config/YamlConfigLoader.kt | 5 +- .../ltdmanager/module/ModuleFactory.kt | 54 ++- .../r3944realms/ltdmanager/module/Modules.kt | 1 + .../ltdmanager/module/WhitelistAuditModule.kt | 377 ++++++++++++++++++ .../whitelist/WhitelistSystemClient.kt | 112 ++++++ .../whitelist/WhitelistSystemQueueItem.kt | 17 + .../whitelist/data/WhitelistPlayerInfo.kt | 22 + .../request/WhitelistActionRequest.kt | 53 +++ .../whitelist/request/WhitelistListRequest.kt | 37 ++ .../request/WhitelistSystemRequest.kt | 13 + .../response/FailedWhitelistSystemResponse.kt | 11 + .../response/WhitelistApiResponse.kt | 12 + .../response/WhitelistSystemResponse.kt | 35 ++ .../ltdmanagertest/gitea/GiteaEventTest.kt | 255 ++++++++++++ .../ltdmanagertest/util/SqlTemplateTest.kt | 112 +++++- .../whitelist/WhitelistRequestTest.kt | 95 +++++ src/test/resources/sql/test/query_test.sql | 3 + src/test/resources/sql/test/upsert_test.sql | 3 + 22 files changed, 1251 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/top/r3944realms/ltdmanager/core/config/WhitelistSystemConfig.kt create mode 100644 src/main/kotlin/top/r3944realms/ltdmanager/module/WhitelistAuditModule.kt create mode 100644 src/main/kotlin/top/r3944realms/ltdmanager/whitelist/WhitelistSystemClient.kt create mode 100644 src/main/kotlin/top/r3944realms/ltdmanager/whitelist/WhitelistSystemQueueItem.kt create mode 100644 src/main/kotlin/top/r3944realms/ltdmanager/whitelist/data/WhitelistPlayerInfo.kt create mode 100644 src/main/kotlin/top/r3944realms/ltdmanager/whitelist/request/WhitelistActionRequest.kt create mode 100644 src/main/kotlin/top/r3944realms/ltdmanager/whitelist/request/WhitelistListRequest.kt create mode 100644 src/main/kotlin/top/r3944realms/ltdmanager/whitelist/request/WhitelistSystemRequest.kt create mode 100644 src/main/kotlin/top/r3944realms/ltdmanager/whitelist/response/FailedWhitelistSystemResponse.kt create mode 100644 src/main/kotlin/top/r3944realms/ltdmanager/whitelist/response/WhitelistApiResponse.kt create mode 100644 src/main/kotlin/top/r3944realms/ltdmanager/whitelist/response/WhitelistSystemResponse.kt create mode 100644 src/test/kotlin/top/r394realms/ltdmanagertest/gitea/GiteaEventTest.kt create mode 100644 src/test/kotlin/top/r394realms/ltdmanagertest/whitelist/WhitelistRequestTest.kt create mode 100644 src/test/resources/sql/test/query_test.sql create mode 100644 src/test/resources/sql/test/upsert_test.sql diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/GlobalManager.kt b/src/main/kotlin/top/r3944realms/ltdmanager/GlobalManager.kt index 23d571e..6070ae3 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/GlobalManager.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/GlobalManager.kt @@ -9,6 +9,7 @@ import top.r3944realms.ltdmanager.mcserver.McSrvStatusClient import top.r3944realms.ltdmanager.module.ModuleManager import top.r3944realms.ltdmanager.napcat.NapCatClient import top.r3944realms.ltdmanager.utils.LoggerUtil +import top.r3944realms.ltdmanager.whitelist.WhitelistSystemClient import java.sql.Connection import java.util.concurrent.atomic.AtomicBoolean @@ -41,6 +42,9 @@ object GlobalManager { val mcsmClient: MCSMClient by lazy { MCSMClient.create() } + val whitelistSystemClient: WhitelistSystemClient by lazy { + WhitelistSystemClient.create() + } val moduleManager: ModuleManager by lazy { ModuleManager() } @@ -82,6 +86,7 @@ object GlobalManager { "Hikari 数据源" to { dataSource.close() }, "CheveretoClient" to { cheveretoClient.close() }, "McsmClient" to { mcsmClient.close() }, + "WhitelistSystemClient" to { whitelistSystemClient.close() }, ) resources.forEach { (name, closer) -> diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ModuleConfig.kt b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ModuleConfig.kt index 43f7ae2..b4b3868 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ModuleConfig.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ModuleConfig.kt @@ -126,6 +126,7 @@ data class ModuleConfig( HELP_MODULE(Modules.HELP), GITEA_WEBHOOK_MODULE(Modules.GITEA_WEBHOOK), RCON_COMMAND_MODULE(Modules.RCON_COMMAND), + WHITELIST_AUDIT_MODULE(Modules.WHITELIST_AUDIT), UNKNOWN_MODULE("UnknownModule"); } // 基础获取方法 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 7cf77e7..9ea2ee8 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ToolConfig.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/ToolConfig.kt @@ -10,6 +10,7 @@ data class ToolConfig( rcon.encryptPassword() } data class RconConfig( + var enableBasePath: Boolean? = false, var mcRconToolPath: String? = null, var mcRconToolConfigPath: String? = null, var serverUrl: String? = null, diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/core/config/WhitelistSystemConfig.kt b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/WhitelistSystemConfig.kt new file mode 100644 index 0000000..8184037 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/WhitelistSystemConfig.kt @@ -0,0 +1,43 @@ +package top.r3944realms.ltdmanager.core.config + +import top.r3944realms.ltdmanager.utils.CryptoUtil +import top.r3944realms.ltdmanager.utils.YamlUpdater + +data class WhitelistSystemConfig( + var url: String? = null, + var encryptedToken: String? = null +) { + 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("Whitelist API token 解密失败", e) + } + } + + fun encryptToken() { + if (encryptedToken == null || isEncrypted()) return + try { + encryptedToken = "ENC(${CryptoUtil.encrypt(encryptedToken!!)})" + YamlUpdater.updateYaml( + YamlConfigLoader.appConfigFilePath.toString(), + "whitelist-system.encrypted-token", + encryptedToken!! + ) + } catch (e: Exception) { + throw IllegalStateException("Whitelist API token 加密失败", e) + } + } + + private fun isEncrypted(): Boolean { + return encryptedToken != null && + encryptedToken!!.startsWith("ENC(") && + encryptedToken!!.endsWith(")") + } + + override fun toString(): String = "WhitelistSystemConfig(url=$url, token=***)" +} 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 b52a276..eb6c578 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/core/config/YamlConfigLoader.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/core/config/YamlConfigLoader.kt @@ -37,10 +37,11 @@ object YamlConfigLoader { config?.http?.encryptToken() config?.mcsm?.encryptApi() config?.mail?.encryptPassword() - config?.tools?.rcon?.encryptPassword() + config?.tools?.encryptPassword() config?.blessingSkinServer?.invitationApi?.encryptToken() config?.dgLab?.wsServer?.encryptPassword() config?.imgTu?.encryptPassword() + config?.whitelistSystem?.encryptToken() } private fun loadAppConfigWrapper(): AppConfigWrapper { if (!Files.exists(appConfigFilePath)) { @@ -102,6 +103,7 @@ object YamlConfigLoader { fun loadBlessingSkinServerConfig(): BlessingSkinServerConfig = appConfig.blessingSkinServer fun loadDgLabConfig(): DgLabConfig = appConfig.dgLab fun loadTuImgConfig(): ImgTuConfig = appConfig.imgTu + fun loadWhitelistSystemConfig(): WhitelistSystemConfig = appConfig.whitelistSystem fun loadModuleConfig(): ModuleConfig = moduleConfig.module data class AppConfigWrapper( var database: DatabaseConfig = DatabaseConfig(), @@ -115,6 +117,7 @@ object YamlConfigLoader { var blessingSkinServer: BlessingSkinServerConfig = BlessingSkinServerConfig(), var dgLab: DgLabConfig = DgLabConfig(), var imgTu: ImgTuConfig = ImgTuConfig(), + var whitelistSystem: WhitelistSystemConfig = WhitelistSystemConfig(), ) data class ModuleConfigWrapper( diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt index b3e970a..ba39571 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt @@ -8,6 +8,7 @@ import top.r3944realms.ltdmanager.module.exception.ConfigError import top.r3944realms.ltdmanager.module.gitea.GiteaEventType import top.r3944realms.ltdmanager.module.gitea.GiteaWebhookModule import top.r3944realms.ltdmanager.module.RconCommandModule +import top.r3944realms.ltdmanager.module.WhitelistAuditModule object ModuleFactory { fun createModule(config: ModuleConfig.Module): BaseModule { @@ -25,6 +26,7 @@ object ModuleFactory { HELP_MODULE -> createHelpModule(config) GITEA_WEBHOOK_MODULE -> createGiteaWebhook(config) RCON_COMMAND_MODULE -> createRconCommand(config) + WHITELIST_AUDIT_MODULE -> createWhitelistAudit(config) UNKNOWN_MODULE -> throw ConfigError(ConfigError.Type.INVALID_PARAMETER, "unknown module") } } @@ -144,6 +146,13 @@ object ModuleFactory { val groupMessagePollingModule = resolveDependency(config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling") as GroupMessagePollingModule val selfNickName = config.string("self-nick-name") val keywords = config.stringList("keywords") + val enableBasePath = toolConfig.rcon.enableBasePath ?: false + var rconToolPath = toolConfig.rcon.mcRconToolPath.toString() + var rconConfigPath = toolConfig.rcon.mcRconToolConfigPath.toString() + if (enableBasePath) { + rconToolPath += config.string("rcon-tool-path") + rconConfigPath += config.string("rcon-config-path") + } return RconPlayerListModule( config.name, groupMessagePollingModule, @@ -151,8 +160,8 @@ object ModuleFactory { cooldownMillis, selfId, selfNickName, - toolConfig.rcon.mcRconToolPath.toString(), - toolConfig.rcon.mcRconToolConfigPath.toString(), + rconToolPath, + rconConfigPath, keywords.toSet() ) } @@ -224,14 +233,21 @@ object ModuleFactory { val commandBlocklist = config.stringList("command-blocklist").toSet() val commandPrefix = config.string("command-prefix") val rconTimeoutSec = config.getOrDefault("rcon-timeout-sec", 5L) + val enableBasePath = toolConfig.rcon.enableBasePath ?: false + var rconToolPath = toolConfig.rcon.mcRconToolPath.toString() + var rconConfigPath = toolConfig.rcon.mcRconToolConfigPath.toString() + if (enableBasePath) { + rconToolPath += config.string("rcon-tool-path") + rconConfigPath += config.string("rcon-config-path") + } val groupMessagePollingModule = resolveDependency( config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling" ) as GroupMessagePollingModule return RconCommandModule( config.name, groupMessagePollingModule, - toolConfig.rcon.mcRconToolPath.toString(), - toolConfig.rcon.mcRconToolConfigPath.toString(), + rconToolPath, + rconConfigPath, rconTimeoutSec, selfId, selfNickName, @@ -241,4 +257,34 @@ object ModuleFactory { ) } + private fun createWhitelistAudit(config: ModuleConfig.Module): WhitelistAuditModule { + val whitelistGroupId = config.long("whitelist-group-id") + val filterQqList = config.list("filter-qq-list").toSet() + val enableEmail = config.getOrDefault("enable-email", false) + val reActivationKeywords = config.stringList("re-activation-keywords").toSet() + val gracePeriodDays = config.int("grace-period-days") + val expiryWarningDays = config.int("expiry-warning-days") + val pollIntervalMinutes = config.getOrDefault("poll-interval-minutes", 60L) + val selfId = config.long("self-id") + val groupMessagePollingModule = resolveDependency( + config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling" + ) as GroupMessagePollingModule + val mailModule = if (enableEmail) { + resolveDependency(config.findDependency(MAIL_MODULE), "mailModule") as MailModule + } else null + return WhitelistAuditModule( + config.name, + whitelistGroupId, + groupMessagePollingModule, + GlobalManager.whitelistSystemClient, + mailModule, + filterQqList, + reActivationKeywords, + gracePeriodDays, + expiryWarningDays, + pollIntervalMinutes, + selfId + ) + } + } \ No newline at end of file diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/Modules.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/Modules.kt index 3d9e0a8..b3f55ff 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/Modules.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/Modules.kt @@ -18,6 +18,7 @@ object Modules { val STATE: String = register("StateModule") val GITEA_WEBHOOK: String = register("GiteaWebhookModule") val RCON_COMMAND: String = register("RconCommandModule") + val WHITELIST_AUDIT: String = register("WhitelistAuditModule") fun register(name: String): String { MODULES.add(name) return name diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/WhitelistAuditModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/WhitelistAuditModule.kt new file mode 100644 index 0000000..faf7f23 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/WhitelistAuditModule.kt @@ -0,0 +1,377 @@ +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.core.mail.mail +import top.r3944realms.ltdmanager.module.common.filter.TriggerMessageFilter +import top.r3944realms.ltdmanager.module.common.filter.type.IgnoreSelfFilter +import top.r3944realms.ltdmanager.module.common.filter.type.KeywordFilter +import top.r3944realms.ltdmanager.module.common.filter.type.NewMessageFilter +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.data.msghistory.MsgHistorySpecificMsg +import top.r3944realms.ltdmanager.napcat.event.group.GetGroupMemberListEvent +import top.r3944realms.ltdmanager.napcat.request.group.GetGroupMemberListRequest +import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest +import top.r3944realms.ltdmanager.utils.LoggerUtil +import top.r3944realms.ltdmanager.whitelist.WhitelistSystemClient +import java.io.File + +class WhitelistAuditModule( + moduleName: String, + private val whitelistGroupId: Long, + private val mainGroupPollingModule: GroupMessagePollingModule, + private val whitelistClient: WhitelistSystemClient, + private val mailModule: MailModule?, + private val filterQqList: Set, + private val reActivationKeywords: Set, + private val gracePeriodDays: Int, + private val expiryWarningDays: Int, + private val pollIntervalMinutes: Long, + private val selfId: Long, +) : BaseModule(Modules.WHITELIST_AUDIT, moduleName), PersistentState { + + private var scope: CoroutineScope? = null + + private val stateFile: File = getStateFileInternal("whitelist_audit_state.json", name) + private val stateBackupFile: File = getStateFileInternal("whitelist_audit_state.json.bak", name) + + private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + + override fun getStateFileInternal(): File = stateFile + + private var auditState: AuditState = loadState() + override fun getState(): AuditState = auditState + + private var lastMsgRealId: Long = -1 + private var lastMsgTime: Long = 0 + + private val reactivationFilter by lazy { + TriggerMessageFilter( + listOf( + IgnoreSelfFilter(selfId), + NewMessageFilter { lastMsgTime to lastMsgRealId }, + KeywordFilter(reActivationKeywords), + ) + ) + } + + override fun onLoad() { + LoggerUtil.logger.info("[$name] 白名单审计模块已装载") + LoggerUtil.logger.info("[$name] 白名单群: $whitelistGroupId, 主群: ${mainGroupPollingModule.targetGroupId}") + LoggerUtil.logger.info("[$name] 检查间隔: ${pollIntervalMinutes}分, 宽限期: ${gracePeriodDays}天, 过期警告: ${expiryWarningDays}天前") + LoggerUtil.logger.info("[$name] 邮件通知: ${mailModule != null}") + + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + scope!!.launch { + delay(10_000) + while (isActive && loaded) { + try { + runAuditCycle() + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 审计周期异常", e) + } + delay(pollIntervalMinutes * 60_000) + } + } + + scope!!.launch { + mainGroupPollingModule.messagesFlow.collect { messages -> + if (loaded) { + val filtered = reactivationFilter.filter(messages) + if (filtered.isNotEmpty()) handleReActivationMessages(filtered) + } + } + } + } + + override suspend fun onUnload() { + saveState(auditState) + scope?.cancel() + LoggerUtil.logger.info("[$name] 白名单审计模块已卸载") + } + + // ======== 审计周期 ======== + + private suspend fun runAuditCycle() { + LoggerUtil.logger.info("[$name] 开始审计周期...") + + val groupMembers = fetchWhitelistGroupMembers() ?: return + val groupMemberQqSet = groupMembers.map { it.userId }.toSet() + LoggerUtil.logger.info("[$name] 白名单群成员数: ${groupMemberQqSet.size}") + + val whitelistEntries = whitelistClient.listApproved() + if (whitelistEntries.isEmpty()) { + LoggerUtil.logger.info("[$name] 白名单通过数为0,跳过") + return + } + LoggerUtil.logger.info("[$name] 白名单通过数: ${whitelistEntries.size}") + + val now = System.currentTimeMillis() + val graceMs = gracePeriodDays * 24 * 60 * 60 * 1000L + val warningMs = expiryWarningDays * 24 * 60 * 60 * 1000L + + for (entry in whitelistEntries) { + val qqLong = entry.qq.toLongOrNull() ?: continue + if (qqLong in filterQqList) continue + if (qqLong in groupMemberQqSet) { + val key = entry.qq + if (auditState.entries.containsKey(key) && !auditState.entries[key]!!.reactivated) { + auditState = auditState.copy( + entries = auditState.entries.toMutableMap().apply { remove(key) } + ) + saveState(auditState) + LoggerUtil.logger.info("[$name] $qqLong 重新加入白名单群,清除审计标记") + } + continue + } + + val key = entry.qq + val existing = auditState.entries[key] + + when { + existing == null -> { + LoggerUtil.logger.info("[$name] $qqLong (${entry.playerName}) 不在白名单群,执行拒绝") + val rejected = whitelistClient.reject(entry.id) + val newEntry = AuditEntry( + qq = entry.qq, + playerId = entry.id, + playerName = entry.playerName, + detectedTime = now, + rejectedTime = if (rejected) now else 0 + ) + auditState = auditState.copy( + entries = auditState.entries.toMutableMap().apply { put(key, newEntry) } + ) + saveState(auditState) + if (rejected) { + sendGroupNotification( + "白名单审计: ${entry.playerName}(QQ:$qqLong) 因退出白名单群已被拒绝\n" + + "回复关键词即可在 ${gracePeriodDays}天内重新激活" + ) + sendEmail( + qqLong, entry.playerName, + "LTD白名单审计通知", + "你的白名单因退出白名单QQ群已被暂时拒绝。\n请在 ${gracePeriodDays} 天之内在主群发送关键词重新激活。" + ) + } + } + + existing.reactivated -> { + LoggerUtil.logger.info("[$name] $qqLong (${entry.playerName}) 再次不在白名单群,重新拒绝") + whitelistClient.reject(entry.id) + sendEmail( + qqLong, entry.playerName, + "LTD白名单审计通知", + "你的白名单再次因退出白名单QQ群被拒绝。请在 ${gracePeriodDays} 天之内在主群发送关键词重新激活。" + ) + val updated = existing.copy( + detectedTime = now, + rejectedTime = now, + warningSentTime = 0, + reactivated = false + ) + auditState = auditState.copy( + entries = auditState.entries.toMutableMap().apply { put(key, updated) } + ) + saveState(auditState) + } + + existing.rejectedTime > 0 -> { + val elapsed = now - existing.rejectedTime + if (elapsed >= graceMs) { + LoggerUtil.logger.info("[$name] $qqLong (${entry.playerName}) 宽限期已过,执行删除") + whitelistClient.remove(entry.id) + auditState = auditState.copy( + entries = auditState.entries.toMutableMap().apply { remove(key) } + ) + saveState(auditState) + sendGroupNotification( + "白名单审计: ${entry.playerName}(QQ:$qqLong) 宽限期已过,白名单已删除" + ) + sendEmail( + qqLong, entry.playerName, + "LTD白名单已删除", + "你的白名单因宽限期已过已被永久删除。请重新申请白名单。" + ) + } else if (elapsed >= graceMs - warningMs && existing.warningSentTime == 0L) { + val remainDays = (graceMs - elapsed) / (24 * 60 * 60 * 1000) + LoggerUtil.logger.info("[$name] $qqLong (${entry.playerName}) 宽限期即将过期 (剩余${remainDays}天),发送警告") + sendGroupNotification( + "⚠️ 白名单审计: ${entry.playerName}(QQ:$qqLong) 宽限期仅剩${remainDays}天\n" + + "请在主群发送关键词重新激活,否则将被永久删除" + ) + sendEmail( + qqLong, entry.playerName, + "LTD白名单即将过期", + "你的白名单宽限期仅剩 ${remainDays} 天。请尽快在主群发送关键词重新激活,否则将被永久删除。" + ) + val updated = existing.copy(warningSentTime = now) + auditState = auditState.copy( + entries = auditState.entries.toMutableMap().apply { put(key, updated) } + ) + saveState(auditState) + } + } + } + } + LoggerUtil.logger.info("[$name] 审计周期完成") + } + + // ======== 重新激活关键词处理 ======== + + private suspend fun handleReActivationMessages(messages: List) { + val msg = messages.maxByOrNull { it.time } ?: return + + val key = msg.userId.toString() + val entry = auditState.entries[key] ?: return + + updateMsgState(msg) + val now = System.currentTimeMillis() + val graceMs = gracePeriodDays * 24 * 60 * 60 * 1000L + + if (entry.rejectedTime > 0 && now - entry.rejectedTime < graceMs) { + LoggerUtil.logger.info("[$name] ${msg.userId} (${entry.playerName}) 发送关键词,重新激活白名单") + val approved = whitelistClient.approve(entry.playerId) + if (approved) { + val updated = entry.copy(reactivated = true) + auditState = auditState.copy( + entries = auditState.entries.toMutableMap().apply { put(key, updated) } + ) + saveState(auditState) + napCatClient.sendUnit( + SendGroupMsgRequest( + MessageElement.reply( + ID.long(msg.realId), + "✅ ${entry.playerName} 白名单已重新激活,请在${gracePeriodDays}天内重新加入白名单群" + ), + ID.long(mainGroupPollingModule.targetGroupId) + ) + ) + sendEmail( + msg.userId, entry.playerName, + "LTD白名单已重新激活", + "你的白名单已重新激活。请在 ${gracePeriodDays} 天之内重新加入白名单QQ群,否则将再次被拒绝。" + ) + } + } else { + napCatClient.sendUnit( + SendGroupMsgRequest( + MessageElement.reply( + ID.long(msg.realId), + "⏰ ${entry.playerName} 宽限期已过,无法重新激活,请重新申请白名单" + ), + ID.long(mainGroupPollingModule.targetGroupId) + ) + ) + } + } + + // ======== 群成员获取 ======== + + private suspend fun fetchWhitelistGroupMembers(): List? { + return try { + val event = napCatClient.send( + GetGroupMemberListRequest(ID.long(whitelistGroupId), false) + ) + event.data.filter { !it.isRobot }.map { + GroupMemberData(it.userId, it.nickname) + } + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 获取群成员列表失败", e) + null + } + } + + private data class GroupMemberData(val userId: Long, val nickname: String) + + // ======== 群通知 ======== + + private suspend fun sendGroupNotification(text: String) { + try { + napCatClient.sendUnit( + SendGroupMsgRequest( + listOf(MessageElement.text(text)), + ID.long(mainGroupPollingModule.targetGroupId) + ) + ) + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 发送群通知失败", e) + } + } + + private fun sendEmail(qq: Long, playerName: String, subject: String, body: String) { + val m = mailModule ?: return + try { + m.enqueue(mail { + to += "${qq}@qq.com" + this.subject = "【$playerName】$subject" + this.body = body + isHtml = false + }) + LoggerUtil.logger.info("[$name] 已入队邮件: $subject → ${qq}@qq.com") + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 邮件入队失败", e) + } + } + + private fun updateMsgState(msg: MsgHistorySpecificMsg) { + lastMsgRealId = msg.realId + lastMsgTime = msg.time + } + + // ======== 持久化 ======== + + @Serializable + data class AuditState( + val entries: Map = emptyMap() + ) + + @Serializable + data class AuditEntry( + val qq: String, + val playerId: Int, + val playerName: String, + val detectedTime: Long, + val rejectedTime: Long = 0, + val warningSentTime: Long = 0, + val reactivated: Boolean = false, + ) + + override fun loadState(): AuditState { + return try { + val file = when { + stateFile.exists() -> stateFile + stateBackupFile.exists() -> stateBackupFile + else -> return AuditState() + } + json.decodeFromString(file.readText()) + } catch (e: Exception) { + LoggerUtil.logger.warn("[$name] 读取状态失败,使用默认值", e) + AuditState() + } + } + + override fun saveState(state: AuditState) { + try { + if (stateFile.exists()) stateFile.copyTo(stateBackupFile, overwrite = true) + stateFile.writeText(json.encodeToString(state)) + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 保存状态失败", e) + } + } + + override fun info(): String = "白名单审计模块 - 群:$whitelistGroupId, 间隔:${pollIntervalMinutes}分, 宽限:${gracePeriodDays}天, 邮件:${mailModule != null}" + override fun help(): String = buildString { + appendLine("白名单审计模块 - 定期检查白名单群成员并自动处理离群用户") + appendLine("检查间隔: ${pollIntervalMinutes}分钟") + appendLine("宽限期: ${gracePeriodDays}天") + appendLine("过期警告: 到期前${expiryWarningDays}天") + appendLine("邮件通知: ${mailModule != null}") + appendLine("重新激活关键词: ${reActivationKeywords.joinToString()}") + } +} diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/WhitelistSystemClient.kt b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/WhitelistSystemClient.kt new file mode 100644 index 0000000..62b5641 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/WhitelistSystemClient.kt @@ -0,0 +1,112 @@ +package top.r3944realms.ltdmanager.whitelist + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore +import top.r3944realms.ltdmanager.core.client.IClient +import top.r3944realms.ltdmanager.core.client.response.IFailedResponse +import top.r3944realms.ltdmanager.core.client.response.ResponseResult +import top.r3944realms.ltdmanager.core.config.YamlConfigLoader +import top.r3944realms.ltdmanager.whitelist.data.WhitelistPlayerInfo +import top.r3944realms.ltdmanager.whitelist.request.WhitelistAction +import top.r3944realms.ltdmanager.whitelist.request.WhitelistActionRequest +import top.r3944realms.ltdmanager.whitelist.request.WhitelistListRequest +import top.r3944realms.ltdmanager.whitelist.request.WhitelistSystemRequest +import top.r3944realms.ltdmanager.whitelist.response.FailedWhitelistSystemResponse +import top.r3944realms.ltdmanager.whitelist.response.WhitelistApiResponse +import top.r3944realms.ltdmanager.whitelist.response.WhitelistSystemResponse +import java.util.PriorityQueue + +class WhitelistSystemClient private constructor() + : IClient { + + private val client = HttpClient(CIO) { + expectSuccess = false + install(HttpTimeout) { + requestTimeoutMillis = 30_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 15_000 + } + } + + private val whitelistSystemConfig = YamlConfigLoader.loadWhitelistSystemConfig() + + private val semaphore = Semaphore(3) + private val requestMutex = Mutex() + private val requestQueue = PriorityQueue(compareBy { it.priority }) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + init { + init() + } + + override fun getBaseUrl(): String = whitelistSystemConfig.url!! + + override fun getType(): String = "WhitelistSystemClient" + + override fun getClient(): HttpClient = client + + override fun getSemaphore(): Semaphore = semaphore + + override fun getRequestMutex(): Mutex = requestMutex + + override fun getResponseQueue(): PriorityQueue = requestQueue + + override fun getScope(): CoroutineScope = scope + + override fun createFailureResponse(exception: Exception?): IFailedResponse { + return FailedWhitelistSystemResponse.Default(exception?.stackTraceToString() ?: "ERROR") + } + + override fun addToQueue( + request: WhitelistSystemRequest, + deferredC: CompletableDeferred>, + priority: Int, + maxRetries: Int + ): WhitelistSystemQueueItem { + val element = WhitelistSystemQueueItem(request, deferredC, priority, maxRetries, false) + requestQueue.add(element) + return element + } + + // -- 便捷方法 -- + + suspend fun listApproved(): List { + return when (val result = submitRequest(WhitelistListRequest())) { + is ResponseResult.Success -> { + val apiResponse = result.response as? WhitelistApiResponse + apiResponse?.data ?: emptyList() + } + is ResponseResult.Failure -> emptyList() + } + } + + suspend fun approve(playerId: Int): Boolean = + callAction(WhitelistAction.APPROVE, playerId) + + suspend fun reject(playerId: Int): Boolean = + callAction(WhitelistAction.REJECT, playerId) + + suspend fun remove(playerId: Int): Boolean = + callAction(WhitelistAction.REMOVE, playerId) + + private suspend fun callAction(action: WhitelistAction, playerId: Int): Boolean { + return when (val result = submitRequest(WhitelistActionRequest(playerId, action))) { + is ResponseResult.Success -> { + val apiResponse = result.response as? WhitelistApiResponse + apiResponse?.code == 200 + } + is ResponseResult.Failure -> false + } + } + + companion object { + fun create(): WhitelistSystemClient = WhitelistSystemClient() + } +} diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/WhitelistSystemQueueItem.kt b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/WhitelistSystemQueueItem.kt new file mode 100644 index 0000000..9c3588a --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/WhitelistSystemQueueItem.kt @@ -0,0 +1,17 @@ +package top.r3944realms.ltdmanager.whitelist + +import kotlinx.coroutines.CompletableDeferred +import top.r3944realms.ltdmanager.core.client.QueueItem +import top.r3944realms.ltdmanager.whitelist.request.WhitelistSystemRequest +import top.r3944realms.ltdmanager.whitelist.response.FailedWhitelistSystemResponse +import top.r3944realms.ltdmanager.whitelist.response.WhitelistSystemResponse + +data class WhitelistSystemQueueItem( + val request0: WhitelistSystemRequest, + val deferred0: CompletableDeferred<*>, + val priority0: Int, + var retries0: Int, + val expectsResponse0: Boolean +) : QueueItem( + request0, deferred0, retries0, priority0, expectsResponse0 +) diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/data/WhitelistPlayerInfo.kt b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/data/WhitelistPlayerInfo.kt new file mode 100644 index 0000000..0248088 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/data/WhitelistPlayerInfo.kt @@ -0,0 +1,22 @@ +package top.r3944realms.ltdmanager.whitelist.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WhitelistPlayerInfo( + val id: Int = 0, + @SerialName("playerName") val playerName: String = "", + val uuid: String = "", + val qq: String = "", + val status: Int = 0, + val description: String = "", + @SerialName("createTime") val createTime: String = "", + @SerialName("regionCode") val regionCode: Long = 0, + @SerialName("regionFullName") val regionFullName: String = "", + @SerialName("operatorId") val operatorId: Int? = null, + @SerialName("operatorUsername") val operatorUsername: String? = null, + @SerialName("operatorNickname") val operatorNickname: String? = null, + @SerialName("totalScore") val totalScore: Int = 0, + @SerialName("emailActive") val emailActive: Boolean = false, +) diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/request/WhitelistActionRequest.kt b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/request/WhitelistActionRequest.kt new file mode 100644 index 0000000..ba1c8e2 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/request/WhitelistActionRequest.kt @@ -0,0 +1,53 @@ +package top.r3944realms.ltdmanager.whitelist.request + +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import top.r3944realms.ltdmanager.core.client.response.ResponseResult +import top.r3944realms.ltdmanager.whitelist.response.FailedWhitelistSystemResponse +import top.r3944realms.ltdmanager.whitelist.response.WhitelistApiResponse +import top.r3944realms.ltdmanager.whitelist.response.WhitelistSystemResponse + +enum class WhitelistAction(val operation: String) { + APPROVE("approve"), + REJECT("reject"), + REMOVE("remove"), +} + +@Serializable +class WhitelistActionRequest( + @Transient + private val playerId: Int = 0, + @Transient + private val action: WhitelistAction = WhitelistAction.APPROVE, +) : WhitelistSystemRequest() { + + override fun toJSON(): String = "{}" + + override fun path(): String = "/api/whitelist/${action.operation}/$playerId" + + override fun method(): HttpMethod = when (action) { + WhitelistAction.REMOVE -> HttpMethod.Delete + else -> HttpMethod.Post + } + + override fun getResponse( + responseJson: String, + httpStatusCode: HttpStatusCode + ): ResponseResult { + return try { + val response = WhitelistSystemResponse.decode(responseJson) as? WhitelistApiResponse + ?: throw IllegalArgumentException("响应类型不匹配") + ResponseResult.Success(response) + } catch (e: Exception) { + ResponseResult.Failure( + FailedWhitelistSystemResponse.Default(failedMessage = "解析${action.operation}响应失败: ${e.message}") + ) + } + } + + override fun expectedResponseType(): String = "whitelist_${action.operation}" + override fun expectedFailureType(): String = "default_failure" + override fun shouldRetryOnFailure(): Boolean = false +} diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/request/WhitelistListRequest.kt b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/request/WhitelistListRequest.kt new file mode 100644 index 0000000..207f9c0 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/request/WhitelistListRequest.kt @@ -0,0 +1,37 @@ +package top.r3944realms.ltdmanager.whitelist.request + +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import kotlinx.serialization.Serializable +import top.r3944realms.ltdmanager.core.client.response.ResponseResult +import top.r3944realms.ltdmanager.whitelist.response.FailedWhitelistSystemResponse +import top.r3944realms.ltdmanager.whitelist.response.WhitelistApiResponse +import top.r3944realms.ltdmanager.whitelist.response.WhitelistSystemResponse + +@Serializable +class WhitelistListRequest : WhitelistSystemRequest() { + + override fun toJSON(): String = "{}" + + override fun path(): String = "/api/whitelist/list" + + override fun method(): HttpMethod = HttpMethod.Get + + override fun getResponse( + responseJson: String, + httpStatusCode: HttpStatusCode + ): ResponseResult { + return try { + val response = WhitelistSystemResponse.decode(responseJson) as? WhitelistApiResponse + ?: throw IllegalArgumentException("响应类型不匹配") + ResponseResult.Success(response) + } catch (e: Exception) { + ResponseResult.Failure( + FailedWhitelistSystemResponse.Default(failedMessage = "解析列表响应失败: ${e.message}") + ) + } + } + + override fun expectedResponseType(): String = "whitelist_list" + override fun expectedFailureType(): String = "default_failure" +} diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/request/WhitelistSystemRequest.kt b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/request/WhitelistSystemRequest.kt new file mode 100644 index 0000000..45a6f10 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/request/WhitelistSystemRequest.kt @@ -0,0 +1,13 @@ +package top.r3944realms.ltdmanager.whitelist.request + +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import top.r3944realms.ltdmanager.core.client.request.IRequest +import top.r3944realms.ltdmanager.whitelist.response.FailedWhitelistSystemResponse +import top.r3944realms.ltdmanager.whitelist.response.WhitelistSystemResponse + +@Serializable +abstract class WhitelistSystemRequest( + @Transient + override val createTime: Long = System.currentTimeMillis() +) : IRequest diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/response/FailedWhitelistSystemResponse.kt b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/response/FailedWhitelistSystemResponse.kt new file mode 100644 index 0000000..8c28c84 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/response/FailedWhitelistSystemResponse.kt @@ -0,0 +1,11 @@ +package top.r3944realms.ltdmanager.whitelist.response + +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import top.r3944realms.ltdmanager.core.client.response.IFailedResponse + +@Serializable +abstract class FailedWhitelistSystemResponse : WhitelistSystemResponse(), IFailedResponse { + @Serializable + class Default(@Transient override val failedMessage: String = "未知错误") : FailedWhitelistSystemResponse() +} diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/response/WhitelistApiResponse.kt b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/response/WhitelistApiResponse.kt new file mode 100644 index 0000000..f8a36ec --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/response/WhitelistApiResponse.kt @@ -0,0 +1,12 @@ +package top.r3944realms.ltdmanager.whitelist.response + +import kotlinx.serialization.Serializable +import top.r3944realms.ltdmanager.whitelist.data.WhitelistPlayerInfo + +@Serializable +data class WhitelistApiResponse( + val code: Int = 0, + val msg: String = "", + val count: Int = 0, + val data: List? = null, +) : WhitelistSystemResponse() diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/response/WhitelistSystemResponse.kt b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/response/WhitelistSystemResponse.kt new file mode 100644 index 0000000..315cad7 --- /dev/null +++ b/src/main/kotlin/top/r3944realms/ltdmanager/whitelist/response/WhitelistSystemResponse.kt @@ -0,0 +1,35 @@ +package top.r3944realms.ltdmanager.whitelist.response + +import io.ktor.http.HttpStatusCode +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.core.client.response.IResponse + +@Serializable +abstract class WhitelistSystemResponse( + @Transient + override val httpStatusCode: HttpStatusCode = HttpStatusCode.OK, + @Transient + override val createTime: Long = System.currentTimeMillis() +) : IResponse { + companion object { + inline fun decode(jsonString: String): T { + return json.decodeFromString(jsonString) + } + + val json: Json by lazy { + Json { + ignoreUnknownKeys = true + serializersModule = SerializersModule { + polymorphic(WhitelistSystemResponse::class) { + subclass(FailedWhitelistSystemResponse.Default::class, FailedWhitelistSystemResponse.Default.serializer()) + subclass(WhitelistApiResponse::class, WhitelistApiResponse.serializer()) + } + } + } + } + } +} diff --git a/src/test/kotlin/top/r394realms/ltdmanagertest/gitea/GiteaEventTest.kt b/src/test/kotlin/top/r394realms/ltdmanagertest/gitea/GiteaEventTest.kt new file mode 100644 index 0000000..2b56267 --- /dev/null +++ b/src/test/kotlin/top/r394realms/ltdmanagertest/gitea/GiteaEventTest.kt @@ -0,0 +1,255 @@ +package top.r394realms.ltdmanagertest.gitea + +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import top.r3944realms.ltdmanager.module.gitea.* + +class GiteaEventTest { + + private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + + // ====== GiteaEventType ====== + + @Test + fun `fromHeader parses all event types case-insensitively`() { + assertEquals(GiteaEventType.PUSH, GiteaEventType.fromHeader("push")) + assertEquals(GiteaEventType.PUSH, GiteaEventType.fromHeader("PUSH")) + assertEquals(GiteaEventType.ISSUES, GiteaEventType.fromHeader("issues")) + assertEquals(GiteaEventType.PULL_REQUEST, GiteaEventType.fromHeader("pull_request")) + assertEquals(GiteaEventType.CREATE, GiteaEventType.fromHeader("create")) + assertEquals(GiteaEventType.DELETE, GiteaEventType.fromHeader("delete")) + assertEquals(GiteaEventType.RELEASE, GiteaEventType.fromHeader("release")) + assertEquals(GiteaEventType.REPOSITORY, GiteaEventType.fromHeader("repository")) + assertEquals(GiteaEventType.FORK, GiteaEventType.fromHeader("fork")) + } + + @Test + fun `fromHeader returns null for unknown event type`() { + assertNull(GiteaEventType.fromHeader("unknown")) + assertNull(GiteaEventType.fromHeader("")) + assertNull(GiteaEventType.fromHeader(null)) + } + + @Test + fun `fromHeader returns null for null input`() { + assertNull(GiteaEventType.fromHeader(null)) + } + + // ====== PushPayload ====== + + @Test + fun `deserialize push payload`() { + val payload = json.decodeFromString(pushJson) + assertEquals("refs/heads/master", payload.ref) + assertEquals(2, payload.totalCommits) + assertEquals("user/repo", payload.repository.fullName) + assertEquals("alice", payload.sender.login) + assertEquals(2, payload.commits.size) + assertEquals("Fix bug", payload.commits[0].message) + assertEquals("bob", payload.commits[0].author.login) + } + + // ====== IssuesPayload ====== + + @Test + fun `deserialize issues payload`() { + val payload = json.decodeFromString(issuesJson) + assertEquals("opened", payload.action) + assertEquals(42, payload.issue.number) + assertEquals("Bug report", payload.issue.title) + assertEquals("open", payload.issue.state) + assertEquals("alice", payload.issue.user.login) + assertEquals("user/repo", payload.repository.fullName) + } + + // ====== PullRequestPayload ====== + + @Test + fun `deserialize pull_request payload`() { + val payload = json.decodeFromString(prJson) + assertEquals("opened", payload.action) + assertEquals(7, payload.number) + assertEquals("feature", payload.pullRequest.headBranch.label) + assertEquals("master", payload.pullRequest.baseBranch.label) + assertEquals("open", payload.pullRequest.state) + assertFalse(payload.pullRequest.merged) + } + + // ====== CreatePayload ====== + + @Test + fun `deserialize create payload`() { + val payload = json.decodeFromString(createJson) + assertEquals("feature-x", payload.ref) + assertEquals("branch", payload.refType) + assertEquals("user/repo", payload.repository.fullName) + } + + // ====== DeletePayload ====== + + @Test + fun `deserialize delete payload`() { + val payload = json.decodeFromString(deleteJson) + assertEquals("old-branch", payload.ref) + assertEquals("branch", payload.refType) + } + + // ====== ReleasePayload ====== + + @Test + fun `deserialize release payload`() { + val payload = json.decodeFromString(releaseJson) + assertEquals("published", payload.action) + assertEquals("v1.0.0", payload.release.tagName) + assertEquals("First Release", payload.release.name) + assertFalse(payload.release.draft) + assertFalse(payload.release.prerelease) + } + + // ====== RepositoryPayload ====== + + @Test + fun `deserialize repository payload`() { + val payload = json.decodeFromString(repoJson) + assertEquals("created", payload.action) + assertEquals("user/new-repo", payload.repository.fullName) + } + + // ====== ForkPayload ====== + + @Test + fun `deserialize fork payload`() { + val payload = json.decodeFromString(forkJson) + assertEquals("user/original", payload.repository.fullName) + assertEquals("alice/original", payload.forkedRepo.fullName) + } + + // ====== GiteaUser / GiteaRepository deserialization ====== + + @Test + fun `deserialize GiteaUser with login_name and full_name`() { + val userJson = """{"id":1,"login":"octocat","login_name":"octocat","full_name":"Octo Cat","email":"octo@cat.com","avatar_url":"https://example.com/avatar.png","username":"octocat"}""" + val user = json.decodeFromString(userJson) + assertEquals(1L, user.id) + assertEquals("octocat", user.login) + assertEquals("octocat", user.loginName) + assertEquals("Octo Cat", user.fullName) + assertEquals("octo@cat.com", user.email) + } + + @Test + fun `deserialize GiteaRepository with defaults for null fields`() { + val repoJson = """{"id":10,"name":"hello-world","full_name":"user/hello-world","html_url":"https://gitea.example.com/user/hello-world","default_branch":"main","private":false}""" + val repo = json.decodeFromString(repoJson) + assertEquals(10L, repo.id) + assertEquals("hello-world", repo.name) + assertEquals("user/hello-world", repo.fullName) + assertEquals("main", repo.defaultBranch) + assertEquals("", repo.description) + } + + // ====== Sample JSON payloads (from Gitea webhook docs) ====== + + companion object { + val pushJson = """ + { + "ref": "refs/heads/master", + "before": "abc123", + "after": "def456", + "compare_url": "https://gitea.example.com/user/repo/compare/abc123...def456", + "commits": [ + {"id": "abc123def456789", "message": "Fix bug", "url": "", "author": {"id":1,"login":"bob","login_name":"bob","full_name":"Bob","email":"bob@bob.com","avatar_url":"","username":"bob"}, "committer": {"id":1,"login":"bob","login_name":"bob","full_name":"Bob","email":"bob@bob.com","avatar_url":"","username":"bob"}, "timestamp": ""}, + {"id": "def456abc789", "message": "Add feature", "url": "", "author": {"id":2,"login":"alice","login_name":"alice","full_name":"Alice","email":"alice@a.com","avatar_url":"","username":"alice"}, "committer": {"id":2,"login":"alice","login_name":"alice","full_name":"Alice","email":"alice@a.com","avatar_url":"","username":"alice"}, "timestamp": ""} + ], + "head_commit": null, + "pusher": {"id":2,"login":"alice","login_name":"alice","full_name":"Alice","email":"","avatar_url":"","username":"alice"}, + "sender": {"id":2,"login":"alice","login_name":"alice","full_name":"Alice","email":"","avatar_url":"","username":"alice"}, + "repository": {"id":1,"name":"repo","full_name":"user/repo","owner":{"id":1,"login":"user","login_name":"user","full_name":"","email":"","avatar_url":"","username":"user"},"html_url":"","description":"","ssh_url":"","clone_url":"","default_branch":"master","private":false,"website":""}, + "total_commits": 2 + } + """.trimIndent() + + val issuesJson = """ + { + "action": "opened", + "issue": { + "id": 100, "number": 42, "title": "Bug report", + "body": "Something is broken", "state": "open", + "html_url": "https://gitea.example.com/user/repo/issues/42", + "user": {"id":2,"login":"alice","login_name":"alice","full_name":"Alice","email":"","avatar_url":"","username":"alice"}, + "is_pull": false, "comments": 0, "created_at": "", "updated_at": "" + }, + "sender": {"id":2,"login":"alice","login_name":"alice","full_name":"Alice","email":"","avatar_url":"","username":"alice"}, + "repository": {"id":1,"name":"repo","full_name":"user/repo","owner":{"id":1,"login":"user","login_name":"user","full_name":"","email":"","avatar_url":"","username":"user"},"html_url":"","description":"","ssh_url":"","clone_url":"","default_branch":"master","private":false,"website":""} + } + """.trimIndent() + + val prJson = """ + { + "action": "opened", "number": 7, + "pull_request": { + "id": 200, "number": 7, "title": "Add feature X", + "body": "This PR adds feature X", "state": "open", "merged": false, + "html_url": "https://gitea.example.com/user/repo/pulls/7", + "user": {"id":3,"login":"charlie","login_name":"charlie","full_name":"Charlie","email":"","avatar_url":"","username":"charlie"}, + "head": {"label":"feature","ref":"feature","sha":"sha123","repo":{"id":1,"name":"repo","full_name":"user/repo","owner":{"id":1,"login":"user","login_name":"user","full_name":"","email":"","avatar_url":"","username":"user"},"html_url":"","description":"","ssh_url":"","clone_url":"","default_branch":"master","private":false,"website":""}}, + "base": {"label":"master","ref":"master","sha":"sha456","repo":{"id":1,"name":"repo","full_name":"user/repo","owner":{"id":1,"login":"user","login_name":"user","full_name":"","email":"","avatar_url":"","username":"user"},"html_url":"","description":"","ssh_url":"","clone_url":"","default_branch":"master","private":false,"website":""}}, + "created_at": "", "updated_at": "" + }, + "sender": {"id":3,"login":"charlie","login_name":"charlie","full_name":"Charlie","email":"","avatar_url":"","username":"charlie"}, + "repository": {"id":1,"name":"repo","full_name":"user/repo","owner":{"id":1,"login":"user","login_name":"user","full_name":"","email":"","avatar_url":"","username":"user"},"html_url":"","description":"","ssh_url":"","clone_url":"","default_branch":"master","private":false,"website":""} + } + """.trimIndent() + + val createJson = """ + { + "ref": "feature-x", "ref_type": "branch", + "sha": "abc123def456", + "sender": {"id":2,"login":"alice","login_name":"alice","full_name":"Alice","email":"","avatar_url":"","username":"alice"}, + "repository": {"id":1,"name":"repo","full_name":"user/repo","owner":{"id":1,"login":"user","login_name":"user","full_name":"","email":"","avatar_url":"","username":"user"},"html_url":"","description":"","ssh_url":"","clone_url":"","default_branch":"master","private":false,"website":""} + } + """.trimIndent() + + val deleteJson = """ + { + "ref": "old-branch", "ref_type": "branch", + "sha": "abc123", + "sender": {"id":2,"login":"alice","login_name":"alice","full_name":"Alice","email":"","avatar_url":"","username":"alice"}, + "repository": {"id":1,"name":"repo","full_name":"user/repo","owner":{"id":1,"login":"user","login_name":"user","full_name":"","email":"","avatar_url":"","username":"user"},"html_url":"","description":"","ssh_url":"","clone_url":"","default_branch":"master","private":false,"website":""} + } + """.trimIndent() + + val releaseJson = """ + { + "action": "published", + "release": { + "id": 1, "tag_name": "v1.0.0", "target_commitish": "master", + "name": "First Release", "body": "Initial release", + "draft": false, "prerelease": false, + "url": "", "html_url": "https://gitea.example.com/user/repo/releases/tag/v1.0.0", + "author": {"id":2,"login":"alice","login_name":"alice","full_name":"Alice","email":"","avatar_url":"","username":"alice"}, + "created_at": "" + }, + "sender": {"id":2,"login":"alice","login_name":"alice","full_name":"Alice","email":"","avatar_url":"","username":"alice"}, + "repository": {"id":1,"name":"repo","full_name":"user/repo","owner":{"id":1,"login":"user","login_name":"user","full_name":"","email":"","avatar_url":"","username":"user"},"html_url":"","description":"","ssh_url":"","clone_url":"","default_branch":"master","private":false,"website":""} + } + """.trimIndent() + + val repoJson = """ + { + "action": "created", + "repository": {"id":5,"name":"new-repo","full_name":"user/new-repo","owner":{"id":1,"login":"user","login_name":"user","full_name":"","email":"","avatar_url":"","username":"user"},"html_url":"","description":"","ssh_url":"","clone_url":"","default_branch":"main","private":false,"website":""}, + "sender": {"id":1,"login":"user","login_name":"user","full_name":"","email":"","avatar_url":"","username":"user"} + } + """.trimIndent() + + val forkJson = """ + { + "forkee": {"id":6,"name":"original","full_name":"alice/original","owner":{"id":2,"login":"alice","login_name":"alice","full_name":"","email":"","avatar_url":"","username":"alice"},"html_url":"","description":"","ssh_url":"","clone_url":"","default_branch":"main","private":false,"website":""}, + "repository": {"id":1,"name":"original","full_name":"user/original","owner":{"id":1,"login":"user","login_name":"user","full_name":"","email":"","avatar_url":"","username":"user"},"html_url":"","description":"","ssh_url":"","clone_url":"","default_branch":"main","private":false,"website":""}, + "sender": {"id":2,"login":"alice","login_name":"alice","full_name":"","email":"","avatar_url":"","username":"alice"} + } + """.trimIndent() + } +} diff --git a/src/test/kotlin/top/r394realms/ltdmanagertest/util/SqlTemplateTest.kt b/src/test/kotlin/top/r394realms/ltdmanagertest/util/SqlTemplateTest.kt index 87275af..6bacdac 100644 --- a/src/test/kotlin/top/r394realms/ltdmanagertest/util/SqlTemplateTest.kt +++ b/src/test/kotlin/top/r394realms/ltdmanagertest/util/SqlTemplateTest.kt @@ -1,18 +1,108 @@ package top.r394realms.ltdmanagertest.util +import org.junit.jupiter.api.* +import org.junit.jupiter.api.Assertions.* import top.r3944realms.ltdmanager.utils.SqlTemplate - -fun main() { - val sqlTemplateTest = SqlTemplateTest() - sqlTemplateTest.main() -} +import java.io.File class SqlTemplateTest { - val testSql = SqlTemplate.fromFile("test/test.sql") - private fun test01(): String { - return testSql.bind("placeholder" to "?") + + companion object { + private val testDir = File("config/sql/test") + + @BeforeAll + @JvmStatic + fun setupFiles() { + testDir.mkdirs() + File(testDir, "query_test.sql").writeText(""" + SELECT id, name, status + FROM test.players + WHERE id IN (${'$'}{placeholders}) + """.trimIndent()) + File(testDir, "upsert_test.sql").writeText(""" + INSERT INTO test.records (id, value) + VALUES ${'$'}{values} + ON DUPLICATE KEY UPDATE value = VALUES(value) + """.trimIndent()) + } + + @AfterAll + @JvmStatic + fun cleanupFiles() { + testDir.listFiles()?.forEach { it.delete() } + testDir.delete() + } } - fun main() { - println(test01()) + + @BeforeEach + fun clearCache() { + SqlTemplate.clearCache() } -} \ No newline at end of file + + @Test + fun `load template from file and bind single placeholder`() { + val tmpl = SqlTemplate.fromFile("test/query_test.sql") + val result = tmpl.bind("placeholders" to "?, ?, ?") + + assertTrue(result.contains("WHERE id IN (?, ?, ?)")) + assertTrue(result.contains("FROM test.players")) + } + + @Test + fun `bind multiple placeholders`() { + val tmpl = SqlTemplate.fromFile("test/upsert_test.sql") + val result = tmpl.bind("values" to "(1, 'a'), (2, 'b')") + + assertTrue(result.contains("VALUES (1, 'a'), (2, 'b')")) + assertTrue(result.contains("ON DUPLICATE KEY UPDATE")) + } + + @Test + fun `placeholder not in template leaves result unchanged`() { + val tmpl = SqlTemplate.fromFile("test/query_test.sql") + val result = tmpl.bind("nonexistent" to "ignored") + + assertTrue(result.contains("\${placeholders}")) + } + + @Test + fun `same path returns cached instance`() { + val a = SqlTemplate.fromFile("test/query_test.sql") + val b = SqlTemplate.fromFile("test/query_test.sql") + + assertSame(a, b) + } + + @Test + fun `different paths return different instances`() { + val a = SqlTemplate.fromFile("test/query_test.sql") + val b = SqlTemplate.fromFile("test/upsert_test.sql") + + assertNotSame(a, b) + } + + @Test + fun `loading nonexistent file throws`() { + assertThrows(IllegalArgumentException::class.java) { + SqlTemplate.fromFile("nonexistent/file.sql") + } + } + + @Test + fun `bind returns different strings for different bindings`() { + val tmpl = SqlTemplate.fromFile("test/query_test.sql") + val r1 = tmpl.bind("placeholders" to "?") + val r2 = tmpl.bind("placeholders" to "?, ?") + + assertNotEquals(r1, r2) + } + + @Test + fun `clearCache evicts all entries`() { + val a = SqlTemplate.fromFile("test/query_test.sql") + SqlTemplate.clearCache() + val b = SqlTemplate.fromFile("test/query_test.sql") + + assertNotSame(a, b) + } +} diff --git a/src/test/kotlin/top/r394realms/ltdmanagertest/whitelist/WhitelistRequestTest.kt b/src/test/kotlin/top/r394realms/ltdmanagertest/whitelist/WhitelistRequestTest.kt new file mode 100644 index 0000000..c4ca681 --- /dev/null +++ b/src/test/kotlin/top/r394realms/ltdmanagertest/whitelist/WhitelistRequestTest.kt @@ -0,0 +1,95 @@ +package top.r394realms.ltdmanagertest.whitelist + +import io.ktor.http.HttpMethod +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import top.r3944realms.ltdmanager.whitelist.request.WhitelistAction +import top.r3944realms.ltdmanager.whitelist.request.WhitelistActionRequest +import top.r3944realms.ltdmanager.whitelist.request.WhitelistListRequest + +class WhitelistRequestTest { + + // ====== WhitelistListRequest ====== + + @Test + fun `list request path`() { + val req = WhitelistListRequest() + assertEquals("/api/whitelist/list", req.path()) + } + + @Test + fun `list request uses GET`() { + val req = WhitelistListRequest() + assertEquals(HttpMethod.Get, req.method()) + } + + // ====== WhitelistActionRequest paths ====== + + @Test + fun `approve request path contains player id`() { + val req = WhitelistActionRequest(42, WhitelistAction.APPROVE) + assertEquals("/api/whitelist/approve/42", req.path()) + } + + @Test + fun `reject request path contains player id`() { + val req = WhitelistActionRequest(7, WhitelistAction.REJECT) + assertEquals("/api/whitelist/reject/7", req.path()) + } + + @Test + fun `remove request path contains player id`() { + val req = WhitelistActionRequest(99, WhitelistAction.REMOVE) + assertEquals("/api/whitelist/remove/99", req.path()) + } + + // ====== WhitelistActionRequest methods ====== + + @Test + fun `approve request uses POST`() { + val req = WhitelistActionRequest(1, WhitelistAction.APPROVE) + assertEquals(HttpMethod.Post, req.method()) + } + + @Test + fun `reject request uses POST`() { + val req = WhitelistActionRequest(1, WhitelistAction.REJECT) + assertEquals(HttpMethod.Post, req.method()) + } + + @Test + fun `remove request uses DELETE`() { + val req = WhitelistActionRequest(1, WhitelistAction.REMOVE) + assertEquals(HttpMethod.Delete, req.method()) + } + + // ====== WhitelistAction enum ====== + + @Test + fun `all actions have distinct operation strings`() { + val ops = WhitelistAction.entries.map { it.operation }.toSet() + assertEquals(WhitelistAction.entries.size, ops.size) + } + + @Test + fun `action enums cover approve reject remove`() { + assertEquals(3, WhitelistAction.entries.size) + assertTrue(WhitelistAction.entries.any { it == WhitelistAction.APPROVE }) + assertTrue(WhitelistAction.entries.any { it == WhitelistAction.REJECT }) + assertTrue(WhitelistAction.entries.any { it == WhitelistAction.REMOVE }) + } + + // ====== shouldRetryOnFailure ====== + + @Test + fun `list request should retry on failure`() { + val req = WhitelistListRequest() + assertTrue(req.shouldRetryOnFailure()) + } + + @Test + fun `action requests should not retry on failure`() { + val req = WhitelistActionRequest(1, WhitelistAction.REJECT) + assertFalse(req.shouldRetryOnFailure()) + } +} diff --git a/src/test/resources/sql/test/query_test.sql b/src/test/resources/sql/test/query_test.sql new file mode 100644 index 0000000..fb57a4b --- /dev/null +++ b/src/test/resources/sql/test/query_test.sql @@ -0,0 +1,3 @@ +SELECT id, name, status +FROM test.players +WHERE id IN (${placeholders}) diff --git a/src/test/resources/sql/test/upsert_test.sql b/src/test/resources/sql/test/upsert_test.sql new file mode 100644 index 0000000..261d590 --- /dev/null +++ b/src/test/resources/sql/test/upsert_test.sql @@ -0,0 +1,3 @@ +INSERT INTO test.records (id, value) +VALUES ${values} +ON DUPLICATE KEY UPDATE value = VALUES(value)