// +---------------------------------------------------------------------- namespace addon\mqttbroker\service; use think\facade\Db; /** * MQTT 持久化服务:保留消息 + 离线消息队列 * * - 保留消息(retained):broker 重启后从 DB 恢复,保证新订阅者仍能收到最后一条。 * - 离线消息(offline):clean=false 会话离线期间投递给它的 QoS1/2 消息入库, * 客户端重连后取出补投。 * * 均为二进制安全(longblob)。所有操作 try/catch,DB 不可用不影响 broker 运行。 * 由 persist_retain / persist_offline 配置开关控制。 */ class Store { /** @var bool 启用保留消息持久化 */ protected $persistRetain; /** @var bool 启用离线消息持久化 */ protected $persistOffline; /** @var int 单客户端离线消息上限,超出丢弃最旧 */ protected $offlineLimit; public function __construct(array $config) { $this->persistRetain = (string) ($config['persist_retain'] ?? '1') === '1'; $this->persistOffline = (string) ($config['persist_offline'] ?? '1') === '1'; $this->offlineLimit = (int) ($config['offline_limit'] ?? 1000); } public function ensureTables(): void { try { Db::execute("CREATE TABLE IF NOT EXISTS `wxapp_mqttbroker_retain` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `topic` varchar(255) NOT NULL DEFAULT '', `payload` longblob, `qos` tinyint(1) NOT NULL DEFAULT '0', `update_at` int DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_topic` (`topic`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); Db::execute("CREATE TABLE IF NOT EXISTS `wxapp_mqttbroker_offline` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `client_id` varchar(191) NOT NULL DEFAULT '', `topic` varchar(255) NOT NULL DEFAULT '', `payload` longblob, `qos` tinyint(1) NOT NULL DEFAULT '0', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_client_id` (`client_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); Db::execute("CREATE TABLE IF NOT EXISTS `wxapp_mqttbroker_outbox` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `topic` varchar(255) NOT NULL DEFAULT '', `payload` longblob, `qos` tinyint(1) NOT NULL DEFAULT '0', `retain` tinyint(1) NOT NULL DEFAULT '0', `source` varchar(32) DEFAULT 'manual', `create_at` int DEFAULT NULL, `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0待投递 1已投递', PRIMARY KEY (`id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); } catch (\Throwable $e) { } } /* ---------------- 手动发布出站队列 ---------------- */ /** * 入队一条手动发布(后台/API 发布经 DB 出栈,Broker 消费后内部路由,零外部依赖) */ public static function enqueueManual(string $topic, string $payload, int $qos, int $retain, string $source): bool { try { Db::name('mqttbroker_outbox')->insert([ 'topic' => $topic, 'payload' => $payload, 'qos' => $qos, 'retain' => $retain ? 1 : 0, 'source' => $source, 'create_at' => time(), 'status' => 0, ]); return true; } catch (\Throwable $e) { return false; } } /** * 取出待投递的出站消息(status=0) * @return array */ public function popOutbox(int $limit = 100): array { $out = []; try { $out = Db::name('mqttbroker_outbox')->where('status', 0) ->order('id', 'asc')->limit($limit)->select()->toArray(); } catch (\Throwable $e) { } return $out; } /** * 标记出站消息已投递(避免重复路由) */ public function markOutboxSent(array $ids): void { if (empty($ids)) { return; } try { Db::name('mqttbroker_outbox')->whereIn('id', $ids)->update(['status' => 1]); } catch (\Throwable $e) { } } /* ---------------- 保留消息 ---------------- */ /** * 启动时加载全部保留消息 * @return array topic => ['payload'=>string,'qos'=>int] */ public function loadRetained(): array { if (!$this->persistRetain) { return []; } $out = []; try { foreach (Db::name('mqttbroker_retain')->select() as $row) { $out[$row['topic']] = ['payload' => (string) $row['payload'], 'qos' => (int) $row['qos']]; } } catch (\Throwable $e) { } return $out; } public function saveRetained(string $topic, string $payload, int $qos): void { if (!$this->persistRetain) { return; } try { $exists = Db::name('mqttbroker_retain')->where('topic', $topic)->find(); if ($exists) { Db::name('mqttbroker_retain')->where('id', $exists['id']) ->update(['payload' => $payload, 'qos' => $qos, 'update_at' => time()]); } else { Db::name('mqttbroker_retain')->insert([ 'topic' => $topic, 'payload' => $payload, 'qos' => $qos, 'update_at' => time(), ]); } } catch (\Throwable $e) { } } public function deleteRetained(string $topic): void { if (!$this->persistRetain) { return; } try { Db::name('mqttbroker_retain')->where('topic', $topic)->delete(); } catch (\Throwable $e) { } } /* ---------------- 离线消息 ---------------- */ public function queueOffline(string $clientId, string $topic, string $payload, int $qos): void { if (!$this->persistOffline) { return; } try { Db::name('mqttbroker_offline')->insert([ 'client_id' => $clientId, 'topic' => $topic, 'payload' => $payload, 'qos' => $qos, 'create_at' => time(), ]); // 超限裁剪:保留最新 offlineLimit 条 $count = Db::name('mqttbroker_offline')->where('client_id', $clientId)->count(); if ($count > $this->offlineLimit) { $ids = Db::name('mqttbroker_offline')->where('client_id', $clientId) ->order('id', 'asc')->limit($count - $this->offlineLimit)->column('id'); if ($ids) { Db::name('mqttbroker_offline')->whereIn('id', $ids)->delete(); } } } catch (\Throwable $e) { } } /** * 取出并清空某客户端的离线消息 * @return array */ public function popOffline(string $clientId): array { if (!$this->persistOffline) { return []; } $out = []; try { $rows = Db::name('mqttbroker_offline')->where('client_id', $clientId)->order('id', 'asc')->select(); foreach ($rows as $r) { $out[] = ['topic' => $r['topic'], 'payload' => (string) $r['payload'], 'qos' => (int) $r['qos']]; } if (!empty($out)) { Db::name('mqttbroker_offline')->where('client_id', $clientId)->delete(); } } catch (\Throwable $e) { } return $out; } }