安全(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(市场 / 开发者中心 / 审核 / 分润)
1086 lines
50 KiB
PHP
1086 lines
50 KiB
PHP
<?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 ywxapp\service\AddonService;
|
||
|
||
/**
|
||
* 智能设备绑定服务(华为式:后台预登记SN+配对码+激活令牌 → 设备入云激活领凭证 → 用户扫码+配对码绑定)
|
||
*
|
||
* 绑定与激活分离(华为 HiLink 模型):
|
||
* 1. 后台 register() 预登记:生成 pair_code(机身贴纸)与 activate_token(出厂预置),不产生连接凭证
|
||
* 2. 设备 activate() 入云激活:设备首次联网持 sn+token 领取 uuid/secret(凭证仅此一次返回)
|
||
* 3. 用户 bind() 认领:App 扫码 SN + 输入配对码,绑定到会员账号;已激活设备不重置凭证
|
||
* 兼容旧流程:直接 bind() 未激活设备(无配对码)时自动激活,米家式扫码即绑仍可用
|
||
*
|
||
* Topic 规范:
|
||
* /devices/{uuid}/state 设备上报状态(retain=1,即"设备影子",离线可查最后状态)
|
||
* /devices/{uuid}/cmd 服务端 / App 下发命令
|
||
* 连接约定:
|
||
* clientId = uuid(MQTT username 约定同值)
|
||
* username = uuid
|
||
* password = device_secret(库中为 bcrypt 哈希,明文仅激活/重置时返回一次)
|
||
*
|
||
* ACL 自动规则(remark 前缀 auto:,可追踪、可整组清理):
|
||
* auto:device:{uuid} target=user={uuid} /devices/%u/# 发布+订阅
|
||
* auto:member:{uuid}:* target=user={会员account} state/status订阅 + cmd发布(仅 allow_member_login 开启时生成)
|
||
*/
|
||
class DeviceService
|
||
{
|
||
/** 设备状态上报主题(影子) */
|
||
public const TOPIC_STATE = '/devices/%s/state';
|
||
/**
|
||
* 设备在线态主题(LWT 保留消息;与 event 分主题避免 retained 互相覆盖)
|
||
* v1.7.0+ 上线报文带上引脚表:{"online":1,"pins":[3,4,10]};LWT 仍固定 {"online":0}
|
||
*/
|
||
public const TOPIC_STATUS = '/devices/%s/status';
|
||
/** 传感器配置下发主题(retained,由服务端 SensorConfigService 发布) */
|
||
public const TOPIC_CONFIG = '/devices/%s/config';
|
||
/** 设备应答主题(config_ack / cmd_ack,非 retained) */
|
||
public const TOPIC_ACK = '/devices/%s/ack';
|
||
/** 命令下发主题 */
|
||
public const TOPIC_CMD = '/devices/%s/cmd';
|
||
/** 设备注册主题(设备发布激活请求,{sn} 占位) */
|
||
public const TOPIC_REGISTER = 'system/device/register/%s';
|
||
/** 设备注册回执主题(服务端发布凭证/订阅信息,{sn} 占位) */
|
||
public const TOPIC_REGISTER_RESP = 'system/device/register/%s/resp';
|
||
|
||
/** 设备注册请求主题(设备侧 publish) */
|
||
public static function registerTopic(string $sn): string
|
||
{
|
||
return sprintf(self::TOPIC_REGISTER, $sn);
|
||
}
|
||
|
||
/** 设备注册回执主题(服务端 publish,设备订阅等待) */
|
||
public static function registerRespTopic(string $sn): string
|
||
{
|
||
return sprintf(self::TOPIC_REGISTER_RESP, $sn);
|
||
}
|
||
|
||
/**
|
||
* 设备连接/订阅信息(激活或绑定成功后返回,供 App / 设备固件使用)
|
||
*/
|
||
public static function deviceTopics(string $uuid): array
|
||
{
|
||
return [
|
||
'state' => "/devices/{$uuid}/state",
|
||
'status' => "/devices/{$uuid}/status",
|
||
'config' => "/devices/{$uuid}/config",
|
||
'ack' => "/devices/{$uuid}/ack",
|
||
'cmd' => "/devices/{$uuid}/cmd",
|
||
'ota' => "/devices/{$uuid}/ota",
|
||
'ota_report' => "/devices/{$uuid}/ota/report",
|
||
'event' => "/devices/{$uuid}/event",
|
||
];
|
||
}
|
||
|
||
/**
|
||
* MQTT 注册入口:设备通过 system/device/register/{sn} 主题携带 activate_token 激活入网
|
||
* 复用 activate(),成功后附加订阅主题信息
|
||
*/
|
||
public static function registerViaMqtt(string $sn, string $token): array
|
||
{
|
||
$res = self::activate($sn, $token);
|
||
$uuid = (string) ($res['uuid'] ?? '');
|
||
if ($uuid !== '') {
|
||
$res['topics'] = self::deviceTopics($uuid);
|
||
}
|
||
return $res;
|
||
}
|
||
|
||
/**
|
||
* MAC 即插即用注册(设备 publish system/device/register/{mac} 触发)
|
||
*
|
||
* 设备上电后发布 retained 注册消息:
|
||
* {"mac":"a0b1c2d3e4f5","type":"esp32c3_temp","model":"KF-C3-3KANG","ver":"1.0"}
|
||
* - mac 为设备唯一身份(12 位十六进制小写,去冒号)
|
||
* - 首次上报:自动创建设备入网并发放 uuid/secret(无需后台预登记)
|
||
* - 再次上报:幂等更新 type/model/ver(含固件版本升级上报),不重置凭证
|
||
* - secret 仅首次注册返回明文,设备需立即保存并改用 uuid+secret 连接
|
||
*
|
||
* @param string $mac 设备 MAC(主题名中的一段,已去冒号小写)
|
||
* @param array $info 注册信息 {mac?, type?, model?, ver?}
|
||
* @return array {id, sn, mac, uuid, dev_type, model, ver, secret?, activated, bound, topics}
|
||
* @throws \InvalidArgumentException|\RuntimeException
|
||
*/
|
||
public static function registerByMac(string $mac, array $info = []): array
|
||
{
|
||
self::ensureTable();
|
||
self::ensureColumns();
|
||
$mac = strtolower(trim($mac));
|
||
$mac = str_replace([':', '-', ' '], '', $mac);
|
||
if ($mac === '' || !preg_match('/^[0-9a-f]{12}$/', $mac)) {
|
||
throw new \InvalidArgumentException('mac 格式非法(应为12位十六进制,去冒号小写)');
|
||
}
|
||
$type = trim((string) ($info['type'] ?? ''));
|
||
$model = trim((string) ($info['model'] ?? ''));
|
||
$ver = trim((string) ($info['ver'] ?? ''));
|
||
$now = time();
|
||
// 优先按 mac 查,兼容老数据按 sn 兜底
|
||
$dev = Db::name('iot_device')->where('mac', $mac)->find();
|
||
if (!$dev) {
|
||
$dev = Db::name('iot_device')->where('sn', $mac)->find();
|
||
}
|
||
if (!$dev) {
|
||
// 即插即用:自动入网(无配对码/激活令牌,用户扫码凭 mac 绑定即可)
|
||
$uuid = self::genUuid();
|
||
$secret = self::genSecret();
|
||
$id = Db::name('iot_device')->insertGetId([
|
||
'sn' => $mac, 'mac' => $mac, 'uuid' => $uuid, 'secret' => Auth::hashPassword($secret),
|
||
'title' => $mac, 'model' => $model, 'dev_type' => $type, 'ver' => $ver,
|
||
'member_id' => 0, 'status' => 1, 'create_at' => $now, 'update_at' => $now,
|
||
]);
|
||
self::provisionCredentials($uuid, $mac, $secret, $now);
|
||
return [
|
||
'id' => (int) $id, 'sn' => $mac, 'mac' => $mac, 'uuid' => $uuid,
|
||
'dev_type' => $type, 'model' => $model, 'ver' => $ver,
|
||
'secret' => $secret, 'activated' => true, 'bound' => false,
|
||
'topics' => self::deviceTopics($uuid),
|
||
];
|
||
}
|
||
// 已存在:补 mac 与 type/model/ver(不重置凭证)
|
||
$uuid = (string) $dev['uuid'];
|
||
$update = ['update_at' => $now];
|
||
if ((string) $dev['mac'] === '') {
|
||
$update['mac'] = $mac;
|
||
}
|
||
if ($type !== '') {
|
||
$update['dev_type'] = $type;
|
||
}
|
||
if ($model !== '') {
|
||
$update['model'] = $model;
|
||
}
|
||
if ($ver !== '') {
|
||
$update['ver'] = $ver;
|
||
}
|
||
// 后台已登记但设备尚未领取凭证 → 补齐并发放
|
||
if ($uuid === '' || (string) $dev['secret'] === '') {
|
||
if ($uuid === '') {
|
||
$uuid = self::genUuid();
|
||
}
|
||
$secret = self::genSecret();
|
||
$update['uuid'] = $uuid;
|
||
$update['secret'] = Auth::hashPassword($secret);
|
||
Db::name('iot_device')->where('id', $dev['id'])->update($update);
|
||
self::provisionCredentials($uuid, $mac, $secret, $now);
|
||
return [
|
||
'id' => (int) $dev['id'], 'sn' => (string) $dev['sn'], 'mac' => $mac, 'uuid' => $uuid,
|
||
'dev_type' => $type !== '' ? $type : (string) $dev['dev_type'],
|
||
'model' => $model !== '' ? $model : (string) $dev['model'],
|
||
'ver' => $ver !== '' ? $ver : (string) $dev['ver'],
|
||
'secret' => $secret, 'activated' => true, 'bound' => (int) $dev['member_id'] > 0,
|
||
'topics' => self::deviceTopics($uuid),
|
||
];
|
||
}
|
||
Db::name('iot_device')->where('id', $dev['id'])->update($update);
|
||
return [
|
||
'id' => (int) $dev['id'], 'sn' => (string) $dev['sn'], 'mac' => $mac, 'uuid' => $uuid,
|
||
'dev_type' => $type !== '' ? $type : (string) $dev['dev_type'],
|
||
'model' => $model !== '' ? $model : (string) $dev['model'],
|
||
'ver' => $ver !== '' ? $ver : (string) $dev['ver'],
|
||
'activated' => true, 'bound' => (int) $dev['member_id'] > 0, 'secret' => '',
|
||
'topics' => self::deviceTopics($uuid),
|
||
];
|
||
}
|
||
|
||
/** 确保 device 表存在(自愈,老库/未执行 install.sql 也可用) */
|
||
public static function ensureTable(): bool
|
||
{
|
||
// prefix 需从连接实例取(connections.mysql.prefix),Db::getConfig('prefix') 顶层取不到
|
||
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
|
||
try {
|
||
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_device` (
|
||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||
`sn` varchar(64) NOT NULL DEFAULT '',
|
||
`mac` varchar(32) NOT NULL DEFAULT '' COMMENT '设备MAC(去冒号小写,即插即用注册身份)',
|
||
`uuid` varchar(64) NOT NULL DEFAULT '',
|
||
`secret` varchar(191) NOT NULL DEFAULT '',
|
||
`pair_code` varchar(16) NOT NULL DEFAULT '' COMMENT '配对码(机身贴纸,绑定时校验)',
|
||
`activate_token` varchar(64) NOT NULL DEFAULT '' COMMENT '入云激活令牌(一次性)',
|
||
`title` varchar(128) NOT NULL DEFAULT '',
|
||
`model` varchar(64) NOT NULL DEFAULT '',
|
||
`dev_type` varchar(64) NOT NULL DEFAULT '' COMMENT '设备类型(esp32c3_temp等,App据此渲染面板)',
|
||
`ver` varchar(32) NOT NULL DEFAULT '' COMMENT '固件版本(OTA检测/提示)',
|
||
`cfg_ver` int unsigned NOT NULL DEFAULT '0' COMMENT '最新传感器配置版本(服务端真源)',
|
||
`sync_ver` int unsigned NOT NULL DEFAULT '0' COMMENT '设备已回执生效的配置版本',
|
||
`sync_at` int DEFAULT NULL COMMENT '配置回执时间',
|
||
`member_id` int unsigned NOT NULL DEFAULT '0',
|
||
`room` varchar(64) NOT NULL DEFAULT '',
|
||
`status` tinyint(1) NOT NULL DEFAULT '1',
|
||
`bind_at` int DEFAULT NULL,
|
||
`create_at` int DEFAULT NULL,
|
||
`update_at` int DEFAULT NULL,
|
||
PRIMARY KEY (`id`), UNIQUE KEY `uk_sn` (`sn`), UNIQUE KEY `uk_mac` (`mac`), UNIQUE KEY `uk_uuid` (`uuid`), KEY `idx_member` (`member_id`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||
self::ensureColumns();
|
||
return true;
|
||
} catch (\Throwable $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 老库加列自愈(2.2.6 新增 pair_code / activate_token;2.2.8 新增 mac / dev_type / ver;
|
||
* v1.7.0 新增 pins)
|
||
* ensureTable 的 CREATE IF NOT EXISTS 对已存在的表不会补列,此处 ALTER 补齐
|
||
*
|
||
* ⚠️ 进程内只真正查一次库(static $done):Broker 是常驻进程,而这个方法会被
|
||
* 每台设备每次上线(/status 报文)调到,没必要反复 `SHOW COLUMNS`。
|
||
* CLI 常驻进程里加完列就一直为真;Web 请求每次是新进程,行为不变。
|
||
*/
|
||
public static function ensureColumns(): bool
|
||
{
|
||
static $done = false;
|
||
if ($done) {
|
||
return true;
|
||
}
|
||
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
|
||
$table = $prefix . 'iot_device';
|
||
try {
|
||
$cols = [];
|
||
$rows = Db::query("SHOW COLUMNS FROM `{$table}`");
|
||
foreach ($rows as $r) {
|
||
$cols[(string) $r['Field']] = true;
|
||
}
|
||
if (!isset($cols['pair_code'])) {
|
||
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `pair_code` varchar(16) NOT NULL DEFAULT '' COMMENT '配对码(机身贴纸,绑定校验)' AFTER `secret`");
|
||
}
|
||
if (!isset($cols['activate_token'])) {
|
||
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `activate_token` varchar(64) NOT NULL DEFAULT '' COMMENT '入云激活令牌(一次性)' AFTER `pair_code`");
|
||
}
|
||
if (!isset($cols['mac'])) {
|
||
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `mac` varchar(32) NOT NULL DEFAULT '' COMMENT '设备MAC(去冒号小写,即插即用注册身份)' AFTER `sn`");
|
||
}
|
||
if (!isset($cols['dev_type'])) {
|
||
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `dev_type` varchar(64) NOT NULL DEFAULT '' COMMENT '设备类型(esp32c3_temp等,App据此渲染面板)' AFTER `model`");
|
||
}
|
||
if (!isset($cols['ver'])) {
|
||
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `ver` varchar(32) NOT NULL DEFAULT '' COMMENT '固件版本(OTA检测/提示)' AFTER `dev_type`");
|
||
}
|
||
// 2.3.0 新增:传感器配置版本三方对齐(cfg_ver 服务端最新 / sync_ver 设备已回执)
|
||
if (!isset($cols['cfg_ver'])) {
|
||
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `cfg_ver` int unsigned NOT NULL DEFAULT '0' COMMENT '最新传感器配置版本(服务端真源)' AFTER `ver`");
|
||
}
|
||
if (!isset($cols['sync_ver'])) {
|
||
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `sync_ver` int unsigned NOT NULL DEFAULT '0' COMMENT '设备已回执生效的配置版本' AFTER `cfg_ver`");
|
||
}
|
||
if (!isset($cols['sync_at'])) {
|
||
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `sync_at` int DEFAULT NULL COMMENT '配置回执时间' AFTER `sync_ver`");
|
||
}
|
||
// v1.7.0 新增:总线 GPIO 表(设备编译期常量 ONEWIRE_PINS[],下标=位置序号)
|
||
// 设备上线时在 /status 报一次 → 这里缓存 → 随 deviceConfig 下发给 App。
|
||
// 有了它,state 报文就不必每条都带 pin(3 个位置省约 24B/条 ≈ 报文 7%)。
|
||
if (!isset($cols['pins'])) {
|
||
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `pins` varchar(64) NOT NULL DEFAULT '' COMMENT '总线GPIO表(CSV如3,4,10;下标=位置序号;设备上线时上报一次)' AFTER `sync_at`");
|
||
}
|
||
$done = true;
|
||
return true;
|
||
} catch (\Throwable $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
* 总线 GPIO 表(pins)—— 把「编译期常量」从高频报文里搬出来
|
||
* ========================================================== */
|
||
|
||
/**
|
||
* 捕获设备 /status 上线报文里的引脚表并缓存
|
||
*
|
||
* 背景:pin 是固件的**编译期常量**(ONEWIRE_PINS[]),与位置序号严格一一对应、
|
||
* 设备这辈子都不会变,却原本每条 state 都带一遍(3 个位置 = 24B/条 ≈ 报文 7%)。
|
||
* v1.7.0 改成「上线时报一次」:设备在 /status 的 retained 报文里带 pins,这里落库,
|
||
* 再由 deviceConfig 下发给 App —— 人眼对接线端子的信息一点没少。
|
||
*
|
||
* 报文形状(新旧兼容):
|
||
* v1.7.0+ {"online":1,"pins":[3,4,10]} ← 带 pins,缓存
|
||
* ≤1.6.2 {"online":0/1} ← 没有 pins,直接返回(正常情况,不是错误)
|
||
*
|
||
* ⚠️ 只认 online=1 时的 pins;不含 pins 的老报文一律原样放过,绝不清空已有缓存
|
||
* (否则一次老设备上线就会把同 uuid 的缓存冲掉 —— 虽然 uuid 唯一,但语义上就不该动)。
|
||
*
|
||
* @param string $topic MQTT 主题(只处理 /devices/{uuid}/status)
|
||
* @param string $payload 报文
|
||
*/
|
||
public static function onStatusMessage(string $topic, string $payload): void
|
||
{
|
||
if (!preg_match('#^/devices/([^/]+)/status$#', $topic, $m)) {
|
||
return;
|
||
}
|
||
$data = json_decode($payload, true);
|
||
if (!is_array($data) || !isset($data['pins']) || !is_array($data['pins'])) {
|
||
return; // 老固件只有 {"online":0/1}
|
||
}
|
||
$pins = [];
|
||
foreach ($data['pins'] as $p) {
|
||
if (is_numeric($p)) {
|
||
$pins[] = (int) $p;
|
||
}
|
||
}
|
||
if (!$pins) {
|
||
return;
|
||
}
|
||
self::setPins($m[1], $pins);
|
||
/* 引脚表是「位置序号 → GPIO」的唯一来源,顺手把缺失的位置配置补齐:
|
||
设备只采「配置里启用的位置」,而新设备默认只有位置 0(ensureDefault)——
|
||
探头插在 GPIO4/GPIO10(位置 1/2)上会永远不出数(现场典型困惑:
|
||
"只有某根针脚能出数据,断掉它接别的都没数")。补齐后可做到插哪路都能出数。
|
||
只补不覆盖:已存在的位置(含停用)一行不动;无新增则完全不产生版本跳动。 */
|
||
try {
|
||
$added = SensorConfigService::fillMissing($m[1], $pins);
|
||
if ($added === 0) {
|
||
/* 「没新增位置」≠「不用下发」:设备端配置可能落后或已被清空(最典型的是
|
||
曾被一条空配置打成全停用,而两侧版本号还恰好对齐 ⇒ 谁都不会主动纠正)。
|
||
所以上线时按当前版本重发一次(retained,一条报文,代价可忽略)。
|
||
有新增时 fillMissing 内部已经 bumpVer + push,不必重复。 */
|
||
SensorConfigService::repush($m[1]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// 补齐/重发失败不阻塞上线主流程(用户仍可在后台手动保存配置)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 写入引脚表缓存(CSV 存库:内容相同则跳过写,避免每次上线都产生一次 UPDATE)
|
||
*
|
||
* @param int[] $pins 下标 = 位置序号
|
||
*/
|
||
public static function setPins(string $uuid, array $pins): bool
|
||
{
|
||
$uuid = trim($uuid);
|
||
if ($uuid === '' || !$pins) {
|
||
return false;
|
||
}
|
||
$pins = array_map('intval', array_values($pins));
|
||
try {
|
||
self::ensureColumns(); // 老库没这列时先补
|
||
if (self::getPins($uuid) === $pins) {
|
||
return true; // 常量表,内容一样就不写库
|
||
}
|
||
Db::name('iot_device')->where('uuid', $uuid)->update([
|
||
'pins' => implode(',', $pins), 'update_at' => time(),
|
||
]);
|
||
return true;
|
||
} catch (\Throwable $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 读取引脚表缓存
|
||
*
|
||
* @return int[] 下标 = 位置序号;从未上报过(老固件 / 老库无此列)返回空数组
|
||
*/
|
||
public static function getPins(string $uuid): array
|
||
{
|
||
$uuid = trim($uuid);
|
||
if ($uuid === '') {
|
||
return [];
|
||
}
|
||
try {
|
||
$raw = Db::name('iot_device')->where('uuid', $uuid)->value('pins');
|
||
} catch (\Throwable $e) {
|
||
return []; // 老库还没有 pins 列
|
||
}
|
||
if (!is_string($raw) || trim($raw) === '') {
|
||
return [];
|
||
}
|
||
$out = [];
|
||
foreach (explode(',', $raw) as $p) {
|
||
if (is_numeric($p)) {
|
||
$out[] = (int) $p;
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* 绑定设备(用户认领)
|
||
* 华为式:设备已入云激活(uuid+secret 已签发)→ 只建立归属关系,不重置凭证;
|
||
* 兼容旧流程:设备未激活(无凭证)→ 绑定即激活(扫码即绑,米家式)
|
||
* @param string $pairCode 配对码(设备设置了 pair_code 则必填且须匹配,防抢绑)
|
||
* @return array 绑定结果(激活/重置时含 secret 明文,仅此一次返回)
|
||
* @throws \InvalidArgumentException|\RuntimeException
|
||
*/
|
||
public static function bind(string $sn, int $memberId, string $title = '', string $model = '', string $room = '', string $pairCode = ''): array
|
||
{
|
||
self::ensureTable();
|
||
self::ensureColumns();
|
||
$sn = trim($sn);
|
||
if ($sn === '' || mb_strlen($sn) > 64) {
|
||
throw new \InvalidArgumentException('设备序列号不能为空且不超过64字符');
|
||
}
|
||
if ($memberId <= 0) {
|
||
throw new \InvalidArgumentException('无效的会员ID');
|
||
}
|
||
$now = time();
|
||
$dev = Db::name('iot_device')->where('sn', $sn)->find();
|
||
|
||
// 已被他人绑定
|
||
if ($dev && (int) $dev['member_id'] > 0 && (int) $dev['member_id'] !== $memberId) {
|
||
throw new \RuntimeException('该设备已被其他用户绑定');
|
||
}
|
||
// 重复绑定到同一会员:幂等返回(不再发放密钥,secret 仅激活时返回一次)
|
||
if ($dev && (int) $dev['member_id'] === $memberId) {
|
||
$uuid = (string) $dev['uuid'];
|
||
return [
|
||
'id' => (int) $dev['id'], 'sn' => $dev['sn'], 'uuid' => $uuid,
|
||
'mac' => (string) ($dev['mac'] ?? ''), 'dev_type' => (string) ($dev['dev_type'] ?? ''), 'ver' => (string) ($dev['ver'] ?? ''),
|
||
'title' => $dev['title'], 'model' => $dev['model'], 'room' => $dev['room'],
|
||
'bound' => true, 'activated' => $uuid !== '' && $dev['secret'] !== '',
|
||
'topics' => $uuid !== '' ? self::deviceTopics($uuid) : [],
|
||
];
|
||
}
|
||
// 配对码校验(华为式防抢绑):设备设置了 pair_code 则必填且须匹配
|
||
if ($dev && (string) $dev['pair_code'] !== '') {
|
||
$code = trim($pairCode);
|
||
if ($code === '' || !hash_equals((string) $dev['pair_code'], $code)) {
|
||
throw new \RuntimeException('配对码错误(请查看设备机身贴纸)');
|
||
}
|
||
}
|
||
|
||
$activated = $dev && $dev['uuid'] !== '' && $dev['secret'] !== '';
|
||
if ($activated) {
|
||
// 华为式:设备已入云激活,绑定仅建立归属,凭证保持不变(设备激活时已领取)
|
||
$uuid = (string) $dev['uuid'];
|
||
Db::name('iot_device')->where('id', $dev['id'])->update([
|
||
'title' => $title !== '' ? $title : $dev['title'],
|
||
'model' => $model !== '' ? $model : $dev['model'],
|
||
'room' => $room !== '' ? $room : $dev['room'],
|
||
'member_id' => $memberId, 'status' => 1, 'bind_at' => $now, 'update_at' => $now,
|
||
]);
|
||
if (self::allowMemberMqtt()) {
|
||
self::syncMemberAcl($memberId, $uuid);
|
||
}
|
||
self::ensureSensorConfig($uuid); // 老设备补默认配置(幂等)
|
||
return [
|
||
'id' => (int) $dev['id'], 'sn' => $sn, 'uuid' => $uuid,
|
||
'mac' => (string) ($dev['mac'] ?? ''), 'dev_type' => (string) ($dev['dev_type'] ?? ''), 'ver' => (string) ($dev['ver'] ?? ''),
|
||
'title' => $title !== '' ? $title : $dev['title'],
|
||
'model' => $model !== '' ? $model : $dev['model'],
|
||
'room' => $room !== '' ? $room : $dev['room'],
|
||
'bound' => true, 'activated' => true, 'secret' => '',
|
||
'topics' => self::deviceTopics($uuid),
|
||
];
|
||
}
|
||
|
||
if (!$dev) {
|
||
// 全新设备:登记 + 激活(米家式兼容:扫码即绑,无配对码)
|
||
$uuid = self::genUuid();
|
||
$secret = self::genSecret();
|
||
$devId = Db::name('iot_device')->insertGetId([
|
||
'sn' => $sn, 'uuid' => $uuid, 'secret' => Auth::hashPassword($secret),
|
||
'title' => $title !== '' ? $title : $sn, 'model' => $model, 'room' => $room,
|
||
'member_id' => $memberId, 'status' => 1, 'bind_at' => $now,
|
||
'create_at' => $now, 'update_at' => $now,
|
||
]);
|
||
$dev = ['id' => $devId, 'sn' => $sn, 'uuid' => $uuid, 'title' => $title !== '' ? $title : $sn];
|
||
} else {
|
||
// 已登记未激活(后台预登记)→ 激活:重置凭证(旧密钥作废,安全),激活令牌作废
|
||
$uuid = $dev['uuid'] !== '' ? $dev['uuid'] : self::genUuid();
|
||
$secret = self::genSecret();
|
||
Db::name('iot_device')->where('id', $dev['id'])->update([
|
||
'uuid' => $uuid, 'secret' => Auth::hashPassword($secret),
|
||
'activate_token' => '',
|
||
'title' => $title !== '' ? $title : $dev['title'],
|
||
'model' => $model !== '' ? $model : $dev['model'],
|
||
'room' => $room !== '' ? $room : $dev['room'],
|
||
'member_id' => $memberId, 'status' => 1, 'bind_at' => $now, 'update_at' => $now,
|
||
]);
|
||
}
|
||
|
||
self::provisionCredentials($uuid, $sn, $secret, $now);
|
||
if (self::allowMemberMqtt()) {
|
||
self::syncMemberAcl($memberId, $uuid);
|
||
}
|
||
|
||
return [
|
||
'id' => (int) ($dev['id'] ?? 0), 'sn' => $sn, 'uuid' => $uuid,
|
||
'mac' => (string) ($dev['mac'] ?? (preg_match('/^[0-9a-f]{12}$/', $sn) ? $sn : '')),
|
||
'dev_type' => (string) ($dev['dev_type'] ?? ''), 'ver' => (string) ($dev['ver'] ?? ''),
|
||
'title' => $title !== '' ? $title : ($dev['title'] ?? $sn),
|
||
'model' => $model, 'room' => $room,
|
||
'secret' => $secret, 'bound' => true, 'activated' => true,
|
||
'topics' => self::deviceTopics($uuid),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 设备入云激活(华为式,设备侧调用,无需登录)
|
||
* 设备出厂预置 sn + activate_token,首次联网调此接口领取正式连接凭证 uuid/secret
|
||
* @param string $sn 设备序列号(机身二维码)
|
||
* @param string $token 激活令牌(后台预登记生成,一次性;激活成功即作废)
|
||
* @return array 激活结果(secret 仅此一次返回明文)
|
||
* @throws \InvalidArgumentException|\RuntimeException
|
||
*/
|
||
public static function activate(string $sn, string $token): array
|
||
{
|
||
self::ensureTable();
|
||
self::ensureColumns();
|
||
$sn = trim($sn);
|
||
$token = trim($token);
|
||
if ($sn === '' || $token === '') {
|
||
throw new \InvalidArgumentException('sn 与 token 均为必填');
|
||
}
|
||
$dev = Db::name('iot_device')->where('sn', $sn)->find();
|
||
if (!$dev) {
|
||
throw new \RuntimeException('设备未登记,请联系管理员预登记');
|
||
}
|
||
if ((int) $dev['status'] !== 1) {
|
||
throw new \RuntimeException('设备已停用');
|
||
}
|
||
// 已激活:幂等返回(设备重试/重复请求不报错),但不再发放 secret(明文仅激活时返回一次)
|
||
if ($dev['uuid'] !== '' && $dev['secret'] !== '') {
|
||
return [
|
||
'id' => (int) $dev['id'], 'sn' => $sn, 'uuid' => $dev['uuid'],
|
||
'mac' => (string) ($dev['mac'] ?? ''), 'dev_type' => (string) ($dev['dev_type'] ?? ''), 'ver' => (string) ($dev['ver'] ?? ''),
|
||
'title' => $dev['title'], 'model' => $dev['model'], 'room' => $dev['room'],
|
||
'activated' => true, 'bound' => (int) $dev['member_id'] > 0, 'secret' => '',
|
||
'topics' => self::deviceTopics((string) $dev['uuid']),
|
||
];
|
||
}
|
||
if ((string) $dev['activate_token'] === '' || !hash_equals((string) $dev['activate_token'], $token)) {
|
||
throw new \RuntimeException('激活令牌无效或已失效');
|
||
}
|
||
$now = time();
|
||
$uuid = self::genUuid();
|
||
$secret = self::genSecret();
|
||
Db::name('iot_device')->where('id', $dev['id'])->update([
|
||
'uuid' => $uuid, 'secret' => Auth::hashPassword($secret),
|
||
'activate_token' => '', 'status' => 1, 'update_at' => $now,
|
||
]);
|
||
self::provisionCredentials($uuid, $sn, $secret, $now);
|
||
|
||
return [
|
||
'id' => (int) $dev['id'], 'sn' => $sn, 'uuid' => $uuid,
|
||
'mac' => (string) ($dev['mac'] ?? ''), 'dev_type' => (string) ($dev['dev_type'] ?? ''), 'ver' => (string) ($dev['ver'] ?? ''),
|
||
'title' => $dev['title'], 'model' => $dev['model'], 'room' => $dev['room'],
|
||
'secret' => $secret, 'activated' => true, 'bound' => (int) $dev['member_id'] > 0,
|
||
'topics' => self::deviceTopics($uuid),
|
||
];
|
||
}
|
||
|
||
/** 后台重新生成激活令牌(仅未激活设备可用,按 SN 定位;已激活设备如需换凭证走 resetSecret) */
|
||
public static function resetActivateToken(string $sn): array
|
||
{
|
||
self::ensureTable();
|
||
self::ensureColumns();
|
||
$dev = self::findByUuid($sn);
|
||
if (!$dev) {
|
||
$dev = Db::name('iot_device')->where('sn', $sn)->find();
|
||
}
|
||
if (!$dev) {
|
||
throw new \RuntimeException('设备不存在');
|
||
}
|
||
if ($dev['uuid'] !== '' && $dev['secret'] !== '') {
|
||
throw new \RuntimeException('设备已激活,无需重新生成激活令牌');
|
||
}
|
||
$token = self::genActivateToken();
|
||
Db::name('iot_device')->where('id', $dev['id'])->update([
|
||
'activate_token' => $token, 'update_at' => time(),
|
||
]);
|
||
return ['sn' => $sn, 'activate_token' => $token];
|
||
}
|
||
|
||
/** 解绑设备(设备账号/凭证与设备ACL保留,可被重新绑定;已激活设备重绑时凭证保持不变) */
|
||
public static function unbind(string $uuid, int $memberId): void
|
||
{
|
||
$dev = self::findByUuid($uuid);
|
||
if (!$dev) {
|
||
throw new \RuntimeException('设备不存在');
|
||
}
|
||
if ((int) $dev['member_id'] !== $memberId) {
|
||
throw new \RuntimeException('无权解绑该设备');
|
||
}
|
||
Db::name('iot_device')->where('id', $dev['id'])->update([
|
||
'member_id' => 0, 'bind_at' => null, 'update_at' => time(),
|
||
]);
|
||
self::clearMemberAcl($uuid);
|
||
}
|
||
|
||
/** 重置设备密钥,返回明文(旧密钥立即作废) */
|
||
public static function resetSecret(string $uuid, ?int $memberId = null): array
|
||
{
|
||
$dev = self::findByUuid($uuid);
|
||
if (!$dev) {
|
||
throw new \RuntimeException('设备不存在');
|
||
}
|
||
if ($memberId !== null && (int) $dev['member_id'] !== $memberId) {
|
||
throw new \RuntimeException('无权重置该设备密钥');
|
||
}
|
||
$secret = self::genSecret();
|
||
$now = time();
|
||
Db::name('iot_device')->where('id', $dev['id'])->update([
|
||
'secret' => Auth::hashPassword($secret), 'update_at' => $now,
|
||
]);
|
||
Db::name('iot_auth')->where('username', $uuid)->update([
|
||
'password' => Auth::hashPassword($secret), 'update_at' => $now,
|
||
]);
|
||
return ['uuid' => $uuid, 'secret' => $secret];
|
||
}
|
||
|
||
/**
|
||
* 后台登记设备(华为式预注册 / MAC 设备补录)
|
||
* 生成 pair_code(机身贴纸,绑定校验防抢绑)与 activate_token(出厂预置,设备入云激活用),
|
||
* 不产生连接凭证,等待设备激活或用户扫码绑定
|
||
* @param string $pairCode 配对码,传空自动生成 6 位数字
|
||
* @param string $mac 设备 MAC(选填,即插即用设备可用 mac 定位)
|
||
* @param string $type 设备类型(选填,决定 App 面板渲染)
|
||
* @param string $ver 固件版本(选填,OTA 检测用)
|
||
*/
|
||
public static function register(string $sn, string $title, string $model = '', string $room = '', string $pairCode = '', string $mac = '', string $type = '', string $ver = ''): array
|
||
{
|
||
self::ensureTable();
|
||
self::ensureColumns();
|
||
$sn = trim($sn);
|
||
if ($sn === '' || mb_strlen($sn) > 64) {
|
||
throw new \InvalidArgumentException('序列号必填且不超过64字符');
|
||
}
|
||
$mac = str_replace([':', '-', ' '], '', strtolower(trim($mac)));
|
||
if ($mac !== '' && !preg_match('/^[0-9a-f]{12}$/', $mac)) {
|
||
throw new \InvalidArgumentException('mac 格式非法(应为12位十六进制,去冒号小写)');
|
||
}
|
||
$now = time();
|
||
$dev = Db::name('iot_device')->where('sn', $sn)->find();
|
||
if (!$dev && $mac !== '') {
|
||
$dev = Db::name('iot_device')->where('mac', $mac)->find();
|
||
}
|
||
if ($dev) {
|
||
$update = [
|
||
'title' => $title !== '' ? $title : $dev['title'],
|
||
'model' => $model !== '' ? $model : $dev['model'],
|
||
'room' => $room !== '' ? $room : $dev['room'],
|
||
'update_at' => $now,
|
||
];
|
||
if ($mac !== '' && (string) $dev['mac'] === '') {
|
||
$update['mac'] = $mac;
|
||
}
|
||
if ($type !== '') {
|
||
$update['dev_type'] = $type;
|
||
}
|
||
if ($ver !== '') {
|
||
$update['ver'] = $ver;
|
||
}
|
||
// 未激活设备:补发激活令牌(已有令牌不覆盖);传入配对码则更新
|
||
$token = (string) $dev['activate_token'];
|
||
if ($token === '' && $dev['uuid'] === '') {
|
||
$token = self::genActivateToken();
|
||
$update['activate_token'] = $token;
|
||
}
|
||
if ($pairCode !== '') {
|
||
$update['pair_code'] = $pairCode;
|
||
}
|
||
Db::name('iot_device')->where('id', $dev['id'])->update($update);
|
||
return [
|
||
'id' => (int) $dev['id'], 'sn' => $sn, 'mac' => (string) ($dev['mac'] ?? ''), 'uuid' => (string) $dev['uuid'],
|
||
'dev_type' => $type !== '' ? $type : (string) ($dev['dev_type'] ?? ''),
|
||
'ver' => $ver !== '' ? $ver : (string) ($dev['ver'] ?? ''),
|
||
'activate_token' => $token, 'pair_code' => $pairCode !== '' ? $pairCode : (string) ($dev['pair_code'] ?? ''),
|
||
];
|
||
}
|
||
$token = self::genActivateToken();
|
||
$code = $pairCode !== '' ? $pairCode : self::genPairCode();
|
||
$id = Db::name('iot_device')->insertGetId([
|
||
'sn' => $sn, 'mac' => $mac, 'uuid' => '', 'secret' => '',
|
||
'pair_code' => $code, 'activate_token' => $token,
|
||
'title' => $title !== '' ? $title : $sn, 'model' => $model, 'dev_type' => $type, 'ver' => $ver, 'room' => $room,
|
||
'member_id' => 0, 'status' => 1, 'bind_at' => null,
|
||
'create_at' => $now, 'update_at' => $now,
|
||
]);
|
||
return ['id' => (int) $id, 'sn' => $sn, 'mac' => $mac, 'uuid' => '', 'dev_type' => $type, 'ver' => $ver, 'activate_token' => $token, 'pair_code' => $code];
|
||
}
|
||
|
||
/** 删除设备(含 MQTT 账号、ACL、影子、订阅、离线队列,慎用) */
|
||
public static function remove(string $uuid): void
|
||
{
|
||
$dev = self::findByUuid($uuid);
|
||
if (!$dev) {
|
||
return;
|
||
}
|
||
Db::name('iot_device')->where('id', $dev['id'])->delete();
|
||
Db::name('iot_auth')->where('username', $uuid)->delete();
|
||
Db::name('iot_acl')->where('remark', 'auto:device:' . $uuid)->delete();
|
||
self::clearMemberAcl($uuid);
|
||
Db::name('iot_retain')->where('topic', 'like', '/devices/' . $uuid . '/%')->delete();
|
||
Db::name('iot_connection')->where('username', $uuid)->update(['status' => 0]);
|
||
Db::name('iot_topic')->where('client_id', $uuid)->delete();
|
||
Db::name('iot_offline')->where('client_id', $uuid)->delete();
|
||
// 清分组关联
|
||
GroupService::clearDevice((int) $dev['id']);
|
||
// 清共享关系与共享 ACL
|
||
ShareService::removeByDevice((int) $dev['id'], $uuid);
|
||
// 清传感器配置(配置真源在服务端,随设备一并删除)
|
||
SensorConfigService::removeByUuid($uuid);
|
||
// 清结构化时序历史(否则残留数据会一直占空间,且 uuid 复用时会串数据)
|
||
SensorPointService::removeByUuid($uuid);
|
||
}
|
||
|
||
/** 设备是否在线 */
|
||
public static function isOnline(string $uuid): bool
|
||
{
|
||
if ($uuid === '') {
|
||
return false;
|
||
}
|
||
try {
|
||
return Db::name('iot_connection')
|
||
->where('username', $uuid)
|
||
->where('status', 1)
|
||
->count() > 0;
|
||
} catch (\Throwable $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** 读取设备影子(最后上报的 state 保留消息) */
|
||
public static function shadow(string $uuid): ?array
|
||
{
|
||
if ($uuid === '') {
|
||
return null;
|
||
}
|
||
try {
|
||
$row = Db::name('iot_retain')
|
||
->where('topic', sprintf(self::TOPIC_STATE, $uuid))
|
||
->find();
|
||
} catch (\Throwable $e) {
|
||
return null;
|
||
}
|
||
if (!$row) {
|
||
return null;
|
||
}
|
||
return [
|
||
'payload' => (string) $row['payload'],
|
||
'update_at' => (int) $row['update_at'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 会员绑定的设备列表(含在线状态 + 主题表;**不发凭证、不发影子**)
|
||
*
|
||
* 🔑 这里是 App 设备列表/面板骨架的入口(经 visibleDevices),所以字段用**显式白名单**,
|
||
* 不再 select() 全字段 —— 三处瘦身,理由都不是「省字节」而是「不该给」:
|
||
* ① 凭证类一律不出(2026-09-15):`secret`(连接密码 hash)、`activate_token`
|
||
* (入云激活一次性令牌)、`pair_code`(机身贴纸防抢绑码)—— 谁拿到谁能顶替设备
|
||
* 或抢绑,列表页拿它们**没有任何用途**(`secret` 原先靠 unset 兜,现在是压根不查)。
|
||
* ⚠️ 后台设备管理(backend\Devices::deviceTable)**必须继续返回** pair_code /
|
||
* activate_token:那一页要显示配对码、要在编辑弹窗里展示未激活设备的一次性令牌。
|
||
* 两条路径互不相干,别把这里的白名单套到后台去。
|
||
* ② 🚫 **不发 `shadow`**:整条 state 报文原文(≈260B/台)在列表里**没有任何消费方**
|
||
* —— App 走 /iot/api/client/deviceState 单独取影子(device-panel 的 loadShadow()),
|
||
* 后台 GroupService 自己拼一份。塞在列表里纯属冗余(要影子请调 deviceState())。
|
||
* ③ `member_id` 也去掉:这条路径只查"我自己"的设备 ⇒ 它恒等于调用者,
|
||
* 归属语义已由 role / perm 表达,App 端从不读它。(共享设备走 sharedToMe(),
|
||
* 那一条返回的 owner_id 是**别人**,不在本白名单内。)
|
||
*
|
||
* ⚠️ 下列字段是**契约**,删任何一个 App 就静默显示空白(不改也不会报错,只是没数据):
|
||
* id / sn / mac / uuid / title / model / dev_type / ver / room
|
||
* + cfg_ver / sync_ver / sync_at(attachSensorConfig() 拼 applied 给首屏骨架)
|
||
* (App 还会另补 online / role / topics / sensors / applied)
|
||
* ⚠️ ensureTable() 已保证 cfg_ver/sync_ver/sync_at 存在(老库会自动 ALTER 补列),
|
||
* 所以 field() 里写死这几列是安全的。
|
||
*/
|
||
public static function myDevices(int $memberId): array
|
||
{
|
||
self::ensureTable();
|
||
$list = Db::name('iot_device')
|
||
->where('member_id', $memberId)
|
||
->field('id,sn,mac,uuid,title,model,dev_type,ver,room,status,pins,'
|
||
. 'bind_at,create_at,update_at,cfg_ver,sync_ver,sync_at')
|
||
->order('id', 'desc')
|
||
->select()
|
||
->toArray();
|
||
foreach ($list as &$d) {
|
||
$d['online'] = self::isOnline((string) $d['uuid']);
|
||
$d['role'] = 'owner';
|
||
$d['topics'] = self::deviceTopics((string) $d['uuid']);
|
||
}
|
||
unset($d);
|
||
return $list;
|
||
}
|
||
|
||
/**
|
||
* 会员可见设备(主人 + 共享给我),role 区分 owner/shared
|
||
*
|
||
* @param bool $withConfig 是否内联位置配置(sensors/cfg_ver/sync_ver/applied)
|
||
* · 设备列表(App 首屏骨架)需要 → 默认开;
|
||
* · 只是顺带取设备行的接口(如 otaList)不需要 → 传 false,省掉那 2 条 SQL 与响应体积。
|
||
*/
|
||
public static function visibleDevices(int $memberId, bool $withConfig = true): array
|
||
{
|
||
$owner = self::myDevices($memberId);
|
||
$shared = ShareService::sharedToMe($memberId);
|
||
$list = array_merge($owner, $shared);
|
||
// 配置内联:App 拉一次设备列表就能画骨架(含离线/首屏),省掉 deviceConfig 那次往返
|
||
return $withConfig ? self::attachSensorConfig($list) : $list;
|
||
}
|
||
|
||
/**
|
||
* 给设备列表批量补「位置配置」(sensors / cfg_ver / sync_ver / applied)
|
||
*
|
||
* 为什么放这里、而不是在 myDevices() 里逐台补:
|
||
* ① owner + shared 两批设备一次性补,共享设备(perm=1 只读)也能看到配置,与 ACL 白名单含 config 一致;
|
||
* ② 必须**批量** —— 见 SensorConfigService::getAll() 的注释(N+1 会拖垮列表接口)。
|
||
*
|
||
* ⚠️ 不改 iot_device.pins 的类型:DB 里存的是 CSV 字符串("3,4,10"),
|
||
* 与 deviceConfig 接口下发的 pins(数组)**不同型**,App 端别把列表里的 pins 直接当数组用;
|
||
* 要 GPIO 提示就等 /status 保留消息或调 deviceConfig(applyPins 只补不清,不会闪空)。
|
||
*
|
||
* @param array $list myDevices()/sharedToMe() 产出的设备行
|
||
*/
|
||
public static function attachSensorConfig(array $list): array
|
||
{
|
||
if (!$list) {
|
||
return $list;
|
||
}
|
||
$uuids = [];
|
||
foreach ($list as $d) {
|
||
$u = (string) ($d['uuid'] ?? '');
|
||
if ($u !== '') {
|
||
$uuids[] = $u;
|
||
}
|
||
}
|
||
if (!$uuids) {
|
||
return $list;
|
||
}
|
||
try {
|
||
$map = SensorConfigService::getAll($uuids);
|
||
} catch (\Throwable $e) {
|
||
return $list; // 配置取不到不影响设备列表本身(App 端回退到 deviceConfig 接口)
|
||
}
|
||
foreach ($list as &$d) {
|
||
$u = (string) ($d['uuid'] ?? '');
|
||
if (!isset($map[$u])) {
|
||
continue;
|
||
}
|
||
$c = $map[$u];
|
||
$d['sensors'] = $c['sensors'];
|
||
$d['cfg_ver'] = $c['cfg_ver'];
|
||
$d['sync_ver'] = $c['sync_ver'];
|
||
$d['applied'] = $c['applied'];
|
||
}
|
||
unset($d);
|
||
return $list;
|
||
}
|
||
|
||
/** 按 uuid 查设备 */
|
||
public static function findByUuid(string $uuid): ?array
|
||
{
|
||
if ($uuid === '') {
|
||
return null;
|
||
}
|
||
try {
|
||
$dev = Db::name('iot_device')->where('uuid', $uuid)->find();
|
||
} catch (\Throwable $e) {
|
||
return null;
|
||
}
|
||
return $dev ?: null;
|
||
}
|
||
|
||
/** 按 id 查设备 */
|
||
public static function findById(int $id): ?array
|
||
{
|
||
if ($id <= 0) {
|
||
return null;
|
||
}
|
||
try {
|
||
$dev = Db::name('iot_device')->where('id', $id)->find();
|
||
} catch (\Throwable $e) {
|
||
return null;
|
||
}
|
||
return $dev ?: null;
|
||
}
|
||
|
||
/** 会员登录账号(member_user.account),用于自动 ACL */
|
||
public static function memberAccount(int $memberId): string
|
||
{
|
||
if ($memberId <= 0) {
|
||
return '';
|
||
}
|
||
try {
|
||
// member_user 主键为 uid(勿用 id)
|
||
$acc = Db::name('member_user')->where('uid', $memberId)->value('account');
|
||
} catch (\Throwable $e) {
|
||
return '';
|
||
}
|
||
return (string) ($acc ?: '');
|
||
}
|
||
|
||
/* ============================================================
|
||
* 内部:凭证与 ACL 维护
|
||
* ========================================================== */
|
||
|
||
protected static function genUuid(): string
|
||
{
|
||
return 'dev_' . strtolower(bin2hex(random_bytes(8)));
|
||
}
|
||
|
||
protected static function genSecret(int $len = 16): string
|
||
{
|
||
return substr(bin2hex(random_bytes(32)), 0, $len);
|
||
}
|
||
|
||
/** 生成 6 位数字配对码(机身贴纸) */
|
||
protected static function genPairCode(int $len = 6): string
|
||
{
|
||
$code = '';
|
||
for ($i = 0; $i < $len; $i++) {
|
||
$code .= (string) random_int(0, 9);
|
||
}
|
||
return $code;
|
||
}
|
||
|
||
/** 生成激活令牌(设备出厂预置,一次性) */
|
||
protected static function genActivateToken(int $len = 32): string
|
||
{
|
||
return substr(bin2hex(random_bytes(32)), 0, $len);
|
||
}
|
||
|
||
/** 发放 MQTT 连接凭证:账号(username=uuid)+ 设备 ACL(激活/绑定共用) */
|
||
protected static function provisionCredentials(string $uuid, string $sn, string $secret, int $now): void
|
||
{
|
||
$acc = Db::name('iot_auth')->where('username', $uuid)->find();
|
||
if ($acc) {
|
||
Db::name('iot_auth')->where('id', $acc['id'])->update([
|
||
'password' => Auth::hashPassword($secret), 'status' => 1,
|
||
'remark' => '智能设备:' . $sn, 'update_at' => $now,
|
||
]);
|
||
} else {
|
||
Db::name('iot_auth')->insert([
|
||
'username' => $uuid, 'password' => Auth::hashPassword($secret),
|
||
'is_superuser' => 0, 'status' => 1,
|
||
'remark' => '智能设备:' . $sn, 'create_at' => $now, 'update_at' => $now,
|
||
]);
|
||
}
|
||
self::syncDeviceAcl($uuid, $sn);
|
||
self::ensureSensorConfig($uuid);
|
||
}
|
||
|
||
/**
|
||
* 确保设备有传感器配置(配置真源在服务端,设备上线订阅 retained config 即可拿到)
|
||
* 幂等且失败不影响注册/激活/绑定主流程
|
||
*/
|
||
protected static function ensureSensorConfig(string $uuid): void
|
||
{
|
||
if ($uuid === '') {
|
||
return;
|
||
}
|
||
try {
|
||
SensorConfigService::ensureDefault($uuid);
|
||
} catch (\Throwable $e) {
|
||
// 配置下发失败不应阻塞设备接入(用户可在后台补配)
|
||
}
|
||
}
|
||
|
||
/** 设备自身 ACL:/devices/%u/# 全权限(%u 会替换为 uuid) */
|
||
protected static function syncDeviceAcl(string $uuid, string $sn): void
|
||
{
|
||
Db::name('iot_acl')->where('remark', 'auto:device:' . $uuid)->delete();
|
||
Db::name('iot_acl')->insert([
|
||
'target_type' => 'user', 'target' => $uuid,
|
||
'topic' => '/devices/%u/#', 'access' => 3, 'allow' => 1,
|
||
'sort' => 100, 'remark' => 'auto:device:' . $uuid, 'create_at' => time(),
|
||
]);
|
||
}
|
||
|
||
/** 会员直连 ACL:state 只读 + cmd 可写(仅在允许会员登录时生成) */
|
||
protected static function syncMemberAcl(int $memberId, string $uuid): void
|
||
{
|
||
$account = self::memberAccount($memberId);
|
||
if ($account === '') {
|
||
return;
|
||
}
|
||
self::clearMemberAcl($uuid);
|
||
$now = time();
|
||
$rows = [
|
||
[
|
||
'target_type' => 'user', 'target' => $account,
|
||
'topic' => sprintf(self::TOPIC_STATE, $uuid), 'access' => 1, 'allow' => 1,
|
||
'sort' => 200, 'remark' => 'auto:member:' . $uuid . ':state', 'create_at' => $now,
|
||
],
|
||
[
|
||
'target_type' => 'user', 'target' => $account,
|
||
'topic' => sprintf(self::TOPIC_STATUS, $uuid), 'access' => 1, 'allow' => 1,
|
||
'sort' => 200, 'remark' => 'auto:member:' . $uuid . ':status', 'create_at' => $now,
|
||
],
|
||
[
|
||
'target_type' => 'user', 'target' => $account,
|
||
'topic' => sprintf(self::TOPIC_CMD, $uuid), 'access' => 2, 'allow' => 1,
|
||
'sort' => 200, 'remark' => 'auto:member:' . $uuid . ':cmd', 'create_at' => $now,
|
||
],
|
||
[
|
||
'target_type' => 'user', 'target' => $account,
|
||
'topic' => '/notify/' . $account . '/#', 'access' => 1, 'allow' => 1,
|
||
'sort' => 200, 'remark' => 'auto:member:' . $uuid . ':notify', 'create_at' => $now,
|
||
],
|
||
];
|
||
Db::name('iot_acl')->insertAll($rows);
|
||
}
|
||
|
||
protected static function clearMemberAcl(string $uuid): void
|
||
{
|
||
Db::name('iot_acl')->where('remark', 'like', 'auto:member:' . $uuid . '%')->delete();
|
||
}
|
||
|
||
/**
|
||
* 重新同步某会员名下全部设备的 MQTT ACL(自愈)
|
||
*
|
||
* 为什么需要:ACL 是「生成时快照」——后来新增的自动规则(如 2.2.9 的
|
||
* /devices/{uuid}/status 订阅,用于 LWT 在线态)不会自动补到老设备的 ACL 上,
|
||
* 老用户升级 App 后会订阅被拒、在线态永远不刷新。
|
||
* 触发点:App 每次换取 MQTT ticket 时调用(幂等,设备量小,代价可忽略)。
|
||
*
|
||
* @return int 同步的设备数
|
||
*/
|
||
public static function resyncMemberAcl(int $memberId): int
|
||
{
|
||
if ($memberId <= 0 || !self::allowMemberMqtt()) {
|
||
return 0;
|
||
}
|
||
$n = 0;
|
||
try {
|
||
$uuids = Db::name('iot_device')
|
||
->where('member_id', $memberId)->where('uuid', '<>', '')
|
||
->column('uuid');
|
||
if (empty($uuids)) {
|
||
return 0;
|
||
}
|
||
// 已有 :status 规则的设备直接跳过,避免每次换 ticket 都全量重建
|
||
$remarks = [];
|
||
foreach ($uuids as $uuid) {
|
||
$remarks[] = 'auto:member:' . $uuid . ':status';
|
||
}
|
||
$have = Db::name('iot_acl')->whereIn('remark', $remarks)->column('remark');
|
||
$haveMap = array_flip(array_map('strval', $have));
|
||
foreach ($uuids as $uuid) {
|
||
if (isset($haveMap['auto:member:' . $uuid . ':status'])) {
|
||
continue;
|
||
}
|
||
self::syncMemberAcl($memberId, (string) $uuid);
|
||
$n++;
|
||
}
|
||
} catch (\Throwable $e) {
|
||
}
|
||
return $n;
|
||
}
|
||
|
||
/** 是否开启框架会员直连 Broker(决定是否生成会员 ACL) */
|
||
protected static function allowMemberMqtt(): bool
|
||
{
|
||
try {
|
||
$saved = AddonService::config('iot');
|
||
} catch (\Throwable $e) {
|
||
$saved = [];
|
||
}
|
||
return !empty($saved['allow_member_login']);
|
||
}
|
||
}
|