* @Date: 2026-08-06 22:05:48 * @LastEditors: YwxApp * @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; // 授权表不存在等异常 → 保守放行,不阻断插件加载 } } }