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

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

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

623 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace addon\iot\service;
use think\facade\Db;
/**
* 场景/自动化引擎(智慧生活核心)
*
* 触发三类:
* - 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];
}
}