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

1086 lines
50 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;
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']);
}
}