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

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