feat: 添加白名单审计系统&单元测试

This commit is contained in:
叁玖领域 2026-06-09 13:09:28 +08:00
parent 79caa2b56e
commit e8fbd30c5e
22 changed files with 1251 additions and 16 deletions

View File

@ -9,6 +9,7 @@ import top.r3944realms.ltdmanager.mcserver.McSrvStatusClient
import top.r3944realms.ltdmanager.module.ModuleManager import top.r3944realms.ltdmanager.module.ModuleManager
import top.r3944realms.ltdmanager.napcat.NapCatClient import top.r3944realms.ltdmanager.napcat.NapCatClient
import top.r3944realms.ltdmanager.utils.LoggerUtil import top.r3944realms.ltdmanager.utils.LoggerUtil
import top.r3944realms.ltdmanager.whitelist.WhitelistSystemClient
import java.sql.Connection import java.sql.Connection
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -41,6 +42,9 @@ object GlobalManager {
val mcsmClient: MCSMClient by lazy { val mcsmClient: MCSMClient by lazy {
MCSMClient.create() MCSMClient.create()
} }
val whitelistSystemClient: WhitelistSystemClient by lazy {
WhitelistSystemClient.create()
}
val moduleManager: ModuleManager by lazy { ModuleManager() } val moduleManager: ModuleManager by lazy { ModuleManager() }
@ -82,6 +86,7 @@ object GlobalManager {
"Hikari 数据源" to { dataSource.close() }, "Hikari 数据源" to { dataSource.close() },
"CheveretoClient" to { cheveretoClient.close() }, "CheveretoClient" to { cheveretoClient.close() },
"McsmClient" to { mcsmClient.close() }, "McsmClient" to { mcsmClient.close() },
"WhitelistSystemClient" to { whitelistSystemClient.close() },
) )
resources.forEach { (name, closer) -> resources.forEach { (name, closer) ->

View File

@ -126,6 +126,7 @@ data class ModuleConfig(
HELP_MODULE(Modules.HELP), HELP_MODULE(Modules.HELP),
GITEA_WEBHOOK_MODULE(Modules.GITEA_WEBHOOK), GITEA_WEBHOOK_MODULE(Modules.GITEA_WEBHOOK),
RCON_COMMAND_MODULE(Modules.RCON_COMMAND), RCON_COMMAND_MODULE(Modules.RCON_COMMAND),
WHITELIST_AUDIT_MODULE(Modules.WHITELIST_AUDIT),
UNKNOWN_MODULE("UnknownModule"); UNKNOWN_MODULE("UnknownModule");
} }
// 基础获取方法 // 基础获取方法

View File

@ -10,6 +10,7 @@ data class ToolConfig(
rcon.encryptPassword() rcon.encryptPassword()
} }
data class RconConfig( data class RconConfig(
var enableBasePath: Boolean? = false,
var mcRconToolPath: String? = null, var mcRconToolPath: String? = null,
var mcRconToolConfigPath: String? = null, var mcRconToolConfigPath: String? = null,
var serverUrl: String? = null, var serverUrl: String? = null,

View File

@ -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=***)"
}

View File

