// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\iot\service; use think\facade\Db; /** * 设备共享服务(华为智慧生活式"家庭授权"模型) * * 关系:设备(owner) → 共享 → 成员(member) * 权限分级:PERM_VIEW=1 查看状态(state 订阅 + 影子/日志查询) * PERM_CTRL=2 查看+控制(另含 cmd 发布 + notify 告警订阅) * 消息层放行:为被共享者的会员账号动态生成 ACL 规则(remark 前缀 auto:share:,可追踪、可整组清理), * 与 DeviceService 的 auto:member: 规则同一套机制,共享撤销时按 remark 清掉。 */ class ShareService { /** 只读:查看状态/影子/日志 */ public const PERM_VIEW = 1; /** 查看+控制:订阅 state + 发布 cmd + 告警订阅 */ public const PERM_CTRL = 2; /** 确保 share 表存在(自愈,老库/未执行 install.sql 也可用) */ public static function ensureTable(): bool { $prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_'; try { Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_share` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `device_id` int unsigned NOT NULL DEFAULT '0' COMMENT '设备ID(iot_device.id)', `owner_id` int unsigned NOT NULL DEFAULT '0' COMMENT '设备主人会员ID', `member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '被共享会员ID', `perm` tinyint(1) NOT NULL DEFAULT '2' COMMENT '1只读 2可控制', `status` 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_member` (`device_id`, `member_id`), KEY `idx_member` (`member_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); return true; } catch (\Throwable $e) { return false; } } /** * 建立/更新共享(仅设备主人可操作) * @return array 共享记录 * @throws \InvalidArgumentException|\RuntimeException */ public static function share(int $deviceId, int $ownerId, int $memberId, int $perm = self::PERM_CTRL): array { self::ensureTable(); if ($deviceId <= 0 || $ownerId <= 0 || $memberId <= 0) { throw new \InvalidArgumentException('参数无效'); } if ($memberId === $ownerId) { throw new \InvalidArgumentException('不能共享给自己'); } if (!in_array($perm, [self::PERM_VIEW, self::PERM_CTRL], true)) { $perm = self::PERM_CTRL; } $dev = DeviceService::findById($deviceId); if (!$dev || (int) $dev['member_id'] !== $ownerId) { throw new \RuntimeException('设备不存在或无权操作'); } $now = time(); $row = Db::name('iot_share') ->where('device_id', $deviceId)->where('member_id', $memberId) ->find(); if ($row) { Db::name('iot_share')->where('id', $row['id'])->update([ 'perm' => $perm, 'status' => 1, 'update_at' => $now, ]); $shareId = (int) $row['id']; } else { $shareId = (int) Db::name('iot_share')->insertGetId([ 'device_id' => $deviceId, 'owner_id' => $ownerId, 'member_id' => $memberId, 'perm' => $perm, 'status' => 1, 'create_at' => $now, 'update_at' => $now, ]); } self::syncShareAcl((string) $dev['uuid'], $memberId, $perm); return [ 'id' => $shareId, 'device_id' => $deviceId, 'owner_id' => $ownerId, 'member_id' => $memberId, 'perm' => $perm, 'uuid' => (string) $dev['uuid'], ]; } /** 撤销共享(仅设备主人可操作) */ public static function revoke(int $deviceId, int $ownerId, int $memberId): void { $dev = DeviceService::findById($deviceId); if (!$dev || (int) $dev['member_id'] !== $ownerId) { throw new \RuntimeException('设备不存在或无权操作'); } Db::name('iot_share') ->where('device_id', $deviceId)->where('member_id', $memberId) ->delete(); self::clearShareAcl((string) $dev['uuid'], $memberId); } /** 某设备的共享成员列表(主人管理页用) */ public static function shareList(int $deviceId, int $ownerId): array { self::ensureTable(); $rows = Db::name('iot_share') ->where('device_id', $deviceId)->where('owner_id', $ownerId) ->where('status', 1) ->order('id', 'desc') ->select() ->toArray(); foreach ($rows as &$r) { $r['account'] = DeviceService::memberAccount((int) $r['member_id']); $r['perm'] = (int) $r['perm']; } return $rows; } /** 共享给我的设备列表(含在线状态 + 影子,剔除密钥) */ public static function sharedToMe(int $uid): array { self::ensureTable(); $prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_'; try { $rows = Db::query( "SELECT s.perm, s.create_at AS share_at, d.* FROM {$prefix}iot_share s " . "INNER JOIN {$prefix}iot_device d ON d.id = s.device_id " . "WHERE s.member_id = ? AND s.status = 1 ORDER BY s.id DESC", [$uid] ); } catch (\Throwable $e) { return []; } $list = []; foreach ($rows as $d) { $list[] = [ 'id' => (int) $d['id'], 'sn' => (string) $d['sn'], 'uuid' => (string) $d['uuid'], 'title' => (string) $d['title'], 'model' => (string) $d['model'], 'room' => (string) $d['room'], 'status' => (int) $d['status'], 'role' => 'shared', 'perm' => (int) $d['perm'], 'shared_by' => DeviceService::memberAccount((int) $d['owner_id']) ?: (string) $d['owner_id'], 'owner_id' => (int) $d['owner_id'], 'online' => DeviceService::isOnline((string) $d['uuid']), 'shadow' => DeviceService::shadow((string) $d['uuid']), 'topics' => DeviceService::deviceTopics((string) $d['uuid']), 'share_at' => (int) ($d['share_at'] ?? 0), ]; } return $list; } /** * 权限判断:主人恒通过;被共享者按 perm 分级。 * @param int $needPerm 需要的最低权限(PERM_VIEW 或 PERM_CTRL) */ public static function canAccess(string $uuid, int $uid, int $needPerm = self::PERM_VIEW): bool { if ($uid <= 0 || $uuid === '') { return false; } $dev = DeviceService::findByUuid($uuid); if (!$dev) { return false; } if ((int) $dev['member_id'] === $uid) { return true; // 主人 } $share = Db::name('iot_share') ->where('device_id', (int) $dev['id']) ->where('member_id', $uid) ->where('status', 1) ->find(); return (bool) $share && (int) $share['perm'] >= $needPerm; } /** 设备删除时清理共享关系与 ACL(DeviceService::remove 调用) */ public static function removeByDevice(int $deviceId, string $uuid): void { try { $members = Db::name('iot_share')->where('device_id', $deviceId)->column('member_id'); Db::name('iot_share')->where('device_id', $deviceId)->delete(); foreach ($members as $mid) { self::clearShareAcl($uuid, (int) $mid); } } catch (\Throwable $e) { } } /** * 重新同步某会员「被共享」的全部设备 ACL(自愈,与 DeviceService::resyncMemberAcl 配套) * 场景:新增自动 ACL 规则(如 /status 订阅)后,老共享关系不会自动补规则。 * * @return int 同步的共享数 */ public static function resyncMember(int $memberId): int { if ($memberId <= 0) { return 0; } self::ensureTable(); $n = 0; try { $rows = Db::name('iot_share') ->where('member_id', $memberId)->where('status', 1) ->select()->toArray(); foreach ($rows as $r) { $dev = DeviceService::findById((int) $r['device_id']); if (!$dev || (string) ($dev['uuid'] ?? '') === '') { continue; } $uuid = (string) $dev['uuid']; // 已有 :status 规则则跳过(老 ACL 里没有才需要补) $mark = 'auto:share:' . $uuid . ':' . $memberId . ':status'; $exist = Db::name('iot_acl')->where('remark', $mark)->count(); if ((int) $exist > 0) { continue; } self::syncShareAcl($uuid, $memberId, (int) $r['perm']); $n++; } } catch (\Throwable $e) { } return $n; } /* ============================================================ * 内部:ACL 维护(与 DeviceService 的 auto:member: 同机制) * ========================================================== */ /** 生成共享 ACL:perm>=1 订阅 state;perm>=2 再给 cmd 发布 + notify 告警订阅 */ protected static function syncShareAcl(string $uuid, int $memberId, int $perm): void { $account = DeviceService::memberAccount($memberId); if ($account === '') { return; // 无会员账号则无法生成用户级 ACL(如幽灵账号),静默跳过 } self::clearShareAcl($uuid, $memberId); $now = time(); $rows = [[ 'target_type' => 'user', 'target' => $account, 'topic' => sprintf(DeviceService::TOPIC_STATE, $uuid), 'access' => 1, 'allow' => 1, 'sort' => 200, 'remark' => 'auto:share:' . $uuid . ':' . $memberId . ':state', 'create_at' => $now, ], [ 'target_type' => 'user', 'target' => $account, 'topic' => sprintf(DeviceService::TOPIC_STATUS, $uuid), 'access' => 1, 'allow' => 1, 'sort' => 200, 'remark' => 'auto:share:' . $uuid . ':' . $memberId . ':status', 'create_at' => $now, ]]; if ($perm >= self::PERM_CTRL) { $rows[] = [ 'target_type' => 'user', 'target' => $account, 'topic' => sprintf(DeviceService::TOPIC_CMD, $uuid), 'access' => 2, 'allow' => 1, 'sort' => 200, 'remark' => 'auto:share:' . $uuid . ':' . $memberId . ':cmd', 'create_at' => $now, ]; $rows[] = [ 'target_type' => 'user', 'target' => $account, 'topic' => '/notify/' . $account . '/#', 'access' => 1, 'allow' => 1, 'sort' => 200, 'remark' => 'auto:share:' . $uuid . ':' . $memberId . ':notify', 'create_at' => $now, ]; } Db::name('iot_acl')->insertAll($rows); } /** 清掉某成员在某设备上的共享 ACL(按 remark 前缀) */ protected static function clearShareAcl(string $uuid, int $memberId): void { Db::name('iot_acl')->where('remark', 'like', 'auto:share:' . $uuid . ':' . $memberId . '%')->delete(); } }