安全(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(市场 / 开发者中心 / 审核 / 分润)
177 lines
6.8 KiB
PHP
177 lines
6.8 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;
|
||
|
||
/**
|
||
* OTA 固件升级服务
|
||
*
|
||
* 流程:
|
||
* 后台/App 上传固件 → 计算 SHA256/大小 → 存 iot_ota
|
||
* → publishOta 选中设备 → 下发 /devices/{uuid}/ota 指令
|
||
* → 设备下载/校验/升级 → 上报 /devices/{uuid}/ota/report 进度
|
||
* → onReport 解析进度,写 device_log + 失败告警/通知
|
||
*
|
||
* 升级指令 payload:
|
||
* {"version":"1.2.0","url":"https://...","hash":"sha256","size":12345}
|
||
*/
|
||
class OtaService
|
||
{
|
||
/** OTA 下发主题 */
|
||
public const TOPIC_OTA = '/devices/%s/ota';
|
||
/** OTA 进度上报主题 */
|
||
public const TOPIC_REPORT = '/devices/%s/ota/report';
|
||
|
||
/** 确保 ota 表存在(自愈) */
|
||
public static function ensureTable(): bool
|
||
{
|
||
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
|
||
try {
|
||
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_ota` (
|
||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||
`model` varchar(64) NOT NULL DEFAULT '',
|
||
`version` varchar(32) NOT NULL DEFAULT '', `filename` varchar(128) NOT NULL DEFAULT '',
|
||
`file_hash` varchar(64) NOT NULL DEFAULT '', `size` int unsigned NOT NULL DEFAULT '0',
|
||
`url` varchar(255) NOT NULL DEFAULT '', `remark` varchar(255) DEFAULT NULL,
|
||
`status` tinyint(1) NOT NULL DEFAULT '1',
|
||
`create_at` int DEFAULT NULL,
|
||
PRIMARY KEY (`id`), KEY `idx_model` (`model`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||
return true;
|
||
} catch (\Throwable $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 登记固件(后台上传后调用)
|
||
* @return int OTA记录ID
|
||
*/
|
||
public static function add(array $data): int
|
||
{
|
||
self::ensureTable();
|
||
$version = trim((string) ($data['version'] ?? ''));
|
||
if ($version === '') {
|
||
throw new \InvalidArgumentException('版本号不能为空');
|
||
}
|
||
$file = trim((string) ($data['filename'] ?? ''));
|
||
$url = trim((string) ($data['url'] ?? ''));
|
||
$hash = trim((string) ($data['file_hash'] ?? ''));
|
||
$size = max(0, (int) ($data['size'] ?? 0));
|
||
if ($file === '' || $url === '') {
|
||
throw new \InvalidArgumentException('固件文件与下载地址不能为空');
|
||
}
|
||
// 校验哈希格式(SHA256 为 64 位 hex;允许手动录入,上传时自动计算)
|
||
if ($hash !== '' && !preg_match('/^[a-f0-9]{64}$/i', $hash)) {
|
||
throw new \InvalidArgumentException('文件哈希须为 SHA256(64位hex)');
|
||
}
|
||
return (int) Db::name('iot_ota')->insertGetId([
|
||
'model' => mb_substr(trim((string) ($data['model'] ?? '')), 0, 64),
|
||
'version' => mb_substr($version, 0, 32),
|
||
'filename' => mb_substr($file, 0, 128),
|
||
'file_hash' => strtolower($hash),
|
||
'size' => $size,
|
||
'url' => mb_substr($url, 0, 255),
|
||
'remark' => mb_substr((string) ($data['remark'] ?? ''), 0, 255),
|
||
'status' => isset($data['status']) && !$data['status'] ? 0 : 1,
|
||
'create_at' => time(),
|
||
]);
|
||
}
|
||
|
||
/** 固件列表(按型号过滤) */
|
||
public static function list(string $model = ''): array
|
||
{
|
||
self::ensureTable();
|
||
$q = Db::name('iot_ota');
|
||
if ($model !== '') {
|
||
$q->where('model', $model);
|
||
}
|
||
return $q->order('id', 'desc')->select()->toArray();
|
||
}
|
||
|
||
/** 删除固件 */
|
||
public static function remove(int $otaId): void
|
||
{
|
||
Db::name('iot_ota')->where('id', $otaId)->delete();
|
||
}
|
||
|
||
/**
|
||
* 下发升级指令到指定设备(逐个 outbox 入队,离线自动补发)
|
||
* @return int 成功入队数
|
||
*/
|
||
public static function publish(int $otaId, array $deviceUuids): int
|
||
{
|
||
$ota = Db::name('iot_ota')->where('id', $otaId)->find();
|
||
if (!$ota || !$ota['status']) {
|
||
throw new \RuntimeException('固件不存在或已停用');
|
||
}
|
||
$payload = (string) json_encode([
|
||
'version' => $ota['version'],
|
||
'url' => $ota['url'],
|
||
'hash' => $ota['file_hash'],
|
||
'size' => (int) $ota['size'],
|
||
], JSON_UNESCAPED_SLASHES);
|
||
$ok = 0;
|
||
foreach (array_unique($deviceUuids) as $uuid) {
|
||
if ($uuid === '') {
|
||
continue;
|
||
}
|
||
if (Store::enqueueManual(sprintf(self::TOPIC_OTA, $uuid), $payload, 1, 0, 'ota')) {
|
||
$ok++;
|
||
}
|
||
}
|
||
return $ok;
|
||
}
|
||
|
||
/**
|
||
* 设备 OTA 进度上报处理(Broker 钩子 /ota/report 调用)
|
||
* payload: {"state":"downloading|installing|done|fail","progress":42,"version":"1.2.0"}
|
||
*/
|
||
public static function onReport(string $uuid, string $payload): void
|
||
{
|
||
DeviceLogService::log(sprintf(self::TOPIC_REPORT, $uuid), $payload, 'in');
|
||
$data = json_decode($payload, true);
|
||
if (!is_array($data)) {
|
||
return;
|
||
}
|
||
$state = (string) ($data['state'] ?? '');
|
||
$dev = DeviceService::findByUuid($uuid);
|
||
if (!$dev || (int) $dev['member_id'] <= 0) {
|
||
return;
|
||
}
|
||
$memberId = (int) $dev['member_id'];
|
||
$version = (string) ($data['version'] ?? '');
|
||
switch ($state) {
|
||
case 'done':
|
||
AlertService::add($memberId, 'ota', 1, '固件升级完成',
|
||
'设备「' . $dev['title'] . '」已升级到 ' . $version, $uuid);
|
||
break;
|
||
case 'fail':
|
||
AlertService::add($memberId, 'ota', 3, '固件升级失败',
|
||
'设备「' . $dev['title'] . '」升级失败:' . mb_substr((string) ($data['error'] ?? '未知原因'), 0, 200), $uuid);
|
||
break;
|
||
default:
|
||
// downloading/installing 进度 → 实时通知(不写告警表,避免刷屏)
|
||
AlertService::push($memberId, 'ota', (string) json_encode([
|
||
'type' => 'progress',
|
||
'uuid' => $uuid,
|
||
'state' => $state,
|
||
'progress' => (int) ($data['progress'] ?? -1),
|
||
'version' => $version,
|
||
'time' => time(),
|
||
]));
|
||
break;
|
||
}
|
||
}
|
||
}
|