Files
YwxAppThink/ywxapp/service/AppService.php
T
yangwx 18101cf11a 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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

343 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/*
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-08-06 22:05:48
* @LastEditors: YwxApp <ywx@ywxapp.cn>
* @LastEditTime: 2026-08-20 20:40:39
* @Description:
* @FilePath: \ywxapp_dev\ywxapp\service\AppService.php
* @CustomString: Copyright (c) 2026 YwxApp
*/
declare(strict_types=1);
namespace ywxapp\service;
use think\facade\Config;
use think\facade\Env;
use think\facade\Event;
use think\facade\Route;
use think\Service;
use ywxapp\middleware\MultiApp;
use ywxapp\utils\UrlBuild;
use think\App;
/**
* 应用服务类
*/
class AppService extends Service
{
public function register()
{
! defined('ADDON_PATH') && define('ADDON_PATH', root_path() . 'addon' . DIRECTORY_SEPARATOR);
! defined('ADDON_STATIC') && define('ADDON_STATIC', public_path() . 'static' . DIRECTORY_SEPARATOR);
! defined('ADDON_TEMP') && define('ADDON_TEMP', root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR);
// 加载插件全局辅助函数层(addon_url / hook / get_addon_*)
require_once __DIR__ . DIRECTORY_SEPARATOR . '/../helper.php';
// Result 必须注册为【单例】:登录时 JwtService::createAccessToken() 经 app()->result->setAccessToken()
// 把 token 存进 Result 实例,而 Login::save 的 $this->result 由 BaseController 构造时解析。
// 若非单例,两处是不同实例,success 响应阶段读不到 token,$this->AccessToken 恒为 null,
// applyTokenCookies() 无法把 token 写入响应 Cookie —— 整页跳转(菜单点击/iframe)读不到 token → 跳登录页。
$this->app->bind('result', \ywxapp\library\Result::class, true);
// JwtService 同样注册为【单例】并委托容器:旧 JwtService::instance() 每次 new 一个新实例,
// 既绕开容器又重复读配置;改为经 app()->jwt 解析(与 Result 一致),便于替换/单测。
$this->app->bind('jwt', \ywxapp\service\JwtService::class, true);
$this->app->bind(abstract: [
// 注意:'result' 不能放这里用 bind(默认非单例)。见下方 singleton 绑定。
// auth 鉴权类:核心后台应用(backend) 直接 AdminAuth;其余默认前台 Auth。
// 插件后台(addon\<插件>\controller\backend\*)路由落在 frontend 应用,
// 容器默认会给前台 Auth——「是否为插件后台」的判定不在闭包里做(闭包执行时机过早,
// request->controller() 可能尚未解析),而是统一在 BackendBase 构造阶段用
// static::class 判定后 bind('auth', AdminAuth::class),保证不依赖 dispatch 时机。
'auth' => function () {
$module = app()->http->getName();
$class = $module === 'backend'
? \ywxapp\library\AdminAuth::class
: \ywxapp\library\Auth::class;
return app()->make($class);
},
'sms' => \ywxapp\library\Sms::class,
'email' => \ywxapp\library\Email::class,
]);
// 预热各启用插件的配置到【默认作用域】,使 config('插件名.xxx') 全局可读。
// 说明:addon::getConfig 之前把配置写入自定义 addonconfig 作用域,而 config() 助手
// 默认从默认作用域读取,导致 config('插件名.xxx') 永远为 null(退化成兜底默认值)。
// 已在 addon::getConfig/setConfig 中改为写入默认作用域,此处负责在每个请求初始化时
// 触发一次 getConfig 把数据库/静态配置注入,后续 config('插件名.xxx') 即可命中。
// 注:appmall 已改为普通应用(配置提升为根级 config/appmall.php 全局加载),不再走本预热。
// 复用 getEnabledaddon()(带 1h 缓存)取启用列表,并直接读配置(静态 readConfig,
// 不再为每个插件实例化 Addon 对象构造 View),降低每请求开销。
\think\facade\Event::listen('AppInit', function ($param) {
foreach ($this->getEnabledaddon() as $info) {
$name = $info['name'] ?? '';
if ($name === '') {
continue;
}
try {
\think\facade\Config::set(\ywxapp\AddonBase::readConfig($name), $name);
} catch (\Throwable $e) {
// 单个插件预热失败不影响主流程
}
}
});
// 默认短信发送监听:开发环境不实际下发(仅记录验证码到日志并回传前端用于调试),
// 生产环境返回 null,交由短信插件(监听 SmsSend 事件)实际发送。
// \think\facade\Event::listen('SmsSend', function ($sms) {
// if (Env::get('app_debug')) {
// \think\facade\Log::info('[DEV] 短信验证码(未实际下发): ' . ($sms['code'] ?? '') . ' -> ' . ($sms['mobile'] ?? ''));
// return true;
// }
// return null;
// });
}
public function boot()
{
$this->app->middleware->import([MultiApp::class], 'global');
// 2. 替换 url() 使用的 UrlBuild 类
$this->app->bind(\think\route\Url::class, UrlBuild::class);
// 框架命令注册(插件命令由 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,
]);
// 统一加载已启用插件:事件 / 中间件 / 服务 / 命令(含插件 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);
$allowedMethods = ['config', 'upload', 'image', 'crawl', 'video', 'audio', 'file', 'listImage', 'listfile'];
if (in_array($action, $allowedMethods) && method_exists($controller, $action)) {
return call_user_func([$controller, $action]);
}
return json(['state' => '无效的action参数']);
})->name('ueditor');
}
/**
* 读取已启用插件列表。
* 供 loadAddons / loadAddonRoutes / AppInit 预热共用。
*
* 缓存策略(修复"禁用插件仍被加载"的缺陷):
* - 旧实现把【已过滤的启用列表】整体缓存 1 小时,导致插件 state 变更(禁用/启用)
* 最长 1 小时内不生效,直接改 info.php 完全不生效,表现为"未启用的页仍加载"。
* - 新实现只缓存【原始插件目录扫描结果】(含 state/license 等原始 info),
* 每次请求按当前 state 重新过滤 + 授权校验。state 变更(含直接改 info.php)立即生效,
* 同时仍避免每请求重复 scandir+include。
* @return array
*/
private function getEnabledaddon(): array
{
$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;
if (!is_dir($addonDir)) {
mkdir($addonDir, 0755, true);
}
$dirs = array_diff(scandir($addonDir), ['.', '..']);
$raw = [];
foreach ($dirs as $dir) {
$info = @include $addonDir . $dir . DIRECTORY_SEPARATOR . 'info.php';
if (is_array($info)) {
$raw[$dir] = $info;
}
}
// 缓存原始扫描结果,有效期1小时(仅用于避免重复目录扫描,不影响 state 实时性)
\think\facade\Cache::set($cacheKey, $raw, 3600);
}
// 每次请求按当前 state 过滤 + 授权校验,保证禁用立即生效
$conf = [];
$licenseCheck = config('ywxapp.addon_license_check', false);
foreach ($raw as $dir => $info) {
if (empty($info['state'])) {
continue;
}
if ($licenseCheck && ! empty($info['license']) && ! $this->hasValidLicense($dir)) {
continue;
}
$conf[] = $info;
}
return $conf;
}
/**
* 统一加载已启用插件:事件 / 中间件 / 服务 / 命令。
*
* 插件 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();
foreach ($conf as $info) {
$name = $info['name'] ?? '';
if ($name === '') {
continue;
}
// 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']);
}
}
// 合并而非覆盖整个 addon 配置域,避免清空 config/addon.php 等静态配置中的其它业务键
$existing = Config::get('addon', []);
Config::set(array_merge($existing, $conf), 'addon');
}
/**
* 全局加载已启用插件的路由文件(route/app.php)
*
* 使插件能注册跨应用全局路由(插件在自身 route/app.php 中声明的路径),
* 对齐 iot 等插件已有的工作模式。仅启用(state=1)的插件参与,
* 使用 include_once 避免重复注册。
*
* 约定(统一路由入口 route/app.php):
* 1. 插件所有路由(业务 + 对外 API)收敛到 route/app.php,用 **相对组名** 书写,
* 本函数统一在外面包一层 `Route::group('<插件名>', ...)`,自动补全插件名前缀:
* Route::group('backend', ...) -> /<插件>/backend/*
* Route::group('developer',...) -> /<插件>/developer/*
* Route::group('api', ...) -> /<插件>/api/*
* 注意:group 前缀是累加的,文件内【不要】再写 /<插件>/ 或 /api/ 这样的前缀(会双重前缀)。
* 2. 全部插件路由都在主应用 boot 阶段经本函数注册(api 子应用不再引入插件 route/app.php),
* 以避免 think-multi-app 把 api 当作应用名叠加前缀(如 /api/<插件>/* 或 /api/api/* 恒 404)。
* 另注:ThinkPHP 的 RuleItem::setRule() 会 ltrim($rule,'/') 后无条件拼上父
* 分组前缀,「前导 / 的绝对规则可跳出分组」在 ThinkPHP 中不成立。
*/
private function loadAddonRoutes(): void
{
// A3: 复用 getEnabledaddon() 缓存(已含启用列表与 info 数据),避免每请求重复 scandir+include info.php
foreach ($this->getEnabledaddon() as $info) {
$dir = $info['name'] ?? '';
if ($dir === '') {
continue;
}
$routeFile = root_path() . 'addon' . DIRECTORY_SEPARATOR . $dir
. DIRECTORY_SEPARATOR . 'route' . DIRECTORY_SEPARATOR . 'app.php';
if (is_file($routeFile)) {
// 自动给插件业务路由加插件名前缀(/<插件>/backend、/<插件>/member)。
Route::group($dir, function () use ($routeFile) {
include_once $routeFile;
});
}
}
}
/**
* 清除插件缓存
* 在插件安装、卸载、启用、禁用时调用
*/
public static function clearAddonCache(): void
{
\think\facade\Cache::delete('addon_loaded_config');
// 同时清除各插件的配置缓存。
// 注意:Config::get('addon') 返回的是框架 addon 组件自身配置(含 multi_app/priority 等),
// 并非插件列表,切勿直接 foreach 它(遍历到 multi_app=true 会触发 "array offset on true")。
// 正确的插件名来源是 addon 主表的 name 列。
try {
$names = \ywxapp\model\CommonAddon::column('name');
foreach ($names as $name) {
\think\facade\Cache::delete('addon_config_' . $name);
}
} catch (\Throwable $e) {
// 数据表可能尚未创建,忽略
\think\facade\Log::warning('clearAddonCache 获取插件列表失败:' . $e->getMessage());
}
}
/**
* 校验插件是否存在有效(未过期)授权(站点级)。
* 授权数据属于中心站(appmall 应用)领域:应用存在时委托其模型校验;
* 应用缺席或表缺失时放行,避免核心依赖中心表。
* 注:appmall 已由 addon/appmall 迁为 app/appmall(中心站专用模块,不随框架包分发):
* 总站装有该模块,走下方模型校验;站长站(客户机)天然无此类,走上方的放行分支,
* 行为与原「插件缺席即放行」设计一致。
*/
private function hasValidLicense(string $addon): bool
{
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 \app\appmall\model\AddonLicense::where('aid', $addonId)
->where(function ($query) {
$query->where('expire_time', 0)->whereOr('expire_time', '>', time());
})
->count() > 0;
} catch (\Throwable $e) {
return true; // 授权表不存在等异常 → 保守放行,不阻断插件加载
}
}
}