// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\iot\controller; use think\facade\Db; use ywxapp\controller\FrontendBase; /** * IoT 插件前台展示页(免登录门面,对外介绍功能/能力/优势) * * GET /iot/index/index * * 遵循插件前台开发约定(与 articles/Index 同构): * - 继承 FrontendBase:视图根自动切到 addon/iot/view/frontend/, * noNeedLogin 默认 ['*'] 游客放行,fetch 叠加皮肤覆盖层; * - 控制器放 controller/ 根(非 api/backend 子目录), * 路由用字符串目标 'addon\iot\controller\Index@index'。 * 数据侧只读聚合指标,全部 try/catch 兜底 —— 任何一张表缺失/异常都 * 降级为 0,绝不 500(门面页的可用性 > 数据完整性)。 */ class Index extends FrontendBase { /** * 展示页 */ public function index() { $this->assign('stats', $this->liveStats()); $this->assign('apiGroups', $this->apiCatalog()); $this->assign('now', date('Y-m-d H:i')); return $this->fetch('index/index'); } /** * 实时聚合指标(每项独立兜底,缺表/异常返回 0) */ protected function liveStats(): array { $count = function (string $table, array $where = []): int { try { return (int) Db::name($table)->where($where)->count(); } catch (\Throwable $e) { return 0; } }; // Broker 快照(iot_stats id=1,Broker 进程每 2s 写入) $broker = ['uptime' => 0, 'clients_online' => 0, 'messages_received' => 0, 'retained' => 0]; try { $row = Db::name('iot_stats')->where('id', 1)->find(); if ($row) { $broker = array_merge($broker, array_intersect_key($row, $broker)); } } catch (\Throwable $e) { } return [ 'uptime' => $this->fmtUptime((int) $broker['uptime']), 'online' => (int) $broker['clients_online'], 'recv' => (int) $broker['messages_received'], 'retained' => (int) $broker['retained'], 'devices' => $count('iot_device'), 'points' => $count('iot_sensor_point'), 'alerts' => $count('iot_alert'), 'scenes' => $count('iot_scene'), 'shares' => $count('iot_share', ['status' => 1]), 'ota_pkgs' => $count('iot_ota'), ]; } /** 供模板格式化运行时长(时:分:秒) */ protected function fmtUptime(int $s): string { return sprintf('%d:%02d:%02d', intdiv($s, 3600), intdiv($s % 3600, 60), $s % 60); } /** * App API 能力域统计:从 route/app.php 自动解析(路由唯一真源)。 * 宣传页只展示功能域划分与接口数量——不输出任何端点路径 / 主题字符串 * (公开页面不做攻击者的侦察地图;详细契约见 docs/guide/iot_api_reference.md)。 * * 解析约定:只匹配数组目标规则(本插件全部 App API 均为 [Xxx::class,'action'] 书写; * backend/* 与前台页是字符串目标,天然不会被误收)。 * 同一 controller+action 只计首次出现:新域前缀 URL 注册在前、文件末尾的 * 旧 client/* 兼容别名在后,去重避免兼容别名把数量翻倍。 */ protected function apiCatalog(): array { try { $file = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'route' . DIRECTORY_SEPARATOR . 'app.php'; if (!is_file($file) || !preg_match_all( "#Route::(get|post)\(\s*'([^']+)'\s*,\s*\[(\w+)::class\s*,\s*'(\w+)'\]#", (string) @file_get_contents($file), $ms, PREG_SET_ORDER )) { return []; } } catch (\Throwable $e) { return []; } $domains = [ 'mqtt' => ['ico' => '📡', 'label' => 'Broker 运维', 'blurb' => '运行状态、指标快照与消息直发'], 'client' => ['ico' => '📱', 'label' => '设备与会员', 'blurb' => '绑定认领、共享授权、短时票据与命令下发'], 'monitor' => ['ico' => '📈', 'label' => '实时监测', 'blurb' => '历史曲线、分钟级聚合、告警与设备日志'], 'scene' => ['ico' => '⚡', 'label' => '场景联动', 'blurb' => '规则引擎、设备分组与批量控制'], 'ota' => ['ico' => '🚀', 'label' => '固件升级', 'blurb' => '升级包管理与分批推送进度跟踪'], '_root' => ['ico' => '🔑', 'label' => '设备入云激活', 'blurb' => '出厂预置令牌换领一机一密凭证'], ]; $groups = []; $seen = []; // "controller/action" => true:兼容别名去重 foreach ($ms as $m) { [, , $rule, $ctrl, $action] = $m; $key = $ctrl . '/' . $action; if (isset($seen[$key])) { continue; } $seen[$key] = true; $domain = strpos($rule, '/') === false ? '_root' : strtok($rule, '/'); if (!isset($domains[$domain])) { continue; // 未知前缀不入目录 } $groups[$domain] = $groups[$domain] ?? ($domains[$domain] + ['count' => 0]); $groups[$domain]['count']++; } return array_values($groups); } }