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:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+176
View File
@@ -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;
}
}
}