312 lines
11 KiB
PHP
312 lines
11 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace ywxapp\utils;
|
||
|
||
use think\App;
|
||
use think\route\Url;
|
||
|
||
/**
|
||
* 多根模式 URL 生成
|
||
*
|
||
* 用法差异(对比 think\app\Url):
|
||
* - url('app2:manage/user/read') → 跨应用跳转 /app2/manage/user/read
|
||
* - url('manage/user/read') → 当前应用内(自动加 prefix:name 前缀)
|
||
* - url('app:admin/user/list') → 强制 /app/admin/user/list
|
||
* - url('app2:manage\Blog::read') → 类解析 +跨应用
|
||
* - url('[blog_read]') → 仅当前应用(官方限制)
|
||
*/
|
||
class UrlBuild extends Url
|
||
{
|
||
/** prefix:name之间的分隔符 */
|
||
protected string $prefixSep = ':';
|
||
|
||
// ============================================================
|
||
// 1. 工具方法:prefix解析与反推
|
||
// ============================================================
|
||
|
||
/**
|
||
* 从 namespace 反推当前应用的 prefix
|
||
* app\manage → 'app'
|
||
* app2\manage → 'app2'
|
||
*/
|
||
protected function getCurrentPrefix(): string
|
||
{
|
||
$ns = trim($this->app->getNamespace(), '\\');
|
||
if ($ns === '') {
|
||
return '';
|
||
}
|
||
$root = explode('\\', $ns)[0] ?? '';
|
||
|
||
$namespaces = $this->app->config->get('app.app_namespaces', []);
|
||
foreach ($namespaces as $key => $nsPrefix) {
|
||
if ($root === trim($nsPrefix, '\\')) {
|
||
return $key;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 获取当前应用的完整名(prefix:name)
|
||
*/
|
||
protected function getCurrentAppName(): string
|
||
{
|
||
$name = $this->app->http->getName();
|
||
$prefix = $this->getCurrentPrefix();
|
||
return $prefix !== '' ? ($prefix . $this->prefixSep . $name) : $name;
|
||
}
|
||
|
||
/**
|
||
* 拆分 URL 字符串中的 prefix:name/...
|
||
* "app2:manage/user/read" → ["app2", "manage/user/read"]
|
||
* "manage/user/read" → ["", "manage/user/read"]
|
||
* "blog/list" → ["", "blog/list"]
|
||
*/
|
||
protected function splitPrefix(string $url): array
|
||
{
|
||
if (!str_contains($url, $this->prefixSep)) {
|
||
return ['', $url];
|
||
}
|
||
$firstSlash = strpos($url, '/');
|
||
$firstColon = strpos($url, $this->prefixSep);
|
||
// colon 必须在第一段(/之前)
|
||
if ($firstSlash === false || $firstColon < $firstSlash) {
|
||
return [
|
||
substr($url, 0, $firstColon),
|
||
substr($url, $firstColon + 1),
|
||
];
|
||
}
|
||
return ['', $url];
|
||
}
|
||
|
||
/**
|
||
* 覆盖父类 getAppName(用于 build() 内部拼接)
|
||
*官方 think\app\Url 的实现会套 app_map 反向替换 key
|
||
* 多根模式: 直接返回 prefix:name 完整形式,反向替换由 parseUrl 自己处理
|
||
*/
|
||
protected function getAppName(): string
|
||
{
|
||
return $this->getCurrentAppName();
|
||
}
|
||
|
||
// ============================================================
|
||
// 2. 核心:parseUrl 重写
|
||
// ============================================================
|
||
|
||
/**
|
||
* 直接解析 URL 地址
|
||
*/
|
||
protected function parseUrl(string $url, string|bool &$domain): string
|
||
{
|
||
$request = $this->app->request;
|
||
|
||
// (1) 以 / 开头 → 直接作为路由地址
|
||
if (str_starts_with($url, '/')) {
|
||
return substr($url, 1);
|
||
}
|
||
|
||
// (2) 包含 \\ → 解析到类
|
||
// 支持: app2:manage\Blog\Article::read
|
||
if (str_contains($url, '\\')) {
|
||
return ltrim(str_replace('\\', '/', $url), '/');
|
||
}
|
||
|
||
// (3) 以 @ 开头 → 解析到控制器
|
||
if (str_starts_with($url, '@')) {
|
||
return substr($url, 1);
|
||
}
|
||
|
||
// (4) 空 URL → 当前 controller/action
|
||
if ($url === '') {
|
||
$url = $request->controller() . '/' . $request->action();
|
||
if (!$this->app->http->isBind()) {
|
||
$url = $this->getAppName() . '/' . $url;
|
||
}
|
||
return $url;
|
||
}
|
||
|
||
// ====== 多根模式特有逻辑 ======
|
||
|
||
// 拆分 prefix:name
|
||
[$prefix, $rest] = $this->splitPrefix($url);
|
||
$effectiveUrl = $prefix !== '' ? $rest : $url;
|
||
|
||
// 拆分 controller/action/app
|
||
$controller = $request->controller();
|
||
$path = explode('/', $effectiveUrl);
|
||
$action = array_pop($path);
|
||
$controller = empty($path) ? $controller : array_pop($path);
|
||
|
||
// 应用名
|
||
if ($prefix !== '') {
|
||
// 跨应用: prefix 已指定,path 最后一段就是应用名
|
||
$appName = empty($path) ? $this->getCurrentAppName() : array_pop($path);
|
||
} else {
|
||
// 当前应用内
|
||
$appName = empty($path) ? $this->getCurrentAppName() : array_pop($path);
|
||
}
|
||
|
||
$url = $controller . '/' . $action;
|
||
|
||
// ====== 域名绑定处理 ======
|
||
$bind = $this->app->config->get('app.domain_bind', []);
|
||
|
||
if ($prefix !== '') {
|
||
// 跨应用跳转 → 找目标应用绑定的域名
|
||
$targetApp = $prefix . $this->prefixSep . $appName;
|
||
if ($key = array_search($targetApp, $bind)) {
|
||
// 用户没显式传 domain 时,才用绑定域名
|
||
$domain = $domain ?: $key;
|
||
}
|
||
} elseif (!$this->app->http->isBind()) {
|
||
// 当前应用内 → 检查当前应用是否绑定域名
|
||
$currentApp = $this->app->http->getName();
|
||
if ($key = array_search($currentApp, $bind)) {
|
||
// 当前域名就是绑定的 → 强制使用
|
||
if (isset($bind[$_SERVER['SERVER_NAME'] ?? ''])) {
|
||
$domain = $_SERVER['SERVER_NAME'];
|
||
}
|
||
$domain = is_bool($domain) ? $key : $domain;
|
||
} else {
|
||
// 非绑定模式,处理 app_map
|
||
$map = $this->app->config->get('app.app_map', []);
|
||
if ($key = array_search($appName, $map)) {
|
||
// 反向映射: app_name → URL段
|
||
$url = $key . '/' . $url;
|
||
} else {
|
||
$url = $appName . '/' . $url;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $url;
|
||
}
|
||
|
||
// ============================================================
|
||
// 3. build() 重写:处理跨应用域名拼接
|
||
// ============================================================
|
||
|
||
public function build(): string
|
||
{
|
||
$url = $this->url;
|
||
$suffix = $this->suffix;
|
||
$domain = $this->domain;
|
||
$request = $this->app->request;
|
||
$vars = $this->vars;
|
||
|
||
// [name] 路由名
|
||
if (str_starts_with($url, '[') && $pos = strpos($url, ']')) {
|
||
$name = substr($url, 1, $pos - 1);
|
||
$url = 'name' . substr($url, $pos + 1);
|
||
}
|
||
|
||
if (!str_contains($url, '://') && !str_starts_with($url, '/')) {
|
||
$info = parse_url($url);
|
||
$url = !empty($info['path']) ? $info['path'] : '';
|
||
if (isset($info['fragment'])) {
|
||
$anchor = $info['fragment'];
|
||
if (str_contains($anchor, '?')) {
|
||
[$anchor, $info['query']] = explode('?', $anchor, 2);
|
||
}
|
||
if (str_contains($anchor, '@')) {
|
||
[$anchor, $domain] = explode('@', $anchor, 2);
|
||
}
|
||
} elseif (str_contains($url, '@') && !str_contains($url, '\\')) {
|
||
[$url, $domain] = explode('@', $url, 2);
|
||
}
|
||
}
|
||
|
||
if ($url) {
|
||
$checkName = $name ?? $url . (isset($info['query']) ? '?' . $info['query'] : '');
|
||
$checkDomain = $domain && is_string($domain) ? $domain : null;
|
||
$rule = $this->route->getName($checkName, $checkDomain);
|
||
if (empty($rule) && isset($info['query'])) {
|
||
$rule = $this->route->getName($url, $checkDomain);
|
||
parse_str($info['query'], $params);
|
||
$vars = array_merge($params, $vars);
|
||
unset($info['query']);
|
||
}
|
||
}
|
||
|
||
if (!empty($rule) && $match = $this->getRuleUrl($rule, $vars, $domain)) {
|
||
// 路由名命中
|
||
$url = $match[0];
|
||
if ($domain && !empty($match[1])) {
|
||
$domain = $match[1];
|
||
}
|
||
if (!is_null($match[2])) {
|
||
$suffix = $match[2];
|
||
}
|
||
// 未绑定域名 → 加当前应用前缀
|
||
if (!$this->app->http->isBind()) {
|
||
$url = $this->getAppName() . '/' . $url;
|
||
}
|
||
} elseif (!empty($rule) && isset($name)) {
|
||
throw new \InvalidArgumentException('route name not exists:' . $name);
|
||
} else {
|
||
// URL 绑定
|
||
$bind = (string) $this->route->getDomainBind($domain && is_string($domain) ? $domain : null);
|
||
if ($bind && str_starts_with($url, $bind)) {
|
||
$url = substr($url, strlen($bind) + 1);
|
||
}
|
||
$url = $this->parseUrl($url, $domain);
|
||
if (isset($info['query'])) {
|
||
parse_str($info['query'], $params);
|
||
$vars = array_merge($params, $vars);
|
||
}
|
||
}
|
||
|
||
// 还原分隔符
|
||
$depr = $this->route->config('pathinfo_depr');
|
||
$url = str_replace('/', $depr, $url);
|
||
$file = $request->baseFile();
|
||
if ($file && !str_starts_with($request->url(), $file)) {
|
||
$file = str_replace('\\', '/', dirname($file));
|
||
}
|
||
$url = rtrim($file, '/') . '/' . $url;
|
||
|
||
// 后缀
|
||
if (str_ends_with($url, '/') || '' == $url) {
|
||
$suffix = '';
|
||
} else {
|
||
$suffix = $this->parseSuffix($suffix);
|
||
}
|
||
|
||
// 锚点
|
||
$anchor = !empty($anchor) ? '#' . $anchor : '';
|
||
|
||
// 参数
|
||
if (!empty($vars)) {
|
||
if ($this->route->config('url_common_param')) {
|
||
$vars = http_build_query($vars);
|
||
$url .= $suffix . ($vars ? '?' . $vars : '') . $anchor;
|
||
} else {
|
||
foreach ($vars as $var => $val) {
|
||
$val = (string) $val;
|
||
if ('' !== $val) {
|
||
$url .= $depr . $var . $depr . urlencode($val);
|
||
}
|
||
}
|
||
$url .= $suffix . $anchor;
|
||
}
|
||
} else {
|
||
$url .= $suffix . $anchor;
|
||
}
|
||
|
||
// 域名(★ 多根模式下,parseDomain 会按当前域名处理)
|
||
$domain = $this->parseDomain($url, $domain);
|
||
|
||
return $domain . rtrim($this->root, '/') . '/' . ltrim($url, '/');
|
||
}
|
||
|
||
/**
|
||
* parseDomain 重写:跨应用跳转时,使用 parseUrl 已选定的 $domain
|
||
*官方实现依赖 Route::getDomains,多根场景下不够灵活
|
||
*/
|
||
protected function parseDomain(string &$url, string|bool $domain): string
|
||
{
|
||
// parseUrl 已经根据 domain_bind 选好了 $domain,这里直接信任
|
||
return parent::parseDomain($url, $domain);
|
||
}
|
||
} |