Files
YwxAppThink/ywxapp/utils/UrlBuild.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

391 lines
15 KiB
PHP
Raw 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
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;
// (0) 第4参兜底:不含点号的裸名不是合法域名,按 应用/插件名 处理
// url('index/index', [], '', 'articles') → 拼成 articles/index/index 后
// 交给后面的插件/应用前缀逻辑统一接管:
// - 绑定了域名(domain_bind) → 自动切到绑定域名并去掉前缀
// - 未绑定域名 → 保留路径前缀访问,不生成域名
if (is_string($domain) && '' !== $domain && !str_contains($domain, '.') && !str_contains($domain, '://')) {
$map = $this->app->config->get('app.app_map', []);
$realApp = isset($map[$domain]) && is_string($map[$domain]) ? $map[$domain] : $domain;
$alias = array_search($realApp, $map, true);
$url = ($alias === false ? $domain : (string) $alias) . '/' . ltrim($url, '/');
$domain = false;
}
// (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;
// ====== 插件路由:首段是已安装插件目录 → 插件名即访问前缀,跳过应用名解析 ======
// url('haonav/rank') → /haonav/rank(未绑定域名)
// url('haonav/rank') 且插件绑定域名 → http://绑定域名/rank(去插件前缀从根访问)
$firstSeg = strtok($effectiveUrl, '/') ?: '';
if ($prefix === '' && $firstSeg !== '' && $this->isAddonDir($firstSeg)) {
$bind = $this->app->config->get('app.domain_bind', []);
if ($bindDomain = $this->findBindDomain($firstSeg, $bind)) {
$domain = $domain ?: $bindDomain;
return ltrim(substr($effectiveUrl, strlen($firstSeg)), '/');
}
return $effectiveUrl;
}
// 拆分 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', []);
$map = $this->app->config->get('app.app_map', []);
if ($prefix !== '') {
// 跨应用跳转(prefix:name 语法) → 找目标应用绑定的域名
$targetApp = $prefix . $this->prefixSep . $appName;
if ($key = array_search($targetApp, $bind)) {
// 用户没显式传 domain 时,才用绑定域名
$domain = $domain ?: $key;
}
} elseif (!$this->app->http->isBind()) {
// 自动多应用模式(免冒号写法)
$realApp = $map[$appName] ?? $appName; // 别名 → 真实应用名
$alias = array_search($realApp, $map, true); // 真实名 → URL 段(别名优先)
$alias = $alias === false ? $realApp : (string) $alias;
$current = [$this->app->http->getName(), $this->getCurrentAppName()];
if (in_array($realApp, $current, true) || in_array($appName, $current, true)) {
// 当前应用内 → 检查当前应用是否绑定域名
if ($key = array_search($this->app->http->getName(), $bind)) {
// 当前域名就是绑定的 → 强制使用
if (isset($bind[$_SERVER['SERVER_NAME'] ?? ''])) {
$domain = $_SERVER['SERVER_NAME'];
}
$domain = is_bool($domain) ? $key : $domain;
} else {
$url = $alias . '/' . $url;
}
} elseif ($bindDomain = $this->findBindDomain($realApp, $bind)) {
// 目标应用绑定了域名 → 自动切到该域名(路径无需应用前缀)
$domain = $domain ?: $bindDomain;
} else {
// 目标应用未绑定域名 → 按别名走路径访问
$url = $alias . '/' . $url;
}
}
return $url;
}
/**
* 在 domain_bind 中查找应用绑定的域名
* 兼容多种配置值写法: 'backend'(真实名) / 'app:backend'(多根前缀)
*/
protected function findBindDomain(string $app, array $bind): string
{
foreach ($bind as $bindDomain => $bindApp) {
if ($bindApp === $app || str_ends_with($bindApp, $this->prefixSep . $app)) {
return (string) $bindDomain;
}
}
return '';
}
/**
* 是否已安装插件(addon/<name> 目录存在),结果进程内缓存
*/
protected function isAddonDir(string $name): bool
{
static $cache = [];
if (!isset($cache[$name])) {
$cache[$name] = is_dir($this->app->getRootPath() . 'addon' . DIRECTORY_SEPARATOR . $name);
}
return $cache[$name];
}
// ============================================================
// 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);
}
}
$rule = [];
if ($url) {
// 显式传入域名(第4参)时跳过路由名反查:路由名按「路由地址」全局索引(跨应用共享),
// 反查会误中其它应用的规则(如 index/index 反查出 blog 插件的 /blog),直接走 parseUrl
$explicitDomain = !isset($name) && is_string($domain) && '' !== $domain;
if (!$explicitDomain) {
$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']);
}
}
}
// 反查结果过滤:带插件前缀的规则(blog/xxx、haonav/xxx)只在当前应用就是该插件时
// 才参与「按路由地址」的反查(该索引跨应用共享,否则 url('index/index') 会反查出
// 其它插件的首页规则);显式 [name] 命名路由不受此限制(跨应用链接插件页面依赖它)
if ($rule && !isset($name)) {
$curApp = $this->app->http->getName();
$rule = array_values(array_filter($rule, function ($item) use ($curApp) {
$first = strtok((string) ($item['rule'] ?? ''), '/') ?: '';
return !$this->isAddonDir($first) || $first === $curApp;
}));
}
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];
}
// 未绑定域名 → 加当前应用前缀(别名优先,如 frontend→home);
// 插件路由规则已自带插件前缀(如 haonav/xxx),跳过避免重复
if (!$this->app->http->isBind() && !$this->isAddonDir(strtok($url, '/') ?: '')) {
$map = $this->app->config->get('app.app_map', []);
$alias = array_search($this->getAppName(), $map, true);
$url = ($alias === false ? $this->getAppName() : (string) $alias) . '/' . $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);
}
}