pref: 改进模块&文档

This commit is contained in:
叁玖领域 2026-06-09 17:26:49 +08:00
parent d1afc51ad3
commit c5ed59c979
13 changed files with 433 additions and 494 deletions

View File

@ -197,7 +197,18 @@ In your Gitea repository → **Settings** → **Webhooks** → **Add Webhook**
**Type:** `WHITELIST_AUDIT_MODULE` **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 #### Lifecycle States
@ -207,17 +218,10 @@ In Whitelist Group ────────────────────
└── User left group ──► Detected ──► Rejected (grace period starts) └── User left group ──► Detected ──► Rejected (grace period starts)
├── Keyword in main group ──► Re-Activated ├── Keyword in main group ──► Re-Activated
├── Warning threshold ──► Email/Group notification sent ├── Warning threshold ──► Email sent (once)
└── Grace period expired ──► Permanently Removed └── 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 #### Example Config
```yaml ```yaml
@ -236,15 +240,21 @@ In Whitelist Group ────────────────────
filter-qq-list: [2561098830, 3327379836] filter-qq-list: [2561098830, 3327379836]
audit-allowed-ids: [2561098830] audit-allowed-ids: [2561098830]
whitelist-group-polling-dep-name: "whitelistGroup" whitelist-group-polling-dep-name: "whitelistGroup"
audit-command-prefix: "审计" audit-command-prefixes:
- "审计"
- "audit"
re-activation-keywords: re-activation-keywords:
- "重新激活" - "重新激活"
- "激活白名单" - "激活白名单"
- "reactivate"
grace-period-days: 7 grace-period-days: 7
expiry-warning-days: 2 expiry-warning-days: 2
poll-interval-minutes: 60 poll-interval-minutes: 60
enable-email: false enable-email: false
# Email templates (omit = use built-in defaults)
email-reject-template: ""
email-warning-template: ""
email-removed-template: ""
email-reactivated-template: ""
``` ```
#### Config Reference #### Config Reference
@ -255,12 +265,16 @@ In Whitelist Group ────────────────────
| `filter-qq-list` | long[] | yes | — | QQ IDs never auto-rejected | | `filter-qq-list` | long[] | yes | — | QQ IDs never auto-rejected |
| `audit-allowed-ids` | long[] | yes | — | QQ IDs allowed to trigger manual audit | | `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 | | `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 | | `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 | | `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 | | `expiry-warning-days` | int | yes | — | Days before expiry to send warning |
| `poll-interval-minutes` | long | no | `60` | Auto-audit interval in minutes | | `poll-interval-minutes` | long | no | `60` | Auto-audit interval in minutes |
| `enable-email` | boolean | no | `false` | Enable email notifications (requires MailModule) | | `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 #### Manual Audit Output Example
@ -270,14 +284,36 @@ In Whitelist Group ────────────────────
白名单总数: 42 白名单总数: 42
在群正常: 35 人 在群正常: 35 人
新发现不在群(已拒绝): 2 人 新发现不在群(已拒绝): 2 人
宽限期中: Steve(剩5天), Alex(剩2天) • Steve(123456789)
• Alex(987654321)
宽限期中: Notch(111222333, 剩5天)
已过期删除: 1 人 已过期删除: 1 人
• Hero(444555666)
过滤列表跳过: 3 人 过滤列表跳过: 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 #### 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 #### Dependencies

View File

@ -197,7 +197,18 @@ RCON 二进制路径和配置文件路径从 `application.yaml` → `tools.rcon`
**类型:** `WHITELIST_AUDIT_MODULE` **类型:** `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 ```yaml
@ -233,23 +237,24 @@ RCON 二进制路径和配置文件路径从 `application.yaml` → `tools.rcon`
type: "MAIL_MODULE" type: "MAIL_MODULE"
config: config:
self-id: 3327379836 self-id: 3327379836
# ---- 权限控制 ---- filter-qq-list: [2561098830, 3327379836]
filter-qq-list: [2561098830, 3327379836] # 永不自动拒绝的保护列表 audit-allowed-ids: [2561098830]
audit-allowed-ids: [2561098830] # 可执行手动审计的管理员QQ
# ---- 白名单群手动审计 ----
whitelist-group-polling-dep-name: "whitelistGroup" whitelist-group-polling-dep-name: "whitelistGroup"
audit-command-prefix: "审计" # 白名单群触发关键词 audit-command-prefixes: # 支持多个关键词
# ---- 重新激活 ---- - "审计"
re-activation-keywords: # 主群重新激活关键词 - "audit"
re-activation-keywords:
- "重新激活" - "重新激活"
- "激活白名单" - "激活白名单"
- "reactivate" grace-period-days: 7
# ---- 时间参数 ---- expiry-warning-days: 2
grace-period-days: 7 # 宽限期(天) poll-interval-minutes: 60
expiry-warning-days: 2 # 到期前N天发警告 enable-email: false
poll-interval-minutes: 60 # 自动审计间隔(分钟) # 邮件模板 (空=使用内置默认,支持占位符)
# ---- 邮件通知 ---- email-reject-template: ""
enable-email: false # 是否启用邮件通知 email-warning-template: ""
email-removed-template: ""
email-reactivated-template: ""
``` ```
#### 配置项 #### 配置项
@ -257,15 +262,19 @@ RCON 二进制路径和配置文件路径从 `application.yaml` → `tools.rcon`
| 字段 | 类型 | 必填 | 默认值 | 说明 | | 字段 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---| |---|---|---|---|---|
| `self-id` | long | 是 | — | 机器人 QQ 号 | | `self-id` | long | 是 | — | 机器人 QQ 号 |
| `filter-qq-list` | long[] | 是 | — | 保护列表,模块不会自动拒绝这些 QQ 的白名单 | | `filter-qq-list` | long[] | 是 | — | 保护列表,永不自动拒绝 |
| `audit-allowed-ids` | long[] | 是 | — | 允许在白名单群执行手动审计的 QQ | | `audit-allowed-ids` | long[] | 是 | — | 允许执行手动审计的 QQ |
| `whitelist-group-polling-dep-name` | string | 是 | — | 白名单群轮询模块的依赖名 | | `whitelist-group-polling-dep-name` | string | 是 | — | 白名单群轮询模块的依赖名 |
| `audit-command-prefix` | string | 否 | `"审计"` | 白名单群触发手动审计的命令 | | `audit-command-prefixes` | string[] | 否 | `["审计"]` | 白名单群触发手动审计的关键词(支持多个) |
| `re-activation-keywords` | string[] | 是 | — | 主群中触发重新激活的关键词列表 | | `re-activation-keywords` | string[] | 是 | — | 主群中触发重新激活的关键词列表 |
| `grace-period-days` | int | 是 | — | 被拒绝后允许重新激活的天数 | | `grace-period-days` | int | 是 | — | 被拒绝后允许重新激活的天数 |
| `expiry-warning-days` | int | 是 | — | 宽限期到期前多少天发送警告 | | `expiry-warning-days` | int | 是 | — | 宽限期到期前多少天发送警告 |
| `poll-interval-minutes` | long | 否 | `60` | 自动审计间隔(分钟) | | `poll-interval-minutes` | long | 否 | `60` | 自动审计间隔(分钟) |
| `enable-email` | boolean | 否 | `false` | 是否发送邮件通知(需 MailModule 依赖) | | `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 白名单总数: 42
在群正常: 35 人 在群正常: 35 人
新发现不在群(已拒绝): 2 人 新发现不在群(已拒绝): 2 人
宽限期中: Steve(剩5天), Alex(剩2天) • Steve(123456789)
• Alex(987654321)
宽限期中: Notch(111222333, 剩5天)
已过期删除: 1 人 已过期删除: 1 人
• Hero(444555666)
过滤列表跳过: 3 人 过滤列表跳过: 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 配置
API 地址和密钥在 `application.yaml``whitelist-system:` 段配置(启动后自动加密): API 地址和密钥在 `application.yaml``whitelist-system:` 段配置(启动后自动加密):
```yaml ```yaml
whitelist-system: whitelist-system:
@ -306,11 +337,11 @@ whitelist-system:
| 触发条件 | 邮件主题 | | 触发条件 | 邮件主题 |
|---|---| |---|---|
| 首次检测离群 → 被拒绝 | `LTD白名单审计通知` — 告知已被拒,宽限期 N 天 | | 首次检测离群 → 被拒绝 | `白名单已被拒绝` — 含宽限期和关键词提示 |
| 重新激活后再离群 → 再次拒绝 | `LTD白名单审计通知` — 告知再次被拒 | | 重新激活后再离群 → 再次拒绝 | `白名单已被拒绝` — 同上 |
| 进入警告窗口 | `LTD白名单即将过期` — 剩余 N 天 | | 进入警告窗口 | `白名单即将过期` — 剩余天 |
| 宽限期过 → 永久删除 | `LTD白名单已删除` — 已永久删除 | | 宽限期过 → 永久删除 | `白名单已删除` |
| 关键词重新激活成功 | `LTD白名单已重新激活` — 提醒加群 | | 关键词重新激活成功 | `白名单已重新激活` — 提醒加群 |
--- ---

