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

+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];
}
}