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
@@ -3,7 +3,7 @@
|
||||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||||
* @Date: 2026-08-06 22:05:48
|
||||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||||
* @LastEditTime: 2026-08-20 11:19:15
|
||||
* @LastEditTime: 2026-08-20 20:40:39
|
||||
* @Description:
|
||||
* @FilePath: \ywxapp_dev\ywxapp\service\AppService.php
|
||||
* @CustomString: Copyright (c) 2026 YwxApp
|
||||
@@ -20,6 +20,7 @@ use think\facade\Route;
|
||||
use think\Service;
|
||||
use ywxapp\middleware\MultiApp;
|
||||
use ywxapp\utils\UrlBuild;
|
||||
use think\App;
|
||||
|
||||
/**
|
||||
* 应用服务类
|
||||
@@ -62,9 +63,10 @@ class AppService extends Service
|
||||
]);
|
||||
// 预热各启用插件的配置到【默认作用域】,使 config('插件名.xxx') 全局可读。
|
||||
// 说明:addon::getConfig 之前把配置写入自定义 addonconfig 作用域,而 config() 助手
|
||||
// 默认从默认作用域读取,导致 config('appmall.xxx') 永远为 null(退化成兜底默认值)。
|
||||
// 默认从默认作用域读取,导致 config('插件名.xxx') 永远为 null(退化成兜底默认值)。
|
||||
// 已在 addon::getConfig/setConfig 中改为写入默认作用域,此处负责在每个请求初始化时
|
||||
// 触发一次 getConfig 把数据库/静态配置注入,后续 config('appmall.xxx') 即可命中。
|
||||
// 触发一次 getConfig 把数据库/静态配置注入,后续 config('插件名.xxx') 即可命中。
|
||||
// 注:appmall 已改为普通应用(配置提升为根级 config/appmall.php 全局加载),不再走本预热。
|
||||
// 复用 getEnabledaddon()(带 1h 缓存)取启用列表,并直接读配置(静态 readConfig,
|
||||
// 不再为每个插件实例化 Addon 对象构造 View),降低每请求开销。
|
||||
\think\facade\Event::listen('AppInit', function ($param) {
|
||||
@@ -95,33 +97,29 @@ class AppService extends Service
|
||||
public function boot()
|
||||
{
|
||||
$this->app->middleware->import([MultiApp::class], 'global');
|
||||
|
||||
|
||||
// 2. 替换 url() 使用的 UrlBuild 类
|
||||
$this->app->bind(\think\route\Url::class, UrlBuild::class);
|
||||
// 插件命令自动注册约定:
|
||||
// 插件在 info.php 声明 'commands' => ['插件名:动作' => \addon\<插件名>\command\Xxx::class]
|
||||
// 命令类 extends think\console\Command,psr-4 (addon\ => addon) 自动加载。
|
||||
// 执行链:Console 构造 → app->initialize() → BootService 触发本 boot() →
|
||||
// $this->commands() 挂 Console::starting 回调 → Console::run() 统一 addCommands。
|
||||
// 坏类名/非 Command 子类会被 addCommands 跳过,单个插件声明错误不影响整体。
|
||||
$commands = [
|
||||
// 框架命令注册(插件命令由 loadAddons() 统一注册)。
|
||||
// 执行链:Console 构造 → app->initialize() → BootService 触发本 boot() →
|
||||
// $this->commands() 挂 Console::starting 回调 → Console::run() 统一 addCommands。
|
||||
// 坏类名/非 Command 子类会被 addCommands 跳过,单个插件声明错误不影响整体。
|
||||
$this->commands([
|
||||
'app' => \ywxapp\command\AppCommand::class,
|
||||
'addon:manage' => \ywxapp\command\AddonManage::class,
|
||||
'addon:make' => \ywxapp\command\AddonMake::class,
|
||||
'addon:crud' => \ywxapp\command\AddonCrud::class,
|
||||
'db:backup' => \ywxapp\command\DbBackupCommand::class,
|
||||
'attachment:clean' => \ywxapp\command\AttachmentCleanCommand::class,
|
||||
'addon:health' => \ywxapp\command\AddonHealth::class,
|
||||
'addon:license-check' => \ywxapp\command\AddonLicenseCheck::class,
|
||||
'addon:repair' => \ywxapp\command\AddonRepair::class,
|
||||
'ywxapp:upgrade' => \ywxapp\command\Upgrade::class,
|
||||
];
|
||||
foreach ($this->getEnabledaddon() as $info) {
|
||||
$addonCmds = $info['commands'] ?? [];
|
||||
if (is_array($addonCmds) && $addonCmds) {
|
||||
$commands = array_merge($commands, $addonCmds);
|
||||
}
|
||||
}
|
||||
$this->commands($commands);
|
||||
$this->loadAddonRelevant();
|
||||
// $this->loadAddonRoutes();
|
||||
]);
|
||||
// 统一加载已启用插件:事件 / 中间件 / 服务 / 命令(含插件 info 加载与启用判断)
|
||||
$this->loadAddons();
|
||||
// 统一注册已启用插件的 route/app.php(包一层 Route::group('<插件名>') 补全前缀)
|
||||
$this->loadAddonRoutes();
|
||||
Route::rule('ueditor', function () {
|
||||
$action = request()->param('action', 'config');
|
||||
$controller = app()->make(\ywxapp\controller\UeditorPlus::class);
|
||||
@@ -133,11 +131,9 @@ class AppService extends Service
|
||||
})->name('ueditor');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 读取已启用插件列表。
|
||||
* 供 loadAddonRelevant / loadAddonRoutes / AppInit 预热共用。
|
||||
* 供 loadAddons / loadAddonRoutes / AppInit 预热共用。
|
||||
*
|
||||
* 缓存策略(修复"禁用插件仍被加载"的缺陷):
|
||||
* - 旧实现把【已过滤的启用列表】整体缓存 1 小时,导致插件 state 变更(禁用/启用)
|
||||
@@ -151,13 +147,11 @@ class AppService extends Service
|
||||
{
|
||||
$cacheKey = 'addon_loaded_config';
|
||||
$raw = \think\facade\Cache::get($cacheKey);
|
||||
|
||||
// 兼容旧缓存格式(旧版缓存的是数字索引的启用列表而非目录名关联的原始 info):
|
||||
// 旧格式无目录名键,会导致 hasValidLicense 误判,故直接按旧缓存失效重新扫描。
|
||||
if (! empty($raw) && array_keys($raw) === range(0, count($raw) - 1)) {
|
||||
$raw = null;
|
||||
}
|
||||
|
||||
// 缓存未命中时,重新扫描插件目录(保留原始 info,不做 state 过滤)
|
||||
if ($raw === null || ! is_array($raw)) {
|
||||
$addonDir = root_path() . 'addon' . DIRECTORY_SEPARATOR;
|
||||
@@ -175,7 +169,6 @@ class AppService extends Service
|
||||
// 缓存原始扫描结果,有效期1小时(仅用于避免重复目录扫描,不影响 state 实时性)
|
||||
\think\facade\Cache::set($cacheKey, $raw, 3600);
|
||||
}
|
||||
|
||||
// 每次请求按当前 state 过滤 + 授权校验,保证禁用立即生效
|
||||
$conf = [];
|
||||
$licenseCheck = config('ywxapp.addon_license_check', false);
|
||||
@@ -188,34 +181,73 @@ class AppService extends Service
|
||||
}
|
||||
$conf[] = $info;
|
||||
}
|
||||
|
||||
return $conf;
|
||||
}
|
||||
|
||||
private function loadAddonRelevant()
|
||||
/**
|
||||
* 统一加载已启用插件:事件 / 中间件 / 服务 / 命令。
|
||||
*
|
||||
* 插件 info 扫描与启用判断(state=1)+ 授权校验由 getEnabledaddon() 完成,
|
||||
* 本方法消费过滤结果,统一执行四类加载。插件在 info.php 声明:
|
||||
* - 'events' => ['bind'=>[], 'listen'=>[], 'subscribe'=>[]] → 注册事件
|
||||
* - 'middleware' => ['alias'=>[], 'priority'=>[]] 或中间件类数组 → 注册中间件(app 作用域)
|
||||
* - 'services' => [\addon\<名>\service\Xxx::class, ...] → 数字列表:真正注册并 boot
|
||||
* (触发 register() 与 boot(),boot() 内可用 $this->commands() 注册命令)
|
||||
* ['abstract' => \addon\<名>\service\Xxx::class] → 关联形式:仅容器绑定(不实例化)
|
||||
* - 'commands' => ['插件名:动作' => \addon\<名>\command\Xxx::class] → 注册命令(psr-4 自动加载)
|
||||
*
|
||||
* 说明:
|
||||
* - boot() 阶段新注册的服务不会进入 App::boot() 的遍历队列,故 register 后须手动 bootService()。
|
||||
* - 坏类名/非 Command 子类/实例化失败会被跳过,单个插件声明错误不影响整体加载。
|
||||
* - 本方法替代旧 loadAddonRelevant():services 由 bind() 改为真正 register(),
|
||||
* 修复"插件 Service 的 register/boot 从未执行"的隐患;并统一收敛插件命令注册。
|
||||
*/
|
||||
private function loadAddons(): void
|
||||
{
|
||||
$conf = $this->getEnabledaddon();
|
||||
|
||||
// 加载已启用插件的事件、中间件、服务
|
||||
$licenseCheck = config('ywxapp.addon_license_check', false);
|
||||
foreach ($conf as $info) {
|
||||
// 重新校验授权(防止缓存期间授权过期)
|
||||
if ($licenseCheck && !empty($info['license']) && !$this->hasValidLicense($info['name'])) {
|
||||
$name = $info['name'] ?? '';
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
app()->loadEvent($info['events'] ?? []);
|
||||
app()->middleware->import($info['middleware'] ?? [], 'app');
|
||||
// Container::bind 要求 ['abstract' => 'concrete'] 字符串键映射;
|
||||
// 兼容 info.php 中以数字列表声明的 services([Service::class]),转为自绑定。
|
||||
$services = $info['services'] ?? [];
|
||||
if (! empty($services) && array_keys($services) === range(0, count($services) - 1)) {
|
||||
$services = array_combine($services, $services);
|
||||
// 1. 事件
|
||||
if (! empty($info['events'])) {
|
||||
app()->loadEvent($info['events']);
|
||||
}
|
||||
|
||||
// 2. 中间件
|
||||
if (! empty($info['middleware'])) {
|
||||
app()->middleware->import($info['middleware'], 'app');
|
||||
}
|
||||
|
||||
// 3. 服务:数字列表真正注册并 boot;关联形式仅容器绑定
|
||||
$services = $info['services'] ?? [];
|
||||
if (! empty($services)) {
|
||||
if (array_keys($services) === range(0, count($services) - 1)) {
|
||||
foreach ($services as $svcClass) {
|
||||
try {
|
||||
$svc = $this->app->make($svcClass);
|
||||
} catch (\Throwable $e) {
|
||||
continue; // 类不存在/实例化失败,跳过该服务
|
||||
}
|
||||
if ($svc instanceof \think\Service) {
|
||||
$this->app->register($svc);
|
||||
// boot() 阶段新注册的服务不会进入 App::boot() 的遍历队列,须手动 boot
|
||||
$this->app->bootService($svc);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->app->bind($services);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 命令
|
||||
if (! empty($info['commands']) && is_array($info['commands'])) {
|
||||
$this->commands($info['commands']);
|
||||
}
|
||||
app()->bind($services);
|
||||
}
|
||||
|
||||
// A2: 合并而非覆盖整个 addon 配置域,避免清空 config/addon.php 等静态配置中的其它业务键
|
||||
// 合并而非覆盖整个 addon 配置域,避免清空 config/addon.php 等静态配置中的其它业务键
|
||||
$existing = Config::get('addon', []);
|
||||
Config::set(array_merge($existing, $conf), 'addon');
|
||||
}
|
||||
@@ -223,8 +255,8 @@ class AppService extends Service
|
||||
/**
|
||||
* 全局加载已启用插件的路由文件(route/app.php)
|
||||
*
|
||||
* 使插件能注册跨应用全局路由(如 market 插件的 /appmall/api/addon/* 对外 API 服务端接口),
|
||||
* 对齐 mqttbroker 等插件已有的工作模式。仅启用(state=1)的插件参与,
|
||||
* 使插件能注册跨应用全局路由(插件在自身 route/app.php 中声明的路径),
|
||||
* 对齐 iot 等插件已有的工作模式。仅启用(state=1)的插件参与,
|
||||
* 使用 include_once 避免重复注册。
|
||||
*
|
||||
* 约定(统一路由入口 route/app.php):
|
||||
@@ -282,20 +314,23 @@ class AppService extends Service
|
||||
|
||||
/**
|
||||
* 校验插件是否存在有效(未过期)授权(站点级)。
|
||||
* 授权数据属于中心站(appmall 插件)领域:插件存在时委托其模型校验;
|
||||
* 插件缺席(纯客户机未装市场服务端)或表缺失时放行,避免核心依赖中心表。
|
||||
* 授权数据属于中心站(appmall 应用)领域:应用存在时委托其模型校验;
|
||||
* 应用缺席或表缺失时放行,避免核心依赖中心表。
|
||||
* 注:appmall 已由 addon/appmall 迁为 app/appmall(中心站专用模块,不随框架包分发):
|
||||
* 总站装有该模块,走下方模型校验;站长站(客户机)天然无此类,走上方的放行分支,
|
||||
* 行为与原「插件缺席即放行」设计一致。
|
||||
*/
|
||||
private function hasValidLicense(string $addon): bool
|
||||
{
|
||||
if (!class_exists(\addon\appmall\model\AddonLicense::class)) {
|
||||
return true; // 无市场插件 → 无授权域,放行
|
||||
if (!class_exists(\app\appmall\model\AddonLicense::class)) {
|
||||
return true; // 无 appmall 应用 → 无授权域,放行
|
||||
}
|
||||
try {
|
||||
$addonId = \ywxapp\model\CommonAddon::where('name', $addon)->value('id');
|
||||
if (!$addonId) {
|
||||
return false;
|
||||
}
|
||||
return \addon\appmall\model\AddonLicense::where('aid', $addonId)
|
||||
return \app\appmall\model\AddonLicense::where('aid', $addonId)
|
||||
->where(function ($query) {
|
||||
$query->where('expire_time', 0)->whereOr('expire_time', '>', time());
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user