安全(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(市场 / 开发者中心 / 审核 / 分润)
284 lines
12 KiB
PHP
284 lines
12 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;
|
||
|
||
/**
|
||
* 设备共享服务(华为智慧生活式"家庭授权"模型)
|
||
*
|
||
* 关系:设备(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();
|
||
}
|
||
}
|