View File

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

View File

@ -271,7 +271,7 @@ object ModuleFactory {
val auditAllowedUsers = config.list<Long>("audit-allowed-ids").toSet() val auditAllowedUsers = config.list<Long>("audit-allowed-ids").toSet()
val enableEmail = config.getOrDefault("enable-email", false) val enableEmail = config.getOrDefault("enable-email", false)
val reActivationKeywords = config.stringList("re-activation-keywords").toSet() val reActivationKeywords = config.stringList("re-activation-keywords").toSet()
val auditCommandPrefix = config.getOrDefault("audit-command-prefix", "审计") val auditCommandPrefixes = config.getOrDefault("audit-command-prefixes", listOf("审计")).toSet()
val gracePeriodDays = config.int("grace-period-days") val gracePeriodDays = config.int("grace-period-days")
val expiryWarningDays = config.int("expiry-warning-days") val expiryWarningDays = config.int("expiry-warning-days")
val pollIntervalMinutes = config.getOrDefault("poll-interval-minutes", 60L) val pollIntervalMinutes = config.getOrDefault("poll-interval-minutes", 60L)
@ -292,6 +292,10 @@ object ModuleFactory {
val mailModule = if (enableEmail) { val mailModule = if (enableEmail) {
resolveDependency(config.findDependency(MAIL_MODULE), "mailModule") as MailModule resolveDependency(config.findDependency(MAIL_MODULE), "mailModule") as MailModule
} else null } else null
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( return WhitelistAuditModule(
config.name, config.name,
groupMessagePollingModule, groupMessagePollingModule,
@ -301,11 +305,15 @@ object ModuleFactory {
filterQqList, filterQqList,
auditAllowedUsers, auditAllowedUsers,
reActivationKeywords, reActivationKeywords,
auditCommandPrefix, auditCommandPrefixes,
gracePeriodDays, gracePeriodDays,
expiryWarningDays, expiryWarningDays,
pollIntervalMinutes, pollIntervalMinutes,
selfId selfId,
emailRejectTemplateRaw = emailRejectTemplate,
emailWarningTemplateRaw = emailWarningTemplate,
emailRemovedTemplateRaw = emailRemovedTemplate,
emailReActivatedTemplateRaw = emailReActivatedTemplate,
) )
} }

View File

@ -1,9 +1,6 @@
package top.r3944realms.ltdmanager.module package top.r3944realms.ltdmanager.module
import kotlinx.coroutines.* 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.core.mail.mail
import top.r3944realms.ltdmanager.module.common.filter.TriggerMessageFilter 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.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.module.common.filter.type.NewMessageFilter
import top.r3944realms.ltdmanager.napcat.data.ID import top.r3944realms.ltdmanager.napcat.data.ID
import top.r3944realms.ltdmanager.napcat.data.MessageElement 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.data.msghistory.MsgHistorySpecificMsg
import top.r3944realms.ltdmanager.napcat.event.group.GetGroupMemberListEvent import top.r3944realms.ltdmanager.napcat.event.group.GetGroupMemberListEvent
import top.r3944realms.ltdmanager.napcat.request.group.GetGroupMemberListRequest import top.r3944realms.ltdmanager.napcat.request.group.GetGroupMemberListRequest
import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest
import top.r3944realms.ltdmanager.utils.LoggerUtil import top.r3944realms.ltdmanager.utils.LoggerUtil
import top.r3944realms.ltdmanager.utils.SqlTemplate
import top.r3944realms.ltdmanager.whitelist.WhitelistSystemClient import top.r3944realms.ltdmanager.whitelist.WhitelistSystemClient
import java.io.File
class WhitelistAuditModule( class WhitelistAuditModule(
moduleName: String, moduleName: String,
@ -29,33 +25,45 @@ class WhitelistAuditModule(
private val filterQqList: Set<Long>, private val filterQqList: Set<Long>,
private val auditAllowedUsers: Set<Long>, private val auditAllowedUsers: Set<Long>,
private val reActivationKeywords: Set<String>, private val reActivationKeywords: Set<String>,
private val auditCommandPrefix: String, private val auditCommandPrefixes: Set<String>,
private val gracePeriodDays: Int, private val gracePeriodDays: Int,
private val expiryWarningDays: Int, private val expiryWarningDays: Int,
private val pollIntervalMinutes: Long, private val pollIntervalMinutes: Long,
private val selfId: Long, private val selfId: Long,
) : BaseModule(Modules.WHITELIST_AUDIT, moduleName), PersistentState<WhitelistAuditModule.AuditState> { // --- 可配置邮件模板(空 = 使用内置默认) ---
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 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 lastMsgRealId: Long = -1
private var lastMsgTime: Long = 0 private var lastMsgTime: Long = 0
private var lastAuditRealId: Long = -1 private var lastAuditRealId: Long = -1
private var lastAuditTime: Long = 0 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 { private val reactivationFilter by lazy {
TriggerMessageFilter( TriggerMessageFilter(
listOf( listOf(
@ -71,28 +79,24 @@ class WhitelistAuditModule(
listOf( listOf(
IgnoreSelfFilter(selfId), IgnoreSelfFilter(selfId),
NewMessageFilter { lastAuditTime to lastAuditRealId }, NewMessageFilter { lastAuditTime to lastAuditRealId },
KeywordFilter(setOf(auditCommandPrefix)), KeywordFilter(auditCommandPrefixes),
) )
) )
} }
override fun onLoad() { override fun onLoad() {
LoggerUtil.logger.info("[$name] 白名单审计模块已装载") dbCreateTableIfNeeded()
LoggerUtil.logger.info("[$name] 白名单审计模块已装载 (DB)")
LoggerUtil.logger.info("[$name] 白名单群: $whitelistGroupId, 主群: ${mainGroupPollingModule.targetGroupId}") LoggerUtil.logger.info("[$name] 白名单群: $whitelistGroupId, 主群: ${mainGroupPollingModule.targetGroupId}")
LoggerUtil.logger.info("[$name] 审计命令: $auditCommandPrefix") LoggerUtil.logger.info("[$name] 检查间隔: ${pollIntervalMinutes}分, 宽限期: ${gracePeriodDays}")
LoggerUtil.logger.info("[$name] 检查间隔: ${pollIntervalMinutes}分, 宽限期: ${gracePeriodDays}天, 过期警告: ${expiryWarningDays}天前")
LoggerUtil.logger.info("[$name] 邮件通知: ${mailModule != null}")
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope!!.launch { scope!!.launch {
delay(10_000) delay(10_000)
while (isActive && loaded) { while (isActive && loaded) {
try { try { runAuditCycle() }
runAuditCycle() catch (e: Exception) { LoggerUtil.logger.error("[$name] 审计周期异常", e) }
} catch (e: Exception) {
LoggerUtil.logger.error("[$name] 审计周期异常", e)
}
delay(pollIntervalMinutes * 60_000) delay(pollIntervalMinutes * 60_000)
} }
} }
@ -117,172 +121,198 @@ class WhitelistAuditModule(
} }
override suspend fun onUnload() { override suspend fun onUnload() {
saveState(auditState)
scope?.cancel() scope?.cancel()
LoggerUtil.logger.info("[$name] 白名单审计模块已卸载") 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<String, AuditRecord> {
val result = mutableMapOf<String, AuditRecord>()
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() { private suspend fun runAuditCycle() {
LoggerUtil.logger.info("[$name] 开始审计周期...") LoggerUtil.logger.info("[$name] 【自动审计】开始...")
val groupMembers = fetchWhitelistGroupMembers() ?: return val groupMembers = fetchWhitelistGroupMembers() ?: return
val groupMemberQqSet = groupMembers.map { it.userId }.toSet() val groupMemberQqSet = groupMembers.map { it.userId }.toSet()
LoggerUtil.logger.info("[$name] 白名单群成员数: ${groupMemberQqSet.size}")
val whitelistEntries = whitelistClient.listApproved() val whitelistEntries = whitelistClient.listApproved()
if (whitelistEntries.isEmpty()) { if (whitelistEntries.isEmpty()) { LoggerUtil.logger.info("[$name] 白名单通过数为0跳过"); return }
LoggerUtil.logger.info("[$name] 白名单通过数为0跳过")
return
}
LoggerUtil.logger.info("[$name] 白名单通过数: ${whitelistEntries.size}")
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val graceMs = gracePeriodDays * 24 * 60 * 60 * 1000L val graceMs = gracePeriodDays * 24 * 60 * 60 * 1000L
val warningMs = expiryWarningDays * 24 * 60 * 60 * 1000L val warningMs = expiryWarningDays * 24 * 60 * 60 * 1000L
val allRecords = dbLoadAll()
var actions = 0
for (entry in whitelistEntries) { for (entry in whitelistEntries) {
val qqLong = entry.qq.toLongOrNull() ?: continue val qqLong = entry.qq.toLongOrNull() ?: continue
if (qqLong in filterQqList) continue if (qqLong in filterQqList) continue
val key = entry.qq
if (qqLong in groupMemberQqSet) { if (qqLong in groupMemberQqSet) {
val key = entry.qq if (allRecords.containsKey(key) && !allRecords[key]!!.reactivated) {
if (auditState.entries.containsKey(key) && !auditState.entries[key]!!.reactivated) { dbDelete(key); actions++
auditState = auditState.copy(
entries = auditState.entries.toMutableMap().apply { remove(key) }
)
saveState(auditState)
LoggerUtil.logger.info("[$name] $qqLong 重新加入白名单群,清除审计标记") LoggerUtil.logger.info("[$name] $qqLong 重新加入白名单群,清除审计标记")
} }
continue continue
} }
val key = entry.qq val existing = allRecords[key]
val existing = auditState.entries[key]
when { when {
existing == null -> { existing == null -> {
LoggerUtil.logger.info("[$name] $qqLong (${entry.playerName}) 不在白名单群,执行拒绝") LoggerUtil.logger.info("[$name] [自动] $qqLong (${entry.playerName}) 不在白名单群,执行拒绝")
val rejected = whitelistClient.reject(entry.id) 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) { if (rejected) {
sendGroupNotification( dbUpsert(AuditRecord(qq = key, playerId = entry.id, playerName = entry.playerName, detectedTime = now, rejectedTime = now))
"白名单审计: ${entry.playerName}(QQ:$qqLong) 因退出白名单群已被拒绝\n" + sendEmail(qqLong, entry.playerName, "白名单已被拒绝", fmt(emailRejectTemplate, entry.playerName, gracePeriodDays))
"回复关键词即可在 ${gracePeriodDays}天内重新激活" actions++
)
sendEmail(
qqLong, entry.playerName,
"LTD白名单审计通知",
"你的白名单因退出白名单QQ群已被暂时拒绝。\n请在 ${gracePeriodDays} 天之内在主群发送关键词重新激活。"
)
} }
} }
existing.reactivated -> { existing.reactivated -> {
LoggerUtil.logger.info("[$name] $qqLong (${entry.playerName}) 再次不在白名单群,重新拒绝") LoggerUtil.logger.info("[$name] [自动] $qqLong (${entry.playerName}) 再次不在白名单群,重新拒绝")
whitelistClient.reject(entry.id) whitelistClient.reject(entry.id)
sendEmail( dbUpsert(existing.copy(detectedTime = now, rejectedTime = now, warningSentTime = 0, reactivated = false))
qqLong, entry.playerName, sendEmail(qqLong, entry.playerName, "LTD白名单审计通知", fmt(emailRejectTemplate, entry.playerName, gracePeriodDays))
"LTD白名单审计通知", actions++
"你的白名单再次因退出白名单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 -> { existing.rejectedTime > 0 -> {
val elapsed = now - existing.rejectedTime val elapsed = now - existing.rejectedTime
if (elapsed >= graceMs) { if (elapsed >= graceMs) {
LoggerUtil.logger.info("[$name] $qqLong (${entry.playerName}) 宽限期已过,执行删除") LoggerUtil.logger.info("[$name] [自动] $qqLong (${entry.playerName}) 宽限期已过,执行删除")
whitelistClient.remove(entry.id) whitelistClient.remove(entry.id); dbDelete(key); actions++
auditState = auditState.copy( sendEmail(qqLong, entry.playerName, "白名单已删除", fmt(emailRemovedTemplate, entry.playerName, 0))
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) { } else if (elapsed >= graceMs - warningMs && existing.warningSentTime == 0L) {
val remainDays = (graceMs - elapsed) / (24 * 60 * 60 * 1000) val remainDays = ((graceMs - elapsed) / (24 * 60 * 60 * 1000)).toInt()
LoggerUtil.logger.info("[$name] $qqLong (${entry.playerName}) 宽限期即将过期 (剩余${remainDays}天),发送警告") LoggerUtil.logger.info("[$name] [自动] $qqLong (${entry.playerName}) 宽限期即将过期 (剩余${remainDays}天)")
sendGroupNotification( sendEmail(qqLong, entry.playerName, "白名单即将过期", fmt(emailWarningTemplate, entry.playerName, remainDays))
"⚠️ 白名单审计: ${entry.playerName}(QQ:$qqLong) 宽限期仅剩${remainDays}\n" + dbUpsert(existing.copy(warningSentTime = now)); actions++
"请在主群发送关键词重新激活,否则将被永久删除"
)
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)
} }
} }
} }
} }
// 额外检查:已拒绝但不在 approved 列表中的用户状态已变API 不再返回)
// 扫尾:已拒绝但不在 approved 列表中的用户
val approvedQqSet = whitelistEntries.map { it.qq }.toSet() 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 (qq in approvedQqSet) continue
if (entry.rejectedTime == 0L) continue if (record.rejectedTime == 0L) continue
val elapsed = now - entry.rejectedTime val elapsed = now - record.rejectedTime
if (elapsed >= graceMs) { if (elapsed >= graceMs) {
LoggerUtil.logger.info("[$name] $qq (${entry.playerName}) 宽限期已过不在approved列表执行删除") LoggerUtil.logger.info("[$name] [自动] $qq (${record.playerName}) 宽限期已过不在approved列表执行删除")
whitelistClient.remove(entry.playerId) whitelistClient.remove(record.playerId); dbDelete(qq); actions++
auditState = auditState.copy( sendEmail(qq.toLong(), record.playerName, "白名单已删除", fmt(emailRemovedTemplate, record.playerName, 0))
entries = auditState.entries.toMutableMap().apply { remove(qq) } } else if (elapsed >= graceMs - warningMs && record.warningSentTime == 0L) {
)
saveState(auditState)
sendGroupNotification(
"白名单审计: ${entry.playerName}(QQ:$qq) 宽限期已过,白名单已删除"
)
sendEmail(
qq.toLong(), entry.playerName,
"LTD白名单已删除",
"你的白名单因宽限期已过已被永久删除。请重新申请白名单。"
)
} else if (elapsed >= graceMs - warningMs && entry.warningSentTime == 0L) {
val remainDays = ((graceMs - elapsed) / (24 * 60 * 60 * 1000)).toInt() val remainDays = ((graceMs - elapsed) / (24 * 60 * 60 * 1000)).toInt()
LoggerUtil.logger.info("[$name] $qq (${entry.playerName}) 宽限期即将过期 (剩余${remainDays}天)") LoggerUtil.logger.info("[$name] [自动] $qq (${record.playerName}) 宽限期即将过期 (剩余${remainDays}天)")
sendEmail( sendEmail(qq.toLong(), record.playerName, "白名单即将过期", fmt(emailWarningTemplate, record.playerName, remainDays))
qq.toLong(), entry.playerName, dbUpsert(record.copy(warningSentTime = now)); actions++
"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] 审计周期完成") LoggerUtil.logger.info("[$name] 【自动审计】完成, 处理 $actions")
} }
// ======== 重新激活关键词处理 ======== // ======== 重新激活关键词处理 ========
@ -292,41 +322,52 @@ class WhitelistAuditModule(
updateMsgState(msg) updateMsgState(msg)
val key = msg.userId.toString() 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 now = System.currentTimeMillis()
val graceMs = gracePeriodDays * 24 * 60 * 60 * 1000L val graceMs = gracePeriodDays * 24 * 60 * 60 * 1000L
if (entry.rejectedTime > 0 && now - entry.rejectedTime < graceMs) { if (record.rejectedTime > 0 && now - record.rejectedTime < graceMs) {
LoggerUtil.logger.info("[$name] ${msg.userId} (${entry.playerName}) 发送关键词,重新激活白名单") LoggerUtil.logger.info("[$name] ${msg.userId} (${record.playerName}) 发送关键词,重新激活白名单")
val approved = whitelistClient.approve(entry.playerId) val approved = whitelistClient.approve(record.playerId)
if (approved) { if (approved) {
val updated = entry.copy(reactivated = true) dbUpsert(record.copy(reactivated = true))
auditState = auditState.copy(
entries = auditState.entries.toMutableMap().apply { put(key, updated) }
)
saveState(auditState)
napCatClient.sendUnit( napCatClient.sendUnit(
SendGroupMsgRequest( SendGroupMsgRequest(
MessageElement.reply( MessageElement.reply(ID.long(msg.realId), "${record.playerName} 白名单已重新激活,请在${gracePeriodDays}天内重新加入白名单群"),
ID.long(msg.realId),
"${entry.playerName} 白名单已重新激活,请在${gracePeriodDays}天内重新加入白名单群"
),
ID.long(mainGroupPollingModule.targetGroupId) ID.long(mainGroupPollingModule.targetGroupId)
) )
) )
sendEmail( sendEmail(msg.userId, record.playerName, "白名单已重新激活", fmt(emailReActivatedTemplate, record.playerName, gracePeriodDays))
msg.userId, entry.playerName, } else {
"LTD白名单已重新激活", napCatClient.sendUnit(
"你的白名单已重新激活。请在 ${gracePeriodDays} 天之内重新加入白名单QQ群否则将再次被拒绝。" 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 { } else {
napCatClient.sendUnit( napCatClient.sendUnit(
SendGroupMsgRequest( SendGroupMsgRequest(
MessageElement.reply( MessageElement.reply(ID.long(msg.realId), "你的状态暂不需要重新激活(未被拒绝)"),
ID.long(msg.realId),
"${entry.playerName} 宽限期已过,无法重新激活,请重新申请白名单"
),
ID.long(mainGroupPollingModule.targetGroupId) ID.long(mainGroupPollingModule.targetGroupId)
) )
) )
@ -337,119 +378,92 @@ class WhitelistAuditModule(
private suspend fun handleAuditCommand(messages: List<MsgHistorySpecificMsg>) { private suspend fun handleAuditCommand(messages: List<MsgHistorySpecificMsg>) {
val msg = messages.maxByOrNull { it.time } ?: return val msg = messages.maxByOrNull { it.time } ?: return
lastAuditRealId = msg.realId lastAuditRealId = msg.realId
lastAuditTime = msg.time lastAuditTime = msg.time
if (msg.userId !in auditAllowedUsers) { if (msg.userId !in auditAllowedUsers) {
napCatClient.sendUnit( napCatClient.sendUnit(SendGroupMsgRequest(MessageElement.reply(ID.long(msg.realId), "你没有权限执行审计命令"), ID.long(whitelistGroupId)))
SendGroupMsgRequest(
MessageElement.reply(ID.long(msg.realId), "你没有权限执行审计命令"),
ID.long(whitelistGroupId)
)
)
return return
} }
lastAuditRealId = msg.realId LoggerUtil.logger.info("[$name] [手动审计] ${msg.userId} 触发")
lastAuditTime = msg.time
LoggerUtil.logger.info("[$name] ${msg.userId} 触发手动审计")
val summary = runAdhocAudit() val summary = runAdhocAudit()
LoggerUtil.logger.info("[$name] [手动审计] 完成")
napCatClient.sendUnit( napCatClient.sendUnit(SendGroupMsgRequest(MessageElement.reply(ID.long(msg.realId), summary), ID.long(whitelistGroupId)))
SendGroupMsgRequest(
MessageElement.reply(ID.long(msg.realId), summary),
ID.long(whitelistGroupId)
)
)
} }
private suspend fun runAdhocAudit(): String { private suspend fun runAdhocAudit(): String {
val groupMembers = fetchWhitelistGroupMembers() ?: return "获取群成员失败,审计中断" val groupMembers = fetchWhitelistGroupMembers() ?: return "获取群成员失败,审计中断"
val groupMemberQqSet = groupMembers.map { it.userId }.toSet() val groupMemberQqSet = groupMembers.map { it.userId }.toSet()
val whitelistEntries = whitelistClient.listApproved() val whitelistEntries = whitelistClient.listApproved()
if (whitelistEntries.isEmpty()) return "白名单通过数为0跳过审计" if (whitelistEntries.isEmpty()) return "白名单通过数为0跳过审计"
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val graceMs = gracePeriodDays * 24 * 60 * 60 * 1000L val graceMs = gracePeriodDays * 24 * 60 * 60 * 1000L
val allRecords = dbLoadAll()
var inGroupOk = 0 var inGroupOk = 0
var notInGroupNew = 0 var notInGroupNew = 0
val rejectedList = mutableListOf<String>() // 本次被拒绝的用户 val rejectedList = mutableListOf<String>()
var notInGroupGrace = mutableListOf<String>() var notInGroupGrace = mutableListOf<String>()
var notInGroupExpired = 0 var notInGroupExpired = 0
val expiredList = mutableListOf<String>() // 本次被删除的用户 val expiredList = mutableListOf<String>()
var inFilter = 0 var inFilter = 0
for (entry in whitelistEntries) { for (entry in whitelistEntries) {
val qqLong = entry.qq.toLongOrNull() ?: continue val qqLong = entry.qq.toLongOrNull() ?: continue
if (qqLong in filterQqList) { inFilter++; continue } if (qqLong in filterQqList) { inFilter++; continue }
val key = entry.qq
if (qqLong in groupMemberQqSet) { if (qqLong in groupMemberQqSet) {
inGroupOk++ inGroupOk++
// 清除已有标记 if (allRecords.containsKey(key) && !allRecords[key]!!.reactivated) {
val key = entry.qq dbDelete(key)
if (auditState.entries.containsKey(key)) { sendEmail(qqLong, entry.playerName, "LTD白名单审计通知", "你的白名单审计标记已清除(已重新加入白名单群)")
auditState = auditState.copy(
entries = auditState.entries.toMutableMap().apply { remove(key) }
)
} }
continue continue
} }
val key = entry.qq val existing = allRecords[key]
val existing = auditState.entries[key]
when { when {
existing == null -> { existing == null -> {
notInGroupNew++ notInGroupNew++
rejectedList.add("${entry.playerName}($qqLong)") rejectedList.add("${entry.playerName}($qqLong)")
whitelistClient.reject(entry.id) whitelistClient.reject(entry.id)
val newEntry = AuditEntry( dbUpsert(AuditRecord(qq = key, playerId = entry.id, playerName = entry.playerName, detectedTime = now, rejectedTime = now))
qq = entry.qq, sendEmail(qqLong, entry.playerName, "LTD白名单审计通知", fmt(emailRejectTemplate, entry.playerName, gracePeriodDays))
playerId = entry.id,
playerName = entry.playerName,
detectedTime = now,
rejectedTime = now
)
auditState = auditState.copy(
entries = auditState.entries.toMutableMap().apply { put(key, newEntry) }
)
} }
existing.rejectedTime > 0 && now - existing.rejectedTime >= graceMs -> { existing.rejectedTime > 0 && now - existing.rejectedTime >= graceMs -> {
notInGroupExpired++ notInGroupExpired++
expiredList.add("${entry.playerName}($qqLong)") expiredList.add("${entry.playerName}($qqLong)")
whitelistClient.remove(entry.id) whitelistClient.remove(entry.id)
auditState = auditState.copy( dbDelete(key)
entries = auditState.entries.toMutableMap().apply { remove(key) } sendEmail(qqLong, entry.playerName, "白名单已删除", fmt(emailRemovedTemplate, entry.playerName, 0))
)
} }
existing.rejectedTime > 0 -> { existing.rejectedTime > 0 -> {
val remainDays = ((graceMs - (now - existing.rejectedTime)) / (24 * 60 * 60 * 1000)).toInt() 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() 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 (qq in approvedQqSet) continue
if (entry.rejectedTime == 0L) continue if (record.rejectedTime == 0L) continue
val elapsed = now - entry.rejectedTime val elapsed = now - record.rejectedTime
if (elapsed >= graceMs) { if (elapsed >= graceMs) {
notInGroupExpired++ notInGroupExpired++
expiredList.add("${entry.playerName}($qq)") expiredList.add("${record.playerName}($qq)")
whitelistClient.remove(entry.playerId) whitelistClient.remove(record.playerId)
auditState = auditState.copy( dbDelete(qq)
entries = auditState.entries.toMutableMap().apply { remove(qq) } sendEmail(qq.toLong(), record.playerName, "白名单已删除", fmt(emailRemovedTemplate, record.playerName, 0))
)
} else { } else {
val remainDays = ((graceMs - elapsed) / (24 * 60 * 60 * 1000)).toInt() 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 { return buildString {
appendLine("审计完成") appendLine("审计完成")
@ -461,19 +475,13 @@ class WhitelistAuditModule(
rejectedList.forEach { appendLine("$it") } rejectedList.forEach { appendLine("$it") }
} }
if (notInGroupGrace.isNotEmpty()) { if (notInGroupGrace.isNotEmpty()) {
appendLine("宽限期中: \n${notInGroupGrace.joinToString()}") appendLine("宽限期中: ${notInGroupGrace.joinToString()}")
} }
if (notInGroupExpired > 0) { if (notInGroupExpired > 0) {
appendLine("已过期删除: $notInGroupExpired") appendLine("已过期删除: $notInGroupExpired")
expiredList.forEach { appendLine("$it") } expiredList.forEach { appendLine("$it") }
} }
if (inFilter > 0) { if (inFilter > 0) appendLine("过滤列表跳过: $inFilter")
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}")
// }
} }
} }
@ -481,37 +489,31 @@ class WhitelistAuditModule(
private suspend fun fetchWhitelistGroupMembers(): List<GroupMemberData>? { private suspend fun fetchWhitelistGroupMembers(): List<GroupMemberData>? {
return try { return try {
val event = napCatClient.send<GetGroupMemberListEvent>( napCatClient.send<GetGroupMemberListEvent>(GetGroupMemberListRequest(ID.long(whitelistGroupId), false))
GetGroupMemberListRequest(ID.long(whitelistGroupId), false) .data.filter { !it.isRobot }.map { GroupMemberData(it.userId, it.nickname) }
)
event.data.filter { !it.isRobot }.map {
GroupMemberData(it.userId, it.nickname)
}
} catch (e: Exception) { } catch (e: Exception) {
LoggerUtil.logger.error("[$name] 获取群成员列表失败", e) LoggerUtil.logger.error("[$name] 获取群成员列表失败", e); null
null
} }
} }
private data class GroupMemberData(val userId: Long, val nickname: String) private data class GroupMemberData(val userId: Long, val nickname: String)
// ======== 通知 ======== // ======== 通知 ========
private suspend fun sendGroupNotification(text: String) { private suspend fun sendGroupNotification(text: String) {
try { try {
napCatClient.sendUnit( napCatClient.sendUnit(SendGroupMsgRequest(listOf(MessageElement.text(text)), ID.long(mainGroupPollingModule.targetGroupId)))
SendGroupMsgRequest(
listOf(MessageElement.text(text)),
ID.long(mainGroupPollingModule.targetGroupId)
)
)
} catch (e: Exception) { } catch (e: Exception) {
LoggerUtil.logger.error("[$name] 发送群通知失败", e) LoggerUtil.logger.error("[$name] 发送群通知失败", e)
} }
} }
private fun sendEmail(qq: Long, playerName: String, subject: String, body: String) { 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 { try {
m.enqueue(mail { m.enqueue(mail {
to += "${qq}@qq.com" to += "${qq}@qq.com"
@ -519,65 +521,31 @@ class WhitelistAuditModule(
this.body = body this.body = body
isHtml = false isHtml = false
}) })
LoggerUtil.logger.info("[$name] 已入队邮件: $subject${qq}@qq.com")
} catch (e: Exception) { } catch (e: Exception) {
LoggerUtil.logger.error("[$name] 邮件入队失败", e) 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) { private fun updateMsgState(msg: MsgHistorySpecificMsg) {
lastMsgRealId = msg.realId lastMsgRealId = msg.realId
lastMsgTime = msg.time lastMsgTime = msg.time
} }
// ======== 持久化 ======== override fun info(): String = "白名单审计模块(DB) - 白名单群:$whitelistGroupId, 间隔:${pollIntervalMinutes}分, 宽限:${gracePeriodDays}"
@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 { override fun help(): String = buildString {
appendLine("白名单审计模块 - 定期检查白名单群成员并自动处理离群用户") appendLine("白名单审计模块(DB) - 定期检查白名单群成员并自动处理离群用户")
appendLine("检查间隔: ${pollIntervalMinutes}分钟") appendLine("检查间隔: ${pollIntervalMinutes}分钟")
appendLine("宽限期: ${gracePeriodDays}") appendLine("宽限期: ${gracePeriodDays}")
appendLine("过期警告: 到期前${expiryWarningDays}") appendLine("过期警告: 到期前${expiryWarningDays}")
appendLine("邮件通知: ${mailModule != null}") appendLine("数据存储: MySQL ltd_manager_bot.whitelist_audit")
appendLine("重新激活关键词: ${reActivationKeywords.joinToString()}") appendLine("重新激活关键词: ${reActivationKeywords.joinToString()}")
} }
} }

View File

@ -15,6 +15,11 @@ object ConfigInitializer {
"sql/invitation/query_code_ids.sql", "sql/invitation/query_code_ids.sql",
"sql/invitation/upsert_ascription.sql", "sql/invitation/upsert_ascription.sql",
"sql/whitelist/query_whitelist_record.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) { fun initConfig(fileName: String = "application.yml", configDir: String = "config", shouldExit: Boolean = true) {

View File

@ -15,7 +15,7 @@
# HELP_MODULE - 帮助模块 # HELP_MODULE - 帮助模块
# RCON_COMMAND_MODULE - 通用 RCON 命令执行 # RCON_COMMAND_MODULE - 通用 RCON 命令执行
# GITEA_WEBHOOK_MODULE - Gitea Webhook 事件通知 # GITEA_WEBHOOK_MODULE - Gitea Webhook 事件通知
# WHITELIST_AUDIT_MODULE - 白名单审计 (自动拒绝/提醒/过期) # WHITELIST_AUDIT_MODULE - 白名单审计 (DB存储自动/手动双模式)
# ============================================================================= # =============================================================================
module: module:
@ -53,6 +53,7 @@ module:
# command-prefix: "rcon" # QQ群触发前缀 # command-prefix: "rcon" # QQ群触发前缀
# admin-ids: [2561098830] # 允许执行RCON的QQ号 # admin-ids: [2561098830] # 允许执行RCON的QQ号
# rcon-timeout-sec: 5 # RCON超时(秒) # rcon-timeout-sec: 5 # RCON超时(秒)
# max-block-records: 200 # 最大阻止记录数
# command-blocklist: # 危险命令黑名单(前缀匹配) # command-blocklist: # 危险命令黑名单(前缀匹配)
# - "stop" # - "stop"
# - "restart" # - "restart"
@ -96,11 +97,14 @@ module:
# config: # config:
# self-id: 3327379836 # self-id: 3327379836
# # ---- 权限控制 ---- # # ---- 权限控制 ----
# filter-qq-list: [2561098830, 3327379836] # 永不自动拒绝的QQ # filter-qq-list: [2561098830, 3327379836] # 永不自动拒绝的保护列表
# audit-allowed-ids: [2561098830] # 可执行"审计"命令的QQ # audit-allowed-ids: [2561098830] # 允许执行手动审计的QQ
# # ---- 白名单群手动审计 ---- # # ---- 手动审计触发 ----
# whitelist-group-polling-dep-name: "whitelistGroup" # whitelist-group-polling-dep-name: "whitelistGroup"
# audit-command-prefix: "审计" # 白名单群触发关键词 # audit-command-prefixes: # 白名单群触发关键词(多选)
# - "审计"
# - "audit"
# - "核查"
# # ---- 重新激活 ---- # # ---- 重新激活 ----
# re-activation-keywords: # 主群重新激活关键词 # re-activation-keywords: # 主群重新激活关键词
# - "重新激活" # - "重新激活"
@ -109,6 +113,11 @@ module:
# # ---- 时间参数 ---- # # ---- 时间参数 ----
# grace-period-days: 7 # 宽限期(天) # grace-period-days: 7 # 宽限期(天)
# expiry-warning-days: 2 # 到期前N天警告 # expiry-warning-days: 2 # 到期前N天警告
# poll-interval-minutes: 60 # 审计间隔(分钟) # poll-interval-minutes: 60 # 自动审计间隔(分钟)
# # ---- 邮件通知 ---- # # ---- 邮件通知 ----
# enable-email: false # 是否启用邮件通知 # enable-email: false # 是否启用邮件通知
# # ---- 邮件模板 (不填=内置默认,支持 ${playerName} ${graceDays} ${remainDays} ${keywords}) ----
# email-reject-template: ""
# email-warning-template: ""
# email-removed-template: ""
# email-reactivated-template: ""

View File

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

View File

@ -0,0 +1 @@
DELETE FROM ltd_manager_bot.whitelist_audit WHERE qq = ?

View File

@ -0,0 +1,2 @@
SELECT qq, player_id, player_name, detected_time, rejected_time, warning_sent_time, reactivated
FROM ltd_manager_bot.whitelist_audit

View File

@ -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 = ?

View File

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

View File

@ -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<AuditState>(serialized)
assertTrue(deserialized.entries.isEmpty())
}
@Test
fun `AuditState round-trip with single entry`() {
val entry = AuditEntry(
qq = "123456789",
playerId = 42,
playerName = "Steve",
detectedTime = 1717891200000L,
rejectedTime = 1717891200000L,
warningSentTime = 0L,
reactivated = false,
)
val state = AuditState(entries = mapOf("123456789" to entry))
val serialized = json.encodeToString(state)
val deserialized = json.decodeFromString<AuditState>(serialized)
assertEquals(1, deserialized.entries.size)
val restored = deserialized.entries["123456789"]!!
assertEquals("123456789", restored.qq)
assertEquals(42, restored.playerId)
assertEquals("Steve", restored.playerName)
assertEquals(1717891200000L, restored.detectedTime)
assertEquals(1717891200000L, restored.rejectedTime)
assertEquals(0L, restored.warningSentTime)
assertFalse(restored.reactivated)
}
@Test
fun `AuditState round-trip with reactivated entry`() {
val entry = AuditEntry(
qq = "111",
playerId = 1,
playerName = "Alex",
detectedTime = 1000L,
rejectedTime = 1000L,
warningSentTime = 5000L,
reactivated = true,
)
val state = AuditState(entries = mapOf("111" to entry))
val serialized = json.encodeToString(state)
val deserialized = json.decodeFromString<AuditState>(serialized)
assertTrue(deserialized.entries["111"]!!.reactivated)
assertEquals(5000L, deserialized.entries["111"]!!.warningSentTime)
}
@Test
fun `AuditState round-trip with multiple entries`() {
val entries = mapOf(
"aaa" to AuditEntry(qq = "aaa", playerId = 1, playerName = "A", detectedTime = 1L),
"bbb" to AuditEntry(qq = "bbb", playerId = 2, playerName = "B", detectedTime = 2L, rejectedTime = 2L),
"ccc" to AuditEntry(qq = "ccc", playerId = 3, playerName = "C", detectedTime = 3L, rejectedTime = 3L, warningSentTime = 3L, reactivated = true),
)
val state = AuditState(entries = entries)
val serialized = json.encodeToString(state)
val deserialized = json.decodeFromString<AuditState>(serialized)
assertEquals(3, deserialized.entries.size)
assertFalse(deserialized.entries["aaa"]!!.reactivated)
assertEquals(0L, deserialized.entries["aaa"]!!.rejectedTime)
assertTrue(deserialized.entries["ccc"]!!.reactivated)
}
// ====== AuditEntry defaults ======
@Test
fun `AuditEntry defaults are zero-empty-false`() {
val entry = AuditEntry(
qq = "test",
playerId = 1,
playerName = "test",
detectedTime = 0L,
)
assertEquals(0L, entry.rejectedTime)
assertEquals(0L, entry.warningSentTime)
assertFalse(entry.reactivated)
}
@Test
fun `AuditEntry copy preserves fields`() {
val entry = AuditEntry(
qq = "qq", playerId = 5, playerName = "P",
detectedTime = 100L, rejectedTime = 200L, warningSentTime = 300L, reactivated = true,
)
val copied = entry.copy(warningSentTime = 400L, reactivated = false)
assertEquals("qq", copied.qq)
assertEquals(5, copied.playerId)
assertEquals(100L, copied.detectedTime)
assertEquals(200L, copied.rejectedTime)
assertEquals(400L, copied.warningSentTime)
assertFalse(copied.reactivated)
}
// ====== Deserialization with missing fields ======
@Test
fun `deserialize AuditState with missing optional fields uses defaults`() {
val jsonStr = """
{"entries": {"test": {"qq":"test","playerId":1,"playerName":"T","detectedTime":0}}}
""".trimIndent()
val state = json.decodeFromString<AuditState>(jsonStr)
val entry = state.entries["test"]!!
assertEquals(0L, entry.rejectedTime)
assertEquals(0L, entry.warningSentTime)
assertFalse(entry.reactivated)
}
@Test
fun `deserialize empty JSON object as AuditState`() {
val state = json.decodeFromString<AuditState>("{}")
assertTrue(state.entries.isEmpty())
}
}