Files
YwxAppThink/addon/iot/service/AlertService.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

385 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\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) {
}
}
}