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:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -0,0 +1,619 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* 传感器位置配置服务 —— 配置的唯一真源(Single Source of Truth)
|
||||
*
|
||||
* 为什么配置不放报文里:
|
||||
* 1) 位置列表必须在设备离线时也能渲染(App 打开详情页不能空白),只能来自服务端;
|
||||
* 2) alias/upper/lower/gap 是「月级」静态数据,塞进秒级 state 报文里是纯浪费(约占报文体积一半)。
|
||||
*
|
||||
* 三方一致(服务端 → 设备 → App):
|
||||
* 后台/App 保存 → iot_sensor_cfg(真源)+ iot_device.cfg_ver++
|
||||
* → publish retained /devices/{uuid}/config(带 cfg_ver)
|
||||
* → 设备写 NVS + 回 /devices/{uuid}/ack 的 config_ack(带 cfg_ver)
|
||||
* → 后台/App 比对 cfg_ver 与 sync_ver 显示「已生效 / 待设备上线生效」
|
||||
* App 另经 HTTP 读同一份配置渲染骨架,state 只负责填值。
|
||||
*
|
||||
* 字段约定:对外统一 index/alias/probes/upper/lower/gap/enabled(index = 位置序号,App 按 index 对齐而非数组顺序)
|
||||
*/
|
||||
class SensorConfigService
|
||||
{
|
||||
/** 配置下发主题(retained,设备订阅;设备 ACL `/devices/%u/#` 已覆盖) */
|
||||
public const TOPIC_CONFIG = '/devices/%s/config';
|
||||
/** 设备应答主题(非 retained) */
|
||||
public const TOPIC_ACK = '/devices/%s/ack';
|
||||
|
||||
/** 位置数上限(与固件 OneWire 总线数 A/B/C 一致) */
|
||||
public const MAX_POS = 3;
|
||||
/** 单位置探头数上限 */
|
||||
public const MAX_PROBE = 3;
|
||||
|
||||
/** 默认超温上限(℃) */
|
||||
public const DEF_UPPER = 85.0;
|
||||
/** 默认低温下限(℃) */
|
||||
public const DEF_LOWER = 0.0;
|
||||
/** 默认位置内温差告警阈值(0=关闭) */
|
||||
public const DEF_GAP = 0.0;
|
||||
|
||||
public static function configTopic(string $uuid): string
|
||||
{
|
||||
return sprintf(self::TOPIC_CONFIG, $uuid);
|
||||
}
|
||||
|
||||
public static function ackTopic(string $uuid): string
|
||||
{
|
||||
return sprintf(self::TOPIC_ACK, $uuid);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 表 / 列自愈
|
||||
* ========================================================== */
|
||||
|
||||
/** 确保 iot_sensor_cfg 表存在(老库/未执行 install.sql 亦可用) */
|
||||
public static function ensureTable(): bool
|
||||
{
|
||||
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
|
||||
try {
|
||||
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_sensor_cfg` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`device_uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '设备uuid',
|
||||
`pos_index` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '位置序号(OneWire总线A/B/C,0起)',
|
||||
`alias` varchar(32) NOT NULL DEFAULT '' COMMENT '位置名称(如 进水/回水)',
|
||||
`probes` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '该位置探头数(1~3)',
|
||||
`upper` decimal(6,2) NOT NULL DEFAULT '85.00' COMMENT '超温上限(℃)',
|
||||
`lower` decimal(6,2) NOT NULL DEFAULT '0.00' COMMENT '低温下限(℃)',
|
||||
`gap` decimal(6,2) NOT NULL DEFAULT '0.00' COMMENT '位置内温差告警阈值(0=关闭)',
|
||||
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0停用',
|
||||
`create_at` int DEFAULT NULL,
|
||||
`update_at` int DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_dev_pos` (`device_uuid`,`pos_index`),
|
||||
KEY `idx_device` (`device_uuid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='传感器位置配置表(配置真源)'");
|
||||
DeviceService::ensureColumns();
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 读取
|
||||
* ========================================================== */
|
||||
|
||||
/**
|
||||
* 读取某设备配置(含同步状态,供 App / 后台 / 下发共用)
|
||||
*
|
||||
* @param bool $onlyEnabled 仅返回启用的位置(下发设备时用)
|
||||
* @return array{cfg_ver:int,sync_ver:int,sync_at:int,applied:bool,sensors:array}
|
||||
*/
|
||||
public static function get(string $uuid, bool $onlyEnabled = false): array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($uuid === '') {
|
||||
return ['cfg_ver' => 0, 'sync_ver' => 0, 'sync_at' => 0, 'applied' => false, 'sensors' => []];
|
||||
}
|
||||
self::ensureTable();
|
||||
$dev = Db::name('iot_device')->where('uuid', $uuid)->find() ?: [];
|
||||
$ver = (int) ($dev['cfg_ver'] ?? 0);
|
||||
$sync = (int) ($dev['sync_ver'] ?? 0);
|
||||
|
||||
$q = Db::name('iot_sensor_cfg')->where('device_uuid', $uuid);
|
||||
if ($onlyEnabled) {
|
||||
$q->where('enabled', 1);
|
||||
}
|
||||
$rows = $q->order('pos_index', 'asc')->select()->toArray();
|
||||
$sensors = [];
|
||||
foreach ($rows as $r) {
|
||||
$sensors[] = self::format($r);
|
||||
}
|
||||
return [
|
||||
'cfg_ver' => $ver,
|
||||
'sync_ver' => $sync,
|
||||
'sync_at' => (int) ($dev['sync_at'] ?? 0),
|
||||
'applied' => $ver > 0 && $sync >= $ver,
|
||||
'sensors' => $sensors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量取多个设备的配置(按 uuid 分组)—— 供**设备列表**接口内联配置用
|
||||
*
|
||||
* 为什么要批量:列表接口是 N 台设备一把返回,若在 foreach 里逐台调 get() 就是 N+1
|
||||
* (get() 自己还要各查一次 iot_device)⇒ 设备一多就把接口拖垮。
|
||||
* 这里固定 2 条 SQL:一条查版本、一条查位置配置,与设备台数无关。
|
||||
*
|
||||
* @param string[] $uuids
|
||||
* @return array<string, array> [uuid => ['cfg_ver'=>int,'sync_ver'=>int,'sync_at'=>int,'applied'=>bool,'sensors'=>array]]
|
||||
*/
|
||||
public static function getAll(array $uuids, bool $onlyEnabled = false): array
|
||||
{
|
||||
$clean = [];
|
||||
foreach ($uuids as $u) {
|
||||
$u = trim((string) $u);
|
||||
if ($u !== '' && !isset($clean[$u])) {
|
||||
$clean[$u] = true;
|
||||
}
|
||||
}
|
||||
$out = [];
|
||||
if (!$clean) {
|
||||
return $out;
|
||||
}
|
||||
$uuids = array_keys($clean);
|
||||
self::ensureTable();
|
||||
|
||||
// 缺省值:任何一台设备都要有这几个键,App 端才能无脑取值
|
||||
foreach ($uuids as $u) {
|
||||
$out[$u] = ['cfg_ver' => 0, 'sync_ver' => 0, 'sync_at' => 0, 'applied' => false, 'sensors' => []];
|
||||
}
|
||||
|
||||
// 1) 版本信息(iot_device.cfg_ver / sync_ver)—— 一次查完
|
||||
try {
|
||||
$devs = Db::name('iot_device')
|
||||
->whereIn('uuid', $uuids)
|
||||
->field('uuid,cfg_ver,sync_ver,sync_at')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($devs as $d) {
|
||||
$u = (string) $d['uuid'];
|
||||
if (!isset($out[$u])) {
|
||||
continue;
|
||||
}
|
||||
$ver = (int) ($d['cfg_ver'] ?? 0);
|
||||
$sync = (int) ($d['sync_ver'] ?? 0);
|
||||
$out[$u]['cfg_ver'] = $ver;
|
||||
$out[$u]['sync_ver'] = $sync;
|
||||
$out[$u]['sync_at'] = (int) ($d['sync_at'] ?? 0);
|
||||
$out[$u]['applied'] = $ver > 0 && $sync >= $ver;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 老库缺列:保持缺省值,绝不因为配置信息拖垮设备列表
|
||||
}
|
||||
|
||||
// 2) 位置配置(iot_sensor_cfg)—— 一次查完,再按 uuid 分组
|
||||
try {
|
||||
$q = Db::name('iot_sensor_cfg')->whereIn('device_uuid', $uuids);
|
||||
if ($onlyEnabled) {
|
||||
$q->where('enabled', 1);
|
||||
}
|
||||
$rows = $q->order('pos_index', 'asc')->select()->toArray();
|
||||
foreach ($rows as $r) {
|
||||
$u = (string) $r['device_uuid'];
|
||||
if (!isset($out[$u])) {
|
||||
continue;
|
||||
}
|
||||
// format() 是服务端统一出口:index/alias/probes/upper/lower/gap/enabled
|
||||
// ⇒ 列表、deviceConfig 接口两处的字段完全一致,App 端可共用一套解析
|
||||
$out[$u]['sensors'][] = self::format($r);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// iot_sensor_cfg 不存在(老库)等:留空数组,App 端会回退到 deviceConfig 接口
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条配置对外格式(App / 后台 / 设备列表共用同一出口)
|
||||
*
|
||||
* 🔑 位置序号对外统一叫 `index`(2026-09-15 统一)—— 与下行 config 报文的键名一致,
|
||||
* 也避免单字母键在 HTTP JSON 里看不出含义。
|
||||
* ⚠️ 改这个键名必须**同步改三处消费方**,否则位置序号会静默变 0(不报错、只是错):
|
||||
* SensorPointService(按位置取阈值落库)、App device-panel、后台 device.html。
|
||||
* ⚠️ 下行 config 报文里的位置序号**同样叫 `index`**(2026-09-15 与 HTTP 字段统一,且只有这
|
||||
* 一个键)。它也是**下行独有**的字段:上行 state 靠数组下标定位(省 10B/位置),而下行
|
||||
* 只发启用位置、下标与序号会错开 ⇒ `index` 必须带。详见 push() 的注释。
|
||||
*/
|
||||
protected static function format(array $r): array
|
||||
{
|
||||
return [
|
||||
'index' => (int) $r['pos_index'],
|
||||
'alias' => (string) $r['alias'],
|
||||
'probes' => (int) $r['probes'],
|
||||
'upper' => (float) $r['upper'],
|
||||
'lower' => (float) $r['lower'],
|
||||
'gap' => (float) $r['gap'],
|
||||
'enabled' => (int) $r['enabled'],
|
||||
'update_at' => (int) $r['update_at'],
|
||||
];
|
||||
}
|
||||
|
||||
/** 当前服务端配置版本(iot_device.cfg_ver) */
|
||||
public static function ver(string $uuid): int
|
||||
{
|
||||
try {
|
||||
return (int) (Db::name('iot_device')->where('uuid', $uuid)->value('cfg_ver') ?? 0);
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 保存 / 删除
|
||||
* ========================================================== */
|
||||
|
||||
/**
|
||||
* 保存设备配置(全量覆盖式:后台弹窗即整体编辑语义,简单且不会残留脏位置)
|
||||
*
|
||||
* @param array $sensors [['index'=>0,'alias'=>'进水','probes'=>2,'upper'=>85,'lower'=>0,'gap'=>3], ...]
|
||||
* @param bool $push 保存后是否立即下发(retained)
|
||||
* @return array 保存后的完整配置
|
||||
* @throws \InvalidArgumentException|\RuntimeException
|
||||
*/
|
||||
public static function save(string $uuid, array $sensors, bool $push = true): array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($uuid === '') {
|
||||
throw new \InvalidArgumentException('设备 uuid 不能为空');
|
||||
}
|
||||
self::ensureTable();
|
||||
$dev = Db::name('iot_device')->where('uuid', $uuid)->find();
|
||||
if (!$dev) {
|
||||
throw new \RuntimeException('设备不存在');
|
||||
}
|
||||
if (empty($sensors)) {
|
||||
throw new \InvalidArgumentException('至少需要保留一个传感器位置');
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
foreach ($sensors as $idx => $s) {
|
||||
$row = self::normalize($s, $idx);
|
||||
$clean[$row['pos_index']] = $row; // 同序号后写覆盖
|
||||
}
|
||||
ksort($clean);
|
||||
if (count($clean) > self::MAX_POS) {
|
||||
throw new \InvalidArgumentException('位置数超出上限(最多 ' . self::MAX_POS . ' 个)');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
Db::startTrans();
|
||||
try {
|
||||
Db::name('iot_sensor_cfg')->where('device_uuid', $uuid)->delete();
|
||||
$rows = [];
|
||||
foreach ($clean as $row) {
|
||||
$row['device_uuid'] = $uuid;
|
||||
$row['create_at'] = $now;
|
||||
$row['update_at'] = $now;
|
||||
$rows[] = $row;
|
||||
}
|
||||
Db::name('iot_sensor_cfg')->insertAll($rows);
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
throw new \RuntimeException('保存配置失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
$ver = self::bumpVer($uuid);
|
||||
if ($push) {
|
||||
self::push($uuid, $ver);
|
||||
}
|
||||
return self::get($uuid);
|
||||
}
|
||||
|
||||
/** 归一化并校验单个位置(非法值自动纠正而非报错,避免后台弹窗体验割裂) */
|
||||
protected static function normalize($s, int $fallbackIndex): array
|
||||
{
|
||||
$s = (array) $s;
|
||||
/* 位置序号:get/save 对外统一 `index`;`i`(App ≤2026-09-15 提交的旧键)与
|
||||
`pos_index`(DB 列名)继续认,不要删 —— 少认一个键就会静默回退成「数组下标」,
|
||||
而 App 提交顺序一变位置就整体错位,更糟的是多个位置会挤到同一序号、
|
||||
被 save() 的全量覆盖语义合并成一个(2026-09-15 现场事故)。 */
|
||||
if (isset($s['index'])) {
|
||||
$pos = (int) $s['index'];
|
||||
} elseif (isset($s['i'])) {
|
||||
$pos = (int) $s['i'];
|
||||
} elseif (isset($s['pos_index'])) {
|
||||
$pos = (int) $s['pos_index'];
|
||||
} else {
|
||||
$pos = $fallbackIndex;
|
||||
}
|
||||
if ($pos < 0 || $pos >= self::MAX_POS) {
|
||||
throw new \InvalidArgumentException('位置序号超出范围(0 ~ ' . (self::MAX_POS - 1) . ')');
|
||||
}
|
||||
$alias = trim((string) ($s['alias'] ?? ($s['title'] ?? '')));
|
||||
if ($alias === '') {
|
||||
$alias = '位置' . ($pos + 1);
|
||||
}
|
||||
if (mb_strlen($alias) > 16) {
|
||||
$alias = mb_substr($alias, 0, 16);
|
||||
}
|
||||
/* 探头数:HTTP 入口沿用长名 probes,顺带认 pn(与 MQTT 下行的新键同名,方便将来统一) */
|
||||
$probes = (int) ($s['probes'] ?? $s['pn'] ?? 1);
|
||||
if ($probes < 1) {
|
||||
$probes = 1;
|
||||
}
|
||||
if ($probes > self::MAX_PROBE) {
|
||||
$probes = self::MAX_PROBE;
|
||||
}
|
||||
$upper = round((float) ($s['upper'] ?? self::DEF_UPPER), 2);
|
||||
$lower = round((float) ($s['lower'] ?? self::DEF_LOWER), 2);
|
||||
$gap = round((float) ($s['gap'] ?? self::DEF_GAP), 2);
|
||||
if ($lower > $upper) { // 上下限填反 → 自动交换
|
||||
$tmp = $upper;
|
||||
$upper = $lower;
|
||||
$lower = $tmp;
|
||||
}
|
||||
if ($gap < 0) {
|
||||
$gap = 0.0;
|
||||
}
|
||||
$enabled = isset($s['enabled']) ? ((int) $s['enabled'] ? 1 : 0) : 1;
|
||||
return [
|
||||
'pos_index' => $pos,
|
||||
'alias' => $alias,
|
||||
'probes' => $probes,
|
||||
'upper' => $upper,
|
||||
'lower' => $lower,
|
||||
'gap' => $gap,
|
||||
'enabled' => $enabled,
|
||||
];
|
||||
}
|
||||
|
||||
/** 配置版本自增(设备 ack 的 sync_ver 与之比对即知是否生效) */
|
||||
public static function bumpVer(string $uuid): int
|
||||
{
|
||||
DeviceService::ensureColumns();
|
||||
$dev = Db::name('iot_device')->where('uuid', $uuid)->find();
|
||||
if (!$dev) {
|
||||
throw new \RuntimeException('设备不存在');
|
||||
}
|
||||
$ver = (int) ($dev['cfg_ver'] ?? 0) + 1;
|
||||
Db::name('iot_device')->where('id', $dev['id'])->update([
|
||||
'cfg_ver' => $ver, 'update_at' => time(),
|
||||
]);
|
||||
return $ver;
|
||||
}
|
||||
|
||||
/** 删除某设备全部配置(删除设备时清理) */
|
||||
public static function removeByUuid(string $uuid): void
|
||||
{
|
||||
try {
|
||||
Db::name('iot_sensor_cfg')->where('device_uuid', $uuid)->delete();
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 下发 / 回执
|
||||
* ========================================================== */
|
||||
|
||||
/**
|
||||
* 下发配置到设备(retained → 设备离线时也会在重连订阅瞬间收到,无需服务端重推)
|
||||
*
|
||||
* @param int|null $ver 指定版本号,null 取当前 cfg_ver
|
||||
*/
|
||||
public static function push(string $uuid, ?int $ver = null): bool
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($uuid === '') {
|
||||
return false;
|
||||
}
|
||||
$cfg = self::get($uuid, true);
|
||||
if ($ver === null) {
|
||||
$ver = $cfg['cfg_ver'];
|
||||
}
|
||||
/* 🛡 空配置绝不下发 —— 这是一条能「把设备打瘫且双方都不知情」的路径:
|
||||
固件 onConfigJson 的语义是「先 POS_MAX 个位置全置 used=false,再按下发的数组逐个启用」
|
||||
⇒ 收到 pos:[] 就等于**把 NVS 里的配置整体清空**,此后设备每周期上报 pos:[],
|
||||
而它 ack 的还是那条 cv(成功)⇒ 服务端 sync_ver == cfg_ver ⇒ 后台显示「已生效」,
|
||||
双方都以为没事,**没有任何自动纠正的途径**(只能靠人再保存一次配置)。
|
||||
所以真源一行都没有时(被清空/误删)宁可不下发 —— 该做的是补齐配置,不是把设备打瘫。
|
||||
注意「把位置全停用」不在此列:那是用户显式意图,数组非空、照发。 */
|
||||
if (!$cfg['sensors'] && !self::get($uuid)['sensors']) {
|
||||
return false;
|
||||
}
|
||||
/* 报文(v1.7.2 起只剩这一种版式,键名与上行 state 完全一致):
|
||||
* {"cv":7,"pos":[{"index":0,"alias":"进水","pn":2,"upper":85,"lower":0,"gap":3}]}
|
||||
*
|
||||
* 🔑 这里曾经叠了三层「灰度兼容」,2026-09-15 全部删除 —— 现场只有两台自用设备、
|
||||
* 且都会重烧固件 ⇒ 兼容的收益是 0,代价却是一个字段挂两把钥匙:
|
||||
* ① 键名长短不一:下行写长名 positions/probes、上行写 pos/pr ⇒ 现在统一为短键;
|
||||
* ② 数组复制两份:多出一份旧式 data[](≤1.4.0 只认它)⇒ 现在只有 pos;
|
||||
* ③ 标量新旧同发:cfg_ver/probes 与 cv/pn 同值双发 ⇒ 现在只有 cv/pn。
|
||||
* 教训:每多一个「同义键」就多一把备用钥匙,改键名时只改一边就**静默拆锁**
|
||||
* (2026-09-15 现场:位置序号全变 0、数据全填位置 0、编辑一行改掉所有行)。
|
||||
* 🔴 升级顺序:**先烧固件 ≥1.7.2,再上线本改动**。旧固件只认 positions/data 与
|
||||
* cfg_ver/probes,收到本条报文会 ack "missing pos"(配置不生效,但**不会误报成功**)
|
||||
* —— 一眼就能看出来,总好过以前那种「ack 成功、配置没落地」的静默错。
|
||||
* 语义不变:全量覆盖,数组里没有的位置一律停用。 */
|
||||
$pos = [];
|
||||
foreach ($cfg['sensors'] as $s) {
|
||||
/* ⚠️ `index` 必须带:sensors 是**只含启用位置**的(get(uuid, true)),
|
||||
所以数组下标 ≠ 位置序号 —— 例如位置 0 停用、位置 2 启用,它会排在下标 0 上。
|
||||
这就是「上行没有 index、下行必须有 index」的原因(上行靠下标定位、且未启用
|
||||
位置写 null 占位把下标撑住;下行不发废位置,下标会塌缩)。 */
|
||||
$pos[] = [
|
||||
'index' => $s['index'],
|
||||
'alias' => $s['alias'],
|
||||
'pn' => $s['probes'], // 该位置「期望探头数」(1 ~ MAX_PROBE)
|
||||
'upper' => $s['upper'],
|
||||
'lower' => $s['lower'],
|
||||
'gap' => $s['gap'],
|
||||
];
|
||||
}
|
||||
$payload = json_encode(
|
||||
['cv' => $ver, 'pos' => $pos],
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
);
|
||||
if ($payload === false) {
|
||||
return false;
|
||||
}
|
||||
return Store::enqueueManual(self::configTopic($uuid), $payload, 1, 1, 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按**当前版本**重发一次配置(不 bumpVer,不产生版本跳动)
|
||||
*
|
||||
* 用途:设备每次上线(/status 带 pins)时调用,专治「设备端配置与服务端不一致」这类
|
||||
* **没有自愈途径**的僵局 —— 最典型的就是设备曾被一条空配置(pos:[])打成全停用:
|
||||
* 设备侧 cfg 被清空、cv 停在那一刻,服务端 sync_ver 恰好也等于那个版本,双方都认为
|
||||
* 「已生效」,于是谁都不会主动纠正,只能等人工再保存一次配置。
|
||||
* 每次上线同版本重发是安全的:固件 onConfigJson **不做版本大小比较**,收到即应用
|
||||
* (见固件该函数的 🔑 注释);retained 投递 ⇒ 设备重连订阅瞬间就能拿到。
|
||||
*
|
||||
* @return bool 是否已入队下发(false = 无启用位置 / 真源为空,见 push() 的 🛡 说明)
|
||||
*/
|
||||
public static function repush(string $uuid): bool
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($uuid === '') {
|
||||
return false;
|
||||
}
|
||||
$cfg = self::get($uuid, true);
|
||||
if (!$cfg['sensors']) {
|
||||
return false;
|
||||
}
|
||||
return self::push($uuid, $cfg['cfg_ver']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理设备 config_ack(Broker 收到 /devices/{uuid}/ack 时调用)
|
||||
* 成功则记 sync_ver,供后台/App 显示「已生效」;失败保持 0 → 显示「待生效」并提示原因
|
||||
*
|
||||
* @param array $payload 已解析的 ack 报文
|
||||
*/
|
||||
public static function onAck(string $uuid, array $payload): void
|
||||
{
|
||||
$type = (string) ($payload['type'] ?? '');
|
||||
if ($type !== '' && $type !== 'config_ack') {
|
||||
return; // cmd_ack 等其它应答不处理
|
||||
}
|
||||
DeviceService::ensureColumns();
|
||||
/* 配置版本:`cv`(与上行 state / 下行 config 同一套短键);旧键 cfg_ver 的兜底已随
|
||||
老固件兼容一起删除(2026-09-15)—— 固件 ack 也只剩 cv,见固件 sendAck() */
|
||||
$ver = (int) ($payload['cv'] ?? 0);
|
||||
$ok = !empty($payload['success']);
|
||||
try {
|
||||
Db::name('iot_device')->where('uuid', $uuid)->update([
|
||||
'sync_ver' => $ok ? $ver : 0,
|
||||
'sync_at' => time(),
|
||||
'update_at' => time(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 默认配置(设备首次纳管)
|
||||
* ========================================================== */
|
||||
|
||||
/**
|
||||
* 确保设备至少有一份配置(设备激活/首次注册时调用)
|
||||
* 已有配置则原样返回,不覆盖用户设置
|
||||
*
|
||||
* @param int $probes 默认探头数(新建时使用)
|
||||
* @param string $alias 默认位置名,空则「位置1」
|
||||
*/
|
||||
public static function ensureDefault(string $uuid, int $probes = 2, string $alias = ''): array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($uuid === '') {
|
||||
return ['cfg_ver' => 0, 'sync_ver' => 0, 'sync_at' => 0, 'applied' => false, 'sensors' => []];
|
||||
}
|
||||
self::ensureTable();
|
||||
$exist = 0;
|
||||
try {
|
||||
$exist = (int) Db::name('iot_sensor_cfg')->where('device_uuid', $uuid)->count();
|
||||
} catch (\Throwable $e) {
|
||||
$exist = 0;
|
||||
}
|
||||
if ($exist > 0) {
|
||||
return self::get($uuid);
|
||||
}
|
||||
return self::save($uuid, [[
|
||||
'index' => 0,
|
||||
'alias' => $alias !== '' ? $alias : '位置1',
|
||||
'probes' => $probes,
|
||||
'upper' => self::DEF_UPPER,
|
||||
'lower' => self::DEF_LOWER,
|
||||
'gap' => self::DEF_GAP,
|
||||
]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按设备上报的引脚表**自动补齐**缺失的位置(只补不覆盖)
|
||||
*
|
||||
* 为什么需要它:位置 ↔ GPIO 是**下标映射**(固件编译期常量 ONEWIRE_PINS[i]),
|
||||
* 而设备只采「服务端配置里启用的位置」—— 新设备只有 ensureDefault 建的
|
||||
* pos_index=0(= GPIO3),于是探头插在 GPIO4/GPIO10(= 位置 1/2)上**永远不出数**。
|
||||
* 现场典型困惑:「只有某根针脚能出数据,断掉它接别的都没数」。
|
||||
* 设备上线时 /status 会带全表 pins(长度 = POS_MAX,与启不启用无关)⇒ 服务端据此
|
||||
* 把缺失的位置补上,于是插 3/4/10 任意一路都能出数,无需重烧固件、无需手动加位置。
|
||||
*
|
||||
* 三条语义(都很重要):
|
||||
* ① **只补不覆盖**:已存在的 pos_index 一行都不动 —— 用户改过的别名/阈值/探头数全保留;
|
||||
* ② **停用不复活**:enabled=0 的行同样算「已存在」,不会被重新启用
|
||||
* (注意这是本功能的固有语义:把位置**删除**了下一次上线还会补回来,
|
||||
* 想彻底不要某个位置应该「停用」而不是删除);
|
||||
* ③ **幂等**:每次上线都会被调用;无新增则直接返回 0,不 bumpVer、不重复下发。
|
||||
*
|
||||
* 老固件(≤1.6.2)的 /status 不含 pins ⇒ 这里不会被触发,行为与从前完全一致。
|
||||
*
|
||||
* @param int[] $pins 下标 = 位置序号(DeviceService::getPins 的原样传入)
|
||||
* @return int 实际新增的位置数(0 = 无需改动 / 失败)
|
||||
*/
|
||||
public static function fillMissing(string $uuid, array $pins): int
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($uuid === '' || !$pins) {
|
||||
return 0;
|
||||
}
|
||||
self::ensureTable();
|
||||
$n = min(count($pins), self::MAX_POS); // pins 长度 = POS_MAX,超出上限的部分忽略
|
||||
|
||||
try {
|
||||
$exist = Db::name('iot_sensor_cfg')->where('device_uuid', $uuid)->column('pos_index');
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
$exist = array_map('intval', is_array($exist) ? $exist : []);
|
||||
|
||||
$now = time();
|
||||
$rows = [];
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
if (in_array($i, $exist, true)) {
|
||||
continue; // 已存在(含停用)→ 绝不动它
|
||||
}
|
||||
$rows[] = [
|
||||
'device_uuid' => $uuid,
|
||||
'pos_index' => $i,
|
||||
'alias' => '位置' . ($i + 1),
|
||||
'probes' => 1,
|
||||
'upper' => self::DEF_UPPER,
|
||||
'lower' => self::DEF_LOWER,
|
||||
'gap' => self::DEF_GAP,
|
||||
'enabled' => 1,
|
||||
'create_at' => $now,
|
||||
'update_at' => $now,
|
||||
];
|
||||
}
|
||||
if (!$rows) {
|
||||
return 0; // 幂等:无事发生
|
||||
}
|
||||
try {
|
||||
Db::name('iot_sensor_cfg')->insertAll($rows);
|
||||
} catch (\Throwable $e) {
|
||||
return 0; // 唯一键撞车等 → 不阻断设备上线
|
||||
}
|
||||
|
||||
try {
|
||||
$ver = self::bumpVer($uuid);
|
||||
self::push($uuid, $ver); // retained:设备重连订阅瞬间即可收到
|
||||
} catch (\Throwable $e) {
|
||||
// 版本自增 / 下发失败不回滚已补的行:下次上线还会走到这里
|
||||
}
|
||||
return count($rows);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user