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

620 lines
27 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;
/**
* 传感器位置配置服务 —— 配置的唯一真源(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);
}
}