Files

287 lines
14 KiB
PHP
Raw Permalink 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-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; // 授权表不存在等异常 → 保守放行,不阻断插件加载
}
}
}