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

+135
View File
@@ -0,0 +1,135 @@
<?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\controller;
use think\facade\Db;
use ywxapp\controller\FrontendBase;
/**
* IoT 插件前台展示页(免登录门面,对外介绍功能/能力/优势)
*
* GET /iot/index/index
*
* 遵循插件前台开发约定(与 articles/Index 同构):
* - 继承 FrontendBase:视图根自动切到 addon/iot/view/frontend/,
* noNeedLogin 默认 ['*'] 游客放行,fetch 叠加皮肤覆盖层;
* - 控制器放 controller/ 根(非 api/backend 子目录),
* 路由用字符串目标 'addon\iot\controller\Index@index'。
* 数据侧只读聚合指标,全部 try/catch 兜底 —— 任何一张表缺失/异常都
* 降级为 0,绝不 500(门面页的可用性 > 数据完整性)。
*/
class Index extends FrontendBase
{
/**
* 展示页
*/
public function index()
{
$this->assign('stats', $this->liveStats());
$this->assign('apiGroups', $this->apiCatalog());
$this->assign('now', date('Y-m-d H:i'));
return $this->fetch('index/index');
}
/**
* 实时聚合指标(每项独立兜底,缺表/异常返回 0)
*/
protected function liveStats(): array
{
$count = function (string $table, array $where = []): int {
try {
return (int) Db::name($table)->where($where)->count();
} catch (\Throwable $e) {
return 0;
}
};
// Broker 快照(iot_stats id=1,Broker 进程每 2s 写入)
$broker = ['uptime' => 0, 'clients_online' => 0, 'messages_received' => 0, 'retained' => 0];
try {
$row = Db::name('iot_stats')->where('id', 1)->find();
if ($row) {
$broker = array_merge($broker, array_intersect_key($row, $broker));
}
} catch (\Throwable $e) {
}
return [
'uptime' => $this->fmtUptime((int) $broker['uptime']),
'online' => (int) $broker['clients_online'],
'recv' => (int) $broker['messages_received'],
'retained' => (int) $broker['retained'],
'devices' => $count('iot_device'),
'points' => $count('iot_sensor_point'),
'alerts' => $count('iot_alert'),
'scenes' => $count('iot_scene'),
'shares' => $count('iot_share', ['status' => 1]),
'ota_pkgs' => $count('iot_ota'),
];
}
/** 供模板格式化运行时长(时:分:秒) */
protected function fmtUptime(int $s): string
{
return sprintf('%d:%02d:%02d', intdiv($s, 3600), intdiv($s % 3600, 60), $s % 60);
}
/**
* App API 能力域统计:从 route/app.php 自动解析(路由唯一真源)。
* 宣传页只展示功能域划分与接口数量——不输出任何端点路径 / 主题字符串
* (公开页面不做攻击者的侦察地图;详细契约见 docs/guide/iot_api_reference.md)。
*
* 解析约定:只匹配数组目标规则(本插件全部 App API 均为 [Xxx::class,'action'] 书写;
* backend/* 与前台页是字符串目标,天然不会被误收)。
* 同一 controller+action 只计首次出现:新域前缀 URL 注册在前、文件末尾的
* 旧 client/* 兼容别名在后,去重避免兼容别名把数量翻倍。
*/
protected function apiCatalog(): array
{
try {
$file = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'route' . DIRECTORY_SEPARATOR . 'app.php';
if (!is_file($file) || !preg_match_all(
"#Route::(get|post)\(\s*'([^']+)'\s*,\s*\[(\w+)::class\s*,\s*'(\w+)'\]#",
(string) @file_get_contents($file),
$ms,
PREG_SET_ORDER
)) {
return [];
}
} catch (\Throwable $e) {
return [];
}
$domains = [
'mqtt' => ['ico' => '📡', 'label' => 'Broker 运维', 'blurb' => '运行状态、指标快照与消息直发'],
'client' => ['ico' => '📱', 'label' => '设备与会员', 'blurb' => '绑定认领、共享授权、短时票据与命令下发'],
'monitor' => ['ico' => '📈', 'label' => '实时监测', 'blurb' => '历史曲线、分钟级聚合、告警与设备日志'],
'scene' => ['ico' => '⚡', 'label' => '场景联动', 'blurb' => '规则引擎、设备分组与批量控制'],
'ota' => ['ico' => '🚀', 'label' => '固件升级', 'blurb' => '升级包管理与分批推送进度跟踪'],
'_root' => ['ico' => '🔑', 'label' => '设备入云激活', 'blurb' => '出厂预置令牌换领一机一密凭证'],
];
$groups = [];
$seen = []; // "controller/action" => true:兼容别名去重
foreach ($ms as $m) {
[, , $rule, $ctrl, $action] = $m;
$key = $ctrl . '/' . $action;
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$domain = strpos($rule, '/') === false ? '_root' : strtok($rule, '/');
if (!isset($domains[$domain])) {
continue; // 未知前缀不入目录
}
$groups[$domain] = $groups[$domain] ?? ($domains[$domain] + ['count' => 0]);
$groups[$domain]['count']++;
}
return array_values($groups);
}
}