Files
YwxAppThink/addon/iot/controller/api/Monitor.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

332 lines
15 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
/*
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-09-18 01:39:38
* @LastEditors: YwxApp <ywx@ywxapp.cn>
* @LastEditTime: 2026-09-18 01:41:50
* @Description:
* @FilePath: \ywxapp_dev\addon\iot\controller\api\Monitor.php
* @CustomString: Copyright (c) 2026 YwxApp
*/
// +----------------------------------------------------------------------
// | 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\controller\api;
use think\facade\Db;
use addon\iot\service\DeviceService;
use addon\iot\service\ShareService;
use addon\iot\service\AlertService;
use addon\iot\service\DeviceLogService;
use addon\iot\service\SensorConfigService;
use addon\iot\service\SensorPointService;
/**
* App 端监测域 API(需框架会员 JWT 登录)
* 路由前缀:/iot/api/monitor/...(监测域;旧 /iot/api/client/* 兼容别名迁移期保留)
*
* 覆盖:传感器位置配置(读写)· 历史曲线 · 设备日志 · 告警。
* 多路数值品类(温湿度/液位/气压等)共用本域,见 ClientBase 头注释的拆分原则。
*/
class Monitor extends ClientBase
{
/* ============================================================
* 传感器配置(配置真源在服务端:App 用它渲染面板骨架,state 只负责填值)
* ========================================================== */
/**
* 读取设备传感器配置
* GET /iot/api/monitor/deviceConfig?uuid=xxx
*
* 返回 {cfg_ver, sync_ver, sync_at, applied, pins, sensors:[{index,alias,probes,upper,lower,gap,enabled}]}
* - 设备离线时同样可用(这正是配置放服务端的核心原因)
* - applied=false 表示设备尚未回执生效(离线时改的配置,等它上线自动收到)
* - pins:总线 GPIO 表(下标 = 位置序号)。pin 是设备编译期常量,v1.7.0 起不在
* state 报文里逐条上报,改由设备上线时在 /status 报一次、服务端缓存后在此下发;
* 从没上报过(≤1.6.2 老固件)返回 [],App 此时靠 state 里的 pin 自行补
*/
public function deviceConfig()
{
$uid = $this->memberId();
$uuid = trim((string) $this->request->get('uuid', ''));
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
if (!DeviceService::findByUuid($uuid)) {
return $this->result->apiError('设备不存在');
}
if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_VIEW)) {
return $this->result->apiError('无权查看该设备');
}
$cfg = SensorConfigService::get($uuid);
$cfg['uuid'] = $uuid;
/* 总线 GPIO 表(下标 = 位置序号):pin 是设备编译期常量,不再逐条进 state 报文,
由设备上线时在 /status 报一次 → DeviceService 缓存 → 这里下发给 App 显示。
没缓存过(≤1.6.2 老固件)返回 [],App 退回用 state 里带的 pin(老固件仍会带)。 */
$cfg['pins'] = DeviceService::getPins($uuid);
$this->result->apiSuccess($cfg);
}
/**
* 保存设备传感器配置(保存即下发 retained,设备在线秒级生效、离线则上线即生效)
* POST /iot/api/monitor/saveDeviceConfig
* param: uuid(必填), sensors(JSON 数组:{index,alias,probes,upper,lower,gap,enabled})
* ⚠️ 位置序号键统一 `index`;旧键 `i` 仍兼容(老 App 提交不会错位),别删兼容分支
*/
public function saveDeviceConfig()
{
$uid = $this->memberId();
$uuid = trim((string) $this->request->post('uuid', ''));
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_CTRL)) {
return $this->result->apiError('无权限修改该设备配置(只读共享)');
}
$sensors = $this->request->post('sensors', '');
if (is_string($sensors)) {
$sensors = json_decode($sensors, true);
}
if (!is_array($sensors)) {
return $this->result->apiError('sensors 参数须为数组或 JSON 数组字符串');
}
try {
$cfg = SensorConfigService::save($uuid, $sensors);
} catch (\Throwable $e) {
return $this->result->apiError($e->getMessage());
}
$cfg['uuid'] = $uuid;
$this->result->apiSuccess($cfg, '配置已保存并下发(设备上线后自动生效)');
}
/**
* 传感器历史曲线(服务端结构化时序)
* GET /iot/api/monitor/sensorSeries?uuid=xxx&pos=0&from=1700000000&to=1700003600&limit=500
*
* 返回「稀疏点」(每条带 interval = 本条覆盖秒数);需要等间隔曲线时由客户端按
* ts → ts+interval 前向填充展开。不在服务端展开的原因:温变上报 10s~120s 一条,
* 展开会放大数倍且大量是重复值,纯浪费带宽。
*
* 每条形状**刻意沿用 HTTP 长名**(temp/temp_diff/probes[{id,temp}],null 即无读数)——
* 与 MQTT 上行报文的两字母键(t/df/pr)是两套:压字节只发生在设备↔服务端之间,
* 服务端往外(HTTP / App / 后台)一律用可读长名,换报文键名不必动 App 的 HTTP 链路。
* alert_mask 为云端复核告警位图(bit j = 槽位 j 越限,bit7 = 位置内温差超限),
* 供 App 标注异常区间。
*/
public function sensorSeries()
{
$uid = $this->memberId();
$uuid = trim((string) $this->request->get('uuid', ''));
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
if ($uuid === '' || !DeviceService::findByUuid($uuid)) {
return $this->result->apiError('设备不存在');
}
if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_VIEW)) {
return $this->result->apiError('无权查看该设备');
}
$pos = (int) $this->request->get('pos', 0);
$from = (int) $this->request->get('from', 0);
$to = (int) $this->request->get('to', 0);
$limit = (int) $this->request->get('limit', 500);
$points = SensorPointService::series($uuid, $pos, $from, $to, $limit);
$data = [
'uuid' => $uuid,
'pos' => $pos,
'from' => $from,
'to' => $to,
'count' => count($points),
'points' => $points,
];
/* debug=1:附查询诊断(库名/表名/实际 SQL/各条件行数/最新原始行/异常原文)。
用于定位「接口 count=0 但直接跑 SQL 有数据」这类静默故障;正常调用不受影响。 */
if ((int) $this->request->get('debug', 0) === 1) {
$data['debug'] = SensorPointService::seriesDebug($uuid, $pos, $from, $to);
}
return $this->result->apiSuccess($data);
}
/**
* 传感器分钟级聚合曲线(时间加权平均)
* GET /iot/api/monitor/sensorAvg?uuid=xxx&pos=0&step=60&window=30&from=&to=&limit=720
*
* - step:桶大小(秒),默认 60 = 每分钟一点;可传 60/120/300/3600…,服务端钳到 [60, 86400]
* - window:查"最近 N 分钟",默认 30;**仅在未传 from 时生效**(from = now - window*60)
* —— 典型用法:最近 30 分钟每分钟一点(step=60&window=30)、最近 60 分钟每分钟一点(step=60&window=60)
* - from/to:显式时间戳(秒),传了优先于 window;to 缺省 = now
* - limit:最多返回桶数(默认 720,即 step=60 时最多覆盖 12 小时)
* - debug=1:附加查询诊断(库名/表名/实际 SQL/各条件行数/最新原始行/异常原文),
* 用于定位「直接跑 SQL 有数据、本接口却空」;正常调用不传即零开销
* - avg 为时间加权平均(SUM(t×秒)/SUM(秒)),min/max 为桶内极值,coverage_s 为桶内数据覆盖秒数
* (coverage_s < step 说明该分钟内有断档,App 可据此画"数据缺口")
* - ts 为桶起点(UTC 对齐),App 显示时按本地时区格式化
*
* 与 sensorSeries 的分工:sensorSeries 返回原始稀疏点(细粒度);本接口返回聚合桶(点数可控)。
*/
public function sensorAvg()
{
$uid = $this->memberId();
$uuid = trim((string) $this->request->get('uuid', ''));
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
if ($uuid === '' || !DeviceService::findByUuid($uuid)) {
return $this->result->apiError('设备不存在');
}
if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_VIEW)) {
return $this->result->apiError('无权查看该设备');
}
$pos = (int) $this->request->get('pos', 0);
$from = (int) $this->request->get('from', 0);
$to = (int) $this->request->get('to', 0);
$step = (int) $this->request->get('step', 60);
$window = (int) $this->request->get('window', 30);
$limit = (int) $this->request->get('limit', 720);
if ($from <= 0 && $window > 0) {
$from = time() - $window * 60; // window 仅在未显式传 from 时生效
}
if ($to <= 0) {
$to = time();
}
$buckets = SensorPointService::seriesAvg($uuid, $pos, $step, $from, $to, $limit);
$data = [
'uuid' => $uuid,
'pos' => $pos,
'step' => $step,
'from' => $from,
'to' => $to,
'count' => count($buckets),
'points' => $buckets,
];
/* debug=1:与 sensorSeries 同一套诊断(库名/表名/实际 SQL/各条件行数/最新原始行/异常原文)。
「SQL 明明有数据、聚合接口却 count=0」时先看这里,不用再猜。 */
if ((int) $this->request->get('debug', 0) === 1) {
$data['debug'] = SensorPointService::seriesDebug($uuid, $pos, $from, $to);
}
return $this->result->apiSuccess($data);
}
/**
* 最近 N 分钟窗口平均(按位置汇总)
* GET /iot/api/monitor/recentAvg?uuid=xxx&window=10&pos=
*
* 与 sensorAvg 的分工:
* - sensorAvg 返回按 step 分桶的曲线序列(适合画趋势)
* - recentAvg 返回每个位置一条汇总值(首屏概览 / 阈值刷新 / 排障都够用)
*
* - window:最近多少分钟,默认 10,范围 [1, 1440](被服务端钳位)
* - pos:可选,只返回某个位置(>=0),默认 -1 = 全部位置
* - 每位置返回 {index, alias, avg, min, max, samples, coverage_s, last_ts, data_ok}
* 无样本的位置 avg=null 且 data_ok=false(前端可据此显示「无数据」而非「0 度」)
*
* 加权方式与 sensorAvg 一致:SUM(t×有效秒)/SUM(秒),避免温变期间简单 AVG 失真。
*/
public function recentAvg()
{
$uid = $this->memberId();
$uuid = trim((string) $this->request->get('uuid', ''));
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
if ($uuid === '' || !DeviceService::findByUuid($uuid)) {
return $this->result->apiError('设备不存在');
}
if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_VIEW)) {
return $this->result->apiError('无权查看该设备');
}
$window = (int) $this->request->get('window', 10);
$pos = (int) $this->request->get('pos', -1);
$result = SensorPointService::recentAvg($uuid, $window, $pos);
$result['uuid'] = $uuid;
$result['window_min'] = $window;
return $this->result->apiSuccess($result);
}
/* ============================================================
* 设备日志 & 告警(P1:基础留痕与通知)
* ========================================================== */
/**
* 设备日志分页(设备维度留痕)
* GET /iot/api/monitor/deviceLogs?uuid=xxx&page=1&size=20&direction=in|out
*/
public function deviceLogs()
{
$uid = $this->memberId();
$uuid = trim((string) $this->request->get('uuid', ''));
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
if ($uuid === '') {
return $this->result->apiError('设备uuid不能为空');
}
$dev = DeviceService::findByUuid($uuid);
if (!$dev) {
return $this->result->apiError('设备不存在');
}
if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_VIEW)) {
return $this->result->apiError('无权查看该设备');
}
$page = max(1, (int) $this->request->get('page', 1));
$size = min(100, max(1, (int) $this->request->get('size', 20)));
$direction = trim((string) $this->request->get('direction', ''));
$data = DeviceLogService::page($uuid, $page, $size, $direction);
return $this->result->apiSuccess($data);
}
/**
* 我的告警列表
* GET /iot/api/monitor/alertList?page=1&size=20&unread=0|1
*/
public function alertList()
{
$uid = $this->memberId();
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
$page = max(1, (int) $this->request->get('page', 1));
$size = min(100, max(1, (int) $this->request->get('size', 20)));
$onlyUnread = (int) $this->request->get('unread', 0) === 1;
$data = AlertService::page($uid, $page, $size, $onlyUnread);
return $this->result->apiSuccess($data);
}
/**
* 标记告警已读(空 ids=全部)
* POST /iot/api/monitor/readAlert
* param: ids[](可选)
*/
public function readAlert()
{
$uid = $this->memberId();
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
$ids = $this->request->post('ids/a', []);
$n = AlertService::markRead($uid, $ids);
return $this->result->apiSuccess(['affected' => $n]);
}
/**
* 未读告警数(App 角标轮询)
* GET /iot/api/monitor/alertUnread
*/
public function alertUnread()
{
$uid = $this->memberId();
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
$data = AlertService::page($uid, 1, 1, true);
return $this->result->apiSuccess(['unread' => $data['unread']]);
}
}