feat: 添加白名单审计系统&单元测试
This commit is contained in:
parent
79caa2b56e
commit
e8fbd30c5e
|
|
@ -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) ->
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
// 基础获取方法
|
// 基础获取方法
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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=***)"
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/test/resources/sql/test/query_test.sql
Normal file
3
src/test/resources/sql/test/query_test.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
SELECT id, name, status
|
||||||
|
FROM test.players
|
||||||
|
WHERE id IN (${placeholders})
|
||||||
3
src/test/resources/sql/test/upsert_test.sql
Normal file
3
src/test/resources/sql/test/upsert_test.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
INSERT INTO test.records (id, value)
|
||||||
|
VALUES ${values}
|
||||||
|
ON DUPLICATE KEY UPDATE value = VALUES(value)
|
||||||
Loading…
Reference in New Issue
Block a user