2799 lines
107 KiB
PHP
2799 lines
107 KiB
PHP
<?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_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/<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」)就已抛异常,这两个属性仍为未初始化状态,直接访问会抛
|
||
// \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/<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. 增量导入 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 = "<?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) 与 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 <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 也可显式声明 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);
|
||
}
|
||
}
|
||
}
|