diff --git a/gradle.properties b/gradle.properties index e1e5499..8edd2e8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,5 +3,5 @@ org.gradle.downloadSources=false org.gradle.parallel=true org.gradle.degree_of_parallelism=16 project_group=top.r3944realms.ltdmanager -project_version=1.21-SNAPSHOT +project_version=1.22-SNAPSHOT dg_lab_version=4.4.14.19 diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt index ba39571..c1254af 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt @@ -258,10 +258,11 @@ 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 auditAllowedUsers = config.list("audit-allowed-ids").toSet() val enableEmail = config.getOrDefault("enable-email", false) val reActivationKeywords = config.stringList("re-activation-keywords").toSet() + val auditCommandPrefix = config.getOrDefault("audit-command-prefix", "审计") val gracePeriodDays = config.int("grace-period-days") val expiryWarningDays = config.int("expiry-warning-days") val pollIntervalMinutes = config.getOrDefault("poll-interval-minutes", 60L) @@ -269,17 +270,29 @@ object ModuleFactory { val groupMessagePollingModule = resolveDependency( config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling" ) as GroupMessagePollingModule + // 第二依赖:白名单群轮询模块(通过依赖名区分) + val whitelistGroupPollingName = config.getOrDefault("whitelist-group-polling-dep-name", "") + val whitelistGroupPollingModule = if (whitelistGroupPollingName.isNotEmpty()) { + resolveDependency( + config.dependencies?.find { it.name == whitelistGroupPollingName && it.type == GROUP_MESSAGE_POLLING_MODULE }, + "whitelistGroupPolling" + ) as GroupMessagePollingModule + } else { + groupMessagePollingModule + } val mailModule = if (enableEmail) { resolveDependency(config.findDependency(MAIL_MODULE), "mailModule") as MailModule } else null return WhitelistAuditModule( config.name, - whitelistGroupId, groupMessagePollingModule, + whitelistGroupPollingModule, GlobalManager.whitelistSystemClient, mailModule, filterQqList, + auditAllowedUsers, reActivationKeywords, + auditCommandPrefix, gracePeriodDays, expiryWarningDays, pollIntervalMinutes, diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/WhitelistAuditModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/WhitelistAuditModule.kt index faf7f23..c8701a5 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/WhitelistAuditModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/WhitelistAuditModule.kt @@ -22,18 +22,22 @@ import java.io.File class WhitelistAuditModule( moduleName: String, - private val whitelistGroupId: Long, private val mainGroupPollingModule: GroupMessagePollingModule, + private val whitelistGroupPollingModule: GroupMessagePollingModule, private val whitelistClient: WhitelistSystemClient, private val mailModule: MailModule?, private val filterQqList: Set, + private val auditAllowedUsers: Set, private val reActivationKeywords: Set, + private val auditCommandPrefix: String, private val gracePeriodDays: Int, private val expiryWarningDays: Int, private val pollIntervalMinutes: Long, private val selfId: Long, ) : BaseModule(Modules.WHITELIST_AUDIT, moduleName), PersistentState { + private val whitelistGroupId: Long get() = whitelistGroupPollingModule.targetGroupId + private var scope: CoroutineScope? = null private val stateFile: File = getStateFileInternal("whitelist_audit_state.json", name) @@ -49,6 +53,9 @@ class WhitelistAuditModule( private var lastMsgRealId: Long = -1 private var lastMsgTime: Long = 0 + private var lastAuditRealId: Long = -1 + private var lastAuditTime: Long = 0 + private val reactivationFilter by lazy { TriggerMessageFilter( listOf( @@ -59,9 +66,20 @@ class WhitelistAuditModule( ) } + private val auditCommandFilter by lazy { + TriggerMessageFilter( + listOf( + IgnoreSelfFilter(selfId), + NewMessageFilter { lastAuditTime to lastAuditRealId }, + KeywordFilter(setOf(auditCommandPrefix)), + ) + ) + } + override fun onLoad() { LoggerUtil.logger.info("[$name] 白名单审计模块已装载") LoggerUtil.logger.info("[$name] 白名单群: $whitelistGroupId, 主群: ${mainGroupPollingModule.targetGroupId}") + LoggerUtil.logger.info("[$name] 审计命令: $auditCommandPrefix") LoggerUtil.logger.info("[$name] 检查间隔: ${pollIntervalMinutes}分, 宽限期: ${gracePeriodDays}天, 过期警告: ${expiryWarningDays}天前") LoggerUtil.logger.info("[$name] 邮件通知: ${mailModule != null}") @@ -87,6 +105,15 @@ class WhitelistAuditModule( } } } + + scope!!.launch { + whitelistGroupPollingModule.messagesFlow.collect { messages -> + if (loaded) { + val filtered = auditCommandFilter.filter(messages) + if (filtered.isNotEmpty()) handleAuditCommand(filtered) + } + } + } } override suspend fun onUnload() { @@ -271,6 +298,118 @@ class WhitelistAuditModule( } } + // ======== 手动审计命令 ======== + + private suspend fun handleAuditCommand(messages: List) { + val msg = messages.maxByOrNull { it.time } ?: return + + if (msg.userId !in auditAllowedUsers) { + napCatClient.sendUnit( + SendGroupMsgRequest( + MessageElement.reply(ID.long(msg.realId), "你没有权限执行审计命令"), + ID.long(whitelistGroupId) + ) + ) + return + } + + lastAuditRealId = msg.realId + lastAuditTime = msg.time + + LoggerUtil.logger.info("[$name] ${msg.userId} 触发手动审计") + val summary = runAdhocAudit() + + napCatClient.sendUnit( + SendGroupMsgRequest( + MessageElement.reply(ID.long(msg.realId), summary), + ID.long(whitelistGroupId) + ) + ) + } + + private suspend fun runAdhocAudit(): String { + val groupMembers = fetchWhitelistGroupMembers() + if (groupMembers == null) return "获取群成员失败,审计中断" + + val groupMemberQqSet = groupMembers.map { it.userId }.toSet() + val whitelistEntries = whitelistClient.listApproved() + if (whitelistEntries.isEmpty()) return "白名单通过数为0" + + val now = System.currentTimeMillis() + val graceMs = gracePeriodDays * 24 * 60 * 60 * 1000L + + var inGroupOk = 0 + var notInGroupNew = 0 + var notInGroupGrace = mutableListOf() + var notInGroupExpired = 0 + var inFilter = 0 + + for (entry in whitelistEntries) { + val qqLong = entry.qq.toLongOrNull() ?: continue + if (qqLong in filterQqList) { inFilter++; continue } + if (qqLong in groupMemberQqSet) { + inGroupOk++ + // 清除已有标记 + val key = entry.qq + if (auditState.entries.containsKey(key)) { + auditState = auditState.copy( + entries = auditState.entries.toMutableMap().apply { remove(key) } + ) + } + continue + } + + val key = entry.qq + val existing = auditState.entries[key] + + when { + existing == null -> { + notInGroupNew++ + whitelistClient.reject(entry.id) + val newEntry = AuditEntry( + qq = entry.qq, + playerId = entry.id, + playerName = entry.playerName, + detectedTime = now, + rejectedTime = now + ) + auditState = auditState.copy( + entries = auditState.entries.toMutableMap().apply { put(key, newEntry) } + ) + } + existing.rejectedTime > 0 && now - existing.rejectedTime >= graceMs -> { + notInGroupExpired++ + whitelistClient.remove(entry.id) + auditState = auditState.copy( + entries = auditState.entries.toMutableMap().apply { remove(key) } + ) + } + existing.rejectedTime > 0 -> { + val remainDays = ((graceMs - (now - existing.rejectedTime)) / (24 * 60 * 60 * 1000)).toInt() + notInGroupGrace.add("${entry.playerName}(剩${remainDays}天)") + } + } + } + saveState(auditState) + + return buildString { + appendLine("审计完成") + appendLine("─".repeat(16)) + appendLine("白名单总数: ${whitelistEntries.size}") + appendLine("在群正常: $inGroupOk 人") + appendLine("新发现不在群(已拒绝): $notInGroupNew 人") + if (notInGroupGrace.isNotEmpty()) { + appendLine("宽限期中: ${notInGroupGrace.joinToString()}") + } + if (notInGroupExpired > 0) { + appendLine("已过期删除: $notInGroupExpired 人") + } + if (inFilter > 0) { + appendLine("过滤列表跳过: $inFilter 人") + } + } + } + // ======== 群成员获取 ======== private suspend fun fetchWhitelistGroupMembers(): List? { diff --git a/src/test/kotlin/top/r394realms/ltdmanagertest/whitelist/WhitelistApiResponseTest.kt b/src/test/kotlin/top/r394realms/ltdmanagertest/whitelist/WhitelistApiResponseTest.kt new file mode 100644 index 0000000..b2f7d7d --- /dev/null +++ b/src/test/kotlin/top/r394realms/ltdmanagertest/whitelist/WhitelistApiResponseTest.kt @@ -0,0 +1,215 @@ +package top.r394realms.ltdmanagertest.whitelist + +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import top.r3944realms.ltdmanager.whitelist.data.WhitelistPlayerInfo +import top.r3944realms.ltdmanager.whitelist.response.WhitelistApiResponse + +class WhitelistApiResponseTest { + + private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + + // ====== WhitelistApiResponse ====== + + @Test + fun `deserialize list response with data`() { + val response = json.decodeFromString(listResponseJson) + assertEquals(200, response.code) + assertEquals("查询成功", response.msg) + assertEquals(42, response.count) + assertEquals(2, response.data!!.size) + } + + @Test + fun `list response player info fields`() { + val response = json.decodeFromString(listResponseJson) + val player = response.data!![0] + + assertEquals(1, player.id) + assertEquals("Steve", player.playerName) + assertEquals("8667ba71b85a4004af54457a9734eed7", player.uuid) + assertEquals("123456789", player.qq) + assertEquals(1, player.status) + assertEquals("北京市", player.regionFullName) + assertEquals(85, player.totalScore) + assertTrue(player.emailActive) + } + + @Test + fun `deserialize action response (approve)`() { + val response = json.decodeFromString(approveResponseJson) + assertEquals(200, response.code) + assertEquals("已加入白名单", response.msg) + assertNull(response.data) + assertEquals(0, response.count) + } + + @Test + fun `deserialize action response (reject)`() { + val response = json.decodeFromString(rejectResponseJson) + assertEquals(200, response.code) + assertEquals("已拒绝申请", response.msg) + } + + @Test + fun `deserialize action response (remove)`() { + val response = json.decodeFromString(removeResponseJson) + assertEquals(200, response.code) + assertEquals("已从白名单移除", response.msg) + } + + @Test + fun `deserialize error response`() { + val response = json.decodeFromString(errorResponseJson) + assertEquals(500, response.code) + assertEquals("操作失败,Rcon连接错误或玩家不存在", response.msg) + } + + @Test + fun `deserialize auth failure response`() { + val response = json.decodeFromString(authFailJson) + assertEquals(403, response.code) + assertEquals("无权限:API Key 无效或未提供", response.msg) + } + + // ====== WhitelistPlayerInfo with all fields ====== + + @Test + fun `deserialize full player info`() { + val player = json.decodeFromString(fullPlayerJson) + assertEquals(1, player.id) + assertEquals("Steve", player.playerName) + assertEquals("8667ba71b85a4004af54457a9734eed7", player.uuid) + assertEquals("123456789", player.qq) + assertEquals(1, player.status) + assertEquals("我是建筑党", player.description) + assertEquals("2026-06-01T12:00:00", player.createTime) + assertEquals(110000L, player.regionCode) + assertEquals("北京市", player.regionFullName) + assertEquals(1, player.operatorId) + assertEquals("admin", player.operatorUsername) + assertEquals("管理员", player.operatorNickname) + assertEquals(85, player.totalScore) + assertTrue(player.emailActive) + } + + @Test + fun `deserialize player with null operator fields`() { + val player = json.decodeFromString(playerWithNullOperatorJson) + assertEquals(2, player.id) + assertEquals("Alex", player.playerName) + assertEquals("2222222", player.qq) + assertEquals(2, player.status) + assertNull(player.operatorId) + assertNull(player.operatorUsername) + assertNull(player.operatorNickname) + } + + // ====== Empty/missing data ====== + + @Test + fun `deserialize list response with empty data array`() { + val response = this.json.decodeFromString( + """{"code":200,"msg":"查询成功","count":0,"data":[]}""" + ) + assertEquals(0, response.count) + assertTrue(response.data!!.isEmpty()) + } + + @Test + fun `deserialize response with null data`() { + val response = json.decodeFromString(approveResponseJson) + assertNull(response.data) + } + + // ====== Sample JSON payloads ====== + + companion object { + val listResponseJson = """ + { + "code": 200, + "msg": "查询成功", + "count": 42, + "data": [ + { + "id": 1, + "playerName": "Steve", + "uuid": "8667ba71b85a4004af54457a9734eed7", + "qq": "123456789", + "status": 1, + "description": "我是建筑党", + "createTime": "2026-06-01T12:00:00", + "regionCode": 110000, + "regionFullName": "北京市", + "operatorId": 1, + "operatorUsername": "admin", + "operatorNickname": "管理员", + "totalScore": 85, + "emailActive": true + }, + { + "id": 2, + "playerName": "Alex", + "uuid": "abc123", + "qq": "987654321", + "status": 1, + "description": "", + "createTime": "2026-06-02T08:00:00", + "regionCode": 310000, + "regionFullName": "上海市", + "operatorId": null, + "operatorUsername": null, + "operatorNickname": null, + "totalScore": 70, + "emailActive": false + } + ] + } + """.trimIndent() + + val approveResponseJson = """{"code":200,"msg":"已加入白名单","data":null}""" + val rejectResponseJson = """{"code":200,"msg":"已拒绝申请","data":null}""" + val removeResponseJson = """{"code":200,"msg":"已从白名单移除","data":null}""" + val errorResponseJson = """{"code":500,"msg":"操作失败,Rcon连接错误或玩家不存在","data":null}""" + val authFailJson = """{"code":403,"msg":"无权限:API Key 无效或未提供","data":null}""" + + val fullPlayerJson = """ + { + "id": 1, + "playerName": "Steve", + "uuid": "8667ba71b85a4004af54457a9734eed7", + "qq": "123456789", + "status": 1, + "description": "我是建筑党", + "createTime": "2026-06-01T12:00:00", + "regionCode": 110000, + "regionFullName": "北京市", + "operatorId": 1, + "operatorUsername": "admin", + "operatorNickname": "管理员", + "totalScore": 85, + "emailActive": true + } + """.trimIndent() + + val playerWithNullOperatorJson = """ + { + "id": 2, + "playerName": "Alex", + "uuid": "def456", + "qq": "2222222", + "status": 2, + "description": "", + "createTime": "2026-06-03T00:00:00", + "regionCode": 0, + "regionFullName": "", + "operatorId": null, + "operatorUsername": null, + "operatorNickname": null, + "totalScore": 0, + "emailActive": false + } + """.trimIndent() + } +} diff --git a/src/test/kotlin/top/r394realms/ltdmanagertest/whitelist/WhitelistAuditStateTest.kt b/src/test/kotlin/top/r394realms/ltdmanagertest/whitelist/WhitelistAuditStateTest.kt new file mode 100644 index 0000000..7666407 --- /dev/null +++ b/src/test/kotlin/top/r394realms/ltdmanagertest/whitelist/WhitelistAuditStateTest.kt @@ -0,0 +1,142 @@ +package top.r394realms.ltdmanagertest.whitelist + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import top.r3944realms.ltdmanager.module.WhitelistAuditModule.AuditEntry +import top.r3944realms.ltdmanager.module.WhitelistAuditModule.AuditState + +class WhitelistAuditStateTest { + + private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + + // ====== AuditState ====== + + @Test + fun `empty AuditState round-trips correctly`() { + val state = AuditState() + val serialized = json.encodeToString(state) + val deserialized = json.decodeFromString(serialized) + + assertTrue(deserialized.entries.isEmpty()) + } + + @Test + fun `AuditState round-trip with single entry`() { + val entry = AuditEntry( + qq = "123456789", + playerId = 42, + playerName = "Steve", + detectedTime = 1717891200000L, + rejectedTime = 1717891200000L, + warningSentTime = 0L, + reactivated = false, + ) + val state = AuditState(entries = mapOf("123456789" to entry)) + + val serialized = json.encodeToString(state) + val deserialized = json.decodeFromString(serialized) + + assertEquals(1, deserialized.entries.size) + val restored = deserialized.entries["123456789"]!! + assertEquals("123456789", restored.qq) + assertEquals(42, restored.playerId) + assertEquals("Steve", restored.playerName) + assertEquals(1717891200000L, restored.detectedTime) + assertEquals(1717891200000L, restored.rejectedTime) + assertEquals(0L, restored.warningSentTime) + assertFalse(restored.reactivated) + } + + @Test + fun `AuditState round-trip with reactivated entry`() { + val entry = AuditEntry( + qq = "111", + playerId = 1, + playerName = "Alex", + detectedTime = 1000L, + rejectedTime = 1000L, + warningSentTime = 5000L, + reactivated = true, + ) + val state = AuditState(entries = mapOf("111" to entry)) + + val serialized = json.encodeToString(state) + val deserialized = json.decodeFromString(serialized) + + assertTrue(deserialized.entries["111"]!!.reactivated) + assertEquals(5000L, deserialized.entries["111"]!!.warningSentTime) + } + + @Test + fun `AuditState round-trip with multiple entries`() { + val entries = mapOf( + "aaa" to AuditEntry(qq = "aaa", playerId = 1, playerName = "A", detectedTime = 1L), + "bbb" to AuditEntry(qq = "bbb", playerId = 2, playerName = "B", detectedTime = 2L, rejectedTime = 2L), + "ccc" to AuditEntry(qq = "ccc", playerId = 3, playerName = "C", detectedTime = 3L, rejectedTime = 3L, warningSentTime = 3L, reactivated = true), + ) + val state = AuditState(entries = entries) + + val serialized = json.encodeToString(state) + val deserialized = json.decodeFromString(serialized) + + assertEquals(3, deserialized.entries.size) + assertFalse(deserialized.entries["aaa"]!!.reactivated) + assertEquals(0L, deserialized.entries["aaa"]!!.rejectedTime) + assertTrue(deserialized.entries["ccc"]!!.reactivated) + } + + // ====== AuditEntry defaults ====== + + @Test + fun `AuditEntry defaults are zero-empty-false`() { + val entry = AuditEntry( + qq = "test", + playerId = 1, + playerName = "test", + detectedTime = 0L, + ) + assertEquals(0L, entry.rejectedTime) + assertEquals(0L, entry.warningSentTime) + assertFalse(entry.reactivated) + } + + @Test + fun `AuditEntry copy preserves fields`() { + val entry = AuditEntry( + qq = "qq", playerId = 5, playerName = "P", + detectedTime = 100L, rejectedTime = 200L, warningSentTime = 300L, reactivated = true, + ) + val copied = entry.copy(warningSentTime = 400L, reactivated = false) + + assertEquals("qq", copied.qq) + assertEquals(5, copied.playerId) + assertEquals(100L, copied.detectedTime) + assertEquals(200L, copied.rejectedTime) + assertEquals(400L, copied.warningSentTime) + assertFalse(copied.reactivated) + } + + // ====== Deserialization with missing fields ====== + + @Test + fun `deserialize AuditState with missing optional fields uses defaults`() { + val jsonStr = """ + {"entries": {"test": {"qq":"test","playerId":1,"playerName":"T","detectedTime":0}}} + """.trimIndent() + + val state = json.decodeFromString(jsonStr) + val entry = state.entries["test"]!! + + assertEquals(0L, entry.rejectedTime) + assertEquals(0L, entry.warningSentTime) + assertFalse(entry.reactivated) + } + + @Test + fun `deserialize empty JSON object as AuditState`() { + val state = json.decodeFromString("{}") + assertTrue(state.entries.isEmpty()) + } +}