feat: 添加邀请码申请模块
This commit is contained in:
parent
ba46c18fc1
commit
6e433b3377
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -43,3 +43,5 @@ bin/
|
|||
/logs/
|
||||
/config/
|
||||
/rcon_playerlist_state.json
|
||||
/invitation_codes_quarry_state.json.bak
|
||||
/invitation_codes_quarry_state.json
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/src/main/kotlin/top/r3944realms/ltdmanager/module/GroupRequestHandlerModule.kt" dialect="GenericSQL" />
|
||||
<file url="file://$PROJECT_DIR$/src/main/resources/init.sql" dialect="MySQL" />
|
||||
<file url="PROJECT" dialect="MySQL" />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -39,6 +39,10 @@ repositories {
|
|||
implementation("com.mysql:mysql-connector-j:8.0.33") // 使用MySQL 8.x驱动
|
||||
implementation("com.zaxxer:HikariCP:5.0.1") // 连接池
|
||||
|
||||
// 邮箱相关
|
||||
implementation("jakarta.mail:jakarta.mail-api:2.0.1") //API
|
||||
implementation("com.sun.mail:jakarta.mail:2.0.1") // 实现
|
||||
|
||||
// 日志系统
|
||||
implementation("org.slf4j:slf4j-api:2.0.7")
|
||||
implementation("org.apache.logging.log4j:log4j-slf4j2-impl:2.20.0")
|
||||
|
|
@ -70,7 +74,7 @@ repositories {
|
|||
}
|
||||
tasks {
|
||||
// ShadowJar 配置
|
||||
named<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar>("shadowJar") {
|
||||
named<ShadowJar>("shadowJar") {
|
||||
archiveClassifier.set("") // 去掉 -all 后缀
|
||||
mergeServiceFiles()
|
||||
manifest {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,40 @@
|
|||
# NapCat
|
||||
将回应抽象为event模型
|
||||
将请求抽象为request模型
|
||||
将请求抽象为request模型
|
||||
## InvitationCodesModule 模块设计时序表
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant Bot as 机器人
|
||||
participant DB as 数据库视图/表
|
||||
participant API as Token API
|
||||
|
||||
User->>Bot: 发送消息触发关键词
|
||||
Bot->>DB: 根据QQ查询 qualified_user_info 获取id, effective, is_used, token
|
||||
alt id不存在
|
||||
DB-->>Bot: 无记录
|
||||
Bot-->>User: 提示无法查询id,请联系管理员
|
||||
else id存在
|
||||
alt effective=1 && is_used=1
|
||||
Bot-->>User: 提示邀请码已使用,勿重复发送
|
||||
else effective=1 && is_used=0
|
||||
DB-->>Bot: 返回token
|
||||
Bot->>User: 构造邮件并发送token
|
||||
Bot-->>User: 邮件已发送
|
||||
else effective=0
|
||||
Bot->>API: 请求生成新Token
|
||||
alt API返回 success=false
|
||||
API-->>Bot: 返回错误信息
|
||||
Bot-->>User: 提示API错误消息
|
||||
else API返回 success=true
|
||||
API-->>Bot: 返回新Token
|
||||
Bot->>User: 构造邮件并发送新Token
|
||||
Bot-->>User: 邮件已发送
|
||||
Bot->>DB: 查询邀请码数据库获取 token_id
|
||||
DB-->>Bot: 返回 token_id
|
||||
Bot->>DB: 写入/更新 invitation_code_ascription 映射
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
````
|
||||
|
|
@ -3,4 +3,4 @@ org.gradle.downloadSources=false
|
|||
org.gradle.parallel=true
|
||||
org.gradle.degree_of_parallelism=16
|
||||
project_group=top.r3944realms.ltdmanager
|
||||
project_version=1.2-SNAPSHOT
|
||||
project_version=1.3-SNAPSHOT
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
package top.r3944realms.ltdmanager
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.*
|
||||
import top.r3944realms.ltdmanager.blessingskin.BlessingSkinClient
|
||||
import top.r3944realms.ltdmanager.core.mysql.MysqlHikariConnectPool
|
||||
import top.r3944realms.ltdmanager.module.ModuleManager
|
||||
import top.r3944realms.ltdmanager.napcat.NapCatClient
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
import java.sql.Connection
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object GlobalManager {
|
||||
// 单例作用域,可在模块中使用协程
|
||||
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private val isRunning = AtomicBoolean(true)
|
||||
// Hikari 数据源
|
||||
private val dataSource: MysqlHikariConnectPool by lazy {
|
||||
MysqlHikariConnectPool()
|
||||
|
|
@ -22,6 +22,9 @@ object GlobalManager {
|
|||
val napCatClient: NapCatClient by lazy {
|
||||
NapCatClient.create()
|
||||
}
|
||||
val blessingSkinClient: BlessingSkinClient by lazy {
|
||||
BlessingSkinClient.create()
|
||||
}
|
||||
|
||||
val moduleManager: ModuleManager by lazy { ModuleManager() }
|
||||
|
||||
|
|
@ -32,24 +35,58 @@ object GlobalManager {
|
|||
fun getConnection(): Connection {
|
||||
return dataSource.getConnection()
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭全局资源
|
||||
* 例如在应用退出时调用
|
||||
*/
|
||||
fun shutdown() {
|
||||
try {
|
||||
LoggerUtil.logger.info("关闭 NapCatClient")
|
||||
napCatClient.close()
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.warn("关闭 NapCatClient 失败", e)
|
||||
fun runBlockingMain(block: suspend () -> Unit) = runBlocking {
|
||||
// 注册全局关闭钩子
|
||||
LoggerUtil.addShutdownHook {
|
||||
shutdownResources()
|
||||
}
|
||||
// 启动逻辑交给外部传入
|
||||
block()
|
||||
|
||||
try {
|
||||
LoggerUtil.logger.info("关闭 Hikari 数据源")
|
||||
dataSource.close()
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.warn("关闭 Hikari 数据源失败", e)
|
||||
// 注册优雅关闭
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
shutdownGracefully()
|
||||
})
|
||||
|
||||
// 保持运行
|
||||
keepRunning()
|
||||
}
|
||||
|
||||
private fun keepRunning() = runBlocking {
|
||||
while (isRunning.get()) {
|
||||
delay(1000L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shutdownResources() {
|
||||
val resources = listOf(
|
||||
"NapCatClient" to { napCatClient.close() },
|
||||
"BlessingSkinClient" to { blessingSkinClient.close() },
|
||||
"Hikari 数据源" to { dataSource.close() }
|
||||
)
|
||||
|
||||
resources.forEach { (name, closer) ->
|
||||
try {
|
||||
LoggerUtil.syncInfo("关闭 $name")
|
||||
closer()
|
||||
LoggerUtil.syncInfo("$name 关闭完成")
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.syncError("关闭 $name 失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shutdownGracefully() = runBlocking {
|
||||
LoggerUtil.syncInfo("\n收到退出信号,正在停止所有模块...")
|
||||
|
||||
moduleManager.stopAllModules()
|
||||
|
||||
LoggerUtil.syncInfo("模块卸载完成,开始关闭资源...")
|
||||
|
||||
// 这会触发 LoggerUtil 中注册的关闭钩子
|
||||
LoggerUtil.shutdownGracefully()
|
||||
|
||||
isRunning.set(false)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
package top.r3944realms.ltdmanager.blessingskin
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
|
||||
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
|
||||
import top.r3944realms.ltdmanager.utils.Environment
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
import java.net.URLEncoder
|
||||
import java.util.*
|
||||
|
||||
class BlessingSkinClient private constructor() : AutoCloseable {
|
||||
private val client = HttpClient(CIO) {
|
||||
expectSuccess = false
|
||||
|
||||
// 安装 HttpTimeout 插件
|
||||
install(HttpTimeout) {
|
||||
// 默认超时配置,会被具体请求的配置覆盖
|
||||
requestTimeoutMillis = 30000
|
||||
connectTimeoutMillis = 10000
|
||||
socketTimeoutMillis = 15000
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val blessingSkinServerConfig = YamlConfigLoader.loadBlessingSkinServerConfig()
|
||||
|
||||
// 限流控制
|
||||
private val semaphore = Semaphore(5)
|
||||
private val requestMutex = Mutex()
|
||||
private val requestQueue = PriorityQueue<BlessingSkinQueueItem<BlessingSkinResponse, FailedBlessingSkinResponse>>(compareBy { it.priority })
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
init {
|
||||
startQueueProcessor()
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交请求
|
||||
*/
|
||||
suspend fun <T : BlessingSkinResponse, F : FailedBlessingSkinResponse> submitRequest(
|
||||
request: BlessingSkinRequest<T, F>,
|
||||
priority: Int = 5,
|
||||
maxRetries: Int = 3
|
||||
): ResponseResult<T, F> {
|
||||
val deferred = CompletableDeferred<ResponseResult<T, F>>()
|
||||
requestMutex.withLock {
|
||||
requestQueue.add(BlessingSkinQueueItem(request, deferred, priority, maxRetries, true))
|
||||
}
|
||||
return deferred.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动队列处理器
|
||||
*/
|
||||
private fun startQueueProcessor() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val item = requestMutex.withLock {
|
||||
requestQueue.poll()
|
||||
}
|
||||
if (item == null) {
|
||||
delay(50)
|
||||
continue
|
||||
}
|
||||
processQueueItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理队列项
|
||||
*/
|
||||
private suspend fun processQueueItem(item: BlessingSkinQueueItem<BlessingSkinResponse, FailedBlessingSkinResponse>) {
|
||||
semaphore.withPermit {
|
||||
val (request, deferred, _, maxRetries, expectsResponse) = item
|
||||
var attempt = 0
|
||||
var lastError: Exception? = null
|
||||
|
||||
while (attempt < maxRetries) {
|
||||
try {
|
||||
// 构建完整的URL,包括查询参数
|
||||
val fullUrl = buildFullUrlWithQueryParams(request)
|
||||
|
||||
if (!Environment.isProduction()) {
|
||||
LoggerUtil.logger.debug("发送请求到: $fullUrl")
|
||||
LoggerUtil.logger.debug("请求方法: {}", request.method())
|
||||
}
|
||||
|
||||
val response = client.request(fullUrl) {
|
||||
method = request.method()
|
||||
|
||||
|
||||
// 设置请求头
|
||||
headers {
|
||||
request.headers().invoke(this)
|
||||
}
|
||||
|
||||
// 对于非GET请求,设置请求体
|
||||
if (request.method() != HttpMethod.Get) {
|
||||
setBody(request.toJSON())
|
||||
}
|
||||
}
|
||||
|
||||
val responseText: String = response.body()
|
||||
|
||||
if (!Environment.isProduction()) {
|
||||
LoggerUtil.logger.debug("响应状态: {}", response.status)
|
||||
LoggerUtil.logger.debug("响应内容: $responseText")
|
||||
}
|
||||
|
||||
// 检查是否是HTML响应(重定向)
|
||||
if (isHtmlResponse(responseText)) {
|
||||
throw IllegalStateException("接收到HTML重定向响应,请检查API URL配置")
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
val result = request.getResponse(responseText, response.status)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(deferred as CompletableDeferred<ResponseResult<BlessingSkinResponse, FailedBlessingSkinResponse>>).complete(result)
|
||||
|
||||
return
|
||||
|
||||
} catch (e: Exception) {
|
||||
lastError = e
|
||||
attempt++
|
||||
|
||||
if (!request.shouldRetryOnFailure() || attempt >= maxRetries) {
|
||||
break
|
||||
}
|
||||
|
||||
LoggerUtil.logger.warn("BlessingSkin请求失败 (尝试 $attempt/$maxRetries): ${e.message}")
|
||||
delay((attempt * 1000L)) // 指数退避
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败或不应重试
|
||||
val errorResponse = createFailureResponse(lastError, request)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(deferred as CompletableDeferred<ResponseResult<BlessingSkinResponse, FailedBlessingSkinResponse>>).complete(
|
||||
ResponseResult.Failure(errorResponse)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整的URL,包含查询参数
|
||||
*/
|
||||
private fun buildFullUrlWithQueryParams(request: BlessingSkinRequest<*, *>): String {
|
||||
val baseUrl = blessingSkinServerConfig.url?.removeSuffix("/")
|
||||
val path = request.path().removePrefix("/")
|
||||
|
||||
// 构建基础URL
|
||||
val urlBuilder = StringBuilder("$baseUrl/$path")
|
||||
|
||||
// 添加查询参数
|
||||
val queryParams = request.queryParameters().entries.joinToString("&") { (key, value) ->
|
||||
"${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}"
|
||||
}
|
||||
|
||||
if (queryParams.isNotEmpty()) {
|
||||
urlBuilder.append("?").append(queryParams)
|
||||
}
|
||||
|
||||
return urlBuilder.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是HTML响应
|
||||
*/
|
||||
private fun isHtmlResponse(text: String): Boolean {
|
||||
return text.contains("<!DOCTYPE html>", ignoreCase = true) ||
|
||||
text.contains("<html>", ignoreCase = true) ||
|
||||
text.contains("Redirecting", ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建失败响应
|
||||
*/
|
||||
private fun createFailureResponse(
|
||||
exception: Exception?,
|
||||
request: BlessingSkinRequest<*, *>
|
||||
): FailedBlessingSkinResponse {
|
||||
return FailedBlessingSkinResponse.Default(
|
||||
failedResult = exception?.message ?: "未知错误",
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
scope.cancel()
|
||||
runBlocking {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(): BlessingSkinClient = BlessingSkinClient()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package top.r3944realms.ltdmanager.blessingskin
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
|
||||
|
||||
data class BlessingSkinQueueItem<out T:BlessingSkinResponse,out F:FailedBlessingSkinResponse>(
|
||||
val request: BlessingSkinRequest<T,F>,
|
||||
val deferred: CompletableDeferred<*>,
|
||||
var retries: Int,
|
||||
val priority: Int,
|
||||
val expectsResponse: Boolean // true 表示返回 BlessingSkinResponse, false 表示 Unit
|
||||
) : Comparable<BlessingSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>> {
|
||||
override fun compareTo(other: BlessingSkinQueueItem<@UnsafeVariance T, @UnsafeVariance F>): Int = priority.compareTo(other.priority)
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package top.r3944realms.ltdmanager.blessingskin.request
|
||||
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
|
||||
|
||||
@Serializable
|
||||
abstract class BlessingSkinRequest<out T : BlessingSkinResponse, out F : FailedBlessingSkinResponse>(
|
||||
@Transient
|
||||
open val createTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
/**
|
||||
* 转换为JSON字符串
|
||||
*/
|
||||
abstract fun toJSON(): String
|
||||
|
||||
/**
|
||||
* 获取API路径(不包含基础URL)
|
||||
* 例如: "invitation-codes/generate"
|
||||
*/
|
||||
abstract fun path(): String
|
||||
|
||||
/**
|
||||
* 获取HTTP方法,默认为GET(因为大多数API使用GET+查询参数)
|
||||
*/
|
||||
open fun method(): HttpMethod = HttpMethod.Get
|
||||
|
||||
/**
|
||||
* 自定义请求头
|
||||
*/
|
||||
open fun headers(): HeadersBuilder.() -> Unit = {
|
||||
// 默认添加Content-Type
|
||||
append(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
// 添加Accept头
|
||||
append(HttpHeaders.Accept, "application/json")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取查询参数(用于URL参数)
|
||||
* 例如: mapOf("token" to "abc123", "amount" to "1")
|
||||
*/
|
||||
open fun queryParameters(): Map<String, String> = emptyMap()
|
||||
|
||||
/**
|
||||
* 获取请求体参数(用于POST请求的JSON body)
|
||||
* 例如: mapOf("token" to "abc123", "amount" to 1)
|
||||
*/
|
||||
open fun bodyParameters(): Map<String, Any> = emptyMap()
|
||||
|
||||
/**
|
||||
* 获取请求体内容类型,默认为Application.Json
|
||||
*/
|
||||
open fun contentType(): ContentType = ContentType.Application.Json
|
||||
|
||||
/**
|
||||
* 解析响应JSON字符串
|
||||
* @param responseJson 响应JSON字符串
|
||||
* @param httpStatusCode HTTP状态码
|
||||
*/
|
||||
abstract fun getResponse(responseJson: String, httpStatusCode: HttpStatusCode): ResponseResult<T, F>
|
||||
|
||||
/**
|
||||
* 获取预期的成功响应类型名称(用于日志和调试)
|
||||
*/
|
||||
abstract fun expectedResponseType(): String
|
||||
|
||||
/**
|
||||
* 获取预期的失败响应类型名称(用于日志和调试)
|
||||
*/
|
||||
abstract fun expectedFailureType(): String
|
||||
|
||||
/**
|
||||
* 是否需要在失败时重试(默认重试)
|
||||
*/
|
||||
open fun shouldRetryOnFailure(): Boolean = true
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package top.r3944realms.ltdmanager.blessingskin.request.invitecode
|
||||
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import top.r3944realms.ltdmanager.blessingskin.request.BlessingSkinRequest
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.FailedBlessingSkinResponse
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse
|
||||
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
|
||||
import java.util.*
|
||||
|
||||
@Serializable
|
||||
class GenerateInvitationCodeRequest(
|
||||
@Transient
|
||||
val token: String? = null,
|
||||
@Transient
|
||||
val amount: Int? = 1,
|
||||
@Transient
|
||||
override val createTime: Long = System.currentTimeMillis()
|
||||
) : BlessingSkinRequest<InvitationCodeGenerationResponse, FailedBlessingSkinResponse.Default>() {
|
||||
|
||||
override fun toJSON(): String {
|
||||
// 对于GET请求,参数在URL中,body可以为空
|
||||
return "{}"
|
||||
}
|
||||
|
||||
override fun path(): String {
|
||||
return YamlConfigLoader.loadBlessingSkinServerConfig().invitationApi?.path ?: "api/invitation-codes/generate"
|
||||
}
|
||||
|
||||
override fun method(): HttpMethod {
|
||||
return HttpMethod.Post // 使用POST方法,参数在查询JSON中
|
||||
}
|
||||
|
||||
override fun queryParameters(): Map<String, String> {
|
||||
val params = mutableMapOf<String, String>()
|
||||
|
||||
// 添加token参数(如果提供)
|
||||
token?.let { params["token"] = it }
|
||||
|
||||
// 添加amount参数(如果提供)
|
||||
amount?.let { params["amount"] = it.toString() }
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
override fun headers(): HeadersBuilder.() -> Unit = {
|
||||
// 调用父类的默认headers
|
||||
super.headers().invoke(this)
|
||||
// 可以添加额外的header
|
||||
append("X-Request-ID", UUID.randomUUID().toString())
|
||||
}
|
||||
|
||||
override fun getResponse(
|
||||
responseJson: String,
|
||||
httpStatusCode: HttpStatusCode
|
||||
): ResponseResult<InvitationCodeGenerationResponse, FailedBlessingSkinResponse.Default> {
|
||||
return try {
|
||||
// 使用BlessingSkinResponse的伴生对象方法解析
|
||||
val response = BlessingSkinResponse.decode(responseJson) as? InvitationCodeGenerationResponse
|
||||
?: throw IllegalArgumentException("响应类型不匹配")
|
||||
|
||||
ResponseResult.Success(response)
|
||||
} catch (e: Exception) {
|
||||
ResponseResult.Failure(
|
||||
FailedBlessingSkinResponse.Default(
|
||||
failedResult = "解析响应失败: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun expectedResponseType(): String {
|
||||
return "invitation_code_generation"
|
||||
}
|
||||
|
||||
override fun expectedFailureType(): String {
|
||||
return "default_failure"
|
||||
}
|
||||
|
||||
override fun shouldRetryOnFailure(): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package top.r3944realms.ltdmanager.blessingskin.response
|
||||
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse
|
||||
|
||||
@Serializable
|
||||
abstract class BlessingSkinResponse (
|
||||
@Transient
|
||||
open val httpStatusCode: HttpStatusCode = HttpStatusCode.OK,
|
||||
@Transient
|
||||
open val createTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
companion object {
|
||||
// 通用的反序列化方法
|
||||
inline fun <reified T : BlessingSkinResponse> decode(jsonString: String): T {
|
||||
return json.decodeFromString(jsonString)
|
||||
}
|
||||
val json: Json by lazy {
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
serializersModule = SerializersModule {
|
||||
polymorphic(BlessingSkinResponse::class) {
|
||||
subclass(FailedBlessingSkinResponse.Default::class, FailedBlessingSkinResponse.Default.serializer())
|
||||
subclass(InvitationCodeGenerationResponse::class, InvitationCodeGenerationResponse.serializer())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package top.r3944realms.ltdmanager.blessingskin.response
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
|
||||
@Serializable
|
||||
abstract class FailedBlessingSkinResponse: BlessingSkinResponse() {
|
||||
abstract fun failedMessage(): String
|
||||
@Serializable
|
||||
class Default(@Transient val failedResult: String? = "未知错误") : FailedBlessingSkinResponse() {
|
||||
override fun failedMessage(): String = failedResult!!
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package top.r3944realms.ltdmanager.blessingskin.response
|
||||
|
||||
// 响应结果封装
|
||||
sealed class ResponseResult<out T : BlessingSkinResponse, out F : FailedBlessingSkinResponse> {
|
||||
data class Success<T : BlessingSkinResponse>(val response: T) : ResponseResult<T, Nothing>()
|
||||
data class Failure<F : FailedBlessingSkinResponse>(val failure: F) : ResponseResult<Nothing, F>()
|
||||
|
||||
/**
|
||||
* 检查是否成功
|
||||
*/
|
||||
fun isSuccess(): Boolean = this is Success
|
||||
|
||||
/**
|
||||
* 获取成功响应(如果存在)
|
||||
*/
|
||||
fun getSuccessResponse(): T? = (this as? Success)?.response
|
||||
|
||||
/**
|
||||
* 获取失败响应(如果存在)
|
||||
*/
|
||||
fun getFailureResponse(): F? = (this as? Failure)?.failure
|
||||
|
||||
/**
|
||||
* 成功时执行操作
|
||||
*/
|
||||
inline fun onSuccess(action: (T) -> Unit): ResponseResult<T, F> {
|
||||
if (this is Success) action(response)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败时执行操作
|
||||
*/
|
||||
inline fun onFailure(action: (F) -> Unit): ResponseResult<T, F> {
|
||||
if (this is Failure) action(failure)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package top.r3944realms.ltdmanager.blessingskin.response.invitecode
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.BlessingSkinResponse
|
||||
@Serializable
|
||||
data class InvitationCodeGenerationResponse(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val data: List<InvitationCode>? = null
|
||||
) : BlessingSkinResponse() {
|
||||
|
||||
@Serializable
|
||||
data class InvitationCode(
|
||||
val code: String,
|
||||
@SerialName("generated_at")
|
||||
val generatedAt: String,
|
||||
@SerialName("expires_at")
|
||||
val expiresAt: String
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package top.r3944realms.ltdmanager.core.config
|
||||
|
||||
import top.r3944realms.ltdmanager.utils.CryptoUtil
|
||||
import top.r3944realms.ltdmanager.utils.YamlUpdater
|
||||
|
||||
data class BlessingSkinServerConfig(
|
||||
var url: String ?= null,
|
||||
var invitationApi: BlessingSkinServerConfig.InvitationApiConfig?= null
|
||||
) {
|
||||
data class BlessingSkinServerConfig(
|
||||
var url: String? = null,
|
||||
var invitationApi: InvitationApiConfig? = null
|
||||
) {
|
||||
data class InvitationApiConfig(
|
||||
var path: String? = null,
|
||||
var encryptedToken: String? = null
|
||||
) {
|
||||
/**
|
||||
* 获取解密后的 token(如果未加密,返回原值)
|
||||
*/
|
||||
val decryptedToken: String?
|
||||
get() {
|
||||
if (encryptedToken == null) return null
|
||||
if (!isEncrypted()) return encryptedToken
|
||||
return try {
|
||||
val cipherText = encryptedToken!!.substring(4, encryptedToken!!.length - 1)
|
||||
CryptoUtil.decrypt(cipherText)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("API token 解密失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密 token(如果未加密)并写回 YAML
|
||||
*/
|
||||
fun encryptToken() {
|
||||
if (encryptedToken == null || isEncrypted()) return
|
||||
try {
|
||||
encryptedToken = "ENC(${CryptoUtil.encrypt(encryptedToken!!)})"
|
||||
YamlUpdater.updateYaml(
|
||||
YamlConfigLoader.configFilePath.toString(),
|
||||
"blessing-skin-server.invitation-api.encrypted-token",
|
||||
encryptedToken!!
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("API token 加密失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已加密
|
||||
*/
|
||||
private fun isEncrypted(): Boolean {
|
||||
return encryptedToken != null &&
|
||||
encryptedToken!!.startsWith("ENC(") &&
|
||||
encryptedToken!!.endsWith(")")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整 API URL
|
||||
*/
|
||||
fun getFullUrl(baseUrl: String?): String? {
|
||||
if (baseUrl == null || path.isNullOrBlank()) return null
|
||||
return baseUrl.trimEnd('/') + "/" + path!!.trimStart('/')
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "InvitationApiConfig(path=$path, token=***)"
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "BlessingSkinServerConfig(url=$url, invitationApi=$invitationApi)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package top.r3944realms.ltdmanager.core.config
|
||||
|
||||
import top.r3944realms.ltdmanager.utils.CryptoUtil
|
||||
import top.r3944realms.ltdmanager.utils.YamlUpdater
|
||||
|
||||
data class MailConfig(
|
||||
var host: String? = null, // SMTP 主机
|
||||
var port: Int? = 587, // 端口(25/465/587)
|
||||
var mailAddress: String? = null, // 邮箱账号
|
||||
var encryptedPassword: String? = null, // 加密后的密码或明文
|
||||
var auth: Boolean? = true, // 是否需要认证
|
||||
var tls: Boolean? = true, // 是否启用 STARTTLS
|
||||
var protocol: String = "smtp" // 协议,默认 smtp
|
||||
) {
|
||||
val decryptedPassword: String?
|
||||
get() {
|
||||
if (encryptedPassword == null) return null
|
||||
if (!isEncrypted()) return encryptedPassword
|
||||
try {
|
||||
val cipherText = encryptedPassword!!.substring(4, encryptedPassword!!.length - 1)
|
||||
return CryptoUtil.decrypt(cipherText)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("邮件密码解密失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密密码(如果未加密),并写回配置文件
|
||||
*/
|
||||
fun encryptPassword() {
|
||||
if (encryptedPassword == null || isEncrypted()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
encryptedPassword = "ENC(${CryptoUtil.encrypt(encryptedPassword!!)})"
|
||||
YamlUpdater.updateYaml(
|
||||
YamlConfigLoader.configFilePath.toString(),
|
||||
"mail.encrypted-password",
|
||||
this.encryptedPassword!!
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("邮件密码加密失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查密码是否已加密
|
||||
*/
|
||||
private fun isEncrypted(): Boolean {
|
||||
return encryptedPassword != null &&
|
||||
encryptedPassword!!.startsWith("ENC(") &&
|
||||
encryptedPassword!!.endsWith(")")
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "MailConfig(host=$host, port=$port, emailAddress=$mailAddress, password=***)"
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,9 @@ import top.r3944realms.ltdmanager.utils.YamlUpdater
|
|||
data class ToolConfig(
|
||||
var rcon: RconConfig = RconConfig()
|
||||
) {
|
||||
fun encryptPassword() {
|
||||
rcon.encryptPassword()
|
||||
}
|
||||
data class RconConfig(
|
||||
var mcRconToolPath: String? = null,
|
||||
var mcRconToolConfigPath: String? = null,
|
||||
|
|
@ -30,12 +33,12 @@ data class ToolConfig(
|
|||
/**
|
||||
* 加密 rcon 密码(如果未加密)
|
||||
*/
|
||||
fun encryptPassword(configFilePath: String) {
|
||||
fun encryptPassword() {
|
||||
if (rconPassword == null || isEncrypted()) return
|
||||
try {
|
||||
rconPassword = "ENC(${CryptoUtil.encrypt(rconPassword!!)})"
|
||||
YamlUpdater.updateYaml(
|
||||
configFilePath,
|
||||
YamlConfigLoader.configFilePath.toString(),
|
||||
"tools.rcon.rcon-password",
|
||||
rconPassword!!
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,9 +21,7 @@ object YamlConfigLoader {
|
|||
|
||||
// 初始化后加密(确保只执行一次)
|
||||
runCatching {
|
||||
_config.database.encryptPassword()
|
||||
_config.websocket.encryptToken()
|
||||
_config.http.encryptToken()
|
||||
ensureConfigEncrypted(_config)
|
||||
}.onFailure { e ->
|
||||
println("初始化加密失败: ${e.message}")
|
||||
e.printStackTrace()
|
||||
|
|
@ -33,6 +31,9 @@ object YamlConfigLoader {
|
|||
config?.database?.encryptPassword()
|
||||
config?.websocket?.encryptToken()
|
||||
config?.http?.encryptToken()
|
||||
config?.mail?.encryptPassword()
|
||||
config?.tools?.rcon?.encryptPassword()
|
||||
config?.blessingSkinServer?.invitationApi?.encryptToken()
|
||||
}
|
||||
private fun loadConfig(): ConfigWrapper {
|
||||
if (!Files.exists(configFilePath)) {
|
||||
|
|
@ -73,6 +74,8 @@ object YamlConfigLoader {
|
|||
fun loadHttpConfig(): HttpConfig = config.http
|
||||
fun loadModeConfig(): ModeConfig = config.mode
|
||||
fun loadToolConfig(): ToolConfig = config.tools
|
||||
fun loadMailConfig(): MailConfig = config.mail
|
||||
fun loadBlessingSkinServerConfig(): BlessingSkinServerConfig = config.blessingSkinServer
|
||||
data class ConfigWrapper(
|
||||
var database: DatabaseConfig = DatabaseConfig(),
|
||||
var crypto: CryptoConfig = CryptoConfig(),
|
||||
|
|
@ -80,6 +83,8 @@ object YamlConfigLoader {
|
|||
var websocket: WebsocketConfig = WebsocketConfig(),
|
||||
var http: HttpConfig = HttpConfig(),
|
||||
var tools: ToolConfig = ToolConfig(),
|
||||
var mail: MailConfig = MailConfig(),
|
||||
var blessingSkinServer: BlessingSkinServerConfig = BlessingSkinServerConfig(),
|
||||
|
||||
)
|
||||
}
|
||||
38
src/main/kotlin/top/r3944realms/ltdmanager/core/mail/Mail.kt
Normal file
38
src/main/kotlin/top/r3944realms/ltdmanager/core/mail/Mail.kt
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package top.r3944realms.ltdmanager.core.mail
|
||||
|
||||
data class Mail(
|
||||
val from: String? = null, // 发件人
|
||||
val to: List<String>, // 收件人(至少一个)
|
||||
val subject: String, // 邮件主题
|
||||
val body: String, // 邮件正文
|
||||
val isHtml: Boolean = false, // 是否 HTML
|
||||
val cc: List<String> = emptyList(), // 抄送
|
||||
val bcc: List<String> = emptyList() // 密送
|
||||
) {
|
||||
companion object {
|
||||
fun simple(
|
||||
to: String,
|
||||
subject: String,
|
||||
body: String,
|
||||
isHtml: Boolean = false
|
||||
): Mail = Mail(
|
||||
to = listOf(to),
|
||||
subject = subject,
|
||||
body = body,
|
||||
isHtml = isHtml
|
||||
)
|
||||
|
||||
fun multiple(
|
||||
to: List<String>,
|
||||
subject: String,
|
||||
body: String,
|
||||
isHtml: Boolean = false
|
||||
): Mail = Mail(
|
||||
to = to,
|
||||
subject = subject,
|
||||
body = body,
|
||||
isHtml = isHtml
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package top.r3944realms.ltdmanager.core.mail
|
||||
|
||||
class MailBuilder {
|
||||
var from: String? = null
|
||||
val to = mutableListOf<String>()
|
||||
var subject: String = ""
|
||||
var body: String = ""
|
||||
var isHtml: Boolean = false
|
||||
val cc = mutableListOf<String>()
|
||||
val bcc = mutableListOf<String>()
|
||||
|
||||
fun build() = Mail(from, to, subject, body, isHtml, cc, bcc)
|
||||
}
|
||||
|
||||
fun mail(block: MailBuilder.() -> Unit): Mail {
|
||||
val builder = MailBuilder()
|
||||
builder.block()
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
|
|
@ -1,29 +1,31 @@
|
|||
package top.r3944realms.ltdmanager
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import top.r3944realms.ltdmanager.module.GroupRequestHandlerModule
|
||||
import top.r3944realms.ltdmanager.module.RconPlayerListModule
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
|
||||
import top.r3944realms.ltdmanager.module.*
|
||||
|
||||
|
||||
fun main() = runBlocking {
|
||||
// 标记程序是否运行
|
||||
val isRunning = AtomicBoolean(true)
|
||||
|
||||
fun main() = GlobalManager.runBlockingMain {
|
||||
val groupId:Long = 538751386
|
||||
val selfQQId = 3327379836
|
||||
// 创建模块实例
|
||||
val groupModule = GroupRequestHandlerModule(
|
||||
client = GlobalManager.napCatClient,
|
||||
targetGroupId = 538751386
|
||||
targetGroupId = groupId
|
||||
)
|
||||
val groupMsgPollingModule = GroupMessagePollingModule(
|
||||
targetGroupId = groupId,
|
||||
pollIntervalMillis = 5_000L,
|
||||
msgHistoryCheck = 15
|
||||
)
|
||||
val toolConfig = YamlConfigLoader.loadToolConfig()
|
||||
val rconModule = RconPlayerListModule(
|
||||
pollIntervalMillis = 3_000L,
|
||||
timeout = 2_000L,
|
||||
groupMessagePollingModule = groupMsgPollingModule,
|
||||
rconTimeOut = 2_000L,
|
||||
cooldownMillis = 10_000L,
|
||||
targetGroupId = 538751386,
|
||||
selfId = 3327379836,
|
||||
selfId = selfQQId,
|
||||
selfNickName = "闲趣老土豆",
|
||||
rconPath = toolConfig.rcon.mcRconToolPath.toString(),
|
||||
rconConfigPath = toolConfig.rcon.mcRconToolConfigPath.toString(),
|
||||
keywords = setOf(
|
||||
//形容
|
||||
"土豆", "马铃薯", "Potato", "potato", "POTATO",
|
||||
|
|
@ -31,30 +33,39 @@ fun main() = runBlocking {
|
|||
//正经
|
||||
"列表","服务器状态", "TPS", "tps", "list", "List"
|
||||
)
|
||||
);
|
||||
)
|
||||
val mailConfig = YamlConfigLoader.loadMailConfig()
|
||||
val mailModule = MailModule(
|
||||
host = mailConfig.host.toString(),
|
||||
authToken = mailConfig.decryptedPassword.toString(),
|
||||
port = mailConfig.port!!,
|
||||
senderEmailAddress = mailConfig.mailAddress!!,
|
||||
)
|
||||
val blessingSkinConfig = YamlConfigLoader.loadBlessingSkinServerConfig()
|
||||
val invitationCodesModule = InvitationCodesModule(
|
||||
groupMessagePollingModule = groupMsgPollingModule,
|
||||
mailModule = mailModule,
|
||||
apiToken = blessingSkinConfig.invitationApi?.decryptedToken!!,
|
||||
selfId = selfQQId,
|
||||
keywords = setOf(
|
||||
"申请皮肤站注册邀请码",
|
||||
"申请土豆服务器注册邀请码",
|
||||
"申请LTD邀请码",
|
||||
"Apply for an invitation code"
|
||||
)
|
||||
)
|
||||
|
||||
// 注册模块到全局模块管理器
|
||||
GlobalManager.moduleManager.registerModule(groupModule)
|
||||
GlobalManager.moduleManager.registerModule(groupMsgPollingModule)
|
||||
GlobalManager.moduleManager.registerModule(rconModule)
|
||||
GlobalManager.moduleManager.registerModule(mailModule)
|
||||
GlobalManager.moduleManager.registerModule(invitationCodesModule)
|
||||
|
||||
// 加载模块
|
||||
GlobalManager.moduleManager.loadModule(groupModule.name)
|
||||
GlobalManager.moduleManager.loadModule(groupMsgPollingModule.name)
|
||||
GlobalManager.moduleManager.loadModule(rconModule.name)
|
||||
|
||||
|
||||
// 捕获 JVM 关闭信号,优雅退出
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
runBlocking {
|
||||
LoggerUtil.logger.info("\n收到退出信号,正在停止所有模块...")
|
||||
GlobalManager.moduleManager.stopAllModules() // 批量 stop
|
||||
LoggerUtil.logger.info("模块卸载完成,程序退出。")
|
||||
GlobalManager.shutdown()
|
||||
}
|
||||
isRunning.set(false)
|
||||
})
|
||||
|
||||
// 持续挂起,保持主线程运行
|
||||
while (isRunning.get()) {
|
||||
delay(1000L)
|
||||
}
|
||||
GlobalManager.moduleManager.loadModule(mailModule.name)
|
||||
GlobalManager.moduleManager.loadModule(invitationCodesModule.name)
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
package top.r3944realms.ltdmanager.module
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import top.r3944realms.ltdmanager.GlobalManager
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* 模块抽象基类
|
||||
|
|
@ -13,6 +16,10 @@ abstract class BaseModule {
|
|||
*/
|
||||
abstract val name: String
|
||||
|
||||
/**
|
||||
* 停止信号
|
||||
*/
|
||||
private val stopSignal = CompletableDeferred<Unit>()
|
||||
/**
|
||||
* 模块是否加载
|
||||
*/
|
||||
|
|
@ -28,6 +35,7 @@ abstract class BaseModule {
|
|||
if (!loaded) {
|
||||
loaded = true
|
||||
onLoad()
|
||||
LoggerUtil.logger.info("模块加载: $name")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -35,10 +43,12 @@ abstract class BaseModule {
|
|||
* 模块卸载
|
||||
* 清理资源,取消协程、关闭监听器等
|
||||
*/
|
||||
open fun unload() {
|
||||
open suspend fun unload() {
|
||||
if (loaded) {
|
||||
loaded = false
|
||||
onUnload()
|
||||
stopSignal.complete(Unit)
|
||||
LoggerUtil.logger.info("模块卸载: $name")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,18 +60,27 @@ abstract class BaseModule {
|
|||
/**
|
||||
* 模块卸载时的实际逻辑,由子类实现
|
||||
*/
|
||||
protected abstract fun onUnload()
|
||||
protected abstract suspend fun onUnload()
|
||||
/**
|
||||
* 可选的停止方法,模块内部协程等后台任务在这里被取消
|
||||
*/
|
||||
open suspend fun stop() {
|
||||
if (!loaded) return
|
||||
LoggerUtil.syncInfo("[$name] 收到停止命令")
|
||||
unload() // 默认实现直接卸载
|
||||
try {
|
||||
stopSignal.await()
|
||||
} catch (_: CancellationException) {}
|
||||
LoggerUtil.syncInfo("[$name] 模块已安全停止")
|
||||
}
|
||||
/**
|
||||
* 提供访问全局 NapCatClient 的快捷方式
|
||||
*/
|
||||
protected val napCatClient get() = GlobalManager.napCatClient
|
||||
|
||||
/**
|
||||
* 提供访问全局 blessingSkinClient 的快捷方式
|
||||
*/
|
||||
protected val blessingSkinClient get() = GlobalManager.blessingSkinClient
|
||||
/**
|
||||
* 获取数据库连接
|
||||
* 使用 try-with-resources 时会自动关闭
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
package top.r3944realms.ltdmanager.module
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import top.r3944realms.ltdmanager.napcat.data.ID
|
||||
import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent
|
||||
import top.r3944realms.ltdmanager.napcat.event.message.GetGroupMsgHistoryEvent
|
||||
import top.r3944realms.ltdmanager.napcat.request.message.GetGroupMsgHistoryRequest
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
|
||||
class GroupMessagePollingModule(
|
||||
val targetGroupId: Long,
|
||||
private val pollIntervalMillis: Long = 5_000L,
|
||||
private val msgHistoryCheck: Int = 15
|
||||
) : BaseModule() {
|
||||
|
||||
override val name: String = "MessagePollingModule"
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
// 用 Flow 存消息,其他模块可以订阅
|
||||
private val _messagesFlow = MutableSharedFlow<List<GetFriendMsgHistoryEvent.SpecificMsg>>(
|
||||
replay = 1, // 保留最近一份消息
|
||||
extraBufferCapacity = 1
|
||||
)
|
||||
val messagesFlow: SharedFlow<List<GetFriendMsgHistoryEvent.SpecificMsg>> = _messagesFlow.asSharedFlow()
|
||||
|
||||
override fun onLoad() {
|
||||
LoggerUtil.logger.info("[$name] 启动消息轮询 (群: $targetGroupId)")
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
scope!!.launch {
|
||||
while (isActive && loaded) {
|
||||
try {
|
||||
val event = napCatClient.send(
|
||||
GetGroupMsgHistoryRequest(
|
||||
count = msgHistoryCheck,
|
||||
groupId = ID.long(targetGroupId)
|
||||
)
|
||||
) as? GetGroupMsgHistoryEvent
|
||||
|
||||
val messages = event?.data?.messages ?: emptyList()
|
||||
LoggerUtil.logger.debug("[$name] 拉取到 ${messages.size} 条消息")
|
||||
_messagesFlow.emit(messages)
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.error("[$name] 拉取消息失败", e)
|
||||
}
|
||||
delay(pollIntervalMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onUnload() {
|
||||
LoggerUtil.logger.info("[$name] 模块卸载,停止轮询")
|
||||
scope?.cancel() // 取消协程
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -18,66 +18,53 @@ class GroupRequestHandlerModule(
|
|||
|
||||
override val name: String = "GroupRequestHandlerModule"
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
private val stopSignal = CompletableDeferred<Unit>()
|
||||
|
||||
override fun onLoad() {
|
||||
LoggerUtil.logger.info("模块[$name]已装载,目标群组: $targetGroupId,轮询间隔: ${pollIntervalMillis}ms")
|
||||
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
// 启动轮询协程
|
||||
scope.launch {
|
||||
scope!!.launch {
|
||||
LoggerUtil.logger.info("[$name] 轮询协程启动")
|
||||
try {
|
||||
while (isActive) {
|
||||
try {
|
||||
LoggerUtil.logger.debug("[$name] 开始轮询群组请求...")
|
||||
while (isActive && loaded) {
|
||||
try {
|
||||
LoggerUtil.logger.debug("[$name] 开始轮询群组请求...")
|
||||
|
||||
// 获取正常请求
|
||||
LoggerUtil.logger.debug("[$name] 获取正常群系统消息...")
|
||||
val systemEvent: GetGroupSystemMsgEvent =
|
||||
client.send(GetGroupSystemMsgRequest())
|
||||
LoggerUtil.logger.debug("[$name] 获取到 ${systemEvent.data.invitedRequest.size} 个邀请请求和 ${systemEvent.data.joinRequests.size} 个加群请求")
|
||||
// 获取正常请求
|
||||
LoggerUtil.logger.debug("[$name] 获取正常群系统消息...")
|
||||
val systemEvent: GetGroupSystemMsgEvent =
|
||||
client.send(GetGroupSystemMsgRequest())
|
||||
LoggerUtil.logger.debug("[$name] 获取到 ${systemEvent.data.invitedRequest.size} 个邀请请求和 ${systemEvent.data.joinRequests.size} 个加群请求")
|
||||
|
||||
handleEvent(systemEvent)
|
||||
handleEvent(systemEvent)
|
||||
|
||||
// 获取被过滤的请求
|
||||
LoggerUtil.logger.debug("[$name] 获取被过滤的群系统消息...")
|
||||
val ignoredEvent: GetGroupIgnoredNotifiesEvent =
|
||||
client.send(GetGroupIgnoredNotifiesRequest())
|
||||
LoggerUtil.logger.debug("[$name] 获取到 ${ignoredEvent.data.invitedRequest.size} 个被过滤的邀请请求和 ${ignoredEvent.data.joinRequests.size} 个被过滤的加群请求")
|
||||
// 获取被过滤的请求
|
||||
LoggerUtil.logger.debug("[$name] 获取被过滤的群系统消息...")
|
||||
val ignoredEvent: GetGroupIgnoredNotifiesEvent =
|
||||
client.send(GetGroupIgnoredNotifiesRequest())
|
||||
LoggerUtil.logger.debug("[$name] 获取到 ${ignoredEvent.data.invitedRequest.size} 个被过滤的邀请请求和 ${ignoredEvent.data.joinRequests.size} 个被过滤的加群请求")
|
||||
|
||||
handleEvent(ignoredEvent)
|
||||
handleEvent(ignoredEvent)
|
||||
|
||||
LoggerUtil.logger.debug("[$name] 本轮轮询完成,等待 ${pollIntervalMillis}ms 后继续")
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.error("[$name] 轮询执行异常", e)
|
||||
}
|
||||
delay(pollIntervalMillis)
|
||||
LoggerUtil.logger.debug("[$name] 本轮轮询完成,等待 ${pollIntervalMillis}ms 后继续")
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.error("[$name] 轮询执行异常", e)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
LoggerUtil.logger.info("[$name] 轮询协程收到取消信号")
|
||||
} finally {
|
||||
LoggerUtil.logger.info("[$name] 轮询协程退出,完成 stopSignal")
|
||||
stopSignal.complete(Unit)
|
||||
delay(pollIntervalMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
LoggerUtil.logger.info("[$name] 收到停止命令,开始关闭协程...")
|
||||
scope.cancel()
|
||||
LoggerUtil.logger.info("[$name] 等待协程退出...")
|
||||
stopSignal.await()
|
||||
LoggerUtil.logger.info("[$name] 协程已退出,卸载模块资源")
|
||||
onUnload()
|
||||
}
|
||||
|
||||
public override fun onUnload() {
|
||||
public override suspend fun onUnload() {
|
||||
LoggerUtil.logger.info("[$name] 已卸载")
|
||||
scope?.cancel()
|
||||
}
|
||||
|
||||
private suspend fun handleEvent(event: Any) {
|
||||
if (!loaded) return
|
||||
LoggerUtil.logger.debug("[$name] 处理群请求事件: ${event.javaClass.simpleName}")
|
||||
|
||||
val provider: GroupRequestProvider? = when (event) {
|
||||
|
|
@ -115,7 +102,7 @@ class GroupRequestHandlerModule(
|
|||
}
|
||||
|
||||
2, 3 -> {
|
||||
val reason = if (status == 2) "审核未通过" else "待审核"
|
||||
val reason = if (status == 3) "审核未通过,或请使用填写白名单所用QQ号加群" else "白名单待审核,请通过后再加"
|
||||
LoggerUtil.logger.info("[$name] 拒绝加群: groupId=${request.groupId}, invitorUin=${request.invitorUin}, status=$status, reason=$reason, requestId=${request.requestId}")
|
||||
val request1 = SetGroupAddRequestRequest(
|
||||
false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package top.r3944realms.ltdmanager.module
|
||||
|
||||
/**
|
||||
* 自定义异常类
|
||||
*/
|
||||
sealed class InvitationCodeException(message: String) : Exception(message) {
|
||||
|
||||
// 使用 public 构造函数
|
||||
class QuantityMismatchException(
|
||||
val expectedCount: Int,
|
||||
val actualCount: Int
|
||||
) : InvitationCodeException("数量不一致: 期望 $expectedCount, 实际 $actualCount")
|
||||
|
||||
// 添加其他类型的异常
|
||||
class DatabaseException(message: String) : InvitationCodeException(message)
|
||||
|
||||
class NetworkException(message: String) : InvitationCodeException(message)
|
||||
class ApiFailureException(message: String) : InvitationCodeException(message)
|
||||
}
|
||||
|
|
@ -0,0 +1,648 @@
|
|||
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.blessingskin.request.invitecode.GenerateInvitationCodeRequest
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.ResponseResult
|
||||
import top.r3944realms.ltdmanager.blessingskin.response.invitecode.InvitationCodeGenerationResponse
|
||||
import top.r3944realms.ltdmanager.core.mail.mail
|
||||
import top.r3944realms.ltdmanager.napcat.NapCatClient
|
||||
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.event.message.GetFriendMsgHistoryEvent
|
||||
import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest
|
||||
import top.r3944realms.ltdmanager.utils.HtmlTemplateUtil
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
import java.io.File
|
||||
import java.sql.Timestamp
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
|
||||
/*
|
||||
1. 订阅消息模块 (触发关键词, 注意过滤自己的消息,避免重复触发) [Done]
|
||||
2. 根据QQ号去查询机器人数据库中的视图表的id (此操作耗时,应设置针对指定用户的持久化冷却)
|
||||
3. id存在 [错误处理: id不存在提醒用户为无法查询到你的id,请联系管理员检查状态]
|
||||
i. effective 和 is_used 均为 1,
|
||||
则回复提醒你已经使用了你的邀请码,切勿重复发送
|
||||
|
||||
ii. effective 为 1 且 is_used 为 0
|
||||
则查询token_id对应的token记录然后构造发送邮件
|
||||
提醒用户邮件已发送
|
||||
|
||||
iii. effective 为 0
|
||||
则先通过API创建Token 获取来的响应 [错误处理: 当获取的json消息解析中success为false,则回复用户message中的错误信息]
|
||||
用Token去邀请码数据库中查询token_id,将其记录在机器人数据库对应白名单id映射token_id表中 [存在则更新,不存在则插入],
|
||||
然后按ii.执行
|
||||
|
||||
*/
|
||||
/*
|
||||
api格式 https://skins.r3944realms.top/api/invitation-codes/generate?token=XXXX&amount=1
|
||||
成功消息:
|
||||
{
|
||||
"success": true,
|
||||
"message": "邀请码生成成功",
|
||||
"data": [
|
||||
{
|
||||
"code": "XXXXXXX",
|
||||
"generated_at": "2025-08-29T09:36:36.910623Z",
|
||||
"expires_at": "2025-09-05T09:36:36.910506Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
失败消息:
|
||||
{
|
||||
"success": false,
|
||||
"message": "无效的 API Token"
|
||||
}
|
||||
*/
|
||||
|
||||
class InvitationCodesModule(
|
||||
private val groupMessagePollingModule: GroupMessagePollingModule,
|
||||
private val mailModule: MailModule,
|
||||
private val apiToken: String,
|
||||
private val selfId: Long,
|
||||
private val cooldownMillis: Long = 120_000,
|
||||
private val keywords: Set<String> = setOf("申请邀请码")
|
||||
) : BaseModule(), PersistentState<InvitationCodesModule.LastTriggerMapState> {
|
||||
|
||||
override val name: String = "InvitationCodesModule"
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
// 持久化文件(带锁 + 备份)
|
||||
private val stateFile = File("invitation_codes_quarry_state.json")
|
||||
private val stateBackupFile = File("invitation_codes_quarry_state.json.bak")
|
||||
private val fileLock = ReentrantLock()
|
||||
|
||||
private var lastTriggerMapState = loadState()
|
||||
override fun getStateFile(): File = stateFile
|
||||
override fun getState(): LastTriggerMapState = lastTriggerMapState
|
||||
override fun onLoad() {
|
||||
LoggerUtil.logger.info("[$name] 模块已装载,目标群组: ${groupMessagePollingModule.targetGroupId}")
|
||||
LoggerUtil.logger.info("[$name] 上次触发状态: lastTriggerMap=${lastTriggerMapState.map}")
|
||||
LoggerUtil.logger.info("[$name] 关键词列表: $keywords")
|
||||
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
scope!!.launch {
|
||||
LoggerUtil.logger.info("[$name] 轮询协程启动")
|
||||
groupMessagePollingModule.messagesFlow.collect { messages ->
|
||||
if (loaded) handleMessages(messages)
|
||||
}
|
||||
}
|
||||
|
||||
// 定时落盘(防止异常退出丢状态)
|
||||
scope!!.launch {
|
||||
while (isActive) {
|
||||
delay(60_000) // 每分钟保存一次
|
||||
saveState(lastTriggerMapState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onUnload() {
|
||||
LoggerUtil.logger.info("[$name] 模块卸载,保存状态...")
|
||||
saveState(lastTriggerMapState)
|
||||
LoggerUtil.logger.info("[$name] 模块卸载,取消协程...")
|
||||
scope?.cancel()
|
||||
LoggerUtil.logger.info("[$name] 模块已卸载完成")
|
||||
}
|
||||
|
||||
// =========================
|
||||
// 消息处理主流程
|
||||
// =========================
|
||||
private suspend fun handleMessages(messages: List<GetFriendMsgHistoryEvent.SpecificMsg>) {
|
||||
val triggerMsgs = filterTriggerMessages(messages)
|
||||
if (triggerMsgs.isEmpty()) return
|
||||
|
||||
try {
|
||||
val hadValidCodeButNotUsed = mutableListOf<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>()
|
||||
val needNewCode = mutableListOf<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>()
|
||||
|
||||
getIdAndSelectSituation(triggerMsgs, hadValidCodeButNotUsed, needNewCode)
|
||||
createAndSearchInvitationCodeIdsThenUpdateDate(needNewCode)
|
||||
hadVaildCodeButNotUseListHandler(hadValidCodeButNotUsed + needNewCode)
|
||||
} catch (e: Exception) {
|
||||
sendFailedMessage(napCatClient, text = "系统错误,请联系管理员: $e")
|
||||
} finally {
|
||||
saveState(lastTriggerMapState)
|
||||
}
|
||||
}
|
||||
|
||||
/** 过滤出符合条件的触发消息 */
|
||||
private fun filterTriggerMessages(messages: List<GetFriendMsgHistoryEvent.SpecificMsg>)
|
||||
: List<GetFriendMsgHistoryEvent.SpecificMsg> {
|
||||
|
||||
val filtered = messages.asSequence()
|
||||
.filter { msg ->
|
||||
msg.userId != selfId &&
|
||||
(msg.time > lastTriggerMapState.getLastTriggerTime(msg.userId) ||
|
||||
(msg.time == lastTriggerMapState.getLastTriggerTime(msg.userId)
|
||||
&& msg.realId > lastTriggerMapState.getLastTriggerRealId(msg.userId))) &&
|
||||
msg.message.any { seg ->
|
||||
seg.type == MessageType.Text &&
|
||||
seg.data.text?.let { text -> keywords.any { keyword -> text == keyword } } == true
|
||||
}
|
||||
}
|
||||
.groupBy { it.userId }
|
||||
.mapNotNull { (_, msgs) -> msgs.maxByOrNull { it.time } }
|
||||
.filter { runBlocking { filterCoolDownMessage(it) } }
|
||||
.toList()
|
||||
|
||||
if (filtered.isNotEmpty()) {
|
||||
LoggerUtil.logger.info("[$name] 待处理消息队列: $filtered")
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
private suspend fun getIdAndSelectSituation(msgs: List<GetFriendMsgHistoryEvent.SpecificMsg>,
|
||||
hadVaildCodeButNotUseList : MutableList<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>,
|
||||
needNewCodeList: MutableList<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>) {
|
||||
if (msgs.isEmpty()) return
|
||||
|
||||
val qqIds = msgs.map { it.userId }
|
||||
val placeholders = java.lang.String.join(",", Collections.nCopies(qqIds.size, "?"))
|
||||
// 修正SQL语句的表名引用
|
||||
val sql = """
|
||||
SELECT q.player_id, q.effective, q.is_used, q.qq
|
||||
FROM ltd_manager_bot.qualified_user_info q
|
||||
WHERE q.qq IN ($placeholders)
|
||||
""".trimIndent()
|
||||
try {
|
||||
getConnection().use { conn ->
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
// 设置所有参数
|
||||
for (i in qqIds.indices) {
|
||||
pstmt.setLong(i + 1, qqIds[i])
|
||||
}
|
||||
|
||||
pstmt.executeQuery().use { rs ->
|
||||
// 创建结果映射表
|
||||
val resultMap = mutableMapOf<Long, Triple<Long?, Boolean?, Boolean?>>()
|
||||
|
||||
while (rs.next()) {
|
||||
val qq = rs.getLong("qq")
|
||||
val playerId = rs.getLong("player_id")
|
||||
// 处理可能的null值
|
||||
val playerIdValue = if (rs.wasNull()) null else playerId
|
||||
val effective = rs.getBoolean("effective")
|
||||
val isUsed = rs.getBoolean("is_used")
|
||||
|
||||
resultMap[qq] = Triple(playerIdValue, effective, isUsed)
|
||||
}
|
||||
|
||||
// 分类处理每个消息
|
||||
for (msg in msgs) {
|
||||
val result = resultMap[msg.userId]
|
||||
|
||||
when {
|
||||
result == null -> {
|
||||
// 数据库中没有记录, 属于是异常
|
||||
LoggerUtil.logger.error("[$name] 无法查询该QQ号为:${msg.userId}的白名单ID,可能该用户非白名单成员")
|
||||
sendFailedMessage(napCatClient, msg.userId, msg.realId, msg.time, "无法查询到你的白名单应用id,请联系管理员检查状态,对应QQ号:${msg.userId}")
|
||||
}
|
||||
result.first != null && result.second == true && result.third == true -> {
|
||||
// 有player_id且已使用
|
||||
LoggerUtil.logger.info("[$name] 该QQ号为:${msg.userId}的白名单ID是${result.first},已使用对应激活码")
|
||||
sendMessage(napCatClient, msg.userId, msg.realId, msg.time, "你已经使用了你的邀请码,切勿重复发送")
|
||||
}
|
||||
result.first != null && result.second == true && result.third == false -> {
|
||||
// 有player_id、有效且未使用
|
||||
LoggerUtil.logger.info("[$name] 该QQ号为:${msg.userId}的白名单ID是${result.first},已有对应激活码但未使用")
|
||||
hadVaildCodeButNotUseList.add(result.first!! to msg)
|
||||
}
|
||||
result.first != null && result.second == false -> {
|
||||
// 没有player_id但有效,需要新code或处理
|
||||
needNewCodeList.add(result.first!! to msg)
|
||||
}
|
||||
else -> {
|
||||
//其它情况,异常,不应该出现
|
||||
sendFailedMessage(napCatClient, msg.userId, msg.realId, msg.time, "非法状态,请联系管理员:$result")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 更好的错误处理
|
||||
LoggerUtil.logger.error("[$name] 批量查询用户资格信息失败: ${e.message}", e)
|
||||
sendFailedMessage(napCatClient, text = "批量查询用户资格信息失败,请联系管理员: ${e.message}")
|
||||
}
|
||||
}
|
||||
private suspend fun hadVaildCodeButNotUseListHandler(list: List<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>) {
|
||||
if (list.isEmpty()) return
|
||||
|
||||
val whiteListIds = list.map { it.first }
|
||||
val placeholders = java.lang.String.join(",", Collections.nCopies(whiteListIds.size, "?"))
|
||||
|
||||
val sql = """
|
||||
SELECT q.player_id, q.player_name, q.token, q.expires_at
|
||||
FROM ltd_manager_bot.qualified_user_info q
|
||||
WHERE q.player_id IN ($placeholders)
|
||||
""".trimIndent()
|
||||
|
||||
try {
|
||||
getConnection().use { conn ->
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
for (i in whiteListIds.indices) {
|
||||
pstmt.setLong(i + 1, whiteListIds[i])
|
||||
}
|
||||
pstmt.executeQuery().use { rs ->
|
||||
val resultMap = mutableMapOf<Long, Triple<String?, String?, Timestamp?>>()
|
||||
while (rs.next()) {
|
||||
val playerId = rs.getLong("player_id")
|
||||
val playerName = rs.getString("player_name")
|
||||
val token = rs.getString("token")
|
||||
val tokenValue = if (rs.wasNull()) null else token
|
||||
val expiresAt = rs.getTimestamp("expires_at")
|
||||
val expiresAtValue = if (rs.wasNull()) null else expiresAt
|
||||
|
||||
resultMap[playerId] = Triple(playerName, tokenValue, expiresAtValue)
|
||||
}
|
||||
|
||||
// 直接遍历原始列表,不需要额外的映射
|
||||
for ((playerId, specificMsg) in list) {
|
||||
val mailRequestArgument = resultMap[playerId]
|
||||
|
||||
if (mailRequestArgument?.second != null && mailRequestArgument.third != null) {
|
||||
mailModule.enqueue(mail {
|
||||
to += specificMsg.userId.toString() + "@qq.com" // 直接使用 specificMsg
|
||||
// 根据需要配置邮件内容
|
||||
subject = "LTD邀请码邮件"
|
||||
isHtml = true
|
||||
body = HtmlTemplateUtil.tokenMailHtmlTemplate(
|
||||
mailRequestArgument.first!!,
|
||||
mailRequestArgument.second!!,
|
||||
mailRequestArgument.third!!,
|
||||
7,2025
|
||||
)
|
||||
})
|
||||
sendMessage(napCatClient, specificMsg.userId, specificMsg.realId, specificMsg.time,"已发送邮件注意,查收QQ邮箱")
|
||||
} else if (mailRequestArgument?.second != null) {
|
||||
mailModule.enqueue(mail {
|
||||
to += specificMsg.userId.toString() + "@qq.com" // 直接使用 specificMsg
|
||||
// 根据需要配置邮件内容
|
||||
subject = "LTD邀请码邮件"
|
||||
isHtml = true
|
||||
body = HtmlTemplateUtil.tokenMailHtmlTemplate(
|
||||
mailRequestArgument.first!!,
|
||||
mailRequestArgument.second!!,
|
||||
timeYear = 2025
|
||||
)
|
||||
})
|
||||
sendMessage(napCatClient, specificMsg.userId, specificMsg.realId, specificMsg.time,"已发送邮件注意,查收QQ邮箱")
|
||||
} else {
|
||||
LoggerUtil.logger.error("[$name] 异常情况,code为 空值")
|
||||
sendFailedMessage(napCatClient, specificMsg.userId, specificMsg.realId, specificMsg.time, "系统内部异常,请联系管理员")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.error("[$name] 查询已获取邀请码但未使用或未过期用户,Code信息失败: ${e.message}", e)
|
||||
sendFailedMessage(napCatClient, text = "查询已获取邀请码但未使用或未过期用户,Code信息失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendMessage(
|
||||
client: NapCatClient,
|
||||
qq: Long,
|
||||
realId: Long,
|
||||
time: Long,
|
||||
text: String = "正常消息"
|
||||
) {
|
||||
LoggerUtil.logger.info("[$name] 发送消息: realId=$realId, text=$text")
|
||||
|
||||
val request = SendGroupMsgRequest(
|
||||
MessageElement.reply(ID.long(realId), text),
|
||||
ID.long(groupMessagePollingModule.targetGroupId)
|
||||
)
|
||||
client.sendUnit(request)
|
||||
LoggerUtil.logger.info("[$name] 已发送 消息")
|
||||
|
||||
// 更新触发的最大 realId
|
||||
lastTriggerMapState = lastTriggerMapState.updateLastTrigger(qq, realId, time)
|
||||
}
|
||||
private suspend fun sendFailedMessage(
|
||||
client: NapCatClient,
|
||||
qq: Long? = null,
|
||||
realId: Long? = null,
|
||||
time: Long? = null,
|
||||
text: String = "失败消息"
|
||||
) {
|
||||
LoggerUtil.logger.info("[$name] 发送失败消息: realId=$realId, text=$text")
|
||||
if (realId != null && qq != null && time != null) {
|
||||
val request = SendGroupMsgRequest(
|
||||
MessageElement.reply(ID.long(realId), text),
|
||||
ID.long(groupMessagePollingModule.targetGroupId)
|
||||
)
|
||||
client.sendUnit(request)
|
||||
LoggerUtil.logger.info("[$name] 已发送 失败消息")
|
||||
|
||||
// 更新触发的最大 realId
|
||||
lastTriggerMapState = lastTriggerMapState.updateLastTrigger(qq, realId, time)
|
||||
} else {
|
||||
val request = SendGroupMsgRequest(
|
||||
listOf(MessageElement.text(text)),
|
||||
ID.long(groupMessagePollingModule.targetGroupId)
|
||||
)
|
||||
client.sendUnit(request)
|
||||
LoggerUtil.logger.info("[$name] 已发送 失败消息[无指定对象]")
|
||||
}
|
||||
}
|
||||
|
||||
// =========================
|
||||
// 冷却逻辑
|
||||
// =========================
|
||||
private suspend fun filterCoolDownMessage(msg: GetFriendMsgHistoryEvent.SpecificMsg): Boolean {
|
||||
val triggerDetail = lastTriggerMapState.map[msg.userId]
|
||||
val lastTriggerTime = triggerDetail?.time ?: -1L
|
||||
val lastCooldownRealId = triggerDetail?.lastCooldownRealId ?: -1L
|
||||
val nowSec = System.currentTimeMillis() / 1000 // 转成秒
|
||||
|
||||
if (lastTriggerTime == -1L || nowSec - lastTriggerTime >= cooldownMillis / 1000) {
|
||||
// 正常触发
|
||||
return true
|
||||
}
|
||||
|
||||
// 冷却中,如果本消息未发送过冷却提示
|
||||
if (msg.realId != lastCooldownRealId) {
|
||||
val remaining = ((cooldownMillis / 1000) - (nowSec - lastTriggerTime)).coerceAtLeast(1)
|
||||
val msgText = "⏳ 申请邀请码过于频繁,请稍后再试(剩余 $remaining 秒)"
|
||||
sendCooldownMessage(napCatClient, msg.userId, msg.realId, msgText)
|
||||
|
||||
// 记录这条消息已发送过冷却提示
|
||||
lastTriggerMapState = lastTriggerMapState.updateLastCooldownRealId(msg.userId, msg.realId)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun sendCooldownMessage(client: NapCatClient, qq: Long, realId: Long, msg: String) {
|
||||
val request = SendGroupMsgRequest(
|
||||
MessageElement.reply(ID.long(realId), msg),
|
||||
ID.long(groupMessagePollingModule.targetGroupId)
|
||||
)
|
||||
client.sendUnit(request)
|
||||
lastTriggerMapState = lastTriggerMapState.updateLastTrigger(qq, realId, -1)
|
||||
}
|
||||
private suspend fun createAndSearchInvitationCodeIdsThenUpdateDate(
|
||||
needNewTokenIdAndMsgPairs: List<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>,
|
||||
) {
|
||||
if (needNewTokenIdAndMsgPairs.isEmpty()) return
|
||||
|
||||
try {
|
||||
// 1. 创建邀请码
|
||||
val invitationCodes = createInvitationCodes(needNewTokenIdAndMsgPairs.size)
|
||||
|
||||
// 2. 验证数量匹配
|
||||
validateCodeCountMatch(invitationCodes, needNewTokenIdAndMsgPairs)
|
||||
|
||||
// 3. 获取邀请码ID
|
||||
val codeToIdMap = getInvitationCodeIds(invitationCodes!!.map { it.code })
|
||||
|
||||
// 4. 更新或插入关联关系
|
||||
updateInvitationCodeAscription(needNewTokenIdAndMsgPairs.map { it.first }, codeToIdMap.values.toList())
|
||||
|
||||
} catch (e: Exception) {
|
||||
handleCreationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. 创建邀请码
|
||||
*/
|
||||
private suspend fun createInvitationCodes(amount: Int): List<InvitationCodeGenerationResponse.InvitationCode>? {
|
||||
return try {
|
||||
val response = blessingSkinClient.submitRequest(
|
||||
GenerateInvitationCodeRequest(amount = amount, token = apiToken)
|
||||
)
|
||||
|
||||
when (response) {
|
||||
is ResponseResult.Success -> {
|
||||
if (response.response.success) {
|
||||
response.response.data
|
||||
} else {
|
||||
LoggerUtil.logger.warn("[$name] API返回失败: ${response.response.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
is ResponseResult.Failure -> {
|
||||
LoggerUtil.logger.warn("[$name] 创建邀请码失败: ${response.failure.failedResult}")
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.error("[$name] 创建邀请码异常", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. 验证数量匹配
|
||||
*/
|
||||
private fun validateCodeCountMatch(
|
||||
invitationCodes: List<InvitationCodeGenerationResponse.InvitationCode>?,
|
||||
needNewTokenIdAndMsgPairs: List<Pair<Long, GetFriendMsgHistoryEvent.SpecificMsg>>
|
||||
) {
|
||||
if (invitationCodes == null) {
|
||||
throw InvitationCodeException.ApiFailureException("获取邀请码请求失败")
|
||||
}
|
||||
|
||||
if (invitationCodes.size != needNewTokenIdAndMsgPairs.size) {
|
||||
throw InvitationCodeException.QuantityMismatchException(
|
||||
expectedCount = needNewTokenIdAndMsgPairs.size,
|
||||
actualCount = invitationCodes.size
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. 获取邀请码ID
|
||||
*/
|
||||
private fun getInvitationCodeIds(invitationCodes: List<String>): Map<String, Long> {
|
||||
if (invitationCodes.isEmpty()) return emptyMap()
|
||||
|
||||
val placeholders = invitationCodes.joinToString(",") { "?" }
|
||||
val sql = """
|
||||
SELECT i.id, i.code
|
||||
FROM blessingskin.invitation_codes i
|
||||
WHERE i.code IN ($placeholders)
|
||||
""".trimIndent()
|
||||
|
||||
return getConnection().use { conn ->
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
// 设置参数
|
||||
invitationCodes.forEachIndexed { index, code ->
|
||||
pstmt.setString(index + 1, code)
|
||||
}
|
||||
|
||||
val resultMap = mutableMapOf<String, Long>()
|
||||
pstmt.executeQuery().use { rs ->
|
||||
while (rs.next()) {
|
||||
val id = rs.getLong("id")
|
||||
val code = rs.getString("code")
|
||||
resultMap[code] = id
|
||||
}
|
||||
}
|
||||
resultMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. 更新或插入关联关系
|
||||
*/
|
||||
private fun updateInvitationCodeAscription(playerIds: List<Long>, codeIds: List<Long>) {
|
||||
if (playerIds.size != codeIds.size) {
|
||||
throw IllegalStateException("playerIds和codeIds数量不匹配: ${playerIds.size} vs ${codeIds.size}")
|
||||
}
|
||||
|
||||
if (playerIds.isEmpty()) return
|
||||
|
||||
val placeholders = playerIds.joinToString(",") { "(?, ?)" }
|
||||
val sql = """
|
||||
INSERT INTO ltd_manager_bot.invitation_code_ascription (id, token_id)
|
||||
VALUES $placeholders
|
||||
ON DUPLICATE KEY UPDATE token_id = VALUES(token_id)
|
||||
""".trimIndent()
|
||||
|
||||
getConnection().use { conn ->
|
||||
conn.prepareStatement(sql).use { pstmt ->
|
||||
var paramIndex = 1
|
||||
for (i in playerIds.indices) {
|
||||
pstmt.setLong(paramIndex++, playerIds[i])
|
||||
pstmt.setLong(paramIndex++, codeIds[i])
|
||||
}
|
||||
|
||||
val affectedRows = pstmt.executeUpdate()
|
||||
LoggerUtil.logger.debug("[$name] 更新了 $affectedRows 条关联记录")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. 错误处理
|
||||
*/
|
||||
private suspend fun handleCreationError(e: Exception) {
|
||||
when (e) {
|
||||
is InvitationCodeException -> {
|
||||
LoggerUtil.logger.error("[$name] ${e.message}")
|
||||
if (e is InvitationCodeException.QuantityMismatchException) {
|
||||
// 数量不匹配的特殊处理
|
||||
handleQuantityMismatch(e.expectedCount, e.actualCount)
|
||||
} else {
|
||||
sendFailedMessage(napCatClient, text = "邀请码创建失败,请联系管理员")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LoggerUtil.logger.error("[$name] 捕获异常", e)
|
||||
sendFailedMessage(napCatClient, text = "系统内部问题,请联系管理员")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 数量不匹配的特殊处理
|
||||
*/
|
||||
private suspend fun handleQuantityMismatch(expectedCount: Int, actualCount: Int) {
|
||||
LoggerUtil.logger.error(
|
||||
"[$name] 数量不一致BUG,期望: $expectedCount, 实际: $actualCount"
|
||||
)
|
||||
sendFailedMessage(napCatClient, text = "系统内部BUG,请联系管理员")
|
||||
// TODO: 清理已创建的邀请码
|
||||
cleanupCreatedInvitationCodes(actualCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理已创建的邀请码(TODO实现)
|
||||
*/
|
||||
private fun cleanupCreatedInvitationCodes(createdCount: Int) {
|
||||
// 实现清理逻辑,删除多余的邀请码
|
||||
LoggerUtil.logger.warn("[$name] 需要清理 $createdCount 个邀请码")
|
||||
}
|
||||
|
||||
|
||||
// =========================
|
||||
// 状态持久化
|
||||
// =========================
|
||||
@Serializable
|
||||
data class LastTriggerMapState(
|
||||
val map: Map<Long, TriggerDetail> = emptyMap()
|
||||
) {
|
||||
fun getLastTriggerTime(qq: Long): Long = map[qq]?.time ?: -1
|
||||
fun getLastTriggerRealId(qq: Long): Long = map[qq]?.realId ?: -1
|
||||
|
||||
fun updateLastTrigger(qq: Long, realId: Long, time: Long = -1): LastTriggerMapState {
|
||||
val old = map[qq]
|
||||
val newTime = if (time != -1L) time else old?.time ?: -1
|
||||
val newMap = map.toMutableMap().apply {
|
||||
put(qq, TriggerDetail(realId, newTime))
|
||||
}
|
||||
return copy(map = newMap)
|
||||
}
|
||||
fun updateLastCooldownRealId(qq: Long, realId: Long): LastTriggerMapState {
|
||||
val old = map[qq]
|
||||
val newMap = map.toMutableMap().apply {
|
||||
put(qq, TriggerDetail(
|
||||
realId = old?.realId ?: -1,
|
||||
time = old?.time ?: -1,
|
||||
lastCooldownRealId = realId
|
||||
))
|
||||
}
|
||||
return copy(map = newMap)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TriggerDetail(val realId : Long, val time: Long, val lastCooldownRealId: Long = -1L )
|
||||
|
||||
override fun loadState(): LastTriggerMapState {
|
||||
return try {
|
||||
if (!stateFile.exists()) {
|
||||
LoggerUtil.logger.info("[$name] 状态文件不存在,使用默认值")
|
||||
return LastTriggerMapState()
|
||||
}
|
||||
val content = stateFile.readText()
|
||||
val state = Json.decodeFromString<LastTriggerMapState>(content)
|
||||
LoggerUtil.logger.info("[$name] 成功加载状态: ${state.map}, 文件路径=${stateFile.absolutePath}")
|
||||
state
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.warn("[$name] 读取状态失败,尝试从备份恢复", e)
|
||||
try {
|
||||
if (stateBackupFile.exists()) {
|
||||
val backup = stateBackupFile.readText()
|
||||
val state = Json.decodeFromString<LastTriggerMapState>(backup)
|
||||
LoggerUtil.logger.info("[$name] 成功从备份恢复状态: ${state.map}")
|
||||
state
|
||||
} else {
|
||||
LastTriggerMapState()
|
||||
}
|
||||
} catch (e2: Exception) {
|
||||
LoggerUtil.logger.error("[$name] 备份也损坏,使用默认值", e2)
|
||||
LastTriggerMapState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun saveState(state: LastTriggerMapState) {
|
||||
fileLock.withLock {
|
||||
try {
|
||||
val json = Json.encodeToString(state)
|
||||
// 先写备份
|
||||
if (stateFile.exists()) stateFile.copyTo(stateBackupFile, overwrite = true)
|
||||
// 写入新文件
|
||||
stateFile.writeText(json)
|
||||
LoggerUtil.logger.info("[$name] 已保存状态: ${state.map}, 文件路径=${stateFile.absolutePath}")
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.error("[$name] 保存状态失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
145
src/main/kotlin/top/r3944realms/ltdmanager/module/MailModule.kt
Normal file
145
src/main/kotlin/top/r3944realms/ltdmanager/module/MailModule.kt
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package top.r3944realms.ltdmanager.module
|
||||
|
||||
import jakarta.mail.*
|
||||
import jakarta.mail.internet.InternetAddress
|
||||
import jakarta.mail.internet.MimeMessage
|
||||
import top.r3944realms.ltdmanager.core.mail.Mail
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
import java.util.*
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class MailModule(
|
||||
private val protocol: String = "SMTP",
|
||||
private val host: String,
|
||||
private val port: Int,
|
||||
private val senderEmailAddress: String,
|
||||
private val authToken: String,
|
||||
private val enableAuth: Boolean = true,
|
||||
private val enableTLS: Boolean = true,
|
||||
private val intervalMillis: Long = 2000L // 每封邮件之间的间隔(默认 2s)
|
||||
) : BaseModule() {
|
||||
|
||||
override val name: String = "MailModule"
|
||||
|
||||
private lateinit var session: Session
|
||||
private val queue = LinkedBlockingQueue<Mail>() // 邮件队列
|
||||
private var workerThread: Thread? = null
|
||||
@Volatile private var running = false
|
||||
|
||||
override fun onLoad() {
|
||||
LoggerUtil.logger.info("[$name] 模块加载中,初始化邮件会话...")
|
||||
/*
|
||||
163 邮箱(以及大多数 SMTP 服务商)区别是:
|
||||
|
||||
465 👉 “隐式 SSL”,必须启用 mail.smtp.ssl.enable=true。
|
||||
|
||||
587 👉 “明文 + STARTTLS”,必须启用 mail.smtp.starttls.enable=true。
|
||||
|
||||
而注释中 onLoad() 写死了:
|
||||
|
||||
put("mail.smtp.starttls.enable", enableTLS)
|
||||
|
||||
所以当用 465 端口时,服务端要求立即握手 SSL,但程序还在用明文 STARTTLS,直接就被 EOF 掉了。
|
||||
* */
|
||||
// val props = Properties().apply {
|
||||
// put("mail.transport.protocol", protocol)
|
||||
// put("mail.smtp.host", host)
|
||||
// put("mail.smtp.port", port)
|
||||
// put("mail.smtp.auth", enableAuth)
|
||||
// put("mail.smtp.starttls.enable", enableTLS)
|
||||
// }
|
||||
val props = Properties().apply {
|
||||
put("mail.transport.protocol", protocol)
|
||||
put("mail.smtp.host", host)
|
||||
put("mail.smtp.port", port)
|
||||
put("mail.smtp.auth", enableAuth)
|
||||
|
||||
when (port) {
|
||||
465 -> {
|
||||
// 隐式 SSL
|
||||
put("mail.smtp.ssl.enable", "true")
|
||||
put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory")
|
||||
put("mail.smtp.socketFactory.port", port.toString())
|
||||
}
|
||||
587 -> {
|
||||
// STARTTLS
|
||||
if (enableTLS) {
|
||||
put("mail.smtp.starttls.enable", "true")
|
||||
put("mail.smtp.starttls.required", "true")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// 普通 25 端口或其他情况
|
||||
if (enableTLS) {
|
||||
put("mail.smtp.starttls.enable", "true")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
session = Session.getInstance(props, object : Authenticator() {
|
||||
override fun getPasswordAuthentication(): PasswordAuthentication {
|
||||
return PasswordAuthentication(senderEmailAddress, authToken)
|
||||
}
|
||||
})
|
||||
|
||||
running = true
|
||||
workerThread = thread(start = true, name = "MailSender-Worker") {
|
||||
LoggerUtil.logger.info("[$name] 邮件发送线程启动")
|
||||
while (running && loaded) {
|
||||
try {
|
||||
val mail = queue.take() // 阻塞等待新任务
|
||||
LoggerUtil.logger.info("[$name] 开始发送邮件 -> 收件人: ${mail.to.joinToString(",")}")
|
||||
sendInternal(mail)
|
||||
LoggerUtil.logger.info("[$name] 邮件发送完成 -> ${mail.to.joinToString(",")}")
|
||||
Thread.sleep(intervalMillis) // 限流
|
||||
} catch (e: InterruptedException) {
|
||||
LoggerUtil.logger.info("[$name] 邮件发送线程被中断,准备退出")
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.error("[$name] 邮件发送出现异常", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onUnload() {
|
||||
LoggerUtil.logger.info("[$name] 模块卸载,停止邮件发送线程")
|
||||
running = false
|
||||
workerThread?.interrupt()
|
||||
workerThread = null
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 加入发送队列
|
||||
*/
|
||||
fun enqueue(mail: Mail) {
|
||||
if (!loaded) throw IllegalStateException("MailModule 未加载,不能发送邮件")
|
||||
LoggerUtil.logger.info("[$name] 邮件加入队列 -> 收件人: ${mail.to.joinToString(",")}, 主题: ${mail.subject}")
|
||||
queue.put(mail)
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正的发送逻辑(内部调用)
|
||||
*/
|
||||
private fun sendInternal(mail: Mail) {
|
||||
val message = MimeMessage(session).apply {
|
||||
setFrom(InternetAddress(senderEmailAddress,mail.from ?: senderEmailAddress, "UTF-8"))
|
||||
setRecipients(Message.RecipientType.TO, mail.to.joinToString(","))
|
||||
if (mail.cc.isNotEmpty()) {
|
||||
setRecipients(Message.RecipientType.CC, mail.cc.joinToString(","))
|
||||
}
|
||||
if (mail.bcc.isNotEmpty()) {
|
||||
setRecipients(Message.RecipientType.BCC, mail.bcc.joinToString(","))
|
||||
}
|
||||
subject = mail.subject
|
||||
setContent(
|
||||
mail.body,
|
||||
if (mail.isHtml) "text/html;charset=UTF-8" else "text/plain;charset=UTF-8"
|
||||
)
|
||||
}
|
||||
|
||||
Transport.send(message)
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,6 @@ class ModuleManager {
|
|||
}
|
||||
try {
|
||||
module.load()
|
||||
LoggerUtil.logger.info("模块加载: $name")
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.error("加载模块 $name 失败", e)
|
||||
}
|
||||
|
|
@ -42,7 +41,7 @@ class ModuleManager {
|
|||
/**
|
||||
* 卸载指定模块
|
||||
*/
|
||||
fun unloadModule(name: String) {
|
||||
suspend fun unloadModule(name: String) {
|
||||
val module = modules[name]
|
||||
if (module == null) {
|
||||
LoggerUtil.logger.warn("尝试卸载不存在的模块: $name")
|
||||
|
|
@ -54,7 +53,6 @@ class ModuleManager {
|
|||
}
|
||||
try {
|
||||
module.unload()
|
||||
LoggerUtil.logger.info("模块卸载: $name")
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.warn("卸载模块 $name 失败", e)
|
||||
}
|
||||
|
|
@ -63,12 +61,12 @@ class ModuleManager {
|
|||
/**
|
||||
* 卸载所有模块
|
||||
*/
|
||||
fun unloadAll() {
|
||||
suspend fun unloadAll() {
|
||||
modules.values.forEach { module ->
|
||||
try {
|
||||
if (module.loaded) {
|
||||
module.unload()
|
||||
LoggerUtil.logger.info("模块卸载: ${module.name}")
|
||||
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.warn("卸载模块 ${module.name} 失败", e)
|
||||
|
|
@ -97,7 +95,7 @@ class ModuleManager {
|
|||
/**
|
||||
* 扩展方法:批量卸载模块
|
||||
*/
|
||||
fun ModuleManager.unloadModules(vararg names: String) {
|
||||
suspend fun ModuleManager.unloadModules(vararg names: String) {
|
||||
names.forEach { unloadModule(it) }
|
||||
}
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package top.r3944realms.ltdmanager.module
|
||||
|
||||
import java.io.File
|
||||
|
||||
interface PersistentState<T> {
|
||||
fun getStateFile(): File
|
||||
fun getState(): T
|
||||
fun saveState(state: T)
|
||||
fun loadState(): T
|
||||
}
|
||||
|
|
@ -4,13 +4,12 @@ import kotlinx.coroutines.*
|
|||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
|
||||
import top.r3944realms.ltdmanager.module.RconPlayerListModule.LastTriggerState
|
||||
import top.r3944realms.ltdmanager.napcat.NapCatClient
|
||||
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.event.message.GetGroupMsgHistoryEvent
|
||||
import top.r3944realms.ltdmanager.napcat.request.message.GetGroupMsgHistoryRequest
|
||||
import top.r3944realms.ltdmanager.napcat.event.message.GetFriendMsgHistoryEvent
|
||||
import top.r3944realms.ltdmanager.napcat.request.message.SendForwardMsgRequest
|
||||
import top.r3944realms.ltdmanager.napcat.request.other.SendGroupMsgRequest
|
||||
import top.r3944realms.ltdmanager.utils.CmdUtil
|
||||
|
|
@ -19,173 +18,161 @@ import java.io.File
|
|||
import java.util.concurrent.TimeoutException
|
||||
|
||||
class RconPlayerListModule(
|
||||
private val pollIntervalMillis: Long = 30_000L,
|
||||
private val timeout: Long = 2_000L,
|
||||
private val cooldownMillis: Long = 30_000L, // 默认 30 秒
|
||||
private val groupMessagePollingModule: GroupMessagePollingModule,
|
||||
private val rconTimeOut: Long = 2_000L,
|
||||
private val cooldownMillis: Long = 30_000L,
|
||||
private var lastSuccessTime: Long = 0L,
|
||||
private var msgHistoryCheck: Int = 5,
|
||||
private val targetGroupId: Long,
|
||||
private val selfId: Long,
|
||||
private val selfNickName: String,
|
||||
private val rconPath: String,
|
||||
private val rconConfigPath: String,
|
||||
private val keywords: Set<String> = setOf("查看玩家列表", "玩家列表", "在线玩家")
|
||||
) : BaseModule() {
|
||||
) : BaseModule(), PersistentState<LastTriggerState> {
|
||||
|
||||
private val stopSignal = CompletableDeferred<Unit>() // 用于等待协程退出
|
||||
override val name: String = "RconPlayerListModule"
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var scope : CoroutineScope? = null
|
||||
|
||||
// 持久化文件路径
|
||||
private val stateFile = File("rcon_playerlist_state.json")
|
||||
|
||||
override fun getStateFile(): File = stateFile
|
||||
|
||||
// 保存最新触发过的消息 realId 和 time
|
||||
private var moduleState: ModuleState = loadState()
|
||||
private var lastTriggerState: LastTriggerState = loadState()
|
||||
|
||||
private val rconPath: String
|
||||
get() = YamlConfigLoader.loadToolConfig().rcon.mcRconToolPath
|
||||
?: throw IllegalStateException("RCON 工具路径未配置")
|
||||
|
||||
private val rconConfigPath: String
|
||||
get() = YamlConfigLoader.loadToolConfig().rcon.mcRconToolConfigPath
|
||||
?: throw IllegalStateException("Rcon配置路径未配置")
|
||||
override fun getState(): LastTriggerState = lastTriggerState
|
||||
|
||||
override fun onLoad() {
|
||||
LoggerUtil.logger.info("[$name] 模块已装载,目标群组: $targetGroupId,轮询间隔: ${pollIntervalMillis}ms")
|
||||
LoggerUtil.logger.info("[$name] 上次触发状态: realId=${moduleState.lastTriggeredRealId}, time=${moduleState.lastTriggerTime}")
|
||||
LoggerUtil.logger.info("[$name] 模块已装载,目标群组: ${groupMessagePollingModule.targetGroupId}")
|
||||
LoggerUtil.logger.info("[$name] 上次触发状态: realId=${lastTriggerState.lastTriggeredRealId}, time=${lastTriggerState.lastTriggerTime}")
|
||||
LoggerUtil.logger.info("[$name] 关键词列表: $keywords")
|
||||
|
||||
scope.launch {
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
scope!!.launch {
|
||||
LoggerUtil.logger.info("[$name] 轮询协程启动")
|
||||
try {
|
||||
while (isActive) {
|
||||
LoggerUtil.logger.debug("[$name] 开始轮询群消息历史...")
|
||||
try {
|
||||
val historyEvent = napCatClient.send(
|
||||
GetGroupMsgHistoryRequest(
|
||||
count = msgHistoryCheck,
|
||||
groupId = ID.long(targetGroupId)
|
||||
)
|
||||
) as? GetGroupMsgHistoryEvent
|
||||
|
||||
val messages = historyEvent?.data?.messages ?: emptyList()
|
||||
LoggerUtil.logger.debug("[$name] 获取到 ${messages.size} 条最近消息")
|
||||
|
||||
// 找到比 lastTriggeredRealId 更新的触发消息
|
||||
val triggerMessages = messages.filter { msg ->
|
||||
((msg.time > moduleState.lastTriggerTime ||
|
||||
(msg.time == moduleState.lastTriggerTime && msg.realId > moduleState.lastTriggeredRealId)) && msg.userId != selfId) &&
|
||||
msg.message.any { seg ->
|
||||
seg.type == MessageType.Text &&
|
||||
seg.data.text?.let { text ->
|
||||
keywords.any { keyword ->
|
||||
text == keyword
|
||||
}
|
||||
} == true
|
||||
}
|
||||
}
|
||||
|
||||
LoggerUtil.logger.debug("[$name] 找到 ${triggerMessages.size} 条符合条件的触发消息")
|
||||
|
||||
if (triggerMessages.isNotEmpty()) {
|
||||
val triggerMsg = triggerMessages.maxBy { it.time }
|
||||
LoggerUtil.logger.info("[$name] 找到触发消息 realId=${triggerMsg.realId}, time=${triggerMsg.time}, userId=${triggerMsg.userId}")
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
// ✅ 首次触发允许直接执行
|
||||
val canTrigger = (lastSuccessTime == 0L) || (now - lastSuccessTime >= cooldownMillis)
|
||||
|
||||
if (!canTrigger) {
|
||||
val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1)
|
||||
LoggerUtil.logger.info("[$name] 冷却中,拒绝执行,剩余 $remaining 秒")
|
||||
sendCooldownMessage(napCatClient, triggerMsg.realId, triggerMsg.time)
|
||||
continue
|
||||
}
|
||||
|
||||
// 执行 RCON
|
||||
val commands = listOf("forge tps","list")
|
||||
LoggerUtil.logger.info("[$name] 执行 RCON 命令: $commands")
|
||||
|
||||
|
||||
runCatching {
|
||||
val tpsOutput = runCatching {
|
||||
CmdUtil.runExeCommand(rconPath, "-c", rconConfigPath, "-T", (timeout / 1000).toString() + "s", "forge tps")
|
||||
}.getOrElse { ex ->
|
||||
LoggerUtil.logger.warn("[$name] 执行 forge tps 失败: ${ex.message}")
|
||||
throw ex
|
||||
}
|
||||
|
||||
val listOutput = runCatching {
|
||||
CmdUtil.runExeCommand(rconPath, "-c", rconConfigPath, "-T", (timeout / 1000).toString() + "s", "list")
|
||||
}.getOrElse { ex ->
|
||||
LoggerUtil.logger.warn("[$name] 执行 list 失败: ${ex.message}")
|
||||
throw ex
|
||||
}
|
||||
if (tpsOutput.contains("i/o timeout") || listOutput.contains("i/o timeout")) {
|
||||
throw TimeoutException()
|
||||
}
|
||||
// 合并输出,再解析
|
||||
buildString {
|
||||
appendLine(tpsOutput.trim())
|
||||
appendLine("--------")
|
||||
appendLine(listOutput.trim())
|
||||
}
|
||||
} .onFailure { ex ->
|
||||
if (ex is TimeoutException) {
|
||||
lastSuccessTime = now // ✅ 成功后记录冷却开始时间
|
||||
LoggerUtil.logger.warn("[$name] RCON 连接超时: ${ex.message}")
|
||||
sendFailedMessage(napCatClient, triggerMsg.realId, triggerMsg.time)
|
||||
} else {
|
||||
lastSuccessTime = now // ✅ 成功后记录冷却开始时间
|
||||
LoggerUtil.logger.error("[$name] RCON 命令执行失败", ex)
|
||||
sendFailedMessage(
|
||||
napCatClient,
|
||||
triggerMsg.realId,
|
||||
triggerMsg.time,
|
||||
"系统内部错误请联系管理员:${ex.message}"
|
||||
)
|
||||
throw ex
|
||||
}
|
||||
} .onSuccess { output ->
|
||||
lastSuccessTime = now // ✅ 成功后记录冷却开始时间
|
||||
LoggerUtil.logger.info("[$name] RCON 命令执行成功,输出长度: ${output.length}")
|
||||
LoggerUtil.logger.debug("[$name] RCON 输出内容: $output")
|
||||
val tpsInfo = parseTPS(output)
|
||||
val playerListInfo = parsePlayerList(output)
|
||||
LoggerUtil.logger.info("[$name] 解析成功: TPS=${tpsInfo.overall.meanTPS}, 在线 ${playerListInfo.onlineCount} 人")
|
||||
// 发送转发消息
|
||||
sendForwardMessage(napCatClient, tpsInfo, playerListInfo, triggerMsg.realId, triggerMsg.time)
|
||||
}
|
||||
} else {
|
||||
LoggerUtil.logger.debug("[$name] 未找到新的触发消息")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.error("[$name] 轮询玩家列表或发送转发消息失败", e)
|
||||
}
|
||||
LoggerUtil.logger.debug("[$name] 本轮轮询完成,等待 ${pollIntervalMillis}ms")
|
||||
delay(pollIntervalMillis)
|
||||
scope!!.launch {
|
||||
LoggerUtil.logger.info("[$name] 开始订阅消息流")
|
||||
groupMessagePollingModule.messagesFlow.collect { messages ->
|
||||
if(loaded) handleMessages(messages)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
LoggerUtil.logger.info("[$name] 轮询协程收到取消信号")
|
||||
} finally {
|
||||
LoggerUtil.logger.info("[$name] 轮询协程退出,完成 stopSignal")
|
||||
stopSignal.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override fun onUnload() {
|
||||
LoggerUtil.logger.info("[$name] 模块已卸载")
|
||||
saveState(moduleState.lastTriggeredRealId, moduleState.lastTriggerTime) // 卸载时保存
|
||||
override suspend fun onUnload() {
|
||||
LoggerUtil.logger.info("[$name] 模块卸载,取消协程...")
|
||||
scope?.cancel()
|
||||
saveState(lastTriggerState)
|
||||
LoggerUtil.logger.info("[$name] 模块已卸载完成")
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
LoggerUtil.logger.info("[$name] 收到停止命令,开始关闭协程...")
|
||||
scope.cancel() // 取消协程
|
||||
LoggerUtil.logger.info("[$name] 等待协程退出...")
|
||||
stopSignal.await() // 等待协程完成
|
||||
LoggerUtil.logger.info("[$name] 协程已退出,卸载模块资源")
|
||||
onUnload() // 卸载模块资源,保存状态
|
||||
private suspend fun handleMessages(messages: List<GetFriendMsgHistoryEvent.SpecificMsg>) {
|
||||
val triggerMessages = messages
|
||||
.asSequence() // 使用序列提高性能,特别是消息量大时
|
||||
.filter { msg ->
|
||||
((msg.time > lastTriggerState.lastTriggerTime ||
|
||||
(msg.time == lastTriggerState.lastTriggerTime && msg.realId > lastTriggerState.lastTriggeredRealId))
|
||||
&& msg.userId != selfId) &&
|
||||
msg.message.any { seg ->
|
||||
seg.type == MessageType.Text &&
|
||||
seg.data.text?.let { text -> keywords.any { keyword -> text == keyword } } == true
|
||||
}
|
||||
}.toList()
|
||||
|
||||
if (triggerMessages.isNotEmpty()) {
|
||||
val triggerMsg = triggerMessages.maxBy { it.time }
|
||||
LoggerUtil.logger.info("[$name] 找到触发消息 realId=${triggerMsg.realId}, time=${triggerMsg.time}, userId=${triggerMsg.userId}")
|
||||
processTrigger(triggerMsg)
|
||||
}
|
||||
}
|
||||
private suspend fun processTrigger(msg: GetFriendMsgHistoryEvent.SpecificMsg) {
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
// ✅ 冷却检查(首次触发直接允许)
|
||||
val canTrigger = (lastSuccessTime == 0L) || (now - lastSuccessTime >= cooldownMillis)
|
||||
if (!canTrigger) {
|
||||
val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1)
|
||||
LoggerUtil.logger.info("[$name] 冷却中,拒绝执行,剩余 $remaining 秒")
|
||||
sendCooldownMessage(napCatClient, msg.realId, msg.time)
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ 执行 RCON 命令
|
||||
val commands = listOf("forge tps", "list")
|
||||
LoggerUtil.logger.info("[$name] 执行 RCON 命令: $commands")
|
||||
|
||||
runCatching {
|
||||
val tpsOutput = runCatching {
|
||||
CmdUtil.runExeCommand(
|
||||
rconPath,
|
||||
"-c", rconConfigPath,
|
||||
"-T", (rconTimeOut / 1000).toString() + "s",
|
||||
"forge tps"
|
||||
)
|
||||
}.getOrElse { ex ->
|
||||
LoggerUtil.logger.warn("[$name] 执行 forge tps 失败: ${ex.message}")
|
||||
throw ex
|
||||
}
|
||||
|
||||
val listOutput = runCatching {
|
||||
CmdUtil.runExeCommand(
|
||||
rconPath,
|
||||
"-c", rconConfigPath,
|
||||
"-T", (rconTimeOut / 1000).toString() + "s",
|
||||
"list"
|
||||
)
|
||||
}.getOrElse { ex ->
|
||||
LoggerUtil.logger.warn("[$name] 执行 list 失败: ${ex.message}")
|
||||
throw ex
|
||||
}
|
||||
|
||||
if (tpsOutput.contains("i/o timeout") || listOutput.contains("i/o timeout")) {
|
||||
throw TimeoutException()
|
||||
}
|
||||
|
||||
// 合并输出,后续一起解析
|
||||
buildString {
|
||||
appendLine(tpsOutput.trim())
|
||||
appendLine("--------")
|
||||
appendLine(listOutput.trim())
|
||||
}
|
||||
}.onFailure { ex ->
|
||||
lastSuccessTime = now // ✅ 成功/失败都要刷新冷却开始时间
|
||||
|
||||
if (ex is TimeoutException) {
|
||||
LoggerUtil.logger.warn("[$name] RCON 连接超时: ${ex.message}")
|
||||
sendFailedMessage(napCatClient, msg.realId, msg.time)
|
||||
} else {
|
||||
LoggerUtil.logger.error("[$name] RCON 命令执行失败", ex)
|
||||
sendFailedMessage(
|
||||
napCatClient,
|
||||
msg.realId,
|
||||
msg.time,
|
||||
"系统内部错误请联系管理员:${ex.message}"
|
||||
)
|
||||
throw ex
|
||||
}
|
||||
}.onSuccess { output ->
|
||||
lastSuccessTime = now
|
||||
LoggerUtil.logger.info("[$name] RCON 命令执行成功,输出长度: ${output.length}")
|
||||
LoggerUtil.logger.debug("[$name] RCON 输出内容: $output")
|
||||
|
||||
val tpsInfo = parseTPS(output)
|
||||
val playerListInfo = parsePlayerList(output)
|
||||
|
||||
LoggerUtil.logger.info(
|
||||
"[$name] 解析成功: TPS=${tpsInfo.overall.meanTPS}, 在线 ${playerListInfo.onlineCount} 人"
|
||||
)
|
||||
|
||||
sendForwardMessage(napCatClient, tpsInfo, playerListInfo, msg.realId, msg.time)
|
||||
}
|
||||
|
||||
// ✅ 更新触发状态 & 持久化
|
||||
lastTriggerState.lastTriggeredRealId = msg.realId
|
||||
lastTriggerState.lastTriggerTime = msg.time
|
||||
saveState(lastTriggerState)
|
||||
}
|
||||
|
||||
|
||||
private suspend fun sendCooldownMessage(client: NapCatClient, realId: Long, time: Long) {
|
||||
val now = System.currentTimeMillis()
|
||||
val remaining = ((cooldownMillis - (now - lastSuccessTime)) / 1000).coerceAtLeast(1) // 至少显示 1 秒
|
||||
|
|
@ -195,14 +182,14 @@ class RconPlayerListModule(
|
|||
|
||||
val request = SendGroupMsgRequest(
|
||||
MessageElement.reply(ID.long(realId), msg),
|
||||
ID.long(targetGroupId)
|
||||
ID.long(groupMessagePollingModule.targetGroupId)
|
||||
)
|
||||
client.sendUnit(request)
|
||||
|
||||
// 更新触发状态,但不更新 lastSuccessTime(避免延长冷却)
|
||||
moduleState.lastTriggeredRealId = realId
|
||||
moduleState.lastTriggerTime = time
|
||||
saveState(realId, time)
|
||||
lastTriggerState.lastTriggeredRealId = realId
|
||||
lastTriggerState.lastTriggerTime = time
|
||||
saveState(lastTriggerState)
|
||||
}
|
||||
|
||||
private val failedMessages = listOf(
|
||||
|
|
@ -211,7 +198,21 @@ class RconPlayerListModule(
|
|||
"🐌 RCON 响应太慢,像蜗牛一样",
|
||||
"🛠️ 系统开小差了,请联系管理员",
|
||||
"⚠️ 服务器没理我,可能在打盹",
|
||||
"🔥 电路冒烟了!查询失败"
|
||||
"🔥 电路冒烟了!查询失败",
|
||||
|
||||
// 新增的
|
||||
"⏳ 等了半天也没回应,土豆睡着了?",
|
||||
"📡 信号迷路了,RCON 连接失败",
|
||||
"🌀 数据转圈圈,一直出不来",
|
||||
"🚧 前方施工中,暂时无法获取数据",
|
||||
"🤖 RCON 小机器人宕机,请稍后重启",
|
||||
"🌩️ 网络打雷了,数据全跑丢了",
|
||||
"🕳️ 请求掉进黑洞了,没有回音",
|
||||
"🎭 服务器玩消失,不肯理我",
|
||||
"📉 查询失败,RCON 掉线了",
|
||||
"🥶 服务器结冰了,冻得说不出话",
|
||||
"📵 RCON 拒绝通信,像开飞行模式",
|
||||
"💤 服务器打瞌睡,回应超时"
|
||||
)
|
||||
private suspend fun sendFailedMessage(
|
||||
client: NapCatClient,
|
||||
|
|
@ -226,15 +227,15 @@ class RconPlayerListModule(
|
|||
|
||||
val request = SendGroupMsgRequest(
|
||||
MessageElement.reply(ID.long(realId), finalText),
|
||||
ID.long(targetGroupId)
|
||||
ID.long(groupMessagePollingModule.targetGroupId)
|
||||
)
|
||||
client.sendUnit(request)
|
||||
LoggerUtil.logger.info("[$name] 已发送 RCON 失败消息")
|
||||
|
||||
// 更新触发的最大 realId
|
||||
moduleState.lastTriggeredRealId = realId
|
||||
moduleState.lastTriggerTime = time
|
||||
saveState(realId, time) // 保存到文件
|
||||
lastTriggerState.lastTriggeredRealId = realId
|
||||
lastTriggerState.lastTriggerTime = time
|
||||
saveState(lastTriggerState) // 保存到文件
|
||||
}
|
||||
private suspend fun sendForwardMessage(client: NapCatClient, tps: TPSInfo, info: PlayerListInfo, realId: Long, time: Long) {
|
||||
LoggerUtil.logger.info("[$name] 发送转发消息: realId=$realId, TPS=${tps.overall.meanTPS}, 在线玩家数=${info.onlineCount}")
|
||||
|
|
@ -279,7 +280,7 @@ class RconPlayerListModule(
|
|||
} else {
|
||||
messages.add(
|
||||
SendForwardMsgRequest.Message(
|
||||
data = SendForwardMsgRequest.PurpleData("😴 当前没有玩家在线"),
|
||||
data = SendForwardMsgRequest.PurpleData("😴 当前没有玩家在线\n"),
|
||||
type = MessageType.Text
|
||||
)
|
||||
)
|
||||
|
|
@ -311,7 +312,7 @@ class RconPlayerListModule(
|
|||
)
|
||||
|
||||
val request = SendForwardMsgRequest(
|
||||
groupId = ID.long(targetGroupId),
|
||||
groupId = ID.long(groupMessagePollingModule.targetGroupId),
|
||||
messages = listOf(topMessage),
|
||||
news = listOf(
|
||||
SendForwardMsgRequest.ForwardModelNews("点击查看服务器状态与玩家列表"),
|
||||
|
|
@ -325,9 +326,9 @@ class RconPlayerListModule(
|
|||
|
||||
client.sendUnit(request)
|
||||
LoggerUtil.logger.info("[$name] 已发送 TPS+玩家列表 转发消息")
|
||||
moduleState.lastTriggeredRealId = realId
|
||||
moduleState.lastTriggerTime = time
|
||||
saveState(realId, time)
|
||||
lastTriggerState.lastTriggeredRealId = realId
|
||||
lastTriggerState.lastTriggerTime = time
|
||||
saveState(lastTriggerState)
|
||||
}
|
||||
|
||||
// 添加时间格式化函数
|
||||
|
|
@ -476,30 +477,29 @@ class RconPlayerListModule(
|
|||
// ---------------- 持久化部分 ----------------
|
||||
|
||||
@Serializable
|
||||
data class ModuleState(var lastTriggeredRealId: Long, var lastTriggerTime: Long)
|
||||
data class LastTriggerState(var lastTriggeredRealId: Long, var lastTriggerTime: Long)
|
||||
|
||||
private fun saveState(realId: Long, time: Long) {
|
||||
override fun saveState(state: LastTriggerState) {
|
||||
try {
|
||||
val state = ModuleState(realId, time)
|
||||
stateFile.writeText(Json.encodeToString(state))
|
||||
LoggerUtil.logger.info("[$name] 已保存状态: lastTriggeredRealId=$realId, lastTriggerTime=$time")
|
||||
LoggerUtil.logger.info("[$name] 已保存状态: lastTriggeredRealId=${state.lastTriggeredRealId}, lastTriggerTime=${state.lastTriggerTime}")
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.error("[$name] 保存状态失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadState(): ModuleState {
|
||||
override fun loadState(): LastTriggerState {
|
||||
return try {
|
||||
if (!stateFile.exists()) {
|
||||
LoggerUtil.logger.info("[$name] 状态文件不存在,使用默认值")
|
||||
return ModuleState(-1L, 0L)
|
||||
return LastTriggerState(-1L, 0L)
|
||||
}
|
||||
val state = Json.decodeFromString<ModuleState>(stateFile.readText())
|
||||
val state = Json.decodeFromString<LastTriggerState>(stateFile.readText())
|
||||
LoggerUtil.logger.info("[$name] 成功加载状态: lastTriggeredRealId=${state.lastTriggeredRealId}, lastTriggerTime=${state.lastTriggerTime}")
|
||||
state
|
||||
} catch (e: Exception) {
|
||||
LoggerUtil.logger.warn("[$name] 读取状态失败,使用默认值", e)
|
||||
ModuleState(-1L, 0L)
|
||||
LastTriggerState(-1L, 0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,6 @@ import top.r3944realms.ltdmanager.utils.Environment
|
|||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayDeque
|
||||
import kotlin.collections.isNotEmpty
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class NapCatClient private constructor() : Closeable {
|
||||
|
|
@ -30,11 +29,11 @@ class NapCatClient private constructor() : Closeable {
|
|||
private val semaphore = Semaphore(3)
|
||||
|
||||
// 普通优先级队列
|
||||
private val requestQueue = PriorityQueue<QueueItem>(compareBy { it.priority })
|
||||
private val requestQueue = PriorityQueue<NapCatQueueItem>(compareBy { it.priority })
|
||||
private val queueMutex = Mutex()
|
||||
|
||||
// 紧急队列 (先进先出,最多 10 个)
|
||||
private val urgentQueue = ArrayDeque<QueueItem>(10)
|
||||
private val urgentQueue = ArrayDeque<NapCatQueueItem>(10)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
|
|
@ -71,7 +70,7 @@ class NapCatClient private constructor() : Closeable {
|
|||
checkRequest(request)
|
||||
val deferred = CompletableDeferred<Unit>()
|
||||
queueMutex.withLock {
|
||||
requestQueue.add(QueueItem(request, deferred, retries, priority, expectsEvent = false))
|
||||
requestQueue.add(NapCatQueueItem(request, deferred, retries, priority, expectsEvent = false))
|
||||
}
|
||||
deferred.await()
|
||||
}
|
||||
|
|
@ -90,7 +89,7 @@ class NapCatClient private constructor() : Closeable {
|
|||
if (urgentQueue.size >= 10) {
|
||||
throw IllegalStateException("紧急任务队列已满 (最多 10 个)")
|
||||
}
|
||||
urgentQueue.addLast(QueueItem(request, deferred, retries, priority = Int.MIN_VALUE, expectsEvent = false))
|
||||
urgentQueue.addLast(NapCatQueueItem(request, deferred, retries, priority = Int.MIN_VALUE, expectsEvent = false))
|
||||
}
|
||||
deferred.await()
|
||||
}
|
||||
|
|
@ -106,7 +105,7 @@ class NapCatClient private constructor() : Closeable {
|
|||
checkRequest(request)
|
||||
val deferred = CompletableDeferred<T>()
|
||||
queueMutex.withLock {
|
||||
requestQueue.add(QueueItem(request, deferred, retries, priority, expectsEvent = true))
|
||||
requestQueue.add(NapCatQueueItem(request, deferred, retries, priority, expectsEvent = true))
|
||||
}
|
||||
return deferred.await()
|
||||
}
|
||||
|
|
@ -125,7 +124,7 @@ class NapCatClient private constructor() : Closeable {
|
|||
if (urgentQueue.size >= 10) {
|
||||
throw IllegalStateException("紧急任务队列已满 (最多 10 个)")
|
||||
}
|
||||
urgentQueue.addLast(QueueItem(request, deferred, retries, priority = Int.MIN_VALUE, expectsEvent = true))
|
||||
urgentQueue.addLast(NapCatQueueItem(request, deferred, retries, priority = Int.MIN_VALUE, expectsEvent = true))
|
||||
}
|
||||
return deferred.await()
|
||||
}
|
||||
|
|
@ -139,7 +138,7 @@ class NapCatClient private constructor() : Closeable {
|
|||
|
||||
}
|
||||
|
||||
private suspend fun processRequest(item: QueueItem) {
|
||||
private suspend fun processRequest(item: NapCatQueueItem) {
|
||||
semaphore.withPermit {
|
||||
val (request, deferred, retries, _, expectsEvent) = item
|
||||
var attempt = 0
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ package top.r3944realms.ltdmanager.napcat
|
|||
import kotlinx.coroutines.CompletableDeferred
|
||||
import top.r3944realms.ltdmanager.napcat.request.NapCatRequest
|
||||
|
||||
data class QueueItem(
|
||||
data class NapCatQueueItem(
|
||||
val request: NapCatRequest,
|
||||
val deferred: CompletableDeferred<*>,
|
||||
var retries: Int,
|
||||
val priority: Int,
|
||||
val expectsEvent: Boolean // true 表示返回 NapCatEvent, false 表示 Unit
|
||||
) : Comparable<QueueItem> {
|
||||
override fun compareTo(other: QueueItem): Int = priority.compareTo(other.priority)
|
||||
) : Comparable<NapCatQueueItem> {
|
||||
override fun compareTo(other: NapCatQueueItem): Int = priority.compareTo(other.priority)
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import kotlinx.serialization.json.Json
|
|||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
data class FailedRequestEvent(
|
||||
data class FailedNapCatRequestEvent(
|
||||
val status: Status = Status.Failed,
|
||||
val retcode: Int,
|
||||
val data: JsonElement?= null,
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
package top.r3944realms.ltdmanager.napcat.event
|
||||
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.r3944realms.ltdmanager.napcat.event.account.AbstractAccountEvent
|
||||
import top.r3944realms.ltdmanager.napcat.event.file.AbstractFileEvent
|
||||
|
|
@ -41,8 +44,8 @@ abstract class NapCatEvent(
|
|||
}
|
||||
}
|
||||
|
||||
private fun failedDecode(jsonString: String): FailedRequestEvent {
|
||||
return FailedRequestEvent.json.decodeFromString(jsonString)
|
||||
private fun failedDecode(jsonString: String): FailedNapCatRequestEvent {
|
||||
return FailedNapCatRequestEvent.json.decodeFromString(jsonString)
|
||||
}
|
||||
fun decodeEvent(jsonString: String, type: String): NapCatEvent {
|
||||
return try {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ package top.r3944realms.ltdmanager.napcat.event.group
|
|||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import top.r3944realms.ltdmanager.napcat.request.group.GetGroupIgnoredNotifiesRequest
|
||||
import top.r3944realms.ltdmanager.napcat.request.group.GetGroupSystemMsgRequest
|
||||
|
||||
/**
|
||||
* GetGroupIgnoredNotifies事件
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ package top.r3944realms.ltdmanager.napcat.event.message.group
|
|||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import top.r3944realms.ltdmanager.napcat.event.NapCatEvent
|
||||
import top.r3944realms.ltdmanager.napcat.event.group.AbstractGroupEvent
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ package top.r3944realms.ltdmanager.napcat.event.message.group
|
|||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import top.r3944realms.ltdmanager.napcat.event.NapCatEvent
|
||||
import top.r3944realms.ltdmanager.napcat.event.group.AbstractGroupEvent
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
package top.r3944realms.ltdmanager
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import top.r3944realms.ltdmanager.module.GroupRequestHandlerModule
|
||||
import top.r3944realms.ltdmanager.utils.LoggerUtil
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
|
||||
fun main() = runBlocking {
|
||||
// 标记程序是否运行
|
||||
val isRunning = AtomicBoolean(true)
|
||||
|
||||
// 创建模块实例
|
||||
val groupModule = GroupRequestHandlerModule(
|
||||
client = GlobalManager.napCatClient,
|
||||
targetGroupId = 538751386
|
||||
)
|
||||
|
||||
|
||||
// 注册模块到全局模块管理器
|
||||
GlobalManager.moduleManager.registerModule(groupModule)
|
||||
|
||||
// 加载模块
|
||||
GlobalManager.moduleManager.loadModule(groupModule.name)
|
||||
|
||||
// 捕获 JVM 关闭信号,优雅退出
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
runBlocking {
|
||||
LoggerUtil.logger.info("\n收到退出信号,正在停止所有模块...")
|
||||
GlobalManager.moduleManager.stopAllModules() // 批量 stop
|
||||
LoggerUtil.logger.info("模块卸载完成,程序退出。")
|
||||
GlobalManager.shutdown()
|
||||
}
|
||||
isRunning.set(false)
|
||||
})
|
||||
|
||||
// 持续挂起,保持主线程运行
|
||||
while (isRunning.get()) {
|
||||
delay(1000L)
|
||||
}
|
||||
}
|
||||
|
|
@ -28,37 +28,10 @@ object ConfigInitializer {
|
|||
if (resourceStream != null) {
|
||||
Files.copy(resourceStream, filePath, StandardCopyOption.REPLACE_EXISTING)
|
||||
LoggerUtil.logger.info("已生成默认配置文件: $filePath")
|
||||
} else {
|
||||
// 资源文件不存在,可写入内置默认 YAML
|
||||
val defaultYaml = """
|
||||
database:
|
||||
url: "jdbc:mysql://localhost:3306/quizdb?useSSL=false&serverTimezone=UTC"
|
||||
user: "root"
|
||||
encrypted-password: "123123aa"
|
||||
crypto:
|
||||
secret-key: "ltd25r3944realms"
|
||||
mode:
|
||||
bot-api-type: HTTP
|
||||
environment: DEVELOPMENT
|
||||
http:
|
||||
url: "https://127.0.0.1:3001"
|
||||
encrypted-token: "123123bb"
|
||||
websocket:
|
||||
url: "wss://127.0.0.1:3002"
|
||||
encrypted-token: "123123cc"
|
||||
tools:
|
||||
rcon:
|
||||
mc-rcon-tool-path: "/path/to/rcon"
|
||||
mc-rcon-tool-config-path: "/path/to/rcon_config"
|
||||
server-url: "your.minecraft.server"
|
||||
rcon-password: "123123dd"
|
||||
""".trimIndent()
|
||||
LoggerUtil.logger.info("第一次启动,请修改配置后再启动")
|
||||
exitProcess(-1);
|
||||
} else throw Error("Jar内部资源文件缺失")
|
||||
|
||||
Files.writeString(filePath, defaultYaml)
|
||||
LoggerUtil.logger.info("已生成默认配置文件(使用内置内容): $filePath")
|
||||
}
|
||||
LoggerUtil.logger.info("第一次启动,请修改配置后再启动")
|
||||
exitProcess(-1);
|
||||
} else {
|
||||
LoggerUtil.logger.info("配置文件已存在: $filePath")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
package top.r3944realms.ltdmanager.utils
|
||||
|
||||
import java.io.InputStreamReader
|
||||
import java.nio.charset.Charset
|
||||
import java.sql.Timestamp
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
|
||||
object HtmlTemplateUtil {
|
||||
|
||||
/**
|
||||
* 从指定 HTML 文件读取内容并替换占位符
|
||||
* @param resourcePath HTML 文件路径
|
||||
* @param variables 占位符变量,如 mapOf("名字" to "小明", "时间" to "2025-08-28")
|
||||
* @param charset 文件编码,默认 UTF-8
|
||||
*/
|
||||
fun renderTemplateFromClasspath(
|
||||
resourcePath: String,
|
||||
variables: Map<String, String>,
|
||||
charset: Charset = Charsets.UTF_8
|
||||
): String {
|
||||
val inputStream = object {}.javaClass.classLoader.getResourceAsStream(resourcePath)
|
||||
?: throw IllegalArgumentException("模板文件未找到: $resourcePath")
|
||||
|
||||
val template = InputStreamReader(inputStream, charset).use { it.readText() }
|
||||
|
||||
var result = template
|
||||
variables.forEach { (key, value) ->
|
||||
result = result.replace("{$key}", value)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成激活码邮件 HTML
|
||||
*/
|
||||
fun tokenMailHtmlTemplate(
|
||||
playerName: String,
|
||||
token: String,
|
||||
expireTime: Timestamp? = null,
|
||||
validDay: Int? = null,
|
||||
timeYear: Int
|
||||
): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
|
||||
return renderTemplateFromClasspath(
|
||||
resourcePath = "mail-body.html",
|
||||
variables = mapOf(
|
||||
"player_name" to playerName,
|
||||
"activation_code" to token,
|
||||
"expire_time" to (expireTime?.let { sdf.format(it) } ?: "永久有效"),
|
||||
"valid_days" to (validDay?.toString() ?: "INF"),
|
||||
"time_year" to timeYear.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,258 @@
|
|||
package top.r3944realms.ltdmanager.utils
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.apache.logging.log4j.LogManager
|
||||
import org.apache.logging.log4j.Logger
|
||||
import org.apache.logging.log4j.core.LoggerContext
|
||||
import org.apache.logging.log4j.core.config.Configurator
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class LoggerUtil {
|
||||
companion object {
|
||||
val logger by lazy {
|
||||
LoggerFactory.getLogger("LTDManagerBot")
|
||||
object LoggerUtil {
|
||||
private val isShuttingDown = AtomicBoolean(false)
|
||||
private val shutdownHooks = mutableListOf<() -> Unit>()
|
||||
|
||||
val logger: Logger by lazy {
|
||||
LogManager.getLogger("LTDManagerBot")
|
||||
}
|
||||
|
||||
init {
|
||||
// 注册关闭钩子
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
shutdownGracefully()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册自定义关闭钩子
|
||||
*/
|
||||
fun addShutdownHook(hook: () -> Unit) {
|
||||
shutdownHooks.add(hook)
|
||||
}
|
||||
|
||||
/**
|
||||
* 优雅关闭日志系统
|
||||
*/
|
||||
fun shutdownGracefully() {
|
||||
if (isShuttingDown.getAndSet(true)) {
|
||||
return // 避免重复关闭
|
||||
}
|
||||
|
||||
try {
|
||||
// 输出关闭开始信息
|
||||
emergencyInfo("🚀 开始优雅关闭日志系统...")
|
||||
|
||||
// 先执行自定义关闭钩子(业务资源关闭)
|
||||
runCustomShutdownHooks()
|
||||
|
||||
// 刷新所有日志输出(确保日志文件保存)
|
||||
flushAllLogs()
|
||||
|
||||
// 给日志一些时间写入磁盘
|
||||
Thread.sleep(200)
|
||||
|
||||
// 关闭 Log4j2 上下文
|
||||
shutdownLog4j2()
|
||||
|
||||
// 最终确认
|
||||
println("🎉 日志系统关闭完成")
|
||||
} catch (e: Exception) {
|
||||
System.err.println("❌ 关闭过程中发生错误: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行自定义关闭钩子
|
||||
*/
|
||||
private fun runCustomShutdownHooks() {
|
||||
if (shutdownHooks.isNotEmpty()) {
|
||||
emergencyInfo("执行 ${shutdownHooks.size} 个自定义关闭钩子")
|
||||
shutdownHooks.forEachIndexed { index, hook ->
|
||||
try {
|
||||
emergencyInfo("执行关闭钩子 ${index + 1}")
|
||||
hook()
|
||||
} catch (e: Exception) {
|
||||
System.err.println("❌ 关闭钩子 ${index + 1} 执行失败: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新所有日志输出 - 针对 RollingFile Appender 优化
|
||||
*/
|
||||
fun flushAllLogs() {
|
||||
try {
|
||||
emergencyInfo("正在刷新日志输出...")
|
||||
|
||||
val context = LogManager.getContext(false)
|
||||
if (context is LoggerContext) {
|
||||
// 获取所有 logger 配置
|
||||
val loggers = context.configuration.loggers
|
||||
|
||||
loggers.forEach { (loggerName, loggerConfig) ->
|
||||
loggerConfig.appenders.forEach { (appenderName, appender) ->
|
||||
try {
|
||||
// 特别处理 RollingFileAppender
|
||||
if (appenderName.contains("File", ignoreCase = true)) {
|
||||
emergencyInfo("刷新文件 Appender: $appenderName")
|
||||
// 停止并重新启动以确保数据刷新
|
||||
appender.stop()
|
||||
// 短暂延迟确保文件操作完成
|
||||
Thread.sleep(50)
|
||||
appender.start()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
System.err.println("❌ 刷新 Appender $appenderName 失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 额外等待确保所有日志写入完成
|
||||
Thread.sleep(150)
|
||||
emergencyInfo("日志刷新完成")
|
||||
|
||||
} catch (e: Exception) {
|
||||
System.err.println("❌ 刷新日志失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 Log4j2 上下文 - 安全版本
|
||||
*/
|
||||
private fun shutdownLog4j2() {
|
||||
try {
|
||||
emergencyInfo("正在关闭 Log4j2 上下文...")
|
||||
|
||||
val context = LogManager.getContext(false)
|
||||
if (context is LoggerContext) {
|
||||
// 先停止所有 appender
|
||||
context.configuration.loggers.forEach { (_, loggerConfig) ->
|
||||
loggerConfig.appenders.values.forEach { appender ->
|
||||
try {
|
||||
appender.stop()
|
||||
} catch (e: Exception) {
|
||||
// 忽略停止错误
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 等待一段时间确保文件操作完成
|
||||
Thread.sleep(100)
|
||||
|
||||
// 关闭上下文
|
||||
context.stop()
|
||||
|
||||
// 使用 Configurator 进行完全关闭
|
||||
Configurator.shutdown(context)
|
||||
}
|
||||
|
||||
emergencyInfo("Log4j2 上下文关闭完成")
|
||||
|
||||
} catch (e: Exception) {
|
||||
System.err.println("❌ 关闭 Log4j2 上下文失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步信息输出(同时输出到控制台和日志)
|
||||
*/
|
||||
fun syncInfo(message: String) {
|
||||
println("[INFO] $message")
|
||||
if (!isShuttingDown.get()) {
|
||||
logger.info(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步调试输出
|
||||
*/
|
||||
fun syncDebug(message: String) {
|
||||
if (!isShuttingDown.get()) {
|
||||
logger.debug(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步错误输出
|
||||
*/
|
||||
fun syncError(message: String, exception: Exception? = null) {
|
||||
System.err.println("[ERROR] $message")
|
||||
exception?.let { System.err.println("[ERROR] Exception: ${it.message}") }
|
||||
|
||||
if (!isShuttingDown.get()) {
|
||||
if (exception != null) {
|
||||
logger.error(message, exception)
|
||||
} else {
|
||||
logger.error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步警告输出
|
||||
*/
|
||||
fun syncWarn(message: String, exception: Exception? = null) {
|
||||
println("[WARN] $message")
|
||||
exception?.let { println("[WARN] Exception: ${it.message}") }
|
||||
|
||||
if (!isShuttingDown.get()) {
|
||||
if (exception != null) {
|
||||
logger.warn(message, exception)
|
||||
} else {
|
||||
logger.warn(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 紧急输出(始终输出到控制台,尝试记录日志)
|
||||
*/
|
||||
fun emergencyInfo(message: String) {
|
||||
val formattedMessage = "[EMERGENCY] $message"
|
||||
println(formattedMessage)
|
||||
|
||||
// 即使正在关闭也尝试记录到日志文件
|
||||
if (!isShuttingDown.get()) {
|
||||
try {
|
||||
logger.info(formattedMessage)
|
||||
} catch (e: Exception) {
|
||||
// 如果日志系统已经关闭,忽略错误
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查日志系统是否正在关闭
|
||||
*/
|
||||
fun isLoggingShutdown(): Boolean = isShuttingDown.get()
|
||||
|
||||
/**
|
||||
* 强制刷新当前日志
|
||||
*/
|
||||
fun flushCurrentLogs() {
|
||||
if (!isShuttingDown.get()) {
|
||||
try {
|
||||
logger.info("手动刷新日志...")
|
||||
// Log4j2 通常会自动刷新,但可以强制调用
|
||||
val context = LogManager.getContext(false)
|
||||
if (context is LoggerContext) {
|
||||
context.configuration.loggers.forEach { (_, loggerConfig) ->
|
||||
loggerConfig.appenders.values.forEach { appender ->
|
||||
try {
|
||||
appender.stop()
|
||||
Thread.sleep(10)
|
||||
appender.start()
|
||||
} catch (e: Exception) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
System.err.println("手动刷新日志失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
#修改后再重启注释将会消失
|
||||
database:
|
||||
# 数据库地址
|
||||
url: "jdbc:mysql://localhost:3306/quizdb?useSSL=false&serverTimezone=UTC"
|
||||
|
|
@ -25,4 +26,20 @@ tools:
|
|||
mc-rcon-tool-config-path: "/path/to/rcon_config"
|
||||
server-url: "your.minecraft.server"
|
||||
# 格式为 ENC(XXX),若不是则会在加载完成配置后自动加密
|
||||
rcon-password: "123123dd"
|
||||
rcon-password: "123123dd"
|
||||
mail:
|
||||
protocol: "SMTP"
|
||||
host: "smtp.example.com"
|
||||
port: 587
|
||||
mail-address: "your_email@example.com"
|
||||
# 格式为 ENC(XXX),若不是则会在加载完成配置后自动加密
|
||||
encrypted-password: "your_password"
|
||||
auth: true
|
||||
tls: true
|
||||
blessing-skin-server:
|
||||
url: "https://your.blessing.server"
|
||||
invitation-api:
|
||||
path: "/api/invitation-codes/generate"
|
||||
# 格式为 ENC(XXX),若不是则会在加载完成配置后自动加密
|
||||
encrypted-token: "your-secret-token"
|
||||
|
||||
|
|
|
|||
39
src/main/resources/init.sql
Normal file
39
src/main/resources/init.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
DELIMITER //
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS ltd_manager_bot //
|
||||
|
||||
USE ltd_manager_bot //
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invitation_code_ascription(
|
||||
id int PRIMARY KEY REFERENCES minecraft_manager_ltd.players(id),
|
||||
token_id int unsigned NULL REFERENCES blessingskin.invitation_codes(id) )
|
||||
//;
|
||||
-- 也许token_id应该改名为code_id
|
||||
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
DELIMITER //
|
||||
DROP VIEW IF EXISTS qualified_user_info //
|
||||
CREATE VIEW qualified_user_info AS
|
||||
SELECT
|
||||
p.id AS player_id,
|
||||
p.player_name AS player_name,
|
||||
p.qq AS qq,
|
||||
ic.code AS token,
|
||||
ic.expires_at AS expires_at,
|
||||
CASE
|
||||
WHEN ic.is_expired = 0 THEN 1 -- 未过期 → 有效
|
||||
WHEN ic.used_by != 0 THEN 1 -- 已使用 → 有效
|
||||
ELSE 0 -- 过期且未使用 → 无效
|
||||
END AS effective,
|
||||
IF(ic.used_by != 0, 1, 0) AS is_used -- 是否使用
|
||||
|
||||
FROM (minecraft_manager_ltd.players p LEFT JOIN ltd_manager_bot.invitation_code_ascription ica ON p.id = ica.id) LEFT JOIN blessingskin.invitation_codes ic ON ica.token_id = ic.id
|
||||
WHERE p.status = 1; //
|
||||
|
||||
DELIMITER ;
|
||||
SELECT p1.id, invitation_codes.id
|
||||
FROM minecraft_manager_ltd.players p1 LEFT JOIN blessingskin.players p2 ON LOWER(p1.player_name) COLLATE utf8mb4_unicode_ci = LOWER(p2.name) COLLATE utf8mb4_unicode_ci JOIN blessingskin.invitation_codes ON p2.uid = invitation_codes.used_by
|
||||
WHERE used_by != 0; --
|
||||
-- uid 拿到token 去查询 ID
|
||||
109
src/main/resources/mail-body.html
Normal file
109
src/main/resources/mail-body.html
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<!--suppress HtmlRequiredTitleElement -->
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Microsoft YaHei', 'Segoe UI', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f7fa;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/*noinspection CssReplaceWithShorthandSafely*/
|
||||
.header {
|
||||
background-color: #0066cc; /* Outlook 桌面版会用这个 */
|
||||
background: linear-gradient(135deg, #0066cc, #004499);/* 现代客户端 */
|
||||
color: white;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 25px;
|
||||
}
|
||||
.code-box {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #0066cc;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 18px;
|
||||
color: #d32f2f;
|
||||
}
|
||||
.footer {
|
||||
background: #f0f2f5;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.warning {
|
||||
background: #fff8e1;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 12px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background: #0066cc;
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<h1 style="margin:0;font-weight:500;">LTD 专属服务器激活码</h1>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>尊敬的 <strong>{player_name}</strong>,您好:</p>
|
||||
|
||||
<p>感谢您选择LTD专属服务器,您的激活凭证如下:</p>
|
||||
|
||||
<div class="code-box">
|
||||
<div style="font-size:14px;color:#666;margin-bottom:5px;">激活码</div>
|
||||
<div style="font-size:22px;font-weight:bold;">{activation_code}</div>
|
||||
</div>
|
||||
|
||||
<div class="warning">
|
||||
<span style="color:#d32f2f;font-weight:bold;">⚠️ 重要提示:</span>
|
||||
本激活码与您的账号绑定,请勿泄露给他人。<br/>
|
||||
失效日期:<strong>{expire_time}</strong>
|
||||
</div>
|
||||
|
||||
<div class="warning">
|
||||
<span style="color:#0066cc;font-weight:bold;">⏳ 有效期说明:</span>
|
||||
本激活码自发放之日起 <strong>{valid_days} 天</strong> 内有效,过期将自动失效。
|
||||
</div>
|
||||
|
||||
<p>请点击下方按钮前往注册:</p>
|
||||
<a href="https://skins.r3944realms.top/auth/register" class="button">皮肤站注册</a>
|
||||
|
||||
<p style="margin-top:25px;">如有任何问题,欢迎随时联系我们的
|
||||
<a href="mailto:f256198830@hotmail.com" style="color:#0066cc;">技术支持</a>。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© {time_year} LTD 服务 | <a href="https://r3944realms.top" style="color:#0066cc;text-decoration:none;">官方网站</a></p>
|
||||
<p style="color:#999;font-size:11px;">此为群发邮件,请勿直接回复</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package top.r394realms.ltdmanagertest.mail
|
||||
|
||||
import top.r3944realms.ltdmanager.GlobalManager
|
||||
import top.r3944realms.ltdmanager.core.config.YamlConfigLoader
|
||||
import top.r3944realms.ltdmanager.core.mail.mail
|
||||
import top.r3944realms.ltdmanager.module.MailModule
|
||||
import top.r3944realms.ltdmanager.utils.HtmlTemplateUtil
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
fun main() = GlobalManager.runBlockingMain {
|
||||
val mailConfig = YamlConfigLoader.loadMailConfig()
|
||||
val mailModule = mailConfig.port?.let { portIt ->
|
||||
mailConfig.mailAddress?.let { mailAddressIt ->
|
||||
MailModule(
|
||||
host = mailConfig.host.toString(),
|
||||
authToken = mailConfig.decryptedPassword.toString(),
|
||||
port = portIt,
|
||||
senderEmailAddress = mailAddressIt,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (mailModule == null) throw IllegalStateException("Lost Required Argument")
|
||||
GlobalManager.moduleManager.registerModule(mailModule)
|
||||
|
||||
GlobalManager.moduleManager.loadModule(mailModule.name)
|
||||
val template = object {}.javaClass.classLoader
|
||||
.getResource("mail-body.html")?: throw IllegalArgumentException("模板文件未找到")
|
||||
val expireHours = 24 // 有效期 24 小时
|
||||
val expireTime = LocalDateTime.now().plusHours(expireHours.toLong())
|
||||
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
|
||||
val bodyC = HtmlTemplateUtil.renderTemplate(template.file.toString(), mapOf(
|
||||
"player_name" to "小明",
|
||||
"activation_code" to "ABC123",
|
||||
"expire_time" to expireTime,
|
||||
"valid_days" to "${expireHours/24}",
|
||||
"time_year" to "2025"
|
||||
))
|
||||
val mail = mail {
|
||||
from = "闲趣时坞"
|
||||
to += "f256198830@hotmail.com"
|
||||
subject = "=-="
|
||||
body = bodyC
|
||||
isHtml = true
|
||||
cc += "f256198830@outlook.com"
|
||||
}
|
||||
mailModule.enqueue(mail)
|
||||
}
|
||||
20
src/test/kotlin/top/r394realms/ltdmanagertest/test.kt
Normal file
20
src/test/kotlin/top/r394realms/ltdmanagertest/test.kt
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package top.r394realms.ltdmanagertest
|
||||
|
||||
import top.r3944realms.ltdmanager.GlobalManager
|
||||
import top.r3944realms.ltdmanager.module.GroupRequestHandlerModule
|
||||
|
||||
|
||||
fun main() = GlobalManager.runBlockingMain {
|
||||
// 创建模块实例
|
||||
val groupModule = GroupRequestHandlerModule(
|
||||
client = GlobalManager.napCatClient,
|
||||
targetGroupId = 538751386
|
||||
)
|
||||
|
||||
|
||||
// 注册模块到全局模块管理器
|
||||
GlobalManager.moduleManager.registerModule(groupModule)
|
||||
|
||||
// 加载模块
|
||||
GlobalManager.moduleManager.loadModule(groupModule.name)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user