Files
YwxAppThink/addon/iot/service/SensorPointService.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

912 lines
41 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;
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;
}
}