// +---------------------------------------------------------------------- declare(strict_types=1); namespace ywxapp\service; use GuzzleHttp\Client; use GuzzleHttp\Exception\TransferException; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use think\Exception; use think\facade\Cache; use think\facade\Config; use think\facade\Db; use think\facade\Env; use think\facade\Log; use think\facade\Request; use ywxapp\exception\AddonException; use ywxapp\model\BackendPower as PowerModel; use ywxapp\model\MemberRule as RuleModel; /** * 插件服务(优化版) * * 功能特性: * - 插件安装、卸载、打包 * - 配置管理(带缓存) * - 版本兼容性检查 * - 安全性增强 * - 性能优化 * - 安装 install($force = false, $extend = []) * - 离线安装 local(\think\File $file, array $extend = []): array * - 卸载 uninstall(array $options = []): bool * - 插件打包 package(): string * - 启用 enable(): bool * - 禁用 disable(): bool * - 备份数据 backupData(string $backupDir = ''): string * - 恢复数据 restoreBackup(string $backupFile = ''): bool * - 清理数据 cleanupOldBackups(int $keepCount = 5): int * @package ywxapp\service */ class AddonService { // ==================== 常量定义 ==================== /** 文件大小限制(100MB) */ const MAX_FILE_SIZE = 102400000; /** 支持的文件扩展名 */ const ALLOWED_EXTENSIONS = ['zip', 'fastaddon']; /** 缓存过期时间(秒) */ const CACHE_EXPIRE = 3600; /** 配置文件名 */ const CONFIG_FILE = '.addonrc'; /** 信息文件名 */ const INFO_FILE = 'info.php'; /** SQL安装文件名 */ const INSTALL_SQL_FILE = 'install.sql'; /** 插件主类文件名 */ const ADDON_CLASS_FILE = 'Addon.php'; // ==================== 属性定义 ==================== /** 插件目录路径 */ private string $addonDir; /** 静态资源目录路径 */ private string $staticDir; /** 临时目录路径 */ private string $tempDir; /** 备份目录路径 */ private string $backupDir; /** 插件名称 */ private string $addon; /** 插件信息 */ private array $info = []; /** install.sql 是否已导入(防止重复导入) */ private bool $sqlImported = false; /** HTTP客户端实例 */ private static ?Client $httpClient = null; /** * 构造函数 * * @param string $name 插件名称 */ public function __construct(string $name = '') { $this->addon = $name; if ($name) { $this->initPaths($name); } } /** * 初始化路径 * * @param string $name 插件名称 */ private function initPaths(string $name): void { $this->addonDir = ADDON_PATH . $name . DIRECTORY_SEPARATOR; $this->staticDir = public_path() . 'static' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR; $this->tempDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . 'temp' . DIRECTORY_SEPARATOR; $this->backupDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . 'backup' . DIRECTORY_SEPARATOR; } /** * 静态工厂方法 * * @param string $name 插件名称 * @return self */ public static function instance(string $name = ''): self { return new self($name); } // ==================== 插件安装相关 ==================== /** * 安装插件 * * @param boolean $force 是否覆盖 * @param array $extend 扩展参数 * @return boolean * @throws Exception * @throws AddonException */ public function install($force = false, $extend = []) { return $this->withErrorHandling(function () use ($force, $extend) { // 远程下载插件 $tmpFile = $this->download($extend); try { // 3. 解析插件信息 $info = $this->parseAddonInfo($tmpFile); // 4. 验证插件信息 $this->validateAddonInfo($info); // 5. 准备安装参数 $params = $this->prepareInstallParams($info, $extend, $tmpFile); // 6. 创建插件目录 $this->createAddonDirectories(); // 7. 验证插件包(远程授权校验) // 远程下载安装:包来自市场,始终走 valid() 远程授权校验。 $this->valid($params); // 8. 解压文件 $this->extractAddonFiles($tmpFile); // 9. 执行安装逻辑 $result = $this->executeInstall($params); // 10. 导入SQL $this->importsql(); // 记录操作日志 $this->logOperation('install', [ 'addon' => $this->addon, 'version' => $info['version'] ?? 'unknown' ]); return $result; } catch (\Exception $e) { // 安装失败,清理资源(用 \Throwable 兜底,避免清理逻辑自身的异常[如未初始化 // typed 属性]掩盖掉本次安装的真实根因) try { $this->cleanupFailedInstall(); } catch (\Throwable $ce) { Log::warning('Cleanup failed (original error: ' . $e->getMessage() . '): ' . $ce->getMessage()); } throw $e; } }, 'online_install'); } /** * 离线安装插件 * * @param \think\File $file 插件压缩包 * @param array $extend 扩展参数 * @return array 插件信息 * @throws Exception|AddonException */ public function local(\think\File $file, array $extend = []): array { return $this->withErrorHandling(function () use ($file, $extend) { // 1. 验证上传文件 $this->validateUploadFile($file); // 2. 上传文件到临时目录 $tmpFile = $this->uploadFile($file); try { // 3. 解析插件信息 $this->info = $this->parseAddonInfo($tmpFile); // 4. 验证插件信息 $this->validateAddonInfo($this->info); // 5. 准备安装参数 $params = $this->prepareInstallParams($this->info, $extend, $tmpFile); // 6. 创建插件目录 $this->createAddonDirectories(); // 7. 验证插件包(远程授权校验) // 离线安装:仅当插件在 info.php 声明了 license(付费/需授权插件)时才走 // 远程市场服务端授权校验;免费插件离线安装无需连接远程 api_url,避免本地 // 开发/离线部署时因市场服务端不可达而安装失败。包结构安全由下一步 // extractAddonFiles 内的 isSuspiciousFile 校验保证。 // (远程下载安装 install() 始终走 valid(),因其包来自市场、需远程授权) if (!empty($this->info['license'])) { $this->valid($params); } // 8. 解压文件 $this->extractAddonFiles($tmpFile); // 9. 执行安装逻辑 $result = $this->executeInstall($params); // 10. 导入SQL $this->importsql(); // 记录操作日志 $this->logOperation('install', [ 'addon' => $this->addon, 'version' => $this->info['version'] ?? 'unknown' ]); return $result; } catch (\Exception $e) { // 安装失败,清理资源(用 \Throwable 兜底,避免清理逻辑自身的异常[如未初始化 // typed 属性]掩盖掉本次安装的真实根因) try { $this->cleanupFailedInstall(); } catch (\Throwable $ce) { Log::warning('Cleanup failed (original error: ' . $e->getMessage() . '): ' . $ce->getMessage()); } throw $e; } }, 'local_install'); } /** * 开发者模式安装(免打包、原地安装) * * 适用于插件源码已直接放在 addon/ 目录、但未走“压缩包安装”流程的场景 * (例如本地边改边测)。该方法在原地执行标准安装步骤:写入 info 状态、调用 install 钩子、 * 注入菜单、导入 install.sql,并将插件置为启用,便于立即测试。 * * 可重复执行(幂等):每次重装前先清理本插件旧菜单,SQL 使用 CREATE TABLE IF NOT EXISTS * 与 INSERT IGNORE,不会因重复执行报错。 * * @return array 插件信息 * @throws AddonException|Exception */ public function developInstall(): array { if (!$this->isInstalled()) { throw new AddonException('插件目录不存在,无法以开发模式安装:' . $this->addon); } $infoFile = $this->addonDir . self::INFO_FILE; if (!is_file($infoFile)) { throw new AddonException('插件 info.php 不存在:' . $infoFile); } return $this->withErrorHandling(function () use ($infoFile) { Db::startTrans(); try { $info = include $infoFile; $info['state'] = 1; // 开发模式默认启用 $info['install_time'] = time(); $this->format_var_export($infoFile, $info); // 执行插件自身安装钩子(如额外的数据初始化) $this->callAddonHook('install'); // 清理旧菜单后重新注入,保证幂等 $this->clearAddonMenu(); $this->createMenu(); // 导入建表 / 种子 SQL $this->importsql(); Db::commit(); $this->clearCaches(); return $info; } catch (\Exception $e) { Db::rollback(); throw $e; } }, 'develop_install'); } /** * 验证上传文件 * * @param \think\File $file 文件对象 * @throws Exception */ private function validateUploadFile(\think\File $file): void { $validate = validate(['zip' => 'filesize:102400000|fileExt:zip,fastaddon'], [], false, false); if (! $validate->check(['zip' => $file])) { throw new Exception(lang($validate->getError())); } } /** * 上传文件到临时目录 * * @param \think\File $file 文件对象 * @return string 临时文件路径 * @throws AddonException */ private function uploadFile(\think\File $file): string { $saveDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . 'temp'; if (!is_dir($saveDir)) { mkdir($saveDir, 0777, true); } // 兼容 ThinkPHP 不同版本获取原始文件名/扩展名 $name = method_exists($file, 'getOriginalName') ? $file->getOriginalName() : $file->getBasename(); $ext = method_exists($file, 'getOriginalExtension') ? $file->getOriginalExtension() : $file->getExtension(); if (!empty($ext)) { $name = preg_replace('/\.' . preg_quote($ext, '/') . '$/', '', $name); } $name = ($name ?: md5(uniqid('', true))) . '.' . (empty($ext) ? 'zip' : $ext); // 直接移动上传的临时文件,避免依赖 Filesystem 门面写空文件 $moved = $file->move($saveDir, $name); if (!$moved) { throw new AddonException(lang('文件上传失败')); } $target = is_object($moved) ? $moved->getPathname() : $saveDir . DIRECTORY_SEPARATOR . $name; return $target; } /** * 解析插件信息 * * @param string $tmpFile 临时文件路径 * @return array 插件信息 * @throws Exception */ private function parseAddonInfo(string $tmpFile): array { $zip = new \ZipArchive(); try { if (!is_file($tmpFile) || filesize($tmpFile) === 0) { throw new Exception('上传的插件包为空或无效,请重新上传'); } if ($zip->open($tmpFile) !== TRUE) { throw new Exception('Unable to open the zip file'); } // 增强安全性验证:检查插件结构 $this->verifyaddontructure($zip); $infoContent = $zip->getFromName(self::INFO_FILE); if (!$infoContent) { throw new Exception('Addon info file not found in zip'); } // 安全解析 info.php $info = $this->safeParseInfoContent($infoContent); $zip->close(); return $info; } catch (\Exception $e) { $zip->close(); throw $e; } } /** * 安全解析 info 内容 * * @param string $content PHP内容 * @return array 配置数组 * @throws Exception */ private function safeParseInfoContent(string $content): array { // 移除 PHP 标签(含结束标签) $content = str_replace([''], '', $content); // 移除注释(基于 tokenizer,仅剔除注释 token,不会误伤字符串字面量内的 // 或 /*) $content = $this->stripInfoComments($content); // 移除空白字符 $content = trim($content); // 检查是否包含 return 语句 if (strpos($content, 'return') === false) { throw new Exception('Invalid info.php format: missing return statement'); } // 严格校验:仅允许数组/标量字面量,禁止函数调用、变量、代码执行 $this->assertDataOnly($content); $content = preg_replace('/^return\s+/', '', $content); $content = rtrim($content, ';'); // 写入临时文件后 include(不使用 eval,避免直接执行任意代码) $tmp = tempnam(sys_get_temp_dir(), 'addon_info_'); file_put_contents($tmp, "numFiles; $i++) { $filename = $zip->getNameIndex($i); // 检查路径穿越攻击 if (strpos($filename, '..') !== false || strpos($filename, '/') === 0) { throw new Exception("检测到恶意文件路径:{$filename}"); } // 检查可疑文件 if ($this->isSuspiciousFile($filename)) { $suspiciousFiles[] = $filename; } // 检查必需文件 foreach ($requiredFiles as $required) { if ($filename === $required || strpos($filename, $required . '/') === 0) { $foundFiles[$required] = true; } } } // 检查必需文件是否都存在 foreach ($requiredFiles as $required) { if (!isset($foundFiles[$required])) { throw new Exception("插件包缺少必需文件:{$required}"); } } // 如果发现可疑文件,记录警告但不阻止安装(可根据需要调整) if (!empty($suspiciousFiles)) { Log::warning("插件包中发现可疑文件:" . implode(', ', $suspiciousFiles)); } } /** * 检查是否为可疑文件 * * @param string $filename 文件名 * @return bool */ private function isSuspiciousFile(string $filename): bool { $suspiciousPatterns = [ '\.php$|\.phtml$', // PHP文件(除了必需文件外) '\.(exe|bat|cmd|sh|ps1)$', // 可执行文件 '\.(vbs|js|jar)$', // 脚本文件 '(\.htaccess|\.htpasswd|web\.config)$', // 服务器配置文件 '(README|LICENSE|CHANGELOG)\.(md|txt)$', // 文档文件(允许) ]; // 允许的文件和目录 $allowedPatterns = [ '^info\.php$', '^Addon\.php$', '^config\.php$', '^install\.sql$', '^route/', '^controller/', '^model/', '^view/', '^lang/', '^service/', '^middleware/', '^validate/', '^event/', '^listener/', '^subscribe/', '^tasks/', '^library/', '^command/', // think-console 命令类(如 haonav 的 CheckLinks.php) '^static/', // 插件静态资源(js/css/img,安装时会移入 public/static//) '^helper\.php$', '^common\.php$', // 插件公共函数文件 '^menu\.json$', '^(README|LICENSE|CHANGELOG)\.(md|txt)$', ]; // 检查是否在允许列表中 foreach ($allowedPatterns as $pattern) { if (preg_match('#' . $pattern . '#i', $filename)) { return false; } } // 检查是否为可疑文件 foreach ($suspiciousPatterns as $pattern) { if (preg_match('#' . $pattern . '#i', $filename)) { return true; } } return false; } /** * 基于 tokenizer 移除 PHP 注释(仅剔除 T_COMMENT / T_DOC_COMMENT token), * 避免正则误删字符串字面量内的 //(如 http://)或 /* 内容。 * * @param string $code 已去掉 50) { return false; } return true; } /** * 验证插件信息 * * @param array $info 插件信息 * @throws Exception */ private function validateAddonInfo(array $info): void { $name = $info['name'] ?? ''; if (!$name) { throw new Exception('Addon info file data incorrect: missing name'); } if (!$this->validateAddonName($name)) { throw new Exception('Addon name incorrect: ' . $name); } // 检查版本号 $version = $info['version'] ?? ''; if (!$version || !preg_match('/^\d+\.\d+\.\d+$/', $version)) { throw new Exception('Addon version incorrect: ' . $version); } // 检查是否已存在 if (is_dir(ADDON_PATH . $name)) { throw new Exception('Addon already exists: ' . $name); } // 更新插件名称和路径 $this->addon = $name; $this->initPaths($name); // 校验依赖关系 $this->validateDependencies($info); // 检测插件冲突 $this->checkConflicts($info); } /** * 校验插件依赖关系 * * @param array $info 插件信息 * @throws Exception */ private function validateDependencies(array $info): void { // 尝试获取插件实例来检查依赖 $addonClass = '\\addon\\' . $info['name'] . '\\Addon'; // 如果插件类不存在,先解压后再检查(此处暂时跳过,等待解压完成后再检查) if (!class_exists($addonClass)) { return; // 稍后在安装过程中再检查 } try { $instance = app()->make($addonClass); // 检查插件依赖 $dependencies = method_exists($instance, 'getDependencies') ? $instance->getDependencies() : []; if (!empty($dependencies)) { foreach ($dependencies as $depAddon) { if (!$this->isAddonInstalled($depAddon)) { throw new Exception("依赖插件 {$depAddon} 未安装,请先安装该插件"); } if (!$this->isAddonEnabled($depAddon)) { throw new Exception("依赖插件 {$depAddon} 未启用,请先启用该插件"); } } } // 检查 PHP 扩展依赖 $extensions = method_exists($instance, 'getExtensions') ? $instance->getExtensions() : []; if (!empty($extensions)) { foreach ($extensions as $ext) { if (!extension_loaded($ext)) { throw new Exception("缺少必需的 PHP 扩展:{$ext}"); } } } // 检查 ThinkPHP 版本依赖 if (method_exists($instance, 'getThinkVersion')) { $requiredVersion = $instance->getThinkVersion(); $currentVersion = \think\App::VERSION; // 解析版本比较 if (preg_match('/^([><=!]+)?\s*(\d+\.\d+\.?\d*)/', $requiredVersion, $matches)) { $operator = $matches[1] ?? '>='; $requiredVer = $matches[2]; if (!version_compare($currentVersion, $requiredVer, $operator)) { throw new Exception("需要 ThinkPHP {$requiredVersion} 或更高版本,当前版本:{$currentVersion}"); } } } } catch (\ReflectionException $e) { // 无法实例化插件类,跳过依赖检查 Log::warning("无法实例化插件类 {$addonClass} 进行依赖检查:" . $e->getMessage()); } } /** * 检查插件是否已安装 * * @param string $addon 插件名称 * @return bool */ private function isAddonInstalled(string $addon): bool { return is_dir(ADDON_PATH . $addon) && is_file(ADDON_PATH . $addon . DIRECTORY_SEPARATOR . 'info.php'); } /** * 检查插件是否已启用 * * @param string $addon 插件名称 * @return bool */ private function isAddonEnabled(string $addon): bool { $infoFile = ADDON_PATH . $addon . DIRECTORY_SEPARATOR . 'info.php'; if (!is_file($infoFile)) { return false; } $info = include $infoFile; return isset($info['state']) && $info['state'] == 1; } /** * 检测插件冲突 * * @param array $info 插件信息 * @throws Exception */ private function checkConflicts(array $info): void { $conflicts = []; $addonName = $info['name']; // 检查命名空间冲突 $namespace = 'addon\\' . $addonName; if (class_exists($namespace . '\\Addon') && $this->isAddonInstalled($addonName)) { $conflicts[] = "命名空间冲突:{$namespace} 已被占用"; } // 检查路由冲突 $existingRoutes = $this->getExistingRoutes(); $addonRoutes = $info['routes'] ?? []; // 检查插件是否有自定义路由文件 $routeFile = ADDON_PATH . $addonName . DIRECTORY_SEPARATOR . 'route' . DIRECTORY_SEPARATOR . 'app.php'; if (is_file($routeFile)) { $routeContent = file_get_contents($routeFile); // 简单提取路由定义 preg_match_all("/Route::(?:get|post|put|delete|patch|any)\(['\"]([^'\"]+)['\"]/", $routeContent, $matches); if (!empty($matches[1])) { $addonRoutes = array_merge($addonRoutes, $matches[1]); } } foreach ($addonRoutes as $route) { $routeKey = '/' . $addonName . '/' . ltrim($route, '/'); if (isset($existingRoutes[$routeKey])) { $conflicts[] = "路由冲突:{$routeKey} 已被插件 {$existingRoutes[$routeKey]} 占用"; } } // 检查菜单冲突 $existingMenus = $this->getExistingMenus(); $addonMenus = $info['menus'] ?? []; // 如果插件有菜单定义,检查冲突 $addonClass = '\\addon\\' . $addonName . '\\Addon'; if (class_exists($addonClass)) { $addonInstance = app()->make($addonClass); if (isset($addonInstance->AddonMenu) && is_array($addonInstance->AddonMenu)) { foreach ($addonInstance->AddonMenu as $layer => $menus) { foreach ($menus as $menu) { if (isset($menu['name'])) { if (isset($existingMenus[$menu['name']])) { $conflicts[] = "菜单冲突:{$menu['name']} 已存在"; } // 检查子菜单 if (isset($menu['sublist']) && is_array($menu['sublist'])) { foreach ($menu['sublist'] as $submenu) { if (isset($submenu['name']) && isset($existingMenus[$submenu['name']])) { $conflicts[] = "菜单冲突:{$submenu['name']} 已存在"; } } } } } } } } // 检查数据库表前缀冲突 $existingTables = $this->getExistingTables(); $addonTables = $this->getAddonTables($addonName); foreach ($addonTables as $table) { if (in_array($table, $existingTables)) { $conflicts[] = "数据库表冲突:{$table} 已存在"; } } if (!empty($conflicts)) { throw new AddonException("插件冲突检测失败:" . implode(';', $conflicts)); } } /** * 获取现有路由 * * @return array */ private function getExistingRoutes(): array { static $routes = null; if ($routes === null) { $routes = []; // 获取所有已启用插件的路由 $addon = Config::get('addon', []); foreach ($addon as $addon) { if (!isset($addon['name']) || !$this->isAddonEnabled($addon['name'])) { continue; } $routeFile = ADDON_PATH . $addon['name'] . DIRECTORY_SEPARATOR . 'route' . DIRECTORY_SEPARATOR . 'app.php'; if (is_file($routeFile)) { $routeContent = file_get_contents($routeFile); preg_match_all("/Route::(?:get|post|put|delete|patch|any)\(['\"]([^'\"]+)['\"]/", $routeContent, $matches); if (!empty($matches[1])) { foreach ($matches[1] as $route) { $routeKey = '/' . $addon['name'] . '/' . ltrim($route, '/'); $routes[$routeKey] = $addon['name']; } } } // 检查info.php中定义的路由 if (isset($addon['routes']) && is_array($addon['routes'])) { foreach ($addon['routes'] as $route) { $routeKey = '/' . $addon['name'] . '/' . ltrim($route, '/'); $routes[$routeKey] = $addon['name']; } } } } return $routes; } /** * 获取现有菜单 * * @return array */ private function getExistingMenus(): array { static $menus = null; if ($menus === null) { $menus = []; try { // 从数据库获取现有菜单。 // 注意:本框架后台菜单表是 admin_power(模型 BackendPower),不存在 auth_rule 表; // 原先误写 auth_rule 导致每次安装都告警"获取现有菜单失败",且菜单冲突检测永远为空。 // admin_power 启用软删除(delete_at,defaultSoftDelete=0),走模型让软删条件自动生效。 $menuItems = \ywxapp\model\BackendPower::column('name', 'id'); foreach ($menuItems as $menuName) { $menus[$menuName] = true; } } catch (\Exception $e) { // 数据库表可能不存在,忽略错误 Log::warning("获取现有菜单失败:" . $e->getMessage()); } } return $menus; } /** * 获取现有数据表 * * @return array */ private function getExistingTables(): array { static $tables = null; if ($tables === null) { $tables = []; try { $prefix = Config::get('database.connections.mysql.prefix'); $tables = Db::query("SHOW TABLES LIKE '{$prefix}%'"); $tables = array_map(function ($table) use ($prefix) { return str_replace($prefix, '', array_values($table)[0]); }, $tables); } catch (\Exception $e) { Log::warning("获取现有数据表失败:" . $e->getMessage()); } } return $tables; } /** * 获取插件定义的数据表 * * @param string $addonName 插件名称 * @return array */ private function getAddonTables(string $addonName): array { $tables = []; $sqlFile = ADDON_PATH . $addonName . DIRECTORY_SEPARATOR . 'install.sql'; if (is_file($sqlFile)) { $sqlContent = file_get_contents($sqlFile); // 提取CREATE TABLE语句中的表名 preg_match_all("/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([^`\s]+)`?/i", $sqlContent, $matches); if (!empty($matches[1])) { $prefix = Config::get('database.connections.mysql.prefix'); foreach ($matches[1] as $table) { // 移除表前缀,只保留纯表名 $tableName = str_replace([$prefix, '__PREFIX__', 'wx_'], '', $table); $tables[] = $tableName; } } } return $tables; } /** * 准备安装参数 * * @param array $info 插件信息 * @param array $extend 扩展参数 * @param string $tmpFile 临时文件路径 * @return array 完整参数 */ private function prepareInstallParams(array $info, array $extend, string $tmpFile): array { // 追加MD5和Data数据 $extend['md5'] = md5_file($tmpFile); // $extend['comment'] = (new \ZipArchive())->open($tmpFile) ? (new \ZipArchive())->getArchiveComment() : ''; $extend['unknownsources'] = Env::get('app_debug') && config('ywxapp.unknownsources'); $extend['yversion'] = config('ywxapp.version'); // 站点域名:注入授权校验参数,供中心站 Market::valid() 做「授权绑站点」判定(对齐 Discuz!) try { $extend['domain'] = (string) Request::domain(); } catch (\Throwable $e) { // CLI / 无请求上下文时留空,中心站仅按 uid 校验 } return array_merge($info, $extend); } /** * 创建插件目录 * * @throws Exception */ private function createAddonDirectories(): void { if (!@mkdir($this->addonDir, 0755, true)) { throw new Exception('Failed to create addon directory: ' . $this->addonDir); } } /** * 验证压缩包、依赖验证 * * @param array $params 验证参数 * @return bool 验证结果 * @throws Exception */ private function valid(array $params = []): bool { // 开发环境 + 允许未知来源 才跳过远程校验(用于本地开发调试) if (Env::get('app_debug') && config('ywxapp.unknownsources')) { Log::info('Addon validation bypassed (app_debug + unknownsources),离线包未经远程授权校验即放行'); return true; } $client = $this->getClient(); $multipart = []; foreach ($params as $name => $value) { $multipart[] = ['name' => $name, 'contents' => $value]; } try { // 自托管插件市场统一走 /api 前缀(与 lists/download 一致,避免多应用默认应用根路由匹配失效)。 // 连官方市场 api.ywxapp.cn 时若其校验接口为无前缀 /addon/valid,请改回。 $response = $client->post('/appmall/api/addon/valid', ['multipart' => $multipart]); $content = $response->getBody()->getContents(); } catch (TransferException $e) { // 网络/证书失败:默认「拒绝安装」而非放行,防止通过阻断授权服务器绕过校验 Log::error('Addon validation network error: ' . $e->getMessage()); throw new Exception('插件授权校验服务器暂不可达,请稍后重试或联系管理员'); } $json = (array) json_decode($content, true); if (!$json || !isset($json['code'])) { // 响应非法或无法解析:拒绝安装,避免被伪造的空响应放行 Log::error('Addon validation returned invalid response: ' . substr($content, 0, 200)); throw new Exception('插件授权校验返回异常,请稍后重试或联系管理员'); } if ($json['code'] == 0) { return true; } throw new Exception($json['msg'] ?? '插件包未通过授权校验'); } /** * 解压插件文件 * * @param string $tmpFile 临时文件路径 * @throws Exception */ private function extractAddonFiles(string $tmpFile): void { $zip = new \ZipArchive(); try { if (!$zip->open($tmpFile)) { throw new Exception('Unable to open zip file for extraction'); } // 防 Zip Slip:逐条校验解压路径,禁止跳出插件目录 $baseDir = realpath($this->addonDir) ?: $this->addonDir; for ($i = 0; $i < $zip->numFiles; $i++) { $entry = $zip->getNameIndex($i); if ($entry === false) { continue; } $target = $baseDir . DIRECTORY_SEPARATOR . $entry; $realBase = realpath(dirname($target)) ?: dirname($target); if (strpos(str_replace('\\', '/', $realBase), str_replace('\\', '/', $baseDir)) !== 0) { $zip->close(); throw new Exception('非法的压缩包路径: ' . $entry); } } if (!$zip->extractTo($this->addonDir)) { throw new Exception('Unable to extract files to addon directory'); } // 处理静态资源 $staticPath = $this->addonDir . DIRECTORY_SEPARATOR . 'static'; if (is_dir($staticPath)) { $newStaticPath = public_path() . 'static' . DIRECTORY_SEPARATOR . $this->addon; if (!is_dir($newStaticPath)) { @mkdir($newStaticPath, 0755, true); } // 将已解压到插件目录的 static/ 递归复制到公共静态目录。 // 注意:ZipArchive::extractTo($dst, 'static') 不会递归抽取子条目(仅匹配同名条目本身), // 实测抽出 0 文件,导致 public/static// 为空。故改用目录复制。 $this->copyAddonDir($staticPath, $newStaticPath); // 删除插件目录中的static文件夹 $this->deleteDirectory($staticPath); } $zip->close(); } catch (\Exception $e) { $zip->close(); throw $e; } } /** * 执行安装逻辑 * * @param array $params 安装参数 * @return array 插件信息 * @throws Exception */ private function executeInstall(array $params): array { Db::startTrans(); try { // 读取并更新info.php $infoFile = $this->addonDir . self::INFO_FILE; $info = include $infoFile; $info['state'] = 0; // 默认禁用 $info['install_time'] = time(); $this->format_var_export($infoFile, $info); // 执行插件自身安装钩子 $this->callAddonHook('install'); // 注入菜单 $this->createMenu(); // 导入建表/种子 SQL(纳入同一事务,保证安装原子性) $this->importsql(); Db::commit(); // 钩子点:插件安装成功后触发(全局事件,任意插件可监听以做联动) event('addon_install_after', ['name' => $this->addon, 'info' => $info]); // 清除相关缓存 $this->clearCaches(); return $info; } catch (\Exception $e) { Db::rollback(); throw $e; } } /** * 安装失败时清理资源 * * 注意:开发环境(app_debug=1)下【保留】已解压的插件目录,不执行删除, * 否则一旦出现 install.sql / 钩子 / 菜单等后置异常,刚解压的文件会被整个 * 清掉,表现为「安装完成但 addon 下没有文件」,极难排查。保留目录便于 * 直接查看 install.sql、Addon.php 并定位异常根因。生产环境仍按原逻辑清理。 */ private function cleanupFailedInstall(): void { if (Env::get('app_debug')) { Log::warning('安装失败:开发环境已保留 addon/' . $this->addon . ' 目录(未清理)以便排查。请查看上方日志中的安装异常信息。'); return; } // 注意:$this->addonDir / $this->staticDir 是 typed string,仅当 validateAddonInfo() // 成功解析出插件名后才会被 initPaths() 初始化。若安装更早阶段(如「Addon already // exists」「version incorrect」)就已抛异常,这两个属性仍为未初始化状态,直接访问会抛 // \Error(Typed property ... must not be accessed before initialization),反而把真实 // 错误掩盖掉。因此这里一律从已保证初始化(构造时置空串)的 $this->addon 派生路径。 $name = $this->addon; if ($name === '' || $name === null) { return; } try { $addonDir = ADDON_PATH . $name . DIRECTORY_SEPARATOR; if (is_dir($addonDir)) { $this->deleteDirectory($addonDir); } $staticDir = public_path() . 'static' . DIRECTORY_SEPARATOR . 'addon' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR; if (is_dir($staticDir)) { $this->deleteDirectory($staticDir); } } catch (\Throwable $e) { Log::warning('Cleanup failed: ' . $e->getMessage()); } } /** * 调用插件自身的 install/uninstall 钩子 * @param string $method * @throws Exception */ private function callAddonHook(string $method, array $args = []): void { $class = '\\addon\\' . $this->addon . '\\Addon'; if (!class_exists($class)) { return; } try { $instance = app($class); if (method_exists($instance, $method)) { $result = $args ? $instance->$method(...$args) : $instance->$method(); if ($result === false) { throw new Exception("插件 {$this->addon} 的 {$method} 方法返回 false"); } } } catch (\Exception $e) { throw new Exception("执行插件 {$this->addon} 的 {$method} 钩子失败: " . $e->getMessage()); } } // ==================== SQL导入 ==================== /** * 导入SQL文件 * * @return bool 导入结果 */ private function importsql(): bool { if ($this->sqlImported) { return true; } $sqlFile = $this->addonDir . self::INSTALL_SQL_FILE; if (!is_file($sqlFile)) { $this->sqlImported = true; return true; } // 读取文件内容 $sqlContent = file_get_contents($sqlFile); if (!$sqlContent) { $this->sqlImported = true; return true; } // 移除注释 $sqlContent = preg_replace('/--.*|\/\*[\s\S]*?\*\//', '', $sqlContent); // 分割为单条SQL语句 $statements = array_filter( array_map('trim', explode(';', $sqlContent)), function ($stmt) { return !empty($stmt) && strlen($stmt) > 5; } ); $defaultDb = Config::get('database.default'); $prefix = Config::get("database.connections.{$defaultDb}.prefix"); foreach ($statements as $statement) { $statement = str_ireplace('__PREFIX__', $prefix, $statement); // 兜底:插件 install.sql 多为 mysqldump 直接导出,表名硬编码为 `wxapp_` 前缀。 // 当用户设置了自定义表前缀时,统一替换为当前库前缀,保证与运行时 // Db::name('xxx') 拼接出的表名一致(仅匹配反引号标识符,不误伤数据值)。 if ($prefix !== '' && $prefix !== 'wxapp_') { $statement = str_ireplace('`wxapp_', '`' . $prefix, $statement); } // 仅允许建表与插入语句,杜绝 DROP / DELETE / UPDATE / ALTER 等破坏性 SQL if (!preg_match('/^\s*(CREATE\s+TABLE|INSERT\s)/i', $statement)) { Log::warning('install.sql 跳过非白名单语句: ' . substr($statement, 0, 80)); continue; } // 使用 INSERT IGNORE 避免重复插入错误 $statement = preg_replace('/^INSERT INTO /i', 'INSERT IGNORE INTO ', $statement); Db::execute($statement); } $this->sqlImported = true; return true; } // ==================== 插件打包 ==================== /** * 插件打包 * * @return string 打包后的文件路径 * @throws Exception */ public function package(): string { return $this->withErrorHandling(function () { // 验证插件信息 $infoFile = $this->addonDir . self::INFO_FILE; if (!is_file($infoFile)) { throw new Exception(lang('Addon info file was not found')); } $info = include $infoFile; if (!$info) { throw new Exception(lang('Addon info file data incorrect')); } $infoname = $info['name'] ?? ''; if (!$infoname || !preg_match('/^[a-z]+$/i', $infoname) || $infoname != $this->addon) { throw new Exception(lang('Addon info name incorrect')); } $infoversion = $info['version'] ?? ''; if (!$infoversion || !preg_match('/^\d+\.\d+\.\d+$/i', $infoversion)) { throw new Exception(lang('Addon info version incorrect')); } // 创建临时目录 if (!is_dir($this->tempDir)) { @mkdir($this->tempDir, 0755, true); } $addonFile = $this->tempDir . $infoname . '-' . $infoversion . '.zip'; if (!class_exists('ZipArchive')) { throw new Exception(lang('ZipArchive not installed')); } $zip = new \ZipArchive(); $zip->open($addonFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); try { $this->addDirectoryToZip($zip, $this->addonDir, ''); if (is_dir($this->staticDir)) { $this->addDirectoryToZip($zip, $this->staticDir, 'static/'); } $zip->close(); $this->logOperation('package', [ 'file' => $addonFile, 'size' => filesize($addonFile) ]); return $addonFile; } catch (\Exception $e) { $zip->close(); throw $e; } }, 'package'); } /** * 添加目录到ZIP文件 * * @param \ZipArchive $zip ZIP对象 * @param string $dir 目录路径 * @param string $prefix ZIP内路径前缀 */ private function addDirectoryToZip(\ZipArchive $zip, string $dir, string $prefix): void { $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($files as $file) { $filePath = $file->getRealPath(); $relativePath = substr($filePath, strlen($dir)); $relativePath = ltrim(str_replace(DIRECTORY_SEPARATOR, '/', $relativePath), '/'); if ($file->isDir()) { $zip->addEmptyDir($prefix . $relativePath); } else { $zip->addFile($filePath, $prefix . $relativePath); } } } // ==================== 配置管理 ==================== /** * 读取或修改插件配置(带缓存) * * @param string $name 插件名称 * @param array $changed 要修改的配置 * @return array 配置数组 */ public static function config(string $name, array $changed = []): array { $cacheKey = 'addon_config_' . $name; $versionKey = 'addon_config_version_' . $name; // 获取当前配置版本 $currentVersion = Cache::get($versionKey, 0); $versionedCacheKey = $cacheKey . '_v' . $currentVersion; // 优先从版本化缓存读取 $config = Cache::get($versionedCacheKey); if ($config === null || !empty($changed)) { self::ensureConfigTable(); // 从统一配置表读取「独立项」(addon + name 唯一,每行一个配置项) $rows = Db::name('addon_config')->where('addon', $name)->column('value', 'name'); $config = $rows ?: []; // 平滑迁移:若该插件此前用 .addonrc 文件存过配置,则一次性导入数据库 if (empty($config)) { $legacyFile = ADDON_PATH . $name . DIRECTORY_SEPARATOR . self::CONFIG_FILE; if (is_file($legacyFile)) { $legacy = (array) json_decode(file_get_contents($legacyFile), true); if (!empty($legacy)) { self::saveConfigRows($name, $legacy); $config = $legacy; } } } // 合并并落库 if (!empty($changed)) { self::saveConfigRows($name, $changed); $config = array_merge($config, $changed); // 增加版本号,使旧缓存失效 $newVersion = $currentVersion + 1; Cache::set($versionKey, $newVersion); Cache::set($cacheKey . '_v' . $newVersion, $config, self::CACHE_EXPIRE); // 异步清理旧版本缓存(延迟5秒,避免影响当前请求) register_shutdown_function(function () use ($cacheKey, $currentVersion) { if ($currentVersion > 0) { Cache::delete($cacheKey . '_v' . $currentVersion); } }); } else { // 没有配置变更,使用当前版本缓存 Cache::set($versionedCacheKey, $config, self::CACHE_EXPIRE); } } return $config; } /** * 自愈建表:确保统一插件配置表存在(CREATE TABLE IF NOT EXISTS) * 表结构:wxapp_addon_config,每个配置项独立成行(addon + name 唯一) */ private static function ensureConfigTable(): void { try { $defaultDb = Config::get('database.default'); $prefix = Config::get("database.connections.{$defaultDb}.prefix"); $table = $prefix . 'addon_config'; $exists = Db::query("SHOW TABLES LIKE '{$table}'"); if (empty($exists)) { Db::execute("CREATE TABLE IF NOT EXISTS `{$table}` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `addon` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '插件标识', `name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '配置项名', `value` TEXT COMMENT '配置值', `create_at` INT UNSIGNED NOT NULL DEFAULT 0, `update_at` INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_addon_name` (`addon`, `name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件配置表';"); } } catch (\Exception $e) { Log::warning('ensureConfigTable failed: ' . $e->getMessage()); } } /** * 批量 upsert 插件配置项(独立行写入统一表) * @param string $name 插件标识 * @param array $values name => value */ private static function saveConfigRows(string $name, array $values): void { $defaultDb = Config::get('database.default'); $prefix = Config::get("database.connections.{$defaultDb}.prefix"); $table = $prefix . 'addon_config'; $time = time(); foreach ($values as $key => $val) { $val = is_array($val) ? json_encode($val, JSON_UNESCAPED_UNICODE) : (string) $val; Db::execute( "INSERT INTO `{$table}` (`addon`,`name`,`value`,`create_at`,`update_at`) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`), `update_at`=VALUES(`update_at`)", [$name, $key, $val, $time, $time] ); } } /** * 清除插件配置缓存 * * @param string $name 插件名称 */ public static function clearConfigCache(string $name): void { // 与 config() 的版本化缓存 key 对齐:删除当前版本缓存,并重置版本号, // 确保下次读取回源数据库、重建最新缓存(旧实现只删无版本后缀的 key,永远命中不到)。 $versionKey = 'addon_config_version_' . $name; $currentVersion = Cache::get($versionKey, 0); Cache::delete('addon_config_' . $name . '_v' . $currentVersion); Cache::delete($versionKey); } /** * 批量获取插件信息 * * @param array $addonNames 插件名称数组 * @return array 插件信息数组 */ public static function batchGetInfo(array $addonNames): array { $results = []; $cacheKey = 'addon_batch_info_' . md5(implode(',', $addonNames)); // 尝试从缓存获取 $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } foreach ($addonNames as $name) { try { $addonDir = ADDON_PATH . $name . DIRECTORY_SEPARATOR; $infoFile = $addonDir . self::INFO_FILE; if (is_file($infoFile)) { $info = include $infoFile; $results[$name] = $info; } } catch (\Exception $e) { Log::warning("Failed to load addon info for {$name}: " . $e->getMessage()); } } // 缓存结果 Cache::set($cacheKey, $results, 300); // 5分钟 return $results; } // ==================== 插件状态管理 ==================== /** * 启用插件 * * @return bool 操作结果 * @throws Exception */ public function enable(): bool { // 先回调插件主类的 enable()(返回 false 可阻断启用),再落库改状态 $this->callAddonHook('enable'); event('addon_enable_after', ['name' => $this->addon]); return $this->updateState(1); } /** * 禁用插件 * * @return bool 操作结果 * @throws Exception */ public function disable(): bool { // 先回调插件主类的 disable()(返回 false 可阻断禁用),再落库改状态 $this->callAddonHook('disable'); event('addon_disable_after', ['name' => $this->addon]); return $this->updateState(0); } /** * 升级插件 * 触发插件主类的 upgrade($currentVersion) 钩子,并清理配置缓存 * * @param string $currentVersion 目标/当前已安装版本(缺省时自动读取) * @return bool * @throws Exception */ public function upgrade(string $currentVersion = ''): bool { if ($currentVersion === '') { $installed = \ywxapp\model\AddonModel::where('name', $this->addon)->value('version'); $currentVersion = $installed ?: ''; } $this->callAddonHook('upgrade', [$currentVersion]); self::clearConfigCache($this->addon); return true; } /** * 在线升级插件(下载指定/最新版本 → 备份 → 覆盖 → 调 upgrade 钩子 → 增量 SQL) * * 与 install/local 不同:本方法允许覆盖已安装的插件目录,不会因「已存在」而拒绝。 * * @param string $version 目标版本(留空取服务端最新) * @return array ['from'=>string, 'to'=>string] * @throws AddonException|Exception */ public function onlineUpgrade(string $version = ''): array { if (!$this->isInstalled()) { throw new AddonException('插件未安装,无法升级,请先安装'); } $currentInfo = include $this->addonDir . self::INFO_FILE; $currentVersion = $currentInfo['version'] ?? ''; $currentState = $currentInfo['state'] ?? 0; return $this->withErrorHandling(function () use ($version, $currentVersion, $currentState) { // 1. 下载目标版本(带 version 参数;留空取服务端最新) $extend = []; if ($version !== '') { $extend['version'] = $version; } $tmpFile = $this->download($extend); // 2. 解析新包信息 $info = $this->parseAddonInfo($tmpFile); $newVersion = $info['version'] ?? ''; // 3. 备份现有插件目录 $backupDir = $this->backupDir . $this->addon . '-' . ($currentVersion ?: 'unknown') . '-' . date('YmdHis') . DIRECTORY_SEPARATOR; $this->copyAddonDir($this->addonDir, $backupDir); // 4. 解压覆盖(不调用 validateAddonInfo,允许覆盖已存在目录) $this->extractAddonFiles($tmpFile); // 5. 调用 upgrade 钩子(传入升级前版本) $this->callAddonHook('upgrade', [$currentVersion]); // 6. 增量导入 SQL(CREATE TABLE IF NOT EXISTS + INSERT IGNORE,幂等安全) $this->importsql(); // 7. 更新 info.php 的版本与状态(保留原启用状态) $newInfo = include $this->addonDir . self::INFO_FILE; $newInfo['version'] = $newVersion ?: ($newInfo['version'] ?? $currentVersion); $newInfo['state'] = $currentState; $newInfo['update_time'] = time(); $this->format_var_export($this->addonDir . self::INFO_FILE, $newInfo); // 8. 清缓存 $this->clearCaches(); return ['from' => $currentVersion, 'to' => $newVersion ?: $currentVersion]; }, 'online_upgrade'); } /** * 离线覆盖升级(本地 zip → 备份 → 解压覆盖 → 增量 SQL → 更新版本) * * 与 onlineUpgrade 的区别:不从远程市场下载,而是直接接收一个本地 zip 文件 * (例如 scripts/package_appmarket_addon.php 生成的 runtime/market/-.zip), * 用于「手动执行升级」场景(php think ywxapp:upgrade --file=xxx.zip --type=addon)。 * * 与 local(首次安装)的区别:本方法允许覆盖已安装的插件目录,不会因「已存在」而拒绝。 * * @param \think\File $file 本地升级包 * @return array ['from'=>string, 'to'=>string] * @throws AddonException|Exception */ public function localUpgrade(\think\File $file): array { if (!$this->isInstalled()) { throw new AddonException('插件未安装,无法升级,请先安装'); } $currentInfo = include $this->addonDir . self::INFO_FILE; $currentVersion = $currentInfo['version'] ?? ''; $currentState = $currentInfo['state'] ?? 0; return $this->withErrorHandling(function () use ($file, $currentVersion, $currentState) { // 1. 上传(移动)本地 zip 到临时目录 $tmpFile = $this->uploadFile($file); // 2. 解析新包信息 $info = $this->parseAddonInfo($tmpFile); $newVersion = $info['version'] ?? ''; // 3. 备份现有插件目录 $backupDir = $this->backupDir . $this->addon . '-' . ($currentVersion ?: 'unknown') . '-' . date('YmdHis') . DIRECTORY_SEPARATOR; $this->copyAddonDir($this->addonDir, $backupDir); // 4. 解压覆盖(extractAddonFiles 含 Zip Slip 防护与 static 资源处理) $this->extractAddonFiles($tmpFile); // 5. 调用 upgrade 钩子(传入升级前版本) $this->callAddonHook('upgrade', [$currentVersion]); // 6. 增量导入 SQL(CREATE TABLE IF NOT EXISTS + INSERT IGNORE,幂等安全) $this->importsql(); // 7. 更新 info.php 的版本与状态(保留原启用状态) $newInfo = include $this->addonDir . self::INFO_FILE; $newInfo['version'] = $newVersion ?: ($newInfo['version'] ?? $currentVersion); $newInfo['state'] = $currentState; $newInfo['update_time'] = time(); $this->format_var_export($this->addonDir . self::INFO_FILE, $newInfo); // 8. 清缓存 $this->clearCaches(); return ['from' => $currentVersion, 'to' => $newVersion ?: $currentVersion]; }, 'local_upgrade'); } /** * 递归复制插件目录(升级前备份用) */ private function copyAddonDir(string $src, string $dst): void { if (!is_dir($dst)) { @mkdir($dst, 0755, true); } $it = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($src, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST ); foreach ($it as $item) { $target = $dst . DIRECTORY_SEPARATOR . $it->getSubPathName(); if ($item->isDir()) { if (!is_dir($target)) { @mkdir($target, 0755, true); } } else { if (!is_dir(dirname($target))) { @mkdir(dirname($target), 0755, true); } copy($item->getRealPath(), $target); } } } /** * 更新插件状态 * * @param int $state 状态(0=禁用, 1=启用) * @return bool 操作结果 * @throws Exception */ private function updateState(int $state): bool { $infoFile = $this->addonDir . self::INFO_FILE; if (!is_file($infoFile)) { throw new Exception('Addon info file not found'); } $info = include $infoFile; $info['state'] = $state; $info['update_time'] = time(); $this->format_var_export($infoFile, $info); // 清除缓存 self::clearConfigCache($this->addon); // 清除插件加载配置缓存(状态变更影响插件加载) \ywxapp\service\AppService::clearAddonCache(); return true; } /** * 格式化打印 * @param string $file * @param array $data */ private function format_var_export($file, $data = []) { $string = " \n array (", "=> [", $string); $string = str_replace("),", "],", $string); $string = str_replace(");", "];", $string); $string = str_replace("array (", "[", $string); $string = str_replace(" ", " ", $string); if (!file_put_contents($file, $string)) { throw new Exception('Failed to update addon state'); } return true; } /** * 获取插件状态 * * @return ?int 状态(0=禁用, 1=启用, null=未找到) */ public function getState(): ?int { $infoFile = $this->addonDir . self::INFO_FILE; if (!is_file($infoFile)) { return null; } $info = include $infoFile; return $info['state'] ?? 0; } /** * 检查插件是否已启用 * * @return bool */ public function isEnabled(): bool { return $this->getState() === 1; } // ==================== 插件信息获取 ==================== /** * 获取插件完整信息 * * @return array 插件信息 * @throws Exception */ public function getInfo(): array { $infoFile = $this->addonDir . self::INFO_FILE; if (!is_file($infoFile)) { throw new Exception('Addon info file not found'); } $info = include $infoFile; // 添加额外信息 $info['path'] = $this->addonDir; $info['static_path'] = $this->staticDir; $info['exists'] = true; $info['installed'] = $this->isInstalled(); $info['enabled'] = $this->isEnabled(); return $info; } /** * 检查插件是否已安装 * * @return bool */ public function isInstalled(): bool { return is_dir($this->addonDir) && is_file($this->addonDir . self::INFO_FILE); } /** * 获取插件版本 * * @return ?string 版本号 */ public function getVersion(): ?string { $infoFile = $this->addonDir . self::INFO_FILE; if (!is_file($infoFile)) { return null; } $info = include $infoFile; return $info['version'] ?? null; } /** * 检查版本兼容性 * * @param string $requiredVersion 需要的最低版本 * @return bool 是否兼容 */ public function checkVersionCompatibility(string $requiredVersion): bool { $currentVersion = $this->getVersion(); if ($currentVersion === null) { return false; } return version_compare($currentVersion, $requiredVersion, '>='); } // ==================== 远程下载 ==================== /** * 远程下载插件 * * @param array $extend 扩展参数 * @return string 下载的文件路径 * @throws AddonException */ public function download(array $extend = []): string { $tmpFile = $this->tempDir . $this->addon . '.zip'; if (!is_dir($this->tempDir)) { @mkdir($this->tempDir, 0755, true); } try { $client = $this->getClient(); // 构造下载请求参数:必带 name;市场安装(downloadInstall)时 $extend 内含 version,服务端按 name+version 唯一定位 $query = $extend; $query['name'] = $this->addon; $version = (string) ($extend['version'] ?? ''); if ($version !== '') { $query['version'] = $version; } // 下载签名:服务端开启 addon_download_sign 时,需携带 sign=md5(name.version.ts.secret) 与 ts(5 分钟有效期) if (config('ywxapp.addon_download_sign', false)) { $ts = time(); $secret = config('ywxapp.addon_secret', ''); $query['ts'] = $ts; $query['sign'] = md5($this->addon . $version . $ts . $secret); } // 流式落地到临时文件(不整包入内存),放宽超时与读取超时 set_time_limit(0); $response = $client->get('/appmall/api/index/index', [ 'query' => $query, 'sink' => $tmpFile, 'timeout' => 3600, 'read_timeout' => 600, ]); if ($response->getStatusCode() >= 400) { $err = (string) @file_get_contents($tmpFile); @unlink($tmpFile); throw new AddonException('下载失败(HTTP ' . $response->getStatusCode() . '):' . mb_substr($err, 0, 200)); } $fh = fopen($tmpFile, 'rb'); $first = $fh ? fread($fh, 1) : ''; if ($fh) { fclose($fh); } if ($first === '{') { $json = (array) json_decode((string) @file_get_contents($tmpFile), true); if (isset($json['data']['url'])) { // 重定向到实际下载地址 $response = $client->get($json['data']['url'], [ 'sink' => $tmpFile, 'timeout' => 3600, 'read_timeout' => 600, ]); $fh = fopen($tmpFile, 'rb'); $first = $fh ? fread($fh, 1) : ''; if ($fh) { fclose($fh); } if ($first === '{') { $json2 = (array) json_decode((string) @file_get_contents($tmpFile), true); @unlink($tmpFile); throw new AddonException($json2['message'] ?? 'Download failed', $json2['code'] ?? 500); } } else { // 下载返回错误 @unlink($tmpFile); throw new AddonException($json['message'] ?? 'Download failed', $json['code'] ?? 500); } } // 完整性校验:比对 Content-Length 与实际落盘字节数,不一致即判定下载被截断 $size = is_file($tmpFile) ? filesize($tmpFile) : 0; $contentLength = (int) $response->getHeaderLine('Content-Length'); if ($size <= 0) { throw new AddonException('下载失败:未获取到文件内容'); } if ($contentLength > 0 && $size !== $contentLength) { @unlink($tmpFile); throw new AddonException('下载插件包不完整(期望 ' . $contentLength . ' 字节,实际 ' . $size . ' 字节)'); } // 记录操作日志 $this->logOperation('download', [ 'file' => $tmpFile, 'size' => $size ]); return $tmpFile; } catch (TransferException $e) { throw new AddonException('Addon package download failed: ' . $e->getMessage()); } } // ==================== HTTP客户端 ==================== /** * 获取HTTP客户端 * * @return Client */ protected function getClient(): Client { $apiUrl = config('ywxapp.api_url', ''); if (empty($apiUrl)) { // 中心站(api_url 留空):自调本机下载端点,用当前站点域名补齐 base_uri $apiUrl = rtrim(Request::root(true), '/'); } if (self::$httpClient === null) { $options = [ 'base_uri' => $apiUrl ?: 'http://127.0.0.1', 'timeout' => 30, 'connect_timeout' => 30, 'verify' => (bool) config('appmall.ssl_verify', true), 'http_errors' => false, 'headers' => [ 'X-REQUESTED-WITH' => 'XMLHttpRequest', 'Referer' => dirname(Request::root(true)), 'Member-Agent' => 'YwxappAddon/' . (config('ywxapp.version') ?? '1.0.0'), ], ]; self::$httpClient = new Client($options); } return self::$httpClient; } /** * 重置HTTP客户端 */ public static function resetHttpClient(): void { self::$httpClient = null; } // ==================== 工具方法 ==================== /** * 统一异常处理装饰器 * * @param callable $callback 回调函数 * @param string $operation 操作名称 * @return mixed * @throws Exception|AddonException */ private function withErrorHandling(callable $callback, string $operation) { try { return $callback(); } catch (AddonException $e) { Log::error("Addon {$operation} error (AddonException): " . $e->getMessage()); throw $e; } catch (\PDOException $e) { Log::error("Addon {$operation} error (Database): " . $e->getMessage()); throw new Exception(lang('数据库操作失败')); } catch (\Exception $e) { Log::error("Addon {$operation} error: " . $e->getMessage()); // 开发环境透传原始异常信息(如 Addon already exists / version incorrect)便于定位; // 生产环境统一返回通用提示,避免泄露 SQL / 路径等内部细节 if (Env::get('app_debug')) { throw new Exception('插件操作失败:' . $e->getMessage()); } throw new Exception('插件操作失败,请稍后重试或联系管理员' . $e->getMessage()); } } /** * 清除相关缓存 */ private function clearCaches(): void { // 清除配置缓存 self::clearConfigCache($this->addon); // 清除本插件相关缓存 Cache::delete('addon_config_' . $this->addon); Cache::delete('addon_batch_info_' . md5($this->addon)); // 清除插件加载配置缓存 \ywxapp\service\AppService::clearAddonCache(); } /** * 记录操作日志 * * @param string $operation 操作名称 * @param array $data 附加数据 */ private function logOperation(string $operation, array $data = []): void { $logData = [ 'addon' => $this->addon, 'operation' => $operation, 'time' => date('Y-m-d H:i:s'), ]; $logData = array_merge($logData, $data); Log::info('Addon Operation', $logData); } // ========== install.sql 解析方法 ========== /** * 从 install.sql 中解析表名(不含前缀) * @return array 表名数组 */ private function parseTablesFromInstallSql(): array { $installSqlFile = $this->addonDir . self::INSTALL_SQL_FILE; // 检查文件是否存在 if (!is_file($installSqlFile)) { Log::warning("install.sql 文件不存在: {$installSqlFile}"); return []; } // 读取文件内容 $content = file_get_contents($installSqlFile); if (!$content) { Log::warning("install.sql 文件内容为空"); return []; } // 移除SQL注释 $content = preg_replace('/--.*|\/\*[\s\S]*?\*\//', '', $content); // 匹配 CREATE TABLE 语句中的表名 // 支持格式:CREATE TABLE `table_name` 或 CREATE TABLE table_name // 支持:CREATE TABLE IF NOT EXISTS $pattern1 = '/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"`\']?__PREFIX__' . preg_quote($this->addon, '/') . '_([a-zA-Z0-9_]+)[`"`\']?/i'; $pattern2 = '/CREATE\s+TABLE\s+(IF\s+NOT\s+EXISTS\s+)?[`"\']?(__PREFIX__)?([a-zA-Z0-9_]+)[`"\']?/i'; $pattern3 = '/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"`\']?(?:__PREFIX__|[\w]+_)?' . preg_quote($this->addon, '/') . '_([a-zA-Z0-9_]+)[`"`\']?/i'; preg_match_all($pattern3, $content, $matches); // $matches[3] 包含表名(不含前缀) $tables = array_unique($matches[1] ?? []); // 过滤空值和无效表名 $tables = array_filter($tables, function ($table) { return !empty($table) && $table !== '__PREFIX__' . $this->addon && strlen($table) <= 64; // MySQL表名最大长度 }); $addon = $this->addon; $tables = array_map(function ($item) use ($addon) { return $this->addon . '_' . $item; }, $tables); return array_values($tables); } /** * 获取完整的表名(包含前缀) * @return array 完整表名数组 */ private function getFullTableNames(): array { $tables = $this->parseTablesFromInstallSql(); $defaultDb = Config::get('database.default'); $prefix = Config::get("database.connections.{$defaultDb}.prefix"); return array_map(function ($table) use ($prefix) { return $prefix . $table; }, $tables); } /** * 检查表是否存在 * @param string $tableName 表名 * @return bool */ protected function tableExists(string $tableName): bool { try { $result = Db::query("SHOW TABLES LIKE '{$tableName}'"); return !empty($result); } catch (\Exception $e) { Log::error("检查表是否存在失败: {$tableName}, " . $e->getMessage()); return false; } } // ========== 数据备份方法 ========== /** * 备份插件数据(表结构 + 数据) * @param string $backupDir 备份目录(可选) * @return string 备份文件路径 * @throws Exception */ public function backupData(string $backupDir = ''): string { return $this->withErrorHandling(function () use ($backupDir) { // 1. 解析表名 $tables = $this->getFullTableNames(); if (empty($tables)) { throw new Exception('未找到需要备份的数据表'); } // 2. 准备备份目录 if (empty($backupDir)) { $backupDir = $this->backupDir . $this->addon . DIRECTORY_SEPARATOR; } if (!is_dir($backupDir)) { if (!@mkdir($backupDir, 0755, true)) { throw new Exception('创建备份目录失败: ' . $backupDir); } } // 3. 生成备份文件名 $timestamp = date('YmdHis'); $backupFile = $backupDir . $this->addon . '_backup_' . $timestamp . '.sql'; // 4. 写入备份文件头 $sql = $this->generateBackupHeader($timestamp); // 5. 备份每个表 $prefix = Env::get('database.prefix', ''); foreach ($tables as $table) { $fullTableName = $prefix . $table; // 检查表是否存在 if (!$this->tableExists($fullTableName)) { Log::warning("表不存在,跳过: {$fullTableName}"); continue; } // 备份表结构 $sql .= $this->backupTableStructure($fullTableName); // 备份表数据 $sql .= $this->backupTableData($fullTableName); $sql .= "\n"; } // 6. 写入文件 if (file_put_contents($backupFile, $sql) === false) { throw new Exception('写入备份文件失败'); } // 7. 记录日志 $this->logOperation('backup', [ 'file' => $backupFile, 'size' => filesize($backupFile), 'tables' => $tables ]); Log::info("插件数据备份成功: {$backupFile}"); return $backupFile; }, 'backup_data'); } /** * 生成备份文件头 * @param string $timestamp 备份时间 * @return string SQL头部内容 */ private function generateBackupHeader(string $timestamp): string { $addonInfo = $this->getInfo(); $header = "-- ========================================\n"; $header .= "-- 插件数据备份文件\n"; $header .= "-- ========================================\n"; $header .= "-- 插件名称: {$this->addon}\n"; $header .= "-- 插件版本: " . ($addonInfo['version'] ?? 'unknown') . "\n"; $header .= "-- 备份时间: " . date('Y-m-d H:i:s', strtotime($timestamp)) . "\n"; $header .= "-- 备份工具: AddonService\n"; $header .= "-- MySQL版本: " . Db::query("SELECT VERSION() as version")[0]['version'] . "\n"; $header .= "-- ========================================\n\n"; $header .= "SET FOREIGN_KEY_CHECKS=0;\n"; $header .= "SET SQL_MODE='NO_AUTO_VALUE_ON_ZERO';\n"; $header .= "SET AUTOCOMMIT=0;\n"; $header .= "START TRANSACTION;\n\n"; return $header; } /** * 备份表结构 * @param string $tableName 表名 * @return string SQL语句 */ private function backupTableStructure(string $tableName): string { $sql = "-- ----------------------------------------\n"; $sql .= "-- 表结构: {$tableName}\n"; $sql .= "-- ----------------------------------------\n\n"; // 获取建表语句 $createTable = Db::query("SHOW CREATE TABLE `{$tableName}`"); if (!empty($createTable)) { // 删除表(如果存在) $sql .= "DROP TABLE IF EXISTS `{$tableName}`;\n"; // 创建表 $sql .= $createTable[0]['Create Table'] . ";\n\n"; } return $sql; } /** * 备份表数据 * @param string $tableName 表名 * @return string SQL语句 */ private function backupTableData(string $tableName): string { $sql = "-- ----------------------------------------\n"; $sql .= "-- 表数据: {$tableName}\n"; $sql .= "-- ----------------------------------------\n\n"; // 获取表数据 $data = Db::query("SELECT * FROM `{$tableName}`"); if (empty($data)) { $sql .= "-- 表为空,无数据\n\n"; return $sql; } // 统计行数 $rowCount = count($data); $sql .= "-- 共 {$rowCount} 条记录\n\n"; // 生成INSERT语句 $batchSize = 100; // 每批插入100条 $batches = array_chunk($data, $batchSize); foreach ($batches as $batch) { $insertSql = "INSERT INTO `{$tableName}` ("; // 获取列名 $columns = array_keys($batch[0]); $insertSql .= "`" . implode("`, `", $columns) . "`) VALUES \n"; // 生成值 $values = []; foreach ($batch as $row) { $rowValues = array_map(function ($value) { if ($value === null) { return 'NULL'; } elseif (is_numeric($value)) { return $value; } else { return "'" . addslashes($value) . "'"; } }, array_values($row)); $values[] = "(" . implode(', ', $rowValues) . ")"; } $insertSql .= implode(",\n", $values) . ";\n"; $sql .= $insertSql; } $sql .= "\n"; return $sql; } /** * 获取最近的备份文件 * @return string|null 备份文件路径 */ public function getLatestBackup(): ?string { $backupDir = $this->backupDir . $this->addon . DIRECTORY_SEPARATOR; if (!is_dir($backupDir)) { return null; } $files = glob($backupDir . $this->addon . '_backup_*.sql'); if (empty($files)) { return null; } // 按文件修改时间排序(最新在前) usort($files, function ($a, $b) { return filemtime($b) - filemtime($a); }); return $files[0]; } /** * 恢复备份数据 * @param string $backupFile 备份文件路径(可选,不传则使用最新备份) * @return bool 恢复结果 * @throws Exception */ public function restoreBackup(string $backupFile = ''): bool { return $this->withErrorHandling(function () use ($backupFile) { // 如果没有指定文件,使用最新备份 if (empty($backupFile)) { $backupFile = $this->getLatestBackup(); } if (empty($backupFile) || !is_file($backupFile)) { throw new Exception('备份文件不存在'); } // 读取备份文件 $sqlContent = file_get_contents($backupFile); if (!$sqlContent) { throw new Exception('读取备份文件失败'); } // 分割为单条SQL语句 $statements = array_filter( array_map('trim', explode(';', $sqlContent)), function ($stmt) { return !empty($stmt) && strlen($stmt) > 5; } ); Db::startTrans(); try { foreach ($statements as $statement) { // 跳过注释 $trimmed = ltrim($statement); if ( strpos($trimmed, '--') === 0 || strpos($trimmed, '/*') === 0 || strpos($trimmed, '#') === 0 ) { continue; } // 跳过SET语句 if (stripos($trimmed, 'SET ') === 0) { continue; } // 白名单:仅允许写入型语句(INSERT / REPLACE INTO / CREATE TABLE), // 拒绝 DROP / DELETE / UPDATE / ALTER 等破坏性语句,防止备份文件被污染后造成破坏 if (!preg_match('/^(INSERT\s+(?:IGNORE\s+)?INTO|REPLACE\s+INTO|CREATE\s+TABLE)/i', $trimmed)) { Log::warning("恢复备份时跳过非允许的语句: " . substr($trimmed, 0, 80)); continue; } Db::execute($statement); } Db::commit(); $this->logOperation('restore', [ 'file' => $backupFile, 'tables' => $this->parseTablesFromInstallSql() ]); Log::info("数据恢复成功: {$backupFile}"); return true; } catch (\PDOException $e) { Db::rollback(); Log::error('数据恢复失败: ' . $e->getMessage()); throw $e; } }, 'restore_backup'); } /** * 删除旧备份文件(保留最近N个) * @param int $keepCount 保留数量 * @return int 已删除文件数 */ public function cleanupOldBackups(int $keepCount = 5): int { $backupDir = $this->backupDir . $this->addon . DIRECTORY_SEPARATOR; if (!is_dir($backupDir)) { return 0; } $files = glob($backupDir . $this->addon . '_backup_*.sql'); if (empty($files) || count($files) <= $keepCount) { return 0; } // 按时间排序(最新在前) usort($files, function ($a, $b) { return filemtime($b) - filemtime($a); }); $deleted = 0; // 删除旧文件(保留前 $keepCount 个) for ($i = $keepCount; $i < count($files); $i++) { if (@unlink($files[$i])) { $deleted++; Log::info("已删除旧备份: {$files[$i]}"); } } return $deleted; } /** * 递归删除目录 * * @param string $dirPath 目录路径 * @return bool 删除结果 * @throws Exception */ private function deleteDirectory(string $dirPath): bool { if (!is_dir($dirPath)) { return false; } try { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dirPath, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($iterator as $item) { if ($item->isDir()) { if (!$item->isWritable()) { @chmod($item->getPathname(), 0755); } if (!@rmdir($item->getPathname())) { throw new Exception("无法删除目录: " . $item->getPathname()); } } else { if (!$item->isWritable()) { @chmod($item->getPathname(), 0644); } if (!@unlink($item->getPathname())) { throw new Exception("无法删除文件: " . $item->getPathname()); } } } if (!@rmdir($dirPath)) { throw new Exception("无法删除根目录: " . $dirPath); } return true; } catch (\Exception $e) { throw new Exception("删除目录 {$dirPath} 时出错: " . $e->getMessage()); } } // ========== 卸载核心方法 ========== /** * 卸载插件 * @param array $options 卸载选项 * - backup_data: bool 是否备份数据(默认true) * - delete_data: bool 是否删除数据表(默认true) * - backup_dir: string 自定义备份目录 * @return bool * @throws Exception */ public function uninstall(array $options = []): bool { $defaultOptions = [ 'backup_data' => true, // 默认备份数据 'delete_data' => true, // 默认删除数据 'backup_dir' => '', // 自定义备份目录 ]; $options = array_merge($defaultOptions, $options); return $this->withErrorHandling(function () use ($options) { $addon = $this->addon; // ========== 1. 验证阶段 ========== // 1.1 验证插件名称 if (empty($this->addon) || !$this->validateAddonName($this->addon)) { throw new Exception('插件名称格式不正确'); } // 1.2 检查插件是否存在 if (!is_dir($this->addonDir)) { throw new Exception("插件 {$addon} 不存在"); } // 1.3 检查插件主类文件是否存在 // if (!file_exists($this->addonDir . self::ADDON_CLASS_FILE)) { // throw new Exception("插件 {$addon} 的主类文件不存在"); // } // ========== 2. 数据备份阶段 ========== $backupFile = null; if ($options['backup_data']) { try { $backupFile = $this->backupData($options['backup_dir']); Log::info("数据已备份到: {$backupFile}"); } catch (\Exception $e) { Log::warning("数据备份失败: " . $e->getMessage()); // 备份失败不中断卸载流程 } } // ========== 3. 执行插件卸载逻辑 ========== $this->callAddonHook('uninstall'); $this->deleteMenu($this->addon); // ========== 4. 删除数据表 ========== if ($options['delete_data']) { try { $this->deleteAddonTables(); } catch (\Exception $e) { Log::warning("删除数据表失败: " . $e->getMessage()); } } // ========== 5. 删除插件目录 ========== try { // 5.1 删除插件主目录 if (is_dir($this->addonDir)) { $this->deleteDirectory($this->addonDir); Log::info("已删除插件目录: {$this->addonDir}"); } // 5.2 删除静态资源目录 if (is_dir($this->staticDir)) { $this->deleteDirectory($this->staticDir); Log::info("已删除静态资源目录: {$this->staticDir}"); } } catch (\Exception $e) { throw new Exception("删除插件目录时出错: " . $e->getMessage()); } // ========== 6. 清除相关缓存 ========== $this->clearCaches(); // ========== 7. 清理旧备份(可选) ========== try { $this->cleanupOldBackups(5); } catch (\Exception $e) { Log::warning("清理旧备份失败: " . $e->getMessage()); } // 钩子点:插件卸载完成后触发(目录与数据表已清理) event('addon_uninstall_after', ['name' => $this->addon]); // ========== 8. 记录操作日志 ========== $this->logOperation('uninstall', [ 'addon' => $addon, 'backup_file' => $backupFile, 'delete_data' => $options['delete_data'], 'backup_data' => $options['backup_data'], 'backup_size' => $backupFile ? filesize($backupFile) : 0, 'time' => date('Y-m-d H:i:s') ]); Log::info("插件卸载成功: {$addon}"); return true; }, 'uninstall'); } /** * 删除插件相关的数据表 * @return bool * @throws Exception */ private function deleteAddonTables(): bool { $tables = $this->getFullTableNames(); if (empty($tables)) { Log::warning("未找到需要删除的数据表"); return true; } Db::startTrans(); try { foreach ($tables as $table) { if ($this->tableExists($table)) { Db::execute("DROP TABLE `{$table}`"); Log::info("已删除数据表: {$table}"); } else { Log::warning("数据表不存在,跳过: {$table}"); } } Db::commit(); return true; } catch (\PDOException $e) { Db::rollback(); Log::error('删除数据表失败: ' . $e->getMessage()); if ( strpos($e->getMessage(), 'doesn\'t exist') !== false || strpos($e->getMessage(), 'Unknown table') !== false ) { return true; } throw $e; } } /** * 创建菜单 */ /** * 清理本插件已注入的菜单(开发模式重装时调用,保证幂等) */ private function clearAddonMenu(): void { $defaultDb = Config::get('database.default'); $prefix = Config::get("database.connections.{$defaultDb}.prefix"); $powerTable = $prefix . 'backend_power'; $ruleTable = $prefix . 'member_rule'; try { // 按 addon 列删(新版写入了 addon)+ 按 name 前缀删(兼容历史数据 addon 列为空的情况) Db::execute("DELETE FROM `{$powerTable}` WHERE addon=? OR name LIKE ?", [$this->addon, $this->addon . ':%']); } catch (\Exception $e) { // 表不存在等异常忽略 } try { Db::execute("DELETE FROM `{$ruleTable}` WHERE name LIKE ?", [$this->addon . ':%']); } catch (\Exception $e) { // 忽略 } } public function createMenu(): void { $file = $this->addonDir . 'menu.json'; if (!is_file($file)) { return; } $menu = json_decode(file_get_contents($file), true); if (!is_array($menu)) { return; } // 修正常见拼写键 $map = [ 'backend' => 'backend', 'member' => 'member', 'frontend' => 'frontend', ]; foreach ($map as $key => $type) { if (!empty($menu[$key])) { if ($type === 'backend') { // 后台顶级菜单归属策略(满足「都注入到顶级菜单 + 生成插件名菜单」需求): // 1) menu.json 显式声明了 superior(指定的顶级菜单名,如 'service_center'/'app'): // 先检查该顶级菜单是否存在 —— 存在则把「插件名分组菜单」挂在它下面; // 不存在(即未指定可用顶级菜单)则回退到「以插件名为标题的顶级菜单」。 // 2) menu.json 未声明 superior:直接生成一个以插件名为标题的顶级菜单(pid=0), // 所有菜单都注入到这个插件名顶级菜单下。 $superior = $menu['superior'] ?? ''; if ($superior !== '') { $appPid = $this->getOrCreateAppMenuId($superior); if ($appPid > 0) { // 指定顶级菜单存在:在其下挂「插件名分组」,子项再挂该分组下 $addonPid = $this->getOrCreateAddonRootMenuId($appPid, $menu); } else { // 指定顶级菜单不存在:回退生成「插件名顶级菜单」 $addonPid = $this->getOrCreateAddonRootMenuId(0, $menu); } } else { // 未指定 superior:生成「插件名顶级菜单」(pid=0),所有菜单注入其下 $addonPid = $this->getOrCreateAddonRootMenuId(0, $menu); } $items = $menu[$key]; // 若后台菜单只有一个顶层「目录型」节点(其本身只是个包裹层, // 真实菜单项都在它的 child 里),则把它的子项直接挂到插件分组下, // 避免「插件名分组 + 该目录」两层冗余(如 blog 的「博客应用」+「博客管理」)。 $single = (count($items) === 1) ? reset($items) : null; $singleChild = $single['child'] ?? $single['sublist'] ?? []; if ($single !== null && !empty($singleChild) && is_array($singleChild)) { $this->saveMenus($singleChild, $addonPid, $type); } else { $this->saveMenus($items, $addonPid, $type); } } else { // 会员中心 / 前台菜单挂到根 $this->saveMenus($menu[$key], 0, $type); } } } } /** * 仅重新导入菜单(不改变安装/启用状态,不跑 install 钩子与 SQL)。 * 用于 menu.json 改动后把 admin_power / user_rule 刷新为最新路由, * 使后台/会员中心菜单链接与 route/app.php 实际注册地址对齐。 */ public function refreshMenu(): void { $infoFile = $this->addonDir . self::INFO_FILE; if (is_file($infoFile)) { $this->info = include $infoFile; } $this->clearAddonMenu(); $this->createMenu(); } /** * 获取「应用」(name='app') 顶级目录菜单的 id;不存在则兜底创建。 * @return int */ private function getOrCreateAppMenuId($superior = 'app'): int { $app = \ywxapp\model\BackendPower::where('name', $superior)->find(); if ($app && !empty($app->id)) { return (int) $app->id; } // $model = \ywxapp\model\BackendPower::create([ // 'pid' => 0, // 'title' => '应用', // 'name' => 'app', // 'icon' => 'layui-icon layui-icon-template-1', // 'status' => 1, // 'type' => 1, // 'sort' => 2, // 'route' => '', // ]); // return !empty($model->id) ? (int) $model->id : 0; return 0; } /** * 创建(或复用)插件菜单的根节点,返回其 id: * - $appPid > 0:在指定的顶级菜单($appPid)下,建一个以插件名/top_title 为标题的「分组菜单」; * - $appPid === 0:直接建一个以插件名/top_title 为标题的「顶级菜单」(pid=0)。 * 该菜单的 addon 列为本插件,重装时会被 clearAddonMenu 一并清理,保证幂等。 * @param int $appPid 指定顶级菜单 id;0 表示生成本插件的顶级菜单 * @return int */ private function getOrCreateAddonRootMenuId(int $appPid, array $menu = []): int { $rootName = $this->addon . ':backend'; $root = \ywxapp\model\BackendPower::where('name', $rootName)->find(); if ($root && !empty($root->id)) { return (int) $root->id; } $title = $this->info['title'] ?? $this->addon; // 顶级一级菜单(appPid=0,即 superior 指向不存在的菜单)且 menu.json 指定了 top_title 时, // 用其作为一级菜单标题;否则回退插件 title。用于把市场服务端独立成「服务中心」一级菜单。 if ($appPid === 0 && !empty($menu['top_title'])) { $title = (string) $menu['top_title']; } // 客户机(ywxapp.api_url 指向其他服务器,见 is_market_client())作为中心站客户端,根菜单标题去掉「(服务端)」字样, // 避免误导——客户机不运行服务中心运营功能,只显示「应用商店 / 我的插件」。 // (角色切换后执行 php think addon:manage -a refresh-menu 即可按新环境重建标题。) if (is_market_client()) { $title = preg_replace('/[((]服务端[))]$/u', '', (string) $title); } $model = \ywxapp\model\BackendPower::create([ 'pid' => $appPid, 'title' => $title, 'name' => $rootName, 'icon' => 'layui-icon layui-icon-app', 'status' => 1, 'type' => 1, 'sort' => 1, 'route' => '', 'addon' => $this->addon, ]); return !empty($model->id) ? (int) $model->id : $appPid; } /** * 使用 strpos 定位第二个冒号并替换 * @param string $str 原始字符串 * @return string 处理后的字符串 */ private function replaceSecondColonV2(string $str): string { // 统计冒号数量 $colonCount = substr_count($str, ':'); if ($colonCount >= 3) { // 正则表达式:匹配前两部分,将第二个冒号替换为点号 // 模式解释: // - ^([^:]+) : 第一部分(非冒号字符) // - : : 第一个冒号 // - ([^:]+) : 第二部分(非冒号字符) // - : : 第二个冒号(要替换的) // - (.+)$ : 剩余部分 $pattern = '/^([^:]+):([^:]+):(.+)$/'; $replacement = '$1:$2.$3'; $str = preg_replace($pattern, $replacement, $str); } $str = str_replace(':', '/', $str); return $str; } /** * 递归保存菜单数据 * @param array $data 菜单数据 * @param int $pid 父级ID * @return bool */ private function saveMenus(array $data, int $pid = 0, string $type = 'backend'): void { foreach ($data as $item) { if (!is_array($item)) { continue; } // centerOnly 标记:仅「中心站(服务端)」显示。 // 当本机作为客户机连接中心站(ywxapp.api_url 指向其他服务器,见 is_market_client())时, // 跳过该项及其子树,使客户机后台只呈现应用商店类菜单、隐藏中心运营菜单(审核/开发者/收益等)。 if (!empty($item['centerOnly']) && is_market_client()) { continue; } $name = $item['name'] ?? ''; // 子菜单字段(兼容 'child' 与 'sublist' 两种写法) $childList = $item['child'] ?? $item['sublist'] ?? []; $hasChild = !empty($childList) && is_array($childList); // type 默认按层级推导:有子项的项作为目录(type=1),叶子项作为可点击菜单(type=2)。 // 菜单.json 也可显式声明 type(1:目录 2:菜单 3:按钮 4:API)覆盖默认。 $defaultType = $hasChild ? 1 : 2; $menuData = [ 'pid' => $pid, 'title' => $item['title'] ?? '', 'name' => $name, 'icon' => $item['icon'] ?? '', 'status' => $item['status'] ?? 1, 'type' => $item['type'] ?? $defaultType, 'sort' => $item['sort'] ?? 1, 'route' => $item['route'] ?? '', ]; $model = null; try { if ($type === 'backend') { // 后台菜单 → admin_power(含 addon 归属列) $menuData['name'] = $this->addon . ':backend:' . $name; $menuData['addon'] = $this->addon; if (empty($menuData['route'])) { // 目录型(type=1)节点是纯容器,不应生成不存在的假路由,统一置为 #; // 叶子菜单(type=2)才按权限名推导一个默认路由兜底。 $menuData['route'] = ($menuData['type'] ?? 1) == 1 ? '#' : ($this->replaceSecondColonV2($menuData['name']) ?? ''); } $model = \ywxapp\model\BackendPower::create($menuData); } else { // 会员中心 / 前台菜单 → user_rule,用 module 区分(user_rule 无 addon 列) $menuData['name'] = $this->addon . ':' . $name; $menuData['module'] = $type; // 'member' | 'frontend' if (empty($menuData['route'])) { // 目录型(type=1)节点统一置 #,叶子菜单才按权限名推导演生路由 $menuData['route'] = ($menuData['type'] ?? 1) == 1 ? '#' : ($this->replaceSecondColonV2($menuData['name']) ?? ''); } $model = \ywxapp\model\MemberRule::create($menuData); } } catch (\Exception $e) { Log::warning("创建插件菜单失败({$type}): " . $e->getMessage()); } $childPid = ($model && !empty($model->id)) ? $model->id : $pid; // 兼容两种子菜单字段名:'child'(多数插件)与 'sublist'(appmall/upgrade 等) if ($hasChild) { $this->saveMenus($childList, $childPid, $type); } } } /** * 删除菜单 */ public function deleteMenu(string $addon = '') { $addon = $addon ?: $this->addon; // 必须物理删除:BackendPower/MemberRule 启用 SoftDelete,普通 delete() 仅软删(置 delete_at), // 记录残留导致卸载后菜单仍显示,且不触发 onAfterDelete 残留角色关联。 // destroy($ids, true) 强制物理删除并触发 onAfterDelete;withTrashed 连历史软删残留一并清。 $powerIds = \ywxapp\model\BackendPower::withTrashed()->where('addon', $addon)->column('id'); if ($powerIds) { \ywxapp\model\BackendPower::destroy($powerIds, true); } $powerIds2 = \ywxapp\model\BackendPower::withTrashed()->where('name', 'like', $addon . ':%')->column('id'); if ($powerIds2) { \ywxapp\model\BackendPower::destroy($powerIds2, true); } $ruleIds = \ywxapp\model\MemberRule::withTrashed()->where('name', 'like', $addon . ':%')->column('id'); if ($ruleIds) { \ywxapp\model\MemberRule::destroy($ruleIds, true); } } }