diff --git a/docs/en/modules.md b/docs/en/modules.md index 2c98d59..bdfabf2 100644 --- a/docs/en/modules.md +++ b/docs/en/modules.md @@ -197,7 +197,18 @@ In your Gitea repository → **Settings** → **Webhooks** → **Add Webhook** **Type:** `WHITELIST_AUDIT_MODULE` -Periodically checks the whitelist group membership against the whitelist API. Automatically rejects users who left the group, with a configurable grace period for re-activation via keywords in the main group. Supports admin-triggered manual audits. +Periodically checks the whitelist group membership against the whitelist API. Automatically rejects users who left the group, with a configurable grace period for re-activation via keywords in the main group. Supports admin-triggered manual audits with detailed per-user reporting. + +**Storage:** MySQL `ltd_manager_bot.whitelist_audit` table (auto-created on first load). + +#### Auto vs Manual Audit + +| | Auto Audit | Manual Audit | +|---|---|---| +| Trigger | Every N minutes (poll-interval) | Admin sends keyword in whitelist group | +| Group messages | Silent (none) | Full summary with per-user detail | +| Email | Always sent for every action | Always sent for every action | +| Log prefix | `[自动]` | `[手动审计]` | #### Lifecycle States @@ -207,17 +218,10 @@ In Whitelist Group ──────────────────── └── User left group ──► Detected ──► Rejected (grace period starts) │ ├── Keyword in main group ──► Re-Activated - ├── Warning threshold ──► Email/Group notification sent + ├── Warning threshold ──► Email sent (once) └── Grace period expired ──► Permanently Removed ``` -#### Two-Trigger System - -| Trigger | Group | Who | Action | -|---|---|---|---| -| Keyword (e.g. "重新激活") | Main group | Any pending user | Re-activates whitelist | -| Audit command (e.g. "审计") | Whitelist group | `audit-allowed-ids` only | Runs immediate audit + posts results | - #### Example Config ```yaml @@ -236,15 +240,21 @@ In Whitelist Group ──────────────────── filter-qq-list: [2561098830, 3327379836] audit-allowed-ids: [2561098830] whitelist-group-polling-dep-name: "whitelistGroup" - audit-command-prefix: "审计" + audit-command-prefixes: + - "审计" + - "audit" re-activation-keywords: - "重新激活" - "激活白名单" - - "reactivate" grace-period-days: 7 expiry-warning-days: 2 poll-interval-minutes: 60 enable-email: false + # Email templates (omit = use built-in defaults) + email-reject-template: "" + email-warning-template: "" + email-removed-template: "" + email-reactivated-template: "" ``` #### Config Reference @@ -255,12 +265,16 @@ In Whitelist Group ──────────────────── | `filter-qq-list` | long[] | yes | — | QQ IDs never auto-rejected | | `audit-allowed-ids` | long[] | yes | — | QQ IDs allowed to trigger manual audit | | `whitelist-group-polling-dep-name` | string | yes | — | Dependency name for the whitelist group polling module | -| `audit-command-prefix` | string | no | `"审计"` | Command to trigger manual audit in whitelist group | +| `audit-command-prefixes` | string[] | no | `["审计"]` | Keywords to trigger manual audit (multiple) | | `re-activation-keywords` | string[] | yes | — | Keywords to re-activate from main group | | `grace-period-days` | int | yes | — | Days user has to re-activate after being rejected | | `expiry-warning-days` | int | yes | — | Days before expiry to send warning | | `poll-interval-minutes` | long | no | `60` | Auto-audit interval in minutes | | `enable-email` | boolean | no | `false` | Enable email notifications (requires MailModule) | +| `email-reject-template` | string | no | `""` | Rejection email body. Placeholders: `${playerName}` `${graceDays}` `${keywords}` | +| `email-warning-template` | string | no | `""` | Expiry warning email body. Placeholders: `${playerName}` `${remainDays}` `${keywords}` | +| `email-removed-template` | string | no | `""` | Removal email body. Placeholders: `${playerName}` | +| `email-reactivated-template` | string | no | `""` | Re-activation email body. Placeholders: `${playerName}` `${graceDays}` | #### Manual Audit Output Example @@ -270,14 +284,36 @@ In Whitelist Group ──────────────────── 白名单总数: 42 在群正常: 35 人 新发现不在群(已拒绝): 2 人 -宽限期中: Steve(剩5天), Alex(剩2天) + • Steve(123456789) + • Alex(987654321) +宽限期中: Notch(111222333, 剩5天) 已过期删除: 1 人 + • Hero(444555666) 过滤列表跳过: 3 人 ``` +#### Email Deduplication + +Emails are sent exactly once per event: + +| Email | Guard | +|---|---| +| Rejection | Only on first detection (`existing == null`) | +| Re-rejection | Only when previously-reactivated user leaves again | +| Expiry warning | `warningSentTime == 0L` — sent once | +| Removal | Record deleted from DB after send — never repeats | +| Re-activation | Only on successful keyword-triggered approve | + #### State Persistence -Audit state is persisted to `data/whitelist_audit_state.json` with automatic backup. Survives bot restarts without losing pending reject records. +Audit state is stored in **MySQL** `ltd_manager_bot.whitelist_audit` table (auto-created, zero config). Survives bot restarts. Historical JSON file-based state is no longer used. + +SQL templates are in `config/sql/whitelist/`: +- `create_audit_table.sql` — DDL +- `query_all_audit.sql` — fetch all records +- `query_audit_by_qq.sql` — fetch by QQ +- `upsert_audit.sql` — insert or update +- `delete_audit.sql` — remove record #### Dependencies diff --git a/docs/zh/modules.md b/docs/zh/modules.md index ca18d76..198430b 100644 --- a/docs/zh/modules.md +++ b/docs/zh/modules.md @@ -197,7 +197,18 @@ RCON 二进制路径和配置文件路径从 `application.yaml` → `tools.rcon` **类型:** `WHITELIST_AUDIT_MODULE` -定期检查白名单群成员与白名单 API 的一致性。自动拒绝已离开白名单群的用户,设置宽限期供用户通过主群关键词重新激活。支持管理员手动触发审计。 +定期检查白名单群成员与白名单 API 的一致性。自动拒绝已离开白名单群的用户,设置宽限期供用户通过主群关键词重新激活。支持管理员手动触发审计,输出详细逐人报告。 + +**存储:** MySQL `ltd_manager_bot.whitelist_audit` 表(首次加载自动建表)。 + +#### 自动 vs 手动审计 + +| | 自动审计 | 手动审计 | +|---|---|---| +| 触发 | 每 N 分钟自动轮询 | 管理员在白名单群发关键词 | +| 群消息 | 不发送(安静) | 发送完整统计摘要 + 逐人详情 | +| 邮件 | 所有操作始终发送 | 所有操作始终发送 | +| 日志前缀 | `[自动]` | `[手动审计]` | #### 生命周期状态 @@ -207,17 +218,10 @@ RCON 二进制路径和配置文件路径从 `application.yaml` → `tools.rcon` └── 用户退出群 ──► 检测到离群 ──► 已被拒绝(宽限期开始) │ ├── 主群发关键词 ──► 已重新激活 - ├── 进入警告窗口 ──► 发送邮件/群通知 + ├── 进入警告窗口 ──► 邮件通知(仅一次) └── 宽限期过 ──► 永久删除 ``` -#### 双触发器系统 - -| 触发器 | 群 | 谁可使用 | 操作 | -|---|---|---|---| -| 关键词 (如"重新激活") | 主群 | 任何待处理用户 | 重新激活白名单 | -| 审计命令 (如"审计") | 白名单群 | 仅 `audit-allowed-ids` | 立即执行审计 + 输出结果 | - #### 配置示例 ```yaml @@ -233,23 +237,24 @@ RCON 二进制路径和配置文件路径从 `application.yaml` → `tools.rcon` type: "MAIL_MODULE" config: self-id: 3327379836 - # ---- 权限控制 ---- - filter-qq-list: [2561098830, 3327379836] # 永不自动拒绝的保护列表 - audit-allowed-ids: [2561098830] # 可执行手动审计的管理员QQ - # ---- 白名单群手动审计 ---- + filter-qq-list: [2561098830, 3327379836] + audit-allowed-ids: [2561098830] whitelist-group-polling-dep-name: "whitelistGroup" - audit-command-prefix: "审计" # 白名单群触发关键词 - # ---- 重新激活 ---- - re-activation-keywords: # 主群重新激活关键词 + audit-command-prefixes: # 支持多个关键词 + - "审计" + - "audit" + re-activation-keywords: - "重新激活" - "激活白名单" - - "reactivate" - # ---- 时间参数 ---- - grace-period-days: 7 # 宽限期(天) - expiry-warning-days: 2 # 到期前N天发警告 - poll-interval-minutes: 60 # 自动审计间隔(分钟) - # ---- 邮件通知 ---- - enable-email: false # 是否启用邮件通知 + grace-period-days: 7 + expiry-warning-days: 2 + poll-interval-minutes: 60 + enable-email: false + # 邮件模板 (空=使用内置默认,支持占位符) + email-reject-template: "" + email-warning-template: "" + email-removed-template: "" + email-reactivated-template: "" ``` #### 配置项 @@ -257,15 +262,19 @@ RCON 二进制路径和配置文件路径从 `application.yaml` → `tools.rcon` | 字段 | 类型 | 必填 | 默认值 | 说明 | |---|---|---|---|---| | `self-id` | long | 是 | — | 机器人 QQ 号 | -| `filter-qq-list` | long[] | 是 | — | 保护列表,模块不会自动拒绝这些 QQ 的白名单 | -| `audit-allowed-ids` | long[] | 是 | — | 允许在白名单群执行手动审计的 QQ | +| `filter-qq-list` | long[] | 是 | — | 保护列表,永不自动拒绝 | +| `audit-allowed-ids` | long[] | 是 | — | 允许执行手动审计的 QQ | | `whitelist-group-polling-dep-name` | string | 是 | — | 白名单群轮询模块的依赖名 | -| `audit-command-prefix` | string | 否 | `"审计"` | 白名单群触发手动审计的命令 | +| `audit-command-prefixes` | string[] | 否 | `["审计"]` | 白名单群触发手动审计的关键词(支持多个) | | `re-activation-keywords` | string[] | 是 | — | 主群中触发重新激活的关键词列表 | | `grace-period-days` | int | 是 | — | 被拒绝后允许重新激活的天数 | | `expiry-warning-days` | int | 是 | — | 宽限期到期前多少天发送警告 | -| `poll-interval-minutes` | long | 否 | `60` | 自动审计间隔(分钟) | -| `enable-email` | boolean | 否 | `false` | 是否发送邮件通知(需 MailModule 依赖) | +| `poll-interval-minutes` | long | 否 | `60` | 自动审计间隔(分钟) | +| `enable-email` | boolean | 否 | `false` | 是否启用邮件通知(需 MailModule 依赖) | +| `email-reject-template` | string | 否 | `""` | 拒绝邮件正文。占位符:`${playerName}` `${graceDays}` `${keywords}` | +| `email-warning-template` | string | 否 | `""` | 过期警告邮件正文。占位符:`${playerName}` `${remainDays}` `${keywords}` | +| `email-removed-template` | string | 否 | `""` | 删除邮件正文。占位符:`${playerName}` | +| `email-reactivated-template` | string | 否 | `""` | 重新激活邮件正文。占位符:`${playerName}` `${graceDays}` | #### 手动审计输出示例 @@ -275,14 +284,36 @@ RCON 二进制路径和配置文件路径从 `application.yaml` → `tools.rcon` 白名单总数: 42 在群正常: 35 人 新发现不在群(已拒绝): 2 人 -宽限期中: Steve(剩5天), Alex(剩2天) + • Steve(123456789) + • Alex(987654321) +宽限期中: Notch(111222333, 剩5天) 已过期删除: 1 人 + • Hero(444555666) 过滤列表跳过: 3 人 ``` +#### 邮件去重 + +每类邮件只发送一次: + +| 邮件 | 去重方式 | +|---|---| +| 拒绝 | 仅首次检测到离群时发送(`existing == null`) | +| 重新拒绝 | 仅之前已重新激活的用户再次离群时发送 | +| 即将过期警告 | `warningSentTime == 0L` 守卫,仅发一次 | +| 已删除 | 发送后立即从 DB 删除记录,不再匹配 | +| 重新激活 | 仅在宽限期内关键词触发成功时发送 | + #### 状态持久化 -审计状态保存在 `data/whitelist_audit_state.json`,附自动备份。机器人重启不会丢失待处理记录。 +审计状态存储在 **MySQL** `ltd_manager_bot.whitelist_audit` 表中(自动建表,零配置)。Bot 重启不丢失已标记的待处理记录。原先基于 JSON 文件的状态持久化已移除。 + +SQL 模板位于 `config/sql/whitelist/`: +- `create_audit_table.sql` — 建表 DDL +- `query_all_audit.sql` — 查全表 +- `query_audit_by_qq.sql` — 按 QQ 查询 +- `upsert_audit.sql` — 新增或更新 +- `delete_audit.sql` — 删除记录 #### 依赖关系 @@ -294,7 +325,7 @@ RCON 二进制路径和配置文件路径从 `application.yaml` → `tools.rcon` #### 白名单 API 配置 -API 地址和密钥在 `application.yaml` 的 `whitelist-system:` 段配置(启动后自动加密): +API 地址和密钥在 `application.yaml` 的 `whitelist-system:` 段配置(启动后自动加密): ```yaml whitelist-system: @@ -306,11 +337,11 @@ whitelist-system: | 触发条件 | 邮件主题 | |---|---| -| 首次检测离群 → 被拒绝 | `LTD白名单审计通知` — 告知已被拒,宽限期 N 天 | -| 重新激活后再离群 → 再次拒绝 | `LTD白名单审计通知` — 告知再次被拒 | -| 进入警告窗口 | `LTD白名单即将过期` — 剩余 N 天 | -| 宽限期过 → 永久删除 | `LTD白名单已删除` — 已永久删除 | -| 关键词重新激活成功 | `LTD白名单已重新激活` — 提醒加群 | +| 首次检测离群 → 被拒绝 | `白名单已被拒绝` — 含宽限期和关键词提示 | +| 重新激活后再离群 → 再次拒绝 | `白名单已被拒绝` — 同上 | +| 进入警告窗口 | `白名单即将过期` — 剩余天数 | +| 宽限期过 → 永久删除 | `白名单已删除` | +| 关键词重新激活成功 | `白名单已重新激活` — 提醒加群 | --- diff --git a/gradle.properties b/gradle.properties index de6ed86..b35db9c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,5 +3,5 @@ org.gradle.downloadSources=false org.gradle.parallel=true org.gradle.degree_of_parallelism=16 project_group=top.r3944realms.ltdmanager -project_version=1.22.5 +project_version=1.22.6 dg_lab_version=4.4.14.19 diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt index 5ec0336..9093f57 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/ModuleFactory.kt @@ -271,7 +271,7 @@ object ModuleFactory { val auditAllowedUsers = config.list("audit-allowed-ids").toSet() val enableEmail = config.getOrDefault("enable-email", false) val reActivationKeywords = config.stringList("re-activation-keywords").toSet() - val auditCommandPrefix = config.getOrDefault("audit-command-prefix", "审计") + val auditCommandPrefixes = config.getOrDefault("audit-command-prefixes", listOf("审计")).toSet() val gracePeriodDays = config.int("grace-period-days") val expiryWarningDays = config.int("expiry-warning-days") val pollIntervalMinutes = config.getOrDefault("poll-interval-minutes", 60L) @@ -292,6 +292,10 @@ object ModuleFactory { val mailModule = if (enableEmail) { resolveDependency(config.findDependency(MAIL_MODULE), "mailModule") as MailModule } else null + val emailRejectTemplate = config.getOrDefault("email-reject-template", "") + val emailWarningTemplate = config.getOrDefault("email-warning-template", "") + val emailRemovedTemplate = config.getOrDefault("email-removed-template", "") + val emailReActivatedTemplate = config.getOrDefault("email-reactivated-template", "") return WhitelistAuditModule( config.name, groupMessagePollingModule, @@ -301,11 +305,15 @@ object ModuleFactory { filterQqList, auditAllowedUsers, reActivationKeywords, - auditCommandPrefix, + auditCommandPrefixes, gracePeriodDays, expiryWarningDays, pollIntervalMinutes, - selfId + selfId, + emailRejectTemplateRaw = emailRejectTemplate, + emailWarningTemplateRaw = emailWarningTemplate, + emailRemovedTemplateRaw = emailRemovedTemplate, + emailReActivatedTemplateRaw = emailReActivatedTemplate, ) } diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/module/WhitelistAuditModule.kt b/src/main/kotlin/top/r3944realms/ltdmanager/module/WhitelistAuditModule.kt index 547b89c..c85df32 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/module/WhitelistAuditModule.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/module/WhitelistAuditModule.kt @@ -1,9 +1,6 @@ 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 @@ -11,14 +8,13 @@ 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.utils.SqlTemplate import top.r3944realms.ltdmanager.whitelist.WhitelistSystemClient -import java.io.File class WhitelistAuditModule( moduleName: String, @@ -29,33 +25,45 @@ class WhitelistAuditModule( private val filterQqList: Set, private val auditAllowedUsers: Set, private val reActivationKeywords: Set, - private val auditCommandPrefix: String, + private val auditCommandPrefixes: Set, private val gracePeriodDays: Int, private val expiryWarningDays: Int, private val pollIntervalMinutes: Long, private val selfId: Long, -) : BaseModule(Modules.WHITELIST_AUDIT, moduleName), PersistentState { + // --- 可配置邮件模板(空 = 使用内置默认) --- + private val emailRejectTemplateRaw: String = "", + private val emailWarningTemplateRaw: String = "", + private val emailRemovedTemplateRaw: String = "", + private val emailReActivatedTemplateRaw: String = "", +) : BaseModule(Modules.WHITELIST_AUDIT, moduleName) { - private val whitelistGroupId: Long get() = whitelistGroupPollingModule.targetGroupId + private val defaultReject = "你的白名单因退出白名单QQ群已被暂时拒绝。\n请在 \${graceDays} 天之内在主群发送关键词(\${keywords})重新激活。" + private val defaultWarning = "你的白名单宽限期仅剩 \${remainDays} 天。\n请尽快在主群发送关键词(\${keywords})重新激活,否则将被永久删除。" + private val defaultRemoved = "你的白名单因宽限期已过已被永久删除。\n请重新申请白名单。" + private val defaultReActivated = "你的白名单已重新激活。\n请在 \${graceDays} 天之内重新加入白名单QQ群,否则将再次被拒绝。" + private val emailRejectTemplate: String get() = emailRejectTemplateRaw.ifEmpty { defaultReject } + private val emailWarningTemplate: String get() = emailWarningTemplateRaw.ifEmpty { defaultWarning } + private val emailRemovedTemplate: String get() = emailRemovedTemplateRaw.ifEmpty { defaultRemoved } + private val emailReActivatedTemplate: String get() = emailReActivatedTemplateRaw.ifEmpty { defaultReActivated } + + private val keywordsStr get() = reActivationKeywords.joinToString("、") 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 var lastAuditRealId: Long = -1 private var lastAuditTime: Long = 0 + private val whitelistGroupId: Long get() = whitelistGroupPollingModule.targetGroupId + + // -- SQL templates -- + private val createTableSql = SqlTemplate.fromFile("whitelist/create_audit_table.sql") + private val queryAllSql = SqlTemplate.fromFile("whitelist/query_all_audit.sql") + private val upsertSql = SqlTemplate.fromFile("whitelist/upsert_audit.sql") + private val deleteSql = SqlTemplate.fromFile("whitelist/delete_audit.sql") + private val queryByQqSql = SqlTemplate.fromFile("whitelist/query_audit_by_qq.sql") + private val reactivationFilter by lazy { TriggerMessageFilter( listOf( @@ -71,28 +79,24 @@ class WhitelistAuditModule( listOf( IgnoreSelfFilter(selfId), NewMessageFilter { lastAuditTime to lastAuditRealId }, - KeywordFilter(setOf(auditCommandPrefix)), + KeywordFilter(auditCommandPrefixes), ) ) } override fun onLoad() { - LoggerUtil.logger.info("[$name] 白名单审计模块已装载") + dbCreateTableIfNeeded() + LoggerUtil.logger.info("[$name] 白名单审计模块已装载 (DB)") LoggerUtil.logger.info("[$name] 白名单群: $whitelistGroupId, 主群: ${mainGroupPollingModule.targetGroupId}") - LoggerUtil.logger.info("[$name] 审计命令: $auditCommandPrefix") - LoggerUtil.logger.info("[$name] 检查间隔: ${pollIntervalMinutes}分, 宽限期: ${gracePeriodDays}天, 过期警告: ${expiryWarningDays}天前") - LoggerUtil.logger.info("[$name] 邮件通知: ${mailModule != null}") + LoggerUtil.logger.info("[$name] 检查间隔: ${pollIntervalMinutes}分, 宽限期: ${gracePeriodDays}天") scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) scope!!.launch { delay(10_000) while (isActive && loaded) { - try { - runAuditCycle() - } catch (e: Exception) { - LoggerUtil.logger.error("[$name] 审计周期异常", e) - } + try { runAuditCycle() } + catch (e: Exception) { LoggerUtil.logger.error("[$name] 审计周期异常", e) } delay(pollIntervalMinutes * 60_000) } } @@ -117,172 +121,198 @@ class WhitelistAuditModule( } override suspend fun onUnload() { - saveState(auditState) scope?.cancel() LoggerUtil.logger.info("[$name] 白名单审计模块已卸载") } - // ======== 审计周期 ======== + // ======== 数据库操作 ======== + + private data class AuditRecord( + val qq: String, + val playerId: Int, + val playerName: String, + val detectedTime: Long, + val rejectedTime: Long = 0, + val warningSentTime: Long = 0, + val reactivated: Boolean = false, + ) + + private fun dbCreateTableIfNeeded() { + try { + getConnection().use { conn -> + conn.prepareStatement(createTableSql.bind()).use { it.execute() } + } + LoggerUtil.logger.info("[$name] 审计表已就绪") + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 创建审计表失败", e) + } + } + + private fun dbLoadAll(): Map { + val result = mutableMapOf() + try { + getConnection().use { conn -> + conn.prepareStatement(queryAllSql.bind()).use { stmt -> + stmt.executeQuery().use { rs -> + while (rs.next()) { + val qq = rs.getString("qq") + result[qq] = AuditRecord( + qq = qq, + playerId = rs.getInt("player_id"), + playerName = rs.getString("player_name"), + detectedTime = rs.getLong("detected_time"), + rejectedTime = rs.getLong("rejected_time"), + warningSentTime = rs.getLong("warning_sent_time"), + reactivated = rs.getBoolean("reactivated"), + ) + } + } + } + } + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 加载审计记录失败", e) + } + return result + } + + private fun dbGet(qq: String): AuditRecord? { + return try { + getConnection().use { conn -> + conn.prepareStatement(queryByQqSql.bind()).use { stmt -> + stmt.setString(1, qq) + stmt.executeQuery().use { rs -> + if (rs.next()) AuditRecord( + qq = qq, + playerId = rs.getInt("player_id"), + playerName = rs.getString("player_name"), + detectedTime = rs.getLong("detected_time"), + rejectedTime = rs.getLong("rejected_time"), + warningSentTime = rs.getLong("warning_sent_time"), + reactivated = rs.getBoolean("reactivated"), + ) else null + } + } + } + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 查询审计记录失败 qq=$qq", e) + null + } + } + + private fun dbUpsert(r: AuditRecord) { + try { + getConnection().use { conn -> + conn.prepareStatement(upsertSql.bind()).use { stmt -> + stmt.setString(1, r.qq) + stmt.setInt(2, r.playerId) + stmt.setString(3, r.playerName) + stmt.setLong(4, r.detectedTime) + stmt.setLong(5, r.rejectedTime) + stmt.setLong(6, r.warningSentTime) + stmt.setBoolean(7, r.reactivated) + stmt.executeUpdate() + } + } + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 写入审计记录失败 qq=${r.qq}", e) + } + } + + private fun dbDelete(qq: String) { + try { + getConnection().use { conn -> + conn.prepareStatement(deleteSql.bind()).use { stmt -> + stmt.setString(1, qq) + stmt.executeUpdate() + } + } + } catch (e: Exception) { + LoggerUtil.logger.error("[$name] 删除审计记录失败 qq=$qq", e) + } + } + + // ======== 自动审计周期(仅邮件,不发群消息) ======== private suspend fun runAuditCycle() { - LoggerUtil.logger.info("[$name] 开始审计周期...") + 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}") + if (whitelistEntries.isEmpty()) { LoggerUtil.logger.info("[$name] 白名单通过数为0,跳过"); return } val now = System.currentTimeMillis() val graceMs = gracePeriodDays * 24 * 60 * 60 * 1000L val warningMs = expiryWarningDays * 24 * 60 * 60 * 1000L + val allRecords = dbLoadAll() + var actions = 0 for (entry in whitelistEntries) { val qqLong = entry.qq.toLongOrNull() ?: continue if (qqLong in filterQqList) continue + val key = entry.qq + 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) + if (allRecords.containsKey(key) && !allRecords[key]!!.reactivated) { + dbDelete(key); actions++ LoggerUtil.logger.info("[$name] $qqLong 重新加入白名单群,清除审计标记") } continue } - val key = entry.qq - val existing = auditState.entries[key] - + val existing = allRecords[key] when { existing == null -> { - LoggerUtil.logger.info("[$name] $qqLong (${entry.playerName}) 不在白名单群,执行拒绝") + 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} 天之内在主群发送关键词重新激活。" - ) + dbUpsert(AuditRecord(qq = key, playerId = entry.id, playerName = entry.playerName, detectedTime = now, rejectedTime = now)) + sendEmail(qqLong, entry.playerName, "白名单已被拒绝", fmt(emailRejectTemplate, entry.playerName, gracePeriodDays)) + actions++ } } - existing.reactivated -> { - LoggerUtil.logger.info("[$name] $qqLong (${entry.playerName}) 再次不在白名单群,重新拒绝") + 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) + dbUpsert(existing.copy(detectedTime = now, rejectedTime = now, warningSentTime = 0, reactivated = false)) + sendEmail(qqLong, entry.playerName, "LTD白名单审计通知", fmt(emailRejectTemplate, entry.playerName, gracePeriodDays)) + actions++ } - 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白名单已删除", - "你的白名单因宽限期已过已被永久删除。请重新申请白名单。" - ) + LoggerUtil.logger.info("[$name] [自动] $qqLong (${entry.playerName}) 宽限期已过,执行删除") + whitelistClient.remove(entry.id); dbDelete(key); actions++ + sendEmail(qqLong, entry.playerName, "白名单已删除", fmt(emailRemovedTemplate, entry.playerName, 0)) } 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) + val remainDays = ((graceMs - elapsed) / (24 * 60 * 60 * 1000)).toInt() + LoggerUtil.logger.info("[$name] [自动] $qqLong (${entry.playerName}) 宽限期即将过期 (剩余${remainDays}天)") + sendEmail(qqLong, entry.playerName, "白名单即将过期", fmt(emailWarningTemplate, entry.playerName, remainDays)) + dbUpsert(existing.copy(warningSentTime = now)); actions++ } } } } - // 额外检查:已拒绝但不在 approved 列表中的用户(状态已变,API 不再返回) + + // 扫尾:已拒绝但不在 approved 列表中的用户 val approvedQqSet = whitelistEntries.map { it.qq }.toSet() - for ((qq, entry) in auditState.entries.toMap()) { + for ((qq, record) in allRecords) { if (qq in approvedQqSet) continue - if (entry.rejectedTime == 0L) continue - val elapsed = now - entry.rejectedTime + if (record.rejectedTime == 0L) continue + val elapsed = now - record.rejectedTime if (elapsed >= graceMs) { - LoggerUtil.logger.info("[$name] $qq (${entry.playerName}) 宽限期已过(不在approved列表),执行删除") - whitelistClient.remove(entry.playerId) - auditState = auditState.copy( - entries = auditState.entries.toMutableMap().apply { remove(qq) } - ) - saveState(auditState) - sendGroupNotification( - "白名单审计: ${entry.playerName}(QQ:$qq) 宽限期已过,白名单已删除" - ) - sendEmail( - qq.toLong(), entry.playerName, - "LTD白名单已删除", - "你的白名单因宽限期已过已被永久删除。请重新申请白名单。" - ) - } else if (elapsed >= graceMs - warningMs && entry.warningSentTime == 0L) { + LoggerUtil.logger.info("[$name] [自动] $qq (${record.playerName}) 宽限期已过(不在approved列表),执行删除") + whitelistClient.remove(record.playerId); dbDelete(qq); actions++ + sendEmail(qq.toLong(), record.playerName, "白名单已删除", fmt(emailRemovedTemplate, record.playerName, 0)) + } else if (elapsed >= graceMs - warningMs && record.warningSentTime == 0L) { val remainDays = ((graceMs - elapsed) / (24 * 60 * 60 * 1000)).toInt() - LoggerUtil.logger.info("[$name] $qq (${entry.playerName}) 宽限期即将过期 (剩余${remainDays}天)") - sendEmail( - qq.toLong(), entry.playerName, - "LTD白名单即将过期", - "你的白名单宽限期仅剩 ${remainDays} 天。请尽快在主群发送关键词重新激活,否则将被永久删除。" - ) - val updated = entry.copy(warningSentTime = now) - auditState = auditState.copy( - entries = auditState.entries.toMutableMap().apply { put(qq, updated) } - ) - saveState(auditState) + LoggerUtil.logger.info("[$name] [自动] $qq (${record.playerName}) 宽限期即将过期 (剩余${remainDays}天)") + sendEmail(qq.toLong(), record.playerName, "白名单即将过期", fmt(emailWarningTemplate, record.playerName, remainDays)) + dbUpsert(record.copy(warningSentTime = now)); actions++ } } - LoggerUtil.logger.info("[$name] 审计周期完成") + LoggerUtil.logger.info("[$name] 【自动审计】完成, 处理 $actions 项") } // ======== 重新激活关键词处理 ======== @@ -292,41 +322,52 @@ class WhitelistAuditModule( updateMsgState(msg) val key = msg.userId.toString() - val entry = auditState.entries[key] ?: return + val record = dbGet(key) + + if (record == null) { + napCatClient.sendUnit( + SendGroupMsgRequest( + MessageElement.reply(ID.long(msg.realId), "你没有待处理的白名单审计记录,无需重新激活"), + ID.long(mainGroupPollingModule.targetGroupId) + ) + ) + return + } + 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 (record.rejectedTime > 0 && now - record.rejectedTime < graceMs) { + LoggerUtil.logger.info("[$name] ${msg.userId} (${record.playerName}) 发送关键词,重新激活白名单") + val approved = whitelistClient.approve(record.playerId) if (approved) { - val updated = entry.copy(reactivated = true) - auditState = auditState.copy( - entries = auditState.entries.toMutableMap().apply { put(key, updated) } - ) - saveState(auditState) + dbUpsert(record.copy(reactivated = true)) napCatClient.sendUnit( SendGroupMsgRequest( - MessageElement.reply( - ID.long(msg.realId), - "✅ ${entry.playerName} 白名单已重新激活,请在${gracePeriodDays}天内重新加入白名单群" - ), + MessageElement.reply(ID.long(msg.realId), "✅ ${record.playerName} 白名单已重新激活,请在${gracePeriodDays}天内重新加入白名单群"), ID.long(mainGroupPollingModule.targetGroupId) ) ) - sendEmail( - msg.userId, entry.playerName, - "LTD白名单已重新激活", - "你的白名单已重新激活。请在 ${gracePeriodDays} 天之内重新加入白名单QQ群,否则将再次被拒绝。" + sendEmail(msg.userId, record.playerName, "白名单已重新激活", fmt(emailReActivatedTemplate, record.playerName, gracePeriodDays)) + } else { + napCatClient.sendUnit( + SendGroupMsgRequest( + MessageElement.reply(ID.long(msg.realId), "⚠️ 重新激活失败,API 返回错误"), + ID.long(mainGroupPollingModule.targetGroupId) + ) ) } + } else if (record.rejectedTime > 0) { + napCatClient.sendUnit( + SendGroupMsgRequest( + MessageElement.reply(ID.long(msg.realId), "⏰ ${record.playerName} 宽限期已过,无法重新激活,请重新申请白名单"), + ID.long(mainGroupPollingModule.targetGroupId) + ) + ) } else { napCatClient.sendUnit( SendGroupMsgRequest( - MessageElement.reply( - ID.long(msg.realId), - "⏰ ${entry.playerName} 宽限期已过,无法重新激活,请重新申请白名单" - ), + MessageElement.reply(ID.long(msg.realId), "你的状态暂不需要重新激活(未被拒绝)"), ID.long(mainGroupPollingModule.targetGroupId) ) ) @@ -337,119 +378,92 @@ class WhitelistAuditModule( private suspend fun handleAuditCommand(messages: List) { val msg = messages.maxByOrNull { it.time } ?: return - lastAuditRealId = msg.realId lastAuditTime = msg.time if (msg.userId !in auditAllowedUsers) { - napCatClient.sendUnit( - SendGroupMsgRequest( - MessageElement.reply(ID.long(msg.realId), "你没有权限执行审计命令"), - ID.long(whitelistGroupId) - ) - ) + napCatClient.sendUnit(SendGroupMsgRequest(MessageElement.reply(ID.long(msg.realId), "你没有权限执行审计命令"), ID.long(whitelistGroupId))) return } - lastAuditRealId = msg.realId - lastAuditTime = msg.time - - LoggerUtil.logger.info("[$name] ${msg.userId} 触发手动审计") + LoggerUtil.logger.info("[$name] [手动审计] ${msg.userId} 触发") val summary = runAdhocAudit() - - napCatClient.sendUnit( - SendGroupMsgRequest( - MessageElement.reply(ID.long(msg.realId), summary), - ID.long(whitelistGroupId) - ) - ) + LoggerUtil.logger.info("[$name] [手动审计] 完成") + napCatClient.sendUnit(SendGroupMsgRequest(MessageElement.reply(ID.long(msg.realId), summary), ID.long(whitelistGroupId))) } private suspend fun runAdhocAudit(): String { val groupMembers = fetchWhitelistGroupMembers() ?: return "获取群成员失败,审计中断" - val groupMemberQqSet = groupMembers.map { it.userId }.toSet() val whitelistEntries = whitelistClient.listApproved() if (whitelistEntries.isEmpty()) return "白名单通过数为0,跳过审计" val now = System.currentTimeMillis() val graceMs = gracePeriodDays * 24 * 60 * 60 * 1000L + val allRecords = dbLoadAll() var inGroupOk = 0 var notInGroupNew = 0 - val rejectedList = mutableListOf() // 本次被拒绝的用户 + val rejectedList = mutableListOf() var notInGroupGrace = mutableListOf() var notInGroupExpired = 0 - val expiredList = mutableListOf() // 本次被删除的用户 + val expiredList = mutableListOf() var inFilter = 0 for (entry in whitelistEntries) { val qqLong = entry.qq.toLongOrNull() ?: continue if (qqLong in filterQqList) { inFilter++; continue } + val key = entry.qq + if (qqLong in groupMemberQqSet) { inGroupOk++ - // 清除已有标记 - val key = entry.qq - if (auditState.entries.containsKey(key)) { - auditState = auditState.copy( - entries = auditState.entries.toMutableMap().apply { remove(key) } - ) + if (allRecords.containsKey(key) && !allRecords[key]!!.reactivated) { + dbDelete(key) + sendEmail(qqLong, entry.playerName, "LTD白名单审计通知", "你的白名单审计标记已清除(已重新加入白名单群)") } continue } - val key = entry.qq - val existing = auditState.entries[key] - + val existing = allRecords[key] when { existing == null -> { notInGroupNew++ rejectedList.add("${entry.playerName}($qqLong)") whitelistClient.reject(entry.id) - val newEntry = AuditEntry( - qq = entry.qq, - playerId = entry.id, - playerName = entry.playerName, - detectedTime = now, - rejectedTime = now - ) - auditState = auditState.copy( - entries = auditState.entries.toMutableMap().apply { put(key, newEntry) } - ) + dbUpsert(AuditRecord(qq = key, playerId = entry.id, playerName = entry.playerName, detectedTime = now, rejectedTime = now)) + sendEmail(qqLong, entry.playerName, "LTD白名单审计通知", fmt(emailRejectTemplate, entry.playerName, gracePeriodDays)) } existing.rejectedTime > 0 && now - existing.rejectedTime >= graceMs -> { notInGroupExpired++ expiredList.add("${entry.playerName}($qqLong)") whitelistClient.remove(entry.id) - auditState = auditState.copy( - entries = auditState.entries.toMutableMap().apply { remove(key) } - ) + dbDelete(key) + sendEmail(qqLong, entry.playerName, "白名单已删除", fmt(emailRemovedTemplate, entry.playerName, 0)) } existing.rejectedTime > 0 -> { val remainDays = ((graceMs - (now - existing.rejectedTime)) / (24 * 60 * 60 * 1000)).toInt() - notInGroupGrace.add("${entry.playerName}(剩${remainDays}天)") + notInGroupGrace.add("${entry.playerName}($qqLong, 剩${remainDays}天)") } } } - // 额外检查:已拒绝但不在 approved 列表中的用户(API 不再返回) + + // 扫尾已拒绝但不在 API 返回列表中的 val approvedQqSet = whitelistEntries.map { it.qq }.toSet() - for ((qq, entry) in auditState.entries.toMap()) { + for ((qq, record) in allRecords) { if (qq in approvedQqSet) continue - if (entry.rejectedTime == 0L) continue - val elapsed = now - entry.rejectedTime + if (record.rejectedTime == 0L) continue + val elapsed = now - record.rejectedTime if (elapsed >= graceMs) { notInGroupExpired++ - expiredList.add("${entry.playerName}($qq)") - whitelistClient.remove(entry.playerId) - auditState = auditState.copy( - entries = auditState.entries.toMutableMap().apply { remove(qq) } - ) + expiredList.add("${record.playerName}($qq)") + whitelistClient.remove(record.playerId) + dbDelete(qq) + sendEmail(qq.toLong(), record.playerName, "白名单已删除", fmt(emailRemovedTemplate, record.playerName, 0)) } else { val remainDays = ((graceMs - elapsed) / (24 * 60 * 60 * 1000)).toInt() - notInGroupGrace.add("${entry.playerName}($qq, 剩${remainDays}天)") + notInGroupGrace.add("${record.playerName}($qq, 剩${remainDays}天)") } } - saveState(auditState) return buildString { appendLine("审计完成") @@ -461,19 +475,13 @@ class WhitelistAuditModule( rejectedList.forEach { appendLine(" • $it") } } if (notInGroupGrace.isNotEmpty()) { - appendLine("宽限期中: \n${notInGroupGrace.joinToString()}") + appendLine("宽限期中: ${notInGroupGrace.joinToString()}") } if (notInGroupExpired > 0) { appendLine("已过期删除: $notInGroupExpired 人") expiredList.forEach { appendLine(" • $it") } } - if (inFilter > 0) { - appendLine("过滤列表跳过: $inFilter 人") - } -// val total = inGroupOk + notInGroupNew + notInGroupGrace.size + notInGroupExpired + inFilter -// if (total != whitelistEntries.size + auditState.entries.size) { -// appendLine("⚠ 计数不闭合: 统计${total} vs 入库${whitelistEntries.size}+状态${auditState.entries.size}") -// } + if (inFilter > 0) appendLine("过滤列表跳过: $inFilter 人") } } @@ -481,37 +489,31 @@ class WhitelistAuditModule( private suspend fun fetchWhitelistGroupMembers(): List? { return try { - val event = napCatClient.send( - GetGroupMemberListRequest(ID.long(whitelistGroupId), false) - ) - event.data.filter { !it.isRobot }.map { - GroupMemberData(it.userId, it.nickname) - } + napCatClient.send(GetGroupMemberListRequest(ID.long(whitelistGroupId), false)) + .data.filter { !it.isRobot }.map { GroupMemberData(it.userId, it.nickname) } } catch (e: Exception) { - LoggerUtil.logger.error("[$name] 获取群成员列表失败", e) - null + 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) - ) - ) + 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 + val m = mailModule + if (m == null) { + LoggerUtil.logger.warn("[$name] 邮件跳过: mailModule 未注入 (enable-email: false?)") + return + } try { m.enqueue(mail { to += "${qq}@qq.com" @@ -519,65 +521,31 @@ class WhitelistAuditModule( this.body = body isHtml = false }) - LoggerUtil.logger.info("[$name] 已入队邮件: $subject → ${qq}@qq.com") } catch (e: Exception) { LoggerUtil.logger.error("[$name] 邮件入队失败", e) } } + private fun fmt(template: String, playerName: String, remainDays: Int): String { + return template + .replace("\${playerName}", playerName) + .replace("\${graceDays}", gracePeriodDays.toString()) + .replace("\${remainDays}", remainDays.toString()) + .replace("\${keywords}", keywordsStr) + } + private fun updateMsgState(msg: MsgHistorySpecificMsg) { lastMsgRealId = msg.realId lastMsgTime = msg.time } - // ======== 持久化 ======== - - @Serializable - data class AuditState( - val entries: Map = 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 info(): String = "白名单审计模块(DB) - 白名单群:$whitelistGroupId, 间隔:${pollIntervalMinutes}分, 宽限:${gracePeriodDays}天" override fun help(): String = buildString { - appendLine("白名单审计模块 - 定期检查白名单群成员并自动处理离群用户") + appendLine("白名单审计模块(DB) - 定期检查白名单群成员并自动处理离群用户") appendLine("检查间隔: ${pollIntervalMinutes}分钟") appendLine("宽限期: ${gracePeriodDays}天") appendLine("过期警告: 到期前${expiryWarningDays}天") - appendLine("邮件通知: ${mailModule != null}") + appendLine("数据存储: MySQL ltd_manager_bot.whitelist_audit") appendLine("重新激活关键词: ${reActivationKeywords.joinToString()}") } } diff --git a/src/main/kotlin/top/r3944realms/ltdmanager/utils/ConfigInitializer.kt b/src/main/kotlin/top/r3944realms/ltdmanager/utils/ConfigInitializer.kt index 1b534fd..80ddb36 100644 --- a/src/main/kotlin/top/r3944realms/ltdmanager/utils/ConfigInitializer.kt +++ b/src/main/kotlin/top/r3944realms/ltdmanager/utils/ConfigInitializer.kt @@ -15,6 +15,11 @@ object ConfigInitializer { "sql/invitation/query_code_ids.sql", "sql/invitation/upsert_ascription.sql", "sql/whitelist/query_whitelist_record.sql", + "sql/whitelist/create_audit_table.sql", + "sql/whitelist/query_all_audit.sql", + "sql/whitelist/upsert_audit.sql", + "sql/whitelist/delete_audit.sql", + "sql/whitelist/query_audit_by_qq.sql", ) fun initConfig(fileName: String = "application.yml", configDir: String = "config", shouldExit: Boolean = true) { diff --git a/src/main/resources/module.yaml b/src/main/resources/module.yaml index c588236..a7f7615 100644 --- a/src/main/resources/module.yaml +++ b/src/main/resources/module.yaml @@ -15,7 +15,7 @@ # HELP_MODULE - 帮助模块 # RCON_COMMAND_MODULE - 通用 RCON 命令执行 # GITEA_WEBHOOK_MODULE - Gitea Webhook 事件通知 -# WHITELIST_AUDIT_MODULE - 白名单审计 (自动拒绝/提醒/过期) +# WHITELIST_AUDIT_MODULE - 白名单审计 (DB存储,自动/手动双模式) # ============================================================================= module: @@ -53,6 +53,7 @@ module: # command-prefix: "rcon" # QQ群触发前缀 # admin-ids: [2561098830] # 允许执行RCON的QQ号 # rcon-timeout-sec: 5 # RCON超时(秒) + # max-block-records: 200 # 最大阻止记录数 # command-blocklist: # 危险命令黑名单(前缀匹配) # - "stop" # - "restart" @@ -96,11 +97,14 @@ module: # config: # self-id: 3327379836 # # ---- 权限控制 ---- - # filter-qq-list: [2561098830, 3327379836] # 永不自动拒绝的QQ - # audit-allowed-ids: [2561098830] # 可执行"审计"命令的QQ - # # ---- 白名单群手动审计 ---- + # filter-qq-list: [2561098830, 3327379836] # 永不自动拒绝的保护列表 + # audit-allowed-ids: [2561098830] # 允许执行手动审计的QQ + # # ---- 手动审计触发 ---- # whitelist-group-polling-dep-name: "whitelistGroup" - # audit-command-prefix: "审计" # 白名单群触发关键词 + # audit-command-prefixes: # 白名单群触发关键词(多选) + # - "审计" + # - "audit" + # - "核查" # # ---- 重新激活 ---- # re-activation-keywords: # 主群重新激活关键词 # - "重新激活" @@ -109,6 +113,11 @@ module: # # ---- 时间参数 ---- # grace-period-days: 7 # 宽限期(天) # expiry-warning-days: 2 # 到期前N天警告 - # poll-interval-minutes: 60 # 审计间隔(分钟) + # poll-interval-minutes: 60 # 自动审计间隔(分钟) # # ---- 邮件通知 ---- # enable-email: false # 是否启用邮件通知 + # # ---- 邮件模板 (不填=内置默认,支持 ${playerName} ${graceDays} ${remainDays} ${keywords}) ---- + # email-reject-template: "" + # email-warning-template: "" + # email-removed-template: "" + # email-reactivated-template: "" diff --git a/src/main/resources/sql/whitelist/create_audit_table.sql b/src/main/resources/sql/whitelist/create_audit_table.sql new file mode 100644 index 0000000..fb6cb1f --- /dev/null +++ b/src/main/resources/sql/whitelist/create_audit_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS ltd_manager_bot.whitelist_audit ( + qq VARCHAR(20) PRIMARY KEY, + player_id INT NOT NULL, + player_name VARCHAR(64) NOT NULL, + detected_time BIGINT NOT NULL, + rejected_time BIGINT NOT NULL DEFAULT 0, + warning_sent_time BIGINT NOT NULL DEFAULT 0, + reactivated TINYINT NOT NULL DEFAULT 0 +) diff --git a/src/main/resources/sql/whitelist/delete_audit.sql b/src/main/resources/sql/whitelist/delete_audit.sql new file mode 100644 index 0000000..482fe2f --- /dev/null +++ b/src/main/resources/sql/whitelist/delete_audit.sql @@ -0,0 +1 @@ +DELETE FROM ltd_manager_bot.whitelist_audit WHERE qq = ? diff --git a/src/main/resources/sql/whitelist/query_all_audit.sql b/src/main/resources/sql/whitelist/query_all_audit.sql new file mode 100644 index 0000000..b2c987a --- /dev/null +++ b/src/main/resources/sql/whitelist/query_all_audit.sql @@ -0,0 +1,2 @@ +SELECT qq, player_id, player_name, detected_time, rejected_time, warning_sent_time, reactivated +FROM ltd_manager_bot.whitelist_audit diff --git a/src/main/resources/sql/whitelist/query_audit_by_qq.sql b/src/main/resources/sql/whitelist/query_audit_by_qq.sql new file mode 100644 index 0000000..0acbee1 --- /dev/null +++ b/src/main/resources/sql/whitelist/query_audit_by_qq.sql @@ -0,0 +1,3 @@ +SELECT qq, player_id, player_name, detected_time, rejected_time, warning_sent_time, reactivated +FROM ltd_manager_bot.whitelist_audit +WHERE qq = ? diff --git a/src/main/resources/sql/whitelist/upsert_audit.sql b/src/main/resources/sql/whitelist/upsert_audit.sql new file mode 100644 index 0000000..8aa4daf --- /dev/null +++ b/src/main/resources/sql/whitelist/upsert_audit.sql @@ -0,0 +1,9 @@ +INSERT INTO ltd_manager_bot.whitelist_audit (qq, player_id, player_name, detected_time, rejected_time, warning_sent_time, reactivated) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON DUPLICATE KEY UPDATE + player_id = VALUES(player_id), + player_name = VALUES(player_name), + detected_time = VALUES(detected_time), + rejected_time = VALUES(rejected_time), + warning_sent_time = VALUES(warning_sent_time), + reactivated = VALUES(reactivated) diff --git a/src/test/kotlin/top/r394realms/ltdmanagertest/whitelist/WhitelistAuditStateTest.kt b/src/test/kotlin/top/r394realms/ltdmanagertest/whitelist/WhitelistAuditStateTest.kt deleted file mode 100644 index 7666407..0000000 --- a/src/test/kotlin/top/r394realms/ltdmanagertest/whitelist/WhitelistAuditStateTest.kt +++ /dev/null @@ -1,142 +0,0 @@ -package top.r394realms.ltdmanagertest.whitelist - -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import top.r3944realms.ltdmanager.module.WhitelistAuditModule.AuditEntry -import top.r3944realms.ltdmanager.module.WhitelistAuditModule.AuditState - -class WhitelistAuditStateTest { - - private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } - - // ====== AuditState ====== - - @Test - fun `empty AuditState round-trips correctly`() { - val state = AuditState() - val serialized = json.encodeToString(state) - val deserialized = json.decodeFromString(serialized) - - assertTrue(deserialized.entries.isEmpty()) - } - - @Test - fun `AuditState round-trip with single entry`() { - val entry = AuditEntry( - qq = "123456789", - playerId = 42, - playerName = "Steve", - detectedTime = 1717891200000L, - rejectedTime = 1717891200000L, - warningSentTime = 0L, - reactivated = false, - ) - val state = AuditState(entries = mapOf("123456789" to entry)) - - val serialized = json.encodeToString(state) - val deserialized = json.decodeFromString(serialized) - - assertEquals(1, deserialized.entries.size) - val restored = deserialized.entries["123456789"]!! - assertEquals("123456789", restored.qq) - assertEquals(42, restored.playerId) - assertEquals("Steve", restored.playerName) - assertEquals(1717891200000L, restored.detectedTime) - assertEquals(1717891200000L, restored.rejectedTime) - assertEquals(0L, restored.warningSentTime) - assertFalse(restored.reactivated) - } - - @Test - fun `AuditState round-trip with reactivated entry`() { - val entry = AuditEntry( - qq = "111", - playerId = 1, - playerName = "Alex", - detectedTime = 1000L, - rejectedTime = 1000L, - warningSentTime = 5000L, - reactivated = true, - ) - val state = AuditState(entries = mapOf("111" to entry)) - - val serialized = json.encodeToString(state) - val deserialized = json.decodeFromString(serialized) - - assertTrue(deserialized.entries["111"]!!.reactivated) - assertEquals(5000L, deserialized.entries["111"]!!.warningSentTime) - } - - @Test - fun `AuditState round-trip with multiple entries`() { - val entries = mapOf( - "aaa" to AuditEntry(qq = "aaa", playerId = 1, playerName = "A", detectedTime = 1L), - "bbb" to AuditEntry(qq = "bbb", playerId = 2, playerName = "B", detectedTime = 2L, rejectedTime = 2L), - "ccc" to AuditEntry(qq = "ccc", playerId = 3, playerName = "C", detectedTime = 3L, rejectedTime = 3L, warningSentTime = 3L, reactivated = true), - ) - val state = AuditState(entries = entries) - - val serialized = json.encodeToString(state) - val deserialized = json.decodeFromString(serialized) - - assertEquals(3, deserialized.entries.size) - assertFalse(deserialized.entries["aaa"]!!.reactivated) - assertEquals(0L, deserialized.entries["aaa"]!!.rejectedTime) - assertTrue(deserialized.entries["ccc"]!!.reactivated) - } - - // ====== AuditEntry defaults ====== - - @Test - fun `AuditEntry defaults are zero-empty-false`() { - val entry = AuditEntry( - qq = "test", - playerId = 1, - playerName = "test", - detectedTime = 0L, - ) - assertEquals(0L, entry.rejectedTime) - assertEquals(0L, entry.warningSentTime) - assertFalse(entry.reactivated) - } - - @Test - fun `AuditEntry copy preserves fields`() { - val entry = AuditEntry( - qq = "qq", playerId = 5, playerName = "P", - detectedTime = 100L, rejectedTime = 200L, warningSentTime = 300L, reactivated = true, - ) - val copied = entry.copy(warningSentTime = 400L, reactivated = false) - - assertEquals("qq", copied.qq) - assertEquals(5, copied.playerId) - assertEquals(100L, copied.detectedTime) - assertEquals(200L, copied.rejectedTime) - assertEquals(400L, copied.warningSentTime) - assertFalse(copied.reactivated) - } - - // ====== Deserialization with missing fields ====== - - @Test - fun `deserialize AuditState with missing optional fields uses defaults`() { - val jsonStr = """ - {"entries": {"test": {"qq":"test","playerId":1,"playerName":"T","detectedTime":0}}} - """.trimIndent() - - val state = json.decodeFromString(jsonStr) - val entry = state.entries["test"]!! - - assertEquals(0L, entry.rejectedTime) - assertEquals(0L, entry.warningSentTime) - assertFalse(entry.reactivated) - } - - @Test - fun `deserialize empty JSON object as AuditState`() { - val state = json.decodeFromString("{}") - assertTrue(state.entries.isEmpty()) - } -}