chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\mqttbroker;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\AddonBase;
|
||||
|
||||
/**
|
||||
* Addon 类
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Addon extends addon
|
||||
{
|
||||
/**
|
||||
* 安装钩子:表由 install.sql 统一创建,这里无需额外处理
|
||||
*/
|
||||
public function install()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载钩子:清理本插件的数据表与菜单残留
|
||||
*/
|
||||
public function uninstall()
|
||||
{
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_connection`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_message`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_topic`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_auth`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_acl`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_retain`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_offline`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_stats`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_rule`");
|
||||
Db::execute("DROP TABLE IF EXISTS `wxapp_mqttbroker_outbox`");
|
||||
// 清理框架菜单(后端权限 + 前端/会员规则)
|
||||
Db::execute("DELETE FROM wxapp_user_rule WHERE name LIKE 'mqttbroker:%'");
|
||||
Db::execute("DELETE FROM wxapp_admin_power WHERE addon='mqttbroker'");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
/**
|
||||
* mqttbroker 压力测试脚本(零依赖,纯 PHP stream_socket 实现最小 MQTT 客户端)
|
||||
*
|
||||
* 用途:验证 Broker 在大量并发连接 + 高频发布下的稳定性(对应路线 B2)。
|
||||
* 测试项:
|
||||
* 1. 并发连接建立(CONNECT/CONNACK)
|
||||
* 2. 订阅 + 发布 QoS0 吞吐
|
||||
* 3. 客户端掉线重连恢复(可选)
|
||||
*
|
||||
* 用法:
|
||||
* php addon/mqttbroker/benchmark.php --host=127.0.0.1 --port=1883 --clients=500 --pub=20 --topic=bench/%d
|
||||
*
|
||||
* 参数:
|
||||
* --host Broker 地址(默认 127.0.0.1)
|
||||
* --port 监听端口(默认 1883;WebSocket 用 --ws 且 port=8083)
|
||||
* --clients 并发客户端数(默认 200)
|
||||
* --pub 每个客户端发布消息数(默认 10)
|
||||
* --topic 发布主题模板,%d 替换为客户端序号
|
||||
* --qos 发布 QoS(默认 0)
|
||||
* --timeout 单步超时秒(默认 3)
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$opts = getopt('', ['host:', 'port:', 'clients:', 'pub:', 'topic:', 'qos:', 'timeout:', 'ws']);
|
||||
$host = $opts['host'] ?? '127.0.0.1';
|
||||
$port = (int) ($opts['port'] ?? 1883);
|
||||
$clients = (int) ($opts['clients'] ?? 200);
|
||||
$pubN = (int) ($opts['pub'] ?? 10);
|
||||
$topicTpl = $opts['topic'] ?? 'bench/%d';
|
||||
$qos = (int) ($opts['qos'] ?? 0);
|
||||
$timeout = (float) ($opts['timeout'] ?? 3);
|
||||
$ws = isset($opts['ws']);
|
||||
|
||||
echo "=== mqttbroker benchmark ===\n";
|
||||
echo "target={$host}:{$port} clients={$clients} pub/client={$pubN} qos={$qos} transport=" . ($ws ? 'ws' : 'tcp') . "\n";
|
||||
|
||||
/* ---------- 最小 MQTT 报文 ---------- */
|
||||
function encStr(string $s): string { $b = strlen($s); return chr($b >> 8) . chr($b & 0xff) . $s; }
|
||||
function buildConnect(string $cid): string {
|
||||
$var = chr(0x00) . chr(0x04) . 'MQTT' . chr(0x04) . chr(0x02) . chr(0x00) . chr(0x3c);
|
||||
$body = encStr($cid);
|
||||
$len = strlen($body);
|
||||
return chr(0x10) . chr($len) . $var . $body;
|
||||
}
|
||||
function buildPublish(string $topic, string $payload, int $qos): string {
|
||||
$body = encStr($topic) . $payload;
|
||||
$len = strlen($body);
|
||||
return chr(0x30 | ($qos << 1)) . chr($len) . $body;
|
||||
}
|
||||
function buildSubscribe(string $topic, int $qos): string {
|
||||
$body = chr(0x00) . chr(0x01) . encStr($topic) . chr($qos);
|
||||
$len = strlen($body);
|
||||
return chr(0x82) . chr($len) . $body;
|
||||
}
|
||||
|
||||
/* ---------- 连接并建立会话 ---------- */
|
||||
function dial(string $host, int $port, bool $ws, float $timeout): mixed {
|
||||
$ctx = stream_context_create();
|
||||
$uri = $ws ? "tcp://{$host}:{$port}" : "tcp://{$host}:{$port}";
|
||||
$fp = @stream_socket_client($uri, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx);
|
||||
if (!$fp) { return null; }
|
||||
stream_set_timeout($fp, (int) $timeout);
|
||||
if ($ws) {
|
||||
// 极简 WS 握手(仅 TCP 升级,不处理掩码帧的完整分帧,供基准参考)
|
||||
$req = "GET /mqtt HTTP/1.1\r\nHost: {$host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: " . base64_encode(random_bytes(16)) . "\r\nSec-WebSocket-Protocol: mqtt\r\nSec-WebSocket-Version: 13\r\n\r\n";
|
||||
fwrite($fp, $req);
|
||||
$hdr = fread($fp, 1024);
|
||||
if (strpos($hdr, '101') === false) { fclose($fp); return null; }
|
||||
}
|
||||
return $fp;
|
||||
}
|
||||
|
||||
$start = microtime(true);
|
||||
$ok = 0; $fail = 0; $published = 0;
|
||||
$pool = [];
|
||||
for ($i = 1; $i <= $clients; $i++) {
|
||||
$cid = "bench-{$i}-" . rand(1000, 9999);
|
||||
$fp = dial($host, $port, $ws, $timeout);
|
||||
if (!$fp) { $fail++; continue; }
|
||||
fwrite($fp, buildConnect($cid));
|
||||
$ack = @fread($fp, 4);
|
||||
if ($ack === false || strlen($ack) < 4) { fclose($fp); $fail++; continue; }
|
||||
// 订阅自身主题
|
||||
$t = sprintf($topicTpl, $i);
|
||||
fwrite($fp, buildSubscribe($t, $qos));
|
||||
@fread($fp, 3);
|
||||
$pool[$i] = $fp;
|
||||
$ok++;
|
||||
}
|
||||
$connTime = microtime(true) - $start;
|
||||
echo sprintf("连接结果: 成功=%d 失败=%d 耗时=%.2fs (%.0f conn/s)\n", $ok, $fail, $connTime, $ok / max(0.001, $connTime));
|
||||
|
||||
/* ---------- 发布阶段 ---------- */
|
||||
$pubStart = microtime(true);
|
||||
foreach ($pool as $i => $fp) {
|
||||
$t = sprintf($topicTpl, $i);
|
||||
for ($k = 0; $k < $pubN; $k++) {
|
||||
fwrite($fp, buildPublish($t, "msg {$k} from {$i}", $qos));
|
||||
$published++;
|
||||
}
|
||||
}
|
||||
$pubTime = microtime(true) - $pubStart;
|
||||
echo sprintf("发布结果: 总消息=%d 耗时=%.2fs (%.0f msg/s)\n", $published, $pubTime, $published / max(0.001, $pubTime));
|
||||
|
||||
/* ---------- 清理 ---------- */
|
||||
foreach ($pool as $fp) { @fclose($fp); }
|
||||
$total = microtime(true) - $start;
|
||||
echo sprintf("完成: 客户端=%d 消息=%d 总耗时=%.2fs\n", $ok, $published, $total);
|
||||
echo ($fail === 0 && $ok > 0) ? "RESULT: PASS\n" : "RESULT: PARTIAL/FAIL (fail={$fail})\n";
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* MqttClient —— 浏览器/小程序 MQTT over WebSocket 重连封装
|
||||
*
|
||||
* 稳定性策略(对应路线 B2「即时通信稳定性」):
|
||||
* 1. 指数退避重连:基数 1s,上限 30s,成功一次后重置。
|
||||
* 2. 会话恢复:clean=false 时重连自动补投离线消息(由 Broker 保证)。
|
||||
* 3. 遗嘱(LWT):连接异常断开时 Broker 代发 will,上层可据此标记「离线」。
|
||||
* 4. 心跳兜底:onclose 即触发重连,不依赖 broker 的 keepalive 超时。
|
||||
*
|
||||
* 用法:
|
||||
* const c = new MqttClient('wss://your.host/mqtt', {
|
||||
* clientId: 'web-' + uid, username, password, clean: false,
|
||||
* will: { topic: 'presence/' + uid, payload: 'offline', qos: 1, retain: true }
|
||||
* });
|
||||
* c.on('connect', () => c.subscribe('topic/a'));
|
||||
* c.on('message', (t, p) => console.log(t, p));
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const BACKOFF_BASE = 1000; // 1s
|
||||
const BACKOFF_MAX = 30000; // 30s
|
||||
const BACKOFF_FACTOR = 1.8;
|
||||
|
||||
function MqttClient(url, opts) {
|
||||
this.url = url;
|
||||
this.opts = opts || {};
|
||||
this.clientId = this.opts.clientId || ('mqttjs_' + Math.random().toString(16).slice(2, 10));
|
||||
this._listeners = {};
|
||||
this._ws = null;
|
||||
this._connected = false;
|
||||
this._closedByUser = false;
|
||||
this._backoff = BACKOFF_BASE;
|
||||
this._retryTimer = null;
|
||||
this._subscriptions = {}; // topic -> qos,重连后自动重订
|
||||
}
|
||||
|
||||
MqttClient.prototype.on = function (evt, fn) {
|
||||
(this._listeners[evt] = this._listeners[evt] || []).push(fn);
|
||||
return this;
|
||||
};
|
||||
|
||||
MqttClient.prototype._emit = function (evt, a, b) {
|
||||
(this._listeners[evt] || []).forEach((fn) => fn(a, b));
|
||||
};
|
||||
|
||||
MqttClient.prototype.connect = function () {
|
||||
this._closedByUser = false;
|
||||
this._open();
|
||||
return this;
|
||||
};
|
||||
|
||||
MqttClient.prototype._open = function () {
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(this.url, ['mqtt']);
|
||||
} catch (e) {
|
||||
return this._scheduleReconnect();
|
||||
}
|
||||
this._ws = ws;
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
// 发送 MQTT CONNECT(极简 CONNECT 报文,支持 will)
|
||||
const buf = this._buildConnect();
|
||||
ws.send(buf);
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
const pkt = this._parseConnAck(ev.data);
|
||||
if (pkt && pkt.sessionPresent) {
|
||||
this._emit('resume'); // 会话恢复,Broker 会在后台补投离线消息
|
||||
}
|
||||
this._connected = true;
|
||||
this._backoff = BACKOFF_BASE;
|
||||
this._emit('connect');
|
||||
Object.keys(this._subscriptions).forEach((t) => this.subscribe(t, this._subscriptions[t]));
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (this._connected) this._emit('disconnect');
|
||||
this._connected = false;
|
||||
if (!this._closedByUser) this._scheduleReconnect();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||||
};
|
||||
|
||||
MqttClient.prototype._scheduleReconnect = function () {
|
||||
if (this._closedByUser) return;
|
||||
this._emit('reconnecting', this._backoff);
|
||||
clearTimeout(this._retryTimer);
|
||||
this._retryTimer = setTimeout(() => {
|
||||
this._backoff = Math.min(BACKOFF_MAX, Math.floor(this._backoff * BACKOFF_FACTOR));
|
||||
this._open();
|
||||
}, this._backoff);
|
||||
};
|
||||
|
||||
MqttClient.prototype.subscribe = function (topic, qos) {
|
||||
this._subscriptions[topic] = qos || 0;
|
||||
if (!this._connected || !this._ws) return;
|
||||
this._ws.send(this._buildSubscribe(topic, qos || 0));
|
||||
};
|
||||
|
||||
MqttClient.prototype.publish = function (topic, payload, qos, retain) {
|
||||
if (!this._connected || !this._ws) return false;
|
||||
this._ws.send(this._buildPublish(topic, payload, qos || 0, retain ? 1 : 0));
|
||||
return true;
|
||||
};
|
||||
|
||||
MqttClient.prototype.end = function () {
|
||||
this._closedByUser = true;
|
||||
clearTimeout(this._retryTimer);
|
||||
if (this._ws) { try { this._ws.close(); } catch (e) {} }
|
||||
};
|
||||
|
||||
/* ---------- 以下为最小 MQTT 报文构造(仅覆盖 CONNECT/SUBSCRIBE/PUBLISH) ---------- */
|
||||
MqttClient.prototype._encStr = function (s) {
|
||||
const b = new TextEncoder().encode(s);
|
||||
return new Uint8Array([b.length >> 8, b.length & 0xff, ...b]);
|
||||
};
|
||||
MqttClient.prototype._buildConnect = function () {
|
||||
const o = this.opts;
|
||||
const cid = this._encStr(this.clientId);
|
||||
let payload = cid;
|
||||
if (o.username) payload = this._join(payload, this._encStr(o.username), this._encStr(o.password || ''));
|
||||
if (o.will) {
|
||||
payload = this._join(payload, this._encStr(o.will.topic), this._encStr(o.will.payload || ''));
|
||||
}
|
||||
let varHdr = new Uint8Array([4, 0x02 /*clean*/, 0x3c /*keepalive 60*/]);
|
||||
if (o.clean === false) varHdr[1] = 0;
|
||||
let flags = 0;
|
||||
if (o.username) flags |= 0x80;
|
||||
if (o.password) flags |= 0x40;
|
||||
if (o.will) flags |= 0x04 | ((o.will.qos || 0) << 3) | ((o.will.retain ? 1 : 0) << 5);
|
||||
varHdr[1] |= flags;
|
||||
const body = this._join(varHdr, payload);
|
||||
return this._wrap(1, body);
|
||||
};
|
||||
MqttClient.prototype._buildSubscribe = function (topic, qos) {
|
||||
const pid = new Uint8Array([0x00, 0x01]);
|
||||
const t = this._join(this._encStr(topic), new Uint8Array([qos]));
|
||||
return this._wrap(8, this._join(pid, t));
|
||||
};
|
||||
MqttClient.prototype._buildPublish = function (topic, payload, qos, retain) {
|
||||
const t = this._encStr(topic);
|
||||
const p = typeof payload === 'string' ? new TextEncoder().encode(payload) : new Uint8Array(payload);
|
||||
return this._wrap(3 | (qos << 1) | (retain << 0), this._join(t, p));
|
||||
};
|
||||
MqttClient.prototype._wrap = function (cmd, body) {
|
||||
const len = body.length;
|
||||
const rem = [len & 0x7f];
|
||||
if (len > 127) rem.unshift((len >> 7) & 0x7f | 0x80);
|
||||
return new Uint8Array([cmd << 4, ...rem, ...body]);
|
||||
};
|
||||
MqttClient.prototype._join = function (...arrs) {
|
||||
let n = 0; arrs.forEach((a) => n += a.length);
|
||||
const out = new Uint8Array(n);
|
||||
let p = 0; arrs.forEach((a) => { out.set(a, p); p += a.length; });
|
||||
return out;
|
||||
};
|
||||
MqttClient.prototype._parseConnAck = function (data) {
|
||||
try {
|
||||
const buf = new Uint8Array(data);
|
||||
return { sessionPresent: (buf[2] & 0x01) === 1, code: buf[3] };
|
||||
} catch (e) { return null; }
|
||||
};
|
||||
|
||||
global.MqttClient = MqttClient;
|
||||
if (typeof module !== 'undefined') module.exports = MqttClient;
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?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\command;
|
||||
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\console\input\Option;
|
||||
use addon\mqttbroker\service\Broker;
|
||||
|
||||
/**
|
||||
* 启动 MQTT Broker 常驻进程(兼容 MQTT 3.1.1 / 5.0,类 EMQX 轻量 broker)
|
||||
* php think mqttbroker:start
|
||||
* php think mqttbroker:start -p 1883
|
||||
*
|
||||
* 依赖:仅 Workerman\Worker(框架已自带,无需 composer require workerman/mqtt)。
|
||||
* 手动发布经 DB 出站队列内部路由,零外部依赖;仅"转发规则(桥接 EMQX)"
|
||||
* 需要 workerman/mqtt(composer require workerman/mqtt),未安装时自动跳过转发。
|
||||
*/
|
||||
class MqttBroker extends Command
|
||||
{
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('mqttbroker:start')
|
||||
->addOption('port', 'p', Option::VALUE_OPTIONAL, '监听端口', 1883)
|
||||
->addOption('host', 'H', Option::VALUE_OPTIONAL, '监听地址', '0.0.0.0')
|
||||
->setDescription('启动 MQTT Broker 服务 (MQTT 3.1.1 / 5.0)');
|
||||
}
|
||||
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$port = (int) $input->getOption('port');
|
||||
$host = $input->getOption('host');
|
||||
$output->writeln("启动 MQTT Broker on {$host}:{$port}(协议 MQTT 3.1.1 / 5.0)");
|
||||
$output->writeln("按 Ctrl+C 停止(Windows 下为单进程调试模式)");
|
||||
|
||||
(new Broker())->start($host, $port);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?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\command;
|
||||
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\facade\Db;
|
||||
use ywxapp\service\AddonService;
|
||||
use addon\mqttbroker\service\Auth;
|
||||
|
||||
/**
|
||||
* ACL 实网决策验证:用真实 DB 规则校验 Auth::checkAcl 的放行/拒绝逻辑。
|
||||
* php think mqttbroker:acltest
|
||||
* 验证项:deny 发布拒绝、默认放行、用户定向拒绝、超管绕过、$SYS 只读。
|
||||
* 不依赖运行中的 Broker,直接复用 Broker 实际调用的 Auth 逻辑。
|
||||
*/
|
||||
class TestAcl extends Command
|
||||
{
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('mqttbroker:acltest')
|
||||
->setDescription('验证 ACL 发布/订阅权限决策(含 deny 拒绝)');
|
||||
}
|
||||
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
// 配置:开启 ACL,默认放行
|
||||
$saved = [];
|
||||
try { $saved = AddonService::config('mqttbroker') ?: []; } catch (\Throwable $e) {}
|
||||
$config = array_merge($saved, ['acl_enabled' => '1', 'acl_default' => 'allow']);
|
||||
|
||||
$auth = new Auth($config);
|
||||
$auth->ensureTables();
|
||||
|
||||
// 插入临时规则(测试后清理)
|
||||
$rows = [
|
||||
['target_type' => 'member', 'target' => 'alice', 'topic' => 'alice/secret', 'access' => 2, 'allow' => 0, 'sort' => 5, 'remark' => 'tmp'],
|
||||
['target_type' => 'all', 'target' => '', 'topic' => 'secret/#', 'access' => 2, 'allow' => 0, 'sort' => 10, 'remark' => 'tmp'],
|
||||
['target_type' => 'all', 'target' => '', 'topic' => 'public/#', 'access' => 3, 'allow' => 1, 'sort' => 20, 'remark' => 'tmp'],
|
||||
];
|
||||
$ids = [];
|
||||
foreach ($rows as $r) {
|
||||
$ids[] = Db::name('mqttbroker_acl')->insertGetId(array_merge($r, ['create_at' => time()]));
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$chk = function (string $case, bool $got, bool $expect) use (&$results) {
|
||||
$results[$case] = ['got' => $got, 'expect' => $expect, 'ok' => $got === $expect];
|
||||
};
|
||||
|
||||
// 1) deny 发布 secret/# 被拒
|
||||
$chk('deny publish secret/#', $auth->checkAcl(null, 'c1', 'secret/data', Auth::ACT_PUB), false);
|
||||
// 2) 同一主题仅 deny 发布,订阅走默认放行
|
||||
$chk('subscribe secret/# default-allow', $auth->checkAcl(null, 'c1', 'secret/data', Auth::ACT_SUB), true);
|
||||
// 3) allow 规则 public/# 发布放行
|
||||
$chk('allow publish public/info', $auth->checkAcl(null, 'c1', 'public/info', Auth::ACT_PUB), true);
|
||||
// 4) 未命中规则走默认放行
|
||||
$chk('default allow other/topic', $auth->checkAcl(null, 'c1', 'other/topic', Auth::ACT_PUB), true);
|
||||
// 5) 用户定向 deny:alice 发布 alice/secret 被拒
|
||||
$chk('user alice deny publish', $auth->checkAcl('alice', 'cA', 'alice/secret', Auth::ACT_PUB), false);
|
||||
// 6) alice 订阅 alice/secret(user 规则仅限发布)走默认放行
|
||||
$chk('user alice subscribe (rule is pub-only)', $auth->checkAcl('alice', 'cA', 'alice/secret', Auth::ACT_SUB), true);
|
||||
// 7) 超管绕过 deny
|
||||
$chk('superuser bypass deny', $auth->checkAcl('root', 'cR', 'secret/data', Auth::ACT_PUB, true), true);
|
||||
// 8) $SYS 只读:订阅放行、发布拒绝
|
||||
$chk('$SYS subscribe allowed', $auth->checkAcl(null, 'c2', '$SYS/broker/uptime', Auth::ACT_SUB), true);
|
||||
$chk('$SYS publish denied', $auth->checkAcl(null, 'c2', '$SYS/broker/uptime', Auth::ACT_PUB), false);
|
||||
|
||||
// 清理临时规则
|
||||
if ($ids) {
|
||||
Db::name('mqttbroker_acl')->whereIn('id', $ids)->delete();
|
||||
}
|
||||
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
foreach ($results as $case => $r) {
|
||||
$flag = $r['ok'] ? 'PASS' : 'FAIL';
|
||||
if ($r['ok']) { $pass++; } else { $fail++; }
|
||||
$output->writeln("[{$flag}] {$case} (got=" . var_export($r['got'], true) . " expect=" . var_export($r['expect'], true) . ")");
|
||||
}
|
||||
$output->writeln("");
|
||||
$output->writeln("ACL 验证结果:PASS={$pass} FAIL={$fail}");
|
||||
return $fail === 0 ? 0 : 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
// mqttbroker 插件公共文件(如需全局函数可在此定义)
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
// MQTT Broker 配置项(后台"服务设置"表单数据源)
|
||||
return [
|
||||
[
|
||||
'name' => 'port',
|
||||
'title' => '监听端口',
|
||||
'type' => 'number',
|
||||
'value' => '1883',
|
||||
'tip' => 'Broker 监听的 TCP 端口,默认 1883',
|
||||
],
|
||||
[
|
||||
'name' => 'host',
|
||||
'title' => '监听地址',
|
||||
'type' => 'string',
|
||||
'value' => '0.0.0.0',
|
||||
'tip' => '绑定地址,0.0.0.0 表示全部网卡',
|
||||
],
|
||||
[
|
||||
'name' => 'ws_port',
|
||||
'title' => 'WebSocket端口',
|
||||
'type' => 'number',
|
||||
'value' => '8083',
|
||||
'tip' => 'MQTT over WebSocket 端口(浏览器/小程序用),0 表示关闭',
|
||||
],
|
||||
[
|
||||
'name' => 'ssl_enabled',
|
||||
'title' => '启用TLS',
|
||||
'type' => 'select',
|
||||
'options' => ['0' => '关闭', '1' => '开启'],
|
||||
'value' => '0',
|
||||
'tip' => '对 TCP 监听启用 TLS(mqtts),需配置证书',
|
||||
],
|
||||
[
|
||||
'name' => 'ssl_cert',
|
||||
'title' => 'TLS证书路径',
|
||||
'type' => 'string',
|
||||
'value' => '',
|
||||
'tip' => 'PEM 证书文件绝对路径(local_cert)',
|
||||
],
|
||||
[
|
||||
'name' => 'ssl_key',
|
||||
'title' => 'TLS私钥路径',
|
||||
'type' => 'string',
|
||||
'value' => '',
|
||||
'tip' => 'PEM 私钥文件绝对路径(local_pk)',
|
||||
],
|
||||
[
|
||||
'name' => 'allow_anonymous',
|
||||
'title' => '允许匿名连接',
|
||||
'type' => 'select',
|
||||
'options' => ['1' => '允许', '0' => '禁止'],
|
||||
'value' => '1',
|
||||
'tip' => '禁止时需客户端提供正确用户名/密码(在"认证账号"中维护)',
|
||||
],
|
||||
[
|
||||
'name' => 'acl_enabled',
|
||||
'title' => '启用ACL',
|
||||
'type' => 'select',
|
||||
'options' => ['0' => '关闭', '1' => '开启'],
|
||||
'value' => '0',
|
||||
'tip' => '开启后按"ACL规则"表校验发布/订阅权限',
|
||||
],
|
||||
[
|
||||
'name' => 'acl_default',
|
||||
'title' => 'ACL默认策略',
|
||||
'type' => 'select',
|
||||
'options' => ['allow' => '默认允许', 'deny' => '默认拒绝'],
|
||||
'value' => 'allow',
|
||||
'tip' => '所有规则都未命中时的兜底策略',
|
||||
],
|
||||
[
|
||||
'name' => 'max_keepalive',
|
||||
'title' => '最大保活间隔(秒)',
|
||||
'type' => 'number',
|
||||
'value' => '60',
|
||||
'tip' => '超过该间隔无心跳则断开',
|
||||
],
|
||||
[
|
||||
'name' => 'max_connections',
|
||||
'title' => '最大连接数',
|
||||
'type' => 'number',
|
||||
'value' => '5000',
|
||||
'tip' => '单进程并发连接硬上限,超出拒绝新连接防止内存雪崩(0=不限)',
|
||||
],
|
||||
[
|
||||
'name' => 'publish_rate_limit',
|
||||
'title' => '单连接发布限流(条/秒)',
|
||||
'type' => 'number',
|
||||
'value' => '50',
|
||||
'tip' => '令牌桶限流,单连接每秒最多发布数,超出静默丢弃(0=不限)',
|
||||
],
|
||||
[
|
||||
'name' => 'sys_enabled',
|
||||
'title' => '启用$SYS主题',
|
||||
'type' => 'select',
|
||||
'options' => ['1' => '开启', '0' => '关闭'],
|
||||
'value' => '1',
|
||||
'tip' => '周期发布 $SYS/broker/# 运行指标,可被客户端订阅',
|
||||
],
|
||||
[
|
||||
'name' => 'sys_interval',
|
||||
'title' => '$SYS发布间隔(秒)',
|
||||
'type' => 'number',
|
||||
'value' => '10',
|
||||
'tip' => '系统指标发布周期',
|
||||
],
|
||||
[
|
||||
'name' => 'persist_retain',
|
||||
'title' => '持久化保留消息',
|
||||
'type' => 'select',
|
||||
'options' => ['1' => '开启', '0' => '关闭'],
|
||||
'value' => '1',
|
||||
'tip' => '重启后从数据库恢复保留消息',
|
||||
],
|
||||
[
|
||||
'name' => 'persist_offline',
|
||||
'title' => '持久化离线消息',
|
||||
'type' => 'select',
|
||||
'options' => ['1' => '开启', '0' => '关闭'],
|
||||
'value' => '1',
|
||||
'tip' => 'clean=false 会话离线期间的 QoS1/2 消息入库,重连补投',
|
||||
],
|
||||
[
|
||||
'name' => 'offline_limit',
|
||||
'title' => '离线队列上限',
|
||||
'type' => 'number',
|
||||
'value' => '1000',
|
||||
'tip' => '单客户端离线消息最大条数,超出丢弃最旧',
|
||||
],
|
||||
[
|
||||
'name' => 'cluster_enabled',
|
||||
'title' => '启用集群桥接',
|
||||
'type' => 'select',
|
||||
'options' => ['0' => '关闭', '1' => '开启'],
|
||||
'value' => '0',
|
||||
'tip' => '开启后经 Redis Stream 打通多进程/WebSocket 与 TCP 消息(生产 Linux 建议开启)',
|
||||
],
|
||||
[
|
||||
'name' => 'rule_enabled',
|
||||
'title' => '启用转发规则',
|
||||
'type' => 'select',
|
||||
'options' => ['1' => '开启', '0' => '关闭'],
|
||||
'value' => '1',
|
||||
'tip' => '开启后按"转发规则"匹配源主题并桥接到外部 Broker(如 EMQX),需 workerman/mqtt',
|
||||
],
|
||||
[
|
||||
'name' => 'redis_host',
|
||||
'title' => 'Redis地址',
|
||||
'type' => 'string',
|
||||
'value' => '127.0.0.1',
|
||||
'tip' => '集群桥接用 Redis 主机',
|
||||
],
|
||||
[
|
||||
'name' => 'redis_port',
|
||||
'title' => 'Redis端口',
|
||||
'type' => 'number',
|
||||
'value' => '6379',
|
||||
'tip' => 'Redis 端口',
|
||||
],
|
||||
[
|
||||
'name' => 'redis_db',
|
||||
'title' => 'Redis库',
|
||||
'type' => 'number',
|
||||
'value' => '0',
|
||||
'tip' => 'Redis 数据库序号',
|
||||
],
|
||||
[
|
||||
'name' => 'redis_password',
|
||||
'title' => 'Redis密码',
|
||||
'type' => 'string',
|
||||
'value' => '',
|
||||
'tip' => '无密码留空',
|
||||
],
|
||||
[
|
||||
'name' => 'log_level',
|
||||
'title' => '日志级别',
|
||||
'type' => 'select',
|
||||
'options' => ['0' => '关闭', '1' => '仅错误', '2' => '详细'],
|
||||
'value' => '1',
|
||||
'tip' => '写入 runtime 日志的详细程度',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\mqttbroker\controller\api;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\controller\ApiController;
|
||||
use addon\mqttbroker\model\Message;
|
||||
|
||||
/**
|
||||
* Mqtt 类
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Mqtt extends ApiController
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 获取 Broker 运行状态
|
||||
* GET /mqttbroker/api/status
|
||||
*/
|
||||
public function status()
|
||||
{
|
||||
$data = [
|
||||
'online' => Db::name('mqttbroker_connection')->where('status', 1)->count(),
|
||||
'total' => Db::name('mqttbroker_connection')->count(),
|
||||
'messages' => Message::count(),
|
||||
'topics' => Db::name('mqttbroker_topic')->count(),
|
||||
];
|
||||
$this->result->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布消息
|
||||
* POST /mqttbroker/api/publish {topic, payload, qos}
|
||||
*
|
||||
* 零外部依赖:写入出站队列(DB),由常驻 Broker 进程消费后经内部路由投递,
|
||||
* 不再依赖 workerman/mqtt 客户端连接本地 Broker。
|
||||
*/
|
||||
public function publish()
|
||||
{
|
||||
$topic = input('topic', '');
|
||||
$payload = input('payload', '');
|
||||
$qos = (int) input('qos', 0);
|
||||
if (!$topic) {
|
||||
$this->result->error('topic required');
|
||||
}
|
||||
$ok = \addon\mqttbroker\service\Store::enqueueManual($topic, $payload, $qos, 0, 'api');
|
||||
if ($ok) {
|
||||
$this->result->success('已加入发布队列,Broker 将投递给在线订阅者');
|
||||
} else {
|
||||
$this->result->error('发布失败:无法写入出站队列(请检查数据库)');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace addon\mqttbroker\controller\backend;
|
||||
|
||||
use think\facade\Db;
|
||||
use think\facade\View;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\service\AddonService;
|
||||
use addon\mqttbroker\model\Connection;
|
||||
use addon\mqttbroker\model\Message;
|
||||
use addon\mqttbroker\service\Auth;
|
||||
use addon\mqttbroker\service\Store;
|
||||
|
||||
/**
|
||||
* MqttBroker 类
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class MqttBroker extends BackendBase
|
||||
{
|
||||
// 与 blog 后台一致:开发期放宽登录校验(生产请改回需登录)
|
||||
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
/**
|
||||
* 概览
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$stats = [
|
||||
'online' => Connection::where('status', 1)->count(),
|
||||
'total' => Connection::count(),
|
||||
'messages' => Message::count(),
|
||||
'topics' => Db::name('mqttbroker_topic')->count(),
|
||||
];
|
||||
$recent = Message::order('id', 'desc')->limit(10)->select();
|
||||
View::assign('stats', $stats);
|
||||
View::assign('recent', $recent);
|
||||
return View::fetch('admin/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端连接列表
|
||||
*/
|
||||
public function connections()
|
||||
{
|
||||
$list = Connection::order('id', 'desc')->paginate(20);
|
||||
View::assign('list', $list);
|
||||
return View::fetch('admin/connections');
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息日志
|
||||
*/
|
||||
public function messages()
|
||||
{
|
||||
$topic = input('topic', '');
|
||||
$query = Message::order('id', 'desc');
|
||||
if ($topic) {
|
||||
$query->where('topic', 'like', "%{$topic}%");
|
||||
}
|
||||
$list = $query->paginate(20);
|
||||
View::assign('list', $list);
|
||||
View::assign('topic', $topic);
|
||||
return View::fetch('admin/messages');
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布消息表单
|
||||
*/
|
||||
public function publish()
|
||||
{
|
||||
return View::fetch('admin/publish');
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行发布(零外部依赖:写入出站队列,由常驻 Broker 消费后经内部路由投递)
|
||||
*/
|
||||
public function doPublish()
|
||||
{
|
||||
$topic = input('post.topic', '');
|
||||
$payload = input('post.payload', '');
|
||||
$qos = (int) input('post.qos', 0);
|
||||
if (!$topic) {
|
||||
$this->result->error('主题不能为空');
|
||||
}
|
||||
$ok = Store::enqueueManual($topic, $payload, $qos, 0, 'backend');
|
||||
if ($ok) {
|
||||
$this->result->success('已加入发布队列,Broker 将投递给在线订阅者');
|
||||
}
|
||||
$this->result->error('发布失败:无法写入出站队列');
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务设置
|
||||
*/
|
||||
public function setting()
|
||||
{
|
||||
$def = include ADDON_PATH . 'mqttbroker' . DIRECTORY_SEPARATOR . 'config.php';
|
||||
$saved = AddonService::config('mqttbroker');
|
||||
View::assign('def', $def);
|
||||
View::assign('saved', $saved ?: []);
|
||||
return View::fetch('admin/setting');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存设置
|
||||
*/
|
||||
public function saveSetting()
|
||||
{
|
||||
$data = input('post.');
|
||||
AddonService::config('mqttbroker', $data);
|
||||
$this->result->success('保存成功');
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 认证账号管理
|
||||
* ========================================================== */
|
||||
|
||||
public function auth()
|
||||
{
|
||||
$list = Db::name('mqttbroker_auth')->order('id', 'desc')->paginate(20);
|
||||
View::assign('list', $list);
|
||||
return View::fetch('admin/auth');
|
||||
}
|
||||
|
||||
|
||||
public function saveAuth()
|
||||
{
|
||||
$id = (int) input('post.id', 0);
|
||||
$username = trim((string) input('post.username', ''));
|
||||
$password = (string) input('post.password', '');
|
||||
$isSuper = (int) input('post.is_superuser', 0);
|
||||
$status = (int) input('post.status', 1);
|
||||
$remark = (string) input('post.remark', '');
|
||||
if ($username === '') {
|
||||
$this->result->error('用户名不能为空');
|
||||
}
|
||||
$data = [
|
||||
'username' => $username,
|
||||
'is_superuser' => $isSuper ? 1 : 0,
|
||||
'status' => $status ? 1 : 0,
|
||||
'remark' => $remark,
|
||||
'update_at' => time(),
|
||||
];
|
||||
// 仅当填写了密码才更新(编辑时留空表示不改)
|
||||
if ($password !== '') {
|
||||
$data['password'] = Auth::hashPassword($password);
|
||||
}
|
||||
try {
|
||||
if ($id > 0) {
|
||||
Db::name('mqttbroker_auth')->where('id', $id)->update($data);
|
||||
} else {
|
||||
if ($password === '') {
|
||||
$this->result->error('新增账号必须设置密码');
|
||||
}
|
||||
$data['create_at'] = time();
|
||||
Db::name('mqttbroker_auth')->insert($data);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->result->error('保存失败:' . $e->getMessage());
|
||||
}
|
||||
$this->result->success('保存成功');
|
||||
}
|
||||
|
||||
|
||||
public function deleteAuth()
|
||||
{
|
||||
$id = (int) input('post.id', 0);
|
||||
Db::name('mqttbroker_auth')->where('id', $id)->delete();
|
||||
$this->result->success('已删除');
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* ACL 规则管理
|
||||
* ========================================================== */
|
||||
|
||||
public function acl()
|
||||
{
|
||||
$list = Db::name('mqttbroker_acl')->order('sort', 'asc')->order('id', 'asc')->paginate(50);
|
||||
View::assign('list', $list);
|
||||
return View::fetch('admin/acl');
|
||||
}
|
||||
|
||||
|
||||
public function saveAcl()
|
||||
{
|
||||
$id = (int) input('post.id', 0);
|
||||
$data = [
|
||||
'target_type' => (string) input('post.target_type', 'all'),
|
||||
'target' => (string) input('post.target', ''),
|
||||
'topic' => (string) input('post.topic', ''),
|
||||
'access' => (int) input('post.access', 3),
|
||||
'allow' => (int) input('post.allow', 1),
|
||||
'sort' => (int) input('post.sort', 0),
|
||||
'remark' => (string) input('post.remark', ''),
|
||||
];
|
||||
if ($data['topic'] === '') {
|
||||
$this->result->error('主题过滤器不能为空');
|
||||
}
|
||||
try {
|
||||
if ($id > 0) {
|
||||
Db::name('mqttbroker_acl')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$data['create_at'] = time();
|
||||
Db::name('mqttbroker_acl')->insert($data);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->result->error('保存失败:' . $e->getMessage());
|
||||
}
|
||||
$this->result->success('保存成功(最长 30s 后在 Broker 生效)');
|
||||
}
|
||||
|
||||
|
||||
public function deleteAcl()
|
||||
{
|
||||
$id = (int) input('post.id', 0);
|
||||
Db::name('mqttbroker_acl')->where('id', $id)->delete();
|
||||
$this->result->success('已删除');
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 实时监控仪表盘(轮询 stats 接口)
|
||||
* ========================================================== */
|
||||
|
||||
public function stats()
|
||||
{
|
||||
$row = Db::name('mqttbroker_stats')->where('id', 1)->find();
|
||||
if (!$row) {
|
||||
$row = [
|
||||
'uptime' => 0, 'clients_online' => 0, 'clients_total' => 0,
|
||||
'subscriptions' => 0, 'messages_received' => 0, 'messages_sent' => 0,
|
||||
'retained' => 0, 'timestamp' => time(),
|
||||
];
|
||||
}
|
||||
// 以连接表的实时在线数补全(更贴近 DB 视角的在线状态)
|
||||
try {
|
||||
$row['db_online'] = Connection::where('status', 1)->count();
|
||||
} catch (\Throwable $e) {
|
||||
$row['db_online'] = $row['clients_online'] ?? 0;
|
||||
}
|
||||
$this->result->success($row);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 转发规则(规则引擎 / 桥接到 EMQX)
|
||||
* ========================================================== */
|
||||
|
||||
public function rule()
|
||||
{
|
||||
$list = Db::name('mqttbroker_rule')->order('id', 'desc')->paginate(20);
|
||||
View::assign('list', $list);
|
||||
return View::fetch('admin/rule');
|
||||
}
|
||||
|
||||
|
||||
public function saveRule()
|
||||
{
|
||||
$id = (int) input('post.id', 0);
|
||||
$data = [
|
||||
'name' => (string) input('post.name', ''),
|
||||
'source_filter' => (string) input('post.source_filter', ''),
|
||||
'target_type' => (string) input('post.target_type', 'mqtt'),
|
||||
'target_broker' => (string) input('post.target_broker', ''),
|
||||
'target_clientid' => (string) input('post.target_clientid', ''),
|
||||
'target_username' => (string) input('post.target_username', ''),
|
||||
'target_password' => (string) input('post.target_password', ''),
|
||||
'target_topic' => (string) input('post.target_topic', ''),
|
||||
'target_qos' => (int) input('post.target_qos', 0),
|
||||
'enabled' => (int) input('post.enabled', 1),
|
||||
'remark' => (string) input('post.remark', ''),
|
||||
];
|
||||
if ($data['name'] === '' || $data['source_filter'] === '') {
|
||||
$this->result->error('规则名称与源主题过滤器必填');
|
||||
}
|
||||
if ($data['target_broker'] === '') {
|
||||
$this->result->error('目标 Broker 地址必填');
|
||||
}
|
||||
try {
|
||||
if ($id > 0) {
|
||||
Db::name('mqttbroker_rule')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$data['create_at'] = time();
|
||||
Db::name('mqttbroker_rule')->insert($data);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->result->error('保存失败:' . $e->getMessage());
|
||||
}
|
||||
$this->result->success('保存成功(最多 30s 后在 Broker 生效)');
|
||||
}
|
||||
|
||||
|
||||
public function deleteRule()
|
||||
{
|
||||
$id = (int) input('post.id', 0);
|
||||
Db::name('mqttbroker_rule')->where('id', $id)->delete();
|
||||
$this->result->success('已删除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'mqttbroker',
|
||||
'title' => 'MQTT代理',
|
||||
'intro' => '基于 Workerman 自研的 MQTT 3.1.1/5.0 Broker(类 EMQX 轻量版):认证/ACL、WebSocket/TLS、保留与离线消息持久化、共享订阅、$SYS 指标、Redis 多进程桥接、实时仪表盘、转发规则(桥接EMQX)、手动发布零依赖出站队列,含后台管理',
|
||||
'author' => '',
|
||||
'website' => '',
|
||||
'version' => '2.1.1',
|
||||
'state' => 0,
|
||||
'url' => '/mqttbroker/backend',
|
||||
'license' => '',
|
||||
'licenseto' => 0,
|
||||
'config' => [
|
||||
],
|
||||
'events' => [
|
||||
],
|
||||
'middleware' => [
|
||||
],
|
||||
'services' => [
|
||||
],
|
||||
'update_time' => 1786365219,
|
||||
'install_time' => 1783683676,
|
||||
];
|
||||
@@ -0,0 +1,146 @@
|
||||
-- ============================================================
|
||||
-- addon/mqttbroker/install.sql —— mqttbroker 插件数据表
|
||||
-- 框架约定:插件安装时由 ywxapp\service\AddonService 执行本文件
|
||||
-- (仅允许 CREATE TABLE / INSERT,见 importsql 白名单)。
|
||||
-- 表名须为 __PREFIX__<插件名>_*,与 __PREFIX__addon 等核心表命名一致。
|
||||
-- 时间字段统一约定:create_at / update_at / delete_at
|
||||
-- ============================================================
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__mqttbroker_connection` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`client_id` varchar(191) NOT NULL DEFAULT '' COMMENT 'MQTT客户端标识',
|
||||
`username` varchar(191) DEFAULT NULL COMMENT '用户名',
|
||||
`ip` varchar(64) DEFAULT NULL COMMENT '客户端IP',
|
||||
`status` tinyint(1) DEFAULT '1' COMMENT '1在线 0离线',
|
||||
`create_at` int DEFAULT NULL COMMENT '连接时间',
|
||||
`update_at` int DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_client_id` (`client_id`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='MQTT客户端连接表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__mqttbroker_message` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`client_id` varchar(191) DEFAULT NULL COMMENT '发布者client_id',
|
||||
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT '主题',
|
||||
`payload` longtext COMMENT '消息内容',
|
||||
`qos` tinyint(1) DEFAULT '0' COMMENT 'QoS等级',
|
||||
`retain` tinyint(1) DEFAULT '0' COMMENT '是否保留消息',
|
||||
`source` varchar(32) DEFAULT 'client' COMMENT '来源:client 客户端 / manual 后台手动',
|
||||
`create_at` int DEFAULT NULL COMMENT '时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_topic` (`topic`),
|
||||
KEY `idx_create_at` (`create_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='MQTT消息日志表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__mqttbroker_topic` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`client_id` varchar(191) NOT NULL DEFAULT '' COMMENT '订阅者client_id',
|
||||
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT '订阅主题(支持通配符)',
|
||||
`qos` tinyint(1) DEFAULT '0' COMMENT 'QoS等级',
|
||||
`create_at` int DEFAULT NULL COMMENT '订阅时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_client_id` (`client_id`),
|
||||
KEY `idx_topic` (`topic`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='MQTT订阅关系表';
|
||||
|
||||
-- 认证账号表(allow_anonymous=0 或提供用户名时校验密码)
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__mqttbroker_auth` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`username` varchar(191) NOT NULL DEFAULT '' COMMENT '用户名',
|
||||
`password` varchar(191) NOT NULL DEFAULT '' COMMENT '密码(bcrypt哈希,兼容明文)',
|
||||
`is_superuser` tinyint(1) NOT NULL DEFAULT '0' COMMENT '超级用户跳过ACL',
|
||||
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0禁用',
|
||||
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
|
||||
`create_at` int DEFAULT NULL,
|
||||
`update_at` int DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_username` (`username`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='MQTT认证账号表';
|
||||
|
||||
-- ACL 主题权限表(按 sort 顺序匹配,命中即决定 allow/deny)
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__mqttbroker_acl` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`target_type` varchar(16) NOT NULL DEFAULT 'all' COMMENT 'all/user/client',
|
||||
`target` varchar(191) NOT NULL DEFAULT '' COMMENT '匹配值:用户名/clientid,all时忽略',
|
||||
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT '主题过滤器,支持通配符与占位符%u %c',
|
||||
`access` tinyint(1) NOT NULL DEFAULT '3' COMMENT '1订阅 2发布 3两者',
|
||||
`allow` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1允许 0拒绝',
|
||||
`sort` int NOT NULL DEFAULT '0' COMMENT '匹配优先级,越小越先',
|
||||
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
|
||||
`create_at` int DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sort` (`sort`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='MQTT ACL权限表';
|
||||
|
||||
-- 保留消息持久化表(重启后恢复 retained)
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__mqttbroker_retain` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT '主题',
|
||||
`payload` longblob COMMENT '消息内容(二进制安全)',
|
||||
`qos` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'QoS等级',
|
||||
`update_at` int DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_topic` (`topic`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='MQTT保留消息表';
|
||||
|
||||
-- 离线消息队列表(clean=false 会话离线期间的 QoS1/2 消息)
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__mqttbroker_offline` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`client_id` varchar(191) NOT NULL DEFAULT '' COMMENT '目标订阅者client_id',
|
||||
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT '主题',
|
||||
`payload` longblob COMMENT '消息内容(二进制安全)',
|
||||
`qos` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'QoS等级',
|
||||
`create_at` int DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_client_id` (`client_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='MQTT离线消息队列表';
|
||||
|
||||
-- 实时运行指标快照(Broker 周期写入单行 id=1,后台仪表盘轮询读取,跨进程安全)
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__mqttbroker_stats` (
|
||||
`id` int unsigned NOT NULL,
|
||||
`uptime` int DEFAULT '0' COMMENT '运行时长(秒)',
|
||||
`clients_online` int DEFAULT '0' COMMENT '在线连接数',
|
||||
`clients_total` int DEFAULT '0' COMMENT '累计会话数',
|
||||
`subscriptions` int DEFAULT '0' COMMENT '订阅关系数',
|
||||
`messages_received` int DEFAULT '0' COMMENT '累计接收消息数',
|
||||
`messages_sent` int DEFAULT '0' COMMENT '累计发送消息数',
|
||||
`retained` int DEFAULT '0' COMMENT '保留消息数',
|
||||
`timestamp` int DEFAULT '0' COMMENT '快照时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MQTT实时运行指标快照';
|
||||
|
||||
-- 转发规则表(规则引擎:匹配源主题后转发到外部 Broker,如 EMQX)
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__mqttbroker_rule` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(191) NOT NULL DEFAULT '' COMMENT '规则名称',
|
||||
`source_filter` varchar(255) NOT NULL DEFAULT '' COMMENT '源主题过滤器(支持通配符 + #)',
|
||||
`target_type` varchar(16) NOT NULL DEFAULT 'mqtt' COMMENT '转发目标类型:mqtt',
|
||||
`target_broker` varchar(255) NOT NULL DEFAULT '' COMMENT '外部Broker地址 host:port',
|
||||
`target_clientid` varchar(191) DEFAULT '' COMMENT '连接外部Broker的clientId(可选)',
|
||||
`target_username` varchar(191) DEFAULT '' COMMENT '外部Broker用户名(可选)',
|
||||
`target_password` varchar(191) DEFAULT '' COMMENT '外部Broker密码(可选)',
|
||||
`target_topic` varchar(255) NOT NULL DEFAULT '' COMMENT '转发目标主题,支持 ${topic} 变量',
|
||||
`target_qos` tinyint(1) NOT NULL DEFAULT '0' COMMENT '转发QoS',
|
||||
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0停用',
|
||||
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
|
||||
`create_at` int DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_enabled` (`enabled`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MQTT转发规则(规则引擎)';
|
||||
|
||||
-- 手动发布出站队列(后台/API 发布经 DB 出栈,Broker 消费后内部路由,零外部依赖)
|
||||
CREATE TABLE IF NOT EXISTS `__PREFIX__mqttbroker_outbox` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT '主题',
|
||||
`payload` longblob COMMENT '消息内容(二进制安全)',
|
||||
`qos` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'QoS等级',
|
||||
`retain` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否保留',
|
||||
`source` varchar(32) DEFAULT 'manual' COMMENT '来源: manual/api/admin',
|
||||
`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 COMMENT='MQTT手动发布出站队列(跨进程内部路由)';
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"backend": [
|
||||
{
|
||||
"name": "mqttbroker",
|
||||
"title": "MQTT代理",
|
||||
"icon": "fa fa-sitemap",
|
||||
"type": 1,
|
||||
"sort": 60,
|
||||
"status": 1,
|
||||
"child": [
|
||||
{ "name": "mqttbroker/index", "title": "概览", "icon": "fa fa-dashboard", "type": 2, "sort": 1, "route": "/mqttbroker/backend/index" },
|
||||
{ "name": "mqttbroker/connections", "title": "客户端连接", "icon": "fa fa-plug", "type": 2, "sort": 2, "route": "/mqttbroker/backend/connections" },
|
||||
{ "name": "mqttbroker/messages", "title": "消息日志", "icon": "fa fa-envelope", "type": 2, "sort": 3, "route": "/mqttbroker/backend/messages" },
|
||||
{ "name": "mqttbroker/publish", "title": "发布消息", "icon": "fa fa-send", "type": 2, "sort": 4, "route": "/mqttbroker/backend/publish" },
|
||||
{ "name": "mqttbroker/auth", "title": "认证账号", "icon": "fa fa-user-secret", "type": 2, "sort": 5, "route": "/mqttbroker/backend/auth" },
|
||||
{ "name": "mqttbroker/acl", "title": "ACL规则", "icon": "fa fa-shield", "type": 2, "sort": 6, "route": "/mqttbroker/backend/acl" },
|
||||
{ "name": "mqttbroker/stats", "title": "实时监控", "icon": "fa fa-line-chart", "type": 2, "sort": 8, "route": "/mqttbroker/backend/stats" },
|
||||
{ "name": "mqttbroker/rule", "title": "转发规则", "icon": "fa fa-exchange", "type": 2, "sort": 9, "route": "/mqttbroker/backend/rule" },
|
||||
{ "name": "mqttbroker/setting", "title": "服务设置", "icon": "fa fa-cog", "type": 2, "sort": 10, "route": "/mqttbroker/backend/setting" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"member": [],
|
||||
"frontend": []
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?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\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* Connection 类
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Connection extends Model
|
||||
{
|
||||
protected $name = 'mqttbroker_connection';
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'create_at';
|
||||
protected $updateTime = 'update_at';
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?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\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* Message 类
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Message extends Model
|
||||
{
|
||||
protected $name = 'mqttbroker_message';
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'create_at';
|
||||
protected $updateTime = false;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?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\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* Topic 类
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Topic extends Model
|
||||
{
|
||||
protected $name = 'mqttbroker_topic';
|
||||
protected $autoWriteTimestamp = 'int';
|
||||
protected $createTime = 'create_at';
|
||||
protected $updateTime = false;
|
||||
}
|
||||
@@ -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\protocol;
|
||||
|
||||
/**
|
||||
* MQTT 协议编解码器(Workerman 标准协议类)
|
||||
*
|
||||
* 遵循 Workerman 自定义协议规范(https://www.workerman.net/doc/workerman/protocols/how-protocols.html):
|
||||
* - 必须实现 input() / decode() / encode() 三个静态方法;
|
||||
* - 无需强制继承 ProtocolInterface,只要类包含这三个静态方法即可;
|
||||
* - 通过 $worker->protocol = Mqtt::class 绑定,Workerman 在收到数据时自动:
|
||||
* input() 判定一个完整包的长度(>0 包长 / 0 继续等待 / -1 协议错误断开)
|
||||
* decode() 解析完整包,结果作为 $data 传入 onMessage
|
||||
* 业务侧 $connection->send($data) 时自动调用 encode() 打包。
|
||||
*
|
||||
* 解码后数据结构(供 Broker 使用):
|
||||
* ['cmd' => int, 'flags' => int, 'body' => string]
|
||||
* - cmd :MQTT 控制报文类型(高 4 位固定头)
|
||||
* - flags:固定头低 4 位(PUBLISH 时为 dup/qos/retain)
|
||||
* - body :变长头 + 负载(已剔除固定头与剩余长度字段)
|
||||
*
|
||||
* 编码时同样接收上述数组,也可直接传原始字符串(透传)。
|
||||
*/
|
||||
class Mqtt
|
||||
{
|
||||
/* ---------- MQTT 控制报文类型 ---------- */
|
||||
const CMD_CONNECT = 1;
|
||||
const CMD_CONNACK = 2;
|
||||
const CMD_PUBLISH = 3;
|
||||
const CMD_PUBACK = 4;
|
||||
const CMD_PUBREC = 5;
|
||||
const CMD_PUBREL = 6;
|
||||
const CMD_PUBCOMP = 7;
|
||||
const CMD_SUBSCRIBE = 8;
|
||||
const CMD_SUBACK = 9;
|
||||
const CMD_UNSUBSCRIBE = 10;
|
||||
const CMD_UNSUBACK = 11;
|
||||
const CMD_PINGREQ = 12;
|
||||
const CMD_PINGRESP = 13;
|
||||
const CMD_DISCONNECT = 14;
|
||||
const CMD_AUTH = 15;
|
||||
|
||||
/**
|
||||
* 检查包的完整性,返回当前包在 buffer 中的总长度
|
||||
* @param string $buffer 当前收到的数据缓冲
|
||||
* @param mixed $connection Workerman 传入的连接对象(此处未使用)
|
||||
* @return int >0 完整包长度 | 0 数据不足需等待 | -1 协议错误(断开连接)
|
||||
*/
|
||||
public static function input(string $buffer, $connection = null): int
|
||||
{
|
||||
$len = strlen($buffer);
|
||||
if ($len < 2) {
|
||||
return 0; // 至少 1 字节固定头 + 1 字节长度
|
||||
}
|
||||
$first = ord($buffer[0]);
|
||||
$cmd = ($first >> 4) & 0x0F;
|
||||
if ($cmd < 1 || $cmd > 15) {
|
||||
return -1; // 非法控制报文类型
|
||||
}
|
||||
// 解码剩余长度(变长整数,最多 4 字节)
|
||||
$value = 0;
|
||||
$multiplier = 1;
|
||||
$i = 0;
|
||||
$pos = 1;
|
||||
do {
|
||||
if ($pos + $i >= $len) {
|
||||
return 0; // 长度字节尚未收全
|
||||
}
|
||||
$byte = ord($buffer[$pos + $i]);
|
||||
$value += ($byte & 0x7F) * $multiplier;
|
||||
$multiplier *= 128;
|
||||
$i++;
|
||||
if ($i > 4) {
|
||||
return -1; // 剩余长度字段超过 4 字节,协议错误
|
||||
}
|
||||
} while (($byte & 0x80) !== 0);
|
||||
|
||||
$headerLen = 1 + $i; // 固定头(1) + 剩余长度字段(i)
|
||||
if ($len - $headerLen < $value) {
|
||||
return 0; // 包体尚未收全
|
||||
}
|
||||
return $headerLen + $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解包:把完整包解析为结构化数组,作为 onMessage 的 $data
|
||||
* @param string $buffer 一个完整 MQTT 报文(含固定头)
|
||||
* @param mixed $connection
|
||||
* @return array ['cmd'=>int,'flags'=>int,'body'=>string]
|
||||
*/
|
||||
public static function decode(string $buffer, $connection = null): array
|
||||
{
|
||||
if ($buffer === '') {
|
||||
return ['cmd' => 0, 'flags' => 0, 'body' => ''];
|
||||
}
|
||||
$first = ord($buffer[0]);
|
||||
$cmd = ($first >> 4) & 0x0F;
|
||||
$flags = $first & 0x0F;
|
||||
|
||||
// 解码剩余长度,定位包体起始
|
||||
$value = 0;
|
||||
$multiplier = 1;
|
||||
$i = 0;
|
||||
$pos = 1;
|
||||
do {
|
||||
$byte = ord($buffer[$pos + $i]);
|
||||
$value += ($byte & 0x7F) * $multiplier;
|
||||
$multiplier *= 128;
|
||||
$i++;
|
||||
} while (($byte & 0x80) !== 0);
|
||||
|
||||
$body = substr($buffer, $pos + $i, $value);
|
||||
return ['cmd' => $cmd, 'flags' => $flags, 'body' => $body];
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包:把结构化数组编码为可发送字节流
|
||||
* @param array|string $data 编码结构 ['cmd'=>int,'flags'=>int,'body'=>string] 或原始字符串
|
||||
* @param mixed $connection
|
||||
* @return string
|
||||
*/
|
||||
public static function encode($data, $connection = null): string
|
||||
{
|
||||
if (is_string($data)) {
|
||||
return $data; // 兼容直接发送原始字节
|
||||
}
|
||||
$cmd = (int) ($data['cmd'] ?? 0);
|
||||
$flags = (int) ($data['flags'] ?? 0);
|
||||
$body = (string) ($data['body'] ?? '');
|
||||
$fh = chr(($cmd << 4) | ($flags & 0x0F));
|
||||
return $fh . self::encodeRemainingLength(strlen($body)) . $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 MQTT 剩余长度(变长整数,最大 4 字节)
|
||||
*/
|
||||
public static function encodeRemainingLength(int $len): string
|
||||
{
|
||||
$bytes = '';
|
||||
do {
|
||||
$byte = $len % 128;
|
||||
$len = intdiv($len, 128);
|
||||
if ($len > 0) {
|
||||
$byte |= 0x80;
|
||||
}
|
||||
$bytes .= chr($byte);
|
||||
} while ($len > 0);
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 MQTT 剩余长度,返回 [值, 占用字节数]
|
||||
*/
|
||||
public static function decodeRemainingLength(string $buffer, int $pos = 0): array
|
||||
{
|
||||
$value = 0;
|
||||
$multiplier = 1;
|
||||
$i = 0;
|
||||
do {
|
||||
$byte = ord($buffer[$pos + $i]);
|
||||
$value += ($byte & 0x7F) * $multiplier;
|
||||
$multiplier *= 128;
|
||||
$i++;
|
||||
if ($i > 4) {
|
||||
break;
|
||||
}
|
||||
} while (($byte & 0x80) !== 0);
|
||||
return [$value, $pos + $i];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
// mqttbroker 插件路由(相对应用:访问前缀 /mqttbroker/...)
|
||||
use think\facade\Route;
|
||||
|
||||
// 后台管理
|
||||
Route::group('backend', function () {
|
||||
Route::get('index', 'backend/MqttBroker/index');
|
||||
Route::get('connections', 'backend/MqttBroker/connections');
|
||||
Route::get('messages', 'backend/MqttBroker/messages');
|
||||
Route::get('publish', 'backend/MqttBroker/publish');
|
||||
Route::post('publish', 'backend/MqttBroker/doPublish');
|
||||
Route::get('setting', 'backend/MqttBroker/setting');
|
||||
Route::post('setting', 'backend/MqttBroker/saveSetting');
|
||||
// 认证账号
|
||||
Route::get('auth', 'backend/MqttBroker/auth');
|
||||
Route::post('saveAuth', 'backend/MqttBroker/saveAuth');
|
||||
Route::post('deleteAuth', 'backend/MqttBroker/deleteAuth');
|
||||
// ACL 规则
|
||||
Route::get('acl', 'backend/MqttBroker/acl');
|
||||
Route::post('saveAcl', 'backend/MqttBroker/saveAcl');
|
||||
Route::post('deleteAcl', 'backend/MqttBroker/deleteAcl');
|
||||
// 实时监控
|
||||
Route::get('stats', 'backend/MqttBroker/stats');
|
||||
// 转发规则(规则引擎 / 桥接到 EMQX)
|
||||
Route::get('rule', 'backend/MqttBroker/rule');
|
||||
Route::post('saveRule', 'backend/MqttBroker/saveRule');
|
||||
Route::post('deleteRule', 'backend/MqttBroker/deleteRule');
|
||||
});
|
||||
|
||||
// 对外 REST API
|
||||
Route::group('api', function () {
|
||||
Route::get('status', 'api/Mqtt/status');
|
||||
Route::post('publish', 'api/Mqtt/publish');
|
||||
});
|
||||
@@ -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 哈希与明文(明文仅便于开发,生产请用哈希)。
|
||||
*
|
||||
* ACL(acl_enabled=1 时生效):
|
||||
* - 规则表按 sort 升序、id 升序匹配,命中第一条即决定 allow/deny。
|
||||
* - target_type:all(全部) / user(按用户名) / client(按 clientId)。
|
||||
* - topic 支持 MQTT 通配符(+ / #)与占位符 %u(用户名) %c(clientId)。
|
||||
* - access:1 订阅 / 2 发布 / 3 两者。
|
||||
* - 超级用户(is_superuser=1)跳过 ACL。
|
||||
* - $SYS/# 始终允许订阅、禁止发布。
|
||||
* - 无命中时采用默认策略 acl_default(allow/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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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/mqtt(composer 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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/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<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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
-- ============================================================
|
||||
-- addon/mqttbroker/upgrade_time_fields.sql
|
||||
-- 时间字段统一约定:create_at / update_at / delete_at
|
||||
-- 适用:已安装过 mqttbroker 插件的旧库(install.sql 的 IF NOT EXISTS 不会改已存在的表)
|
||||
-- 用法:在数据库客户端执行本文件即可(幂等,重复执行仅 CHANGE 同名列,无副作用)
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE `__PREFIX__mqttbroker_connection`
|
||||
CHANGE `connect_time` `create_at` INT DEFAULT NULL COMMENT '连接时间',
|
||||
CHANGE `update_time` `update_at` INT DEFAULT NULL COMMENT '更新时间';
|
||||
|
||||
ALTER TABLE `__PREFIX__mqttbroker_message`
|
||||
CHANGE `create_time` `create_at` INT DEFAULT NULL COMMENT '时间',
|
||||
DROP INDEX `idx_create_time`,
|
||||
ADD INDEX `idx_create_at` (`create_at`);
|
||||
|
||||
ALTER TABLE `__PREFIX__mqttbroker_topic`
|
||||
CHANGE `create_time` `create_at` INT DEFAULT NULL COMMENT '订阅时间';
|
||||
|
||||
ALTER TABLE `__PREFIX__mqttbroker_auth`
|
||||
CHANGE `create_time` `create_at` INT DEFAULT NULL,
|
||||
CHANGE `update_time` `update_at` INT DEFAULT NULL;
|
||||
|
||||
ALTER TABLE `__PREFIX__mqttbroker_acl`
|
||||
CHANGE `create_time` `create_at` INT DEFAULT NULL;
|
||||
|
||||
ALTER TABLE `__PREFIX__mqttbroker_retain`
|
||||
CHANGE `update_time` `update_at` INT DEFAULT NULL;
|
||||
|
||||
ALTER TABLE `__PREFIX__mqttbroker_offline`
|
||||
CHANGE `create_time` `create_at` INT DEFAULT NULL;
|
||||
|
||||
ALTER TABLE `__PREFIX__mqttbroker_rule`
|
||||
CHANGE `create_time` `create_at` INT DEFAULT NULL;
|
||||
|
||||
ALTER TABLE `__PREFIX__mqttbroker_outbox`
|
||||
CHANGE `create_time` `create_at` INT DEFAULT NULL;
|
||||
@@ -0,0 +1,74 @@
|
||||
{extend name="layout"}
|
||||
|
||||
<div class="layui-container">
|
||||
<div class="admin-title">ACL 权限规则</div>
|
||||
<div class="layui-text" style="margin-bottom:10px;color:#999;">
|
||||
需在"服务设置"中开启 ACL 才生效。按 sort 升序匹配,命中第一条即决定放行/拒绝;
|
||||
主题支持通配符 <code>+ / #</code> 与占位符 <code>%u</code>(用户名) <code>%c</code>(clientId)。
|
||||
</div>
|
||||
<button class="layui-btn layui-btn-sm" onclick="editAcl()">+ 新增规则</button>
|
||||
<table class="layui-table" style="margin-top:10px;">
|
||||
<thead>
|
||||
<tr><th>sort</th><th>目标</th><th>主题过滤器</th><th>权限</th><th>动作</th><th>备注</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach $list as $c}
|
||||
<tr>
|
||||
<td>{$c.sort}</td>
|
||||
<td>{$c.target_type}{if $c.target}:{$c.target}{/if}</td>
|
||||
<td>{$c.topic}</td>
|
||||
<td>{if $c.access==1}订阅{elseif $c.access==2}发布{else}订阅+发布{/if}</td>
|
||||
<td>{if $c.allow}<span class="layui-badge layui-bg-green">允许</span>{else}<span class="layui-badge">拒绝</span>{/if}</td>
|
||||
<td>{$c.remark}</td>
|
||||
<td>
|
||||
<a class="layui-btn layui-btn-xs" onclick='editAcl({$c|json_encode})'>编辑</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger" onclick="delAcl({$c.id})">删除</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
{$list|raw}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['layer', 'jquery'], function(){
|
||||
var $ = layui.jquery, layer = layui.layer;
|
||||
window.editAcl = function(row){
|
||||
row = row || {};
|
||||
var sel = function(name, val, opts){
|
||||
var s = '<select name="'+name+'">';
|
||||
opts.forEach(function(o){ s += '<option value="'+o[0]+'" '+(val==o[0]?'selected':'')+'>'+o[1]+'</option>'; });
|
||||
return s + '</select>';
|
||||
};
|
||||
var html = '<form class="layui-form" style="padding:20px;" lay-filter="aclForm">'
|
||||
+ '<input type="hidden" name="id" value="'+(row.id||'')+'">'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">目标类型</label><div class="layui-input-inline">'+sel('target_type', row.target_type||'all', [['all','全部'],['user','按用户名'],['client','按clientId']])+'</div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">目标值</label><div class="layui-input-inline"><input name="target" class="layui-input" value="'+(row.target||'')+'" placeholder="target_type=all 时留空"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">主题过滤器</label><div class="layui-input-inline"><input name="topic" class="layui-input" value="'+(row.topic||'')+'" placeholder="如 device/%c/# 或 sensor/+/temp"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">权限</label><div class="layui-input-inline">'+sel('access', row.access||3, [[1,'订阅'],[2,'发布'],[3,'订阅+发布']])+'</div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">动作</label><div class="layui-input-inline">'+sel('allow', (row.allow===undefined?1:row.allow), [[1,'允许'],[0,'拒绝']])+'</div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">排序</label><div class="layui-input-inline"><input name="sort" class="layui-input" value="'+(row.sort||0)+'"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">备注</label><div class="layui-input-inline"><input name="remark" class="layui-input" value="'+(row.remark||'')+'"></div></div>'
|
||||
+ '</form>';
|
||||
layer.open({type:1, title:(row.id?'编辑':'新增')+'规则', area:['520px','auto'], content:html, btn:['保存','取消'],
|
||||
success:function(){ layui.form.render(); },
|
||||
yes:function(idx){
|
||||
var data = {};
|
||||
$('[lay-filter=aclForm]').serializeArray().forEach(function(i){ data[i.name]=i.value; });
|
||||
$.post('/mqttbroker/backend/saveAcl', data, function(res){
|
||||
layer.msg(res.message||res.msg||'ok');
|
||||
layer.close(idx); setTimeout(function(){location.reload();},600);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
window.delAcl = function(id){
|
||||
layer.confirm('确认删除该规则?', function(idx){
|
||||
$.post('/mqttbroker/backend/deleteAcl', {id:id}, function(res){ layer.msg(res.message||'已删除'); setTimeout(function(){location.reload();},600); });
|
||||
layer.close(idx);
|
||||
});
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{extend name="layout"}
|
||||
|
||||
<div class="layui-container">
|
||||
<div class="admin-title">认证账号</div>
|
||||
<div class="layui-text" style="margin-bottom:10px;color:#999;">
|
||||
当"允许匿名连接"关闭时,客户端必须使用此处账号连接;超级用户跳过 ACL 校验。
|
||||
</div>
|
||||
<button class="layui-btn layui-btn-sm" onclick="editAuth()">+ 新增账号</button>
|
||||
<table class="layui-table" style="margin-top:10px;">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>用户名</th><th>超级用户</th><th>状态</th><th>备注</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach $list as $c}
|
||||
<tr>
|
||||
<td>{$c.id}</td>
|
||||
<td>{$c.username}</td>
|
||||
<td>{if $c.is_superuser}<span class="layui-badge layui-bg-orange">是</span>{else}否{/if}</td>
|
||||
<td>{if $c.status}<span class="layui-badge layui-bg-green">启用</span>{else}<span class="layui-badge">禁用</span>{/if}</td>
|
||||
<td>{$c.remark}</td>
|
||||
<td>
|
||||
<a class="layui-btn layui-btn-xs" onclick='editAuth({$c|json_encode})'>编辑</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger" onclick="delAuth({$c.id})">删除</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
{$list|raw}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['layer', 'jquery'], function(){
|
||||
var $ = layui.jquery, layer = layui.layer;
|
||||
window.editAuth = function(row){
|
||||
row = row || {};
|
||||
var html = '<form class="layui-form" style="padding:20px;" lay-filter="authForm">'
|
||||
+ '<input type="hidden" name="id" value="'+(row.id||'')+'">'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">用户名</label><div class="layui-input-inline"><input name="username" class="layui-input" value="'+(row.username||'')+'"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">密码</label><div class="layui-input-inline"><input name="password" class="layui-input" placeholder="'+(row.id?'留空表示不修改':'必填')+'"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">超级用户</label><div class="layui-input-inline"><select name="is_superuser"><option value="0" '+(!row.is_superuser?'selected':'')+'>否</option><option value="1" '+(row.is_superuser==1?'selected':'')+'>是</option></select></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-inline"><select name="status"><option value="1" '+(row.status!=0?'selected':'')+'>启用</option><option value="0" '+(row.status==0?'selected':'')+'>禁用</option></select></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">备注</label><div class="layui-input-inline"><input name="remark" class="layui-input" value="'+(row.remark||'')+'"></div></div>'
|
||||
+ '</form>';
|
||||
layer.open({type:1, title:(row.id?'编辑':'新增')+'账号', area:['480px','auto'], content:html, btn:['保存','取消'],
|
||||
success:function(){ layui.form.render(); },
|
||||
yes:function(idx){
|
||||
var data = {};
|
||||
$('[lay-filter=authForm]').serializeArray().forEach(function(i){ data[i.name]=i.value; });
|
||||
$.post('/mqttbroker/backend/saveAuth', data, function(res){
|
||||
layer.msg(res.message||res.msg||'ok');
|
||||
if((res.code===0)||(res.code===1)) { layer.close(idx); setTimeout(function(){location.reload();},600); }
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
window.delAuth = function(id){
|
||||
layer.confirm('确认删除该账号?', function(idx){
|
||||
$.post('/mqttbroker/backend/deleteAuth', {id:id}, function(res){ layer.msg(res.message||'已删除'); setTimeout(function(){location.reload();},600); });
|
||||
layer.close(idx);
|
||||
});
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
<div class="layui-container">
|
||||
<div class="admin-title">客户端连接</div>
|
||||
<table class="layui-table">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>ClientID</th><th>用户名</th><th>IP</th><th>状态</th><th>连接时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach $list as $c}
|
||||
<tr>
|
||||
<td>{$c.id}</td>
|
||||
<td>{$c.client_id}</td>
|
||||
<td>{$c.username}</td>
|
||||
<td>{$c.ip}</td>
|
||||
<td>{$c.status|default=0}</td>
|
||||
<td>{if !empty($c.create_at)}{$c.create_at|date='Y-m-d H:i:s'}{else}-{/if}</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
{$list|raw}
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
<div class="layui-container">
|
||||
<div class="admin-title">MQTT 代理概览</div>
|
||||
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">在线连接</div>
|
||||
<div class="layui-card-body" style="font-size:30px;font-weight:bold;color:#1E9FFF;">{$stats.online}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">累计连接</div>
|
||||
<div class="layui-card-body" style="font-size:30px;font-weight:bold;">{$stats.total}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">消息总数</div>
|
||||
<div class="layui-card-body" style="font-size:30px;font-weight:bold;">{$stats.messages}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">订阅关系</div>
|
||||
<div class="layui-card-body" style="font-size:30px;font-weight:bold;">{$stats.topics}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-title">最近消息</div>
|
||||
<table class="layui-table">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>主题</th><th>来源</th><th>内容</th><th>时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach $recent as $m}
|
||||
<tr>
|
||||
<td>{$m.id}</td>
|
||||
<td>{$m.topic}</td>
|
||||
<td>{$m.source}</td>
|
||||
<td>{$m.payload}</td>
|
||||
<td>{if !empty($m.create_at)}{$m.create_at|date='Y-m-d H:i:s'}{else}-{/if}</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="layui-text" style="margin-top:15px;color:#999;">
|
||||
启动 Broker:<code>php think mqttbroker:start</code>(需在命令行运行常驻进程)
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
<div class="layui-container">
|
||||
<div class="admin-title">消息日志</div>
|
||||
<form class="layui-form" action="" style="margin-bottom:10px;">
|
||||
<div class="layui-inline">
|
||||
<input type="text" name="topic" value="{$topic}" placeholder="按主题搜索" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn" lay-submit>搜索</button>
|
||||
</div>
|
||||
</form>
|
||||
<table class="layui-table">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>ClientID</th><th>主题</th><th>QoS</th><th>内容</th><th>来源</th><th>时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach $list as $m}
|
||||
<tr>
|
||||
<td>{$m.id}</td>
|
||||
<td>{$m.client_id}</td>
|
||||
<td>{$m.topic}</td>
|
||||
<td>{$m.qos}</td>
|
||||
<td>{$m.payload}</td>
|
||||
<td>{$m.source}</td>
|
||||
<td>{if !empty($m.create_at)}{$m.create_at|date='Y-m-d H:i:s'}{else}-{/if}</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
{$list|raw}
|
||||
</div>
|
||||
@@ -0,0 +1,45 @@
|
||||
|
||||
<div class="layui-container">
|
||||
<div class="admin-title">发布消息</div>
|
||||
<form class="layui-form" id="publishForm">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">主题</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="topic" required lay-verify="required" placeholder="如 sensor/room1/temp" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">内容</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="payload" placeholder="消息内容" class="layui-textarea"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">QoS</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="qos">
|
||||
<option value="0">0</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn" lay-submit lay-filter="publish">发布</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['form', 'jquery'], function(){
|
||||
var form = layui.form, $ = layui.jquery;
|
||||
form.on('submit(publish)', function(data){
|
||||
$.post('/mqttbroker/backend/publish', data.field, function(res){
|
||||
layer.msg(res.message || res.msg || '已提交');
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
{extend name="layout"}
|
||||
|
||||
<div class="layui-container">
|
||||
<div class="admin-title">转发规则(规则引擎 / 桥接到 EMQX)</div>
|
||||
<div class="layui-text" style="margin-bottom:10px;color:#999;">
|
||||
匹配"源主题过滤器"的消息,将被转发到外部 MQTT Broker(如 EMQX)。目标主题支持
|
||||
<code>${topic}</code> 变量(沿用原主题)。规则最多 30s 后在 Broker 生效。
|
||||
转发依赖 <code>workerman/mqtt</code> 客户端,未安装时自动跳过。
|
||||
</div>
|
||||
<button class="layui-btn layui-btn-sm" onclick="editRule()">+ 新增规则</button>
|
||||
<table class="layui-table" style="margin-top:10px;">
|
||||
<thead>
|
||||
<tr><th>名称</th><th>源主题过滤器</th><th>目标Broker</th><th>目标主题</th><th>QoS</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach $list as $r}
|
||||
<tr>
|
||||
<td>{$r.name}</td>
|
||||
<td>{$r.source_filter}</td>
|
||||
<td>{$r.target_broker}</td>
|
||||
<td>{if !empty($r.target_topic)}{$r.target_topic}{else/}${topic}{/if}</td>
|
||||
<td>{$r.target_qos}</td>
|
||||
<td>{if $r.enabled}<span class="layui-badge layui-bg-green">启用</span>{else}<span class="layui-badge">停用</span>{/if}</td>
|
||||
<td>
|
||||
<a class="layui-btn layui-btn-xs" onclick='editRule({$r|json_encode})'>编辑</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger" onclick="delRule({$r.id})">删除</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
{$list|raw}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['layer', 'jquery'], function(){
|
||||
var $ = layui.jquery, layer = layui.layer;
|
||||
window.editRule = function(row){
|
||||
row = row || {};
|
||||
var sel = function(name, val, opts){
|
||||
var s = '<select name="'+name+'">';
|
||||
opts.forEach(function(o){ s += '<option value="'+o[0]+'" '+(val==o[0]?'selected':'')+'>'+o[1]+'</option>'; });
|
||||
return s + '</select>';
|
||||
};
|
||||
var html = '<form class="layui-form" style="padding:20px;" lay-filter="ruleForm">'
|
||||
+ '<input type="hidden" name="id" value="'+(row.id||'')+'">'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">规则名称</label><div class="layui-input-inline"><input name="name" class="layui-input" value="'+(row.name||'')+'"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">源主题过滤器</label><div class="layui-input-inline"><input name="source_filter" class="layui-input" value="'+(row.source_filter||'')+'" placeholder="如 sensor/+/temp"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">目标类型</label><div class="layui-input-inline">'+sel('target_type', row.target_type||'mqtt', [['mqtt','MQTT Broker']])+'</div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">目标Broker</label><div class="layui-input-inline"><input name="target_broker" class="layui-input" value="'+(row.target_broker||'')+'" placeholder="host:port,如 192.168.1.10:1883"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">目标主题</label><div class="layui-input-inline"><input name="target_topic" class="layui-input" value="'+(row.target_topic||'')+'" placeholder="${topic} 表示沿用原主题"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">目标QoS</label><div class="layui-input-inline">'+sel('target_qos', (row.target_qos===undefined?0:row.target_qos), [[0,'0'],[1,'1'],[2,'2']])+'</div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">ClientID</label><div class="layui-input-inline"><input name="target_clientid" class="layui-input" value="'+(row.target_clientid||'')+'" placeholder="可选"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">用户名</label><div class="layui-input-inline"><input name="target_username" class="layui-input" value="'+(row.target_username||'')+'" placeholder="可选"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">密码</label><div class="layui-input-inline"><input name="target_password" type="password" class="layui-input" value="'+(row.target_password||'')+'" placeholder="可选"></div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-inline">'+sel('enabled', (row.enabled===undefined?1:row.enabled), [[1,'启用'],[0,'停用']])+'</div></div>'
|
||||
+ '<div class="layui-form-item"><label class="layui-form-label">备注</label><div class="layui-input-inline"><input name="remark" class="layui-input" value="'+(row.remark||'')+'"></div></div>'
|
||||
+ '</form>';
|
||||
layer.open({type:1, title:(row.id?'编辑':'新增')+'转发规则', area:['560px','auto'], content:html, btn:['保存','取消'],
|
||||
success:function(){ layui.form.render(); },
|
||||
yes:function(idx){
|
||||
var data = {};
|
||||
$('[lay-filter=ruleForm]').serializeArray().forEach(function(i){ data[i.name]=i.value; });
|
||||
$.post('/mqttbroker/backend/saveRule', data, function(res){
|
||||
layer.msg(res.message||res.msg||'ok');
|
||||
layer.close(idx); setTimeout(function(){location.reload();},600);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
window.delRule = function(id){
|
||||
layer.confirm('确认删除该规则?', function(idx){
|
||||
$.post('/mqttbroker/backend/deleteRule', {id:id}, function(res){ layer.msg(res.message||'已删除'); setTimeout(function(){location.reload();},600); });
|
||||
layer.close(idx);
|
||||
});
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
|
||||
<div class="layui-card-body">
|
||||
<div class="admin-title">服务设置</div>
|
||||
<form class="layui-form" id="settingForm">
|
||||
{foreach $def as $item}
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">{$item.title}</label>
|
||||
<div class="layui-input-inline">
|
||||
{if isset($item.options) && $item.options}
|
||||
<select name="{$item.name}">
|
||||
{foreach $item.options as $k=>$v}
|
||||
<option value="{$k}" {if (isset($saved[$item['name']]) && $saved[$item['name']]==$k)}selected{/if}>{$v}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
{else}
|
||||
<input type="text" name="{$item.name}" value="{$saved[$item['name']] ?? $item.value}" class="layui-input">
|
||||
{/if}
|
||||
</div>
|
||||
{if isset($item.tip) && $item.tip}<div class="layui-form-mid layui-word-aux">{$item.tip}</div>{/if}
|
||||
</div>
|
||||
{/foreach}
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn" lay-submit lay-filter="save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['form', 'jquery'], function(){
|
||||
var form = layui.form, $ = layui.jquery;
|
||||
form.on('submit(save)', function(data){
|
||||
$.post('/mqttbroker/backend/setting', data.field, function(res){
|
||||
layer.msg(res.message || '已保存');
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
|
||||
<div class="layui-container">
|
||||
<div class="admin-title">MQTT 实时运行监控
|
||||
<span class="layui-badge layui-bg-blue" id="live" style="margin-left:10px;">实时</span>
|
||||
<span style="float:right;color:#999;font-size:12px;">最后刷新:<span id="lastSync">--</span></span>
|
||||
</div>
|
||||
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card"><div class="layui-card-header">在线连接</div>
|
||||
<div class="layui-card-body" style="font-size:30px;font-weight:bold;color:#1E9FFF;" id="m_online">0</div></div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card"><div class="layui-card-header">会话总数</div>
|
||||
<div class="layui-card-body" style="font-size:30px;font-weight:bold;" id="m_total">0</div></div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card"><div class="layui-card-header">订阅关系</div>
|
||||
<div class="layui-card-body" style="font-size:30px;font-weight:bold;color:#16a34a;" id="m_subs">0</div></div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card"><div class="layui-card-header">运行时长</div>
|
||||
<div class="layui-card-body" style="font-size:30px;font-weight:bold;" id="m_uptime">00:00:00</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card"><div class="layui-card-header">累计接收</div>
|
||||
<div class="layui-card-body" style="font-size:26px;font-weight:bold;" id="m_recv">0</div></div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card"><div class="layui-card-header">累计发送</div>
|
||||
<div class="layui-card-body" style="font-size:26px;font-weight:bold;" id="m_sent">0</div></div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card"><div class="layui-card-header">保留消息</div>
|
||||
<div class="layui-card-body" style="font-size:26px;font-weight:bold;" id="m_retained">0</div></div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card"><div class="layui-card-header">快照时间</div>
|
||||
<div class="layui-card-body" style="font-size:18px;" id="m_ts">--</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-text" style="margin-top:10px;color:#999;">
|
||||
指标由 Broker 进程每 2 秒写入数据库,本页每 3 秒轮询刷新(跨进程安全,Windows 调试与 Linux 生产通用)。
|
||||
若长时间显示 0,请确认 Broker 已启动:<code>php think mqttbroker:start</code>。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['jquery'], function(){
|
||||
var $ = layui.jquery;
|
||||
function fmtUptime(s){
|
||||
s = parseInt(s||0);
|
||||
var h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;
|
||||
var p = function(n){ return (n<10?'0':'')+n; };
|
||||
return p(h)+':'+p(m)+':'+p(ss);
|
||||
}
|
||||
function load(){
|
||||
$.getJSON('/mqttbroker/backend/stats', function(res){
|
||||
if (res.code !== 0 || !res.data) return;
|
||||
var d = res.data;
|
||||
$('#m_online').text(d.db_online != null ? d.db_online : d.clients_online);
|
||||
$('#m_total').text(d.clients_total);
|
||||
$('#m_subs').text(d.subscriptions);
|
||||
$('#m_uptime').text(fmtUptime(d.uptime));
|
||||
$('#m_recv').text(d.messages_received);
|
||||
$('#m_sent').text(d.messages_sent);
|
||||
$('#m_retained').text(d.retained);
|
||||
$('#m_ts').text(new Date((d.timestamp||0)*1000).toLocaleString());
|
||||
$('#lastSync').text(new Date().toLocaleTimeString());
|
||||
$('#live').text('实时●');
|
||||
}).fail(function(){ $('#live').text('断开'); });
|
||||
}
|
||||
load();
|
||||
setInterval(load, 3000);
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user