// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\iot\controller\api; use think\facade\Db; use addon\iot\service\Store; use addon\iot\service\DeviceService; use addon\iot\service\ShareService; use addon\iot\service\TicketService; /** * App 端设备域 API(需框架会员 JWT 登录) * 路由前缀:/iot/api/client/... * * 2026-09-18 按功能域拆分后本控制器只保留「接入层」:连接/设备绑定/共享/ * 影子/命令 —— 全品类共用(品类无关)。传感器配置/曲线/日志/告警在 Monitor, * 场景/分组在 Scene,OTA 在 Ota。共用会员取数在 ClientBase。 */ class Client extends ClientBase { /** 免登录公开接口(设备侧调用,非会员) */ protected $noNeedLogin = ['deviceActivate']; protected $noNeedVerify = ['deviceActivate']; /** * 我的设备 / 连接列表 * GET /iot/api/client/devices */ public function devices() { $account = $this->memberAccount(); if ($account === '') { return $this->result->apiError('无法获取当前会员账号'); } $list = Db::name('iot_connection') ->where('username', $account) ->order('update_at', 'desc') ->limit(200) ->field('client_id,username,ip,status,create_at,update_at') ->select() ->toArray(); return $this->result->setCount(count($list))->apiSuccess($list); } /** * 我的订阅主题列表 * GET /iot/api/client/subscriptions */ public function subscriptions() { $account = $this->memberAccount(); if ($account === '') { return $this->result->apiError('无法获取当前会员账号'); } $clientIds = Db::name('iot_connection') ->where('username', $account) ->column('client_id'); $list = []; if ($clientIds) { $list = Db::name('iot_topic') ->whereIn('client_id', $clientIds) ->order('id', 'desc') ->field('client_id,topic,qos,create_at') ->select() ->toArray(); } return $this->result->apiSuccess(['list' => $list, 'total' => count($list)]); } /** * 发布消息(服务端代发,强制命名空间隔离) * POST /iot/api/client/publish * param: topic, payload, qos(0/1/2), retain(0/1) */ public function publish() { $account = $this->memberAccount(); if ($account === '') { return $this->result->apiError('无法获取当前会员账号'); } $topic = trim((string) $this->request->post('topic', '')); $payload = (string) $this->request->post('payload', ''); $qos = (int) $this->request->post('qos', 0); $retain = (int) $this->request->post('retain', 0); if ($topic === '' || $topic[0] === '$') { return $this->result->apiError('主题不能为空,且禁止发布到 $SYS 等系统主题'); } // 命名空间隔离:topic 必须以 {username}/ 开头,避免越权发到他人主题 $prefix = $account . '/'; if (strncmp($topic, $prefix, strlen($prefix)) !== 0) { return $this->result->apiError("主题必须以 {$prefix} 开头(命名空间隔离)"); } if (!in_array($qos, [0, 1, 2], true)) { $qos = 0; } $ok = Store::enqueueManual($topic, $payload, $qos, $retain, 'app'); if (!$ok) { return $this->result->apiError('发布失败,请稍后重试'); } return $this->result->apiSuccess(['topic' => $topic]); } /* ============================================================ * 智能设备绑定(华为式:激活与认领分离) * 绑定与激活分离:设备先入云激活领凭证(deviceActivate,设备侧调), * 用户再扫码 SN + 配对码绑定(bindDevice,认领归属)。 * 连接约定:设备 MQTT username=clientId=uuid,密码=激活时下发的 secret * Topic:/devices/{uuid}/state(设备上报·影子) /devices/{uuid}/cmd(下发命令) * ========================================================== */ /** * 绑定设备(用户认领:扫码 SN + 配对码,华为式) * 设备已激活(uuid+secret 已签发)→ 绑定仅建立归属,凭证不变; * 设备未激活(无配对码的老设备)→ 兼容米家式:绑定即激活 * POST /iot/api/client/bindDevice * param: sn(必填), pair_code(设备设置了配对码则必填), title, model, room */ public function bindDevice() { $uid = $this->memberId(); if ($uid <= 0) { return $this->result->apiError('未登录'); } $sn = trim((string) $this->request->post('sn', '')); $pairCode = trim((string) $this->request->post('pair_code', '')); $title = trim((string) $this->request->post('title', '')); $model = trim((string) $this->request->post('model', '')); $room = trim((string) $this->request->post('room', '')); try { $res = DeviceService::bind($sn, $uid, $title, $model, $room, $pairCode); } catch (\Throwable $e) { return $this->result->apiError($e->getMessage()); } return $this->result->apiSuccess($res); } /** * 设备入云激活(华为式,设备侧调用,无需会员登录) * 设备出厂预置 sn + activate_token,首次联网调此接口领取正式连接凭证 uuid/secret(明文仅此一次返回) * POST /iot/api/deviceActivate * param: sn(必填), token(必填,后台预登记生成的激活令牌) * 幂等:已激活设备重复调用返回激活状态但不再次下发 secret(如需换凭证走 resetSecret) */ public function deviceActivate() { $sn = trim((string) $this->request->post('sn', '')); $token = trim((string) $this->request->post('token', '')); try { $res = DeviceService::activate($sn, $token); } catch (\Throwable $e) { return $this->result->apiError($e->getMessage()); } return $this->result->apiSuccess($res); } /** * 我的设备列表(主人 + 共享给我,role 区分 owner/shared,含在线状态 + 影子,剔除密钥) * GET /iot/api/client/myDevices */ public function myDevices() { $uid = $this->memberId(); if ($uid <= 0) { return $this->result->apiError('未登录'); } $list = DeviceService::visibleDevices($uid); return $this->result->setCount(count($list))->apiSuccess($list); } /** * 共享设备给其他会员(仅主人可操作,华为式家庭授权) * POST /iot/api/client/shareDevice * param: device_id(必填), account(被共享者会员账号/手机号), perm(1只读 2可控制,默认2) */ public function shareDevice() { $uid = $this->memberId(); if ($uid <= 0) { return $this->result->apiError('未登录'); } $deviceId = (int) $this->request->post('device_id', 0); $account = trim((string) $this->request->post('account', '')); $perm = (int) $this->request->post('perm', ShareService::PERM_CTRL); if ($deviceId <= 0 || $account === '') { return $this->result->apiError('设备与对方账号必填'); } // 按账号查被共享会员(member_user 主键 uid) $target = Db::name('member_user')->where('account', $account)->find(); if (!$target || empty($target['uid'])) { return $this->result->apiError('对方账号不存在'); } try { $res = ShareService::share($deviceId, $uid, (int) $target['uid'], $perm); } catch (\Throwable $e) { return $this->result->apiError($e->getMessage()); } return $this->result->apiSuccess($res); } /** * 撤销共享(仅主人可操作) * POST /iot/api/client/revokeShare * param: device_id, member_id */ public function revokeShare() { $uid = $this->memberId(); if ($uid <= 0) { return $this->result->apiError('未登录'); } $deviceId = (int) $this->request->post('device_id', 0); $memberId = (int) $this->request->post('member_id', 0); if ($deviceId <= 0 || $memberId <= 0) { return $this->result->apiError('参数无效'); } try { ShareService::revoke($deviceId, $uid, $memberId); } catch (\Throwable $e) { return $this->result->apiError($e->getMessage()); } return $this->result->apiSuccess('已撤销共享'); } /** * 某设备的共享成员列表(主人管理页) * GET /iot/api/client/shareList?device_id=1 */ public function shareList() { $uid = $this->memberId(); if ($uid <= 0) { return $this->result->apiError('未登录'); } $deviceId = (int) $this->request->get('device_id', 0); if ($deviceId <= 0) { return $this->result->apiError('参数无效'); } $dev = DeviceService::findById($deviceId); if (!$dev || (int) $dev['member_id'] !== $uid) { return $this->result->apiError('设备不存在或无权操作'); } $list = ShareService::shareList($deviceId, $uid); return $this->result->apiSuccess(['list' => $list, 'total' => count($list)]); } /** * 解绑设备 * POST /iot/api/client/unbindDevice * param: uuid */ public function unbindDevice() { $uid = $this->memberId(); $uuid = trim((string) $this->request->post('uuid', '')); if ($uid <= 0) { return $this->result->apiError('未登录'); } try { DeviceService::unbind($uuid, $uid); } catch (\Throwable $e) { return $this->result->apiError($e->getMessage()); } return $this->result->apiSuccess('解绑成功,设备可被重新绑定(密钥将重置)'); } /** * 重置设备密钥(旧密钥立即作废,返回新明文一次) * POST /iot/api/client/resetSecret * param: uuid */ public function resetSecret() { $uid = $this->memberId(); $uuid = trim((string) $this->request->post('uuid', '')); if ($uid <= 0) { return $this->result->apiError('未登录'); } try { $res = DeviceService::resetSecret($uuid, $uid); } catch (\Throwable $e) { return $this->result->apiError($e->getMessage()); } return $this->result->apiSuccess($res); } /** * 换取 MQTT 一次性连接凭证(ticket) * App 每次建立/重连 MQTT 前调用;连接时 username=会员账号(返回 account)、password=ticket。 * 凭证默认 60s 有效,过期失效;不直接使用会员明文密码,防反编译泄露。 * POST /iot/api/client/mqttTicket */ public function mqttTicket() { $uid = $this->memberId(); if ($uid <= 0) { return $this->result->apiError('未登录'); } $account = $this->memberAccount(); if ($account === '') { return $this->result->apiError('无法获取当前会员账号'); } // ACL 自愈:ACL 是生成时快照,后来新增的自动规则(如 /status 在线态订阅)不会补到老设备上, // 老用户升级 App 后会订阅被拒。换 ticket 是每次连接必经之路,在此顺手重建,幂等且代价极小。 try { DeviceService::resyncMemberAcl($uid); ShareService::resyncMember($uid); } catch (\Throwable $e) { } $ticket = TicketService::issue($uid, $account); return $this->result->apiSuccess([ 'ticket' => $ticket, 'account' => $account, 'expire_in' => TicketService::TTL, 'expires_at' => time() + TicketService::TTL, ]); } /** * 向绑定的设备下发命令 * POST /iot/api/client/sendCommand * param: uuid, payload(JSON 字符串), qos(可选0/1/2) */ public function sendCommand() { $uid = $this->memberId(); if ($uid <= 0) { return $this->result->apiError('未登录'); } $uuid = trim((string) $this->request->post('uuid', '')); $payload = (string) $this->request->post('payload', ''); $qos = (int) $this->request->post('qos', 0); if ($uuid === '') { return $this->result->apiError('设备uuid不能为空'); } if ($payload === '') { return $this->result->apiError('命令内容不能为空'); } $dev = DeviceService::findByUuid($uuid); if (!$dev) { return $this->result->apiError('设备不存在'); } if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_CTRL)) { return $this->result->apiError('无权控制该设备'); } if (!in_array($qos, [0, 1, 2], true)) { $qos = 0; } $ok = Store::enqueueManual(sprintf(DeviceService::TOPIC_CMD, $uuid), $payload, $qos, 0, 'app'); if (!$ok) { return $this->result->apiError('命令下发失败,请稍后重试'); } return $this->result->apiSuccess(['uuid' => $uuid, 'topic' => sprintf(DeviceService::TOPIC_CMD, $uuid)]); } /** * 读取设备状态(影子:最后上报的 retain 消息 + 在线状态) * GET /iot/api/client/deviceState?uuid=xxx */ public function deviceState() { $uid = $this->memberId(); $uuid = trim((string) $this->request->get('uuid', '')); if ($uid <= 0) { return $this->result->apiError('未登录'); } $dev = DeviceService::findByUuid($uuid); if (!$dev) { return $this->result->apiError('设备不存在'); } if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_VIEW)) { return $this->result->apiError('无权查看该设备'); } return $this->result->apiSuccess([ 'uuid' => $uuid, 'online' => DeviceService::isOnline($uuid), 'shadow' => DeviceService::shadow($uuid), ]); } }