Files

2799 lines
107 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
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/<name> 目录、但未走“压缩包安装”流程的场景
* (例如本地边改边测)。该方法在原地执行标准安装步骤:写入 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(['<?php', '<?', '?>'], '', $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, "<?php return " . $content . ";");
try {
$info = include $tmp;
} finally {
@unlink($tmp);
}
if (!is_array($info)) {
throw new Exception('Info.php must return an array');
}
return $info;
}
/**
* 验证插件包结构和安全性
*
* @param \ZipArchive $zip ZipArchive实例
* @throws Exception
*/
private function verifyaddontructure(\ZipArchive $zip): void
{
$requiredFiles = ['info.php', 'Addon.php'];
$foundFiles = [];
$suspiciousFiles = [];
// 检查必需文件和恶意文件
for ($i = 0; $i < $zip->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/<name>/
'^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 已去掉 <?php 标签的 PHP 代码片段
* @return string
*/
private function stripInfoComments(string $code): string
{
$tokens = token_get_all('<?php ' . $code);
$out = '';
foreach ($tokens as $token) {
if (is_array($token)) {
$type = $token[0];
// 跳过为 tokenizer 拼接的 PHP 开始标签与注释 token,其余原样保留
if ($type === T_OPEN_TAG || $type === T_COMMENT || $type === T_DOC_COMMENT) {
continue;
}
$out .= $token[1];
} else {
$out .= $token;
}
}
return $out;
}
/**
* 校验 info.php 内容仅包含数据字面量(数组/字符串/数字/布尔/null)
* 禁止任何函数调用、变量、表达式执行,防止代码注入。
*/
private function assertDataOnly(string $code): void
{
$tokens = token_get_all('<?php ' . $code . ';');
foreach ($tokens as $token) {
if (is_array($token)) {
$type = $token[0];
// 跳过为 tokenizer 拼接的 PHP 开始/结束标签
if ($type === T_OPEN_TAG || $type === T_CLOSE_TAG) {
continue;
}
$allowed = [
T_RETURN,
T_ARRAY,
T_ARRAY_CAST,
T_CONSTANT_ENCAPSED_STRING,
T_LNUMBER,
T_DNUMBER,
T_BOOL_CAST,
T_WHITESPACE,
T_DOUBLE_ARROW,
T_DIR,
T_FILE,
T_LINE,
T_NS_SEPARATOR,
];
if (in_array($type, $allowed, true)) {
continue;
}
if ($type === T_STRING) {
// 仅允许 true/false/null 关键字作为裸标识符
if (!in_array(strtolower($token[1]), ['true', 'false', 'null'], true)) {
throw new Exception('info.php 包含非法标识符: ' . $token[1]);
}
continue;
}
throw new Exception('info.php 包含非法内容,禁止执行');
}
// 允许的标点:数组/字符串相关
if (!in_array($token, ['(', ')', '[', ']', ',', ';', '=', '.', "'", '"', '?', ':'], true)) {
throw new Exception('info.php 包含非法字符,禁止执行');
}
}
}
/**
* 验证插件名称(防止路径遍历)
*
* @param string $name 插件名称
* @return bool 是否有效
*/
private function validateAddonName(string $name): bool
{
// 只允许字母、数字、下划线
if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
return false;
}
// 防止路径遍历
if (strpos($name, '..') !== false || strpos($name, '/') !== false || strpos($name, '\\') !== false) {
return false;
}
// 限制长度
if (strlen($name) > 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_atdefaultSoftDelete=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/<name>/ 为空。故改用目录复制。
$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」)就已抛异常,这两个属性仍为未初始化状态,直接访问会抛
// \ErrorTyped 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. 增量导入 SQLCREATE 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/<name>-<ver>.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. 增量导入 SQLCREATE 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 = "<?php\n\nreturn " . var_export($data, TRUE) . ";";
$string = str_replace("=> \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) 与 ts5 分钟有效期)
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 <name> -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 也可显式声明 type1:目录 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) 强制物理删除并触发 onAfterDeletewithTrashed 连历史软删残留一并清。
$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);
}
}
}