Files
pk10/App/Core/PluginManager.php
T
li 4a94fe36cf feat: 重构代码架构 + 新增报表/跟单/输赢统计功能
- 拆分 HomeController → TransactionController, ReportWebController, FollowPlanController
- 新增 Service 层: TransactionService, ReportService, FollowPlanService
- pk10.php JS 抽离为 4 个独立文件 (sound/race/bet/poll)
- 前台新增报表查询页面 (/report) + 跟单计划页面 (/follow-plan)
- 后台新增跟单计划管理 + 用户输赢明细统计
- 封盘状态显示倒计时 (x:xx)
- 音效仅在开奖弹窗打开时播放
- 路由按模块分组整理
- autoload 支持 App\Services 命名空间
2026-03-27 18:41:06 +08:00

697 lines
22 KiB
PHP
Executable File
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
namespace App\Core;
class PluginManager {
protected $db;
protected $pluginDir;
protected $loadedPlugins = [];
protected $router;
protected $logFile;
protected $installedPluginsCache = null;
protected $registeredRoutes = [];
protected $plugins = null;
protected $enabledPlugins = [];
protected $systemRoutes = [];
protected $enabledRoutes = [];
private $lastError = '';
private function setError(string $msg) {
$this->lastError = $msg;
$this->log("[ERROR] $msg");
}
public function getLastError(): string {
return $this->lastError;
}
/**
* 构造函数
* @param object $db 数据库对象
* @param object $router 路由对象
* @param string $pluginDir 插件目录
*/
public function __construct($db, $router, string $pluginDir) {
$this->db = $db;
$this->router = $router;
$this->pluginDir = rtrim($pluginDir, '/');
$rootDir = realpath(__DIR__ . '/../../');
$logDir = $rootDir . '/Storage/log';
$this->logFile = $logDir . '/plugin_manager.log';
// 尝试准备日志目录;失败时降级到 PHP error_log,不能阻断主流程
if (!is_dir($logDir) && !@mkdir($logDir, 0755, true) && !is_dir($logDir)) {
$this->logFile = null;
error_log("PluginManager log directory create failed: $logDir");
return;
}
if (!$this->canWriteLogFile()) {
$this->logFile = null;
error_log("PluginManager log path not writable: $logDir");
return;
}
// 检查日志文件大小并自动清理(大于2MB时)
$this->cleanupLogFile(2); // 传入最大允许的MB数
}
/**
* 清理日志日志文件清理
* @param int $maxSizeMB 最大允许的文件大小(MB)
*/
private function cleanupLogFile(int $maxSizeMB) {
if (!$this->canWriteLogFile() || !file_exists($this->logFile)) {
return;
}
// 转换MB为字节
$maxSizeBytes = $maxSizeMB * 1024 * 1024;
// 获取当前文件大小
$currentSize = @filesize($this->logFile);
if ($currentSize === false) {
return;
}
// 如果文件大小超过限制,清空文件
if ($currentSize > $maxSizeBytes) {
// 先备份当前日志内容(可选)
$backupFile = $this->logFile . '.bak_' . date('YmdHis');
@copy($this->logFile, $backupFile);
// 清空日志文件
@file_put_contents($this->logFile, '');
// 记录清理日志
$message = "[" . date('Y-m-d H:i:s') . "] 日志文件超过{$maxSizeMB}MB,已自动清理\n";
@file_put_contents($this->logFile, $message, FILE_APPEND);
}
}
private function canWriteLogFile(): bool {
if (empty($this->logFile)) {
return false;
}
$logDir = dirname($this->logFile);
if (!is_dir($logDir) || !is_writable($logDir)) {
return false;
}
return !file_exists($this->logFile) || is_writable($this->logFile);
}
/**
* 设置系统核心路由
* @param array $routes 格式: [['GET', '/path', 'handler'], ...]
*/
public function setSystemRoutes(array $routes): void {
$this->systemRoutes = [];
foreach ($routes as $route) {
if (count($route) < 2) continue;
[$method, $path] = $route;
$key = strtoupper(trim($method)) . ' ' . trim($path);
$this->systemRoutes[$key] = true;
}
}
/**
* 获取所有已注册路由(系统+已启用插件)
* @return array 路由键名数组
*/
public function getAllRegisteredRoutes(): array {
return array_merge(
array_keys($this->systemRoutes),
array_keys($this->enabledRoutes)
);
}
public function getDB() {
return $this->db;
}
protected function log(string $msg, string $level = 'INFO'): void {
$date = date('Y-m-d H:i:s');
$logMsg = "[$date] [$level] $msg\n";
try {
if ($this->canWriteLogFile()) {
@file_put_contents($this->logFile, $logMsg, FILE_APPEND);
} else {
error_log("PluginManager: " . trim($logMsg));
}
} catch (\Throwable $e) {
error_log("Failed to write plugin log: " . $e->getMessage());
}
}
/**
* 获取所有扫描到的插件信息
* @return array
*/
public function getAllPlugins(): array {
$this->scanPlugins();
return $this->plugins;
}
public function scanPlugins() {
if ($this->plugins !== null) {
return $this->plugins;
}
$this->log("scanPlugins called");
$this->plugins = [];
if (!is_dir($this->pluginDir)) {
mkdir($this->pluginDir, 0755, true);
$this->log("Plugin directory created: {$this->pluginDir}");
return $this->plugins;
}
$dirs = scandir($this->pluginDir);
foreach ($dirs as $dir) {
if ($dir === '.' || $dir === '..') continue;
$pluginPath = $this->pluginDir . '/' . $dir;
$pluginFile = $pluginPath . '/mian.php';
if (is_dir($pluginPath) && is_file($pluginFile)) {
$pluginConfig = $this->getPluginInfo($pluginFile, $dir);
if (empty($pluginConfig)) {
$this->log("[WARN] 插件 {$dir} 不规范,已跳过");
continue;
}
$this->plugins[$dir] = array_merge([
'dir' => $dir,
'path' => $pluginPath,
], $pluginConfig);
$this->log("Plugin found: $dir ({$pluginConfig['name']})");
}
}
return $this->plugins;
}
public function getAllPluginIcons(): array
{
$pluginDirs = array_filter(scandir(PLUGIN_PATH . '/'), function($dir) {
return $dir !== '.' && $dir !== '..';
});
$icons = [];
foreach ($pluginDirs as $pluginDir) {
$pluginFile = PLUGIN_PATH . "/{$pluginDir}/mian.php";
if (file_exists($pluginFile)) {
$info = include $pluginFile;
$icons[$pluginDir] = $info['menus'][0]['icon'] ?? 'fa fa-plug';
}
}
return $icons;
}
/**
* 从插件文件头部注释获取插件信息
*/
private function getPluginInfo(string $pluginFile, string $pluginDirName): array {
$info = [];
$arrayConfig = @include $pluginFile;
if (!is_array($arrayConfig)) {
$this->setError("[ERROR] Plugin file {$pluginFile} must return an array");
return [];
}
$lines = file($pluginFile);
if (!$lines) {
$this->setError("[ERROR] Cannot read plugin file: {$pluginFile}");
return [];
}
// 读取前 30 行,兼容大部分注释头
$header = implode('', array_slice($lines, 0, 30));
//$this->log("Header of {$pluginFile}:\n" . $header);
if (preg_match_all('/^\s*\*\s*([A-Za-z ]+):\s*(.+)$/m', $header, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$key = strtolower(str_replace(' ', '_', trim($match[1])));
$value = trim($match[2]);
$info[$key] = $value;
}
}
// 必填字段列表
$requiredFields = ['plugin_name', 'version', 'description', 'author', 'plugin_url'];
// 检查必要字段是否存在且非空
foreach ($requiredFields as $field) {
if (empty($info[$field])) {
$this->setError("[ERROR] Plugin {$pluginFile} 缺少必要字段或为空: {$field}");
return [];
}
}
// 插件文件夹名必须和插件名一致
if ($pluginDirName !== $info['plugin_name']) {
$this->setError("[ERROR] 插件目录名 {$pluginDirName} 与插件名 {$info['plugin_name']} 不一致");
return [];
}
if (isset($info['plugin_name'])) {
$arrayConfig['name'] = $info['plugin_name'];
}
if (isset($info['description'])) {
$arrayConfig['description'] = $info['description'];
}
if (isset($info['version'])) {
$arrayConfig['version'] = $info['version'];
}
if (isset($info['author'])) {
$arrayConfig['author'] = $info['author'];
}
if (isset($info['plugin_url'])) {
$arrayConfig['url'] = $info['plugin_url'];
}
return $arrayConfig;
}
public function clearCache() {
$this->plugins = null;
}
public function getEnabledPluginMenus(): array {
$menus = [];
foreach ($this->enabledPlugins as $plugin) {
if (isset($plugin['menus']) && is_array($plugin['menus'])) {
$menus = array_merge($menus, $plugin['menus']);
}
}
return $menus;
}
/**
* 获取已安装插件列表
* @return array
*/
public function getInstalledPlugins(): array {
// 如果数据库对象不存在或未初始化,直接返回空数组
if (!$this->db || !$this->db->medoo) {
return [];
}
if ($this->installedPluginsCache !== null) {
return $this->installedPluginsCache;
}
try {
$installed = $this->db->select('plugins', '*');
if (!is_array($installed)) {
$installed = [];
}
} catch (\Exception $e) {
// 捕获数据库异常,返回空数组
$installed = [];
}
$installedPlugins = [];
foreach ($installed as $row) {
$installedPlugins[$row['name']] = $row;
}
$this->installedPluginsCache = $installedPlugins;
return $installedPlugins;
}
/**
* 获取所有插件状态信息
* @return array
*/
public function getPluginStatusList(): array {
$this->scanPlugins();
$installedPlugins = $this->getInstalledPlugins();
$result = [];
foreach ($this->plugins as $name => $plugin) {
$installed = isset($installedPlugins[$name]);
$status = $installed ? (int)$installedPlugins[$name]['status'] : 0;
$result[] = [
'name' => $name,
'installed' => $installed ? 1 : 0,
'status' => $status,
'path' => $plugin['path'],
'title' => $installed ? $installedPlugins[$name]['title'] : ($plugin['title'] ?? $name),
'version' => $installed ? $installedPlugins[$name]['version'] : ($plugin['version'] ?? ''),
'description' => $installed ? $installedPlugins[$name]['description'] : ($plugin['description'] ?? ''),
'author' => $installed ? $installedPlugins[$name]['author'] : ($plugin['author'] ?? ''),
'url' => $installed ? $installedPlugins[$name]['url'] : ($plugin['url'] ?? ''),
];
}
return $result;
}
/**
* 获取启用的插件名称列表
* @return array
*/
public function getEnabledPlugins(): array {
$enabled = [];
$installed = $this->getInstalledPlugins();
foreach ($this->plugins as $name => $plugin) {
if (isset($installed[$name]) && $installed[$name]['status'] == 1) {
$enabled[] = $name;
}
}
return $enabled;
}
/**
* 递归加载控制器目录内所有PHP文件
* @param string $dir
*/
protected function loadControllersRecursively(string $dir): void {
if (!is_dir($dir)) {
return;
}
$files = scandir($dir);
foreach ($files as $file) {
if ($file === '.' || $file === '..') continue;
$fullPath = $dir . DIRECTORY_SEPARATOR . $file;
if (is_dir($fullPath)) {
$this->loadControllersRecursively($fullPath);
} elseif (is_file($fullPath) && pathinfo($file, PATHINFO_EXTENSION) === 'php') {
require_once $fullPath;
$this->log("Loaded controller: $fullPath");
}
}
}
private function isAssoc(array $arr): bool {
return array_keys($arr) !== range(0, count($arr) - 1);
}
public function loadEnabledPlugins(): void {
static $loaded = false;
if ($loaded) {
return;
}
$loaded = true;
$this->scanPlugins();
$installed = $this->getInstalledPlugins();
$this->enabledPlugins = [];
$this->registeredRoutes = [];
$this->enabledRoutes = [];
foreach ($this->plugins as $pluginName => $pluginData) {
if (isset($installed[$pluginName]) && $installed[$pluginName]['status'] == 1) {
// 激活插件(调用init
if (!empty($pluginData['init']) && is_callable($pluginData['init'])) {
try {
call_user_func($pluginData['init']);
//$this->log("Initialized plugin $pluginName");
} catch (\Throwable $e) {
$this->log("[ERROR] Exception in init of plugin $pluginName: " . $e->getMessage());
}
}
// 注册普通路由
if (!empty($pluginData['routes']) && is_array($pluginData['routes'])) {
foreach ($pluginData['routes'] as $route) {
if (count($route) < 3) {
$this->log("[WARNING] Invalid route config for plugin $pluginName");
continue;
}
[$method, $path, $handler] = $route;
$method = strtoupper($method);
$routeKey = $method . ' ' . $path;
if (in_array($routeKey, $this->registeredRoutes, true)) {
$this->log("[WARNING] Route conflict: [$method] $path");
continue;
}
$this->router->add($method, $path, $handler);
$this->registeredRoutes[] = $routeKey;
$this->enabledRoutes[$routeKey] = true;
//$this->log("Registered route [$method] $path for plugin $pluginName");
}
}
// 注册路由组
if (!empty($pluginData['route_group'])) {
$groups = $this->isAssoc($pluginData['route_group'])
? [$pluginData['route_group']]
: $pluginData['route_group'];
foreach ($groups as $group) {
if (isset($group['prefix'], $group['namespace'], $group['routes']) && is_array($group['routes'])) {
$this->router->group(
$group['prefix'],
$group['namespace'],
$group['routes']
);
//$this->log("Registered route group [prefix={$group['prefix']}] for plugin $pluginName");
} else {
$this->log("[WARNING] Invalid route_group config in plugin $pluginName");
}
}
}
$this->enabledPlugins[] = $pluginData;
}
}
}
/**
* 安装插件
* @param string $name
* @return bool
*/
public function installPlugin(string $name): bool|string {
if (!isset($this->plugins[$name])) {
$this->log("Install failed: plugin $name not found");
return false;
}
$plugin = $this->plugins[$name];
// 1. 路由冲突检测
if (!empty($plugin['route_group']) && is_array($plugin['route_group'])) {
$prefixes = [];
foreach ($plugin['route_group'] as $group) {
if (isset($group['prefix'])) {
$prefixes[] = $group['prefix'];
}
}
$prefixes = array_unique($prefixes);
if (!$this->checkRouteConflicts($prefixes)) {
return false; // 路由冲突阻止安装
}
}
// 2. 表冲突检测
if (!empty($plugin['tables']) && is_array($plugin['tables'])) {
$conflictTables = [];
foreach ($plugin['tables'] as $table) {
$stmt = $this->db->query("SHOW TABLES LIKE '{$table}'");
if ($stmt && $stmt->fetch()) {
$conflictTables[] = $table;
}
}
if (!empty($conflictTables)) {
$conflictList = implode(', ', $conflictTables);
$errorMsg = "插件 {$name} 安装失败:以下数据表已存在 -> {$conflictList}";
$this->log("[ERROR] $errorMsg");
return $errorMsg; // 返回错误消息字符串阻止安装
}
}
// 3. 插件元信息写入数据库
$pluginData = [
'name' => $name,
'title' => $plugin['title'] ?? $name,
'description' => $plugin['description'] ?? '',
'version' => $plugin['version'] ?? '',
'author' => $plugin['author'] ?? '',
'url' => $plugin['url'] ?? '',
'status' => 1,
];
$count = $this->db->count('plugins', '*', ['name' => $name]);
if ($count > 0) {
$this->db->update('plugins', $pluginData, ['name' => $name]);
} else {
$this->db->insert('plugins', $pluginData);
}
$this->installedPluginsCache = null;
return true;
}
/**
* 路由冲突检测
*/
public function checkRouteConflicts(array $newPrefixes): bool {
$installedPlugins = $this->getInstalledPlugins();
$installedPrefixes = [];
foreach ($this->plugins as $pluginName => $plugin) {
if (!isset($installedPlugins[$pluginName])) continue;
if (!empty($plugin['route_group']) && is_array($plugin['route_group'])) {
if (isset($plugin['route_group'][0]) && is_array($plugin['route_group'][0])) {
foreach ($plugin['route_group'] as $group) {
$prefix = $group['prefix'] ?? '';
if ($prefix) $installedPrefixes[] = $prefix;
}
} else {
$prefix = $plugin['route_group']['prefix'] ?? '';
if ($prefix) $installedPrefixes[] = $prefix;
}
}
}
$installedPrefixes = array_unique($installedPrefixes);
$conflictNewPrefixes = [];
$conflictInstalledPrefixes = [];
foreach ($newPrefixes as $newPrefix) {
foreach ($installedPrefixes as $installedPrefix) {
if ($this->isPrefixConflict($newPrefix, $installedPrefix)) {
$conflictNewPrefixes[] = $newPrefix;
$conflictInstalledPrefixes[] = $installedPrefix;
}
}
}
if (!empty($conflictNewPrefixes)) {
echo json_encode([
'success' => false,
'message' => "路由前缀冲突:新插件的前缀(" . implode(', ', array_unique($conflictNewPrefixes)) . ")与已安装插件的前缀(" . implode(', ', array_unique($conflictInstalledPrefixes)) . ")重复,请修改后再安装。"
]);
exit;
}
return true;
}
/**
* 判断两个路由前缀是否冲突
*/
protected function isPrefixConflict(string $prefixA, string $prefixB): bool {
$prefixA = rtrim(trim($prefixA), '/');
$prefixB = rtrim(trim($prefixB), '/');
// 两个都是空字符串,视为冲突
if ($prefixA === '' && $prefixB === '') {
return true;
}
// 完全相等才算冲突
if ($prefixA === $prefixB) {
return true;
}
// 不再判断包含关系为冲突,直接返回不冲突
return false;
}
/**
* 卸载插件
* @param string $name
* @return bool
*/
public function uninstallPlugin(string $name): bool {
if (!isset($this->plugins[$name])) {
$this->log("Uninstall failed: plugin $name not found");
return false;
}
$plugin = $this->plugins[$name];
// 删除插件相关的数据库表
if (!empty($plugin['tables']) && is_array($plugin['tables'])) {
foreach ($plugin['tables'] as $table) {
$exists = $this->db->query("SHOW TABLES LIKE '{$table}'")->fetch();
if ($exists) {
try {
$this->db->query("DROP TABLE IF EXISTS `{$table}`");
$this->log("Dropped table: {$table}");
} catch (\Throwable $e) {
$this->log("[ERROR] Failed to drop table {$table}: " . $e->getMessage());
}
}
}
}
// 删除插件记录
$this->db->delete('plugins', ['name' => $name]);
// 清除已安装插件缓存
$this->installedPluginsCache = null;
$this->log("Uninstalled plugin {$name}");
return true;
}
/**
* 启用插件
* @param string $name
* @return bool
*/
public function enablePlugin(string $name): bool {
$count = $this->db->count('plugins', '*', ['name' => $name]);
if ($count == 0) {
$this->log("Enable failed: plugin $name not installed");
return false;
}
$this->db->update('plugins', ['status' => 1], ['name' => $name]);
$this->installedPluginsCache = null;
$this->log("Enabled plugin $name");
return true;
}
/**
* 禁用插件
* @param string $name
* @return bool
*/
public function disablePlugin(string $name): bool {
$count = $this->db->count('plugins', '*', ['name' => $name]);
if ($count == 0) {
$this->log("Disable failed: plugin $name not installed");
return false;
}
$this->db->update('plugins', ['status' => 0], ['name' => $name]);
$this->installedPluginsCache = null;
$this->log("Disabled plugin $name");
return true;
}
}