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(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+384
View File
@@ -0,0 +1,384 @@
<?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) {
}
}
}
+709
View File
@@ -0,0 +1,709 @@
<?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\iot\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)。
*
* ⚠️ 私有主题硬归属校验(不受 acl_enabled / acl_default 影响,见 checkPrivateTopic):
* /devices/{uuid}/... 与 /notify/{account}/... 属私有资源,按 **业务真源**
* (iot_device.member_id / iot_share)裁决,而非 ACL 规则快照:
* 设备本体(clientId=username=uuid 且密码已验通过)→ 放行
* 设备主人 → 全权;被共享者 → perm=2 全权 / perm=1 仅订阅只读子主题
* 认证账号 → 需 ACL 规则显式 allow(默认拒绝)
* 匿名 / 未绑定会员 / 他人设备 → 一律拒绝
* 原因:出厂默认 acl_enabled=0、acl_default=allow,若把归属交给 ACL,
* 会出现「没绑定也能订阅别人设备」的越权(2026-09-11 修复)。
*
* 表结构见 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;
/** @var array 归属校验运行时缓存(key => [value, expire_at]),防高频 publish 反复查库 */
protected $aclRuntime = [];
/** @var int 归属校验缓存有效期:撤销共享后最多慢这么久生效 */
protected $ownerTtl = 30;
/**
* @var string 最近一次 checkAcl() 的拒绝原因(放行时为空串)
*
* 可观测性专用:Broker 把它写进日志,直接回答「为什么订阅/发布被拒」——
* 是身份降级成 anonymous-named?不是设备主人也没共享?还是 ACL 兜底拒绝?
* 在此之前日志只有「订阅被 ACL 拒绝」,四种完全不同的故障看起来一模一样。
*/
protected $lastDenyReason = '';
/** 共享权限常量(与 ShareService::PERM_VIEW / PERM_CTRL 对齐,避免循环依赖) */
const PERM_VIEW = 1;
const PERM_CTRL = 2;
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_iot_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_iot_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");
// prefix 需从连接实例取(connections.mysql.prefix),Db::getConfig('prefix') 顶层取不到
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_device` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`sn` varchar(64) NOT NULL DEFAULT '' COMMENT '设备序列号',
`uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '设备连接用户名',
`secret` varchar(191) NOT NULL DEFAULT '' COMMENT '连接密码(bcrypt)',
`pair_code` varchar(16) NOT NULL DEFAULT '' COMMENT '配对码(机身贴纸,绑定校验)',
`activate_token` varchar(64) NOT NULL DEFAULT '' COMMENT '入云激活令牌(一次性)',
`title` varchar(128) NOT NULL DEFAULT '' COMMENT '设备名称',
`model` varchar(64) NOT NULL DEFAULT '' COMMENT '产品型号',
`member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '绑定会员ID',
`room` varchar(64) NOT NULL DEFAULT '' COMMENT '房间/位置',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0禁用',
`bind_at` int DEFAULT NULL,
`create_at` int DEFAULT NULL,
`update_at` int DEFAULT NULL,
PRIMARY KEY (`id`), UNIQUE KEY `uk_sn` (`sn`), UNIQUE KEY `uk_uuid` (`uuid`), KEY `idx_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// 设备日志表(设备维度留痕)
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_device_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '设备uuid',
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT '主题',
`payload` longtext COMMENT '消息内容',
`direction` varchar(8) NOT NULL DEFAULT 'in' COMMENT 'in=设备上报 out=服务端下发',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_uuid` (`uuid`), KEY `idx_create_at` (`create_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// 告警记录表
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(可空)',
`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");
// OTA 固件表
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_ota` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`model` varchar(64) NOT NULL DEFAULT '',
`version` varchar(32) NOT NULL DEFAULT '', `filename` varchar(128) NOT NULL DEFAULT '',
`file_hash` varchar(64) NOT NULL DEFAULT '', `size` int unsigned NOT NULL DEFAULT '0',
`url` varchar(255) NOT NULL DEFAULT '', `remark` varchar(255) DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT '1',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_model` (`model`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// 设备分组表
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_group` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`member_id` int unsigned NOT NULL DEFAULT '0',
`title` varchar(64) NOT NULL DEFAULT '', `type` varchar(16) NOT NULL DEFAULT 'room',
`sort` int NOT NULL DEFAULT '0',
`create_at` int DEFAULT NULL, `update_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_group_device` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`group_id` int unsigned NOT NULL DEFAULT '0',
`device_id` int unsigned NOT NULL DEFAULT '0',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), UNIQUE KEY `uk_group_device` (`group_id`,`device_id`),
KEY `idx_device` (`device_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// 场景表
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属会员(0=后台全局场景)',
`title` varchar(64) NOT NULL DEFAULT '' COMMENT '场景名',
`icon` varchar(32) NOT NULL DEFAULT '' COMMENT '图标标识',
`enabled` 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`), KEY `idx_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// 场景触发器
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene_trigger` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`scene_id` int unsigned NOT NULL DEFAULT '0' COMMENT '场景ID',
`type` varchar(16) NOT NULL DEFAULT 'device_state' COMMENT 'device_state/schedule/manual',
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT 'device_state: 主题过滤器',
`condition_json` text COMMENT '条件JSON',
`cron` varchar(64) NOT NULL DEFAULT '' COMMENT 'schedule: 5段cron',
`enabled` tinyint(1) NOT NULL DEFAULT '1',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_scene` (`scene_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// 场景动作
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene_action` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`scene_id` int unsigned NOT NULL DEFAULT '0' COMMENT '场景ID',
`sort` int NOT NULL DEFAULT '0' COMMENT '执行顺序',
`type` varchar(16) NOT NULL DEFAULT 'send_cmd' COMMENT 'send_cmd/http/notify',
`device_uuid` varchar(64) NOT NULL DEFAULT '' COMMENT 'send_cmd: 目标设备',
`payload_json` text COMMENT 'send_cmd: 命令payload / http: 请求体',
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT 'http: 回调URL / notify: 通知标题',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_scene` (`scene_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// 场景执行日志
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`scene_id` int unsigned NOT NULL DEFAULT '0' COMMENT '场景ID',
`trigger_type` varchar(16) NOT NULL DEFAULT '' COMMENT 'device_state/schedule/manual',
`matched` tinyint(1) NOT NULL DEFAULT '0' COMMENT '1命中 0未命中',
`message` text COMMENT '命中摘要/失败原因',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_scene` (`scene_id`), KEY `idx_create_at` (`create_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// 自愈:老表补 member_id 列(框架会员直通用)
$has = Db::query("SHOW COLUMNS FROM `{$prefix}iot_auth` LIKE 'member_id'");
if (empty($has)) {
Db::execute("ALTER TABLE `{$prefix}iot_auth` ADD `member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属框架会员ID(会员直通回填)'");
}
// 自愈:老库 device 表补 pair_code / activate_token(2.2.6 华为式绑定)
$cols = [];
foreach (Db::query("SHOW COLUMNS FROM `{$prefix}iot_device`") as $r) {
$cols[(string) $r['Field']] = true;
}
if (!isset($cols['pair_code'])) {
Db::execute("ALTER TABLE `{$prefix}iot_device` ADD `pair_code` varchar(16) NOT NULL DEFAULT '' COMMENT '配对码(机身贴纸,绑定校验)' AFTER `secret`");
}
if (!isset($cols['activate_token'])) {
Db::execute("ALTER TABLE `{$prefix}iot_device` ADD `activate_token` varchar(64) NOT NULL DEFAULT '' COMMENT '入云激活令牌(一次性)' AFTER `pair_code`");
}
if (!isset($cols['mac'])) {
Db::execute("ALTER TABLE `{$prefix}iot_device` ADD `mac` varchar(32) NOT NULL DEFAULT '' COMMENT '设备MAC(去冒号小写,即插即用注册身份)' AFTER `sn`");
}
if (!isset($cols['dev_type'])) {
Db::execute("ALTER TABLE `{$prefix}iot_device` ADD `dev_type` varchar(64) NOT NULL DEFAULT '' COMMENT '设备类型(esp32c3_temp等,App据此渲染面板)' AFTER `model`");
}
if (!isset($cols['ver'])) {
Db::execute("ALTER TABLE `{$prefix}iot_device` ADD `ver` varchar(32) NOT NULL DEFAULT '' COMMENT '固件版本(OTA检测/提示)' AFTER `dev_type`");
}
} 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('iot_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) {
// 无此账号:尝试框架会员直通(受后台「允许框架会员登录」开关约束)
// ⚠️ 刻意不受 allow_anonymous 影响:这是「显式凭证校验」,不是匿名语义。
// 曾经要求「禁止匿名」才尝试,导致默认配置(允许匿名)下会员永远无法以会员身份认证,
// 只能降级成 anonymous-named —— 既拿不到 member_id,也绕过了按会员归属的越权校验。
if (!empty($this->config['allow_member_login'])) {
$member = null;
try {
$member = Db::name('member_user')
->where('account', $username)
->where('status', 1)
->field('uid,password')
->find();
} catch (\Throwable $e) {
$member = null;
}
// 会员密码使用 bcrypt 原生盐(详见 ywxapp\model\MemberUser),
// 表无独立 salt 列,直接 verify 即可。member_user 主键为 uid。
if ($member && !empty($member['password'])
&& password_verify((string) $password, $member['password'])) {
$isSuper = !empty($this->config['member_is_superuser']);
return ['ok' => true, 'superuser' => $isSuper, 'reason' => 'member', 'member_id' => $member['uid']];
}
}
// 无此账号:尝试 MQTT 一次性凭证(password=ticket,App 端先 HTTP 换取,短时效,无需会员密码参与)
// 同样不受 allow_anonymous 影响:App 主路径(换票连)不能因为站点开了匿名而失去会员身份。
$ticketMember = TicketService::verify($username, (string) $password);
if ($ticketMember > 0) {
return ['ok' => true, 'superuser' => false, 'reason' => 'ticket', 'member_id' => $ticketMember];
}
// 匿名开时视为普通匿名用户(用户名仅作标识,**不享有会员/设备身份**),否则拒绝
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);
}
/**
* 最近一次 checkAcl() 的拒绝原因(空串 = 最近一次为放行)
*/
public function lastDenyReason(): string
{
return $this->lastDenyReason;
}
/**
* ACL 校验
*
* 拒绝时会把人类可读的原因写入 lastDenyReason(),供 Broker 写日志/上层排查。
*
* @param int $action self::ACT_SUB | self::ACT_PUB
*/
public function checkAcl(?string $username, string $clientId, string $topic, int $action, bool $superuser = false, string $authReason = '', int $memberId = 0): bool
{
$this->lastDenyReason = '';
// $SYS 特殊处理:只读
if (strncmp($topic, '$SYS', 4) === 0) {
if ($action !== self::ACT_SUB) {
$this->lastDenyReason = '$SYS 只读(禁止发布)';
return false;
}
return true;
}
// 设备注册主题:入网必经(发布 system/device/register/{sn}、订阅 .../resp)
// ⚠️ 必须排除通配符:原写法 `[^/]+` 连 `#` / `+` 一起放行,等于把「全站设备注册回执
// (含 uuid + secret)」任人订阅 → 只放行精确的 sn(字母数字/-/_,不含通配符)。
if (preg_match('#^system/device/register/[A-Za-z0-9_.\-]{1,64}(/resp)?$#', $topic)) {
return true;
}
if ($superuser) {
return true;
}
// ── 通配符订阅封堵:防 `#` / `+/...` 绕过私有主题归属校验 ──
// /devices/... 与 /notify/... 的保护建立在「真实主题」上,而顶层通配符在订阅时刻
// 无法表达归属(不以 /devices/ 开头 → checkPrivateTopic 返回 null → 落到 acl_default 兜底)。
// 一旦兜底为「允许」,订阅 `#` 就等于拿到全站设备数据 → 非超管的通配符订阅,
// 只要不以受控命名空间开头(即可能命中私有主题),一律拒绝。
if ($action === self::ACT_SUB
&& strpbrk($topic, '+#') !== false
&& strncmp($topic, '/devices/', 9) !== 0
&& strncmp($topic, '/notify/', 8) !== 0
) {
$this->lastDenyReason = '通配符订阅封堵:过滤器既不以 /devices/ 也不以 /notify/ 开头(防 # 通配越权)';
return false;
}
// ── 私有主题(/devices/{uuid}/... 、/notify/{account}/...)硬归属校验 ──
// 刻意放在 acl_enabled 之前:**设备归属是业务底线,不该由 ACL 开关决定**。
// 否则出厂默认(acl_enabled=0 或 acl_default=allow)下,任何人/任何未绑定会员
// 都能订阅他人设备的 state 并发布 cmd。
$hard = $this->checkPrivateTopic($username, $clientId, $topic, $action, $authReason, $memberId);
if ($hard !== null) {
return $hard;
}
if (!$this->aclEnabled) {
return true;
}
$rule = $this->matchAclRules($username, $clientId, $topic, $action);
if ($rule === null) {
if (!$this->aclDefaultAllow) {
$this->lastDenyReason = 'ACL 无规则命中,且 acl_default=deny(兜底拒绝)';
}
return $this->aclDefaultAllow;
}
if (!$rule) {
$this->lastDenyReason = '命中 ACL 规则(deny),topic=' . $topic;
}
return $rule;
}
/**
* 常规 ACL 规则匹配(sort/id 升序,命中第一条即定论)
* @return bool|null null = 无规则命中,由调用方决定兜底
*/
protected function matchAclRules(?string $username, string $clientId, string $topic, int $action, bool $userOnly = false): ?bool
{
$this->loadAclRules();
$username = (string) $username;
foreach ($this->aclRules as $r) {
$acc = (int) $r['access'];
if ($acc !== 3 && $acc !== $action) {
continue;
}
$type = $r['target_type'];
// $userOnly:跨设备通配符只认「点名该账号」的规则,防止 target_type=all 的旧规则误放开全站设备
if ($userOnly && $type !== 'user') {
continue;
}
if ($type === 'user' && $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 null;
}
/**
* 私有主题归属裁决(业务真源,不看 ACL 快照)
*
* /devices/{uuid}/{tail}
* ① 设备本体 : authReason=ok(iot_auth 账号密码已验)且身份就是该 uuid
* ② 设备主人 : iot_device.member_id === 会员 uid → 全权
* ③ 被共享者 : iot_share.perm=2 全权 / perm=1 仅订阅只读子主题
* ④ 认证账号 : 仅当 ACL 规则显式 allow 才放行(默认拒绝)
* ⑤ 匿名/未知 : 一律拒绝
*
* /notify/{account}/... 仅该账号本人(服务端内部推送不经 ACL)
*
* @param string $authReason authenticate() 的 reason:ok / member / ticket / anonymous-named / anonymous
* @return bool|null null = 非私有主题,交回常规 ACL 流程
*/
protected function checkPrivateTopic(?string $username, string $clientId, string $topic, int $action, string $authReason, int $memberId): ?bool
{
// ── 告警/事件推送主题 ──
if (strncmp($topic, '/notify/', 8) === 0) {
$account = (string) (explode('/', $topic)[2] ?? '');
if ($account !== '' && $account === (string) $username) {
return true;
}
$this->lastDenyReason = '/notify 主题仅账号本人可访问(topic.account=' . $account
. ',当前 user=' . (string) $username . ',身份=' . $authReason . ')';
return false;
}
// ── 设备私有主题 ──
if (strncmp($topic, '/devices/', 9) !== 0) {
return null;
}
$parts = explode('/', $topic);
$uuid = (string) ($parts[2] ?? '');
if ($uuid === '') {
return false;
}
$tail = implode('/', array_slice($parts, 3)); // state / status / cmd / ota/report ...
// ① 设备本体(clientId=username=uuid,密码已在 authenticate() 中验过)
if ($authReason === 'ok' && ($username === $uuid || ($username === '' || $username === null) && $clientId === $uuid)) {
return true;
}
// ②③ 会员(含 ticket 与会员直通):按业务归属判定
$uid = $memberId;
if ($uid <= 0 && $authReason === 'member') {
$uid = $this->memberIdByAccount((string) $username);
}
if ($uid > 0) {
// 通配符订阅(/devices/+/state 这类跨设备过滤)无法用单一归属表达,
// 直接放行等于把全部用户设备暴露给一个人 → 默认拒绝;
// 且只认「点名该账号」的规则(userOnly),防止 target_type=all 的历史规则误放开
if (strpbrk($uuid, '+#') !== false) {
$rule = $this->matchAclRules($username, $clientId, $topic, $action, true);
if ($rule === true) {
return true;
}
$this->lastDenyReason = '跨设备通配符订阅需 ACL 规则点名该账号放行(device=' . $uuid . ',会员 uid=' . $uid . ')';
return false;
}
return $this->memberCanAccessDevice($uuid, $uid, $tail, $action);
}
// ④ 认证账号(管理员建的账号):设备主题默认拒绝,仅「ACL 规则显式放行」可通
if ($authReason === 'ok') {
$rule = $this->matchAclRules($username, $clientId, $topic, $action);
if ($rule === true) {
return true;
}
$this->lastDenyReason = '当前是「认证账号」(iot_auth) 身份,访问设备私有主题必须:'
. '①用自己的 username 当设备 uuid 连(username=' . $uuid . '),或 ②加一条 user=' . (string) $username . ' 的 ACL allow 规则';
return false;
}
// ⑤ 匿名 / 未知身份
$this->lastDenyReason = '当前身份=' . ($authReason !== '' ? $authReason : '(空)') . ',匿名/未认证身份一律不可访问设备私有主题'
. ';若你用会员账号登录:请确认后台已开启「允许框架会员登录」且密码正确'
. '(密码错或开关未开时会静默降级成本身份,连接仍然显示成功)';
return false;
}
/** 会员对该设备子主题的访问裁决(主人恒通过;被共享者按 perm 分级) */
protected function memberCanAccessDevice(string $uuid, int $uid, string $tail, int $action): bool
{
$dev = $this->deviceInfo($uuid);
if ($dev === null) {
$this->lastDenyReason = '设备 ' . $uuid . ' 在 iot_device 中不存在(同时防 uuid 探测)';
return false; // 设备不存在(同时防 uuid 探测)
}
if ((int) $dev['member_id'] === $uid) {
return true; // 主人:全权
}
$perm = $this->sharePerm((int) $dev['id'], $uid);
if ($perm >= self::PERM_CTRL) {
return true;
}
if ($perm >= self::PERM_VIEW) {
// 只读共享:仅允许订阅「非控制类」子主题(cmd/ota 是控制通道,只读用户连订阅都不给)
if ($action === self::ACT_SUB && self::isReadonlyTail($tail)) {
return true;
}
$this->lastDenyReason = '只读共享(perm=1) 仅可订阅 state/status/event/config/ack/ota/report,当前 tail=' . $tail;
return false;
}
$this->lastDenyReason = '当前会员 uid=' . $uid . ' 既不是设备主人(iot_device.member_id='
. (int) $dev['member_id'] . '),也没有 iot_share 共享记录 → 拒绝(请核对「设备绑定」到底绑给了哪个会员)';
return false;
}
/** 只读共享可见的子主题(不含 cmd / ota) */
protected static function isReadonlyTail(string $tail): bool
{
return in_array($tail, ['state', 'status', 'event', 'config', 'ack', 'ota/report'], true);
}
/** 设备 id + 归属会员(带缓存;不存在返回 null) */
protected function deviceInfo(string $uuid): ?array
{
$key = 'dev:' . $uuid;
$hit = $this->runtimeGet($key);
if ($hit !== null) {
return $hit === false ? null : $hit;
}
try {
$row = Db::name('iot_device')->where('uuid', $uuid)->field('id,member_id')->find();
} catch (\Throwable $e) {
$row = null;
}
$val = $row ? ['id' => (int) $row['id'], 'member_id' => (int) $row['member_id']] : false;
$this->runtimePut($key, $val);
return $row ? $val : null;
}
/** 共享权限(带缓存;无共享返回 0) */
protected function sharePerm(int $deviceId, int $uid): int
{
if ($deviceId <= 0 || $uid <= 0) {
return 0;
}
$key = 'share:' . $deviceId . ':' . $uid;
$hit = $this->runtimeGet($key);
if ($hit !== null) {
return (int) $hit;
}
$perm = 0;
try {
$perm = (int) (Db::name('iot_share')
->where('device_id', $deviceId)->where('member_id', $uid)->where('status', 1)
->value('perm') ?? 0);
} catch (\Throwable $e) {
$perm = 0;
}
$this->runtimePut($key, $perm);
return $perm;
}
/** 会员账号 → uid(带缓存;不存在返回 0) */
protected function memberIdByAccount(string $account): int
{
if ($account === '') {
return 0;
}
$key = 'mem:' . $account;
$hit = $this->runtimeGet($key);
if ($hit !== null) {
return (int) $hit;
}
$uid = 0;
try {
$uid = (int) (Db::name('member_user')->where('account', $account)->value('uid') ?? 0);
} catch (\Throwable $e) {
$uid = 0;
}
$this->runtimePut($key, $uid);
return $uid;
}
/** 归属校验缓存读取 */
protected function runtimeGet(string $key)
{
$row = $this->aclRuntime[$key] ?? null;
if ($row === null) {
return null;
}
if ($row[1] < time()) {
unset($this->aclRuntime[$key]);
return null;
}
return $row[0];
}
/** 归属校验缓存写入(Broker 是长驻进程,需限长防内存膨胀) */
protected function runtimePut(string $key, $val): void
{
if (count($this->aclRuntime) > 2000) {
$this->aclRuntime = [];
}
$this->aclRuntime[$key] = [$val, time() + $this->ownerTtl];
}
/**
* 加载 ACL 规则(带 TTL 缓存)
*/
protected function loadAclRules(): void
{
$now = time();
if ($now - $this->aclLoadedAt < $this->aclTtl && $this->aclLoadedAt > 0) {
return;
}
try {
$this->aclRules = Db::name('iot_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);
}
}
+237
View File
@@ -0,0 +1,237 @@
<?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\iot\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 统计 key 前缀(各进程指标 hash 前缀) */
protected $statsPrefix;
/** @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'] ?? 'iot';
$this->stream = $prefix . ':bus';
$this->statsPrefix = $prefix;
$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) {
}
}
/**
* 将本进程运行指标写入共享 Redis(TTL 10s,进程退出自动过期)。
* 各进程周期调用,供 primary 聚合后落库展示全局连接数/消息数。
*/
public function writeStats(array $data): void
{
if (!$this->enabled) {
return;
}
try {
$key = $this->statsPrefix . ':stats:' . $this->nodeId;
$this->redis->hMSet($key, $data);
$this->redis->expire($key, 10);
} catch (\Throwable $e) {
$this->reconnect();
}
}
/**
* 聚合所有存活进程的指标。
* 连接数/订阅数/收发数为求和;retained 为全量快照(各进程相同),取最大值。
*/
public function aggregateStats(): array
{
$agg = [
'clients_online' => 0,
'clients_total' => 0,
'subscriptions' => 0,
'messages_received' => 0,
'messages_sent' => 0,
'retained' => 0,
];
if (!$this->enabled) {
return $agg;
}
try {
$keys = $this->redis->keys($this->statsPrefix . ':stats:*');
$retainedMax = 0;
foreach ($keys as $k) {
$v = $this->redis->hGetAll($k);
if (!is_array($v)) {
continue;
}
$agg['clients_online'] += (int) ($v['clients_online'] ?? 0);
$agg['clients_total'] += (int) ($v['clients_total'] ?? 0);
$agg['subscriptions'] += (int) ($v['subscriptions'] ?? 0);
$agg['messages_received'] += (int) ($v['messages_received'] ?? 0);
$agg['messages_sent'] += (int) ($v['messages_sent'] ?? 0);
$retainedMax = max($retainedMax, (int) ($v['retained'] ?? 0));
}
$agg['retained'] = $retainedMax;
} catch (\Throwable $e) {
$this->reconnect();
}
return $agg;
}
}
File diff suppressed because it is too large. Load diff
+104
View File
@@ -0,0 +1,104 @@
<?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;
/**
* 设备日志服务(设备维度留痕:状态上报/事件/命令下发)
*
* 用途:
* - Broker 发布钩子:设备上报 state/event → 写 in 日志
* - 服务端/场景下发命令 → 写 out 日志
* - App/后台按 uuid 分页查询,用于设备诊断与审计
*
* 约定:
* - 仅在主题属于 /devices/{uuid}/# 时记录,避免海量无关消息入库
* - Broker 长驻进程内调用,全部防御式 try/catch
*/
class DeviceLogService
{
/** 确保 device_log 表存在(自愈) */
public static function ensureTable(): bool
{
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
try {
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_device_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '设备uuid',
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT '主题',
`payload` longtext COMMENT '消息内容',
`direction` varchar(8) NOT NULL DEFAULT 'in' COMMENT 'in=设备上报 out=服务端下发',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_uuid` (`uuid`), KEY `idx_create_at` (`create_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
return true;
} catch (\Throwable $e) {
return false;
}
}
/**
* 写入一条设备日志(自动从主题解析 uuid,非 /devices/... 主题忽略)
*/
public static function log(string $topic, string $payload, string $direction = 'in'): void
{
if (!preg_match('#^/devices/([^/]+)/#', $topic, $m)) {
return; // 非设备主题不记录
}
$uuid = $m[1];
if ($uuid === '' || strlen($uuid) > 64) {
return;
}
self::ensureTable();
try {
Db::name('iot_device_log')->insert([
'uuid' => $uuid,
'topic' => $topic,
'payload' => mb_substr($payload, 0, 65535),
'direction' => $direction === 'out' ? 'out' : 'in',
'create_at' => time(),
]);
} catch (\Throwable $e) {
// 忽略写失败(不影响 Broker 主流程)
}
}
/**
* 分页查询某设备日志(App/后台)
* @return array{list:array,total:int}
*/
public static function page(string $uuid, int $page = 1, int $size = 20, string $direction = ''): array
{
self::ensureTable();
$q = Db::name('iot_device_log')->where('uuid', $uuid);
if ($direction === 'in' || $direction === 'out') {
$q->where('direction', $direction);
}
$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];
}
/** 删除某设备全部日志(解绑/删除设备时可选调用) */
public static function clear(string $uuid): void
{
try {
Db::name('iot_device_log')->where('uuid', $uuid)->delete();
} catch (\Throwable $e) {
}
}
}
File diff suppressed because it is too large. Load diff
+123
View File
@@ -0,0 +1,123 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace addon\iot\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);
}
}
+181
View File
@@ -0,0 +1,181 @@
<?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;
/**
* 设备分组服务(智慧生活:房间/自定义分组)
*
* 权限模型:
* - member_id=0 → 后台全局分组(所有会员可见)
* - member_id>0 → 仅归属会员可见/可管理
*/
class GroupService
{
/** 确保分组表存在(自愈) */
public static function ensureTables(): bool
{
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
try {
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_group` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`member_id` int unsigned NOT NULL DEFAULT '0',
`title` varchar(64) NOT NULL DEFAULT '', `type` varchar(16) NOT NULL DEFAULT 'room',
`sort` int NOT NULL DEFAULT '0',
`create_at` int DEFAULT NULL, `update_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_group_device` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`group_id` int unsigned NOT NULL DEFAULT '0',
`device_id` int unsigned NOT NULL DEFAULT '0',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), UNIQUE KEY `uk_group_device` (`group_id`,`device_id`),
KEY `idx_device` (`device_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
return true;
} catch (\Throwable $e) {
return false;
}
}
/** 校验分组归属(memberId>0 时仅自己;0 可见全局+自己) */
protected static function checkOwner(int $groupId, int $memberId): void
{
$g = Db::name('iot_group')->where('id', $groupId)->find();
if (!$g) {
throw new \RuntimeException('分组不存在');
}
if ($memberId > 0 && (int) $g['member_id'] !== $memberId && (int) $g['member_id'] !== 0) {
throw new \RuntimeException('无权操作该分组');
}
}
/**
* 创建/编辑分组
* @return int 分组ID
*/
public static function save(array $data): int
{
self::ensureTables();
$id = (int) ($data['id'] ?? 0);
$title = mb_substr(trim((string) ($data['title'] ?? '')), 0, 64);
if ($title === '') {
throw new \InvalidArgumentException('分组名不能为空');
}
$now = time();
$row = [
'member_id' => (int) ($data['member_id'] ?? 0),
'title' => $title,
'type' => ($data['type'] ?? 'room') === 'custom' ? 'custom' : 'room',
'sort' => max(0, (int) ($data['sort'] ?? 0)),
'update_at' => $now,
];
if ($id > 0) {
self::checkOwner($id, $row['member_id']);
Db::name('iot_group')->where('id', $id)->update($row);
} else {
$row['create_at'] = $now;
$id = (int) Db::name('iot_group')->insertGetId($row);
}
return $id;
}
/** 删除分组(连带关联) */
public static function remove(int $groupId, int $memberId = 0): void
{
self::checkOwner($groupId, $memberId);
Db::name('iot_group')->where('id', $groupId)->delete();
Db::name('iot_group_device')->where('group_id', $groupId)->delete();
}
/** 分组列表(会员可见:全局 + 自己的),带设备数与在线数 */
public static function groups(int $memberId = 0): array
{
self::ensureTables();
$q = Db::name('iot_group');
if ($memberId > 0) {
$q->where('member_id', 0)->whereOr('member_id', $memberId);
}
$rows = $q->order('sort', 'asc')->order('id', 'asc')->select()->toArray();
foreach ($rows as &$g) {
$deviceIds = Db::name('iot_group_device')->where('group_id', $g['id'])->column('device_id');
$g['device_count'] = count($deviceIds);
$g['online_count'] = 0;
if ($deviceIds) {
$uuids = Db::name('iot_device')->whereIn('id', $deviceIds)->column('uuid');
foreach ($uuids as $u) {
if (DeviceService::isOnline($u)) {
$g['online_count']++;
}
}
}
}
return $rows;
}
/** 设备加入分组(幂等) */
public static function addDevice(int $groupId, int $deviceId, int $memberId = 0): void
{
self::checkOwner($groupId, $memberId);
$exists = Db::name('iot_group_device')
->where('group_id', $groupId)->where('device_id', $deviceId)->count();
if ($exists > 0) {
return;
}
Db::name('iot_group_device')->insert([
'group_id' => $groupId, 'device_id' => $deviceId, 'create_at' => time(),
]);
}
/** 设备移出分组 */
public static function removeDevice(int $groupId, int $deviceId, int $memberId = 0): void
{
self::checkOwner($groupId, $memberId);
Db::name('iot_group_device')
->where('group_id', $groupId)->where('device_id', $deviceId)->delete();
}
/** 组内设备(含在线/影子增强字段) */
public static function devicesByGroup(int $groupId, int $memberId = 0): array
{
self::checkOwner($groupId, $memberId);
$deviceIds = Db::name('iot_group_device')->where('group_id', $groupId)->column('device_id');
if (!$deviceIds) {
return ['list' => [], 'total' => 0];
}
$devices = Db::name('iot_device')->whereIn('id', $deviceIds)->select()->toArray();
foreach ($devices as &$d) {
$d['online'] = DeviceService::isOnline($d['uuid']);
$shadow = DeviceService::shadow($d['uuid']);
$d['shadow'] = $shadow ? $shadow['payload'] : '';
}
return ['list' => $devices, 'total' => count($devices)];
}
/** 设备所在分组(供设备详情展示) */
public static function groupsOfDevice(int $deviceId): array
{
$gids = Db::name('iot_group_device')->where('device_id', $deviceId)->column('group_id');
if (!$gids) {
return [];
}
return Db::name('iot_group')->whereIn('id', $gids)->column('title');
}
/** 解绑/删除设备时清关联 */
public static function clearDevice(int $deviceId): void
{
Db::name('iot_group_device')->where('device_id', $deviceId)->delete();
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace addon\iot\service;
use think\facade\Db;
/**
* OTA 固件升级服务
*
* 流程:
* 后台/App 上传固件 → 计算 SHA256/大小 → 存 iot_ota
* → publishOta 选中设备 → 下发 /devices/{uuid}/ota 指令
* → 设备下载/校验/升级 → 上报 /devices/{uuid}/ota/report 进度
* → onReport 解析进度,写 device_log + 失败告警/通知
*
* 升级指令 payload:
* {"version":"1.2.0","url":"https://...","hash":"sha256","size":12345}
*/
class OtaService
{
/** OTA 下发主题 */
public const TOPIC_OTA = '/devices/%s/ota';
/** OTA 进度上报主题 */
public const TOPIC_REPORT = '/devices/%s/ota/report';
/** 确保 ota 表存在(自愈) */
public static function ensureTable(): bool
{
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
try {
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_ota` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`model` varchar(64) NOT NULL DEFAULT '',
`version` varchar(32) NOT NULL DEFAULT '', `filename` varchar(128) NOT NULL DEFAULT '',
`file_hash` varchar(64) NOT NULL DEFAULT '', `size` int unsigned NOT NULL DEFAULT '0',
`url` varchar(255) NOT NULL DEFAULT '', `remark` varchar(255) DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT '1',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_model` (`model`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
return true;
} catch (\Throwable $e) {
return false;
}
}
/**
* 登记固件(后台上传后调用)
* @return int OTA记录ID
*/
public static function add(array $data): int
{
self::ensureTable();
$version = trim((string) ($data['version'] ?? ''));
if ($version === '') {
throw new \InvalidArgumentException('版本号不能为空');
}
$file = trim((string) ($data['filename'] ?? ''));
$url = trim((string) ($data['url'] ?? ''));
$hash = trim((string) ($data['file_hash'] ?? ''));
$size = max(0, (int) ($data['size'] ?? 0));
if ($file === '' || $url === '') {
throw new \InvalidArgumentException('固件文件与下载地址不能为空');
}
// 校验哈希格式(SHA256 为 64 位 hex;允许手动录入,上传时自动计算)
if ($hash !== '' && !preg_match('/^[a-f0-9]{64}$/i', $hash)) {
throw new \InvalidArgumentException('文件哈希须为 SHA256(64位hex)');
}
return (int) Db::name('iot_ota')->insertGetId([
'model' => mb_substr(trim((string) ($data['model'] ?? '')), 0, 64),
'version' => mb_substr($version, 0, 32),
'filename' => mb_substr($file, 0, 128),
'file_hash' => strtolower($hash),
'size' => $size,
'url' => mb_substr($url, 0, 255),
'remark' => mb_substr((string) ($data['remark'] ?? ''), 0, 255),
'status' => isset($data['status']) && !$data['status'] ? 0 : 1,
'create_at' => time(),
]);
}
/** 固件列表(按型号过滤) */
public static function list(string $model = ''): array
{
self::ensureTable();
$q = Db::name('iot_ota');
if ($model !== '') {
$q->where('model', $model);
}
return $q->order('id', 'desc')->select()->toArray();
}
/** 删除固件 */
public static function remove(int $otaId): void
{
Db::name('iot_ota')->where('id', $otaId)->delete();
}
/**
* 下发升级指令到指定设备(逐个 outbox 入队,离线自动补发)
* @return int 成功入队数
*/
public static function publish(int $otaId, array $deviceUuids): int
{
$ota = Db::name('iot_ota')->where('id', $otaId)->find();
if (!$ota || !$ota['status']) {
throw new \RuntimeException('固件不存在或已停用');
}
$payload = (string) json_encode([
'version' => $ota['version'],
'url' => $ota['url'],
'hash' => $ota['file_hash'],
'size' => (int) $ota['size'],
], JSON_UNESCAPED_SLASHES);
$ok = 0;
foreach (array_unique($deviceUuids) as $uuid) {
if ($uuid === '') {
continue;
}
if (Store::enqueueManual(sprintf(self::TOPIC_OTA, $uuid), $payload, 1, 0, 'ota')) {
$ok++;
}
}
return $ok;
}
/**
* 设备 OTA 进度上报处理(Broker 钩子 /ota/report 调用)
* payload: {"state":"downloading|installing|done|fail","progress":42,"version":"1.2.0"}
*/
public static function onReport(string $uuid, string $payload): void
{
DeviceLogService::log(sprintf(self::TOPIC_REPORT, $uuid), $payload, 'in');
$data = json_decode($payload, true);
if (!is_array($data)) {
return;
}
$state = (string) ($data['state'] ?? '');
$dev = DeviceService::findByUuid($uuid);
if (!$dev || (int) $dev['member_id'] <= 0) {
return;
}
$memberId = (int) $dev['member_id'];
$version = (string) ($data['version'] ?? '');
switch ($state) {
case 'done':
AlertService::add($memberId, 'ota', 1, '固件升级完成',
'设备「' . $dev['title'] . '」已升级到 ' . $version, $uuid);
break;
case 'fail':
AlertService::add($memberId, 'ota', 3, '固件升级失败',
'设备「' . $dev['title'] . '」升级失败:' . mb_substr((string) ($data['error'] ?? '未知原因'), 0, 200), $uuid);
break;
default:
// downloading/installing 进度 → 实时通知(不写告警表,避免刷屏)
AlertService::push($memberId, 'ota', (string) json_encode([
'type' => 'progress',
'uuid' => $uuid,
'state' => $state,
'progress' => (int) ($data['progress'] ?? -1),
'version' => $version,
'time' => time(),
]));
break;
}
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace addon\iot\service;
use think\facade\Db;
/**
* 规则引擎:匹配源主题过滤器后,将消息转发到外部 Broker(桥接到 EMQX)
*
* 规则表 wxapp_iot_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_iot_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('iot_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);
}
}
+622
View File
@@ -0,0 +1,622 @@
<?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;
/**
* 场景/自动化引擎(智慧生活核心)
*
* 触发三类:
* - device_state:设备状态/事件上报 → topic 匹配 + 条件求值(支持 ref 读其他设备影子)
* - schedule :定时(5 段 cron,Broker 进程内每分钟扫描)
* - manual :App/后台手动一键执行
*
* 执行原则:
* - 评估在 Broker 事件钩子内同步、纯内存判断,动作统一入 outbox 异步投递,不阻塞主循环
* - 场景配置内存缓存 TTL 30s(同 Auth::loadAclRules 模式,Broker 单进程安全)
* - 所有 DB 访问防御式 try/catch
*/
class SceneService
{
/** 场景缓存 TTL(秒) */
public const CACHE_TTL = 30;
protected static array $sceneCache = [];
protected static int $loadedAt = 0;
/* ============================================================
* 建表自愈
* ========================================================== */
public static function ensureTables(): bool
{
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
try {
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`member_id` int unsigned NOT NULL DEFAULT '0',
`title` varchar(64) NOT NULL DEFAULT '', `icon` varchar(32) NOT NULL DEFAULT '',
`enabled` tinyint(1) NOT NULL DEFAULT '1', `remark` varchar(255) DEFAULT NULL,
`create_at` int DEFAULT NULL, `update_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene_trigger` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`scene_id` int unsigned NOT NULL DEFAULT '0',
`type` varchar(16) NOT NULL DEFAULT 'device_state',
`topic` varchar(255) NOT NULL DEFAULT '', `condition_json` text,
`cron` varchar(64) NOT NULL DEFAULT '', `enabled` tinyint(1) NOT NULL DEFAULT '1',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_scene` (`scene_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene_action` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`scene_id` int unsigned NOT NULL DEFAULT '0', `sort` int NOT NULL DEFAULT '0',
`type` varchar(16) NOT NULL DEFAULT 'send_cmd',
`device_uuid` varchar(64) NOT NULL DEFAULT '', `payload_json` text,
`topic` varchar(255) NOT NULL DEFAULT '', `create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_scene` (`scene_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`scene_id` int unsigned NOT NULL DEFAULT '0',
`trigger_type` varchar(16) NOT NULL DEFAULT '',
`matched` tinyint(1) NOT NULL DEFAULT '0', `message` text,
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_scene` (`scene_id`), KEY `idx_create_at` (`create_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
return true;
} catch (\Throwable $e) {
return false;
}
}
/* ============================================================
* 事件钩子入口(Broker::handlePublish / handlePubRel 调用)
* ========================================================== */
/**
* 消息发布 → 评估所有启用场景的 device_state 触发器
*/
public static function onMessagePublished(string $topic, string $payload): void
{
foreach (self::loadScenes() as $scene) {
foreach ($scene['triggers'] as $trg) {
if ($trg['type'] !== 'device_state' || !$trg['enabled']) {
continue;
}
$matched = false;
$reason = '';
try {
if (!self::topicMatch($trg['topic'], $topic)) {
continue; // 主题不匹配不记日志
}
$matched = self::matchConditions($trg['condition_json'], $payload, $topic);
$reason = $matched ? '条件命中' : '条件未满足';
} catch (\Throwable $e) {
$reason = '评估异常: ' . $e->getMessage();
}
if ($matched) {
self::runScene($scene['id'], 'device_state', 'topic=' . $topic);
} else {
self::writeLog($scene['id'], 'device_state', false, $reason);
}
}
}
}
/* ============================================================
* 定时触发(Broker onWorkerStart Timer 每分钟调用)
* ========================================================== */
public static function onTick(): void
{
$now = time();
foreach (self::loadScenes() as $scene) {
foreach ($scene['triggers'] as $trg) {
if ($trg['type'] !== 'schedule' || !$trg['enabled'] || $trg['cron'] === '') {
continue;
}
if (self::cronMatch($trg['cron'], $now)) {
self::runScene($scene['id'], 'schedule', 'cron=' . $trg['cron']);
}
}
}
}
/* ============================================================
* 场景执行
* ========================================================== */
/**
* 执行场景(App/后台手动 + 引擎内部)
*/
public static function runScene(int $sceneId, string $triggerType = 'manual', string $message = ''): bool
{
$scene = self::findScene($sceneId);
if (!$scene || !$scene['enabled']) {
return false;
}
$done = 0;
foreach ($scene['actions'] as $act) {
try {
if (self::runAction($act)) {
$done++;
}
} catch (\Throwable $e) {
// 单动作失败不中断后续
}
}
self::writeLog($sceneId, $triggerType, $done > 0,
$message !== '' ? $message : ($done > 0 ? "执行 {$done} 个动作" : '无动作'));
return $done > 0;
}
protected static function runAction(array $act): bool
{
switch ($act['type']) {
case 'send_cmd':
if ($act['device_uuid'] === '' || $act['payload_json'] === '') {
return false;
}
return Store::enqueueManual(
sprintf(DeviceService::TOPIC_CMD, $act['device_uuid']),
$act['payload_json'], 1, 0, 'scene'
);
case 'http':
if ($act['topic'] === '') {
return false;
}
return self::httpPost($act['topic'], $act['payload_json']);
case 'notify':
$dev = $act['device_uuid'] !== '' ? DeviceService::findByUuid($act['device_uuid']) : null;
$memberId = $dev ? (int) $dev['member_id'] : (int) ($act['scene_member_id'] ?? 0);
if ($memberId <= 0) {
return false;
}
$title = $act['topic'] !== '' ? $act['topic'] : '场景通知';
AlertService::add($memberId, 'scene', 2, $title,
mb_substr($act['payload_json'] !== '' ? $act['payload_json'] : '场景联动已执行', 0, 512));
return true;
default:
return false;
}
}
/** HTTP POST 回调(短超时,失败不影响主流程) */
protected static function httpPost(string $url, string $body): bool
{
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_TIMEOUT => 3,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
curl_exec($ch);
$err = curl_errno($ch);
curl_close($ch);
return $err === 0;
}
return false;
}
/* ============================================================
* 条件求值
* ========================================================== */
/** MQTT 主题通配符匹配(+/#) */
public static function topicMatch(string $filter, string $topic): bool
{
if ($filter === '') {
$filter = '/devices/+/state';
}
// MQTT 通配符转正则:+ 单层,# 多层
$regex = preg_quote($filter, '#');
$regex = str_replace(['\\+', '\\#'], ['[^/]+', '.*'], $regex);
return (bool) preg_match('#^' . $regex . '$#', $topic);
}
/**
* 条件 JSON 求值(全部满足才 true;空条件视为 true)
* 支持 ref:引用其他设备影子字段(/devices/{uuid}/state 的 retain payload)
*/
protected static function matchConditions(string $conditionJson, string $payload, string $topic): bool
{
$conds = json_decode($conditionJson, true);
if (!is_array($conds) || !$conds) {
return true;
}
$data = json_decode($payload, true);
if (!is_array($data)) {
$data = ['value' => $payload]; // 非 JSON payload 视为裸 value
}
foreach ($conds as $cond) {
if (!is_array($cond)) {
continue;
}
$field = (string) ($cond['field'] ?? '');
$op = (string) ($cond['op'] ?? 'eq');
$value = $cond['value'] ?? null;
$ref = (string) ($cond['ref'] ?? '');
$src = $data;
if ($ref !== '') {
$shadow = DeviceService::shadow($ref);
$parsed = json_decode((string) ($shadow['payload'] ?? ''), true);
if (!is_array($parsed)) {
return false; // 引用影子不可用 → 不触发
}
$src = $parsed;
}
$actual = $field === '' ? ($src['value'] ?? null) : self::dig($src, $field);
if (!self::compare($op, $actual, $value)) {
return false;
}
}
return true;
}
/**
* 点分路径取值:dig(['pos'=>[['t'=>26]]], 'pos.0.t') = 26
*
* 报文改过三轮键名,场景条件是用户手写在 condition_json 里的,不能在设备升级后悄悄失效:
* ≤1.4.0 data.N.t / df / v / iv / vs.M
* v1.5.0 positions.N.temp / temp_diff / probes.M.{id,temp}、interval
* v1.6.0 positions.N.t / df / probes.M.[id, temp](下标即位置序号、探头变二元组)
* v1.7.0 pos.N.t / df / pr.M.<分量下标>(**只是键名压短**,结构与语义同 v1.6.0)
* 这里按「payload 里有没有 pos / positions 键」判版式,是新格式就把路径自动改写
* 成当前线上词汇(v1.7.0)。⚠️ 库里既存场景的条件路径可能写成任一历史词汇
* (data. / positions. / interval. / probes.),全靠 $map 一趟收敛到 pos. / iv. / pr. ——
* 漏一条就会出现「路径取不到值 → 条件恒不成立」这种**静默**故障,
* 所以每次改报文键名都必须同步这里(这是全项目唯一靠字符串路径取报文的地方)。
* 两个迁不了的(路径取不到值 → 条件恒不成立,得人工改):
* · `v` —— v1.5.0 起字段已删(等价于「t !== null」)→ 改用 pos.N.t 的 exists/gt
* · `i` —— v1.6.0 起位置序号就是数组下标,报文里没有对应字段了
*/
protected static function dig(array $arr, string $path)
{
/* 「新式」= 带 pos(v1.7.0)或 positions(≥1.5.0)且不是旧式 data。
旧式(≤1.4.0)没有 null 语义、词汇也是另一套,绝不能改写路径 */
if ((array_key_exists('pos', $arr) || array_key_exists('positions', $arr))
&& !array_key_exists('data', $arr)) {
$path = self::upgradeStatePath($path);
}
$cur = $arr;
foreach (explode('.', $path) as $seg) {
if (!is_array($cur) || !array_key_exists($seg, $cur)) {
return null;
}
$cur = $cur[$seg];
}
return $cur;
}
/**
* 旧 state 路径 → v1.6.0 词汇(只改名字与探头取法,语义不变)
*
* 目标形态:positions[下标] = {pin, t, df, probes[[id, temp]]}
* 关键点:probes 从「对象数组」变成「二元组」,所以 probes.N 后面那一段
* 要从**字段名**(temp/id)改成**分量下标**(1/0)。
* 两趟走,顺序不能反:先把分量名换成下标,再改名 —— 否则 `probes.0.temp`
* 会先被改名成 `probes.0.t`,第二趟就认不出它是"温度"还是"位置温度"了。
*/
protected static function upgradeStatePath(string $path): string
{
$segs = explode('.', $path);
/* 第一趟:探头部份(vs.N / probes.N / ids.N / pr.N)→ pr.N.<分量下标> */
for ($k = 0; $k + 1 < count($segs); $k++) {
$head = $segs[$k];
if (!in_array($head, ['vs', 'probes', 'ids', 'pr'], true) || !ctype_digit((string) $segs[$k + 1])) {
continue;
}
$tail = $segs[$k + 2] ?? null;
if ($head === 'ids') {
/* 旧写法 ids.N 取的就是身份本身 */
$segs[$k] = 'pr';
if ($tail === null || !ctype_digit((string) $tail)) {
array_splice($segs, $k + 2, 0, ['0']);
}
continue;
}
$segs[$k] = 'pr';
if ($tail === 'temp' || $tail === 'id' || $tail === 't') {
$segs[$k + 2] = ($tail === 'id') ? '0' : '1'; // 二元组 [身份, 温度]
} elseif ($tail === null || !ctype_digit((string) $tail)) {
/* `vs.N` 这种只顾到探头号的旧写法:当时取到的是温度 → 补上分量下标 1 */
array_splice($segs, $k + 2, 0, ['1']);
}
}
/* 第二趟:段名映射 —— 把所有历史词汇收敛到 v1.7.0 线上词汇
(`t` / `df` 各版同名,天然不用改)。
⚠️ `positions`→`pos`、`interval`→`iv` 这两条是给**库里已存**的场景路径用的:
它们是按 v1.5.0/v1.6.0 词汇写进 iot_scene 的,不改写就取不到值。 */
$map = [
'data' => 'pos',
'positions' => 'pos',
'probes' => 'pr',
'vs' => 'pr',
'ids' => 'pr',
'temp' => 't',
'temp_diff' => 'df',
'interval' => 'iv',
];
foreach ($segs as $k => $seg) {
if (isset($map[$seg])) {
$segs[$k] = $map[$seg];
}
}
return implode('.', $segs);
}
protected static function compare(string $op, $actual, $expected): bool
{
if (is_numeric($actual) && is_numeric($expected)) {
$actual = (float) $actual;
$expected = (float) $expected;
}
switch ($op) {
case 'eq': return $actual == $expected;
case 'neq': return $actual != $expected;
case 'gt': return $actual > $expected;
case 'gte': return $actual >= $expected;
case 'lt': return $actual < $expected;
case 'lte': return $actual <= $expected;
case 'exists': return $actual !== null && $actual !== '';
case 'contains': return is_string($actual) && strpos($actual, (string) $expected) !== false;
default: return false;
}
}
/* ============================================================
* cron 匹配(最小 5 段实现:分 时 日 月 周,支持 * 与数字)
* ========================================================== */
public static function cronMatch(string $cron, ?int $ts = null): bool
{
$ts = $ts ?? time();
$parts = preg_split('/\s+/', trim($cron));
if (count($parts) !== 5) {
return false;
}
$minute = (int) date('i', $ts);
$hour = (int) date('G', $ts);
$day = (int) date('j', $ts);
$month = (int) date('n', $ts);
$week = (int) date('w', $ts);
$fields = [$minute, $hour, $day, $month, $week];
foreach ($parts as $i => $part) {
if ($part === '*') {
continue;
}
$allowed = [];
foreach (explode(',', $part) as $seg) {
$allowed[] = (int) $seg;
}
if (!in_array($fields[$i], $allowed, true)) {
return false;
}
}
return true;
}
/* ============================================================
* 数据访问(内存缓存)
* ========================================================== */
/** 加载启用场景(含触发器/动作),TTL 缓存 */
protected static function loadScenes(): array
{
$now = time();
if ($now - self::$loadedAt < self::CACHE_TTL && self::$sceneCache) {
return self::$sceneCache;
}
try {
$scenes = Db::name('iot_scene')
->where('enabled', 1)
->order('id', 'asc')
->select()->toArray();
$sceneIds = array_column($scenes, 'id');
$triggers = $actions = [];
if ($sceneIds) {
$triggers = Db::name('iot_scene_trigger')
->whereIn('scene_id', $sceneIds)
->where('enabled', 1)
->order('id', 'asc')
->select()->toArray();
$actions = Db::name('iot_scene_action')
->whereIn('scene_id', $sceneIds)
->order('sort', 'asc')
->select()->toArray();
}
$byId = [];
foreach ($triggers as $t) {
$byId[(int) $t['scene_id']]['triggers'][] = $t;
}
foreach ($actions as $a) {
$byId[(int) $a['scene_id']]['actions'][] = $a;
}
foreach ($scenes as &$s) {
$sid = (int) $s['id'];
$s['triggers'] = $byId[$sid]['triggers'] ?? [];
$s['actions'] = $byId[$sid]['actions'] ?? [];
foreach ($s['actions'] as &$a) {
$a['scene_member_id'] = (int) $s['member_id'];
}
}
self::$sceneCache = $scenes;
self::$loadedAt = $now;
} catch (\Throwable $e) {
self::$sceneCache = [];
self::$loadedAt = $now;
}
return self::$sceneCache;
}
/** 单场景完整查询(不过滤 enabled) */
public static function findScene(int $sceneId): ?array
{
try {
$scene = Db::name('iot_scene')->where('id', $sceneId)->find();
if (!$scene) {
return null;
}
$scene['triggers'] = Db::name('iot_scene_trigger')
->where('scene_id', $sceneId)->order('id', 'asc')->select()->toArray();
$scene['actions'] = Db::name('iot_scene_action')
->where('scene_id', $sceneId)->order('sort', 'asc')->select()->toArray();
foreach ($scene['actions'] as &$a) {
$a['scene_member_id'] = (int) $scene['member_id'];
}
return $scene;
} catch (\Throwable $e) {
return null;
}
}
/** 写执行日志(防爆:单场景保留最近 500 条) */
protected static function writeLog(int $sceneId, string $type, bool $matched, string $message): void
{
try {
Db::name('iot_scene_log')->insert([
'scene_id' => $sceneId,
'trigger_type' => $type,
'matched' => $matched ? 1 : 0,
'message' => mb_substr($message, 0, 500),
'create_at' => time(),
]);
// 防爆清理
$cnt = (int) Db::name('iot_scene_log')->where('scene_id', $sceneId)->count();
if ($cnt > 500) {
$ids = Db::name('iot_scene_log')->where('scene_id', $sceneId)
->order('id', 'asc')->limit($cnt - 500)->column('id');
if ($ids) {
Db::name('iot_scene_log')->whereIn('id', $ids)->delete();
}
}
} catch (\Throwable $e) {
}
}
/* ============================================================
* CRUD(App / 后台)
* ========================================================== */
public static function save(array $data): int
{
self::ensureTables();
$id = (int) ($data['id'] ?? 0);
$now = time();
$sceneData = [
'member_id' => (int) ($data['member_id'] ?? 0),
'title' => mb_substr(trim((string) ($data['title'] ?? '')), 0, 64),
'icon' => mb_substr(trim((string) ($data['icon'] ?? '')), 0, 32),
'enabled' => !empty($data['enabled']) ? 1 : 0,
'remark' => mb_substr(trim((string) ($data['remark'] ?? '')), 0, 255),
];
if ($sceneData['title'] === '') {
throw new \InvalidArgumentException('场景名不能为空');
}
if ($id > 0) {
$sceneData['update_at'] = $now;
Db::name('iot_scene')->where('id', $id)->update($sceneData);
} else {
$sceneData['create_at'] = $now;
$sceneData['update_at'] = $now;
$id = (int) Db::name('iot_scene')->insertGetId($sceneData);
}
// 替换触发器与动作
Db::name('iot_scene_trigger')->where('scene_id', $id)->delete();
Db::name('iot_scene_action')->where('scene_id', $id)->delete();
$triggers = $data['triggers'] ?? [];
foreach ($triggers as $t) {
$type = (string) ($t['type'] ?? 'device_state');
Db::name('iot_scene_trigger')->insert([
'scene_id' => $id,
'type' => in_array($type, ['device_state', 'schedule', 'manual'], true) ? $type : 'device_state',
'topic' => mb_substr((string) ($t['topic'] ?? ''), 0, 255),
'condition_json' => is_array($t['condition_json'] ?? null)
? (string) json_encode($t['condition_json'], JSON_UNESCAPED_UNICODE)
: (string) ($t['condition_json'] ?? ''),
'cron' => mb_substr((string) ($t['cron'] ?? ''), 0, 64),
'enabled' => !empty($t['enabled']) ? 1 : 0,
'create_at' => $now,
]);
}
$actions = $data['actions'] ?? [];
$sort = 0;
foreach ($actions as $a) {
$type = (string) ($a['type'] ?? 'send_cmd');
Db::name('iot_scene_action')->insert([
'scene_id' => $id,
'sort' => $sort++,
'type' => in_array($type, ['send_cmd', 'http', 'notify'], true) ? $type : 'send_cmd',
'device_uuid' => mb_substr((string) ($a['device_uuid'] ?? ''), 0, 64),
'payload_json' => is_array($a['payload_json'] ?? null)
? (string) json_encode($a['payload_json'], JSON_UNESCAPED_UNICODE)
: (string) ($a['payload_json'] ?? ''),
'topic' => mb_substr((string) ($a['topic'] ?? ''), 0, 255),
'create_at' => $now,
]);
}
return $id;
}
public static function remove(int $sceneId, int $memberId = 0): void
{
$q = Db::name('iot_scene')->where('id', $sceneId);
if ($memberId > 0) {
$q->where('member_id', $memberId);
}
$q->delete();
Db::name('iot_scene_trigger')->where('scene_id', $sceneId)->delete();
Db::name('iot_scene_action')->where('scene_id', $sceneId)->delete();
}
public static function toggle(int $sceneId, int $enabled, int $memberId = 0): void
{
$q = Db::name('iot_scene')->where('id', $sceneId);
if ($memberId > 0) {
$q->where('member_id', $memberId);
}
$q->update(['enabled' => $enabled ? 1 : 0, 'update_at' => time()]);
}
public static function list(int $memberId = 0): array
{
$q = Db::name('iot_scene');
if ($memberId > 0) {
$q->where('member_id', $memberId);
}
$rows = $q->order('id', 'desc')->select()->toArray();
foreach ($rows as &$r) {
$r['trigger_count'] = (int) Db::name('iot_scene_trigger')->where('scene_id', $r['id'])->count();
$r['action_count'] = (int) Db::name('iot_scene_action')->where('scene_id', $r['id'])->count();
}
return $rows;
}
public static function logs(int $sceneId, int $page = 1, int $size = 20): array
{
$q = Db::name('iot_scene_log')->where('scene_id', $sceneId);
$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];
}
}
+619
View File
@@ -0,0 +1,619 @@
<?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;
/**
* 传感器位置配置服务 —— 配置的唯一真源(Single Source of Truth)
*
* 为什么配置不放报文里:
* 1) 位置列表必须在设备离线时也能渲染(App 打开详情页不能空白),只能来自服务端;
* 2) alias/upper/lower/gap 是「月级」静态数据,塞进秒级 state 报文里是纯浪费(约占报文体积一半)。
*
* 三方一致(服务端 → 设备 → App):
* 后台/App 保存 → iot_sensor_cfg(真源)+ iot_device.cfg_ver++
* → publish retained /devices/{uuid}/config(带 cfg_ver)
* → 设备写 NVS + 回 /devices/{uuid}/ack 的 config_ack(带 cfg_ver)
* → 后台/App 比对 cfg_ver 与 sync_ver 显示「已生效 / 待设备上线生效」
* App 另经 HTTP 读同一份配置渲染骨架,state 只负责填值。
*
* 字段约定:对外统一 index/alias/probes/upper/lower/gap/enabled(index = 位置序号,App 按 index 对齐而非数组顺序)
*/
class SensorConfigService
{
/** 配置下发主题(retained,设备订阅;设备 ACL `/devices/%u/#` 已覆盖) */
public const TOPIC_CONFIG = '/devices/%s/config';
/** 设备应答主题(非 retained) */
public const TOPIC_ACK = '/devices/%s/ack';
/** 位置数上限(与固件 OneWire 总线数 A/B/C 一致) */
public const MAX_POS = 3;
/** 单位置探头数上限 */
public const MAX_PROBE = 3;
/** 默认超温上限(℃) */
public const DEF_UPPER = 85.0;
/** 默认低温下限(℃) */
public const DEF_LOWER = 0.0;
/** 默认位置内温差告警阈值(0=关闭) */
public const DEF_GAP = 0.0;
public static function configTopic(string $uuid): string
{
return sprintf(self::TOPIC_CONFIG, $uuid);
}
public static function ackTopic(string $uuid): string
{
return sprintf(self::TOPIC_ACK, $uuid);
}
/* ============================================================
* 表 / 列自愈
* ========================================================== */
/** 确保 iot_sensor_cfg 表存在(老库/未执行 install.sql 亦可用) */
public static function ensureTable(): bool
{
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
try {
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_sensor_cfg` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`device_uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '设备uuid',
`pos_index` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '位置序号(OneWire总线A/B/C,0起)',
`alias` varchar(32) NOT NULL DEFAULT '' COMMENT '位置名称(如 进水/回水)',
`probes` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '该位置探头数(1~3)',
`upper` decimal(6,2) NOT NULL DEFAULT '85.00' COMMENT '超温上限(℃)',
`lower` decimal(6,2) NOT NULL DEFAULT '0.00' COMMENT '低温下限(℃)',
`gap` decimal(6,2) NOT NULL DEFAULT '0.00' COMMENT '位置内温差告警阈值(0=关闭)',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0停用',
`create_at` int DEFAULT NULL,
`update_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_dev_pos` (`device_uuid`,`pos_index`),
KEY `idx_device` (`device_uuid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='传感器位置配置表(配置真源)'");
DeviceService::ensureColumns();
return true;
} catch (\Throwable $e) {
return false;
}
}
/* ============================================================
* 读取
* ========================================================== */
/**
* 读取某设备配置(含同步状态,供 App / 后台 / 下发共用)
*
* @param bool $onlyEnabled 仅返回启用的位置(下发设备时用)
* @return array{cfg_ver:int,sync_ver:int,sync_at:int,applied:bool,sensors:array}
*/
public static function get(string $uuid, bool $onlyEnabled = false): array
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['cfg_ver' => 0, 'sync_ver' => 0, 'sync_at' => 0, 'applied' => false, 'sensors' => []];
}
self::ensureTable();
$dev = Db::name('iot_device')->where('uuid', $uuid)->find() ?: [];
$ver = (int) ($dev['cfg_ver'] ?? 0);
$sync = (int) ($dev['sync_ver'] ?? 0);
$q = Db::name('iot_sensor_cfg')->where('device_uuid', $uuid);
if ($onlyEnabled) {
$q->where('enabled', 1);
}
$rows = $q->order('pos_index', 'asc')->select()->toArray();
$sensors = [];
foreach ($rows as $r) {
$sensors[] = self::format($r);
}
return [
'cfg_ver' => $ver,
'sync_ver' => $sync,
'sync_at' => (int) ($dev['sync_at'] ?? 0),
'applied' => $ver > 0 && $sync >= $ver,
'sensors' => $sensors,
];
}
/**
* 批量取多个设备的配置(按 uuid 分组)—— 供**设备列表**接口内联配置用
*
* 为什么要批量:列表接口是 N 台设备一把返回,若在 foreach 里逐台调 get() 就是 N+1
* (get() 自己还要各查一次 iot_device)⇒ 设备一多就把接口拖垮。
* 这里固定 2 条 SQL:一条查版本、一条查位置配置,与设备台数无关。
*
* @param string[] $uuids
* @return array<string, array> [uuid => ['cfg_ver'=>int,'sync_ver'=>int,'sync_at'=>int,'applied'=>bool,'sensors'=>array]]
*/
public static function getAll(array $uuids, bool $onlyEnabled = false): array
{
$clean = [];
foreach ($uuids as $u) {
$u = trim((string) $u);
if ($u !== '' && !isset($clean[$u])) {
$clean[$u] = true;
}
}
$out = [];
if (!$clean) {
return $out;
}
$uuids = array_keys($clean);
self::ensureTable();
// 缺省值:任何一台设备都要有这几个键,App 端才能无脑取值
foreach ($uuids as $u) {
$out[$u] = ['cfg_ver' => 0, 'sync_ver' => 0, 'sync_at' => 0, 'applied' => false, 'sensors' => []];
}
// 1) 版本信息(iot_device.cfg_ver / sync_ver)—— 一次查完
try {
$devs = Db::name('iot_device')
->whereIn('uuid', $uuids)
->field('uuid,cfg_ver,sync_ver,sync_at')
->select()
->toArray();
foreach ($devs as $d) {
$u = (string) $d['uuid'];
if (!isset($out[$u])) {
continue;
}
$ver = (int) ($d['cfg_ver'] ?? 0);
$sync = (int) ($d['sync_ver'] ?? 0);
$out[$u]['cfg_ver'] = $ver;
$out[$u]['sync_ver'] = $sync;
$out[$u]['sync_at'] = (int) ($d['sync_at'] ?? 0);
$out[$u]['applied'] = $ver > 0 && $sync >= $ver;
}
} catch (\Throwable $e) {
// 老库缺列:保持缺省值,绝不因为配置信息拖垮设备列表
}
// 2) 位置配置(iot_sensor_cfg)—— 一次查完,再按 uuid 分组
try {
$q = Db::name('iot_sensor_cfg')->whereIn('device_uuid', $uuids);
if ($onlyEnabled) {
$q->where('enabled', 1);
}
$rows = $q->order('pos_index', 'asc')->select()->toArray();
foreach ($rows as $r) {
$u = (string) $r['device_uuid'];
if (!isset($out[$u])) {
continue;
}
// format() 是服务端统一出口:index/alias/probes/upper/lower/gap/enabled
// ⇒ 列表、deviceConfig 接口两处的字段完全一致,App 端可共用一套解析
$out[$u]['sensors'][] = self::format($r);
}
} catch (\Throwable $e) {
// iot_sensor_cfg 不存在(老库)等:留空数组,App 端会回退到 deviceConfig 接口
}
return $out;
}
/**
* 单条配置对外格式(App / 后台 / 设备列表共用同一出口)
*
* 🔑 位置序号对外统一叫 `index`(2026-09-15 统一)—— 与下行 config 报文的键名一致,
* 也避免单字母键在 HTTP JSON 里看不出含义。
* ⚠️ 改这个键名必须**同步改三处消费方**,否则位置序号会静默变 0(不报错、只是错):
* SensorPointService(按位置取阈值落库)、App device-panel、后台 device.html。
* ⚠️ 下行 config 报文里的位置序号**同样叫 `index`**(2026-09-15 与 HTTP 字段统一,且只有这
* 一个键)。它也是**下行独有**的字段:上行 state 靠数组下标定位(省 10B/位置),而下行
* 只发启用位置、下标与序号会错开 ⇒ `index` 必须带。详见 push() 的注释。
*/
protected static function format(array $r): array
{
return [
'index' => (int) $r['pos_index'],
'alias' => (string) $r['alias'],
'probes' => (int) $r['probes'],
'upper' => (float) $r['upper'],
'lower' => (float) $r['lower'],
'gap' => (float) $r['gap'],
'enabled' => (int) $r['enabled'],
'update_at' => (int) $r['update_at'],
];
}
/** 当前服务端配置版本(iot_device.cfg_ver) */
public static function ver(string $uuid): int
{
try {
return (int) (Db::name('iot_device')->where('uuid', $uuid)->value('cfg_ver') ?? 0);
} catch (\Throwable $e) {
return 0;
}
}
/* ============================================================
* 保存 / 删除
* ========================================================== */
/**
* 保存设备配置(全量覆盖式:后台弹窗即整体编辑语义,简单且不会残留脏位置)
*
* @param array $sensors [['index'=>0,'alias'=>'进水','probes'=>2,'upper'=>85,'lower'=>0,'gap'=>3], ...]
* @param bool $push 保存后是否立即下发(retained)
* @return array 保存后的完整配置
* @throws \InvalidArgumentException|\RuntimeException
*/
public static function save(string $uuid, array $sensors, bool $push = true): array
{
$uuid = trim($uuid);
if ($uuid === '') {
throw new \InvalidArgumentException('设备 uuid 不能为空');
}
self::ensureTable();
$dev = Db::name('iot_device')->where('uuid', $uuid)->find();
if (!$dev) {
throw new \RuntimeException('设备不存在');
}
if (empty($sensors)) {
throw new \InvalidArgumentException('至少需要保留一个传感器位置');
}
$clean = [];
foreach ($sensors as $idx => $s) {
$row = self::normalize($s, $idx);
$clean[$row['pos_index']] = $row; // 同序号后写覆盖
}
ksort($clean);
if (count($clean) > self::MAX_POS) {
throw new \InvalidArgumentException('位置数超出上限(最多 ' . self::MAX_POS . ' 个)');
}
$now = time();
Db::startTrans();
try {
Db::name('iot_sensor_cfg')->where('device_uuid', $uuid)->delete();
$rows = [];
foreach ($clean as $row) {
$row['device_uuid'] = $uuid;
$row['create_at'] = $now;
$row['update_at'] = $now;
$rows[] = $row;
}
Db::name('iot_sensor_cfg')->insertAll($rows);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw new \RuntimeException('保存配置失败:' . $e->getMessage());
}
$ver = self::bumpVer($uuid);
if ($push) {
self::push($uuid, $ver);
}
return self::get($uuid);
}
/** 归一化并校验单个位置(非法值自动纠正而非报错,避免后台弹窗体验割裂) */
protected static function normalize($s, int $fallbackIndex): array
{
$s = (array) $s;
/* 位置序号:get/save 对外统一 `index`;`i`(App ≤2026-09-15 提交的旧键)与
`pos_index`(DB 列名)继续认,不要删 —— 少认一个键就会静默回退成「数组下标」,
而 App 提交顺序一变位置就整体错位,更糟的是多个位置会挤到同一序号、
被 save() 的全量覆盖语义合并成一个(2026-09-15 现场事故)。 */
if (isset($s['index'])) {
$pos = (int) $s['index'];
} elseif (isset($s['i'])) {
$pos = (int) $s['i'];
} elseif (isset($s['pos_index'])) {
$pos = (int) $s['pos_index'];
} else {
$pos = $fallbackIndex;
}
if ($pos < 0 || $pos >= self::MAX_POS) {
throw new \InvalidArgumentException('位置序号超出范围(0 ~ ' . (self::MAX_POS - 1) . ')');
}
$alias = trim((string) ($s['alias'] ?? ($s['title'] ?? '')));
if ($alias === '') {
$alias = '位置' . ($pos + 1);
}
if (mb_strlen($alias) > 16) {
$alias = mb_substr($alias, 0, 16);
}
/* 探头数:HTTP 入口沿用长名 probes,顺带认 pn(与 MQTT 下行的新键同名,方便将来统一) */
$probes = (int) ($s['probes'] ?? $s['pn'] ?? 1);
if ($probes < 1) {
$probes = 1;
}
if ($probes > self::MAX_PROBE) {
$probes = self::MAX_PROBE;
}
$upper = round((float) ($s['upper'] ?? self::DEF_UPPER), 2);
$lower = round((float) ($s['lower'] ?? self::DEF_LOWER), 2);
$gap = round((float) ($s['gap'] ?? self::DEF_GAP), 2);
if ($lower > $upper) { // 上下限填反 → 自动交换
$tmp = $upper;
$upper = $lower;
$lower = $tmp;
}
if ($gap < 0) {
$gap = 0.0;
}
$enabled = isset($s['enabled']) ? ((int) $s['enabled'] ? 1 : 0) : 1;
return [
'pos_index' => $pos,
'alias' => $alias,
'probes' => $probes,
'upper' => $upper,
'lower' => $lower,
'gap' => $gap,
'enabled' => $enabled,
];
}
/** 配置版本自增(设备 ack 的 sync_ver 与之比对即知是否生效) */
public static function bumpVer(string $uuid): int
{
DeviceService::ensureColumns();
$dev = Db::name('iot_device')->where('uuid', $uuid)->find();
if (!$dev) {
throw new \RuntimeException('设备不存在');
}
$ver = (int) ($dev['cfg_ver'] ?? 0) + 1;
Db::name('iot_device')->where('id', $dev['id'])->update([
'cfg_ver' => $ver, 'update_at' => time(),
]);
return $ver;
}
/** 删除某设备全部配置(删除设备时清理) */
public static function removeByUuid(string $uuid): void
{
try {
Db::name('iot_sensor_cfg')->where('device_uuid', $uuid)->delete();
} catch (\Throwable $e) {
}
}
/* ============================================================
* 下发 / 回执
* ========================================================== */
/**
* 下发配置到设备(retained → 设备离线时也会在重连订阅瞬间收到,无需服务端重推)
*
* @param int|null $ver 指定版本号,null 取当前 cfg_ver
*/
public static function push(string $uuid, ?int $ver = null): bool
{
$uuid = trim($uuid);
if ($uuid === '') {
return false;
}
$cfg = self::get($uuid, true);
if ($ver === null) {
$ver = $cfg['cfg_ver'];
}
/* 🛡 空配置绝不下发 —— 这是一条能「把设备打瘫且双方都不知情」的路径:
固件 onConfigJson 的语义是「先 POS_MAX 个位置全置 used=false,再按下发的数组逐个启用」
⇒ 收到 pos:[] 就等于**把 NVS 里的配置整体清空**,此后设备每周期上报 pos:[],
而它 ack 的还是那条 cv(成功)⇒ 服务端 sync_ver == cfg_ver ⇒ 后台显示「已生效」,
双方都以为没事,**没有任何自动纠正的途径**(只能靠人再保存一次配置)。
所以真源一行都没有时(被清空/误删)宁可不下发 —— 该做的是补齐配置,不是把设备打瘫。
注意「把位置全停用」不在此列:那是用户显式意图,数组非空、照发。 */
if (!$cfg['sensors'] && !self::get($uuid)['sensors']) {
return false;
}
/* 报文(v1.7.2 起只剩这一种版式,键名与上行 state 完全一致):
* {"cv":7,"pos":[{"index":0,"alias":"进水","pn":2,"upper":85,"lower":0,"gap":3}]}
*
* 🔑 这里曾经叠了三层「灰度兼容」,2026-09-15 全部删除 —— 现场只有两台自用设备、
* 且都会重烧固件 ⇒ 兼容的收益是 0,代价却是一个字段挂两把钥匙:
* ① 键名长短不一:下行写长名 positions/probes、上行写 pos/pr ⇒ 现在统一为短键;
* ② 数组复制两份:多出一份旧式 data[](≤1.4.0 只认它)⇒ 现在只有 pos;
* ③ 标量新旧同发:cfg_ver/probes 与 cv/pn 同值双发 ⇒ 现在只有 cv/pn。
* 教训:每多一个「同义键」就多一把备用钥匙,改键名时只改一边就**静默拆锁**
* (2026-09-15 现场:位置序号全变 0、数据全填位置 0、编辑一行改掉所有行)。
* 🔴 升级顺序:**先烧固件 ≥1.7.2,再上线本改动**。旧固件只认 positions/data 与
* cfg_ver/probes,收到本条报文会 ack "missing pos"(配置不生效,但**不会误报成功**)
* —— 一眼就能看出来,总好过以前那种「ack 成功、配置没落地」的静默错。
* 语义不变:全量覆盖,数组里没有的位置一律停用。 */
$pos = [];
foreach ($cfg['sensors'] as $s) {
/* ⚠️ `index` 必须带:sensors 是**只含启用位置**的(get(uuid, true)),
所以数组下标 ≠ 位置序号 —— 例如位置 0 停用、位置 2 启用,它会排在下标 0 上。
这就是「上行没有 index、下行必须有 index」的原因(上行靠下标定位、且未启用
位置写 null 占位把下标撑住;下行不发废位置,下标会塌缩)。 */
$pos[] = [
'index' => $s['index'],
'alias' => $s['alias'],
'pn' => $s['probes'], // 该位置「期望探头数」(1 ~ MAX_PROBE)
'upper' => $s['upper'],
'lower' => $s['lower'],
'gap' => $s['gap'],
];
}
$payload = json_encode(
['cv' => $ver, 'pos' => $pos],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
if ($payload === false) {
return false;
}
return Store::enqueueManual(self::configTopic($uuid), $payload, 1, 1, 'admin');
}
/**
* 按**当前版本**重发一次配置(不 bumpVer,不产生版本跳动)
*
* 用途:设备每次上线(/status 带 pins)时调用,专治「设备端配置与服务端不一致」这类
* **没有自愈途径**的僵局 —— 最典型的就是设备曾被一条空配置(pos:[])打成全停用:
* 设备侧 cfg 被清空、cv 停在那一刻,服务端 sync_ver 恰好也等于那个版本,双方都认为
* 「已生效」,于是谁都不会主动纠正,只能等人工再保存一次配置。
* 每次上线同版本重发是安全的:固件 onConfigJson **不做版本大小比较**,收到即应用
* (见固件该函数的 🔑 注释);retained 投递 ⇒ 设备重连订阅瞬间就能拿到。
*
* @return bool 是否已入队下发(false = 无启用位置 / 真源为空,见 push() 的 🛡 说明)
*/
public static function repush(string $uuid): bool
{
$uuid = trim($uuid);
if ($uuid === '') {
return false;
}
$cfg = self::get($uuid, true);
if (!$cfg['sensors']) {
return false;
}
return self::push($uuid, $cfg['cfg_ver']);
}
/**
* 处理设备 config_ack(Broker 收到 /devices/{uuid}/ack 时调用)
* 成功则记 sync_ver,供后台/App 显示「已生效」;失败保持 0 → 显示「待生效」并提示原因
*
* @param array $payload 已解析的 ack 报文
*/
public static function onAck(string $uuid, array $payload): void
{
$type = (string) ($payload['type'] ?? '');
if ($type !== '' && $type !== 'config_ack') {
return; // cmd_ack 等其它应答不处理
}
DeviceService::ensureColumns();
/* 配置版本:`cv`(与上行 state / 下行 config 同一套短键);旧键 cfg_ver 的兜底已随
老固件兼容一起删除(2026-09-15)—— 固件 ack 也只剩 cv,见固件 sendAck() */
$ver = (int) ($payload['cv'] ?? 0);
$ok = !empty($payload['success']);
try {
Db::name('iot_device')->where('uuid', $uuid)->update([
'sync_ver' => $ok ? $ver : 0,
'sync_at' => time(),
'update_at' => time(),
]);
} catch (\Throwable $e) {
}
}
/* ============================================================
* 默认配置(设备首次纳管)
* ========================================================== */
/**
* 确保设备至少有一份配置(设备激活/首次注册时调用)
* 已有配置则原样返回,不覆盖用户设置
*
* @param int $probes 默认探头数(新建时使用)
* @param string $alias 默认位置名,空则「位置1」
*/
public static function ensureDefault(string $uuid, int $probes = 2, string $alias = ''): array
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['cfg_ver' => 0, 'sync_ver' => 0, 'sync_at' => 0, 'applied' => false, 'sensors' => []];
}
self::ensureTable();
$exist = 0;
try {
$exist = (int) Db::name('iot_sensor_cfg')->where('device_uuid', $uuid)->count();
} catch (\Throwable $e) {
$exist = 0;
}
if ($exist > 0) {
return self::get($uuid);
}
return self::save($uuid, [[
'index' => 0,
'alias' => $alias !== '' ? $alias : '位置1',
'probes' => $probes,
'upper' => self::DEF_UPPER,
'lower' => self::DEF_LOWER,
'gap' => self::DEF_GAP,
]]);
}
/**
* 按设备上报的引脚表**自动补齐**缺失的位置(只补不覆盖)
*
* 为什么需要它:位置 ↔ GPIO 是**下标映射**(固件编译期常量 ONEWIRE_PINS[i]),
* 而设备只采「服务端配置里启用的位置」—— 新设备只有 ensureDefault 建的
* pos_index=0(= GPIO3),于是探头插在 GPIO4/GPIO10(= 位置 1/2)上**永远不出数**。
* 现场典型困惑:「只有某根针脚能出数据,断掉它接别的都没数」。
* 设备上线时 /status 会带全表 pins(长度 = POS_MAX,与启不启用无关)⇒ 服务端据此
* 把缺失的位置补上,于是插 3/4/10 任意一路都能出数,无需重烧固件、无需手动加位置。
*
* 三条语义(都很重要):
* ① **只补不覆盖**:已存在的 pos_index 一行都不动 —— 用户改过的别名/阈值/探头数全保留;
* ② **停用不复活**:enabled=0 的行同样算「已存在」,不会被重新启用
* (注意这是本功能的固有语义:把位置**删除**了下一次上线还会补回来,
* 想彻底不要某个位置应该「停用」而不是删除);
* ③ **幂等**:每次上线都会被调用;无新增则直接返回 0,不 bumpVer、不重复下发。
*
* 老固件(≤1.6.2)的 /status 不含 pins ⇒ 这里不会被触发,行为与从前完全一致。
*
* @param int[] $pins 下标 = 位置序号(DeviceService::getPins 的原样传入)
* @return int 实际新增的位置数(0 = 无需改动 / 失败)
*/
public static function fillMissing(string $uuid, array $pins): int
{
$uuid = trim($uuid);
if ($uuid === '' || !$pins) {
return 0;
}
self::ensureTable();
$n = min(count($pins), self::MAX_POS); // pins 长度 = POS_MAX,超出上限的部分忽略
try {
$exist = Db::name('iot_sensor_cfg')->where('device_uuid', $uuid)->column('pos_index');
} catch (\Throwable $e) {
return 0;
}
$exist = array_map('intval', is_array($exist) ? $exist : []);
$now = time();
$rows = [];
for ($i = 0; $i < $n; $i++) {
if (in_array($i, $exist, true)) {
continue; // 已存在(含停用)→ 绝不动它
}
$rows[] = [
'device_uuid' => $uuid,
'pos_index' => $i,
'alias' => '位置' . ($i + 1),
'probes' => 1,
'upper' => self::DEF_UPPER,
'lower' => self::DEF_LOWER,
'gap' => self::DEF_GAP,
'enabled' => 1,
'create_at' => $now,
'update_at' => $now,
];
}
if (!$rows) {
return 0; // 幂等:无事发生
}
try {
Db::name('iot_sensor_cfg')->insertAll($rows);
} catch (\Throwable $e) {
return 0; // 唯一键撞车等 → 不阻断设备上线
}
try {
$ver = self::bumpVer($uuid);
self::push($uuid, $ver); // retained:设备重连订阅瞬间即可收到
} catch (\Throwable $e) {
// 版本自增 / 下发失败不回滚已补的行:下次上线还会走到这里
}
return count($rows);
}
}
+911
View File
@@ -0,0 +1,911 @@
<?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;
use think\facade\Log;
/**
* 传感器结构化时序服务(落库 + 云端复核判定 + 曲线查询)
*
* 与 DeviceLogService 的分工:
* - iot_device_log :原始报文留痕(longtext payload),用于诊断/审计,不做统计;
* - iot_sensor_point :解析后的结构化数值(一行 = 一个位置的一次上报),
* 用于画曲线、算统计、云端阈值复核 —— longtext 里加不了数值索引。
*
* 为什么必须存 iv:设备是「事件驱动稀疏上报」(温变 / 告警翻转 / 有效性变化才发,
* 冷却 5s、兜底 120s),相邻两点可能隔几十秒。画图与统计必须按 ts → ts+iv 前向填充,
* 否则曲线全是断点、均值会偏。
*
* 为什么云端要再判一遍:固件 alertMaskOf() 是「实时兜底」(断网可用、零延迟),
* 但它看不见自己固件有 bug、阈值版本没同步(cfg_ver 落后服务端)等情况。
* 云端拿同一份真源复核,兜住设备漏报 —— 阈值定义仍只有 iot_sensor_cfg 这一处。
*
* 落库点:Broker 的 mqtt_message_published 钩子(QoS0/1 与 QoS2/PUBREL 两条路径)。
* 该钩子在 Workerman 长驻进程内执行 → 全部防御式 try/catch,绝不影响 Broker 主流程。
*/
class SensorPointService
{
/** 单条上报最多入库的位置数(与固件 POS_MAX 一致) */
public const MAX_POS = 3;
/** 云端复核告警抑制窗口(秒):窗口内已有该设备告警则不再补,避免双份刷屏 */
public const RECHECK_SUPPRESS = 600;
/** 历史点默认保留天数(0 = 不清理) */
public const KEEP_DAYS = 90;
/* ============================================================
* 表自愈
* ========================================================== */
/** 进程内建表标记:capture 每条上报都会调用,不能每条消息都跑一次 DDL */
private static bool $tableReady = false;
/** 确保 iot_sensor_point 表存在(老库/未执行 install.sql 亦可用) */
public static function ensureTable(): bool
{
if (self::$tableReady) {
return true;
}
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
try {
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_sensor_point` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '设备uuid',
`pos_index` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '位置序号(0起)',
`ts` int unsigned NOT NULL DEFAULT '0' COMMENT '设备NTP秒(0=未对时,已取服务端接收时间)',
`iv` smallint unsigned NOT NULL DEFAULT '0' COMMENT '本条数据覆盖秒数(前向填充用)',
`t` decimal(6,2) DEFAULT NULL COMMENT '位置温度=有效探头均值',
`df` decimal(6,2) DEFAULT NULL COMMENT '位置内极差',
`v` tinyint(1) NOT NULL DEFAULT '0' COMMENT '1=位置有有效读数',
`vs_json` varchar(255) NOT NULL DEFAULT '' COMMENT '逐探头明细 {\"A3F2\":25.6}',
`ok_mask` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '探头有效位图',
`alert_mask` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '云端复核告警位图(bit7=gap)',
`cfg_ver` int unsigned NOT NULL DEFAULT '0' COMMENT '上报时的配置版本',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_dev_pos_ts` (`uuid`,`pos_index`,`ts`),
KEY `idx_create_at` (`create_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='传感器结构化时序(曲线/统计/云端复核)'");
return true;
} catch (\Throwable $e) {
return false;
}
}
/* ============================================================
* 上报捕获(Broker 钩子入口)
* ========================================================== */
/**
* 捕获一条设备上报:解析 → 结构化落库 → 云端复核补告警
*
* 只处理 /devices/{uuid}/state,其余主题立即返回(Broker 会收到全部流量,
* 这里必须尽早短路,避免给无关消息做 json_decode)。
*/
public static function capture(string $topic, string $payload): void
{
if (!preg_match('#^/devices/([^/]+)/state$#', $topic, $m)) {
return;
}
$uuid = $m[1];
if ($uuid === '' || strlen($uuid) > 64) {
return;
}
$data = json_decode($payload, true);
/* 位置数组键名三代都认:pos(v1.7.0)/ positions(≥1.5.0)/ data(≤1.4.0)。
元素怎么定位(index / i / 数组下标)见下面 $i 的推导 */
$rawPos = is_array($data) ? ($data['pos'] ?? $data['positions'] ?? $data['data'] ?? null) : null;
if (!is_array($rawPos) || !$rawPos) {
return;
}
if (!self::ensureTable()) {
return;
}
$now = time();
$ts = (int) ($data['ts'] ?? 0);
if ($ts <= 0) {
$ts = $now; // 设备未对时 → 用服务端接收时间兜底
}
/* 覆盖秒数:iv(v1.7.0)/ interval(v1.6.x)/ iv(≤1.5.0 同名同义)——
老固件本来就叫 iv,所以新键回退过来天然兼容 */
$iv = max(0, min(65535, (int) ($data['iv'] ?? $data['interval'] ?? 0)));
/* 配置版本:cv(v1.7.0)/ cfg_ver(≤1.6.2) */
$cfgVer = (int) ($data['cv'] ?? $data['cfg_ver'] ?? 0);
/* 阈值一律取自配置真源(不在这里另立一套),按位置号索引 */
$cfgByPos = [];
try {
$cfg = SensorConfigService::get($uuid, true);
foreach ($cfg['sensors'] as $s) {
/* 位置序号键:index(2026-09-15 统一)/ i(旧出口)—— 取不到会静默落到 0,
于是**所有位置都按位置 0 的阈值**复核(落库 alert_mask、曲线标注全错),
所以两个键都要认。 */
$cfgByPos[(int) ($s['index'] ?? $s['i'] ?? 0)] = $s;
}
} catch (\Throwable $e) {
// 读配置失败不阻断落库:曲线数据本身仍有价值
}
$rows = [];
$alertRows = [];
foreach (array_values($rawPos) as $k => $pos) {
/* 非数组 = v1.6.0 的占位元素(该位置未启用)→ 跳过。
空数组 `[]` 也当占位:正常位置至少带 pin,空对象只可能来自
「固件把占位写成了 {} 而不是 null」这种异常(断言式兜底,不指望用上)。 */
if (!is_array($pos) || $pos === []) {
continue;
}
/* 位置序号各版式:
* ≤1.4.0 data[] 元素自带 i
* v1.5.0 positions[] 元素自带 index
* v1.6.0 positions[] **数组下标即位置序号**(没有 index,未启用位置写 null 占位)
* v1.7.0 pos[] 与 v1.6.0 完全同构,只是数组键名 positions → pos
* 显式键优先、都没有才用数组下标 —— 灰度期各版混跑都能落库 */
$i = (int) ($pos['index'] ?? $pos['i'] ?? $k);
if ($i < 0 || $i >= self::MAX_POS) {
continue;
}
/* 探头明细统一成「槽位 → {id, temp}」再往下走:
* v1.7.0 pr[[id,temp]] —— 二元组(键名 probes → pr),下标即槽位
* v1.6.0 probes[[id,temp]] —— 二元组,下标即槽位(键名省掉,省约 12B/颗)
* v1.5.0 probes[{id,temp}] —— 对象数组,temp=null 即该槽无读数
* ≤1.4.0 vs[]/ok[]/ids[] —— 三个平行数组按位对应
* 槽位下标 j 各版式都是「总线 ROM 搜索序第几颗」,语义一致。 */
$probes = [];
/* 键名 pr(v1.7.0)优先、回落 probes(≤1.6.2)—— 现场老固件不会重烧,长期兼容 */
$rawProbes = $pos['pr'] ?? $pos['probes'] ?? null;
if (is_array($rawProbes)) {
foreach (array_values($rawProbes) as $j => $p) {
if (is_array($p) && array_key_exists(0, $p)) {
$pid = $p[0] ?? null; // v1.6.0 二元组 [身份, 温度]
$pt = $p[1] ?? null;
} else {
$p = is_array($p) ? $p : [];
$pid = $p['id'] ?? null;
$pt = $p['temp'] ?? null;
}
$probes[$j] = [
'id' => (is_string($pid) && $pid !== '') ? $pid : null,
'temp' => is_numeric($pt) ? round((float) $pt, 2) : null,
];
}
} else {
$vs = (isset($pos['vs']) && is_array($pos['vs'])) ? array_values($pos['vs']) : [];
$ok = (isset($pos['ok']) && is_array($pos['ok'])) ? array_values($pos['ok']) : [];
$ids = (isset($pos['ids']) && is_array($pos['ids'])) ? array_values($pos['ids']) : [];
foreach ($vs as $j => $rawT) {
$probes[$j] = [
'id' => (isset($ids[$j]) && is_string($ids[$j]) && $ids[$j] !== '') ? $ids[$j] : null,
'temp' => !empty($ok[$j]) ? round((float) $rawT, 2) : null,
];
}
}
$c = $cfgByPos[$i] ?? null;
$upper = $c ? (float) $c['upper'] : 0.0;
$lower = $c ? (float) $c['lower'] : 0.0;
$gap = $c ? (float) $c['gap'] : 0.0;
/* 位置级:判「本位置有没有有效读数」并取温度/极差
* ≤1.4.0 v 标志 + t / df —— 用 v 判有效(旧版没有 null 语义)
* v1.5.0 temp / temp_diff —— 值为 null 即无有效读数
* v1.6.0 t / df —— 同上,只是键名缩短
* ⚠️ 必须先判 v 是否存在:v1.6.0 的 t 与旧式同键名,只有 v 能区分二者 ——
* 旧固件 v=0 时 t 照样带 0,若误当有效会把"没读数"存成 0.00℃
* ⚠️ 新式必须用 array_key_exists:值本身就是 null,isset 会返回 false */
if (array_key_exists('v', $pos)) {
$vValid = !empty($pos['v']);
$tVal = $vValid ? round((float) ($pos['t'] ?? 0), 2) : null;
$dfVal = $vValid ? round((float) ($pos['df'] ?? 0), 2) : null;
} else {
$rawT = array_key_exists('t', $pos) ? $pos['t'] : ($pos['temp'] ?? null);
$rawDf = array_key_exists('df', $pos) ? $pos['df'] : ($pos['temp_diff'] ?? null);
$vValid = is_numeric($rawT);
$tVal = $vValid ? round((float) $rawT, 2) : null;
$dfVal = ($vValid && is_numeric($rawDf)) ? round((float) $rawDf, 2) : null;
}
$kv = [];
$okMask = 0;
$alertMask = 0;
foreach ($probes as $j => $p) {
$isOk = ($p['temp'] !== null);
if ($isOk) {
$okMask |= (1 << $j);
}
/* key 优先用探头身份(ROM 短码),未识别时退化为下标 key,保证不丢数据。
短码只有 4 位十六进制,同总线两颗撞码时会互相覆盖 → 追加槽位消歧 */
$key = ($p['id'] !== null) ? $p['id'] : ('p' . $j);
if (array_key_exists($key, $kv)) {
$key .= '@' . $j;
}
$kv[$key] = $p['temp'];
/* 云端复核:逐探头越限(与固件 alertMaskOf 同语义) */
if ($isOk && $c) {
$val = (float) $p['temp'];
if (($upper > 0 && $val > $upper) || ($lower != 0.0 && $val < $lower)) {
$alertMask |= (1 << $j);
}
}
}
/* 位置内温差告警(bit7,与固件一致) */
if ($c && $vValid && $gap > 0 && $dfVal !== null && $dfVal >= $gap) {
$alertMask |= 0x80;
}
$vsJson = json_encode($kv, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($vsJson === false || strlen($vsJson) > 255) {
$vsJson = ''; // 异常超长则放弃明细,t/df/ok_mask 仍完整
}
$rows[] = [
'uuid' => $uuid,
'pos_index' => $i,
'ts' => $ts,
'iv' => $iv,
't' => $tVal,
'df' => $dfVal,
'v' => $vValid ? 1 : 0,
'vs_json' => (string) $vsJson,
'ok_mask' => $okMask,
'alert_mask' => $alertMask,
'cfg_ver' => $cfgVer,
'create_at' => $now,
];
if ($alertMask !== 0) {
$alertRows[] = [
'pos' => $i,
'alias' => $c ? (string) $c['alias'] : ('位置' . ($i + 1)),
't' => $tVal,
'df' => $dfVal,
'mask' => $alertMask,
'upper' => $upper,
'lower' => $lower,
'gap' => $gap,
];
}
}
if ($rows) {
try {
Db::name('iot_sensor_point')->insertAll($rows);
} catch (\Throwable $e) {
return; // 落库失败则不再补告警,避免同一份数据反复告警
}
}
if ($alertRows) {
try {
self::raiseAlerts($uuid, $alertRows, $cfgVer);
} catch (\Throwable $e) {
}
}
}
/* ============================================================
* 云端复核告警
* ========================================================== */
/**
* 云端复核发现异常 → 补告警(AlertService 落 iot_alert + 推 /notify/{account}/alert)
*
* 抑制策略(三道,目的:只兜「设备真漏报」,不制造双份打扰):
* 1) 连续两次确认:设备触发告警时会同时发 state 与 event 两条消息,首次异常时
* 设备自己的 event 可能只差几十毫秒入库 —— 等下一次上报仍异常才补,
* 这样「设备已报」的情况自然被策略 2 挡住;
* 2) 窗口内该设备已有告警(含设备端 event)→ 跳过;
* 3) 一次上报最多补一条(取第一个越限位置),防多点同时越限刷屏。
*/
protected static function raiseAlerts(string $uuid, array $items, int $cfgVer): void
{
/* 策略 1:本次是「首次异常」→ 等下一次确认(此刻设备的 event 可能尚未入库) */
if (!self::lastWasAlert($uuid, (int) $items[0]['pos'])) {
return;
}
/* 策略 2 */
if (self::hasRecentAlert($uuid)) {
return;
}
$dev = DeviceService::findByUuid($uuid);
if (!$dev || (int) ($dev['member_id'] ?? 0) <= 0) {
return; // 未绑定会员 → 无处投递
}
$it = $items[0];
$reasons = [];
if (((int) $it['mask'] & 0x80) !== 0) {
$reasons[] = sprintf('位置内温差 %.2f℃ 达到阈值 %.2f℃', (float) $it['df'], (float) $it['gap']);
}
$t = (float) $it['t'];
if ((float) $it['upper'] > 0 && $t > (float) $it['upper']) {
$reasons[] = sprintf('温度 %.2f℃ 超上限 %.2f℃', $t, (float) $it['upper']);
}
if ((float) $it['lower'] != 0.0 && $t < (float) $it['lower']) {
$reasons[] = sprintf('温度 %.2f℃ 低于下限 %.2f℃', $t, (float) $it['lower']);
}
/* 同一次上报里其它越限位置只作提示(不各发一条) */
$others = [];
foreach ($items as $k => $x) {
if ($k === 0) {
continue;
}
$others[] = (string) $x['alias'];
}
if ($others) {
$reasons[] = implode('、', $others) . ' 同时异常';
}
if (!$reasons) {
return;
}
AlertService::add(
(int) $dev['member_id'],
'condition',
3,
'温度异常:' . (string) $it['alias'],
implode(';', $reasons) . '(云端复核 · 配置 v' . $cfgVer . ')',
$uuid,
(int) $it['pos']
);
}
/**
* 上一条同位置落库点是否已判为异常(「连续两次确认」用)
*
* 调用时本次数据已入库,所以取最近 2 条的 [1] 才是「上一条」。
* 查不出来返回 false(不补告警)——宁可漏补一次,也不要误报打扰。
*/
protected static function lastWasAlert(string $uuid, int $pos): bool
{
try {
$masks = Db::name('iot_sensor_point')
->where('uuid', $uuid)
->where('pos_index', $pos)
->order('id', 'desc')
->limit(2)
->column('alert_mask');
} catch (\Throwable $e) {
return false;
}
return isset($masks[1]) && (int) $masks[1] !== 0;
}
/** 抑制判定:窗口内该设备已有告警(condition/event)则返回 true */
protected static function hasRecentAlert(string $uuid): bool
{
try {
return (int) Db::name('iot_alert')
->where('uuid', $uuid)
->whereIn('type', ['condition', 'event'])
->where('create_at', '>', time() - self::RECHECK_SUPPRESS)
->count() > 0;
} catch (\Throwable $e) {
return true; // 查不出来时保守跳过:宁可少报也不刷屏
}
}
/* ============================================================
* 查询(App 曲线)
* ========================================================== */
/**
* 查询某位置的历史曲线点(按 ts 升序)
*
* 返回「稀疏点」而非等间隔序列:每条带 iv(覆盖秒数),需要等间隔序列时由调用方
* 按 ts → ts+iv 前向填充展开 —— 这样避免服务端把数据放大数倍(温变上报 10s~120s 一条)。
*
* @param int $fromTs 起(含),0 = 不限
* @param int $toTs 止(含),0 = 不限
* @param int $limit 最多返回点数(取最近 N 条后翻回升序)
* @return array<int,array{ts:int,iv:int,t:float|null,df:float|null,v:bool,vs:array,alert:int}>
*/
public static function series(string $uuid, int $posIndex, int $fromTs = 0, int $toTs = 0, int $limit = 500): array
{
$uuid = trim($uuid);
if ($uuid === '' || $posIndex < 0 || $posIndex >= self::MAX_POS) {
return [];
}
self::ensureTable();
$limit = max(1, min(2000, $limit));
try {
$q = Db::name('iot_sensor_point')
->where('uuid', $uuid)
->where('pos_index', $posIndex);
if ($fromTs > 0) {
$q->where('ts', '>=', $fromTs);
}
if ($toTs > 0) {
$q->where('ts', '<=', $toTs);
}
$rows = $q->order('ts', 'desc')->limit($limit)->select()->toArray();
} catch (\Throwable $e) {
/* ⚠️ 这里以前是「静默 return []」——导致接口只回 count=0,看不出是连库失败、
表名不对还是查询语法错。落一条日志:排障时能直接看到异常原文。 */
Log::error('[iot] sensorSeries 查询失败 uuid=' . $uuid . ' pos=' . $posIndex
. ' from=' . $fromTs . ' to=' . $toTs . ' err=' . $e->getMessage());
return [];
}
$out = [];
foreach (array_reverse($rows) as $r) { // 翻回升序,前端可直接画
/* vs_json 的 key 是探头身份(ROM 短码,如 "A3F2");身份未识别时是 "p{槽位}"。
这里转成对象数组 {id,temp}。注意它与 MQTT 上行**故意不同**:
· MQTT 上行是「每 10s~120s 一条」的高频链路 → 用最省的二元组 [id,temp];
· 本接口是「用户点开曲线才请求一次」的 HTTP 链路 → 保持可读键名
(每条 6 个键 ≈18B,相对一次 500 点的传输占比可忽略,换来 App 侧好读)。
不要再把两者"统一"成一种形状:统一只会让其中一端付出不该付的代价。
DB 列名(iv/t/df/v/vs_json)保持不动:那是存储实现,改列要动历史数据。 */
$vsJson = json_decode((string) $r['vs_json'], true);
$probes = [];
if (is_array($vsJson)) {
foreach ($vsJson as $pid => $pt) {
$probes[] = [
'id' => preg_match('/^p\d+$/', (string) $pid) ? null : (string) $pid,
'temp' => is_numeric($pt) ? (float) $pt : null, // null = 该探头无读数
];
}
}
$out[] = [
'ts' => (int) $r['ts'],
'interval' => (int) $r['iv'],
'temp' => $r['t'] === null ? null : (float) $r['t'],
'temp_diff' => $r['df'] === null ? null : (float) $r['df'],
'probes' => $probes,
'alert_mask' => (int) $r['alert_mask'], // bit j = 槽位 j 越限,bit7 = 位置内温差
];
}
return $out;
}
/**
* 曲线查询诊断(排障专用;`debug=1` 时由 Monitor::sensorSeries 附加返回)
*
* 起因:接口回 count=0,但直接在数据库里跑同样的 SQL 明明有数据 —— series() 只有一个
* 静默的 `catch { return []; }`,无从分辨「连错库 / 表名不对 / 时间条件不成立 / 查询抛异常」。
* 本方法把**每一层条件各算一次行数**,并回传真实库名、实际下发的 SQL(含前缀解析结果)
* 与最新几条原始行,一次请求即可定位,不用再猜。
*
* ⚠️ 只在显式传 debug=1 时执行;正常链路零开销。返回内容含表名/库名,属内部信息,
* 调用方(Monitor::sensorSeries)本身已要求「已登录 + 对该设备有查看权限」。
*
* @return array<string,mixed>
*/
public static function seriesDebug(string $uuid, int $posIndex, int $fromTs, int $toTs, int $limit = 3): array
{
$uuid = trim($uuid);
$out = [
'database' => '', // 接口实际连的库(与你跑 SQL 的库对比,一票定生死)
'table' => '', // 实际带前缀的表名
'sql' => '', // 实际下发的 SQL(fetchSql 只拼不执行)
'server_ts' => time(), // 接口侧当前时间戳(与 DB 的 UNIX_TIMESTAMP() 对比可辨时钟偏移)
'uuid' => $uuid,
'pos' => $posIndex,
'from' => $fromTs,
'to' => $toTs,
'rows_uuid' => 0, // 只按 uuid 的行数
'rows_pos' => 0, // uuid + pos
'rows_range' => 0, // uuid + pos + 时间范围(= 接口实际条件的行数)
'min_ts' => null, // uuid 维度最早/最新 ts
'max_ts' => null,
'pos_min_ts' => null, // uuid + pos 维度最早/最新 ts
'pos_max_ts' => null,
'recent' => [], // uuid 最新 3 条原始行(ts/pos_index/iv/t/alert_mask)
'error' => '', // 查询异常原文(原来被静默吞掉的那条)
];
if ($uuid === '') {
return $out;
}
try {
self::ensureTable();
$db = Db::query('SELECT DATABASE() AS db');
$out['database'] = (string) ($db[0]['db'] ?? '');
$out['sql'] = (string) Db::name('iot_sensor_point')
->where('uuid', $uuid)
->where('pos_index', $posIndex)
->where('ts', '>=', $fromTs)
->where('ts', '<=', $toTs)
->order('ts', 'desc')
->limit($limit)
->fetchSql(true)
->select();
if (preg_match('/\bfrom\s+`?([\w]+)`?/i', $out['sql'], $m)) {
$out['table'] = $m[1];
}
$out['rows_uuid'] = (int) Db::name('iot_sensor_point')->where('uuid', $uuid)->count();
$out['rows_pos'] = (int) Db::name('iot_sensor_point')
->where('uuid', $uuid)->where('pos_index', $posIndex)->count();
$out['rows_range'] = (int) Db::name('iot_sensor_point')
->where('uuid', $uuid)->where('pos_index', $posIndex)
->where('ts', '>=', $fromTs)->where('ts', '<=', $toTs)->count();
if ($out['rows_uuid'] > 0) {
$agg = Db::name('iot_sensor_point')->where('uuid', $uuid)
->field('MIN(ts) AS mn, MAX(ts) AS mx')->find();
$out['min_ts'] = isset($agg['mn']) ? (int) $agg['mn'] : null;
$out['max_ts'] = isset($agg['mx']) ? (int) $agg['mx'] : null;
$out['recent'] = Db::name('iot_sensor_point')->where('uuid', $uuid)
->field('ts,pos_index,iv,t,alert_mask')
->order('ts', 'desc')->limit(3)->select()->toArray();
}
if ($out['rows_pos'] > 0) {
$agg = Db::name('iot_sensor_point')->where('uuid', $uuid)
->where('pos_index', $posIndex)
->field('MIN(ts) AS mn, MAX(ts) AS mx')->find();
$out['pos_min_ts'] = isset($agg['mn']) ? (int) $agg['mn'] : null;
$out['pos_max_ts'] = isset($agg['mx']) ? (int) $agg['mx'] : null;
}
// fetchSql 不执行,这里真跑一次,把异常原文捞出来(这是最可能的 count=0 元凶)
Db::name('iot_sensor_point')
->where('uuid', $uuid)->where('pos_index', $posIndex)
->where('ts', '>=', $fromTs)->where('ts', '<=', $toTs)
->limit(1)->select();
} catch (\Throwable $e) {
$out['error'] = $e->getMessage();
}
return $out;
}
/**
* 按时间桶聚合某位置历史(时间加权平均,供"每分钟/每N分钟一个点"的粗粒度曲线)
*
* 加权公式:桶内 avg = SUM(t×有效秒) / SUM(有效秒)。用 iv(有效期)加权而非简单 AVG:
* 一条 iv=120 的点和一条 iv=10 的点权重不该一样,温变期间简单平均会失真。
* 有效秒 = min(iv, 下一点的 ts - 本点 ts, 桶尾 - 本点 ts),即把 iv 裁剪到桶内,
* 避免跨桶"溢出"(12:58 上报 iv=120 的点不会把权重漏进 13:00 桶)。
*
* 与 series() 的分工:series 返回原始稀疏点(细粒度、App 自己前向填充);
* 本方法返回聚合桶(粗粒度、点数可控)——典型用途:最近 30/60 分钟每分钟一点。
* 两者并存,不要用本方法替代 series。
*
* @param int $bucketSec 桶大小(秒),60 = 每分钟一点;会被钳到 [60, 86400]
* @param int $fromTs 起(含),0 = 不限
* @param int $toTs 止(含),0 = 不限
* @param int $limit 最多返回桶数(取最近 N 桶后翻回升序)
* @return array<int,array{ts:int,avg:float|null,min:float|null,max:float|null,samples:int,coverage_s:int}>
* ts = 桶起点(UTC 对齐;App 显示时按本地时区格式化);coverage_s = 桶内被数据覆盖的秒数
*/
public static function seriesAvg(string $uuid, int $posIndex, int $bucketSec = 60, int $fromTs = 0, int $toTs = 0, int $limit = 720): array
{
$uuid = trim($uuid);
if ($uuid === '' || $posIndex < 0 || $posIndex >= self::MAX_POS) {
return [];
}
$bucketSec = max(60, min(86400, $bucketSec)); // 桶最小 1 分钟,最大 1 天
$limit = max(1, min(2000, $limit));
self::ensureTable();
try {
$q = Db::name('iot_sensor_point')
->where('uuid', $uuid)
->where('pos_index', $posIndex)
->whereNotNull('t');
if ($fromTs > 0) {
$q->where('ts', '>=', $fromTs);
}
if ($toTs > 0) {
$q->where('ts', '<=', $toTs);
}
$rows = $q->order('ts', 'asc')->select()->toArray();
} catch (\Throwable $e) {
/* 与 series() 同口径:不要静默 return [](否则接口只回 count=0,
分不清「连错库 / 表名不对 / 查询异常」)。排障先看这条日志。 */
Log::error('[iot] sensorAvg 查询失败 uuid=' . $uuid . ' pos=' . $posIndex
. ' step=' . $bucketSec . ' from=' . $fromTs . ' to=' . $toTs . ' err=' . $e->getMessage());
return [];
}
if (!$rows) {
return [];
}
// 一次遍历完成分桶 + 跨点裁剪(rows 已按 ts 升序)
$buckets = []; // bucketTs => [w_sum, sec_sum, min, max, samples]
$n = count($rows);
$lastTs = (int) $rows[$n - 1]['ts'] + (int) $rows[$n - 1]['iv']; // 末点的有效期终点
for ($i = 0; $i < $n; $i++) {
$ts = (int) $rows[$i]['ts'];
$t = (float) $rows[$i]['t'];
// 有效期终点:iv 与"下一点 ts"取小(下一点来了,本点权重就到此为止)
$end = $ts + (int) $rows[$i]['iv'];
if ($i + 1 < $n) {
$nextTs = (int) $rows[$i + 1]['ts'];
if ($nextTs > $ts && $nextTs < $end) {
$end = $nextTs;
}
}
if ($end <= $ts) {
$end = $ts + 1; // 防御:iv=0 至少记 1 秒权重
}
// 跨桶把本点切段:每段只把落在桶内的秒数计入该桶
$cur = $ts;
while ($cur < $end && $cur < $lastTs) {
$bucketTs = intdiv($cur, $bucketSec) * $bucketSec;
$segEnd = min($end, $bucketTs + $bucketSec);
$sec = $segEnd - $cur;
if ($sec > 0) {
if (!isset($buckets[$bucketTs])) {
$buckets[$bucketTs] = ['w' => 0.0, 's' => 0, 'min' => $t, 'max' => $t, 'n' => 0];
}
$b = &$buckets[$bucketTs];
$b['w'] += $t * $sec;
$b['s'] += $sec;
$b['n'] += 1;
if ($t < $b['min']) $b['min'] = $t;
if ($t > $b['max']) $b['max'] = $t;
unset($b);
}
$cur = $segEnd;
}
}
if (count($buckets) > $limit) {
$buckets = array_slice($buckets, -$limit, null, true); // 只留最近 N 桶
}
$out = [];
foreach ($buckets as $bucketTs => $b) {
$out[] = [
'ts' => $bucketTs,
'avg' => $b['s'] > 0 ? round($b['w'] / $b['s'], 2) : null,
'min' => round($b['min'], 2),
'max' => round($b['max'], 2),
'samples' => $b['n'],
'coverage_s' => $b['s'],
];
}
return $out; // 桶起点天然升序
}
/**
* 最近 N 分钟按位置加权平均(一次性返回所有位置,单条 SQL)
*
* 加权公式与 seriesAvg 一致:SUM(t×有效秒) / SUM(秒),
* 跨点用下一点 ts 裁剪,跨窗口边界用 [from, to] 裁剪
* —— 一条 iv=120 的点与一条 iv=10 的点权重不同,简单 AVG 在温变期间会失真
*
* 与 seriesAvg() 的差别:seriesAvg 返回「按 step 分桶的曲线」,
* 本方法返回「每个位置一条汇总值」——典型用途:首屏概览、最近 N 分钟均值、阈值刷新、排障。
*
* @param int $windowMin 窗口分钟数,[1, 1440];1=最近 1 分钟,1440=最近 1 天
* @param int $posIndex 指定位置返回(>=0),-1 = 全部位置
* @return array{from:int,to:int,samples_total:int,positions:array<int,array{index:int,alias:string,avg:float|null,min:float|null,max:float|null,samples:int,coverage_s:int,last_ts:int,data_ok:bool}>}
*/
public static function recentAvg(string $uuid, int $windowMin = 10, int $posIndex = -1): array
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['from' => 0, 'to' => 0, 'samples_total' => 0, 'positions' => []];
}
$windowMin = max(1, min(1440, $windowMin));
self::ensureTable();
$now = time();
$from = $now - $windowMin * 60;
// 一次拉窗口内全部有效点(窗口 1~1440 分钟,单设备一般几千行内,单次查询很轻)
try {
$q = Db::name('iot_sensor_point')
->where('uuid', $uuid)
->where('ts', '>=', $from)
->where('ts', '<=', $now)
->whereNotNull('t');
if ($posIndex >= 0) {
$q->where('pos_index', $posIndex);
}
$rows = $q->order('ts', 'asc')->select()->toArray();
} catch (\Throwable $e) {
return ['from' => $from, 'to' => $now, 'samples_total' => 0, 'positions' => []];
}
// 拿 cfg 取别名(轻量;cfg 用 uuid+index 主键,列只取 alias),拿不到不影响主流程
$aliasMap = [];
try {
$cfgRows = Db::name('iot_sensor_cfg')->where('uuid', $uuid)->column('alias', 'index');
foreach ($cfgRows as $idx => $name) {
$aliasMap[(int) $idx] = (string) $name;
}
} catch (\Throwable $e) {
}
// 按 pos_index 聚合(每组内做 iv 加权 + 跨点裁剪 + 跨窗口裁剪)
$byPos = [];
$n = count($rows);
for ($i = 0; $i < $n; $i++) {
$pos = (int) $rows[$i]['pos_index'];
if ($pos < 0 || $pos >= self::MAX_POS) {
continue;
}
$ts = (int) $rows[$i]['ts'];
$t = (float) $rows[$i]['t'];
$end = $ts + max(1, (int) $rows[$i]['iv']);
if ($i + 1 < $n) {
$nextTs = (int) $rows[$i + 1]['ts'];
if ($nextTs > $ts && $nextTs < $end) {
$end = $nextTs; // 下一点来时,本点 iv 自动让位(与 seriesAvg 同语义)
}
}
// 跨窗口边界裁剪:只统计落在 [from, now] 内的秒数
$segStart = max($ts, $from);
$segEnd = min($end, $now);
if ($segEnd <= $segStart) {
continue;
}
$sec = $segEnd - $segStart;
if (!isset($byPos[$pos])) {
$byPos[$pos] = ['w' => 0.0, 's' => 0, 'min' => $t, 'max' => $t, 'n' => 0, 'last' => 0];
}
$b = &$byPos[$pos];
$b['w'] += $t * $sec;
$b['s'] += $sec;
$b['n'] += 1;
if ($t < $b['min']) {
$b['min'] = $t;
}
if ($t > $b['max']) {
$b['max'] = $t;
}
if ($ts > $b['last']) {
$b['last'] = $ts;
}
unset($b);
}
// 全部位置补齐(即便 0 样本也返回,便于前端占位显示「无数据」而非跳过整行)
$positions = [];
$totalSamples = 0;
for ($p = 0; $p < self::MAX_POS; $p++) {
if ($posIndex >= 0 && $p !== $posIndex) {
continue;
}
$b = $byPos[$p] ?? null;
$positions[] = [
'index' => $p,
'alias' => $aliasMap[$p] ?? ('位置' . ($p + 1)),
'avg' => ($b && $b['s'] > 0) ? round($b['w'] / $b['s'], 2) : null,
'min' => $b ? round($b['min'], 2) : null,
'max' => $b ? round($b['max'], 2) : null,
'samples' => $b ? $b['n'] : 0,
'coverage_s' => $b ? $b['s'] : 0,
'last_ts' => $b ? $b['last'] : 0,
'data_ok' => $b !== null && $b['s'] > 0,
];
if ($b) {
$totalSamples += $b['n'];
}
}
return [
'from' => $from,
'to' => $now,
'samples_total' => $totalSamples,
'positions' => $positions,
];
}
/** 最近一条落库点(App 首屏快速回填用;取不到返回 null) */
public static function latest(string $uuid, int $posIndex): ?array
{
$list = self::series($uuid, $posIndex, 0, 0, 1);
return $list ? $list[0] : null;
}
/* ============================================================
* 维护
* ========================================================== */
/** 清理超过保留期的历史点(定时任务调用),返回删除行数 */
public static function cleanup(int $days = self::KEEP_DAYS): int
{
if ($days <= 0) {
return 0;
}
self::ensureTable();
try {
return (int) Db::name('iot_sensor_point')
->where('create_at', '<', time() - $days * 86400)
->delete();
} catch (\Throwable $e) {
return 0;
}
}
/** 删除某设备全部历史点(解绑/删除设备时清理) */
public static function removeByUuid(string $uuid): void
{
if ($uuid === '') {
return;
}
try {
Db::name('iot_sensor_point')->where('uuid', $uuid)->delete();
} catch (\Throwable $e) {
}
}
/**
* 按小时聚合某位置历史(时间加权平均)
*
* @param int $fromTs 起(秒,含),0 = 不限
* @param int $toTs 止(秒,含),0 = 不限
* @param int $limit 最多返回多少小时桶(取最近 N 个)
* @return array<int,array{hour_ts:int,avg:float,min:float,max:float,samples:int,coverage_s:int}>
*/
public static function hourlyAvg(string $uuid, int $posIndex, int $fromTs = 0, int $toTs = 0, int $limit = 168): array
{
$uuid = trim($uuid);
if ($uuid === '' || $posIndex < 0 || $posIndex >= self::MAX_POS) {
return [];
}
self::ensureTable();
$limit = max(1, min(8760, $limit)); // 一年上限
try {
// 一次拉范围内全部点(小时桶后不超过几万行,单次查询很轻)
$q = Db::name('iot_sensor_point')
->where('uuid', $uuid)
->where('pos_index', $posIndex)
->where('t', 'not null');
if ($fromTs > 0) $q->where('ts', '>=', $fromTs);
if ($toTs > 0) $q->where('ts', '<=', $toTs);
$rows = $q->order('ts', 'asc')->select()->toArray();
} catch (\Throwable $e) {
return [];
}
$buckets = []; // hour_ts => [sum_t_iv, sum_iv, min, max, samples]
foreach ($rows as $r) {
$ts = (int) $r['ts'];
$t = (float) $r['t'];
$iv = max(1, (int) $r['iv']); // 防御:iv=0 视为 1s 权重
$hour = intdiv($ts, 3600) * 3600; // 整点对齐
if (!isset($buckets[$hour])) {
$buckets[$hour] = [
'sum_t_iv' => 0.0,
'sum_iv' => 0,
'min' => $t,
'max' => $t,
'samples' => 0,
];
}
$b = &$buckets[$hour];
$b['sum_t_iv'] += $t * $iv;
$b['sum_iv'] += $iv;
$b['samples'] += 1;
if ($t < $b['min']) $b['min'] = $t;
if ($t > $b['max']) $b['max'] = $t;
unset($b);
}
ksort($buckets);
if (count($buckets) > $limit) {
$buckets = array_slice($buckets, -$limit, null, true); // 只留最近 N 个桶
}
$out = [];
foreach ($buckets as $hour => $b) {
$out[] = [
'hour_ts' => $hour,
'avg' => $b['sum_iv'] > 0 ? round($b['sum_t_iv'] / $b['sum_iv'], 2) : null,
'min' => round($b['min'], 2),
'max' => round($b['max'], 2),
'samples' => $b['samples'],
'coverage_s' => $b['sum_iv'], // 桶内"被覆盖的秒数"
];
}
return $out;
}
}
+283
View File
@@ -0,0 +1,283 @@
<?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;
/**
* 设备共享服务(华为智慧生活式"家庭授权"模型)
*
* 关系:设备(owner) → 共享 → 成员(member)
* 权限分级:PERM_VIEW=1 查看状态(state 订阅 + 影子/日志查询)
* PERM_CTRL=2 查看+控制(另含 cmd 发布 + notify 告警订阅)
* 消息层放行:为被共享者的会员账号动态生成 ACL 规则(remark 前缀 auto:share:,可追踪、可整组清理),
* 与 DeviceService 的 auto:member: 规则同一套机制,共享撤销时按 remark 清掉。
*/
class ShareService
{
/** 只读:查看状态/影子/日志 */
public const PERM_VIEW = 1;
/** 查看+控制:订阅 state + 发布 cmd + 告警订阅 */
public const PERM_CTRL = 2;
/** 确保 share 表存在(自愈,老库/未执行 install.sql 也可用) */
public static function ensureTable(): bool
{
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
try {
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_share` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`device_id` int unsigned NOT NULL DEFAULT '0' COMMENT '设备ID(iot_device.id)',
`owner_id` int unsigned NOT NULL DEFAULT '0' COMMENT '设备主人会员ID',
`member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '被共享会员ID',
`perm` tinyint(1) NOT NULL DEFAULT '2' COMMENT '1只读 2可控制',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1生效 0撤销',
`create_at` int DEFAULT NULL,
`update_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_dev_member` (`device_id`, `member_id`),
KEY `idx_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
return true;
} catch (\Throwable $e) {
return false;
}
}
/**
* 建立/更新共享(仅设备主人可操作)
* @return array 共享记录
* @throws \InvalidArgumentException|\RuntimeException
*/
public static function share(int $deviceId, int $ownerId, int $memberId, int $perm = self::PERM_CTRL): array
{
self::ensureTable();
if ($deviceId <= 0 || $ownerId <= 0 || $memberId <= 0) {
throw new \InvalidArgumentException('参数无效');
}
if ($memberId === $ownerId) {
throw new \InvalidArgumentException('不能共享给自己');
}
if (!in_array($perm, [self::PERM_VIEW, self::PERM_CTRL], true)) {
$perm = self::PERM_CTRL;
}
$dev = DeviceService::findById($deviceId);
if (!$dev || (int) $dev['member_id'] !== $ownerId) {
throw new \RuntimeException('设备不存在或无权操作');
}
$now = time();
$row = Db::name('iot_share')
->where('device_id', $deviceId)->where('member_id', $memberId)
->find();
if ($row) {
Db::name('iot_share')->where('id', $row['id'])->update([
'perm' => $perm, 'status' => 1, 'update_at' => $now,
]);
$shareId = (int) $row['id'];
} else {
$shareId = (int) Db::name('iot_share')->insertGetId([
'device_id' => $deviceId, 'owner_id' => $ownerId, 'member_id' => $memberId,
'perm' => $perm, 'status' => 1, 'create_at' => $now, 'update_at' => $now,
]);
}
self::syncShareAcl((string) $dev['uuid'], $memberId, $perm);
return [
'id' => $shareId, 'device_id' => $deviceId, 'owner_id' => $ownerId,
'member_id' => $memberId, 'perm' => $perm, 'uuid' => (string) $dev['uuid'],
];
}
/** 撤销共享(仅设备主人可操作) */
public static function revoke(int $deviceId, int $ownerId, int $memberId): void
{
$dev = DeviceService::findById($deviceId);
if (!$dev || (int) $dev['member_id'] !== $ownerId) {
throw new \RuntimeException('设备不存在或无权操作');
}
Db::name('iot_share')
->where('device_id', $deviceId)->where('member_id', $memberId)
->delete();
self::clearShareAcl((string) $dev['uuid'], $memberId);
}
/** 某设备的共享成员列表(主人管理页用) */
public static function shareList(int $deviceId, int $ownerId): array
{
self::ensureTable();
$rows = Db::name('iot_share')
->where('device_id', $deviceId)->where('owner_id', $ownerId)
->where('status', 1)
->order('id', 'desc')
->select()
->toArray();
foreach ($rows as &$r) {
$r['account'] = DeviceService::memberAccount((int) $r['member_id']);
$r['perm'] = (int) $r['perm'];
}
return $rows;
}
/** 共享给我的设备列表(含在线状态 + 影子,剔除密钥) */
public static function sharedToMe(int $uid): array
{
self::ensureTable();
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
try {
$rows = Db::query(
"SELECT s.perm, s.create_at AS share_at, d.* FROM {$prefix}iot_share s "
. "INNER JOIN {$prefix}iot_device d ON d.id = s.device_id "
. "WHERE s.member_id = ? AND s.status = 1 ORDER BY s.id DESC",
[$uid]
);
} catch (\Throwable $e) {
return [];
}
$list = [];
foreach ($rows as $d) {
$list[] = [
'id' => (int) $d['id'],
'sn' => (string) $d['sn'],
'uuid' => (string) $d['uuid'],
'title' => (string) $d['title'],
'model' => (string) $d['model'],
'room' => (string) $d['room'],
'status' => (int) $d['status'],
'role' => 'shared',
'perm' => (int) $d['perm'],
'shared_by' => DeviceService::memberAccount((int) $d['owner_id']) ?: (string) $d['owner_id'],
'owner_id' => (int) $d['owner_id'],
'online' => DeviceService::isOnline((string) $d['uuid']),
'shadow' => DeviceService::shadow((string) $d['uuid']),
'topics' => DeviceService::deviceTopics((string) $d['uuid']),
'share_at' => (int) ($d['share_at'] ?? 0),
];
}
return $list;
}
/**
* 权限判断:主人恒通过;被共享者按 perm 分级。
* @param int $needPerm 需要的最低权限(PERM_VIEW 或 PERM_CTRL)
*/
public static function canAccess(string $uuid, int $uid, int $needPerm = self::PERM_VIEW): bool
{
if ($uid <= 0 || $uuid === '') {
return false;
}
$dev = DeviceService::findByUuid($uuid);
if (!$dev) {
return false;
}
if ((int) $dev['member_id'] === $uid) {
return true; // 主人
}
$share = Db::name('iot_share')
->where('device_id', (int) $dev['id'])
->where('member_id', $uid)
->where('status', 1)
->find();
return (bool) $share && (int) $share['perm'] >= $needPerm;
}
/** 设备删除时清理共享关系与 ACL(DeviceService::remove 调用) */
public static function removeByDevice(int $deviceId, string $uuid): void
{
try {
$members = Db::name('iot_share')->where('device_id', $deviceId)->column('member_id');
Db::name('iot_share')->where('device_id', $deviceId)->delete();
foreach ($members as $mid) {
self::clearShareAcl($uuid, (int) $mid);
}
} catch (\Throwable $e) {
}
}
/**
* 重新同步某会员「被共享」的全部设备 ACL(自愈,与 DeviceService::resyncMemberAcl 配套)
* 场景:新增自动 ACL 规则(如 /status 订阅)后,老共享关系不会自动补规则。
*
* @return int 同步的共享数
*/
public static function resyncMember(int $memberId): int
{
if ($memberId <= 0) {
return 0;
}
self::ensureTable();
$n = 0;
try {
$rows = Db::name('iot_share')
->where('member_id', $memberId)->where('status', 1)
->select()->toArray();
foreach ($rows as $r) {
$dev = DeviceService::findById((int) $r['device_id']);
if (!$dev || (string) ($dev['uuid'] ?? '') === '') {
continue;
}
$uuid = (string) $dev['uuid'];
// 已有 :status 规则则跳过(老 ACL 里没有才需要补)
$mark = 'auto:share:' . $uuid . ':' . $memberId . ':status';
$exist = Db::name('iot_acl')->where('remark', $mark)->count();
if ((int) $exist > 0) {
continue;
}
self::syncShareAcl($uuid, $memberId, (int) $r['perm']);
$n++;
}
} catch (\Throwable $e) {
}
return $n;
}
/* ============================================================
* 内部:ACL 维护(与 DeviceService 的 auto:member: 同机制)
* ========================================================== */
/** 生成共享 ACL:perm>=1 订阅 state;perm>=2 再给 cmd 发布 + notify 告警订阅 */
protected static function syncShareAcl(string $uuid, int $memberId, int $perm): void
{
$account = DeviceService::memberAccount($memberId);
if ($account === '') {
return; // 无会员账号则无法生成用户级 ACL(如幽灵账号),静默跳过
}
self::clearShareAcl($uuid, $memberId);
$now = time();
$rows = [[
'target_type' => 'user', 'target' => $account,
'topic' => sprintf(DeviceService::TOPIC_STATE, $uuid), 'access' => 1, 'allow' => 1,
'sort' => 200, 'remark' => 'auto:share:' . $uuid . ':' . $memberId . ':state', 'create_at' => $now,
], [
'target_type' => 'user', 'target' => $account,
'topic' => sprintf(DeviceService::TOPIC_STATUS, $uuid), 'access' => 1, 'allow' => 1,
'sort' => 200, 'remark' => 'auto:share:' . $uuid . ':' . $memberId . ':status', 'create_at' => $now,
]];
if ($perm >= self::PERM_CTRL) {
$rows[] = [
'target_type' => 'user', 'target' => $account,
'topic' => sprintf(DeviceService::TOPIC_CMD, $uuid), 'access' => 2, 'allow' => 1,
'sort' => 200, 'remark' => 'auto:share:' . $uuid . ':' . $memberId . ':cmd', 'create_at' => $now,
];
$rows[] = [
'target_type' => 'user', 'target' => $account,
'topic' => '/notify/' . $account . '/#', 'access' => 1, 'allow' => 1,
'sort' => 200, 'remark' => 'auto:share:' . $uuid . ':' . $memberId . ':notify', 'create_at' => $now,
];
}
Db::name('iot_acl')->insertAll($rows);
}
/** 清掉某成员在某设备上的共享 ACL(按 remark 前缀) */
protected static function clearShareAcl(string $uuid, int $memberId): void
{
Db::name('iot_acl')->where('remark', 'like', 'auto:share:' . $uuid . ':' . $memberId . '%')->delete();
}
}
+233
View File
@@ -0,0 +1,233 @@
<?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\iot\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_iot_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_iot_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_iot_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('iot_outbox')->insert([
'topic' => $topic,
'payload' => $payload,
'qos' => $qos,
'retain' => $retain ? 1 : 0,
'source' => $source,
'create_at' => time(),
'status' => 0,
]);
// 下发留痕(仅设备命令主题)
DeviceLogService::log($topic, $payload, 'out');
return true;
} catch (\Throwable $e) {
return false;
}
}
/**
* 取出待投递的出站消息(status=0)
* @return array
*/
public function popOutbox(int $limit = 100): array
{
$out = [];
try {
$out = Db::name('iot_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('iot_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('iot_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('iot_retain')->where('topic', $topic)->find();
if ($exists) {
Db::name('iot_retain')->where('id', $exists['id'])
->update(['payload' => $payload, 'qos' => $qos, 'update_at' => time()]);
} else {
Db::name('iot_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('iot_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('iot_offline')->insert([
'client_id' => $clientId, 'topic' => $topic,
'payload' => $payload, 'qos' => $qos, 'create_at' => time(),
]);
// 超限裁剪:保留最新 offlineLimit 条
$count = Db::name('iot_offline')->where('client_id', $clientId)->count();
if ($count > $this->offlineLimit) {
$ids = Db::name('iot_offline')->where('client_id', $clientId)
->order('id', 'asc')->limit($count - $this->offlineLimit)->column('id');
if ($ids) {
Db::name('iot_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('iot_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('iot_offline')->where('client_id', $clientId)->delete();
}
} catch (\Throwable $e) {
}
return $out;
}
}
+103
View File
@@ -0,0 +1,103 @@
<?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\iot\service;
use think\facade\Db;
/**
* MQTT 一次性连接凭证(ticket)
*
* 背景:App 直接用会员明文密码连 MQTT(allow_member_login)在对外分发场景
* 存在反编译泄露风险;本服务提供「短时效一次性凭证」替代:
* - App 登录后调 /iot/api/client/mqttTicket 换取 ticket(默认 60s 有效)
* - MQTT 连接时 username=会员账号、password=ticket
* - Auth::authenticate() 校验 ticket 表:存在、未过期、账号一致 → 放行并回填 member_id
* - 凭证过期自动失效,重连前 App 重新换取;无需会员密码参与 MQTT 认证
*
* 表:iot_ticket(自愈建表,见 ensureTable)
*/
class TicketService
{
/** 凭证有效期(秒):需覆盖 App 一次连接建立 + 一次短重连 */
const TTL = 60;
/**
* 自愈建表(幂等)
*/
public static function ensureTable(): void
{
try {
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_ticket` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`ticket` varchar(64) NOT NULL DEFAULT '' COMMENT '一次性凭证(48位hex)',
`member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '会员ID',
`username` varchar(64) NOT NULL DEFAULT '' COMMENT '会员账号(MQTT连接用户名)',
`expire_at` int NOT NULL DEFAULT '0' COMMENT '过期时间戳',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), UNIQUE KEY `uk_ticket` (`ticket`), KEY `idx_expire` (`expire_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
} catch (\Throwable $e) {
}
}
/**
* 签发凭证(每次连接前调用,一次一签)
* @return string 48 位十六进制凭证
*/
public static function issue(int $memberId, string $username, int $ttl = self::TTL): string
{
self::ensureTable();
self::cleanup();
$ticket = bin2hex(random_bytes(24));
$now = time();
Db::name('iot_ticket')->insert([
'ticket' => $ticket,
'member_id' => $memberId,
'username' => $username,
'expire_at' => $now + max(10, (int) $ttl),
'create_at' => $now,
]);
return $ticket;
}
/**
* 校验凭证:存在、未过期、且与连接用户名绑定一致
* @return int 关联会员ID(0=无效)
*/
public static function verify(string $username, string $ticket): int
{
if ($ticket === '' || $username === '') {
return 0;
}
self::ensureTable();
$row = Db::name('iot_ticket')->where('ticket', $ticket)->find();
if (!$row) {
return 0;
}
if ((int) $row['expire_at'] < time()) {
return 0;
}
if ((string) $row['username'] !== $username) {
return 0;
}
return (int) $row['member_id'];
}
/**
* 清理过期凭证(低频,签发时顺手调用)
*/
public static function cleanup(): void
{
try {
Db::name('iot_ticket')->where('expire_at', '<', time())->delete();
} catch (\Throwable $e) {
}
}
}