安全(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(市场 / 开发者中心 / 审核 / 分润)
623 lines
26 KiB
PHP
623 lines
26 KiB
PHP
<?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];
|
||
}
|
||
}
|