pref: 改进模块&文档
This commit is contained in:
parent
d1afc51ad3
commit
c5ed59c979
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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白名单已重新激活` — 提醒加群 |
|
||||
| 首次检测离群 → 被拒绝 | `白名单已被拒绝` — 含宽限期和关键词提示 |
|
||||
| 重新激活后再离群 → 再次拒绝 | `白名单已被拒绝` — 同上 |
|
||||
| 进入警告窗口 | `白名单即将过期` — 剩余天数 |
|
||||
| 宽限期过 → 永久删除 | `白名单已删除` |
|
||||
| 关键词重新激活成功 | `白名单已重新激活` — 提醒加群 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ object ModuleFactory {
|
|||
val auditAllowedUsers = config.list<Long>("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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Long>,
|
||||
private val auditAllowedUsers: Set<Long>,
|
||||
private val reActivationKeywords: Set<String>,
|
||||
private val auditCommandPrefix: String,
|
||||
private val auditCommandPrefixes: Set<String>,
|
||||
private val gracePeriodDays: Int,
|
||||
private val expiryWarningDays: Int,
|
||||
private val pollIntervalMinutes: Long,
|
||||
private val selfId: Long,
|
||||
) : BaseModule(Modules.WHITELIST_AUDIT, moduleName), PersistentState<WhitelistAuditModule.AuditState> {
|
||||
// --- 可配置邮件模板(空 = 使用内置默认) ---
|
||||
private 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<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() {
|
||||
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
|
||||
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 (qqLong in groupMemberQqSet) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 额外检查:已拒绝但不在 approved 列表中的用户(状态已变,API 不再返回)
|
||||
val approvedQqSet = whitelistEntries.map { it.qq }.toSet()
|
||||
for ((qq, entry) in auditState.entries.toMap()) {
|
||||
if (qq in approvedQqSet) continue
|
||||
if (entry.rejectedTime == 0L) continue
|
||||
val elapsed = now - entry.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) {
|
||||
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] [自动] $qqLong (${entry.playerName}) 宽限期即将过期 (剩余${remainDays}天)")
|
||||
sendEmail(qqLong, entry.playerName, "白名单即将过期", fmt(emailWarningTemplate, entry.playerName, remainDays))
|
||||
dbUpsert(existing.copy(warningSentTime = now)); actions++
|
||||
}
|
||||
}
|
||||
LoggerUtil.logger.info("[$name] 审计周期完成")
|
||||
}
|
||||
}
|
||||
|
||||
// 扫尾:已拒绝但不在 approved 列表中的用户
|
||||
val approvedQqSet = whitelistEntries.map { it.qq }.toSet()
|
||||
for ((qq, record) in allRecords) {
|
||||
if (qq in approvedQqSet) continue
|
||||
if (record.rejectedTime == 0L) continue
|
||||
val elapsed = now - record.rejectedTime
|
||||
if (elapsed >= graceMs) {
|
||||
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 (${record.playerName}) 宽限期即将过期 (剩余${remainDays}天)")
|
||||
sendEmail(qq.toLong(), record.playerName, "白名单即将过期", fmt(emailWarningTemplate, record.playerName, remainDays))
|
||||
dbUpsert(record.copy(warningSentTime = now)); actions++
|
||||
}
|
||||
}
|
||||
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 now = System.currentTimeMillis()
|
||||
val graceMs = gracePeriodDays * 24 * 60 * 60 * 1000L
|
||||
val record = dbGet(key)
|
||||
|
||||
if (entry.rejectedTime > 0 && now - entry.rejectedTime < graceMs) {
|
||||
LoggerUtil.logger.info("[$name] ${msg.userId} (${entry.playerName}) 发送关键词,重新激活白名单")
|
||||
val approved = whitelistClient.approve(entry.playerId)
|
||||
if (approved) {
|
||||
val updated = entry.copy(reactivated = true)
|
||||
auditState = auditState.copy(
|
||||
entries = auditState.entries.toMutableMap().apply { put(key, updated) }
|
||||
)
|
||||
saveState(auditState)
|
||||
if (record == null) {
|
||||
napCatClient.sendUnit(
|
||||
SendGroupMsgRequest(
|
||||
MessageElement.reply(
|
||||
ID.long(msg.realId),
|
||||
"✅ ${entry.playerName} 白名单已重新激活,请在${gracePeriodDays}天内重新加入白名单群"
|
||||
),
|
||||
MessageElement.reply(ID.long(msg.realId), "你没有待处理的白名单审计记录,无需重新激活"),
|
||||
ID.long(mainGroupPollingModule.targetGroupId)
|
||||
)
|
||||
)
|
||||
sendEmail(
|
||||
msg.userId, entry.playerName,
|
||||
"LTD白名单已重新激活",
|
||||
"你的白名单已重新激活。请在 ${gracePeriodDays} 天之内重新加入白名单QQ群,否则将再次被拒绝。"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val graceMs = gracePeriodDays * 24 * 60 * 60 * 1000L
|
||||
|
||||
if (record.rejectedTime > 0 && now - record.rejectedTime < graceMs) {
|
||||
LoggerUtil.logger.info("[$name] ${msg.userId} (${record.playerName}) 发送关键词,重新激活白名单")
|
||||
val approved = whitelistClient.approve(record.playerId)
|
||||
if (approved) {
|
||||
dbUpsert(record.copy(reactivated = true))
|
||||
napCatClient.sendUnit(
|
||||
SendGroupMsgRequest(
|
||||
MessageElement.reply(ID.long(msg.realId), "✅ ${record.playerName} 白名单已重新激活,请在${gracePeriodDays}天内重新加入白名单群"),
|
||||
ID.long(mainGroupPollingModule.targetGroupId)
|
||||
)
|
||||
)
|
||||
sendEmail(msg.userId, record.playerName, "白名单已重新激活", fmt(emailReActivatedTemplate, record.playerName, gracePeriodDays))
|
||||
} else {
|
||||
napCatClient.sendUnit(
|
||||
SendGroupMsgRequest(
|
||||
MessageElement.reply(
|
||||
ID.long(msg.realId),
|
||||
"⏰ ${entry.playerName} 宽限期已过,无法重新激活,请重新申请白名单"
|
||||
),
|
||||
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), "你的状态暂不需要重新激活(未被拒绝)"),
|
||||
ID.long(mainGroupPollingModule.targetGroupId)
|
||||
)
|
||||
)
|
||||
|
|
@ -337,119 +378,92 @@ class WhitelistAuditModule(
|
|||
|
||||
private suspend fun handleAuditCommand(messages: List<MsgHistorySpecificMsg>) {
|
||||
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<String>() // 本次被拒绝的用户
|
||||
val rejectedList = mutableListOf<String>()
|
||||
var notInGroupGrace = mutableListOf<String>()
|
||||
var notInGroupExpired = 0
|
||||
val expiredList = mutableListOf<String>() // 本次被删除的用户
|
||||
val expiredList = mutableListOf<String>()
|
||||
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<GroupMemberData>? {
|
||||
return try {
|
||||
val event = napCatClient.send<GetGroupMemberListEvent>(
|
||||
GetGroupMemberListRequest(ID.long(whitelistGroupId), false)
|
||||
)
|
||||
event.data.filter { !it.isRobot }.map {
|
||||
GroupMemberData(it.userId, it.nickname)
|
||||
}
|
||||
napCatClient.send<GetGroupMemberListEvent>(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<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 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()}")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
9
src/main/resources/sql/whitelist/create_audit_table.sql
Normal file
9
src/main/resources/sql/whitelist/create_audit_table.sql
Normal 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
|
||||
)
|
||||
1
src/main/resources/sql/whitelist/delete_audit.sql
Normal file
1
src/main/resources/sql/whitelist/delete_audit.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DELETE FROM ltd_manager_bot.whitelist_audit WHERE qq = ?
|
||||
2
src/main/resources/sql/whitelist/query_all_audit.sql
Normal file
2
src/main/resources/sql/whitelist/query_all_audit.sql
Normal 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
|
||||
3
src/main/resources/sql/whitelist/query_audit_by_qq.sql
Normal file
3
src/main/resources/sql/whitelist/query_audit_by_qq.sql
Normal 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 = ?
|
||||
9
src/main/resources/sql/whitelist/upsert_audit.sql
Normal file
9
src/main/resources/sql/whitelist/upsert_audit.sql
Normal 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)
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user