287 lines
14 KiB
PHP
287 lines
14 KiB
PHP
<?php
|
||
/*
|
||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||
* @Date: 2026-08-06 22:05:48
|
||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||
* @LastEditTime: 2026-08-13 16:46:11
|
||
* @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;
|
||
|
||
/**
|
||
* 应用服务类
|
||
*/
|
||
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('appmall.xxx') 永远为 null(退化成兜底默认值)。
|
||
// 已在 addon::getConfig/setConfig 中改为写入默认作用域,此处负责在每个请求初始化时
|
||
// 触发一次 getConfig 把数据库/静态配置注入,后续 config('appmall.xxx') 即可命中。
|
||
// 复用 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\addon::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);
|
||
$this->commands([
|
||
'app' => \ywxapp\command\AppCommand::class,
|
||
'addon:manage' => \ywxapp\command\AddonManage::class,
|
||
'addon:make' => \ywxapp\command\AddonMake::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,
|
||
]);
|
||
$this->loadAddonRelevant();
|
||
// $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');
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 读取已启用插件列表。
|
||
* 供 loadAddonRelevant / 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;
|
||
}
|
||
|
||
private function loadAddonRelevant()
|
||
{
|
||
$conf = $this->getEnabledaddon();
|
||
|
||
// 加载已启用插件的事件、中间件、服务
|
||
$licenseCheck = config('ywxapp.addon_license_check', false);
|
||
foreach ($conf as $info) {
|
||
// 重新校验授权(防止缓存期间授权过期)
|
||
if ($licenseCheck && !empty($info['license']) && !$this->hasValidLicense($info['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);
|
||
}
|
||
app()->bind($services);
|
||
}
|
||
|
||
// A2: 合并而非覆盖整个 addon 配置域,避免清空 config/addon.php 等静态配置中的其它业务键
|
||
$existing = Config::get('addon', []);
|
||
Config::set(array_merge($existing, $conf), 'addon');
|
||
}
|
||
|
||
/**
|
||
* 全局加载已启用插件的路由文件(route/app.php)
|
||
*
|
||
* 使插件能注册跨应用全局路由(如 market 插件的 /appmall/api/addon/* 对外 API 服务端接口),
|
||
* 对齐 mqttbroker 等插件已有的工作模式。仅启用(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');
|
||
// 同时清除配置缓存
|
||
$addon = \think\facade\Config::get('addon', []);
|
||
foreach ($addon as $addon) {
|
||
\think\facade\Cache::delete('addon_config_' . $addon['name']);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 校验插件是否存在有效(未过期)授权(站点级)。
|
||
* 授权数据属于中心站(appmall 插件)领域:插件存在时委托其模型校验;
|
||
* 插件缺席(纯客户机未装市场服务端)或表缺失时放行,避免核心依赖中心表。
|
||
*/
|
||
private function hasValidLicense(string $addon): bool
|
||
{
|
||
if (!class_exists(\addon\appmall\model\AddonLicense::class)) {
|
||
return true; // 无市场插件 → 无授权域,放行
|
||
}
|
||
try {
|
||
$addonId = \ywxapp\model\AddonModel::where('name', $addon)->value('id');
|
||
if (!$addonId) {
|
||
return false;
|
||
}
|
||
return \addon\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; // 授权表不存在等异常 → 保守放行,不阻断插件加载
|
||
}
|
||
}
|
||
}
|