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(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -0,0 +1,176 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user