feat: 完善白名单审计系统&单元测试

This commit is contained in:
叁玖领域 2026-06-09 13:25:19 +08:00
parent e8fbd30c5e
commit 23ca0454ff
5 changed files with 513 additions and 4 deletions

View File

@ -3,5 +3,5 @@ org.gradle.downloadSources=false
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.degree_of_parallelism=16 org.gradle.degree_of_parallelism=16
project_group=top.r3944realms.ltdmanager project_group=top.r3944realms.ltdmanager
project_version=1.21-SNAPSHOT project_version=1.22-SNAPSHOT
dg_lab_version=4.4.14.19 dg_lab_version=4.4.14.19

View File

@ -258,10 +258,11 @@ object ModuleFactory {
} }
private fun createWhitelistAudit(config: ModuleConfig.Module): WhitelistAuditModule { private fun createWhitelistAudit(config: ModuleConfig.Module): WhitelistAuditModule {
val whitelistGroupId = config.long("whitelist-group-id")
val filterQqList = config.list<Long>("filter-qq-list").toSet() val filterQqList = config.list<Long>("filter-qq-list").toSet()
val auditAllowedUsers = config.list<Long>("audit-allowed-ids").toSet()
val enableEmail = config.getOrDefault("enable-email", false) val enableEmail = config.getOrDefault("enable-email", false)
val reActivationKeywords = config.stringList("re-activation-keywords").toSet() val reActivationKeywords = config.stringList("re-activation-keywords").toSet()
val auditCommandPrefix = config.getOrDefault("audit-command-prefix", "审计")
val gracePeriodDays = config.int("grace-period-days") val gracePeriodDays = config.int("grace-period-days")
val expiryWarningDays = config.int("expiry-warning-days") val expiryWarningDays = config.int("expiry-warning-days")
val pollIntervalMinutes = config.getOrDefault("poll-interval-minutes", 60L) val pollIntervalMinutes = config.getOrDefault("poll-interval-minutes", 60L)
@ -269,17 +270,29 @@ object ModuleFactory {
val groupMessagePollingModule = resolveDependency( val groupMessagePollingModule = resolveDependency(
config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling" config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling"
) as GroupMessagePollingModule ) 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) { val mailModule = if (enableEmail) {
resolveDependency(config.findDependency(MAIL_MODULE), "mailModule") as MailModule resolveDependency(config.findDependency(MAIL_MODULE), "mailModule") as MailModule
} else null } else null
return WhitelistAuditModule( return WhitelistAuditModule(
config.name, config.name,
whitelistGroupId,
groupMessagePollingModule, groupMessagePollingModule,
whitelistGroupPollingModule,
GlobalManager.whitelistSystemClient, GlobalManager.whitelistSystemClient,
mailModule, mailModule,
filterQqList, filterQqList,
auditAllowedUsers,
reActivationKeywords, reActivationKeywords,
auditCommandPrefix,
gracePeriodDays, gracePeriodDays,
expiryWarningDays, expiryWarningDays,
pollIntervalMinutes, pollIntervalMinutes,

View File

@ -22,18 +22,22 @@ import java.io.File
class WhitelistAuditModule( class WhitelistAuditModule(
moduleName: String, moduleName: String,
private val whitelistGroupId: Long,
private val mainGroupPollingModule: GroupMessagePollingModule, private val mainGroupPollingModule: GroupMessagePollingModule,
private val whitelistGroupPollingModule: GroupMessagePollingModule,
private val whitelistClient: WhitelistSystemClient, private val whitelistClient: WhitelistSystemClient,
private val mailModule: MailModule?, private val mailModule: MailModule?,
private val filterQqList: Set<Long>, private val filterQqList: Set<Long>,
private val auditAllowedUsers: Set<Long>,
private val reActivationKeywords: Set<String>, private val reActivationKeywords: Set<String>,
private val auditCommandPrefix: String,
private val gracePeriodDays: Int, private val gracePeriodDays: Int,
private val expiryWarningDays: Int, private val expiryWarningDays: Int,
private val pollIntervalMinutes: Long, private val pollIntervalMinutes: Long,
private val selfId: Long, private val selfId: Long,
) : BaseModule(Modules.WHITELIST_AUDIT, moduleName), PersistentState<WhitelistAuditModule.AuditState> { ) : BaseModule(Modules.WHITELIST_AUDIT, moduleName), PersistentState<WhitelistAuditModule.AuditState> {
private val whitelistGroupId: Long get() = whitelistGroupPollingModule.targetGroupId
private var scope: CoroutineScope? = null private var scope: CoroutineScope? = null
private val stateFile: File = getStateFileInternal("whitelist_audit_state.json", name) private val stateFile: File = getStateFileInternal("whitelist_audit_state.json", name)
@ -49,6 +53,9 @@ class WhitelistAuditModule(
private var lastMsgRealId: Long = -1 private var lastMsgRealId: Long = -1
private var lastMsgTime: Long = 0 private var lastMsgTime: Long = 0
private var lastAuditRealId: Long = -1
private var lastAuditTime: Long = 0
private val reactivationFilter by lazy { private val reactivationFilter by lazy {
TriggerMessageFilter( TriggerMessageFilter(
listOf( 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() { override fun onLoad() {
LoggerUtil.logger.info("[$name] 白名单审计模块已装载") LoggerUtil.logger.info("[$name] 白名单审计模块已装载")
LoggerUtil.logger.info("[$name] 白名单群: $whitelistGroupId, 主群: ${mainGroupPollingModule.targetGroupId}") LoggerUtil.logger.info("[$name] 白名单群: $whitelistGroupId, 主群: ${mainGroupPollingModule.targetGroupId}")
LoggerUtil.logger.info("[$name] 审计命令: $auditCommandPrefix")
LoggerUtil.logger.info("[$name] 检查间隔: ${pollIntervalMinutes}分, 宽限期: ${gracePeriodDays}天, 过期警告: ${expiryWarningDays}天前") LoggerUtil.logger.info("[$name] 检查间隔: ${pollIntervalMinutes}分, 宽限期: ${gracePeriodDays}天, 过期警告: ${expiryWarningDays}天前")
LoggerUtil.logger.info("[$name] 邮件通知: ${mailModule != null}") 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() { override suspend fun onUnload() {
@ -271,6 +298,118 @@ class WhitelistAuditModule(
} }
} }
// ======== 手动审计命令 ========
private suspend fun handleAuditCommand(messages: List<MsgHistorySpecificMsg>) {
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<String>()
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<GroupMemberData>? { private suspend fun fetchWhitelistGroupMembers(): List<GroupMemberData>? {

View File

@ -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<WhitelistApiResponse>(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<WhitelistApiResponse>(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<WhitelistApiResponse>(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<WhitelistApiResponse>(rejectResponseJson)
assertEquals(200, response.code)
assertEquals("已拒绝申请", response.msg)
}
@Test
fun `deserialize action response (remove)`() {
val response = json.decodeFromString<WhitelistApiResponse>(removeResponseJson)
assertEquals(200, response.code)
assertEquals("已从白名单移除", response.msg)
}
@Test
fun `deserialize error response`() {
val response = json.decodeFromString<WhitelistApiResponse>(errorResponseJson)
assertEquals(500, response.code)
assertEquals("操作失败Rcon连接错误或玩家不存在", response.msg)
}
@Test
fun `deserialize auth failure response`() {
val response = json.decodeFromString<WhitelistApiResponse>(authFailJson)
assertEquals(403, response.code)
assertEquals("无权限API Key 无效或未提供", response.msg)
}
// ====== WhitelistPlayerInfo with all fields ======
@Test
fun `deserialize full player info`() {
val player = json.decodeFromString<WhitelistPlayerInfo>(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<WhitelistPlayerInfo>(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<WhitelistApiResponse>(
"""{"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<WhitelistApiResponse>(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()
}
}

View File

@ -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<AuditState>(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<AuditState>(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<AuditState>(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<AuditState>(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<AuditState>(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<AuditState>("{}")
assertTrue(state.entries.isEmpty())
}
}