502 lines
20 KiB
PHP
502 lines
20 KiB
PHP
<?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) {
|
||
self::ensureSessionPath();
|
||
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());
|
||
}
|
||
|
||
/**
|
||
* 确保 session 存储路径可用。
|
||
* 服务器 session.save_path 可能指向不存在/不可写目录(如旧域名残留配置、
|
||
* php_admin_value 锁定等),此时 session_start() 会报 Warning 并失败。
|
||
* 本方法依次尝试:1) session_save_path() 切换到项目本地目录;2) 若被锁则
|
||
* 注册自定义文件 session handler 完全绕过服务器配置。
|
||
*/
|
||
private static function ensureSessionPath()
|
||
{
|
||
$sp = session_save_path();
|
||
// session.save_path 可能带 N;/path 深度前缀,取实际路径部分判断
|
||
$spDir = ($pos = strpos($sp, ';')) !== false ? substr($sp, $pos + 1) : $sp;
|
||
if ($spDir !== '' && is_dir($spDir) && is_writable($spDir)) {
|
||
return; // 服务器路径正常,无需处理
|
||
}
|
||
|
||
$localSession = BASE_PATH . '/storage/sessions';
|
||
if (!is_dir($localSession)) {
|
||
@mkdir($localSession, 0755, true);
|
||
}
|
||
if (!is_dir($localSession) || !is_writable($localSession)) {
|
||
return; // 本地目录也建不了,交给 session_start() 原样报错
|
||
}
|
||
|
||
// 尝试 1:session_save_path() 切换(php_value 级别可生效)
|
||
session_save_path($localSession);
|
||
$checkPath = session_save_path();
|
||
$checkDir = ($pos = strpos($checkPath, ';')) !== false ? substr($checkPath, $pos + 1) : $checkPath;
|
||
if ($checkDir === $localSession) {
|
||
return; // 切换成功
|
||
}
|
||
|
||
// 尝试 2:被 php_admin_value 锁定,注册自定义文件 handler 完全绕过
|
||
$handler = new LocalSessionHandler($localSession);
|
||
session_set_save_handler($handler, true);
|
||
}
|
||
|
||
/** 解析请求路径为段数组 */
|
||
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 XML(Google/Bing/Baidu 自动抓取) */
|
||
private static function outputSitemap(): void
|
||
{
|
||
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>';
|
||
exit;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 本地文件 session handler —— 当服务器 session.save_path 不可用/被锁时,
|
||
* 将 session 数据存到项目 storage/sessions/ 目录,完全绕过服务器配置。
|
||
* 兼容 PHP 7.4 ~ 8.x(不声明返回类型,用 #[\ReturnTypeWillChange] 抑制 8.x 弃用提示)。
|
||
*/
|
||
class LocalSessionHandler implements \SessionHandlerInterface
|
||
{
|
||
private $dir;
|
||
|
||
public function __construct(string $dir)
|
||
{
|
||
$this->dir = $dir;
|
||
}
|
||
|
||
public function open($savePath, $sessionName)
|
||
{
|
||
return is_dir($this->dir) && is_writable($this->dir);
|
||
}
|
||
|
||
public function close()
|
||
{
|
||
return true;
|
||
}
|
||
|
||
#[\ReturnTypeWillChange]
|
||
public function read($id)
|
||
{
|
||
$f = $this->dir . '/sess_' . $id;
|
||
return is_file($f) ? (string) @file_get_contents($f) : '';
|
||
}
|
||
|
||
public function write($id, $data)
|
||
{
|
||
return @file_put_contents($this->dir . '/sess_' . $id, $data) !== false;
|
||
}
|
||
|
||
public function destroy($id)
|
||
{
|
||
$f = $this->dir . '/sess_' . $id;
|
||
return is_file($f) ? @unlink($f) : true;
|
||
}
|
||
|
||
#[\ReturnTypeWillChange]
|
||
public function gc($maxlifetime)
|
||
{
|
||
$n = 0;
|
||
foreach ((array) @glob($this->dir . '/sess_*') as $f) {
|
||
if (is_file($f) && filemtime($f) + $maxlifetime < time()) {
|
||
@unlink($f);
|
||
$n++;
|
||
}
|
||
}
|
||
return $n;
|
||
}
|
||
}
|