feat: 完善白名单审计系统&单元测试
This commit is contained in:
parent
e8fbd30c5e
commit
23ca0454ff
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>? {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user