Files
Lucanlee d5c1edefae perf: V0.9.4 商品列表分页 + sitemap 文件缓存 + banner/asset/gzip/类型修复
- P1: Product::listing() 分页(WHERE status=1+LIMIT/OFFSET+总数),ProductController 改用并加翻页器,消除列表全表
- P1: outputSitemap() 加 storage/cache/sitemap.xml 文件缓存(TTL 3600s),消除每次爬虫全表
- P2: 首页 banner 改 whereLimit('status',1,20)
- P2: asset() 追加 ?v=filemtime 版本串,theme.css 改后自动击穿 1y 浏览器缓存
- P2: nginx 启用 gzip(文本资源压缩)
- P2: Model::count() $val 隐式可空改 mixed(PHP 8.4+ 弃用)
2026-08-08 23:53:52 +08:00

422 lines
18 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
namespace Core;
/**
* 应用核心:自动加载、配置、路由、分发
*/
class App
{
public static $config = [];
/** 初始化:辅助函数、配置、会话、自动加载(install 脚本可复用) */
public static function init()
{
// 0. 全局辅助函数
require_once BASE_PATH . '/app/Core/Helper.php';
// 1. 时区 & 会话
date_default_timezone_set(self::config('app.timezone', 'Asia/Shanghai'));
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
'cookie_secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), // HTTPS 下仅安全传输
]);
}
// 2. 自动加载(Core / App\Controllers / App\Models
spl_autoload_register(function ($class) {
$prefixes = [
'Core\\' => BASE_PATH . '/app/Core/',
'App\\Controllers\\' => BASE_PATH . '/app/Controllers/',
'App\\Models\\' => BASE_PATH . '/app/Models/',
];
foreach ($prefixes as $prefix => $base) {
if (strncmp($class, $prefix, strlen($prefix)) === 0) {
$rel = substr($class, strlen($prefix));
$file = $base . str_replace('\\', '/', $rel) . '.php';
if (is_file($file)) { require $file; return true; }
}
}
return false;
});
}
public static function run()
{
self::init();
self::dispatch(self::parseRoute());
}
/** 解析请求路径为段数组 */
public static function parseRoute(): array
{
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
// 仅当 SCRIPT_NAME 真实以 index.php 结尾时(Apache/生产模式)才剥离目录前缀;
// 内置服务器路由模式下 SCRIPT_NAME 等于请求路径,此时不剥离,避免误删 admin 等段。
$script = $_SERVER['SCRIPT_NAME'] ?? '/index.php';
if (substr($script, -strlen('/index.php')) === '/index.php') {
$dir = dirname($script);
if ($dir !== '/' && strpos($uri, $dir) === 0) {
$uri = substr($uri, strlen($dir));
}
}
$uri = preg_replace('#/index\.php$#i', '', $uri);
$uri = trim($uri, '/');
if ($uri === '') return [];
return explode('/', $uri);
}
/** 路由分发 */
public static function dispatch(array $segments)
{
// 静态资源直出
$asset = BASE_PATH . '/public/' . implode('/', $segments);
if ($segments && is_file($asset) && !is_dir($asset)) {
self::serveFile($asset);
return;
}
$admin = self::config('app.admin_path', 'admin');
if (!empty($segments) && $segments[0] === $admin) {
self::dispatchAdmin(array_slice($segments, 1));
return;
}
// 分系统入口:CRM 客户管理 / PSI 进销存(统一受 super_admin 与分系统权限管辖)
// 大小写不敏感:/crm、/CRM、/psi、/PSI 均可进入,避免因 URL 大小写不同导致 404
if (!empty($segments)) {
$seg0 = strtoupper($segments[0]);
if ($seg0 === 'CRM' || $seg0 === 'PSI') {
self::dispatchSubsys($seg0, array_slice($segments, 1));
return;
}
}
self::dispatchFront($segments);
}
private static function dispatchSubsys(string $raw, array $s)
{
$sys = strtoupper($raw); // CRM / PSI
$sysKey = $sys === 'PSI' ? 'psi' : 'crm';
// 未登录或无该系统角色:拦截(拥有 crm/psi 任意角色即可进入;写操作由各 Controller 的 subsys_admin 二次把关)
if (!subsys_can_enter($sysKey)) {
self::forbidden('您没有访问「' . $sys . '」系统的权限');
return;
}
// 统一入口:CRM\DashboardController / PSI\DashboardController 内部再做子路由
$ctrl = 'App\\Controllers\\' . $sys . '\\DashboardController';
$action = 'dispatch';
if (!class_exists($ctrl)) { self::notFound("系统不存在: $sys"); return; }
$instance = new $ctrl();
if (!method_exists($instance, $action)) { self::notFound("入口不存在: $action"); return; }
try {
$html = call_user_func_array([$instance, $action], [$s]);
} catch (\Throwable $e) {
// 子系统异常兜底:保留左导 + 右框,框内显示错误,绝不白屏
error_log('Subsys[' . $sys . '] error: ' . $e->getMessage());
$html = self::subsysErrorFrame($sysKey, $instance, $e);
}
if (is_string($html)) echo $html;
}
private static function dispatchFront(array $s)
{
$key = $s[0] ?? '';
$seg2 = $s[1] ?? null;
// 验证码图片输出(独立端点,直接输出 PNG)
if ($key === 'captcha' && $seg2 === 'image') {
captcha_image();
return;
}
// Sitemap 动态生成(XML 格式,搜索引擎自动抓取)
if ($key === 'sitemap.xml') {
self::outputSitemap();
return;
}
// 资源详情路由(带第二段 slug)
if (($key === 'product' || $key === 'products') && $seg2) {
self::call('App\\Controllers\\ProductController', 'show', [$seg2]);
return;
}
if ($key === 'news' && $seg2) {
self::call('App\\Controllers\\NewsController', 'show', [$seg2]);
return;
}
if ($key === 'cases' && $seg2) {
self::call('App\\Controllers\\CaseController', 'show', [$seg2]);
return;
}
// 订单 / 支付(含网关异步通知,无需登录)
if ($key === 'order') {
$action = $s[1] ?? 'checkout';
$param = $s[2] ?? null;
self::call('App\\Controllers\\OrderController', $action, [$param]);
return;
}
if ($key === 'pay') {
$action = $s[1] ?? 'notify';
$param = $s[2] ?? null;
self::call('App\\Controllers\\PayController', $action, [$param]);
return;
}
$map = [
'' => ['HomeController', 'index'],
'home' => ['HomeController', 'index'],
'products' => ['ProductController', 'index'],
'news' => ['NewsController', 'index'],
'cases' => ['CaseController', 'index'],
'page' => ['PageController', 'show'],
'contact' => ['ContactController', 'index'],
];
if (isset($map[$key])) {
[$ctrl, $action] = $map[$key];
$param = $seg2;
self::call('App\\Controllers\\' . $ctrl, $action, [$param]);
return;
}
// 未知路径:按单页 slug 处理(/about、/service ...
self::call('App\\Controllers\\PageController', 'show', [$key]);
}
private static function dispatchAdmin(array $s)
{
$res = $s[0] ?? '';
$action = $s[1] ?? 'index';
$id = $s[2] ?? null;
$map = [
'' => ['Admin\\DashboardController', 'index'],
'dashboard' => ['Admin\\DashboardController', 'index'],
'login' => ['Admin\\AuthController', 'login'],
'logout' => ['Admin\\AuthController', 'logout'],
'password' => ['Admin\\AuthController', 'password'],
'products' => ['Admin\\ProductController', 'index'],
'categories'=> ['Admin\\CategoryController', 'index'],
'news' => ['Admin\\NewsController', 'index'],
'pages' => ['Admin\\PageController', 'index'],
'banners' => ['Admin\\BannerController', 'index'],
'settings' => ['Admin\\SettingController', 'index'],
'theme' => ['Admin\\SettingController', 'theme'],
'seo' => ['Admin\\SettingController', 'seo'],
'users' => ['Admin\\UserController', 'index'],
'system' => ['Admin\\SystemController', 'index'],
'orders' => ['Admin\\OrderController', 'index'],
'payments' => ['Admin\\SettingController', 'payment'],
'cases' => ['Admin\\CaseController', 'index'],
'media' => ['Admin\\MediaController', 'index'],
'upgrade' => ['Admin\\UpgradeController', 'index'],
'db' => ['Admin\\DatabaseController', 'index'],
];
if (!isset($map[$res])) {
self::notFound();
return;
}
// 权限能力检查:未登录或权限不足直接拦截(登录态由对应控制器再兜底)
$capMap = [
'users' => 'users',
'system' => 'users',
'upgrade' => 'users',
'db' => 'users',
'settings' => 'settings',
'theme' => 'settings',
'seo' => 'settings',
'payments' => 'settings',
'products' => 'products',
'categories'=> 'categories',
'news' => 'news',
'pages' => 'pages',
'banners' => 'banners',
'orders' => 'orders',
'cases' => 'cases',
'media' => 'pages',
];
if (isset($capMap[$res]) && !admin_can($capMap[$res])) {
self::forbidden('当前账号无访问「' . $res . '」的权限');
return;
}
[$ctrl, $default] = $map[$res];
$method = ($action === 'index') ? $default : $action;
self::call('App\\Controllers\\' . $ctrl, $method, [$id]);
}
private static function call($class, $method, array $args = [])
{
if (!class_exists($class)) { self::notFound("类不存在: $class"); return; }
$instance = new $class();
if (!method_exists($instance, $method)) { self::notFound("方法不存在: $method"); return; }
$html = call_user_func_array([$instance, $method], $args);
if (is_string($html)) echo $html;
}
public static function notFound($msg = '')
{
http_response_code(404);
echo '<!doctype html><meta charset=utf-8><title>404</title>
<style>body{font-family:system-ui;display:grid;place-items:center;height:100vh;margin:0;background:#0f172a;color:#e2e8f0}
.b{text-align:center}.c{color:#38bdf8;font-size:64px;font-weight:800;margin:0}</style>
<div class="b"><p class="c">404</p><p>页面不存在' . ($msg ? '' . htmlspecialchars($msg) : '') . '</p>
<p><a style="color:#38bdf8" href="' . site_url() . '">返回首页</a></p></div>';
}
public static function forbidden(string $msg = '')
{
http_response_code(403);
echo '<!doctype html><meta charset=utf-8><title>403</title>
<style>body{font-family:system-ui;display:grid;place-items:center;height:100vh;margin:0;background:#0f172a;color:#e2e8f0}
.b{text-align:center}.c{color:#f87171;font-size:56px;font-weight:800;margin:0}</style>
<div class="b"><p class="c">403</p><p>无访问权限' . ($msg ? '' . htmlspecialchars($msg) : '') . '</p>
<p><a style="color:#38bdf8" href="' . site_url('admin') . '">返回后台</a></p></div>';
}
/**
* 子系统异常兜底框:左导 + 右框保持完整,框内显示错误信息,绝不白屏。
*/
private static function subsysErrorFrame(string $sysKey, $instance, \Throwable $e): string
{
$nav = method_exists($instance, 'nav') ? $instance->nav('') : [];
$content = '<div class="page-head"><h1>页面加载出错</h1>'
. '<p class="sub">系统遇到问题,错误已记录,请稍后重试或联系管理员</p></div>'
. '<div class="alert alert-err">' . e('错误信息:' . $e->getMessage()) . '</div>';
return \Core\View::make('layouts/subsys', [
'content' => $content,
'_sys' => $sysKey,
'_nav' => $nav,
'_seg' => '',
]);
}
private static function serveFile($file)
{
$real = realpath($file);
$pub = realpath(BASE_PATH . '/public');
if ($real === false || $pub === false || strpos($real, $pub . DIRECTORY_SEPARATOR) !== 0 || !is_file($real)) {
self::notFound('非法文件访问');
return;
}
$mime = mime_content_type($real);
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($real));
readfile($real);
exit;
}
/** 读取配置 config('app.name') */
/**
* 生成站内 URL(兼容子目录部署)。
* 用法:App::url('PSI/reminders') -> https://host/base/PSI/reminders
*/
public static function url(string $path = ''): string
{
return site_url($path);
}
public static function config(string $key, $default = null)
{
if (empty(self::$config)) {
self::$config = require BASE_PATH . '/config/config.php';
}
$keys = explode('.', $key);
$v = self::$config;
foreach ($keys as $k) {
if (!is_array($v) || !array_key_exists($k, $v)) return $default;
$v = $v[$k];
}
return $v;
}
/** 动态生成 Sitemap XMLGoogle/Bing/Baidu 自动抓取)。V0.9.4:加文件缓存(TTL),避免每次爬虫请求全表扫描。 */
private static function outputSitemap(): void
{
$cacheFile = rtrim(BASE_PATH, '/') . '/storage/cache/sitemap.xml';
$ttl = 3600;
if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < $ttl) {
header('Content-Type: application/xml; charset=utf-8');
readfile($cacheFile);
exit;
}
ob_start();
header('Content-Type: application/xml; charset=utf-8');
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
// 首页
echo '<url><loc>' . e(site_url()) . '</loc><priority>1.0</priority><changefreq>daily</changefreq></url>' . "\n";
// 静态页面
$staticPages = [
['url' => site_url('products'), 'prio' => '0.9', 'freq' => 'weekly'],
['url' => site_url('news'), 'prio' => '0.8', 'freq' => 'daily'],
['url' => site_url('cases'), 'prio' => '0.8', 'freq' => 'weekly'],
['url' => site_url('page/about'), 'prio' => '0.7', 'freq' => 'monthly'],
['url' => site_url('contact'), 'prio' => '0.7', 'freq' => 'monthly'],
];
foreach ($staticPages as $p) {
echo '<url><loc>' . e($p['url']) . '</loc><priority>' . $p['prio'] . '</priority><changefreq>' . $p['freq'] . '</changefreq></url>' . "\n";
}
// 动态页面列表:产品 / 新闻 / 案例 / 单页
$models = [
['class' => 'App\\Models\\Product', 'method' => 'all', 'urlPrefix' => 'product/', 'prio' => '0.85', 'freq' => 'weekly'],
['class' => 'App\\Models\\News', 'method' => 'published', 'urlPrefix' => 'news/', 'prio' => '0.75', 'freq' => 'weekly'],
['class' => 'App\\Models\\CustomerCase', 'method' => 'published', 'urlPrefix' => 'cases/', 'prio' => '0.75', 'freq' => 'weekly'],
['class' => 'App\\Models\\Page', 'method' => 'all', 'urlPrefix' => 'page/', 'prio' => '0.6', 'freq' => 'monthly'],
];
foreach ($models as $m) {
if (!class_exists($m['class'])) continue;
try {
$instance = new $m['class']();
$items = [];
if ($m['method'] === 'all') {
$items = $instance->all();
} elseif ($m['method'] === 'published') {
$items = $instance->published(200);
}
foreach ($items as $item) {
$slug = $item['slug'] ?? ($item['id'] ?? '');
if (empty($slug)) continue;
$url = site_url($m['urlPrefix'] . $slug);
$lastmod = '';
if (!empty($item['updated_at'])) {
$lastmod = '<lastmod>' . e($item['updated_at']) . '</lastmod>';
} elseif (!empty($item['created_at'])) {
$lastmod = '<lastmod>' . e($item['created_at']) . '</lastmod>';
}
echo '<url><loc>' . e($url) . '</loc>' . $lastmod . '<priority>' . $m['prio'] . '</priority><changefreq>' . $m['freq'] . '</changefreq></url>' . "\n";
}
} catch (\Throwable $e) {
// 静默跳过异常的模型,保证 sitemap 完整性
continue;
}
}
// 产品分类页(/products?cat=ID
if (class_exists('App\\Models\\Category')) {
try {
$catM = new \App\Models\Category();
foreach ($catM->all() as $c) {
$cid = $c['id'] ?? 0;
if (!$cid) continue;
$url = site_url('products?cat=' . $cid);
echo '<url><loc>' . e($url) . '</loc><priority>0.7</priority><changefreq>weekly</changefreq></url>' . "\n";
}
} catch (\Throwable $e) {
// 忽略
}
}
echo '</urlset>';
$xml = ob_get_clean();
// 写文件缓存(best-effort:目录不存在则创建,写失败不影响本次输出)
$dir = dirname($cacheFile);
if (!is_dir($dir)) @mkdir($dir, 0755, true);
if (is_dir($dir)) @file_put_contents($cacheFile, $xml);
echo $xml;
exit;
}
}