287 lines
9.5 KiB
PHP
287 lines
9.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace ywxapp\middleware;
|
|
|
|
use Closure;
|
|
use think\App;
|
|
use think\exception\HttpException;
|
|
use think\Request;
|
|
use think\Response;
|
|
|
|
/**
|
|
* 多根目录多应用中间件
|
|
*
|
|
* 核心语义:
|
|
* - "blog" → 按 roots顺序查找,找到第一个匹配即用
|
|
* - "app2:blog" → 强制在 /app2/blog找,找不到直接 404
|
|
* - 都不支持"先查 app找不到再降级到 app2"再降级这种串行写法
|
|
* (显式前缀必须严格匹配,避免行为不可预测)
|
|
*/
|
|
class MultiApp
|
|
{
|
|
protected App $app;
|
|
|
|
/** @var array<string,string> 根目录标识 => 绝对路径 */
|
|
protected array $roots = [];
|
|
|
|
/** @var array<string,string> 根目录标识 => 命名空间前缀 */
|
|
protected array $namespaces = [];
|
|
|
|
public function __construct(App $app)
|
|
{
|
|
$this->app = $app;
|
|
$this->roots = $app->config->get('app.app_roots', [
|
|
'app' => $app->getBasePath(),
|
|
'addon' => $app->getRootPath() . 'addon' . DIRECTORY_SEPARATOR,
|
|
]);
|
|
// $this->namespaces = $app->config->get('app.app_namespaces', [
|
|
// 'app' => 'app',
|
|
// 'addon' => 'addon',
|
|
// ]);
|
|
$this->namespaces = [
|
|
'app' => 'app',
|
|
'addon' => 'addon',
|
|
];
|
|
}
|
|
|
|
public function handle($request, Closure $next)
|
|
{
|
|
if (!$this->parseMultiApp()) {
|
|
return $next($request);
|
|
}
|
|
return $this->app->middleware
|
|
->pipeline('app')
|
|
->send($request)
|
|
->then(function ($request) use ($next) {
|
|
return $next($request);
|
|
});
|
|
}
|
|
|
|
protected function getRoutePath(): string
|
|
{
|
|
return $this->app->getAppPath() . 'route' . DIRECTORY_SEPARATOR;
|
|
}
|
|
|
|
/**
|
|
* 核心解析
|
|
*/
|
|
protected function parseMultiApp(): bool
|
|
{
|
|
$scriptName = $this->getScriptName();
|
|
$defaultApp = $this->app->config->get('app.default_app') ?: 'index';
|
|
$appName = $this->app->http->getName();
|
|
|
|
// ==================== 阶段 1:独立入口 / 显式绑定 ====================
|
|
if ($appName || ($scriptName && !in_array($scriptName, ['index', 'router', 'think']))) {
|
|
$appName = $appName ?: $scriptName;
|
|
$this->app->http->setBind();
|
|
|
|
// 独立入口可不走 domain_bind,直接解析
|
|
$resolved = $this->resolve($appName);
|
|
if (!$resolved) {
|
|
throw new HttpException(404, 'app not exists:' . $appName);
|
|
}
|
|
return $this->applyResolved($resolved, $appName);
|
|
}
|
|
|
|
// ==================== 阶段 2: 自动识别 ====================
|
|
$this->app->http->setBind(false);
|
|
$appName = null;
|
|
|
|
// -------- 2.1 域名绑定 --------
|
|
$bind = $this->app->config->get('app.domain_bind', []);
|
|
if (!empty($bind)) {
|
|
$subDomain = $this->app->request->subDomain();
|
|
$domain = $this->app->request->host(true);
|
|
if (isset($bind[$domain])) {
|
|
$appName = $bind[$domain];
|
|
$this->app->http->setBind();
|
|
} elseif (isset($bind[$subDomain])) {
|
|
$appName = $bind[$subDomain];
|
|
$this->app->http->setBind();
|
|
} elseif (isset($bind['*'])) {
|
|
$appName = $bind['*'];
|
|
$this->app->http->setBind();
|
|
}
|
|
}
|
|
|
|
if ($this->app->http->isBind()) {
|
|
$resolved = $this->resolve($appName);
|
|
if (!$resolved) {
|
|
throw new HttpException(404, 'app not exists:' . $appName);
|
|
}
|
|
return $this->applyResolved($resolved, $appName);
|
|
}
|
|
|
|
// -------- 2.2 URL 路径识别 --------
|
|
$path = $this->app->request->pathinfo();
|
|
$map = $this->app->config->get('app.app_map', []);
|
|
$deny = $this->app->config->get('app.deny_app_list', []);
|
|
|
|
$fullName = current(explode('/', $path));
|
|
if (strpos($fullName, '.')) {
|
|
$fullName = strstr($fullName, '.', true);
|
|
}
|
|
|
|
// 分支1: 命中 app_map
|
|
if (isset($map[$fullName])) {
|
|
if ($map[$fullName] instanceof Closure) {
|
|
$mapped = call_user_func_array($map[$fullName], [$this->app]) ?: $fullName;
|
|
} else {
|
|
$mapped = $map[$fullName];
|
|
}
|
|
$resolved = $this->resolve($mapped);
|
|
}
|
|
// 分支 2: 黑名单 / map 显式禁用
|
|
elseif ($fullName !== '' && (false !== array_search($fullName, $map) || in_array($fullName, $deny))) {
|
|
throw new HttpException(404, 'app not exists:' . $fullName);
|
|
}
|
|
// 分支 3: map 通配 *
|
|
elseif ($fullName !== '' && isset($map['*'])) {
|
|
$resolved = $this->resolve($map['*']);
|
|
}
|
|
// 分支 4: URL 段本身带前缀(强制指定根)
|
|
elseif (str_contains($fullName, ':')) {
|
|
$resolved = $this->resolve($fullName);
|
|
}
|
|
// 分支 5: 默认行为 —— 多根顺序查找
|
|
else {
|
|
$name = $fullName !== '' ? $fullName : null;
|
|
// URL 没给应用名 → 用 default_app
|
|
$name ??= $defaultApp;
|
|
$resolved = $this->resolve($name); // ← prefix='',走多根查找
|
|
}
|
|
|
|
// ===== 查找失败处理 =====
|
|
if (!$resolved) {
|
|
// app_express=true 且不是显式前缀时 → 兜底 default_app
|
|
$express = $this->app->config->get('app.app_express', false);
|
|
if ($express && !str_contains($fullName, ':')) {
|
|
$resolved = $this->resolve($defaultApp);
|
|
}
|
|
if (!$resolved) {
|
|
throw new HttpException(404, 'app not exists:' . ($fullName ?: $defaultApp));
|
|
}
|
|
}
|
|
|
|
// 重写 pathinfo: 把 URL 第一段剥掉
|
|
if ($fullName) {
|
|
$this->app->request->setRoot('/' . $fullName);
|
|
$this->app->request->setPathinfo(
|
|
strpos($path, '/') ? ltrim(strstr($path, '/'), '/') : ''
|
|
);
|
|
}
|
|
|
|
return $this->applyResolved($resolved, $fullName);
|
|
}
|
|
|
|
/**
|
|
* 解析应用名为最终 [prefix, name, appPath]
|
|
* 支持:
|
|
* "app2:blog" → ['app2', 'blog', '/www/app2/blog/']
|
|
* "blog" → ['', 'blog', null] ← 调用方拿到 null 时按 roots 顺序兜底
|
|
*/
|
|
protected function resolve(string $appName): ?array
|
|
{
|
|
[$prefix, $name] = $this->splitAppName($appName);
|
|
|
|
if ($prefix !== '') {
|
|
// 显式前缀: 必须严格匹配,失败立即返回 null(不再降级)
|
|
if (!isset($this->roots[$prefix])) {
|
|
return null;
|
|
}
|
|
$path = $this->roots[$prefix] . $name . DIRECTORY_SEPARATOR;
|
|
return is_dir($path) ? [$prefix, $name, $path] : null;
|
|
}
|
|
|
|
// 多根顺序查找
|
|
foreach ($this->roots as $key => $root) {
|
|
$path = $root . $name . DIRECTORY_SEPARATOR;
|
|
if (is_dir($path)) {
|
|
return [$key, $name, $path];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 拆分应用名
|
|
* "app2:blog" → ["app2", "blog"]
|
|
* "blog" → ["", "blog"]
|
|
*/
|
|
protected function splitAppName(string $fullName): array
|
|
{
|
|
if ($fullName !== '' && str_contains($fullName, ':')) {
|
|
[$prefix, $name] = explode(':', $fullName, 2);
|
|
return [$prefix ?: '', $name];
|
|
}
|
|
return ['', $fullName];
|
|
}
|
|
|
|
protected function getScriptName(): string
|
|
{
|
|
if (isset($_SERVER['SCRIPT_FILENAME'])) {
|
|
$file = $_SERVER['SCRIPT_FILENAME'];
|
|
} elseif (isset($_SERVER['argv'][0])) {
|
|
$file = realpath($_SERVER['argv'][0]);
|
|
}
|
|
return isset($file) ? pathinfo($file, PATHINFO_FILENAME) : '';
|
|
}
|
|
|
|
/**
|
|
* 把 resolve() 结果落地到 App上下文
|
|
*/
|
|
protected function applyResolved(array $resolved, string $urlName): bool
|
|
{
|
|
[$prefix, $name, $appPath] = $resolved;
|
|
|
|
$this->app->http->name($name);
|
|
|
|
// 应用目录
|
|
$this->app->setAppPath($appPath);
|
|
|
|
// 命名空间
|
|
$nsPrefix = $this->namespaces[$prefix] ?? $prefix;
|
|
$this->app->setNamespace($nsPrefix . '\\' . $name);
|
|
|
|
// 运行时: 按根 + 应用双层隔离
|
|
$this->app->setRuntimePath(
|
|
$this->app->getRuntimePath() . $prefix . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR
|
|
);
|
|
|
|
// 路由目录
|
|
$this->app->http->setRoutePath($this->getRoutePath());
|
|
|
|
// 应用专属配置/中间件/provider/语言包
|
|
$this->loadApp($name, $appPath);
|
|
|
|
return true;
|
|
}
|
|
|
|
protected function loadApp(string $appName, string $appPath): void
|
|
{
|
|
if (is_file($appPath . 'common.php')) {
|
|
include_once $appPath . 'common.php';
|
|
}
|
|
|
|
$files = glob($appPath . 'config' . DIRECTORY_SEPARATOR . '*' . $this->app->getConfigExt());
|
|
foreach ($files as $file) {
|
|
$this->app->config->load($file, pathinfo($file, PATHINFO_FILENAME));
|
|
}
|
|
|
|
if (is_file($appPath . 'event.php')) {
|
|
$this->app->loadEvent(include $appPath . 'event.php');
|
|
}
|
|
if (is_file($appPath . 'middleware.php')) {
|
|
$this->app->middleware->import(include $appPath . 'middleware.php', 'app');
|
|
}
|
|
if (is_file($appPath . 'provider.php')) {
|
|
$this->app->bind(include $appPath . 'provider.php');
|
|
}
|
|
|
|
$this->app->loadLangPack($this->app->lang->defaultLangSet());
|
|
}
|
|
}
|