@ -37,10 +37,11 @@ object YamlConfigLoader {
config?.http?.encryptToken() config?.http?.encryptToken()
config?.mcsm?.encryptApi() config?.mcsm?.encryptApi()
config?.mail?.encryptPassword() config?.mail?.encryptPassword()
config?.tools?.rcon?.encryptPassword() config?.tools?.encryptPassword()
config?.blessingSkinServer?.invitationApi?.encryptToken() config?.blessingSkinServer?.invitationApi?.encryptToken()
config?.dgLab?.wsServer?.encryptPassword() config?.dgLab?.wsServer?.encryptPassword()
config?.imgTu?.encryptPassword() config?.imgTu?.encryptPassword()
config?.whitelistSystem?.encryptToken()
} }
private fun loadAppConfigWrapper(): AppConfigWrapper { private fun loadAppConfigWrapper(): AppConfigWrapper {
if (!Files.exists(appConfigFilePath)) { if (!Files.exists(appConfigFilePath)) {
@ -102,6 +103,7 @@ object YamlConfigLoader {
fun loadBlessingSkinServerConfig(): BlessingSkinServerConfig = appConfig.blessingSkinServer fun loadBlessingSkinServerConfig(): BlessingSkinServerConfig = appConfig.blessingSkinServer
fun loadDgLabConfig(): DgLabConfig = appConfig.dgLab fun loadDgLabConfig(): DgLabConfig = appConfig.dgLab
fun loadTuImgConfig(): ImgTuConfig = appConfig.imgTu fun loadTuImgConfig(): ImgTuConfig = appConfig.imgTu
fun loadWhitelistSystemConfig(): WhitelistSystemConfig = appConfig.whitelistSystem
fun loadModuleConfig(): ModuleConfig = moduleConfig.module fun loadModuleConfig(): ModuleConfig = moduleConfig.module
data class AppConfigWrapper( data class AppConfigWrapper(
var database: DatabaseConfig = DatabaseConfig(), var database: DatabaseConfig = DatabaseConfig(),
@ -115,6 +117,7 @@ object YamlConfigLoader {
var blessingSkinServer: BlessingSkinServerConfig = BlessingSkinServerConfig(), var blessingSkinServer: BlessingSkinServerConfig = BlessingSkinServerConfig(),
var dgLab: DgLabConfig = DgLabConfig(), var dgLab: DgLabConfig = DgLabConfig(),
var imgTu: ImgTuConfig = ImgTuConfig(), var imgTu: ImgTuConfig = ImgTuConfig(),
var whitelistSystem: WhitelistSystemConfig = WhitelistSystemConfig(),
) )
data class ModuleConfigWrapper( data class ModuleConfigWrapper(

View File

@ -8,6 +8,7 @@ import top.r3944realms.ltdmanager.module.exception.ConfigError
import top.r3944realms.ltdmanager.module.gitea.GiteaEventType import top.r3944realms.ltdmanager.module.gitea.GiteaEventType
import top.r3944realms.ltdmanager.module.gitea.GiteaWebhookModule import top.r3944realms.ltdmanager.module.gitea.GiteaWebhookModule
import top.r3944realms.ltdmanager.module.RconCommandModule import top.r3944realms.ltdmanager.module.RconCommandModule
import top.r3944realms.ltdmanager.module.WhitelistAuditModule
object ModuleFactory { object ModuleFactory {
fun createModule(config: ModuleConfig.Module): BaseModule { fun createModule(config: ModuleConfig.Module): BaseModule {
@ -25,6 +26,7 @@ object ModuleFactory {
HELP_MODULE -> createHelpModule(config) HELP_MODULE -> createHelpModule(config)
GITEA_WEBHOOK_MODULE -> createGiteaWebhook(config) GITEA_WEBHOOK_MODULE -> createGiteaWebhook(config)
RCON_COMMAND_MODULE -> createRconCommand(config) RCON_COMMAND_MODULE -> createRconCommand(config)
WHITELIST_AUDIT_MODULE -> createWhitelistAudit(config)
UNKNOWN_MODULE -> throw ConfigError(ConfigError.Type.INVALID_PARAMETER, "unknown module") 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 groupMessagePollingModule = resolveDependency(config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling") as GroupMessagePollingModule
val selfNickName = config.string("self-nick-name") val selfNickName = config.string("self-nick-name")
val keywords = config.stringList("keywords") 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( return RconPlayerListModule(
config.name, config.name,
groupMessagePollingModule, groupMessagePollingModule,
@ -151,8 +160,8 @@ object ModuleFactory {
cooldownMillis, cooldownMillis,
selfId, selfId,
selfNickName, selfNickName,
toolConfig.rcon.mcRconToolPath.toString(), rconToolPath,
toolConfig.rcon.mcRconToolConfigPath.toString(), rconConfigPath,
keywords.toSet() keywords.toSet()
) )
} }
@ -224,14 +233,21 @@ object ModuleFactory {
val commandBlocklist = config.stringList("command-blocklist").toSet() val commandBlocklist = config.stringList("command-blocklist").toSet()
val commandPrefix = config.string("command-prefix") val commandPrefix = config.string("command-prefix")
val rconTimeoutSec = config.getOrDefault("rcon-timeout-sec", 5L) 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( val groupMessagePollingModule = resolveDependency(
config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling" config.findDependency(GROUP_MESSAGE_POLLING_MODULE), "groupMessagePolling"
) as GroupMessagePollingModule ) as GroupMessagePollingModule
return RconCommandModule( return RconCommandModule(
config.name, config.name,
groupMessagePollingModule, groupMessagePollingModule,
toolConfig.rcon.mcRconToolPath.toString(), rconToolPath,
toolConfig.rcon.mcRconToolConfigPath.toString(), rconConfigPath,
rconTimeoutSec, rconTimeoutSec,
selfId, selfId,
selfNickName, 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<Long>("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
)
}
} }

View File

@ -18,6 +18,7 @@ object Modules {
val STATE: String = register("StateModule") val STATE: String = register("StateModule")
val GITEA_WEBHOOK: String = register("GiteaWebhookModule") val GITEA_WEBHOOK: String = register("GiteaWebhookModule")
val RCON_COMMAND: String = register("RconCommandModule") val RCON_COMMAND: String = register("RconCommandModule")
val WHITELIST_AUDIT: String = register("WhitelistAuditModule")
fun register(name: String): String { fun register(name: String): String {
MODULES.add(name) MODULES.add(name)
return name return name

View File

@ -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<Long>,
private val reActivationKeywords: Set<String>,
private val gracePeriodDays: Int,
private val expiryWarningDays: Int,
private val pollIntervalMinutes: Long,
private val selfId: Long,
) : BaseModule(Modules.WHITELIST_AUDIT, moduleName), PersistentState<WhitelistAuditModule.AuditState> {
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<MsgHistorySpecificMsg>) {
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<GroupMemberData>? {
return try {
val event = napCatClient.send<GetGroupMemberListEvent>(
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<String, AuditEntry> = 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()}")
}
}

View File

@ -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<WhitelistSystemRequest, WhitelistSystemQueueItem, WhitelistSystemResponse, FailedWhitelistSystemResponse> {
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<WhitelistSystemQueueItem>(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<WhitelistSystemQueueItem> = 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<ResponseResult<WhitelistSystemResponse, FailedWhitelistSystemResponse>>,
priority: Int,
maxRetries: Int
): WhitelistSystemQueueItem {
val element = WhitelistSystemQueueItem(request, deferredC, priority, maxRetries, false)
requestQueue.add(element)
return element
}
// -- 便捷方法 --
suspend fun listApproved(): List<WhitelistPlayerInfo> {
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()
}
}

View File

@ -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<WhitelistSystemRequest, WhitelistSystemResponse, FailedWhitelistSystemResponse>(
request0, deferred0, retries0, priority0, expectsResponse0
)

View File

@ -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,
)

View File

@ -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<WhitelistApiResponse, FailedWhitelistSystemResponse.Default> {
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
}

View File

@ -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<WhitelistApiResponse, FailedWhitelistSystemResponse.Default> {
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"
}

View File

@ -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<WhitelistSystemResponse, FailedWhitelistSystemResponse>

View File

@ -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()
}

View File

@ -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<WhitelistPlayerInfo>? = null,
) : WhitelistSystemResponse()

View File

@ -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 <reified T : WhitelistSystemResponse> 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())
}
}
}
}
}
}

View File

@ -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<PushPayload>(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<IssuesPayload>(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<PullRequestPayload>(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<CreatePayload>(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<DeletePayload>(deleteJson)
assertEquals("old-branch", payload.ref)
assertEquals("branch", payload.refType)
}
// ====== ReleasePayload ======
@Test
fun `deserialize release payload`() {
val payload = json.decodeFromString<ReleasePayload>(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<RepositoryPayload>(repoJson)
assertEquals("created", payload.action)
assertEquals("user/new-repo", payload.repository.fullName)
}
// ====== ForkPayload ======
@Test
fun `deserialize fork payload`() {
val payload = json.decodeFromString<ForkPayload>(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<GiteaUser>(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<GiteaRepository>(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()
}
}

View File

@ -1,18 +1,108 @@
package top.r394realms.ltdmanagertest.util package top.r394realms.ltdmanagertest.util
import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*
import top.r3944realms.ltdmanager.utils.SqlTemplate import top.r3944realms.ltdmanager.utils.SqlTemplate
import java.io.File
fun main() {
val sqlTemplateTest = SqlTemplateTest()
sqlTemplateTest.main()
}
class SqlTemplateTest { class SqlTemplateTest {
val testSql = SqlTemplate.fromFile("test/test.sql")
private fun test01(): String { companion object {
return testSql.bind("placeholder" to "?") 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()
} }
}
@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)
}
}

View File

@ -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())
}
}

View File

@ -0,0 +1,3 @@
SELECT id, name, status
FROM test.players
WHERE id IN (${placeholders})

View File

@ -0,0 +1,3 @@
INSERT INTO test.records (id, value)
VALUES ${values}
ON DUPLICATE KEY UPDATE value = VALUES(value)