Class PluginInitiationUtils

java.lang.Object
com.ultikits.ultitools.utils.PluginInitiationUtils

public class PluginInitiationUtils extends Object
Utility class for plugin initialization and WebSocket communication. Handles account login, WebSocket connection, and message processing for UltiPanel integration.
插件初始化和WebSocket通信的实用工具类。 处理UltiPanel集成的账户登录、WebSocket连接和消息处理。
Since:
6.0.0
Author:
wisdomme
  • Field Details

    • panelWS

      private static UltiPanelWebSocketClient panelWS
      WebSocket client for panel communication
    • token

      private static TokenEntity token
      Authentication token for API requests
    • SERVER_PROPERTIES_FILE

      private static final String SERVER_PROPERTIES_FILE
      server_properties 走独立管理器,不是一个真正的配置文件路径。
      See Also:
    • cloudEnabled

      private static final AtomicBoolean cloudEnabled
      云连接是否处于「应当保持连接」的状态。

      这是整条重连链的唯一开关。在它存在之前,有四个地方各自独立地决定「要不要继续 重连」,而谁都不是所有者:UltiPanelWebSocketClient.onClose 按每实例 5 次算、 reinitWebSocket 造新实例把计数清零、ulticloud logout 只清凭证根本不碰 状态机、只有 onDisable 真正拆得干净。结果是 logout 之后插件仍在拿已作废的凭证 持续敲面板。见 issue #181 与 #223。

      现在的规则只有一条:reinitWebSocket 只在本标志为 true 时才会重建连接。

    • cloudLifecycleLock

      private static final Object cloudLifecycleLock
      云管理器「接线」与「拆线」的互斥锁。

      cloudEnabled 单独用是不够的:它只能表达状态,表达不了「检查与动作之间不许有人插队」。 initializeManagers() 读到 true 之后、真正接线之前,disableCloud() 完全可以 插进来把开关置否并把监听器拆干净,随后前者继续往下又把它们装回去——logout 之后监听器 还在,甚至还能继续往面板发事件。见 PR #264 的两轮评审。

      两边都持这把锁之后,二者只能整体先后发生:要么先接线再拆掉(干净),要么先拆再接而接线 一侧在持锁复查时看到 false 直接返回(也干净)。

    • MAX_REINIT_ATTEMPTS

      private static final int MAX_REINIT_ATTEMPTS
      外层 reinit 的全局上限。超过之后进入终态,需要 /ulticloud login 或重启才恢复。
      See Also:
    • reinitBackoff

      private static final ExponentialBackoffStrategy reinitBackoff
      外层重连(reinit 循环)的全局预算与退避。

      客户端自身那 5 次是每实例的上限,而 reinitWebSocket 每次都造一个新实例, 于是每实例上限对整体毫无约束——这正是无界循环的成因。本策略是跨实例的, 只有一次成功的 onOpen 能把它重置。

      顺带启用了 ExponentialBackoffStrategy——它此前是同包内零引用的死代码。

  • Constructor Details

    • PluginInitiationUtils

      public PluginInitiationUtils()
  • Method Details

    • loginWithToken

      public static boolean loginWithToken(TokenEntity existingToken) throws IOException
      Login to UltiPanel using an existing token (from magic-link or saved token). Registers or updates the server without needing username/password.
      使用现有令牌登录UltiPanel(来自魔法链接或保存的令牌)。
      Parameters:
      existingToken - the pre-authenticated token
      Returns:
      true if server registration/update succeeded
      Throws:
      IOException - if an I/O error occurs
    • initWebsocket

      public static void initWebsocket() throws IOException
      Initialize websocket.
      初始化websocket。
      Throws:
      IOException
    • onWebSocketOpened

      static void onWebSocketOpened(UltiPanelWebSocketClient client)
      握手成功之后的接线动作。

      参数就是这次握手自己的客户端,方法体内绝不重读静态 panelWS onOpen 是异步回调:跑到这里时 disableCloud() 可能已经把静态字段置空 (/ulticloud logout,或重连预算耗尽——后者跑在 WebSocket 线程上), 也可能 reinitWebSocket 已经把它换成了另一个实例。重读静态字段的话, subscribeToServer / uploadConfig / uploadServerProperties 三处都会踩空——initializeManagers() 有持锁复查护着,它前后的代码没有。

      往一个已断开的客户端发消息是安全的:sendMessage 在未连接时打一条 WARNING 就返回。真正危险的是空引用,所以这里解决的是引用稳定性,不是连接状态。

      包级可见而非 private —— 只为可测。要在测试里触发它,否则得有真实的鉴权 token 与真实的 WebSocket 握手;与 handleInboundMessage(com.google.gson.JsonObject) 同一处理。

    • handleInboundMessage

      static void handleInboundMessage(com.google.gson.JsonObject message)
      处理面板下发的入站 WebSocket 消息。

      initWebsocket() 的 lambda 中提取出来,唯一目的是让它可以被单元测试直接调用: 原先它是 setMessageHandler 的匿名 lambda,要构造它需要真实的鉴权 token 与真实的 WebSocket 客户端,畸形输入这条路径因此完全没有测试覆盖。见 issue #234。

      包级可见而非 public —— 它不是对外 API,只是为了可测。

      Parameters:
      message - 面板下发的消息,允许为 null
    • initializeManagers

      static void initializeManagers()
      把所有 WebSocket 管理器接到当前连接上。

      本方法挂在 onConnectHandler 上,而 /ulticloud logout 之后仍可能有一次 在途的握手落地。不设防的话,disableCloud() 刚摘掉的监听器会被这次迟到的 onOpen 原样装回去——这正是 #181/#223 里「谁都不是所有者」那个毛病换个地方重现。

      光检查 cloudEnabled 是不够的。那只是一次锁外的读:读到 true 之后、 真正接线之前,disableCloud() 完全可以插进来把开关置否并拆干净,然后本方法 继续往下把监听器又装回去。所以接线与拆线必须落在同一把 cloudLifecycleLock 上, 并在持锁期间复查开关。见 PR #264 的两轮评审。

      包级可见而非 private —— 只为可测。

    • wireManagers

      private static void wireManagers()
      initializeManagers() 的实际接线动作。调用方必须持有 cloudLifecycleLock
    • handleConfigUpdate

      static void handleConfigUpdate(com.google.gson.JsonObject data)
      处理配置更新。

      这条路径过去有三处与面板对不上,而且失败是静默的(issue #236):面板在 data.configData 里发内容、这里读 data.config;面板用 data.fileName 指定文件、这里除 server_properties 外从不读它; 面板不发 requestId、这里以 requestId 存在与否作为「是不是一条请求」 的判据,缺了就只记一行 Level.FINE 然后丢弃——而 FINE 在默认日志 配置下不打印。于是面板收到 HTTP 200、服务器上什么也没变、两端都不报错。

      现在的判据换成「有没有配置内容」:没有内容才是回声/回执,有内容就是一条请求, 缺 requestId 也照样应用,只是记 WARNING 说明结果无法回报。

      包级可见而非 private —— 只为可测。

    • readString

      private static String readString(com.google.gson.JsonObject data, String field)
      读一个可能缺失、也可能是 JSON null 的字符串字段。
    • readConfigContent

      private static String readConfigContent(com.google.gson.JsonObject data)
      取配置内容,优先 data.configData

      data.config 是本方法过去唯一读的字段,但在树内找不到任何生产者—— 面板一直发的是 configData。保留它只是为了兼容可能存在的第三方面板, 读到就记废弃日志。

    • applyConfigUpdate

      private static void applyConfigUpdate(String fileName, String configContent) throws IOException
      fileName 决定写到哪里。

      三条分支对应三种载荷形状,这是原来「fileName 从不读」掩盖掉的东西: server_properties 是一份扁平的属性表,交给专用管理器; 指定了文件名就是那一个配置文件自己的 {配置项: 值}; 没有文件名才是 ConfigManager.toJson() 那种全量嵌套结构。

      Throws:
      IOException
    • sendConfigUpdateResponse

      private static void sendConfigUpdateResponse(String requestId, boolean success, String error)
      回一条 config_update_response

      载荷放在 data 里,与其余所有 插件→Worker 的消息一致 (见 CommandExecutionManager.sendCommandResult)。此前这一条是扁平写法, 字段直接挂在顶层;Worker 侧两种都读(ultipanel-api-worker#30),所以这次改动 不需要和面板同时上线。

    • handlePing

      private static void handlePing(com.google.gson.JsonObject message)
      处理ping消息
    • handlePong

      private static void handlePong(com.google.gson.JsonObject data)
      处理pong消息
    • handleSubscribe

      private static void handleSubscribe(com.google.gson.JsonObject data)
      处理订阅消息
    • handleUnsubscribe

      private static void handleUnsubscribe(com.google.gson.JsonObject data)
      处理取消订阅消息
    • handleNotification

      private static void handleNotification(com.google.gson.JsonObject data)
      处理通知消息
    • handleError

      private static void handleError(com.google.gson.JsonObject data)
      处理错误消息
    • handlePlayerEvent

      private static void handlePlayerEvent(com.google.gson.JsonObject data)
      处理玩家事件
    • handleCommandResult

      private static void handleCommandResult(com.google.gson.JsonObject data)
      处理命令执行结果
    • handleFileOperationResult

      private static void handleFileOperationResult(com.google.gson.JsonObject data)
      处理文件操作结果
    • handleBackupOperation

      private static void handleBackupOperation(com.google.gson.JsonObject data)
      处理备份操作
    • handleBackupProgress

      private static void handleBackupProgress(com.google.gson.JsonObject data)
      处理备份进度
    • handleConfigUpload

      private static void handleConfigUpload(com.google.gson.JsonObject data)
      处理配置上传
    • handleConfigUploadLogic

      private static void handleConfigUploadLogic(com.google.gson.JsonObject data) throws Exception
      处理配置上传逻辑
      Throws:
      Exception
    • sendErrorResponse

      private static void sendErrorResponse(String errorMessage)
      发送错误响应
    • handlePluginListRequest

      private static void handlePluginListRequest(com.google.gson.JsonObject data)
      处理插件列表请求
    • handleServerStatusRequest

      private static void handleServerStatusRequest(com.google.gson.JsonObject data)
      处理服务器状态请求
    • handleMetricsRequest

      private static void handleMetricsRequest(com.google.gson.JsonObject data)
      处理性能数据请求
    • uploadConfig

      private static void uploadConfig(UltiPanelWebSocketClient client)
      上传本地配置到服务器
    • uploadServerProperties

      private static void uploadServerProperties(UltiPanelWebSocketClient client)
      Upload server.properties safe keys to cloud for panel editing.
    • reinitWebSocket

      public static void reinitWebSocket()
      Re-initialize the WebSocket connection with a fresh token. Disconnects the old client (if any), refreshes the token if needed, and creates a new WebSocket client.
      使用新令牌重新初始化WebSocket连接。
    • disableCloud

      public static void disableCloud()
      关闭云连接并让重连状态机进入明确的 disabled 态。

      /ulticloud logout 调用。与 stopWebsocket() 的区别是:后者只断开当前 客户端,而重连链会把它重新拉起来;本方法先把 cloudEnabled 置否,因此 reinitWebSocket() 之后会直接返回,状态机不会自我复活。

      顺带摘掉 root logger 上的日志 handler 与传输线程,并停掉 token 刷新调度—— 都是「云功能已关闭」这句话应当为真的组成部分。

      整个方法持有 cloudLifecycleLock,与 initializeManagers() 互斥。 不然的话,一次在途的 onOpen 可以在「置否」与「拆线」之间挤进来,把刚要拆的东西 又装回去。见 PR #264 的两轮评审。

    • doDisableCloud

      private static void doDisableCloud()
      disableCloud() 的实际拆线动作。调用方必须持有 cloudLifecycleLock
    • teardownStep

      private static void teardownStep(String what, Runnable action)
      跑一步拆线动作,失败只记 FINE 不向外抛。

      拆线的每一步都必须尽力执行完:任何一步抛出去都会让它后面的步骤被跳过,而那些 步骤正是「云功能已关闭」这句话的组成部分。原先这是六段一模一样的 try/catch, 提取出来只是把那个不变量说清楚一次,行为不变。

      Parameters:
      what - 失败时写进日志的动作描述
      action - 拆线动作
    • onWebSocketConnected

      static void onWebSocketConnected()
      重连成功时调用:把外层预算清零。

      只有真正握手成功才配重置预算。若在 reinitWebSocket 里重置, 那么「造出了一个客户端」就会被当成成功,预算永远用不完,闸门等于没加。

    • activateCloudIfCurrent

      public static boolean activateCloudIfCurrent(long generation) throws IOException
      在云生命周期锁内,原子地「复查代际 → 开启状态机 → 建连 → 起刷新调度」。

      只让写凭证那一步对 logout 原子是不够的:magic-link 轮询在提交凭证之后还要做 enableCloud() + initWebsocket() + startTokenRefreshScheduler(), 这一串才是真正把服务器连回去的动作。logout 挤在「提交成功」与「开始激活」之间的话, 拆线拆的是一个还没建起来的连接,随后轮询线程照样把它建起来——logout 于是被撤销。

      这里与 disableCloud() 抢同一把 cloudLifecycleLock,因此二者只能整体 先后发生:要么先激活再被拆掉(干净),要么先拆线、本方法持锁复查代际时看到已变而 直接返回 false(也干净)。

      锁内刻意loginWithToken() —— 那是一次 HTTP 往返,持锁做会让 /ulticloud logout 在主线程上阻塞数秒。它只向面板注册服务器,不改本地状态, 放在锁外重复执行也无害。

      Parameters:
      generation - 调用方出发时记下的凭证代际
      Returns:
      已激活返回 true;代际已变、激活被放弃则返回 false
      Throws:
      IOException - 建连失败
    • enableCloud

      public static void enableCloud()
      把状态机置为「应当保持连接」,并清零外层重连预算。

      只有显式动作才应当调用它:服务器启动时的云登录,以及 /ulticloud login 成功之后。initWebsocket() 刻意不调——它同时被 reinitWebSocket() 复用, 在那里置位会让一个正在途中的重连把刚被 logout 关掉的状态机重新拉起来。

    • isCloudEnabled

      static boolean isCloudEnabled()
      供测试断言状态机是否处于启用态。
    • stopWebsocket

      public static void stopWebsocket()
    • getPanelWebsocketClient

      private static UltiPanelWebSocketClient getPanelWebsocketClient() throws IOException
      Throws:
      IOException
    • safeGetString

      private static String safeGetString(com.google.gson.JsonObject obj, String key)
    • safeGetBoolean

      private static boolean safeGetBoolean(com.google.gson.JsonObject obj, String key, boolean defaultValue)
    • safeGetLong

      private static long safeGetLong(com.google.gson.JsonObject obj, String key, long defaultValue)
    • safeGetDouble

      private static double safeGetDouble(com.google.gson.JsonObject obj, String key, double defaultValue)