chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+238
View File
@@ -0,0 +1,238 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace addon\mqttbroker\service;
use think\facade\Db;
/**
* MQTT 认证与 ACL 服务
*
* 认证:
* - allow_anonymous=1:不强制账号;若客户端提供了用户名,则仍校验其密码(存在账号时)。
* - allow_anonymous=0:必须提供用户名+密码且匹配启用中的账号。
* 密码兼容 bcrypt 哈希与明文(明文仅便于开发,生产请用哈希)。
*
* ACLacl_enabled=1 时生效):
* - 规则表按 sort 升序、id 升序匹配,命中第一条即决定 allow/deny。
* - target_typeall(全部) / user(按用户名) / client(按 clientId)。
* - topic 支持 MQTT 通配符(+ / #)与占位符 %u(用户名) %c(clientId)。
* - access1 订阅 / 2 发布 / 3 两者。
* - 超级用户(is_superuser=1)跳过 ACL。
* - $SYS/# 始终允许订阅、禁止发布。
* - 无命中时采用默认策略 acl_defaultallow/deny)。
*
* 表结构见 install.sql;本类启动时 ensureTables() 自愈建表。
*/
class Auth
{
const ACT_SUB = 1;
const ACT_PUB = 2;
/** @var array 运行配置 */
protected $config;
/** @var bool 是否启用 ACL */
protected $aclEnabled;
/** @var bool ACL 默认放行 */
protected $aclDefaultAllow;
/** @var array ACL 规则缓存 */
protected $aclRules = [];
/** @var int ACL 缓存刷新时间戳 */
protected $aclLoadedAt = 0;
/** @var int 缓存有效期(秒) */
protected $aclTtl = 30;
public function __construct(array $config)
{
$this->config = $config;
$this->aclEnabled = (string) ($config['acl_enabled'] ?? '0') === '1';
$this->aclDefaultAllow = (string) ($config['acl_default'] ?? 'allow') !== 'deny';
}
/**
* 建表自愈(未执行 install.sql / 老版本插件也可用)
*/
public function ensureTables(): void
{
try {
Db::execute("CREATE TABLE IF NOT EXISTS `wxapp_mqttbroker_auth` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(191) NOT NULL DEFAULT '',
`password` varchar(191) NOT NULL DEFAULT '',
`is_superuser` tinyint(1) NOT NULL DEFAULT '0',
`status` tinyint(1) NOT NULL DEFAULT '1',
`remark` varchar(255) DEFAULT NULL,
`create_at` int DEFAULT NULL,
`update_at` int DEFAULT NULL,
PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
Db::execute("CREATE TABLE IF NOT EXISTS `wxapp_mqttbroker_acl` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`target_type` varchar(16) NOT NULL DEFAULT 'all',
`target` varchar(191) NOT NULL DEFAULT '',
`topic` varchar(255) NOT NULL DEFAULT '',
`access` tinyint(1) NOT NULL DEFAULT '3',
`allow` tinyint(1) NOT NULL DEFAULT '1',
`sort` int NOT NULL DEFAULT '0',
`remark` varchar(255) DEFAULT NULL,
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_sort` (`sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
} catch (\Throwable $e) {
}
}
/**
* 认证
* @return array{ok:bool, superuser:bool, reason:string}
*/
public function authenticate(?string $username, ?string $password, string $clientId): array
{
$anonymous = (string) ($this->config['allow_anonymous'] ?? '1') === '1';
if ($username === null || $username === '') {
// 未提供用户名:匿名开则放行,否则拒绝
return $anonymous
? ['ok' => true, 'superuser' => false, 'reason' => 'anonymous']
: ['ok' => false, 'superuser' => false, 'reason' => 'username required'];
}
// 提供了用户名:查账号
$acc = null;
try {
$acc = Db::name('mqttbroker_auth')->where('username', $username)->find();
} catch (\Throwable $e) {
// 表不存在等异常:匿名开则放行
return $anonymous
? ['ok' => true, 'superuser' => false, 'reason' => 'auth-store-unavailable']
: ['ok' => false, 'superuser' => false, 'reason' => 'auth store unavailable'];
}
if (!$acc) {
// 无此账号:匿名开时视为普通匿名用户(用户名仅作标识),否则拒绝
return $anonymous
? ['ok' => true, 'superuser' => false, 'reason' => 'anonymous-named']
: ['ok' => false, 'superuser' => false, 'reason' => 'account not found'];
}
if ((int) $acc['status'] !== 1) {
return ['ok' => false, 'superuser' => false, 'reason' => 'account disabled'];
}
if (!$this->verifyPassword((string) $password, (string) $acc['password'])) {
return ['ok' => false, 'superuser' => false, 'reason' => 'bad password'];
}
return ['ok' => true, 'superuser' => (int) $acc['is_superuser'] === 1, 'reason' => 'ok'];
}
/**
* 密码校验:bcrypt/argon 走 password_verify,其余按明文比较
*/
protected function verifyPassword(string $input, string $stored): bool
{
if ($stored === '') {
return false;
}
if (preg_match('/^\$(2y|2a|argon2)/', $stored)) {
return password_verify($input, $stored);
}
return hash_equals($stored, $input);
}
/**
* ACL 校验
* @param int $action self::ACT_SUB | self::ACT_PUB
*/
public function checkAcl(?string $username, string $clientId, string $topic, int $action, bool $superuser = false): bool
{
// $SYS 特殊处理:只读
if (strncmp($topic, '$SYS', 4) === 0) {
return $action === self::ACT_SUB;
}
if (!$this->aclEnabled || $superuser) {
return true;
}
$this->loadAclRules();
$username = (string) $username;
foreach ($this->aclRules as $r) {
$acc = (int) $r['access'];
if ($acc !== 3 && $acc !== $action) {
continue;
}
$type = $r['target_type'];
if ($type === 'member' && $r['target'] !== $username) {
continue;
}
if ($type === 'client' && $r['target'] !== $clientId) {
continue;
}
$filter = str_replace(['%u', '%c'], [$username, $clientId], (string) $r['topic']);
if ($this->topicMatch($filter, $topic)) {
return (int) $r['allow'] === 1;
}
}
return $this->aclDefaultAllow;
}
/**
* 加载 ACL 规则(带 TTL 缓存)
*/
protected function loadAclRules(): void
{
$now = time();
if ($now - $this->aclLoadedAt < $this->aclTtl && $this->aclLoadedAt > 0) {
return;
}
try {
$this->aclRules = Db::name('mqttbroker_acl')->order('sort', 'asc')->order('id', 'asc')->select()->toArray();
} catch (\Throwable $e) {
$this->aclRules = [];
}
$this->aclLoadedAt = $now;
}
/**
* 主题过滤器匹配(与 Broker 中一致的 + / # 规则)
*/
protected function topicMatch(string $filter, string $topic): bool
{
if ($filter === $topic) {
return true;
}
$f = explode('/', $filter);
$t = explode('/', $topic);
foreach ($f as $i => $seg) {
if ($seg === '#') {
return true;
}
if ($seg === '+') {
if (!isset($t[$i])) {
return false;
}
continue;
}
if (!isset($t[$i]) || $seg !== $t[$i]) {
return false;
}
}
return count($f) === count($t);
}
/**
* 生成密码哈希(供后台创建账号使用)
*/
public static function hashPassword(string $plain): string
{
return password_hash($plain, PASSWORD_DEFAULT);
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace addon\mqttbroker\service;
/**
* 跨进程消息桥接(基于 Redis Stream
*
* 背景:Workerman 每个 Worker 是独立进程,内存不共享。要让 TCP 监听进程与
* WebSocket 监听进程(以及 count>1 的多进程)之间互通消息,需要一个共享总线。
*
* 方案:
* - 发布:每条消息 XADD 到 Redis Stream(带来源 nodeId,用于去重)。
* - 消费:每个进程用非阻塞 XREAD 周期轮询该 Stream(不阻塞事件循环),
* 收到非自身产生的消息后投递给本进程的本地订阅者。
* - Stream 通过 MAXLEN 近似裁剪,避免无限增长。
*
* 开关:cluster_enabled=1 且 Redis 可连时启用;否则 isEnabled()=false
* Broker 退化为单进程本地路由,功能不受影响。
*
* 已知限制:跨进程的共享订阅($share/)会在每个进程各自 round-robin
* 若同一共享组成员分布在不同进程,可能重复投递(同进程内正确)。
*/
class Bridge
{
/** @var array */
protected $config;
/** @var bool */
protected $enabled = false;
/** @var \Redis|null */
protected $redis;
/** @var string 本进程唯一标识 */
protected $nodeId;
/** @var string Stream key */
protected $stream;
/** @var string 读取游标 */
protected $lastId = '0-0';
/** @var int Stream 近似最大长度 */
protected $maxlen;
public function __construct(array $config)
{
$this->config = $config;
$this->nodeId = getmypid() . '-' . substr(md5(uniqid('', true)), 0, 6);
$prefix = $config['redis_prefix'] ?? 'mqttbroker';
$this->stream = $prefix . ':bus';
$this->maxlen = (int) ($config['bridge_maxlen'] ?? 10000);
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function nodeId(): string
{
return $this->nodeId;
}
/**
* 连接 Redis 并初始化游标(须在 worker 进程内调用,即 onWorkerStart
*/
public function connect(): bool
{
if ((string) ($this->config['cluster_enabled'] ?? '0') !== '1') {
$this->enabled = false;
return false;
}
if (!class_exists(\Redis::class)) {
$this->enabled = false;
return false;
}
try {
$r = new \Redis();
$r->connect($this->config['redis_host'] ?? '127.0.0.1', (int) ($this->config['redis_port'] ?? 6379), 1.5);
if (!empty($this->config['redis_password'])) {
$r->auth((string) $this->config['redis_password']);
}
if (isset($this->config['redis_db']) && $this->config['redis_db'] !== '') {
$r->select((int) $this->config['redis_db']);
}
$r->ping();
$this->redis = $r;
// 从当前末尾开始消费,忽略历史积压
$last = $r->xRevRange($this->stream, '+', '-', 1);
$this->lastId = $last ? (string) array_key_first($last) : '0-0';
$this->enabled = true;
} catch (\Throwable $e) {
$this->enabled = false;
}
return $this->enabled;
}
/**
* 将消息投递到总线(供其它进程消费)
*/
public function publish(string $topic, string $msg, int $qos, int $retain, ?string $from): void
{
if (!$this->enabled) {
return;
}
try {
$payload = json_encode([
'n' => $this->nodeId,
't' => $topic,
'p' => base64_encode($msg),
'q' => $qos,
'r' => $retain,
'f' => (string) $from,
], JSON_UNESCAPED_UNICODE);
// 近似裁剪,减少 XADD 开销
$this->redis->xAdd($this->stream, '*', ['d' => $payload], $this->maxlen, true);
} catch (\Throwable $e) {
$this->reconnect();
}
}
/**
* 非阻塞轮询总线,对每条非本进程消息回调 $cb($topic,$msg,$qos,$retain,$from)
*/
public function poll(callable $cb): void
{
if (!$this->enabled) {
return;
}
try {
$res = $this->redis->xRead([$this->stream => $this->lastId], 200); // 非阻塞,最多取 200 条
if (!$res || empty($res[$this->stream])) {
return;
}
foreach ($res[$this->stream] as $id => $fields) {
$this->lastId = (string) $id;
$d = json_decode($fields['d'] ?? '', true);
if (!is_array($d)) {
continue;
}
if (($d['n'] ?? '') === $this->nodeId) {
continue; // 跳过自身产生的消息,避免回环
}
$cb(
(string) ($d['t'] ?? ''),
base64_decode($d['p'] ?? ''),
(int) ($d['q'] ?? 0),
(int) ($d['r'] ?? 0),
$d['f'] ?? null
);
}
} catch (\Throwable $e) {
$this->reconnect();
}
}
protected function reconnect(): void
{
$this->enabled = false;
try {
$this->connect();
} catch (\Throwable $e) {
}
}
}
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace addon\mqttbroker\service;
use Workerman\Mqtt\Client;
/**
* 外部 Broker 转发器(规则引擎的投递通道)
*
* 将本地匹配到的消息转发到外部 MQTT Broker(如 EMQX),实现"桥接到 EMQX"。
* 每个目标 Broker 维护一个异步常驻连接(基于 Workerman\Mqtt\Client,复用 Broker 的事件循环),
* 连接未就绪时消息进入待发缓冲(上限 200,避免无限堆积),连接成功后冲刷。
*
* 依赖 workerman/mqttcomposer require workerman/mqtt)。未安装时 isAvailable()=false
* 规则引擎自动跳过转发,不影响 Broker 其它功能。
*/
class Forwarder
{
/** @var array key => ['client'=>Client|null,'connected'=>bool,'pending'=>callable[]] */
protected $clients = [];
public function isAvailable(): bool
{
return class_exists(Client::class);
}
/**
* 按规则转发一条消息到外部 Broker
*/
public function forward(array $rule, string $topic, string $msg, int $qos): void
{
if (!$this->isAvailable()) {
return;
}
$key = md5(($rule['target_broker'] ?? '') . '|' . ($rule['target_clientid'] ?? '') . '|' . ($rule['target_username'] ?? ''));
if (!isset($this->clients[$key]) || $this->clients[$key]['client'] === null) {
$this->connect($key, $rule);
}
$entry = &$this->clients[$key];
$target = $this->buildTargetTopic((string) ($rule['target_topic'] ?? ''), $topic);
$pubQos = min($qos, (int) ($rule['target_qos'] ?? 0));
$send = function () use ($entry, $target, $msg, $pubQos) {
try {
$entry['client']->publish($target, $msg, ['qos' => $pubQos]);
} catch (\Throwable $e) {
}
};
if ($entry['connected']) {
$send();
} elseif (count($entry['pending']) < 200) {
$entry['pending'][] = $send;
}
}
/**
* 建立到目标 Broker 的异步连接(懒连接)
*/
protected function connect(string $key, array $rule): void
{
$this->clients[$key] = ['client' => null, 'connected' => false, 'pending' => []];
try {
$uri = $this->buildUri((string) ($rule['target_broker'] ?? ''));
$opts = [];
if (!empty($rule['target_clientid'])) {
$opts['clientId'] = $rule['target_clientid'];
}
if (!empty($rule['target_username'])) {
$opts['username'] = $rule['target_username'];
$opts['password'] = $rule['target_password'] ?? '';
}
$client = new Client($uri, $opts);
$self = $this;
$client->onConnect = function () use ($key, $self) {
$self->clients[$key]['connected'] = true;
foreach ($self->clients[$key]['pending'] as $cb) {
try { $cb(); } catch (\Throwable $e) {
}
}
$self->clients[$key]['pending'] = [];
};
$client->onError = function () use ($key, $self) {
$self->clients[$key]['connected'] = false;
};
$client->onClose = function () use ($key, $self) {
$self->clients[$key]['connected'] = false;
};
$client->connect();
$this->clients[$key]['client'] = $client;
} catch (\Throwable $e) {
$this->clients[$key] = ['client' => null, 'connected' => false, 'pending' => []];
}
}
protected function buildUri(string $broker): string
{
$broker = trim($broker);
if ($broker === '' || strpos($broker, '://') !== false) {
return $broker === '' ? 'mqtt://127.0.0.1:1883' : $broker;
}
return 'mqtt://' . $broker;
}
/**
* 目标主题模板:空或 ${topic} 表示沿用原主题,否则替换变量
*/
protected function buildTargetTopic(string $tpl, string $srcTopic): string
{
if ($tpl === '' || $tpl === '${topic}') {
return $srcTopic;
}
return str_replace('${topic}', $srcTopic, $tpl);
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace addon\mqttbroker\service;
use think\facade\Db;
/**
* 规则引擎:匹配源主题过滤器后,将消息转发到外部 Broker(桥接到 EMQX
*
* 规则表 wxapp_mqttbroker_rule
* source_filter 源主题过滤器(支持 + / #)
* target_type mqtt(当前仅支持转发到外部 MQTT Broker
* target_broker 外部地址 host:port
* target_topic 目标主题模板,支持 ${topic} 变量
* target_qos 转发 QoS
* enabled 1启用 0停用
*
* 行为:
* - 仅对本地产生的消息生效(distribute 本地路径调用),桥接/外部回流消息不二次转发,避免回环。
* - $SYS 系统主题不转发。
* - 规则带 30s TTL 缓存,新增/修改后最多 30s 生效。
*/
class RuleEngine
{
/** @var array */
protected $config;
/** @var Forwarder */
protected $forwarder;
/** @var array 规则缓存 */
protected $rules = [];
/** @var int 缓存刷新时间戳 */
protected $loadedAt = 0;
/** @var int 缓存有效期(秒) */
protected $ttl = 30;
public function __construct(array $config, Forwarder $forwarder)
{
$this->config = $config;
$this->forwarder = $forwarder;
}
public function ensureTables(): void
{
try {
Db::execute("CREATE TABLE IF NOT EXISTS `wxapp_mqttbroker_rule` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(191) NOT NULL DEFAULT '',
`source_filter` varchar(255) NOT NULL DEFAULT '',
`target_type` varchar(16) NOT NULL DEFAULT 'mqtt',
`target_broker` varchar(255) NOT NULL DEFAULT '',
`target_clientid` varchar(191) DEFAULT '',
`target_username` varchar(191) DEFAULT '',
`target_password` varchar(191) DEFAULT '',
`target_topic` varchar(255) NOT NULL DEFAULT '',
`target_qos` tinyint(1) NOT NULL DEFAULT '0',
`enabled` tinyint(1) NOT NULL DEFAULT '1',
`remark` varchar(255) DEFAULT NULL,
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_enabled` (`enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
} catch (\Throwable $e) {
}
}
/**
* 匹配并转发
*/
public function matchAndForward(string $topic, string $msg, int $qos): void
{
if ((string) ($this->config['rule_enabled'] ?? '1') !== '1') {
return;
}
if (strncmp($topic, '$SYS', 4) === 0) {
return; // 系统主题不转发
}
if (!$this->forwarder->isAvailable()) {
return;
}
foreach ($this->loadRules() as $r) {
if ((int) $r['enabled'] !== 1 || empty($r['source_filter']) || empty($r['target_broker'])) {
continue;
}
if ($this->topicMatch((string) $r['source_filter'], $topic)) {
$this->forwarder->forward($r, $topic, $msg, $qos);
}
}
}
protected function loadRules(): array
{
$now = time();
if ($now - $this->loadedAt < $this->ttl && $this->loadedAt > 0) {
return $this->rules;
}
try {
$this->rules = Db::name('mqttbroker_rule')->where('enabled', 1)
->order('id', 'asc')->select()->toArray();
} catch (\Throwable $e) {
$this->rules = [];
}
$this->loadedAt = $now;
return $this->rules;
}
protected function topicMatch(string $filter, string $topic): bool
{
if ($filter === $topic) {
return true;
}
$f = explode('/', $filter);
$t = explode('/', $topic);
foreach ($f as $i => $seg) {
if ($seg === '#') {
return true;
}
if ($seg === '+') {
if (!isset($t[$i])) {
return false;
}
continue;
}
if (!isset($t[$i]) || $seg !== $t[$i]) {
return false;
}
}
return count($f) === count($t);
}
}
+231
View File
@@ -0,0 +1,231 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace addon\mqttbroker\service;
use think\facade\Db;
/**
* MQTT 持久化服务:保留消息 + 离线消息队列
*
* - 保留消息(retained)broker 重启后从 DB 恢复,保证新订阅者仍能收到最后一条。
* - 离线消息(offline)clean=false 会话离线期间投递给它的 QoS1/2 消息入库,
* 客户端重连后取出补投。
*
* 均为二进制安全(longblob)。所有操作 try/catchDB 不可用不影响 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<int,array{topic:string,payload:string,qos:int}>
*/
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;
}
}