// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\iot\service; use think\facade\Db; /** * 告警/通知服务(设备离线、场景联动、OTA 失败等) * * 落点: * - iot_alert 表:站内告警(App/后台可查、可标记已读) * - /notify/{account}/# 主题:MQTT 实时通知(retain 兜底,App 在线即推) * * 离线告警抑制:同一设备 10 分钟内只记一次,避免断连抖动刷屏 */ class AlertService { /** 离线告警抑制窗口(秒) */ public const OFFLINE_SUPPRESS = 600; /** pos_index 取值:255 = 该告警与传感器位置无关(离线/场景/OTA 等) */ public const POS_NA = 255; /** * 设备 event 里 rs(reason 短码)→ 人话 * * v1.6.0 起固件不再把中文塞进每条告警(中文 3 字节/字,"位置内温差超限"一条就 24B), * 改发两个字母的码;人话在服务端这一处展开 —— 想改文案不用重烧固件。 * ov = 探头越限(某个探头超出 upper/lower) * df = 位置内温差超限(同总线两颗探头极差 ≥ gap) */ public const REASON_TEXT = [ 'ov' => '探头越限', 'df' => '位置内温差超限', ]; /** 确保 alert 表存在(自愈) */ public static function ensureTable(): bool { $prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_'; try { Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_alert` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属会员', `uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '关联设备uuid(可空)', `pos_index` tinyint unsigned NOT NULL DEFAULT '255' COMMENT '传感器位置序号(255=不适用)', `type` varchar(32) NOT NULL DEFAULT '' COMMENT 'offline/scene/condition/custom', `level` tinyint(1) NOT NULL DEFAULT '2' COMMENT '1提示 2警告 3严重', `title` varchar(128) NOT NULL DEFAULT '' COMMENT '标题', `content` varchar(512) NOT NULL DEFAULT '' COMMENT '内容', `is_read` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0未读 1已读', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_member` (`member_id`), KEY `idx_create_at` (`create_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); self::ensureColumns(); return true; } catch (\Throwable $e) { return false; } } /** * 老库加列自愈(pos_index:把告警精确到传感器位置 * —— 通知里只有 alias 文本时,多个位置同名就无法定位到具体位置) */ public static function ensureColumns(): bool { $prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_'; $table = $prefix . 'iot_alert'; try { $cols = []; foreach (Db::query("SHOW COLUMNS FROM `{$table}`") as $r) { $cols[(string) $r['Field']] = true; } if (!isset($cols['pos_index'])) { Db::execute("ALTER TABLE `{$table}` ADD COLUMN `pos_index` tinyint unsigned NOT NULL DEFAULT '255' COMMENT '传感器位置序号(255=不适用)' AFTER `uuid`"); } return true; } catch (\Throwable $e) { return false; } } /** * 新增告警(写表 + 实时通知) * * @param int $posIndex 传感器位置序号;与位置无关的告警传 self::POS_NA(255)。 * 带上它,App/后台才能把通知精确对应到某个位置卡片 * (仅靠 title 里的 alias 文本,同名位置无法区分)。 * @return int 告警ID */ public static function add(int $memberId, string $type, int $level, string $title, string $content, string $uuid = '', int $posIndex = self::POS_NA): int { self::ensureTable(); $now = time(); $pos = max(0, min(255, $posIndex)); $id = 0; try { $id = (int) Db::name('iot_alert')->insertGetId([ 'member_id' => $memberId, 'uuid' => $uuid, 'pos_index' => $pos, 'type' => $type, 'level' => max(1, min(3, $level)), 'title' => mb_substr($title, 0, 128), 'content' => mb_substr($content, 0, 512), 'is_read' => 0, 'create_at' => $now, ]); } catch (\Throwable $e) { } if ($id > 0) { self::push($memberId, 'alert', (string) json_encode([ 'id' => $id, 'type' => $type, 'level' => max(1, min(3, $level)), 'title' => mb_substr($title, 0, 128), 'content' => mb_substr($content, 0, 512), 'uuid' => $uuid, 'pos' => $pos, 'time' => $now, ])); } return $id; } /** * 设备离线告警(带抑制窗口,Broker onTcpClose 调用) */ public static function onDeviceOffline(string $uuid): void { if ($uuid === '') { return; } $dev = DeviceService::findByUuid($uuid); if (!$dev || (int) $dev['member_id'] <= 0) { return; // 非绑定设备/未绑定会员不告警 } self::ensureTable(); $now = time(); try { // 抑制:10 分钟内已有同设备 offline 告警则跳过 $recent = Db::name('iot_alert') ->where('uuid', $uuid) ->where('type', 'offline') ->where('create_at', '>', $now - self::OFFLINE_SUPPRESS) ->count(); if ($recent > 0) { return; } } catch (\Throwable $e) { return; } self::add((int) $dev['member_id'], 'offline', 2, '设备离线', '设备「' . $dev['title'] . '」已断开连接', $uuid); } /** * 设备主动事件上报 → 告警 + 实时通知(Broker 钩子 /devices/{uuid}/event 调用) * * 支持两类 payload: * 1) 通用事件:{"level":1,"title":"门被打开","content":"前门传感器检测到开合"} * level: 1提示 2警告 3严重(越界自动收敛) * 2) 传感器结构化告警(水温固件 v1.4+)—— 各版式都认: * v1.7.0 {"type":"alert","alerts":[{"alias":"进水","t":95.6,"df":0.3,"rs":"ov", * "pr":[[0,"A3F2",95.6]]}, null]} * = v1.6.0 同构,只是键名压短(probes → pr);下面解析对新旧键都兜底 * v1.6.0 {"type":"alert","alerts":[{"alias":"进水","t":95.6,"df":0.3,"rs":"ov", * "probes":[[0,"A3F2",95.6]]}, null]} * alerts 下标 = 位置序号(未告警的位置写 null 占位) * v1.5.0 {"alerts":[{"index":0,"alias":"进水","temp":95.6,"temp_diff":0.3, * "reason":"探头越限","probes":[{"slot":0,"id":"A3F2","temp":95.6}]}]} * ≤1.4.0 {"alerts":[{"alias":"位置1","t":95.6,"df":0.3,"reason":"overunder", * "bad":[0],"bad_ids":["A3F2"]}]} ← 没带位置号,只能记 POS_NA * 设备端 alertMaskOf() 是「实时兜底」(断网也报),这里把结构化告警逐位置落库, * 并带上 pos_index(位置号)与探头 ROM 身份,否则通知里只剩一段原始 JSON, * 既定位不到位置也认不出是哪颗探头。 */ public static function onDeviceEvent(string $uuid, string $payload): void { if ($uuid === '') { return; } $data = json_decode($payload, true); if (!is_array($data)) { return; } $dev = DeviceService::findByUuid($uuid); if (!$dev || (int) $dev['member_id'] <= 0) { return; // 非绑定设备不告警 } $memberId = (int) $dev['member_id']; /* 结构化传感器告警:一个位置一条,便于 App 精确标注到位置卡片 */ if (isset($data['alerts']) && is_array($data['alerts']) && $data['alerts']) { foreach (array_values($data['alerts']) as $k => $a) { if (!is_array($a)) { continue; // v1.6.0 的 null 占位 = 该位置本次没告警 } /* 位置序号:v1.6.0 认数组下标(报文里已没有 index 键);v1.5.0 认 index; ≤1.4.0 两者都没有(那时 alerts 是稀疏数组、位置号根本没上过报文)→ POS_NA。 v1.6 与 ≤1.4.0 都没有 index,用 rs 键区分:只有 v1.6 才有 rs。 */ if (isset($a['index'])) { $i = (int) $a['index']; } elseif (isset($a['i'])) { $i = (int) $a['i']; } elseif (array_key_exists('rs', $a)) { $i = $k; } else { $i = self::POS_NA; } $alias = (string) ($a['alias'] ?? ('位置' . ($i + 1))); /* v1.6.0 = t/df,v1.5.0 = temp/temp_diff(可为 null),≤1.4.0 = t/df */ $rawT = $a['temp'] ?? $a['t'] ?? null; $rawD = $a['temp_diff'] ?? $a['df'] ?? null; $t = is_numeric($rawT) ? (float) $rawT : null; $df = is_numeric($rawD) ? (float) $rawD : null; /* 告警原因:v1.6.0 发短码 rs(省掉每条约 24B 的中文重复),v1.5.0 发中文, ≤1.4.0 发 overunder/gap 之类的英文码 —— 短码在这里统一翻成人话 */ $why = trim((string) ($a['reason'] ?? '')); if ($why === '' && isset($a['rs'])) { $why = self::REASON_TEXT[(string) $a['rs']] ?? ''; } $parts = []; if ($why !== '') { $parts[] = $why; } if ($t !== null) { $parts[] = sprintf('位置温度 %.2f℃', $t); } if ($df !== null) { $parts[] = sprintf('位置内极差 %.2f℃', $df); } /* 探头身份:明确「哪一颗」,而不是「第几号下标」 */ $named = []; /* 键名 pr(v1.7.0)优先、回落 probes(≤1.6.2)—— 现场老固件不重烧,长期兼容 */ $rawPr = $a['pr'] ?? $a['probes'] ?? null; if (is_array($rawPr)) { /* 身份与读数一起给出,通知里可直接展示: v1.7.0/v1.6.0 pr|probes[[槽位, id, temp]] 三元组;v1.5.0 [{slot,id,temp}] 对象 */ foreach (array_values($rawPr) as $p) { if (!is_array($p)) { continue; } if (array_key_exists(0, $p)) { $pidRaw = $p[1] ?? null; $pid = (is_string($pidRaw) && $pidRaw !== '') ? $pidRaw : ''; $pt = is_numeric($p[2] ?? null) ? sprintf('%.2f℃', (float) $p[2]) : ''; } else { $pid = (isset($p['id']) && is_string($p['id']) && $p['id'] !== '') ? $p['id'] : ''; $pt = (isset($p['temp']) && is_numeric($p['temp'])) ? sprintf('%.2f℃', (float) $p['temp']) : ''; } if ($pid !== '' && $pt !== '') { $named[] = $pid . ' ' . $pt; } elseif ($pid !== '') { $named[] = $pid; } elseif ($pt !== '') { $named[] = $pt; } } } elseif (isset($a['bad_ids']) && is_array($a['bad_ids'])) { /* ≤1.4.0:bad[] 槽位下标 + bad_ids[] 身份,按位对应 */ foreach ($a['bad_ids'] as $b) { if (is_string($b) && $b !== '') { $named[] = $b; } } } if ($named) { $parts[] = '异常探头 ' . implode('、', $named); } elseif (isset($a['bad']) && is_array($a['bad']) && $a['bad']) { $parts[] = '异常探头下标 ' . implode('、', array_map('strval', $a['bad'])); } $content = implode(';', $parts); if ($content === '') { $content = '设备上报温度异常'; } self::add($memberId, 'event', 3, '温度异常:' . $alias, $content, $uuid, $i); } return; } /* 通用事件 */ $level = (int) ($data['level'] ?? 2); $title = (string) ($data['title'] ?? '设备事件'); $content = (string) ($data['content'] ?? ($data['msg'] ?? $payload)); self::add($memberId, 'event', $level, $title, $content, $uuid); } /** * 推送到会员 MQTT 通知主题(retain=1 兜底) * @param string $topic 如 alert / scene / ota */ public static function push(int $memberId, string $topic, string $payload): void { $account = DeviceService::memberAccount($memberId); if ($account === '') { return; } try { Db::name('iot_outbox')->insert([ 'topic' => sprintf('/notify/%s/%s', $account, $topic), 'payload' => $payload, 'qos' => 1, 'retain' => 1, 'status' => 0, 'create_at' => time(), ]); } catch (\Throwable $e) { } } /** * 会员告警分页(App/后台) * @return array{list:array,total:int,unread:int} */ public static function page(int $memberId, int $page = 1, int $size = 20, bool $onlyUnread = false): array { self::ensureTable(); $q = Db::name('iot_alert')->where('member_id', $memberId); if ($onlyUnread) { $q->where('is_read', 0); } $total = (int) $q->count(); $unread = (int) Db::name('iot_alert') ->where('member_id', $memberId) ->where('is_read', 0) ->count(); $list = (clone $q) ->order('id', 'desc') ->page(max(1, $page), min(100, max(1, $size))) ->select() ->toArray(); return ['list' => $list, 'total' => $total, 'unread' => $unread]; } /** 后台:全部会员告警分页 */ public static function pageAll(int $page = 1, int $size = 20, bool $onlyUnread = false): array { self::ensureTable(); $q = Db::name('iot_alert'); if ($onlyUnread) { $q->where('is_read', 0); } $total = (int) $q->count(); $list = (clone $q)->order('id', 'desc') ->page(max(1, $page), min(100, max(1, $size))) ->select()->toArray(); return ['list' => $list, 'total' => $total]; } /** 标记已读(空 ids = 全部已读),返回影响行数 */ public static function markRead(int $memberId, array $ids = []): int { self::ensureTable(); $q = Db::name('iot_alert')->where('member_id', $memberId)->where('is_read', 0); if ($ids) { $q->whereIn('id', array_map('intval', $ids)); } try { return (int) $q->update(['is_read' => 1]); } catch (\Throwable $e) { return 0; } } /** 删除告警(支持批量) */ public static function delete(int $memberId, array $ids = []): void { $q = Db::name('iot_alert')->where('member_id', $memberId); if ($ids) { $q->whereIn('id', array_map('intval', $ids)); } try { $q->delete(); } catch (\Throwable $e) { } } }